[
  {
    "path": ".github/workflows/gradle-wrapper-validation.yml",
    "content": "name: \"Validate Gradle Wrapper\"\non: [push, pull_request]\n\njobs:\n  validation:\n    name: \"Validation\"\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v2\n      - uses: gradle/wrapper-validation-action@v1\n"
  },
  {
    "path": ".gitignore",
    "content": "\n# gradle/build files\nbuild\n.gradle\n/framework/dependencies\n\n# build generated files\n/moqui*.war\n/Save*.zip\n\n# runtime directory (separate repository so ignore directory entirely)\n/runtime\n/execwartmp\n/docker/runtime\n/docker/db\n/docker/elasticsearch/data/nodes\n/docker/opensearch/data/nodes/*\n/docker/opensearch/data/*.conf\n!/docker/opensearch/data/nodes/README\n/docker/acme.sh\n/docker/nginx/conf.d\n/docker/nginx/vhost.d\n/docker/nginx/html\n## Do not want to accidentally commit production certificates https://www.theregister.com/2024/07/25/data_from_deleted_github_repos/\n/docker/certs\n!/docker/certs/moqui1.local.*\n!/docker/certs/moqui2.local.*\n!/docker/certs/moqui.local.*\n!/docker/certs/README\n\n# IntelliJ IDEA files\n.idea\nout\n*.ipr\n*.iws\n*.iml\n\n# Eclipse files (and some general ones also used by Eclipse)\n.metadata\nbin\ntmp\n*.tmp\n*.bak\n*.swp\n*~.nib\nlocal.properties\n.settings\n.loadpath\n\n# NetBeans files\nnbproject/private\nnbbuild\n.nb-gradle\nnbdist\nnbactions.xml\nnb-configuration.xml\n\n# VSCode files\n.vscode\n\n# Emacs files\n.projectile\n\n# Version managers (sdkman, mise, asdf)\nmise.toml\n.tool-versions\n\n# OSX auto files\n.DS_Store\n.AppleDouble\n.LSOverride\n._*\n\n# Windows auto files\nThumbs.db\nehthumbs.db\nDesktop.ini\n\n# Linux auto files\n*~\n"
  },
  {
    "path": ".travis.yml",
    "content": "language: groovy\n\njdk:\n  - openjdk11\n\ninstall: true\n\nenv:\n  - TERM=dumb\n\nscript:\n  - ./gradlew getRuntime\n  - ./gradlew load\n  - ./gradlew test --info\n\ncache:\n  directories:\n    - $HOME/.gradle/caches\n    - $HOME/.gradle/wrapper\n"
  },
  {
    "path": ".whitesource",
    "content": "{\n  \"generalSettings\": {\n    \"shouldScanRepo\": true\n  },\n  \"checkRunSettings\": {\n    \"vulnerableCheckRunConclusionLevel\": \"failure\"\n  },\n  \"issueSettings\": {\n    \"minSeverityLevel\": \"LOW\"\n  }\n}\n"
  },
  {
    "path": "AUTHORS",
    "content": "Moqui Framework (http://github.com/moqui/moqui)\n\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n\n===========================================================================\n\nCopyright Waiver\n\nI dedicate any and all copyright interest in this software to the\npublic domain. I make this dedication for the benefit of the public at\nlarge and to the detriment of my heirs and successors. I intend this\ndedication to be an overt act of relinquishment in perpetuity of all\npresent and future rights to this software under copyright law.\n\nTo the best of my knowledge and belief, my contributions are either\noriginally authored by me or are derived from prior works which I have\nverified are also in the public domain and are not subject to claims\nof copyright by other parties.\n\nTo the best of my knowledge and belief, no individual, business,\norganization, government, or other entity has any copyright interest\nin my contributions, and I affirm that I will not make contributions\nthat are otherwise encumbered.\n\nSigned by git commit adding my legal name and git username:\n\nWritten in 2010-2022 by David E. Jones - jonesde\nWritten in 2021-2026 by D. Michael Jones - acetousk\nWritten in 2014-2015 by Solomon Bessire - sbessire\nWritten in 2014-2015 by Jacopo Cappellato - jacopoc\nWritten in 2014-2015 by Abdullah Shaikh - abdullahs\nWritten in 2014-2015 by Yao Chunlin - chunlinyao\nWritten in 2014-2015 by Jimmy Shen  - shendepu\nWritten in 2014-2015 by Dony Zulkarnaen - donniexyz\nWritten in 2015 by Sam Hamilton - samhamilton\nWritten in 2015 by Leonardo Carvalho - CarvalhoLeonardo\nWritten in 2015 by Swapnil M Mane - swapnilmmane\nWritten in 2015 by Anton Akhiar - akhiar\nWritten in 2015-2023 by Jens Hardings - jenshp\nWritten in 2016 by Shifeng Zhang - zhangshifeng\nWritten in 2016 by Scott Gray - lektran\nWritten in 2016 by Mark Haney - mphaney\nWritten in 2016 by Qiushi Yan - yanqiushi\nWritten in 2017 by Oleg Andrieiev - oandreyev\nWritten in 2018 by Zhang Wei - zhangwei1979\nWritten in 2018 by Nirendra Singh - nirendra10695\nWritten in 2018-2023 by Ayman Abi Abdallah - aabiabdallah\nWritten in 2019 by Daniel Taylor - danieltaylor-nz\nWritten in 2020 by Jacob Barnes - Tellan\nWritten in 2020 by Amir Anjomshoaa - amiranjom\nWritten in 2021 by Deepak Dixit - dixitdeepak\nWritten in 2021 by Taher Alkhateeb - pythys\nWritten in 2022 by Zhang Wei - hellozhangwei\nWritten in 2023 by Rohit Pawar - rohitpawar2811\n\n===========================================================================\n\nGrant of Patent License\n\nI hereby grant to recipients of software a perpetual, worldwide,\nnon-exclusive, no-charge, royalty-free, irrevocable (except as stated in\nthis section) patent license to make, have made, use, offer to sell, sell,\nimport, and otherwise transfer the Work, where such license applies only to\nthose patent claims licensable by me that are necessarily infringed by my\nContribution(s) alone or by combination of my Contribution(s) with the\nWork to which such Contribution(s) was submitted. If any entity institutes\npatent litigation against me or any other entity (including a cross-claim\nor counterclaim in a lawsuit) alleging that my Contribution, or the Work to\nwhich I have contributed, constitutes direct or contributory patent\ninfringement, then any patent licenses granted to that entity under this\nAgreement for that Contribution or Work shall terminate as of the date such\nlitigation is filed.\n\nSigned by git commit adding my legal name and git username:\n\nWritten in 2010-2022 by David E. Jones - jonesde\nWritten in 2021-2021 by D. Michael Jones - acetousk\nWritten in 2014-2015 by Solomon Bessire - sbessire\nWritten in 2014-2015 by Jacopo Cappellato - jacopoc\nWritten in 2014-2015 by Yao Chunlin - chunlinyao\nWritten in 2015 by Dony Zulkarnaen - donniexyz\nWritten in 2015 by Swapnil M Mane - swapnilmmane\nWritten in 2015 by Jimmy Shen - shendepu\nWritten in 2015-2016 by Sam Hamilton - samhamilton\nWritten in 2015 by Leonardo Carvalho - CarvalhoLeonardo\nWritten in 2015 by Anton Akhiar - akhiar\nWritten in 2015-2023 by Jens Hardings - jenshp\nWritten in 2016 by Shifeng Zhang - zhangshifeng\nWritten in 2016 by Scott Gray - lektran\nWritten in 2016 by Mark Haney - mphaney\nWritten in 2016 by Qiushi Yan - yanqiushi\nWritten in 2017 by Oleg Andrieiev - oandreyev\nWritten in 2018 by Zhang Wei - zhangwei1979\nWritten in 2018 by Nirendra Singh - nirendra10695\nWritten in 2018-2023 by Ayman Abi Abdallah - aabiabdallah\nWritten in 2019 by Daniel Taylor - danieltaylor-nz\nWritten in 2020 by Jacob Barnes - Tellan\nWritten in 2020 by Amir Anjomshoaa - amiranjom\nWritten in 2021 by Deepak Dixit - dixitdeepak\nWritten in 2021 by Taher Alkhateeb - pythys\nWritten in 2022 by Zhang Wei - hellozhangwei\nWritten in 2023 by Rohit Pawar - rohitpawar2811\n\n===========================================================================\n"
  },
  {
    "path": "LICENSE.md",
    "content": "Because of a lack of patent licensing in CC0 1.0 this software includes a\nseparate Grant of Patent License adapted from Apache License 2.0.\n\n===========================================================================\n\nCreative Commons Legal Code\n\nCC0 1.0 Universal\n\nCREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE\nLEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN\nATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS\nINFORMATION ON AN \"AS-IS\" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES\nREGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS\nPROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM\nTHE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED\nHEREUNDER.\n\nStatement of Purpose\n\nThe laws of most jurisdictions throughout the world automatically confer\nexclusive Copyright and Related Rights (defined below) upon the creator\nand subsequent owner(s) (each and all, an \"owner\") of an original work of\nauthorship and/or a database (each, a \"Work\").\n\nCertain owners wish to permanently relinquish those rights to a Work for\nthe purpose of contributing to a commons of creative, cultural and\nscientific works (\"Commons\") that the public can reliably and without fear\nof later claims of infringement build upon, modify, incorporate in other\nworks, reuse and redistribute as freely as possible in any form whatsoever\nand for any purposes, including without limitation commercial purposes.\nThese owners may contribute to the Commons to promote the ideal of a free\nculture and the further production of creative, cultural and scientific\nworks, or to gain reputation or greater distribution for their Work in\npart through the use and efforts of others.\n\nFor these and/or other purposes and motivations, and without any\nexpectation of additional consideration or compensation, the person\nassociating CC0 with a Work (the \"Affirmer\"), to the extent that he or she\nis an owner of Copyright and Related Rights in the Work, voluntarily\nelects to apply CC0 to the Work and publicly distribute the Work under its\nterms, with knowledge of his or her Copyright and Related Rights in the\nWork and the meaning and intended legal effect of CC0 on those rights.\n\n1. Copyright and Related Rights. A Work made available under CC0 may be\nprotected by copyright and related or neighboring rights (\"Copyright and\nRelated Rights\"). Copyright and Related Rights include, but are not\nlimited to, the following:\n\n  i. the right to reproduce, adapt, distribute, perform, display,\n     communicate, and translate a Work;\n ii. moral rights retained by the original author(s) and/or performer(s);\niii. publicity and privacy rights pertaining to a person's image or\n     likeness depicted in a Work;\n iv. rights protecting against unfair competition in regards to a Work,\n     subject to the limitations in paragraph 4(a), below;\n  v. rights protecting the extraction, dissemination, use and reuse of data\n     in a Work;\n vi. database rights (such as those arising under Directive 96/9/EC of the\n     European Parliament and of the Council of 11 March 1996 on the legal\n     protection of databases, and under any national implementation\n     thereof, including any amended or successor version of such\n     directive); and\nvii. other similar, equivalent or corresponding rights throughout the\n     world based on applicable law or treaty, and any national\n     implementations thereof.\n\n2. Waiver. To the greatest extent permitted by, but not in contravention\nof, applicable law, Affirmer hereby overtly, fully, permanently,\nirrevocably and unconditionally waives, abandons, and surrenders all of\nAffirmer's Copyright and Related Rights and associated claims and causes\nof action, whether now known or unknown (including existing as well as\nfuture claims and causes of action), in the Work (i) in all territories\nworldwide, (ii) for the maximum duration provided by applicable law or\ntreaty (including future time extensions), (iii) in any current or future\nmedium and for any number of copies, and (iv) for any purpose whatsoever,\nincluding without limitation commercial, advertising or promotional\npurposes (the \"Waiver\"). Affirmer makes the Waiver for the benefit of each\nmember of the public at large and to the detriment of Affirmer's heirs and\nsuccessors, fully intending that such Waiver shall not be subject to\nrevocation, rescission, cancellation, termination, or any other legal or\nequitable action to disrupt the quiet enjoyment of the Work by the public\nas contemplated by Affirmer's express Statement of Purpose.\n\n3. Public License Fallback. Should any part of the Waiver for any reason\nbe judged legally invalid or ineffective under applicable law, then the\nWaiver shall be preserved to the maximum extent permitted taking into\naccount Affirmer's express Statement of Purpose. In addition, to the\nextent the Waiver is so judged Affirmer hereby grants to each affected\nperson a royalty-free, non transferable, non sublicensable, non exclusive,\nirrevocable and unconditional license to exercise Affirmer's Copyright and\nRelated Rights in the Work (i) in all territories worldwide, (ii) for the\nmaximum duration provided by applicable law or treaty (including future\ntime extensions), (iii) in any current or future medium and for any number\nof copies, and (iv) for any purpose whatsoever, including without\nlimitation commercial, advertising or promotional purposes (the\n\"License\"). The License shall be deemed effective as of the date CC0 was\napplied by Affirmer to the Work. Should any part of the License for any\nreason be judged legally invalid or ineffective under applicable law, such\npartial invalidity or ineffectiveness shall not invalidate the remainder\nof the License, and in such case Affirmer hereby affirms that he or she\nwill not (i) exercise any of his or her remaining Copyright and Related\nRights in the Work or (ii) assert any associated claims and causes of\naction with respect to the Work, in either case contrary to Affirmer's\nexpress Statement of Purpose.\n\n4. Limitations and Disclaimers.\n\n a. No trademark or patent rights held by Affirmer are waived, abandoned,\n    surrendered, licensed or otherwise affected by this document.\n b. Affirmer offers the Work as-is and makes no representations or\n    warranties of any kind concerning the Work, express, implied,\n    statutory or otherwise, including without limitation warranties of\n    title, merchantability, fitness for a particular purpose, non\n    infringement, or the absence of latent or other defects, accuracy, or\n    the present or absence of errors, whether or not discoverable, all to\n    the greatest extent permissible under applicable law.\n c. Affirmer disclaims responsibility for clearing rights of other persons\n    that may apply to the Work or any use thereof, including without\n    limitation any person's Copyright and Related Rights in the Work.\n    Further, Affirmer disclaims responsibility for obtaining any necessary\n    consents, permissions or other rights required for any use of the\n    Work.\n d. Affirmer understands and acknowledges that Creative Commons is not a\n    party to this document and has no duty or obligation with respect to\n    this CC0 or use of the Work.\n\n\n===========================================================================\n\nGrant of Patent License\n\n\"License\" shall mean the terms and conditions for use, reproduction, and \ndistribution.\n\n\"Licensor\" shall mean the original copyright owner or entity authorized by \nthe original copyright owner that is granting the License.\n\n\"Legal Entity\" shall mean the union of the acting entity and all other \nentities that control, are controlled by, or are under common control with\nthat entity. For the purposes of this definition, \"control\" means (i) the \npower, direct or indirect, to cause the direction or management of such \nentity, whether by contract or otherwise, or (ii) ownership of fifty \npercent (50%) or more of the outstanding shares, or (iii) beneficial \nownership of such entity.\n\n\"You\" (or \"Your\") shall mean an individual or Legal Entity exercising \npermissions granted by this License.\n\n\"Source\" form shall mean the preferred form for making modifications, \nincluding but not limited to software source code, documentation source, \nand configuration files.\n\n\"Object\" form shall mean any form resulting from mechanical transformation\nor translation of a Source form, including but not limited to compiled\nobject code, generated documentation, and conversions to other media types.\n\n\"Work\" shall mean the work of authorship, whether in Source or Object form,\nmade available under the License, as indicated by a copyright notice that\nis included in or attached to the work.\n\n\"Derivative Works\" shall mean any work, whether in Source or Object form, \nthat is based on (or derived from) the Work and for which the editorial \nrevisions, annotations, elaborations, or other modifications represent, as\na whole, an original work of authorship. For the purposes of this License,\nDerivative Works shall not include works that remain separable from, or \nmerely link (or bind by name) to the interfaces of, the Work and \nDerivative Works thereof.\n\n\"Contribution\" shall mean any work of authorship, including the original\nversion of the Work and any modifications or additions to that Work or\nDerivative Works thereof, that is intentionally submitted to Licensor for\ninclusion in the Work by the copyright owner or by an individual or Legal\nEntity authorized to submit on behalf of the copyright owner. For the\npurposes of this definition, \"submitted\" means any form of electronic, \nverbal, or written communication sent to the Licensor or its \nrepresentatives, including but not limited to communication on electronic\nmailing lists, source code control systems, and issue tracking systems that\nare managed by, or on behalf of, the Licensor for the purpose of discussing\nand improving the Work, but excluding communication that is conspicuously\nmarked or otherwise designated in writing by the copyright owner as \"Not a\nContribution.\"\n\n\"Contributor\" shall mean Licensor and any individual or Legal Entity on \nbehalf of whom a Contribution has been received by Licensor and \nsubsequently incorporated within the Work.\n\nEach Contributor hereby grants to You a perpetual, worldwide, \nnon-exclusive, no-charge, royalty-free, irrevocable (except as stated in \nthis section) patent license to make, have made, use, offer to sell, sell, \nimport, and otherwise transfer the Work, where such license applies only to\nthose patent claims licensable by such Contributor that are necessarily \ninfringed by their Contribution(s) alone or by combination of their \nContribution(s) with the Work to which such Contribution(s) was submitted. \nIf You institute patent litigation against any entity (including a \ncross-claim or counterclaim in a lawsuit) alleging that the Work or a \nContribution incorporated within the Work constitutes direct or \ncontributory patent infringement, then any patent licenses granted to You\nunder this License for that Work shall terminate as of the date such \nlitigation is filed.\n"
  },
  {
    "path": "MoquiInit.properties",
    "content": "# No copyright or license for configuration file, details here are not\n# considered a creative work.\n\n# This file is used for the base settings when deploying moqui.war as a\n# webapp in a servlet container or running the WAR file as an executable\n# JAR with java -jar.\n\n# NOTE: configure here before running \"gradle build\", this file is added\n# to the war file.\n\n# You can override these settings with command-line arguments like:\n#    -Dmoqui.runtime=runtime\n#    -Dmoqui.conf=conf/MoquiProductionConf.xml\n\n# The location of the runtime directory for Moqui to use.\n# If empty it will come from the \"moqui.runtime\" system property.\n#\n# The default property below assumes the application server is started in a\n# directory that is a sibling to a \"moqui\" directory that contains a \"runtime\"\n# directory.\nmoqui.runtime=../moqui/runtime\n# NOTE: if there is a \"runtime\" directory in the war file (in the root of the\n# webapp) that will be used instead of this setting to make it easier to\n# include the runtime in a deployed war without knowing where it will be\n# exploded in the file system.\n\n# The Moqui Conf XML file to use for runtime settings.\n# This property is relative to the runtime location.\nmoqui.conf=conf/MoquiProductionConf.xml\n"
  },
  {
    "path": "Procfile",
    "content": "web: java -cp . MoquiStart port=5000 conf=conf/MoquiProductionConf.xml\n"
  },
  {
    "path": "Procfile.README",
    "content": "No memory or other JVM options specified here so that the standard JAVA_TOOL_OPTIONS env var may be used (command line args trump JAVA_TOOL_OPTIONS)\n\nFor example: export JAVA_TOOL_OPTIONS=\"-Xmx1024m -Xms1024m\"\nNote that in Java 21 if no max heap size is specified it will default to 1/4 system memory\n\nThe port specified here is the default for the AWS ElasticBeanstalk Java SE image\nsee: https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/java-se-procfile.html\n\n"
  },
  {
    "path": "README.md",
    "content": "## Welcome to Moqui Framework\n\n[![license](https://img.shields.io/badge/license-CC0%201.0%20Universal-blue.svg)](https://github.com/moqui/moqui-framework/blob/master/LICENSE.md)\n[![build](https://travis-ci.org/moqui/moqui-framework.svg)](https://travis-ci.org/moqui/moqui-framework)\n[![release](https://img.shields.io/github/release/moqui/moqui-framework.svg)](https://github.com/moqui/moqui-framework/releases)\n[![commits since release](http://img.shields.io/github/commits-since/moqui/moqui-framework/v4.0.0.svg)](https://github.com/moqui/moqui-framework/commits/master)\n[![downloads](https://img.shields.io/github/downloads/moqui/moqui-framework/total.svg)](https://github.com/moqui/moqui-framework/releases)\n[![downloads](https://img.shields.io/github/downloads/moqui/moqui-framework/v4.0.0/total.svg)](https://github.com/moqui/moqui-framework/releases/tag/v4.0.0)\n\n[![Discourse Forum](https://img.shields.io/badge/moqui%20forum-discourse-blue.svg)](https://forum.moqui.org)\n[![Google Group](https://img.shields.io/badge/google%20group-moqui-blue.svg)](https://groups.google.com/d/forum/moqui)\n[![LinkedIn Group](https://img.shields.io/badge/linked%20in%20group-moqui-blue.svg)](https://www.linkedin.com/groups/4640689)\n[![Gitter Chat at https://gitter.im/moqui/moqui-framework](https://badges.gitter.im/moqui/moqui-framework.svg)](https://gitter.im/moqui/moqui-framework?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)\n[![Stack Overflow](https://img.shields.io/badge/stack%20overflow-moqui-blue.svg)](http://stackoverflow.com/questions/tagged/moqui)\n\n\nFor information about community infrastructure for code, discussions, support, etc see the Community Guide:\n\n<https://www.moqui.org/docs/moqui/Community+Guide>\n\nFor details about running and deploying Moqui see:\n\n<https://www.moqui.org/docs/framework/Run+and+Deploy>\n\nNote that a runtime directory is required for Moqui Framework to run, but is not included in the source repository. The\nGradle get component, load, and run tasks will automatically add the default runtime (from the moqui-runtime repository).\n\nFor information about the current and near future status of Moqui Framework\nsee the [ReleaseNotes.md](https://github.com/moqui/moqui-framework/blob/master/ReleaseNotes.md) file.\n\nFor an overview of features see:\n\n<https://www.moqui.org/docs/framework/Framework+Features>\n\nGet started with Moqui development quickly using the Tutorial at:\n\n<https://www.moqui.org/docs/framework/Quick+Tutorial>\n\nFor comprehensive documentation of Moqui Framework see the wiki based documentation on moqui.org (*running on Moqui HiveMind*):\n \n<https://www.moqui.org/m/docs/framework>\n"
  },
  {
    "path": "ReleaseNotes.md",
    "content": "# Moqui Framework Release Notes\n\n## Release 4.0.0 - 27 Feb 2026\n\nMoqui framework v4.0.0 is a major new release with massive changes some of which\nare breaking changes. All users are advised to upgrade to benefit from all the\nnew features, security fixes, upgrades, performance improvements and so on.\n\n### Major Changes\n\n#### Java Upgrade to Version 21 (Incompatible Change)\n\nMoqui Framework now requires Java 21. This provides improved performance,\nlong-term support, and access to modern JVM features, while removing legacy\nAPIs. All custom code and components must be validated against Java 21 to ensure\ncompatibility.\n\nAs part of this work:\n\n- Remove deprecated finalize methods no longer applicable in JDK21.\n- Lots of code improvements to comply with JDK21.\n\n#### Groovy upgrade to version 5 (Incompatible Change)\n\nGroovy 5 in combination with newer JDK21 is more strict in @CompileStatic. There\nwere illegal bytecodes being generated, and it has to do with accessing fields\nfrom inner classes.\n\nAnother change is that Groovysh is removed. Therefore, the terminal interface\nwas rewritten from scratch using a different architecture based on\n`groovy.lang.GroovyShell`. This led to both Screen changes (in runtime) and\nbackend changes.\n\n#### Change EntityValue API (Breaking Change)\n\nChange `EntityValue.getEntityName()` to `EntityValue.resolveEntityName()` and\n`EntityValue.getEntityNamePretty()` to `EntityValue.resolveEntityNamePretty()`.\n\nGroovy 4+ introduced a change in the way property to method mapping happens as\n[documented](https://groovy-lang.org/style-guide.html#_getters_and_setters).\n\nThis introduced a bug that occurs when querying an entity that has a field named\n`entityName`. The bug occurs because the query returns an object of type\n`org.moqui.entity.EntityValue`. The problem is that the EntityValue class has a\nmethod called getEntityName() and as per the groovy 4+ behavior this function is\ncalled when trying to access a field named `entityName`. Sample code:\n\n```\ndef someMember = ec.entity\n    .find('moqui.entity.view.DbViewEntityMember')\n    .condition(...)\n    .one()\nsomeMember.entityName // BUG returns .getEntityName(), not .get('entityName')\n```\n\n#### Upgrade to Jetty version 12.1 and EE 11\n\nThis is a major migration. It bumps jetty to version 12.1 and also servlet\nrelated packages (websocket, webapp, proxy) to jakarta EE 11.\n\nThe upgrade broke the existing custom moqui class loaders, and required\nsignificant refactoring of class loaders and webapp structure (e.g.\nWebAppContext, Session Handling, etc ...)\n\nImpact on developers:\n\nAny custom work for jetty should be upgraded to the new versions compatible with\njetty 12.1 and jakarta EE 11\n\n#### Upgrade all javax libraries to jakarta\n\nAll libraries including commons-fileupload, xml.bind-api, activation, mail,\nwebsocket, servlets (6.1), and others are all migrated to their jakarta\nequivalents. As part of this exercise, many deprecated, old or irrelevant / not\nused dependencies were removed. This change required refactoring critical moqui\nfacades and core API to comply with the switch to Jakarta.\n\nAny custom work for older javax should be upgraded where applicable to use the\njakarta equivalent libraries.\n\n#### Integration with the New Bitronix Fork (Incompatible Change)\n\nMoqui Framework now depends on the actively maintained Bitronix fork at:\nhttps://github.com/moqui/bitronix\n\nThe current integrated version is 4.0.0-BETA1, with stabilization ongoing.\n\nThis fork includes:\n\n- Major modernization and cleanup\n- Jakarta namespace migration\n- JMS namespace migration\n- Important bug fixes and stability improvements\n- Legacy Bitronix artifacts are no longer supported.\n- Deployments must remove old Bitronix dependencies.\n\n#### Migration From javax.transaction to jakarta.transaction (BREAKING CHANGE)\n\nMoqui has migrated all transaction-related imports and internal APIs from\njavax.transaction.* to jakarta.transaction.*, following changes in the new\nBitronix fork.\n\nImpact on developers:\n\n- Any code referencing javax.transaction.* must update imports to\n  jakarta.transaction.*.\n- Affects transaction facade usage, user transactions, and service-layer\n  transaction management.\n- If using custom transaction API, then compilation failures should be expected\n  until imports are updated. This does not impact projects that are purely\n  depending on moqui facades without accessing the underlying APIs\n\nThis aligns Moqui with the Jakarta EE namespace changes and the newer Bitronix\ntransaction manager.\n\n#### Upgrade Infrastructure\n\n- Postgres to version 18.1\n- MySQL to version 9.5\n- Remove docker compose \"version\" obsolete key\n- Upgrade opensearch to 3.4.0\n- Upgrade java in docker to eclipse-temurin:21\n- Switch jwilder/nginx-proxy to nginxproxy/nginx-proxy\n\nThese upgrades require careful planning when migrating to moqui V4. It is\nrecommended to delete elastic / open search and reindex, and to switch\nfrom elasticsearch to opensearch. Also ensure an upgrade path for your chosen\ndatabase.\n\nAlso, in newer versions of docker, the \"version\" key is obsolete, so ensure\nupdating installed docker so that it works without the \"version\" setting.\n\n#### Gradle Wrapper Updated to 9.2 (BREAKING CHANGE)\n\nThe framework now builds using Gradle 9.2, bringing:\n\n- Faster builds\n- Stricter validation and deprecation cleanup\n\nChanges included:\n- Refactored property assignments and function calls to satisfy newer Gradle immutability rules.\n- Replaced deprecated exec {} blocks with Groovy execute() usage (Windows support still being refined).\n- Updated and corrected dependency declarations, including replacing deprecated modules and fixing invalid version strings.\n- Numerous misc. updates required by Gradle 9.x API changes.\n- Unified dependencyUpdates settings\n\nThis upgrade required significant modifications to component build scripts.\n\nGiven the upgrade to gradle, Java and bitronix, the following community components were upgraded to comply with new requirements:\n- HiveMind\n- PopCommerce\n- PopRestStore\n- example\n- mantle-braintree\n- mantle-usl\n- moqui-camel\n- moqui-cups\n- moqui-fop\n- moqui-hazelcast\n- moqui-image\n- moqui-orientdb\n- moqui-poi\n- moqui-runtime\n- moqui-sftp\n- moqui-sso\n- moqui-wikitext\n- start\n\n### New Features\n\n- Upgrade groovy to version 5\n- Upgrade to JDK21 by default\n- Upgrade to Apache Shiro 2, no longer using INI factory, but rather INI environment classes\n- Upgrade to jetty 2.1 and jakarta EE 11\n- Upgrade docker infrastructure including opensearch, mysql, postgres to latest\n- Upgrade all dependencies to their latest versions\n- Switch from Thread.getId() to Thread.threadId() to work on both virtual and platform threads\n\n## Release 3.9.9 - 25 Feb 2026\n\nMoqui Framework 3.9.9 is a minor new feature and bug fix release, but mostly a maintenance release for the Moqui Framework \n4.0.0 release series.\n\nFor a complete list see the commit log:\n\nhttps://github.com/moqui/moqui-framework/compare/v3.0.0...v3.9.9\n\n## Release 3.1.0 - Canceled release\n\n## Release 3.0.0 - 31 May 2022\n\nMoqui Framework 3.0.0 is a major new feature and bug fix release with some changes that are not backward compatible.\n\nJava 11 is now the minimum Java version required. For development and deployment make sure Java 11 is installed\n(such as openjdk-11-jdk or adoptopenjdk-11-openj9 on Linux), active (on Linux use 'sudo update-alternatives --config java'),\nand that JAVA_HOME is set to the Java 11 JDK install path (for openjdk-11-jdk on Linux: /usr/lib/jvm/java-11-openjdk-amd64).\n\nIn this release the old moqui-elasticsearch component with embedded ElasticSearch is no longer supported. Instead, the new\nElasticFacade is included in the framework as a client to an external OpenSearch or ElasticSearch instance which can be \ninstalled in runtime/opensearch or runtime/elasticsearch and automatically started/stopped in a separate process by the\nMoquiStart class (executable WAR, not when WAR file dropped into Servlet container).\n\nFor search the recommended versions for this release are OpenSearch 1.3.1 (https://opensearch.org/) or ElasticSearch 7.10.2.\nFor ElasticSearch this is the last version released under the Apache 2.0 license).\n\nNow that JavaScript/CSS minify and certain other issues with tools have been resolved, Gradle 7+ is supported.   \n\nThis is a brief summary of the changes since the last release, for a complete list see the commit log:\n\nhttps://github.com/moqui/moqui-framework/compare/v2.1.3...v3.0.0\n\n### Non Backward Compatible Changes\n\n- Java 11 is now required, updated from Java 8\n- Updated Spock to 2.1 and with that update now using JUnit Platform and JUnit 5 (Jupiter); with this update old JUnit 4\n  test annotations and such are supported, but JUnit 4 TestSuite implementations need to be updated to use the new \n  JUnit Platform and Jupiter annotations\n- Library updates have been done that conflict with ElasticSearch making it impossible to run embedded\n- XMLRPC support had been partly removed years ago, is now completely removed\n- CUPS4J library no longer included in moqui-framework, use the moqui-cups component to add this functionality\n- Network printing services (org.moqui.impl.PrintServices) are now mostly placeholders that return error messages if used, CUPS4J\n  library and services that depend on it are now in the moqui-cups tool component\n- H2 has non-backward compatible changes, including VALUE now being a reserved word; the Moqui Conf XML file now supports\n  per-database entity and field name substitution to handle this and similar future issues; the main issue this cannot\n  solve is with older H2 database files that have columns named VALUE, these may need to be changes to THE_VALUE using\n  an older version of H2 before updating (this is less common as H2 databases are not generally retained long-term) \n\n### New Features\n\n- Recommended Gradle version is 7+ with updates to support the latest versions of Gradle\n- Updated Jetty to version 10 (which requires Java 11 or later)\n- MFA support for login and update password in screens and REST API with factors including authc code by email and SMS, \n  TOTP code (via authenticator app), backup codes; can set a flag on UserGroup to require second factor for all users in\n  the group, and if any user has any additional factor enable then a second factor will be required\n- Various security updates including vulnerabilities in 3rd party libraries (including Log4j, Jackson, Shiro, Jetty), \n  and some in Moqui itself including XSS vulnerabilities in certain error cases and other framework generated \n  messages/responses based on testing with OWASP Zap and two commercial third party reviews (done by larger Moqui users)\n- Optimization for startup-add-missing to get meta data for all tables and columns instead of per entity for much faster startup\n  when enabled; default for runtime-add-missing is now 'false' and startup-add-missing is now 'true' for all DBs including H2\n- View Entity find improvements\n  - correlated sub-select using SQL LATERAL (mysql8, postgres, db2) or APPLY (mssql and oracle; not yet implemented)\n  - extend the member-entity.@sub-select attribute with non-lateral option where not wanted, is used by default as is best for how\n    sub-select is commonly used in view entities\n  - entity find SQL improvements for view entities where a member entity links to another member-entity with a function on a join field\n  - support entity-condition in view-entity used as a sub-select, was being ignored before\n- Improvements to DataDocument generation for a DataFeed to handle very large database tables to feed to ES or elsewhere,\n  including chunking and excluding service parameters from the per ExecutionContext instance service call history  \n- DataFeed and DataDocument support for manual delete of documents and automatic delete on primary entity record delete\n- Scheduled screen render to send regular reports to users by email (simple email with CSV or XSLT attachment) using \n  saved finds on any form-list based screen\n- For entity field encryption default to PBEWithHmacSHA256AndAES_128 instead of PBEWithMD5AndDES, and add configuration\n  options for old field encrypt settings (algo, key, etc) to support changing settings, with a service to re-encrypt all \n  encrypted fields on all records, or can re-encrypt only when data is touched (as long as all old settings are retained,\n  the framework will attempt decrypt with each)\n- Groovy Shell screen added to the Tools app (with special permission), an interactive Groovy Console for testing in \n  various environments and for fixing certain production issues\n\n### Bug Fixes\n\n- H2 embedded shutdown hook removal updated, no more Bitronix errors on shutdown from H2 already having been terminated\n\n## Release 2.1.3 - 07 Dec 2019\n\nMoqui Framework 2.1.3 is a patch level new feature and bug fix release.\n\nThere are only minor changes and fixes in this release. For a complete list of changes see:\n\nhttps://github.com/moqui/moqui-framework/compare/v2.1.2...v2.1.3\n\nThis is the last release where the moqui-elasticsearch component for embedded ElasticSearch will be supported. It is\nbeing replaced by the new ElasticFacade included in this release.\n\n### New Features\n\n- Java 11 now supported with some additional libraries (like javax.activation) included by default; some code changes\n  to address deprecations in the Java 11 API but more needed to resolve all for better future compatibility\n  (in other words expect deprecation warnings when building with Java 11) \n- Built-in ElasticSearch client in the new ElasticFacade that uses pooled HTTP connections with the Moqui RestClient\n  for the ElasticSearch JSON REST API; this is most easily used with Groovy where you can use the inline Map and List\n  syntax to build what becomes the JSON body for search and other requests; after this release it will replace the old\n  moqui-elasticsearch component, now included in the framework because the large ES jar files are no longer required\n- RestClient improvements to support an externally managed RequestFactory to maintain a HttpClient across requests\n  for connection pooling, managing cookies, etc \n- Support for binary render modes for screen with new ScreenWidgetRender interface and screen-facade.screen-output\n  element in the Moqui Conf XML file; this was initially implemented to support an xlsx render mode implemented in\n  the new moqui-poi tool component\n- Screen rendering to XLSX file with one sheet to form-list enabled with the form-list.@show-xlsx-button attribute,\n  the XLS button will only show if the moqui-poi tool component is in place\n- Support for binary rendered screen attachments to emails, and reusable emailScreenAsync transition and EmailScreenSection\n  to easily add a form to screens to send the screen render as an attachment to an outgoing email, rendered in the background\n- WikiServices to upload and delete attachments, and delete wiki pages; improvements to clone wiki page\n\n## Release 2.1.2 - 23 July 2019\n\nMoqui Framework 2.1.2 is a patch level new feature and bug fix release.\n\nThere are only minor changes and fixes in this release. For a complete list of changes see:\n\nhttps://github.com/moqui/moqui-framework/compare/v2.1.1...v2.1.2\n\n### New Features\n\n- Service include for refactoring, etc with new services.service-include element\n- RestClient now supports retry on timeout for call() and 429 (velocity) return for callFuture()\n- The general worker thread pool now checks for an active ExecutionContext after each run to make sure destroyed\n- CORS preflight OPTIONS request and CORS actual request handling in MoquiServlet\n    - headers configured using cors-preflight and cors-actual types in webapp.response-header elements with default headers in MoquiDefaultConf.xml\n    - allowed origins configured with the webapp.@allow-origins attribute which defaults the value of the 'webapp_allow_origins'\n      property or env var for production configuration; default to empty which means only same origin is allowed\n- Docker and instance management monitoring and configuration option improvements, Postgres support for database instances\n- Entity field currency-amount now has 4 decimal digits in the DB and currency-precise has 5 decimal digits for more currency flexibility\n- Added minRetryTime to ServiceJob to avoid immediate and excessive retries\n- New Gradle tasks for managing git tags\n- Support for read only clone datasource configuration and use (if available) in entity finds\n\n### Bug Fixes\n\n- Issue with DataFeed Runnable not destroying the ExecutionContext causing errors to bleed over\n- Fix double content type header in RestClient in certain scenarios\n\n## Release 2.1.1 - 29 Nov 2018\n\nMoqui Framework 2.1.1 is a patch level new feature and bug fix release.\n\nWhile this release has new features maybe significant enough to warrant a 2.2.0 version bump it is mostly refinements and \nimprovements to existing functionality or to address design limitations and generally make things easier and cleaner. \n\nThere are various bug fixes and security improvements in this release. There are no known backward compatibility issues since the \nlast release but there are minor cases where default behavior has changed (see detailed notes). \n\n### New Features\n\n- Various library updates (see framework/build.gradle for details)\n- Updated to Gradle 4 along with changes to gradle files that require Gradle 4.0 or later\n- In gradle addRuntime task create version.json files for framework/runtime and for each component, shown on System app dashboard\n- New gradle gitCheckoutAll task to bulk checkout branches with option to create\n- New default/example Procfile, include in moqui-plus-runtime.war\n\n##### Web Facade and HTTP\n\n- RestClient improvements for background requests with a Future, retry on 429 for velocity limited APIs, multipart requests, etc\n- In user preferences support override by Java system property (or env var if default-property declared in Moqui Conf XML)\n- Add WebFacade.getRequestBodyText() method, use to get body text more easily and now necessary as WebFacade reads the body for all \n  requests with a text content type instead of just application/json or text/json types as before\n- Add email support for notifications with basic default template, enabled only per user for a specific NotificationTopic\n- Add NotificationTopic for web (screen) critical errors\n- Invalidate session before login (with attributes copy to new session) to mitigate session fixation attacks\n- Add more secure defaults for Strict-Transport-Security, Content-Security-Policy, and X-Frame-Options\n\n##### XML Screen and Form\n\n- Support for Vue component based XML Screens using a .js file and a .vuet file that gets merged into the Vue component as the \n  template (template can be inline in the .js file); for an example see the DynamicExampleItems.xml screen in the example component\n- XML Screen and WebFacade response headers now configurable with webapp.response-header element in Moqui Conf XML\n- Add moqui-conf.screen-facade.screen and screen.subscreens-item elements that override screen.subscreens.subscreens-item elements \n  within a screen definition so that application root screens can be added under webroot and apps in a MoquiConf.xml file in a \n  component or in the active Moqui Conf XML file instead of using database records\n- Add support for 'no sub-path' subscreens to extend or override screens, transitions, and resources under the parent screen by \n  looking first in each no sub-path subscreen for a given screen path and if not found then look under the parent screen; for \n  example this is used in the moqui-org component for the moqui.org web-site so that /index.html is found in the moqui-org \n  component and so that /Login resolves to the Login.xml screen in the moqui-org component instead of the default one under webroot\n- Add screen path alias support configured with ScreenPathAlias entity records\n- Now uses URLDecoder for all screen path segments to match use of URLEncoder as default for URL encoding in output\n- In XML Screen transition both service-call and actions are now allowed, service-call runs first\n\n\n- Changed Markdown rendering from Pegdown to flexmark-java to support CommonMark 0.28, some aspects of GitHub Flavored Markdown,\n  and automatic table of contents\n- Add form-single.@pass-through-parameters attribute to create hidden inputs for current request parameters\n- Moved validate-* attributes from XML Form field element to sub-field elements so that in form-list different validation can be\n  done for header, first-/second-/last-row, and default-/conditional-field; as part of this the automatic validate settings from\n  transition.service-call are now set on the sub-field instead of the field element\n\n##### Service Facade\n\n- Add seca.@id and eeca.@id attributes to specify optional IDs that can be used to override or disable SECAs and EECAs\n- SystemMessage improvements for security, HTTP receive endpoint, processing/etc timeouts, etc\n- Service semaphore concurrency improvements, support for semaphore-name which defaults to prior behavior of service name\n\n##### Entity Facade\n\n- Add eeca.set-results attribute to set results of actions in the fields for rules run before entity operation\n- Add entity.relationship.key-value element for constants on join conditions \n- Authorization based entity find filters are now applied after view entities are trimmed so constraints are only added for \n  entities actually used in the query\n- EntityDataLoader now supports a create only mode (used in the improved Data Import screen in the Tools app, usable directly)\n- Add mysql8 database conf for new MySQL 8 JDBC driver\n\n### Bug Fixes\n\n- Serious bug in MoquiAuthFilter where it did not destroy ExecutionContext leaving it in place for the next request using that \n  thread; also changed MoquiServlet to better protect against existing ExecutionContext for thread; also changed WebFacade init\n  from HTTP request to remove current user if it doesn't match user authenticated in session with Shiro, or if no user is \n  authenticated in session\n- MNode merge methods did not properly clear node by name internal cache when adding child nodes causing new children to show up\n  in full child node list but not when getting first or all children by node name if they had been accessed by name before the merge\n- Fix RestClient path and parameter encoding\n- Fix RestClient basic authentication realm issue, now custom builds Authorization request header\n- Fix issue in update#Password service with reset password when UserAccount has a resetPassword but no currentPassword\n- Disable default geo IP lookup for Visit records because the freegeoip service has been discontinued\n- Fix DataFeed trigger false positives for PK fields on related entities included in DataDocument definitions\n- Fix transaction response type screen-last in vuet/vapps mode, history wasn't being maintained server side\n\n## Release 2.1.0 - 22 Oct 2017\n\nMoqui Framework 2.1.0 is a minor new feature and bug fix release.\n\nMost of the effort in the Moqui Ecosystem since the last release has been on the business artifact and application levels. Most of\nthe framework changes have been for improved user interfaces but there have also been various lower level refinements and \nenhancements. \n\nThis release has a few bug fixes from the 2.0.0 release and has new features like DbResource and WikiPage version management, \na simple tool for ETL, DataDocument based dynamic view entities, and various XML Screen and Form widget options and usability \nimprovements. This release was originally planned to be a patch level release primarily for bug fixes but very soon after the 2.0.0 \nrelease work start on the Vue based client rendering (SPA) functionality and various other new features that due to business deals \nprogressed quickly.\n\nThe default moqui-runtime now has support for hybrid static/dynamic XML Screen rendering based on Vue JS. There are various changes \nfor better server side handling but most changes are in moqui-runtime. See the moqui-runtime release notes for more details. \nSome of these changes may be useful for other client rendering purposes, ie for other client side tools and frameworks.\n\n### Non Backward Compatible Changes\n\n- New compile dependency on Log4J2 and not just SLF4J\n- DataDocument JSON generation no longer automatically adds all primary key fields of the primary entity to allow for aggregation\n  by function in DataDocument based queries (where DataDocument is used to create a dynamic view entity); for ElasticSearch indexing\n  a unique ID is required so all primary key fields of the primary entity should be defined\n- The DataDocumentField, DataDocumentCondition, and DataDocumentLink entities now have an artificial/sequenced secondary key instead \n  of using another field (fieldPath, fieldNameAlias, label); existing tables may work with some things but reloading seed data will\n  fail if you have any DataDocument records in place; these are typically seed data records so the easiest way to update/migrate\n  is to drop the tables for DataDocumentField/Link/Condition entities and then reload seed data as normal for a code update\n- If using moqui-elasticsearch the index approach has changed to one index per DataDocument to prep for ES6 and improve the\n  performance and index types by field name; to update an existing instance it is best to start with an empty ES instance or at\n  least delete old indexes and re-index based on data feeds\n- The default Dockerfile now runs the web server on port 80 instead of 8080 within the container\n\n### New Features\n\n- Various library updates\n- SLF4J MDC now used to track moqui_userId and moqui_visitorId for logging\n- New ExecutionContextFactory.registerLogEventSubscriber() method to register for Log4J2 LogEvent processing, initially used in the\n  moqui-elasticsearch component to send log messages to ElasticSearch for use in the new LogViewer screen in the System app\n- Improved Docker Compose samples with HTTPS and PostgreSQL, new file for Kibana behind transparent proxy servlet in Moqui\n- Added MoquiAuthFilter that can be used to require authorization and specified permission for arbitrary paths such as servlets;\n  this is used along with the Jetty ProxyServlet$Transparent to provide secure access to things server only accessible tools like\n  ElasticSearch (on /elastic) and Kibana (on /kibana) in the moqui-elasticsearch component\n- Multi service calls now pass results from previous calls to subsequent calls if parameter names match, and return results\n- Service jobs may now have a lastRunTime parameter passed by the job scheduler; lastRunTime on lock and passed to service is now\n  the last run time without an error\n- view-entity now supports member-entity with entity-condition and no key-map for more flexible join expressions\n- TransactionCache now handles more situations like using EntityListIterator.next() calls and not just getCompleteList(), and \n  deletes through the tx cache are more cleanly handled for records created through the tx cache\n- ResourceReference support for versions in supported implementations (initially DbResourceReference)\n- ResourceFacade locations now support a version suffix following a hash\n- Improved wiki services to track version along with underlying ResourceReference\n- New SimpleEtl class plus support for extract and load through EntityFacade\n- Various improvements in send#EmailTemplate, email view tracking with transparent pixel image\n- Improvements for form-list aggregations and show-total now supports avg, count, min, max, first, and last in addition to sum\n- Improved SQLException handling with more useful messages and error codes from database\n- Added view-entity.member-relationship element as a simpler alternative to member-entity using existing relationships\n- DataDocumentField now has a functionName attribute for functions on fields in a DataDocument based query \n- Any DataDocument can now be treated as an entity using the name pattern DataDocument.${dataDocumentId}\n- Sub-select (sub-query) is now supported for view-entity by a simple flag on member-entity (or member-relationship); this changes\n  the query structure so the member entity is joined in a select clause with any conditions for fields on that member entity put\n  in its where clause instead of the where clause for the top-level select; any fields selected are selected in the sub-select as\n  are any fields used for the join ON conditions; the first example of this is the InvoicePaymentApplicationSummary view-entity in\n  mantle-usl which also uses alias.@function and alias.complex-alias to use concat_ws for combined name aliases\n- Sub-select also now supported for view-entity members of other view entities; this provides much more flexibility for functions\n  and complex-aliases in the sub-select queries; there are also examples of this in mantle-usl\n- Now uses Jackson Databind for JSON serialization and deserialization; date/time values are in millis since epoch\n\n### Bug Fixes\n\n- Improved exception (throwable) handling for service jobs, now handled like other errors and don't break the scheduler\n- Fixed field.@hide attribute not working with runtime conditions, now evaluated each time a form-list is rendered\n- Fixed long standing issue with distinct counts and limited selected fields, now uses a distinct sub-select under a count select\n- Fixed long standing issue where view-entity aliased fields were not decrypted\n- Fixed issue with XML entity data loading using sub-elements for related entities and under those sub-elements for field data\n- Fixed regression in EntityFind where cache was used even if forUpdate was set\n- Fixed concurrency issue with screen history (symptom was NPE on iterator.next() call)\n\n## Release 2.0.0 - 24 Nov 2016\n\nMoqui Framework 2.0.0 is a major new feature and bug fix release, with various non backward compatible API and other changes.\n\nThis is the first release since 1.0.0 with significant and non backwards compatible changes to the framework API. Various deprecated\nmethods have been removed. The Cache Facade now uses the standard javax.cache interfaces and the Service Facade now uses standard \njava.util.concurrent interfaces for async and scheduled services. Ehcache and Quartz Scheduler have been replaced by direct, \nefficient interfaces implementations.\n\nThis release includes significant improvements in configuration and with the new ToolFactory functionality is more modular with\nmore internals exposed through interfaces and extendable through components. Larger and less universally used tool are now in \nseparate components including Apache Camel, Apache FOP, ElasticSearch, JBoss KIE and Drools, and OrientDB.\n\nMulti-server instances are far better supported by using Hazelcast for distributed entity cache invalidation, notifications,\ncaching, background service execution, and for web session replication. The moqui-hazelcast component is pre-configured to enable\nall of this functionality in its MoquiConf.xml file. To use add the component and add a hazelcast.xml file to the classpath with\nsettings for your cluster (auto-discover details, etc).\n\nMoqui now scales up better with performance improvements, concurrency fixes, and Hazelcast support (through interfaces other \ndistributed system libraries like Apache Ignite could also be used). Moqui also now scales down better with improved memory \nefficiency and through more modular tools much smaller runtime footprints are possible.\n\nThe multi-tenant functionality has been removed and replaced with the multi-instance approach. There is now a Dockerfile included\nwith the recommended approach to run Moqui in Docker containers and Docker Compose files for various scenarios including an\nautomatic reverse proxy using nginx-proxy. There are now service interfaces and screens in the System application for managing\nmultiple Moqui instances from a master instance. Instances with their own database can be automatically provisioned using \nconfigurable services, with initial support for Docker containers and MySQL databases. Provisioning services will be added over time\nto support other instance hosts and databases, and you can write your own for whatever infrastructure you prefer to use.\n\nTo support WebSocket a more recent Servlet API the embedded servlet container is now Jetty 9 instead of Winstone. When running \nbehind a proxy such as nginx or httpd running in the embedded mode (executable WAR file) is now adequate for production use.\n\nIf you are upgrading from an earlier version of Moqui Framework please read all notes about Non Backward Compatible Changes. Code,\nconfiguration, and database meta data changes may be necessary depending on which features of the framework you are using.\n\nIn this version Moqui Framework starts and runs faster, uses less memory, is more flexible, configuration is easier, and there are\nnew and better ways to deploy and manage multiple instances. A decent machine ($1800 USD Linux workstation, i7-6800K 6 core CPU) \ngenerated around 350 screens per second with an average response time under 200ms. This was running Moqui and MySQL on the same \nmachine with a JMeter script running on a separate machine doing a 23 step order to ship/bill process that included 2 reports \n(one MySQL based, one ElasticSearch based) and all the GL posting, etc. The load simulated entering and shipping (by internal users) \naround 1000 orders/minute which would support thousands of concurrent internal or ecommerce users. On larger server hardware and \nwith some lower level tuning (this was on stock/default Linux, Java 8, and MySQL 5.7 settings) a single machine could handle \nsignificantly more traffic.  \n\nWith the latest framework code and the new Hazelcast plugin Moqui supports high performance clusters to handle massive loads. The \nmost significant limit is now database performance as we need a transactional SQL database for this sort of business process \n(with locking on inventory reservations and issuances, GL posting, etc as currently implemented in Mantle USL).\n\nEnjoy!\n\n### Non Backward Compatible Changes\n\n- Java JDK 8 now required (Java 7 no longer supported)\n- Now requires Servlet Container supporting the Servlet 3.1 specification\n- No longer using Winstone embedded web server, now using Jetty 9\n- Multi-Tenant Functionality Removed\n  - ExecutionContext.getTenant() and getTenantId() removed\n  - UserFacade.loginUser() third parameter (tenantId) removed\n  - CacheFacade.getCache() with second parameter for tenantId removed\n  - EntityFacade no longer per-tenant, getTenantId() removed\n  - TransactionInternal and EntityDatasourceFactory methods no longer have tenantId parameter\n  - Removed tenantcommon entity group and moqui.tenant entities\n  - Removed tenant related MoquiStart command line options\n  - Removed tenant related Moqui Conf XML, XML Screen, etc attributes\n- Entity Definitions\n  - XSDs updated for these changes, though old attributes still supported\n  - changed entity.@package-name to entity.@package\n  - changed entity.@group-name to entity.@group\n  - changed relationship.@related-entity-name to relationship.@related\n  - changed key-map.@related-field-name to key-map.@related\n  - UserField no longer supported (UserField and UserFieldValue entities)\n- XML Screen and Form\n  - field.@entry-name attribute replaced by field.@from attribute (more meaningful, matches attribute used on set element); the old\n    entry-name attribute is still supported, but removed from XSD\n- Service Job Scheduling\n  - Quartz Scheduler has been removed, use new ServiceJob instead with more relevant options, much cleaner and more manageable\n  - Removed ServiceFacade.getScheduler() method\n  - Removed ServiceCallSchedule interface, implementation, and ServiceFacade.schedule() factory method\n  - Removed ServiceQuartzJob class (impl of Job interface)\n  - Removed EntityJobStore class (impl of JobStore interface); this is a huge and complicated class to handle the various \n    complexities of Quartz and was never fully working, had some remaining issues in testing\n  - Removed HistorySchedulerListener and HistoryTriggerListener classes\n  - Removed all entities in the moqui.service.scheduler and moqui.service.quartz packages\n  - Removed quartz.properties and quartz_data.xml configuration files\n  - Removed Scheduler screens from System app in tools component\n  - For all of these artifacts see moqui-framework commit #d42ede0 and moqui-runtime commit #6a9c61e\n- Externalized Tools\n  - ElasticSearch (and Apache Lucene)\n    - libraries, classes and all related services, screens, etc are now in the moqui-elasticsearch component\n    - System/DataDocument screens now in moqui-elasticsearch component and added to tools/System app through SubscreensItem record\n    - all ElasticSearch services in org.moqui.impl.EntityServices moved to org.moqui.search.SearchServices including:\n      index#DataDocuments, put#DataDocumentMappings, index#DataFeedDocuments, search#DataDocuments, search#CountBySource\n    - Moved index#WikiSpacePages service from org.moqui.impl.WikiServices to org.moqui.search.SearchServices\n    - ElasticSearch dependent REST API methods moved to the 'elasticsearch' REST API in the moqui-elasticsearch component\n  - Apache FOP is now in the moqui-fop tool component; everything in the framework, including the now poorly named MoquiFopServlet, \n    use generic interfaces but XML-FO files will not transform to PDF/etc without this component in place\n  - OrientDB and Entity Facade interface implementations are now in the moqui-orientdb component, see its README.md for usage\n  - Apache Camel along with the CamelServiceRunner and MoquiServiceEndpoint are now in the moqui-camel component which has a \n    MoquiConf.xml file so no additional configuration is needed\n  - JBoss KIE and Drools are now in tool component moqui-kie, an optional component for mantle-usl; has MoquiConf to add ToolFactory\n  - Atomikos TM moved to moqui-atomikos tool component\n- ExecutionContext and ExecutionContextFactory\n  - Removed initComponent(), destroyComponent() methods; were never well supported (runtime component init/destroy caused issues)\n  - Removed getCamelContext() from ExecutionContextFactory and ExecutionContext, use getTool(\"Camel\", CamelContext.class)\n  - Removed getElasticSearchClient() from ExecutionContextFactory and ExecutionContext, use getTool(\"ElasticSearch\", Client.class)\n  - Removed getKieContainer, getKieSession, and getStatelessKieSession methods from ExecutionContextFactory and ExecutionContext, \n    use getTool(\"KIE\", KieToolFactory.class) and use the corresponding methods there\n  - See new feature notes under Tool Factory\n- Caching\n  - Ehcache has been removed\n  - The org.moqui.context.Cache interface is replaced by javax.cache.Cache\n  - Configuration options for caches changed (moqui-conf.cache-list.cache)\n- NotificationMessage\n  - NotificationMessage, NotificationMessageListener interfaces have various changes for more features and to better support \n    serialized messages for notification through a distributed topic\n- Async Services\n  - Now uses more standard java.util.concurrent interfaces\n  - Removed ServiceCallAsync.maxRetry() - was never supported\n  - Removed ServiceCallAsync.persist() - was never supported well, used to simply call through Quartz Scheduler when set\n  - Removed persist option from XML Actions service-call.@async attribute\n  - Async services never called through Quartz Scheduler (only scheduled)\n  - ServiceCallAsync.callWaiter() replaced by callFuture()\n  - Removed ServiceCallAsync.resultReceiver()\n  - Removed ServiceResultReceiver interface - use callFuture() instead\n  - Removed ServiceResultWaiter class - use callFuture() instead\n  - See related new features below\n- Service parameter.subtype element removed, use the much more flexible nested parameter element\n- JCR and Apache Jackrabbit\n  - The repository.@type, @location, and @conf-location attributes have been removed and the repository.parameter sub-element \n    added for use with the javax.jcr.RepositoryFactory interface\n  - See new configuration examples in MoquiDefaultConf.xml under the repository-list element\n- OWASP ESAPI and AntiSamy\n  - ESAPI removed, now using simple StringEscapeUtils from commons-lang\n  - AntiSamy replaced by Jsoup.clean()\n- Removed ServiceSemaphore entity, now using ServiceParameterSemaphore\n- Deprecated methods\n  - These methods were deprecated (by methods with shorter names) long ago and with other API changes now removing them\n  - Removed getLocalizedMessage() and formatValue() from L10nFacade\n  - Removed renderTemplateInCurrentContext(), runScriptInCurrentContext(), evaluateCondition(), evaluateContextField(), and \n    evaluateStringExpand() from ResourceFacade\n  - Removed EntityFacade.makeFind()   \n- ArtifactHit and ArtifactHitBin now use same artifact type enum as ArtifactAuthz, for efficiency and consistency; configuration of\n  artifact-stats by sub-type no longer supported, had little value and caused performance overhead\n- Removed ArtifactAuthzRecord/Cond entities and support for them; this was never all that useful and is replaced by the \n  ArtifactAuthzFilter and EntityFilter entities\n- The ContextStack class has moved to the org.moqui.util package\n- Replaced Apache HttpComponents client with jetty-client to get support for HTTP/2, cleaner API, better async support, etc\n- When updating to this version recommend stopping all instances in a cluster before starting any instance with the new version\n\n### New Features\n\n- Now using Jetty embedded for the executable WAR instead of Winstone\n  - using Jetty 9 which requires Java 8\n  - now internally using Servlet API 3.1.0\n- Many library updates, cleanup of classes found in multiple jar files (ElasticSearch JarHell checks pass; nice in general)\n- Configuration\n  - Added default-property element to set Java System properties from the configuration file\n  - Added Groovy string expansion to various configuration attributes\n    - looks for named fields in Java System properties and environment variables\n    - used in default-property.@value and all xa-properties attributes\n    - replaces the old explicit check for ${moqui.runtime}, which was a simple replacement hack\n    - because these are Groovy expressions the typical dots used in property names cannot be used in these strings, use an \n      underscore instead of a dot, ie ${moqui_runtime} instead of ${moqui.runtime}; if a property name contains underscores and \n      no value is found with the literal name it replaces underscores with dots and looks again\n- Deployment and Docker\n  - The MoquiStart class can now run from an expanded WAR file, i.e. from a directory with the contents of a Moqui executable WAR\n  - On startup DataSource (database) connections are retried 5 times, every 5 seconds, for situations where init of separate \n    containers is triggered at the same time like with Docker Compose\n  - Added a MySQLConf.xml file where settings can come from Java system properties or system environment variables\n  - The various webapp.@http* attributes can now be set as system properties or environment variables\n  - Added a Dockerfile and docker-build.sh script to build a Docker image from moqui-plus-runtime.war or moqui.war and runtime\n  - Added sample Docker Compose files for moqui+mysql, and for moqui, mysql, and nginx-proxy for reverse proxy that supports \n    virtual hosts for multiple Docker containers running Moqui\n  - Added script to run a Docker Compose file after copying configuration and data persistence runtime directories if needed\n- Multi-Instance Management\n  - New services (InstanceServices.xml) and screens in the System app for Moqui instance management\n  - This replaces the removed multi-tenant functionality\n  - Initially supports Docker for the instance hosting environment via Docker REST API\n  - Initially supports MySQL for instance databases (one DB per instance, just like in the past)\n- Tool Factory\n  - Added org.moqui.context.ToolFactory interface used to initialize, destroy, and get instances of tools\n  - Added tools.tool-factory element in Moqui Conf XML file; has default tools in MoquiDefaultConf.xml and can be populated or \n    modified in component and/or runtime conf XML files\n  - Use new ExecutionContextFactory.getToolFactory(), ExecutionContextFactory.getTool(), and ExecutionContext.getTool() methods \n    to interact with tools\n  - See non backward compatible change notes for ExecutionContextFactory\n- WebSocket Support\n  - Now looks for javax.websocket.server.ServerContainer in ServletContext during init, available from ECFI.getServerContainer()\n  - If ServletContainer found adds endpoints defined in the webapp.endpoint element in the Moqui Conf XML file\n  - Added MoquiAbstractEndpoint, extend this when implementing an Endpoint so that Moqui objects such as ExecutionContext/Factory \n    are available, UserFacade initialized from handshake (HTTP upgrade) request, etc\n  - Added NotificationEndpoint which listens for NotificationMessage through ECFI and sends them over WebSocket to notify user\n- NotificationMessage\n  - Notifications can now be configured to send through a topic interface for distributed topics (implemented in the \n    moqui-hazelcast component); this handles the scenario where a notification is generated on one server but a user is connected \n    (by WebSocket, etc) to another\n  - Various additional fields for display in the JavaScript NotificationClient including type, title and link templates, etc\n- Caching\n  - CacheFacade now supports separate local and distributed caches both using the javax.cache interfaces\n  - Added new MCache class for faster local-only caches\n    - implements the javax.cache.Cache interface\n    - supports expire by create, access, update\n    - supports custom expire on get\n    - supports max entries, eviction done in separate thread\n  - Support for distributed caches such as Hazelcast\n  - New interfaces to plugin entity distributed cache invalidation through a SimpleTopic interface, supported in moqui-hazelcast\n  - Set many entities to cache=never, avoid overhead of cache where read/write ratio doesn't justify it or cache could cause issues\n- Async Services\n  - ServiceCallAsync now using standard java.util.concurrent interfaces\n  - Use callFuture() to get a Future object instead of callWaiter()\n  - Can now get Runnable or Callable objects to run a service through a ExecutorService of your choice\n  - Services can now be called local or distributed\n    - Added ServiceCallAsync.distribute() method\n    - Added distribute option to XML Actions service-call.@async attribute\n    - Distributed executor is configurable, supported in moqui-hazelcast\n  - Distributed services allow offloading service execution to worker nodes\n- Service Jobs\n  - Configure ad-hoc (explicitly executed) or scheduled jobs using the new ServiceJob and related entities\n  - Tracks execution in ServiceJobRun records\n  - Can send NotificationMessage, success or error, to configured topic\n  - Run service job through ServiceCallJob interface, ec.service.job()\n  - Replacement for Quartz Scheduler scheduled services\n- Added SubEtha SMTP server which receives email messages and calls EMECA rules, an alternative to polling IMAP and POP3 servers\n- Hazelcast Integration (moqui-hazelcast component)\n  - These features are only enabled with this tool component in place\n  - Added default Hazelcast web session replication config\n  - Hazelcast can be used for distributed entity cache, web session replication, distributed execution, and OrientDB clustering\n  - Implemented distributed entity cache invalidate using a Hazelcast Topic, enabled in Moqui Conf XML file with the\n    @distributed-cache-invalidate attribute on the entity-facade element\n- XSL-FO rendering now supports a generic ToolFactory to create a org.xml.sax.ContentHandler object, with an implementation \n  using Apache FOP now in the moqui-fop component\n- JCR and Apache Jackrabbit\n  - JCR support (for content:// locations in the ResourceFacade) now uses javax.jcr interfaces only, no dependencies on Jackrabbit\n  - JCR repository configuration now supports other JCR implementations by using RepositoryFactory parameters\n- Added ADMIN_PASSWORD permission for administrative password change (in UserServices.update#Password service)\n- Added UserServices.enable#UserAccount service to enable disabled account\n- Added support for error screens rendered depending on type of error\n  - configured in the webapp.error-screen element in Moqui Conf XML file\n  - if error screen render fails sends original error response\n  - this is custom content that avoids sending an error response\n- A component may now have a MoquiConf.xml file that overrides the default configuration file (MoquiDefaultConf.xml from the \n  classpath) but is overridden by the runtime configuration file; the MoquiConf.xml file in each component is merged into the main \n  conf based on the component dependency order (logged on startup)\n- Added ExecutionContext.runAsync method to run a closure in a worker thread with an ExecutionContext like the current (user, etc)\n- Added configuration for worker thread pool parameters, used for local async services, EC.runAsync, etc\n- Transaction Facade\n  - The write-through transaction cache now supports a read only mode\n  - Added service.@no-tx-cache attribute which flushes and disables write through transaction cache for the rest of the transaction\n  - Added flushAndDisableTransactionCache() method to flush/disable the write through cache like service.@no-tx-cache\n- Entity Facade\n  - In view-entity.alias.complex-alias the expression attribute is now expanded so context fields may be inserted or other Groovy \n    expressions evaluated using dollar-sign curly-brace (${}) syntax\n  - Added view-entity.alias.case element with when and else sub-elements that contain complex-alias elements; these can be used for \n    CASE, CASE WHEN, etc SQL expressions\n  - EntityFind.searchFormMap() now has a defaultParameters argument, used when no conditions added from the input fields Map\n  - EntityDataWriter now supports export with a entity master definition name, applied only to entities exported that have a master \n    definition with the given master name\n- XML Screen and Form\n  - screen path URLs that don't exist are now by default disabled instead of throwing an exception\n  - form-list now supports @header-dialog to put header-field widgets in a dialog instead of in the header\n  - form-list now supports @select-columns to allow users to select which fields are displayed in which columns, or not displayed\n  - added search-form-inputs.default-parameters element whose attributes are used as defaultParameters in searchFormMap()\n  - ArtifactAuthzFailure records are only created when a user tries to use an artifact, not when simply checking to see if use is \n    permitted (such as in menus, links, etc)\n  - significant macro cleanups and improvements\n  - csv render macros now improved to support more screen elements, more intelligently handle links (only include anchor/text), etc\n  - text render macros now use fixed width output (number of characters) along with new field attributes to specify print settings\n  - added field.@aggregate attribute for use in form-list with options to aggregate field values across multiple results or\n    display fields in a sub-list under a row with the common fields for the group of rows\n  - added form-single.@owner-form attribute to skip HTML form element and add the HTML form attribute to fields so they are owned\n    by a different form elsewhere in the web page\n- The /status path now a transition instead of a screen and returns JSON with more server status information\n- XML Actions now statically import all the old StupidUtilities methods so 'StupidUtilities.' is no longer needed, shouldn't be used\n- StupidUtilities and StupidJavaUtilities reorganized into the new ObjectUtilities, CollectionUtilities, and StringUtilities\n  classes in the moqui.util package (in the moqui-util project)\n\n### Bug Fixes\n\n- Fixed issues with clean shutdown running with the embedded Servlet container and with gradle test\n- Fixed issue with REST and other requests using various HTTP request methods that were not handled, MoquiServlet now uses the\n  HttpServlet.service() method instead of the various do*() methods\n- Fixed issue with REST and other JSON request body parameters where single entry lists were unwrapped to just the entry\n- Fixed NPE in EntityFind.oneMaster() when the master value isn't found, returns null with no error; fixes moqui-runtime issue #18\n- Fixed ElFinder rm (moqui-runtime GitHub issue #23), response for upload\n- Screen sub-content directories treated as not found so directory entries not listed (GitHub moqui-framework issue #47)\n- In entity cache auto clear for list of view-entity fixed mapping of member entity fields to view entity alias, and partial match \n  when only some view entity fields are on a member entity\n- Cache clear fix for view-entity list cache, fixes adding a permission on the fly\n- Fixed issue with Entity/DataEdit screens in the Tools application where the parameter and form field name 'entityName' conflicted \n  with certain entities that have a field named entityName\n- Concurrency Issues\n  - Fixed concurrent update errors in EntityCache RA (reverse association) using Collections.synchronizedList()\n  - Fixed per-entity DataFeed info rebuild to avoid multiple runs and rebuild before adding to cache in use to avoid partial data\n  - Fixed attribute and child node wrapper caching in FtlNodeWrapper where in certain cases a false null would be returned\n\n## Release 1.6.2 - 26 Mar 2016\n\nMoqui Framework 1.6.2 is a minor new feature and bug fix release.\n\nThis release is all about performance improvements, bug fixes, library\nupdates and cleanups. There are a number of minor new features like better\nmulti-tenant handling (and security), optionally loading data on start if\nthe DB is empty, more flexible handling of runtime Moqui Conf XML location,\ndatabase support and transaction management, and so on.\n\n### Non Backward Compatible Changes\n\n- Entity field types are somewhat more strict for database operations; this\n  is partly for performance reasons and partly to avoid database errors\n  that happen only on certain databases (ie some allow passing a String for\n  a Timestamp, others don't; now you have to use a Timestamp or other date\n  object); use EntityValue.setString or similar methods to do data\n  conversions higher up\n- Removed the TenantCurrency, TenantLocale, TenantTimeZone, and\n  TenantCountry entities; they aren't generally used and better not to have\n  business settings in these restricted technical config entities\n\n### New Features\n\n- Many performance improvements based on profiling; cached entities finds\n  around 6x faster, non cached around 3x; screen rendering also faster\n- Added JDBC Connection stash by tenant, entity group, and transaction,\n  can be disabled with transaction-facade.@use-connection-stash=false in\n  the Moqui Conf XML file\n- Many code cleanups and more CompileStatic with XML handling using new\n  MNode class instead of Groovy Node; UserFacadeImpl and\n  TransactionFacadeImpl much cleaner with internal classes for state\n- Added tools.@empty-db-load attribute with data file types to load on\n  startup (through webapp ContextListener init only) if the database is\n  empty (no records for moqui.basic.Enumeration)\n- If the moqui.conf property (system property, command line, or in\n  MoquiInit.properties) starts with a forward slash ('/') it is now\n  considered an absolute path instead of relative to the runtime directory\n  allowing a conf file outside the runtime directory (an alternative\n  to using ../)\n- UserAccount.userId and various other ID fields changed from id-long to id\n  as userId is only an internal/sequenced ID now, and for various others\n  the 40 char length changed years ago is more than adequate; existing\n  columns can be updated for the shorter length, but don't have to be\n- Changes to run tests without example component in place (now a component\n  separate from moqui-runtime), using the moqui.test and other entities\n- Added run-jackrabbit option to run Apache Jackrabbit locally when Moqui\n  starts and stop is when Moqui stops, with conf/etc in runtime/jackrabbit\n- Added SubscreensDefault entity and supporting code to override default\n  subscreens by tenant and/or condition with database records\n- Now using the VERSION_2_3_23 version for FreeMarker instead of a\n  previous release compatibility version\n- Added methods to L10nFacade that accept a Locale when something other\n  than the current user's locale is needed\n- Added TransactionFacade runUseOrBegin() and runRequireNew() methods to\n  run code (in a Groovy Closure) in a transaction\n- ArtifactHit/Bin persistence now done in a worker thread instead of async\n  service; uses new eci.runInWorkerThread() method, may be added\n  ExecutionContext interface in the future\n- Added XML Form text-line.depends-on element so autocomplete fields can\n  get data on the client from other form fields and clear on change\n- Improved encode/decode handling for URL path segments and parameters\n- Service parameters with allow-html=safe are now accepted even with\n  filtered elements and attributes, non-error messages are generated and\n  the clean HTML from AntiSamy is used\n- Now using PegDown for Markdown processing instead of Markdown4J\n- Multi Tenant\n  - Entity find and CrUD operations for entities in the tenantcommon group\n    are restricted to the DEFAULT instance, protects REST API and so on\n    regardless of admin permissions a tenant admin might assign\n  - Added tenants allowed on SubscreensItem entity and subscreens-item\n    element, makes more sense to filter apps by tenant than in screen\n  - Improvements to tenant provisioning services, new MySQL provisioning,\n    and enable/disable tenant services along with enable check on switch\n  - Added ALL_TENANTS option for scheduled services, set on system\n    maintenance services in quartz_data.xml by default; runs the service\n    for each known tenant (by moqui.tenant.Tenant records)\n- Entity Facade\n  - DB meta data (create tables, etc) and primary sequenced ID queries now\n    use a separate thread to run in a different transaction instead of\n    suspend/resume as some databases have issues with that, especially\n    nested which happens when service and framework code suspends\n- Service Facade\n  - Added separateThread option to sync service call as an alternative to\n    requireNewTransaction which does a suspend/resume, runs service in a\n    separate thread and waits for the service to complete\n  - Added service.@semaphore-parameter attribute which creates a distinct\n    semaphore per value of that parameter\n  - Services called with a ServiceResultWaiter now get messages passed\n    through from the service job in the current MessageFacade (through\n    the MessageFacadeException), better handling for other Throwable\n  - Async service calls now run through lighter weight worker thread pool\n    if persist not set (if persist set still through Quartz Scheduler)\n- Dynamic (SPA) browser features\n  - Added screen element when render screen to support macros at the screen\n    level, such as code for components and services in Angular 2\n  - Added support for render mode extension (like .html, .js, etc) to\n    last screen name in screen path (or URL), uses the specified\n    render-mode and doesn't try to render additional subscreens\n  - Added automatic actions.json transition for all screens, runs actions\n    and returns results as JSON for use in client-side template rendering\n  - Added support for .json extension to transitions, will run the\n    transition and if the response goes to another screen returns path to\n    that screen in a list and parameters for it, along with\n    messages/errors/etc for client side routing between screens\n\n### Bug Fixes\n\n- DB operations for sequenced IDs, service semaphores, and DB meta data are\n  now run in a separate thread instead of tx suspend/resume as some\n  databases have issues with suspend/resume, especially multiple\n  outstanding suspended transactions\n- Fixed issue with conditional default subscreen URL caching\n- Internal login from login/api key and async/scheduled services now checks\n  for disabled accounts, expired passwords, etc just like normal login\n- Fixed issue with entity lists in TransactionCache, were not cloned so\n  new/updated records changed lists that calling code might use\n- Fixed issue with cached entity lists not getting cleared when a record is\n  updated that wasn't in a list already in the cache but that matches its\n  condition\n- Fixed issue with cached view-entity lists not getting cleared on new or\n  updated records; fixes issues with new authz, tarpits and much more not\n  applied immediately\n- Fixed issue with cached view-entity one results not getting cleared when\n  a member entity is updated (was never implemented)\n- Entities in the tenantcommon group no longer available for find and CrUD\n  operations outside the DEFAULT instance (protect tenant data)\n- Fixed issue with find one when using a Map as a condition that may\n  contain non-PK fields and having an artifact authz filter applied, was\n  getting non-PK fields and constraining query when it shouldn't\n  (inconsistent with previous behavior)\n- Fixed ElasticSearch automatic mappings where sub-object mappings always\n  had just the first property\n- Fixed issues with Entity DataFeed where cached DataDocument mappings per\n  entity were not consistent and no feed was done for creates\n- Fixed safe HTML service parameters (allow-html=safe), was issue loading\n  antisamy-esapi.xml though ESAPI so now using AntiSamy directly\n- Fixed issues with DbResource reference move and other operations\n- Fixed issues with ResourceReference operations and wiki page updates\n\n\n## Release 1.6.1 - 24 Jan 2016\n\nMoqui Framework 1.6.1 is a minor new feature and bug fix release.\n\nThis is the first release after the repository reorganization in Moqui \nEcosystem. The runtime directory is now in a separate repository. The \nframework build now gets JAR files from Bintray JCenter instead of having\nthem in the framework/lib directory. Overall the result is a small\nfoundation with additional libraries, components, etc added as needed using\nGradle tasks.\n\n### Build Changes\n\n- Gradle tasks to help handle runtime directory in a separate repository\n  from Moqui Framework\n- Added component management features as Gradle tasks\n  - Components available configured in addons.xml\n  - Repositories components come from configured in addons.xml\n  - Get component from current or release archive (getCurrent, getRelease)\n  - Get component from git repositories (getGit)\n  - When getting a component, automatically gets all components it depends\n    on (must be configured in addons.xml so it knows where to get them)\n  - Do a git pull for moqui, runtime, and all components\n- Most JAR files removed, framework build now uses Bintray JCenter\n- JAR files are downloaded as needed on build\n- For convenience in IDEs to copy JAR files to the framework/dependencies\n  directory use: gradle framework:copyDependencies; note that this is not\n  necessary in IntelliJ IDEA (will import dependencies when creating a new\n  project based on the gradle files, use the refresh button in the Gradle\n  tool window to update after updating moqui)\n- If your component builds source or runs Spock tests changes will be\n  needed, see the runtime/base-component/example/build.gradle file\n\n### New Features\n\n- The makeCondition(Map) methods now support _comp entry for comparison\n  operator, _join entry for join operator, and _list entry for a list of\n  conditions that will be combined with other fields/values in the Map\n- In FieldValueCondition if the value is a collection and operator is\n  EQUALS set to IN, or if NOT_EQUAL then NOT_IN\n\n### Bug Fixes\n\n- Fixed issue with EntityFindBase.condition() where condition break down\n  set ignore case to true\n- Fixed issue with from/thru date where conversion from String was ignored\n- Fixed MySQL date-time type for milliseconds; improved example conf for XA\n- If there are errors in screen actions the error message is displayed\n  instead of rendering the widgets (usually just resulting in more errors)\n\n\n## Long Term To Do List - aka Informal Road Map\n\n- Support local printers, scales, etc in web-based apps using https://qz.io/\n\n- PDF, Office, etc document indexing for wiki attachments (using Apache Tika)\n- Wiki page version history with full content history diff, etc; store just differences, lib for that?\n  - https://code.google.com/archive/p/java-diff-utils/\n    - compile group: 'com.googlecode.java-diff-utils', name: 'diffutils', version: '1.3.0'\n  - https://bitbucket.org/cowwoc/google-diff-match-patch/\n    - compile group: 'org.bitbucket.cowwoc', name: 'diff-match-patch', version: '1.1'\n\n- Option for transition to only mount if all response URLs for screen paths exist\n\n- Saved form-list Finds\n  - Save settings for a user or group to share (i.e. associate with userId or userGroupId). Allow for any group a user is in.\n  - allow different aggregate/show-total/etc options in select-columns, more complex but makes sense?\n  - add form-list presets in xml file, like saved finds but perhaps more options? allow different aggregate settings in presets?\n\n- form-list data prep, more self-contained\n  - X form-list.entity-find element support instead of form-list.@list attribute\n  - _ form-list.service-call\n  - _ also more general form-list.actions element?\n- form-single.entity-find-one element support, maybe form-single.actions too\n\n- Instance Provisioning and Management\n  - embedded and gradle docker client (for docker host or docker swarm)\n    - direct through Docker API\n      - https://docs.docker.com/engine/reference/commandline/dockerd/#bind-docker-to-another-host-port-or-a-unix-socket\n      - https://docs.docker.com/engine/security/https/\n      - https://docs.docker.com/engine/reference/api/docker_remote_api/\n\n- Support incremental (add/subtract) updates in EntityValue.update() or a variation of it; deterministic DB style\n- Support seek for faster pagination like jOOQ: https://blog.jooq.org/2013/10/26/faster-sql-paging-with-jooq-using-the-seek-method/\n\n- Improved Distributed Datasource Support\n  - Put all framework, mantle entities in the 4 new groups: transactional, nontransactional, configuration, analytical\n  - Review warnings about view-entities that have members in multiple groups (which may be in different databases)\n  - Test with transactional in H2 and nontransactional, configuration, analytical in OrientDB\n  - Known changes needed\n    - Check distributed foreign keys in create, update, delete (make sure records exist or don't exist in other databases)\n    - Add augment-member to view-entity that can be in a separate database\n      - Make it easier to define view-entity so that caller can treat it mostly as a normal join-based view\n      - Augment query results with optionally cached values from records in a separate database\n      - For conditions on fields from augment-member do a pre-query to get set of PKs, use them in an IN condition on\n        the main query (only support simple AND scenario, error otherwise); sort of like a sub-select\n      - How to handle order by fields on augment-member? Might require separate query and some sort of fancy sorting...\n    - Some sort of EntityDynamicView handling without joins possible? Maybe augment member methods?\n    - DataDocument support across multiple databases, doing something other than one big dynamic join...\n  - Possibly useful\n    - Consider meta-data management features such as versioning and more complete history for nontransactional and\n      configuration, preferably using some sort of more efficient underlying features in the datasource\n      (like Jackrabbit/Oak; any support for this in OrientDB? ElasticSearch keeps version number for concurrency, but no history)\n    - Write EntityFacade interface for ElasticSearch to use like OrientDB?\n    - Support persistence through EntityFacade as nested documents, ie specify that detail/etc entities be included in parent/master document\n    - SimpleFind interface as an alternative to EntityFind for datasources that don't support joins, etc (like OrientDB)\n      and maybe add support for the internal record ID that can be used for faster graph traversal, etc\n\n- Try Caffeine JCache at https://github.com/ben-manes/caffeine\n  - do in moqui-caffeine tool component\n  - add multiple threads to SpeedTest.xml?\n\n- WebSocket Notifications\n  - Increment message, event, task count labels in header?\n    - DataDocument add flag if new or updated\n    - if new increment count with JS\n    - Side note: DataDocument add info about what was updated somehow?\n- User Notification\n  - Add Moqui Conf XML elements to configure NotificationMessageListener classes\n  - Listener to send Email with XML Screen to layout (and try out using JSON documents as nested Maps from a screen)\n    - where to configure the email and screen to use? use EmailTemplate/emailTemplateId, but where to specify?\n      - for notifications from DataFeeds can add DataFeed.emailTemplateId (or not, what about toAddresses, etc?)\n      - maybe have a more general way to configure details of topics, including emailTemplateId and screenLocation...\n\n- Hazelcast based improvements\n  - configuration for 'microservice' deployments, partitioning services to run on particular servers in a cluster and\n    not others (partition groups or other partition feature?)\n  - can use for reliable WAN service calls like needed for EntitySync?\n    - ie to a remote cluster\n    - different from commercial only WAN replication feature\n    - would be nice for reliable message queue\n  - Quartz Scheduler\n    - can use Hazelcast for scheduled service execution in a cluster, perhaps something on top of, underneath, or instead of Quartz Scheduler?\n    - consider using Hazelcast as a Quartz JobStore, ie: https://github.com/FlavioF/quartz-scheduler-hazelcast-jobstore\n  - DB (or ElasticSearch?) MapStore for persisted (backed up) Hazelcast maps\n    - use MapStore and MapLoader interfaces\n    - see http://docs.hazelcast.org/docs/latest/manual/html-single/index.html#loading-and-storing-persistent-data\n    - https://github.com/mozilla-metrics/bagheera-elasticsearch\n      - older, useful only as a reference for implementing something like this in Moqui\n    - best to implement something using the EntityFacade for easier configuration, etc\n    - see JDBC, etc samples: https://github.com/hazelcast/hazelcast-code-samples/tree/master/distributed-map/mapstore/src/main/java\n  - Persisted Queue for Async Services, etc\n    - use QueueStore interface\n    - see http://docs.hazelcast.org/docs/latest/manual/html-single/index.html#queueing-with-persistent-datastore\n    - use DB?\n\n- XML Screens\n  - Screen section-iterate pagination\n  - Screen form automatic client JS validation for more service in-parameters\n    for: number-range, text-length, text-letters, time-range, credit-card.@types\n  - Dynamic Screens (database-driven: DynamicScreen* entities)\n- Entity Facade\n  - LiquiBase integration (entity.change-set element?)\n  - Add view log, like current change audit log (AuditLogView?)\n  - Improve entity cache auto-clear performance using ehcache search\n    http://ehcache.org/generated/2.9.0/html/ehc-all/#page/Ehcache_Documentation_Set%2Fto-srch_searching_a_cache.html%23\n- Artifact Execution Facade\n  - Call ArtifactExecutionFacade.push() (to track, check authz, etc) for\n    other types of artifacts (if/as determined to be helpful), including:\n    Component, Webapp, Screen Section, Screen Form, Screen Form Field,\n    Template, Script, Entity Field\n  - For record-level authz automatically add constraints to queries if\n    the query follows an adequate pattern and authz requires it, or fail\n    authz if can't add constraint\n- Tools Screens\n  - Auto Screen\n    - Editable data grid, created by form-list, for detail and assoc related entities\n  - Entity\n    - Entity model internal check (relationship, view-link.key-map, ?)\n    - Database meta-data check/report against entity definitions; NOTE: use LiquiBase for this\n  - Script Run (or groovy shell?)\n  - Service\n    - Configure and run chain of services (dynamic wizard)\n  - Artifact Info screens (with in/out references for all)\n    - Screen tree and graph browse screen\n    - Entity usage/reference section\n    - Service usage/reference section on ServiceDetail screen\n  - Screen to install a component (upload and register, load data from it; require special permission for this, not enabled on the demo server)\n\n- Data Document and Feed\n  - API (or service?) push outstanding data changes (registration/connection, time trigger; tie to SystemMessage)\n  - API (or service?) receive/persist data change messages - going reverse of generation for DataDocuments... should be interesting\n  - Consumer System Registry\n    - feed transport (for each: supports confirmation?)\n      - WebSocket (use Notification system, based on notificationName (and userId?))\n  - Service to send email from DataFeed (ie receive#DataFeed implementation), use XML Screen for email content\n    - don't do this directly, do through NotificationMessage, ie the next item... or maybe not, too many parameters for\n      email from too many places related to a DataDocument, may not be flexible enough and may be quite messy\n  - Service (receive#DataFeed impl) to send documents as User NotificationMessages (one message per DataDocument); this\n    is probably the best way to tie a feed to WebSocket notifications for data updates\n    - Use the dataFeedId as the NotificationMessage topic\n    - Use this in HiveMind to send notifications of project, task, and wiki changes (maybe?)\n\n- Integration\n  - OData V4 (http://www.odata.org) compliant entity auto REST API\n    - like current but use OData URL structure, query parameters, etc\n    - mount on /odata4 as alternative to existing /rest\n    - generate EDMX for all entities (and exported services?)\n    - use Apache Olingo (http://olingo.apache.org)\n    - see: https://templth.wordpress.com/2015/04/27/implementing-an-odata-service-with-olingo/\n    - also add an ElasticSearch interface? https://templth.wordpress.com/2015/04/03/handling-odata-queries-with-elasticsearch/\n  - Generate minimal Data Document based on changes (per TX possible, runs async so not really; from existing doc, like current ES doc)\n  - Update database from Data Document\n  - Data Document UI\n    - show/edit field, rel alias, condition, link\n    - special form for add (edit?) field with 5 drop-downs for relationships, one for field, all updated based on\n      master entity and previous selections\n  - Data Document REST interface\n    - get single by dataDocumentId and PK values for primary entity\n    - search through ElasticSearch for those with associated feed/index\n    - json-schema, RAML, Swagger API defs\n    - generic service for sending Data Document to REST (or other?) end point\n  - Service REST API\n    - allow mapping DataDocument operations as well\n    - Add attribute for resource/method like screen for anonymous and no authz access\n  - OAuth2 Support\n    - Simple OAuth2 for authentication only\n      - https://tools.ietf.org/html/draft-ietf-oauth-v2-27#section-4.4\n      - use current api key functionality, or expand for limiting tokens to a particular client by registered client ID\n    - Use Apache Oltu, see https://cwiki.apache.org/confluence/display/OLTU/OAuth+2.0+Authorization+Server\n    - Spec at http://tools.ietf.org/html/rfc6749\n    - http://oltu.apache.org/apidocs/oauth2/reference/org/apache/oltu/oauth2/as/request/package-summary.html\n    - http://search.maven.org/#search|ga|1|org.apache.oltu\n    - https://stormpath.com/blog/build-api-restify-stormpath/\n    - https://github.com/PROCERGS/login-cidadao/blob/master/app/Resources/doc/en/examplejava.md\n    - https://github.com/swagger-api/swagger-ui/issues/807\n    - Add authz and token transitions in rest.xml\n    - Support in Service REST API (and entity/master?)\n    - Add examples of auth and service calls using OAuth2\n    - Add OAuth2 details in Swagger and RAML files\n    - More?\n\n- AS2 Client and Server\n  - use OpenAS2 (http://openas2.sourceforge.net, https://github.com/OpenAS2/OpenAs2App)?\n  - tie into SystemMessage for send/receive (with AS2 service for send, code to receive SystemMessage from AS2 server)\n\n- Email verification by random code on registration and email change\n- Login through Google, Facebook, etc\n  - OpenID, SAML, OAuth, ...\n  - https://developers.facebook.com/docs/facebook-login/login-flow-for-web/v2.0\n\n- Workflow that manages activity flow with screens and services attached to\n  activities, and tasks based on them taking users to defined or automatic\n  screen; see BonitaSoft.com Open Source BPM for similar concept; generally\n  workflow without requiring implementation of an entire app once the\n  workflow itself is defined\n\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\nThe primary supported version for each repository is the latest commit in the master (primary) branch.\n\nMoqui Ecosystem projects are maintained by volunteer contributors, primarily people who use and work with the code as part of their employment or professional services. There are periodic community releases but most distributions and releases involve custom code and are managed, internally or publicly, by third parties. Community releases are checkpoint releases, not maintained release branches, and are best for evaluation rather than production use.\n\nMoqui uses a 'continous release' approach for managing code repositories. Aside from new (work-in-progress) and archived repositories, the master branch in each repository is considered production ready. Rather than running a centrally dictated release schedule and process, the focus is on keeping master branches in a production ready state so that users may use whatever release process and frequency they prefer.\n\nFor most use cases we recommend using code directly from the master branch in each repository. For stabilization and periodic updates (instead of continuous) we recommend using a fork for each git repository with an 'upstream' remote pointing to the Moqui Ecosystem repository for easy upstream updates.\n\n## Reporting a Vulnerability\n\nTo report security issues that should not be disclosed publicly before they are fixed, please use the private **[moqui-board@googlegroups.com](mailto:moqui-board@googlegroups.com)** mailing list. This is setup so that anyone can send messages to it, but only members of the group can read the messages.\n\n## Issues and Pull Requests\n\nFor more information on submitting issues and pull requests please see the [Issue and Pull Request Guide](https://moqui.org/m/docs/moqui/Issue+and+Pull+Request+Guide) on moqui.org.\n"
  },
  {
    "path": "addons.xml",
    "content": "<addons default-repository=\"github\">\n    <!--\n    ========== MODIFYING THIS FILE NOT RECOMMENDED ==========\n\n    Contains known open source Moqui components, those in the GitHub 'moqui' group and others.\n\n    To add or override repository, runtime, and component elements use a \"myaddons.xml\" file.\n    -->\n\n    <repository name=\"github\">\n        <location type=\"current\" url=\"https://github.com/${component.'@group'}/${component.'@name'}/archive/${component.'@branch'}.zip\"/>\n        <location type=\"release\" url=\"https://github.com/${component.'@group'}/${component.'@name'}/archive/v${component.'@version'}.zip\"/>\n        <location type=\"binary\" url=\"https://github.com/${component.'@group'}/${component.'@name'}/releases/download/v${component.'@version'}/${component.'@name'}-${component.'@version'}.zip\"/>\n        <location type=\"git\" url=\"https://github.com/${component.'@group'}/${component.'@name'}.git\"/>\n    </repository>\n    <repository name=\"github-ssh\">\n        <location type=\"current\" url=\"https://github.com/${component.'@group'}/${component.'@name'}/archive/${component.'@branch'}.zip\"/>\n        <location type=\"release\" url=\"https://github.com/${component.'@group'}/${component.'@name'}/archive/v${component.'@version'}.zip\"/>\n        <location type=\"binary\" url=\"https://github.com/${component.'@group'}/${component.'@name'}/releases/download/v${component.'@version'}/${component.'@name'}-${component.'@version'}.zip\"/>\n        <location type=\"git\" url=\"git@github.com:${component.'@group'}/${component.'@name'}.git\"/>\n    </repository>\n\n    <repository name=\"bitbucket\">\n        <location type=\"current\" url=\"https://bitbucket.org/${component.'@group'}/${component.'@name'}/get/${component.'@branch'}.zip\"/>\n        <location type=\"release\" url=\"https://bitbucket.org/${component.'@group'}/${component.'@name'}/get/v${component.'@version'}.zip\"/>\n        <location type=\"git\" url=\"https://bitbucket.org/${component.'@group'}/${component.'@name'}.git\"/>\n    </repository>\n    <repository name=\"bitbucket-ssh\">\n        <location type=\"current\" url=\"https://bitbucket.org/${component.'@group'}/${component.'@name'}/get/${component.'@branch'}.zip\"/>\n        <location type=\"release\" url=\"https://bitbucket.org/${component.'@group'}/${component.'@name'}/get/v${component.'@version'}.zip\"/>\n        <location type=\"git\" url=\"git@bitbucket.org:${component.'@group'}/${component.'@name'}.git\"/>\n    </repository>\n    <repository name=\"bitbucket-token\">\n        <location type=\"git\" url=\"https://x-token-auth:${component.'@token'}@bitbucket.org/${component.'@group'}/${component.'@name'}.git\"/>\n    </repository>\n\n    <!-- Where to get runtime directory if not present -->\n    <runtime name=\"moqui-runtime\" group=\"moqui\" version=\"4.0.0\" branch=\"master\"/>\n\n    <!-- Example Component -->\n    <component name=\"example\" group=\"moqui\" version=\"4.0.0\" branch=\"master\"/>\n    <component name=\"start\" group=\"moqui\" version=\"1.0.2\" branch=\"master\"/>\n\n    <!-- Moqui Tool Components -->\n    <component name=\"moqui-aws\" group=\"moqui\" version=\"1.1.2\" branch=\"master\"/>\n    <component name=\"moqui-camel\" group=\"moqui\" version=\"2.0.0\" branch=\"master\"/>\n    <component name=\"moqui-cups\" group=\"moqui\" version=\"1.0.2\" branch=\"master\"/>\n    <!-- no longer supported: <component name=\"moqui-elasticsearch\" group=\"moqui\" version=\"1.2.2\" branch=\"master\"/> -->\n    <component name=\"moqui-fop\" group=\"moqui\" version=\"2.0.0\" branch=\"master\"/>\n    <component name=\"moqui-hazelcast\" group=\"moqui\" version=\"2.0.0\" branch=\"master\"/>\n    <component name=\"moqui-image\" group=\"moqui\" version=\"1.0.0\" branch=\"master\"/>\n    <component name=\"moqui-kie\" group=\"moqui\" version=\"2.0.0\" branch=\"master\"/>\n    <component name=\"moqui-orientdb\" group=\"moqui\" version=\"2.0.0\" branch=\"master\"/>\n    <component name=\"moqui-poi\" group=\"moqui\" version=\"1.0.3\" branch=\"master\"/>\n    <component name=\"moqui-sftp\" group=\"moqui\" version=\"1.0.3\" branch=\"master\"/>\n    <component name=\"moqui-sso\" group=\"moqui\" version=\"2.0.0\" branch=\"master\"/>\n    <component name=\"moqui-wikitext\" group=\"moqui\" version=\"1.0.5\" branch=\"master\"/>\n\n    <!-- <component name=\"moqui-atomikos\" group=\"moqui\" version=\"1.0.0\" branch=\"master\"/> Deprecated after Moqui 4.0 release and bitronix fork (Atomikos intentionally broke community version to sell commercial; not actively maintained -->\n\n    <!-- Mantle Business Artifact and Integration Components -->\n    <component name=\"mantle-udm\" group=\"moqui\" version=\"2.2.1\" branch=\"master\"/>\n    <component name=\"mantle-usl\" group=\"moqui\" version=\"3.0.0\" branch=\"master\"/>\n\n    <component name=\"AuthorizeDotNet\" group=\"moqui\" version=\"1.2.5\" branch=\"master\"/>\n    <component name=\"mantle-edi\" group=\"moqui\" version=\"1.1.5\" branch=\"master\"/>\n    <component name=\"mantle-paytrace\" group=\"moqui\" version=\"1.0.3\" branch=\"master\"/>\n    <component name=\"mantle-shippo\" group=\"moqui\" version=\"1.2.1\" branch=\"master\"/>\n    <component name=\"mantle-yotpo\" group=\"moqui\" version=\"1.0.2\" branch=\"master\"/>\n\n    <component name=\"mantle-braintree\" group=\"moqui\" version=\"2.0.0\" branch=\"master\"/>\n    <component name=\"mantle-rsis\" group=\"moqui\" version=\"\" branch=\"master\"/><!-- no releases yet -->\n\n    <component name=\"mantle-oagis\" group=\"moqui\" version=\"\" branch=\"master\"/><!-- no releases yet -->\n    <component name=\"mantle-ubpl\" group=\"moqui\" version=\"\" branch=\"master\"/><!-- no releases -->\n\n    <!-- Moqui Applications -->\n    <component name=\"SimpleScreens\" group=\"moqui\" version=\"2.2.2\" branch=\"master\"/>\n    <component name=\"HiveMind\" group=\"moqui\" version=\"1.5.2\" branch=\"master\"/>\n    <component name=\"PopCommerce\" group=\"moqui\" version=\"2.2.2\" branch=\"master\"/>\n    <component name=\"MarbleERP\" group=\"moqui\" version=\"1.0.1\" branch=\"master\"/>\n    <component name=\"PopRestStore\" group=\"moqui\" version=\"1.1.2\" branch=\"master\"/>\n    <component name=\"WeCreate\" group=\"moqui\" version=\"\" branch=\"master\"/>\n    <component name=\"moqui-mjml\" group=\"moqui\" version=\"\" branch=\"master\"/>\n    <!-- Moqui Web and Demo Sites -->\n    <component name=\"moqui-org\" group=\"moqui\" version=\"\" branch=\"master\"/><!-- no releases -->\n    <component name=\"moqui-demo\" group=\"moqui\" version=\"\" branch=\"master\"/><!-- no releases -->\n\n    <!-- Third Party Components -->\n    <component name=\"moqui-captcha\" group=\"shendepu\" version=\"\" branch=\"master\"/><!-- no releases -->\n    <component name=\"moqui-chile\" group=\"Moitcl\" version=\"\" branch=\"master\"/><!-- no releases -->\n    <component name=\"moqui-de_DE-addon\" group=\"mckhoi\" version=\"\" branch=\"master\"/><!-- no releases -->\n    <!-- <component name=\"moqui-graphql\" group=\"shendepu\" version=\"\" branch=\"master\"/> Deprecated, graphql was a bad idea. Don't use it -->\n    <!-- <component name=\"moqui-react-ssr\" group=\"shendepu\" version=\"\" branch=\"master\"/> Deprecated, moqui-react-ssr is an experiment. We suggest you just use standard js tools if you want full control over the UI. no releases -->\n    <component name=\"moqui-zh_CN-addon\" group=\"chunlinyao\" version=\"\" branch=\"master\"/><!-- no releases -->\n    <!-- moqui-fop replacement use alibaba easyexcel for excel output -->\n    <component name=\"moqui-easyexcel\" group=\"chunlinyao\" version=\"\" branch=\"master\"/><!-- no releases -->\n    <component name=\"OFBizToMantle\" group=\"jonesde\" version=\"\" branch=\"master\"/><!-- no releases -->\n    <component name=\"ServiceJobMonitor\" group=\"tailorsoft\" version=\"\" branch=\"master\"/><!-- no releases -->\n    <component name=\"Sales\" group=\"xolvegroup\" version=\"\" branch=\"main\"/><!-- no releases -->\n    <component name=\"WorkManagement\" group=\"xolvegroup\" version=\"\" branch=\"main\"/><!-- no releases -->\n    <component name=\"coarchy\" group=\"coarchy\" version=\"\" branch=\"coarchy\"/><!-- no releases -->\n    <component name=\"stripe\" group=\"coarchy\" version=\"\" branch=\"master\"/><!-- no releases -->\n\n    <!-- Component Sets -->\n    <!-- NOTE: using these component sets is NOT recommended, with so many components doing\n        different things it is better to add and configure only the components you need;\n        some components change system behavior and may cause unexpected and undesired results -->\n    <component-set name=\"framework\" components=\"example,moqui-aws,moqui-cups,moqui-fop,moqui-hazelcast,moqui-kie,moqui-orientdb,moqui-poi,moqui-sftp,moqui-wikitext\"/>\n    <component-set name=\"mantle\" components=\"mantle-udm,mantle-usl,AuthorizeDotNet,mantle-edi,mantle-paytrace,mantle-shippo,mantle-yotpo\"/>\n    <component-set name=\"apps\" components=\"HiveMind,PopCommerce,PopRestStore,MarbleERP\"/>\n    <component-set name=\"ecosystem\" sets=\"framework,mantle,apps\"/>\n\n    <component-set name=\"demo\" components=\"moqui-poi,moqui-demo,example,HiveMind,PopCommerce,PopRestStore,MarbleERP\"/>\n    <component-set name=\"popc\" components=\"PopCommerce,PopRestStore\"/>\n    <component-set name=\"all-moqui\" components=\"example,start,moqui-aws,moqui-camel,moqui-cups,moqui-fop,moqui-hazelcast,moqui-image,moqui-kie,moqui-orientdb,moqui-poi,moqui-sftp,moqui-sso,moqui-wikitext,mantle-udm,mantle-usl,AuthorizeDotNet,mantle-edi,mantle-paytrace,mantle-shippo,mantle-yotpo,mantle-braintree,mantle-rsis,mantle-oagis,mantle-ubpl,SimpleScreens,HiveMind,PopCommerce,MarbleERP,PopRestStore,WeCreate,moqui-mjml,moqui-org,moqui-demo\"/>\n    <component-set name=\"all-working\" components=\"example,start,moqui-aws,moqui-camel,moqui-cups,moqui-fop,moqui-hazelcast,moqui-image,moqui-kie,moqui-orientdb,moqui-poi,moqui-sftp,moqui-sso,moqui-wikitext,mantle-udm,mantle-usl,AuthorizeDotNet,mantle-edi,mantle-paytrace,mantle-shippo,mantle-yotpo,mantle-braintree,mantle-rsis,mantle-oagis,mantle-ubpl,SimpleScreens,HiveMind,PopCommerce,MarbleERP,PopRestStore,WeCreate,moqui-org,moqui-demo,moqui-de_DE-addon,moqui-zh_CN-addon,Sales,WorkManagement,coarchy,stripe\"/>\n\n    <!-- Release builds:\n        gradle getComponentSet -PcomponentSet=demo -PlocationType=release\n        gradle getComponentSet -PcomponentSet=popc -PlocationType=release\n        gradle getComponent -Pcomponent=HiveMind -PlocationType=release\n        - these make the source distro for each\n        - to build the demo war: gradle load test addRuntime\n    -->\n</addons>\n"
  },
  {
    "path": "build.gradle",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\nplugins {\n  id 'com.github.ben-manes.versions' version '0.53.0'\n  id 'org.ajoberstar.grgit' version '5.3.3'\n}\n\n// Filters dependencyUpdates to report only stable (official) releases\n// Use `./gradlew dependencyUpdates` to check which packages are upgradable in all components\ndependencyUpdates.resolutionStrategy {\n    componentSelection { rules ->\n        rules.all { ComponentSelection selection ->\n            boolean rejected = ['alpha', 'beta', 'rc', 'cr', 'm', 'b'].any { qualifier ->\n                selection.candidate.version ==~ /(?i).*[.-]${qualifier}[.\\d-].*/\n            }\n            if (rejected) selection.reject('Release candidate')\n        }\n    }\n}\n\n// Run headless so GradleWorkerMain does not steal focus (mostly a macOS annoyance)\nallprojects {\n    tasks.withType(JavaForkOptions) {\n        jvmArgs '-Djava.awt.headless=true'\n    }\n    repositories {\n        mavenCentral()\n    }\n}\n\nimport groovy.util.Node\nimport groovy.xml.XmlParser\nimport groovy.xml.XmlSlurper\nimport org.ajoberstar.grgit.*\n\ndefaultTasks 'build'\n\ndef openSearchVersion = '3.4.0'\ndef elasticSearchVersion = '7.10.2'\n\ndef tomcatHome = '../apache-tomcat'\n// no longer include version in war file name: def getWarName() { 'moqui-' + childProjects.framework.version + '.war' }\ndef getWarName() { 'moqui.war' }\ndef plusRuntimeName = 'moqui-plus-runtime.war'\ndef execTempDir = 'execwartmp'\ndef moquiRuntime = 'runtime'\n\ndef moquiConfDev = 'conf/MoquiDevConf.xml'\ndef moquiConfProduction = 'conf/MoquiProductionConf.xml'\n\ndef allCleanTasks = getTasksByName('clean', true)\ndef allBuildTasks = getTasksByName('build', true)\ndef allTestTasks = getTasksByName('test', true)\nallTestTasks.each { it.systemProperties << System.properties.subMap(getDefaultPropertyKeys()) }\n// kill the build -> check -> test dependency, only run tests explicitly and not always on build\ngetTasksByName('check', true).each { it.dependsOn.clear() }\n\nSet<Task> getComponentTestTasks() {\n    Set<Task> testTasks = new LinkedHashSet()\n    for (Project subProject in getSubprojects())\n        if (subProject.getPath().startsWith(':runtime:component:')) testTasks.addAll(subProject.getTasksByName('test', false))\n    return testTasks\n}\ndef getDefaultPropertyKeys() {\n    def defaultProperties = []\n    Node confXml = new XmlParser().parse(file('framework/src/main/resources/MoquiDefaultConf.xml'))\n    for (Node defaultProperty in confXml.'default-property') { defaultProperties << defaultProperty.'@name' }\n    defaultProperties\n}\n\n// ========== clean tasks ==========\n\ntask clean(type: Delete) { delete file(warName); delete file(execTempDir); delete file('wartemp'); cleanVersionDetailFiles() }\ntask cleanTempDir(type: Delete) { delete file(execTempDir) }\ntask cleanDb { doLast {\n    if (!file(moquiRuntime).exists()) return\n    delete files(file(moquiRuntime+'/db/derby').listFiles()) - files(moquiRuntime+'/db/derby/derby.properties')\n    delete file(moquiRuntime+'/db/h2')\n    delete file(moquiRuntime+'/db/orientdb/databases')\n    delete fileTree(dir: moquiRuntime+'/txlog', include: '*')\n    cleanElasticSearch(moquiRuntime)\n} }\ntask cleanLog(type: Delete) { delete fileTree(dir: moquiRuntime+'/log', include: '*') }\ntask cleanSessions(type: Delete) { delete fileTree(dir: moquiRuntime+'/sessions', include: '*') }\ntask cleanLoadSave(type: Delete) { delete file('SaveH2.zip'); delete file('SaveDEFAULT.zip')\n    delete file('SaveTransactional.zip'); delete file('SaveAnalytical.zip'); delete file('SaveOrientDb.zip')\n    delete file('SaveElasticSearch.zip'); delete file('SaveOpenSearch.zip') }\ntask cleanPlusRuntime(type: Delete) { delete file(plusRuntimeName) }\ntask cleanOther(type: Delete) { delete fileTree(dir: '.', includes: ['**/.nbattrs', '**/*~', '**/.#*', '**/.DS_Store', '**/*.rej', '**/*.orig']) }\n\ntask cleanAll { dependsOn clean, allCleanTasks, cleanDb, cleanLog, cleanSessions, cleanLoadSave, cleanPlusRuntime }\n\n// ========== ElasticSearch tasks (for install in runtime/elasticsearch) ==========\n\ndef cleanElasticSearch(String moquiRuntime) {\n    File osDir = file(moquiRuntime + '/opensearch')\n    String workDir = moquiRuntime + (osDir.exists() ? '/opensearch' : '/elasticsearch')\n    if (file(workDir+'/bin').exists()) {\n        def pidFile = file(workDir+'/pid')\n        if (pidFile.exists()) {\n            String pid = pidFile.getText()\n            logger.lifecycle(\"${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'} running with pid ${pid}, stopping before deleting data then restarting\")\n            ['kill', pid].execute(null, file(workDir)).waitFor()\n            ['tail', \"--pid=${pid}\", '-f', '/dev/null'].execute(null, file(workDir)).waitFor()\n\n            delete file(workDir+'/data')\n            if (file(workDir+'/logs').exists()) delete files(file(workDir+'/logs').listFiles())\n            if (pidFile.exists()) delete pidFile\n\n            startSearch()\n        } else {\n            logger.lifecycle(\"Found ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'} in ${workDir}/bin directory but no pid, deleting data without stop/start; WARNING if ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'} is running this will cause problems!\")\n            delete file(workDir+'/data')\n            if (file(workDir+'/logs').exists()) delete files(file(workDir+'/logs').listFiles())\n        }\n    } else {\n        delete file(workDir+'/data')\n        if (file(workDir+'/logs').exists()) delete files(file(workDir+'/logs').listFiles())\n    }\n}\ntask downloadOpenSearch { doLast {\n    // NOTE: works with Linux and macOS\n    // TODO: Windows support...\n    String distType = \"tar.gz\"\n    // https://artifacts.opensearch.org/releases/core/opensearch/1.3.1/opensearch-min-1.3.1-linux-x64.tar.gz\n    String esUrl = \"https://artifacts.opensearch.org/releases/core/opensearch/${openSearchVersion}/opensearch-min-${openSearchVersion}-linux-x64.${distType}\"\n    String targetDirPath = moquiRuntime + '/opensearch'\n    String esExtraDirPath = targetDirPath + '/opensearch-' + openSearchVersion\n    File targetDir = file(targetDirPath)\n    if (targetDir.exists()) { logger.lifecycle(\"Found directory at ${targetDirPath}, deleting\"); delete targetDir }\n    File zipFile = file(\"${moquiRuntime}/opensearch-min-${openSearchVersion}-linux-x64.${distType}\")\n    if (!zipFile.exists()) {\n        logger.lifecycle(\"Downloading OpenSearch from ${esUrl}\")\n        ant.get(src: esUrl, dest: zipFile)\n    } else {\n        logger.lifecycle(\"Found OpenSearch archive at ${zipFile.getPath()}, using that instead of downloading\")\n    }\n    // the eachFile closure removes the first path from each file, moving everything up a directory, which also requires delete of the extra dirs\n    copy { from distType == \"zip\" ? zipTree(zipFile) : tarTree(zipFile); into targetDir; eachFile {\n        def pathList = it.getRelativePath().getSegments() as List\n        if (pathList[0] == \".\") pathList = pathList.tail()\n        it.setPath(pathList.tail().join(\"/\"))\n        return it\n    } }\n\n    // make sure there is a logs directory, OpenSearch (just like ES) has start error without it\n    File esLogsDir = file(targetDirPath + '/logs')\n    if (!esLogsDir.exists()) esLogsDir.mkdir()\n\n    File extraDir = file(esExtraDirPath)\n    if (extraDir.exists()) delete extraDir\n\n    delete zipFile\n}}\ntask downloadElasticSearch { doLast {\n    String suffix\n    String distType\n    String osName = System.getProperty(\"os.name\").toLowerCase()\n    if (osName.startsWith(\"windows\")) {\n        suffix = \"windows-x86_64.zip\"\n        distType = \"zip\"\n    } else if (osName.startsWith(\"mac\")) {\n        suffix = \"darwin-x86_64.tar.gz\"\n        distType = \"tar.gz\"\n    } else {\n        suffix = \"linux-x86_64.tar.gz\"\n        distType = \"tar.gz\"\n    }\n    String esUrl = \"https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-oss-${elasticSearchVersion}-no-jdk-${suffix}\"\n    String targetDirPath = moquiRuntime + '/elasticsearch'\n    String esExtraDirPath = targetDirPath + '/elasticsearch-' + elasticSearchVersion\n    File targetDir = file(targetDirPath)\n    if (targetDir.exists()) { logger.lifecycle(\"Found directory at ${targetDirPath}, deleting\"); delete targetDir }\n    File zipFile = file(\"${targetDirPath}-${elasticSearchVersion}.${distType}\")\n    if (!zipFile.exists()) {\n        logger.lifecycle(\"Downloading ElasticSearch from ${esUrl}\")\n        ant.get(src: esUrl, dest: zipFile)\n    } else {\n        logger.lifecycle(\"Found ElasticSearch archive at ${zipFile.getPath()}, using that instead of downloading\")\n    }\n    // the eachFile closure removes the first path from each file, moving everything up a directory, which also requires delete of the extra dirs\n    copy { from distType == \"zip\"? zipTree(zipFile) : tarTree(zipFile); into targetDir; eachFile {\n        def pathList = it.getRelativePath().getSegments() as List\n        if (pathList[0] == \".\") pathList = pathList.tail()\n        it.setPath(pathList.tail().join(\"/\"))\n        return it\n    } }\n\n    // make sure there is a logs directory, ES start error without it\n    File esLogsDir = file(targetDirPath + '/logs')\n    if (!esLogsDir.exists()) esLogsDir.mkdir()\n\n    File extraDir = file(esExtraDirPath)\n    if (extraDir.exists()) delete extraDir\n\n    delete zipFile\n}}\n/* startElasticSearch old approach, with ES 7.10.2 and OpenSearch never exits, gradle just sits there doing nothing (though same command in terminal does exit)\ntask startElasticSearch(type:Exec) {\n    File osDir = file(moquiRuntime + '/opensearch')\n    workingDir moquiRuntime + (osDir.exists() ? '/opensearch' : '/elasticsearch')\n    commandLine (osDir.exists() ? ['./bin/opensearch', '-d', '-p', 'pid'] : ['./bin/elasticsearch', '-d', '-p', 'pid'])\n    ignoreExitValue true\n    onlyIf { (file(moquiRuntime + '/elasticsearch/bin').exists() || file(moquiRuntime + '/opensearch/bin').exists())\n            && !file(moquiRuntime + '/elasticsearch/pid').exists() && !file(moquiRuntime + '/opensearch/pid').exists() }\n    doFirst {\n        logger.lifecycle(\"Starting ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'} installed in runtime/${osDir.exists() ? 'opensearch' : 'elasticsearch'}\")\n    }\n}\n*/\nvoid startSearch(String moquiRuntime) {\n    File osDir = file(moquiRuntime + '/opensearch')\n    String workDir = moquiRuntime + (osDir.exists() ? '/opensearch' : '/elasticsearch')\n    def pidFile = file(workDir + '/pid')\n    def binFile = file(workDir + '/bin')\n    if (binFile.exists() && !pidFile.exists()) {\n        logger.lifecycle(\"Starting ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'} installed in ${workDir}\")\n        ProcessBuilder pb = new ProcessBuilder((osDir.exists() ? './bin/opensearch' : './bin/elasticsearch'), '-d', '-p', 'pid')\n        pb.directory(file(workDir))\n        pb.redirectOutput()\n        pb.redirectError()\n        pb.inheritIO()\n        logger.lifecycle(\"Starting process with command ${pb.command()} in ${pb.directory().path}\")\n        try {\n            Process proc = pb.start()\n            // logger.lifecycle(\"ran start waiting...\")\n            int result = proc.waitFor()\n            logger.lifecycle(\"Process finished with ${result}\")\n        } catch (Exception e) {\n            logger.lifecycle(\"Error starting ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'}\", e)\n        }\n    } else {\n        if (pidFile.exists()) logger.lifecycle(\"Not Starting ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'} installed in ${workDir}, pid file already exists\")\n        if (!binFile.exists()) logger.lifecycle(\"Not Starting ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'}, no ${workDir}/bin directory found\")\n    }\n}\ntask startElasticSearch { doLast {\n    startSearch(moquiRuntime)\n} }\nvoid stopSearch(String moquiRuntime) {\n    File osDir = file(moquiRuntime + '/opensearch')\n    String workDir = moquiRuntime + (osDir.exists() ? '/opensearch' : '/elasticsearch')\n    def pidFile = file(workDir + '/pid')\n    def binFile = file(workDir + '/bin')\n    if (pidFile.exists() && binFile.exists()) {\n        String pid = pidFile.getText()\n        logger.lifecycle(\"Stopping ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'} installed in ${workDir} with pid ${pid}\")\n        [\"kill\", pid].execute(null, file(workDir)).waitFor()\n        if (pidFile.exists()) delete pidFile\n    } else {\n        if (!pidFile.exists()) logger.lifecycle(\"Not Stopping ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'} installed in ${workDir}, no pid file found\")\n        if (!binFile.exists()) logger.lifecycle(\"Not Stopping ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'}, no ${workDir}/bin directory found\")\n    }\n}\ntask stopElasticSearch { doLast {\n    stopSearch(moquiRuntime)\n} }\n\n// ========== JDBC driver download tasks ==========\n\ntask getPostgresJdbc {\n    description = \"Download the latest PostgreSQL JDBC driver to runtime/lib\"\n    dependsOn 'getRuntime'\n    doLast {\n        def libDir = file(moquiRuntime + '/lib')\n        if (!libDir.exists()) libDir.mkdirs()\n\n        // Remove existing Postgres JAR files\n        fileTree(dir: libDir, include: 'postgres*.jar').each { it.delete() }\n\n        // Get the latest version from Maven repository\n        def metadataUrl = 'https://repo1.maven.org/maven2/org/postgresql/postgresql/maven-metadata.xml'\n        def metadataFile = file(\"${buildDir}/postgresql-maven-metadata.xml\")\n        ant.get(src: metadataUrl, dest: metadataFile)\n        def metadata = new XmlSlurper().parse(metadataFile)\n        def latestVersion = metadata.versioning.latest.text()\n        metadataFile.delete()\n\n        // Download the latest version\n        def downloadUrl = \"https://repo1.maven.org/maven2/org/postgresql/postgresql/${latestVersion}/postgresql-${latestVersion}.jar\"\n        def jarFile = file(\"${libDir}/postgresql-${latestVersion}.jar\")\n        logger.lifecycle(\"Downloading PostgreSQL JDBC driver ${latestVersion} from ${downloadUrl}\")\n        ant.get(src: downloadUrl, dest: jarFile)\n        logger.lifecycle(\"Downloaded PostgreSQL JDBC driver to ${jarFile}\")\n    }\n}\n\ntask getMySqlJdbc {\n    description = \"Download the latest MySQL JDBC driver to runtime/lib\"\n    dependsOn 'getRuntime'\n    doLast {\n        def libDir = file(moquiRuntime + '/lib')\n        if (!libDir.exists()) libDir.mkdirs()\n\n        // Remove existing MySQL connector JAR files\n        fileTree(dir: libDir, include: 'mysql-connector*.jar').each { it.delete() }\n\n        // Get the latest version from Maven repository\n        def metadataUrl = 'https://repo1.maven.org/maven2/com/mysql/mysql-connector-j/maven-metadata.xml'\n        def metadataFile = file(\"${buildDir}/mysql-connector-j-maven-metadata.xml\")\n        ant.get(src: metadataUrl, dest: metadataFile)\n        def metadata = new XmlSlurper().parse(metadataFile)\n        def latestVersion = metadata.versioning.latest.text()\n        metadataFile.delete()\n\n        // Download the latest version\n        def downloadUrl = \"https://repo1.maven.org/maven2/com/mysql/mysql-connector-j/${latestVersion}/mysql-connector-j-${latestVersion}.jar\"\n        def jarFile = file(\"${libDir}/mysql-connector-j-${latestVersion}.jar\")\n        logger.lifecycle(\"Downloading MySQL JDBC driver ${latestVersion} from ${downloadUrl}\")\n        ant.get(src: downloadUrl, dest: jarFile)\n        logger.lifecycle(\"Downloaded MySQL JDBC driver to ${jarFile}\")\n    }\n}\n\n// ========== development tasks ==========\ntask setupIntellij {\n    description = \"Adds all XML catalog items to intellij to enable autocomplete\"\n    doLast {\n        def ideaDir = \"${rootDir}/.idea\"\n        def parser = new XmlSlurper()\n        parser.setFeature(\"http://apache.org/xml/features/disallow-doctype-decl\", false)\n        parser.setFeature(\"http://apache.org/xml/features/nonvalidating/load-external-dtd\", false)\n        def catalogEntries = parser.parse(file(\"${rootDir}/framework/xsd/framework-catalog.xml\"))\n                .system\n                .list()\n                .stream()\n                .map { [url: it.@systemId, location: \"\\$PROJECT_DIR\\$/framework/xsd/${it.@uri}\"] }\n                .collect(java.util.stream.Collectors.toList())\n        mkdir ideaDir\n        def rawXml\n        def miscFile = file(\"${ideaDir}/misc.xml\")\n        if (!miscFile.exists()) {\n            def builder = new groovy.xml.StreamingMarkupBuilder()\n            builder.encoding = 'UTF-8'\n            rawXml = builder.bind {\n                project(version: '4') {\n                    component(name: 'ExternalStorageConfigurationManager', enabled: true)\n                    component(name: 'ProjectResources') {\n                        catalogEntries.each { resource(url: it.url, location: it.location) }\n                    }\n                }\n            }\n        } else {\n            def projectNode = parser.parse(miscFile)\n            def resourcesNode = projectNode.children().find { it.@name == 'ProjectResources' }\n            if (resourcesNode.size() == 0) {\n                projectNode.appendNode {\n                    component(name: 'ProjectResources') {\n                        catalogEntries.each { resource(url: it.url, location: it.location) }\n                    }\n                }\n            } else {\n                catalogEntries.each { cat ->\n                    def existingEntry = resourcesNode.children().find { it.@url == cat.url }\n                    if (existingEntry.size() > 0) {\n                        existingEntry.replaceNode { resource(url: cat.url, location: cat.location) }\n                    } else {\n                        resourcesNode.appendNode { resource(url: cat.url, location: cat.location) }\n                    }\n                }\n            }\n            rawXml = projectNode\n        }\n        def misc = groovy.xml.XmlUtil.serialize(rawXml)\n        miscFile.write(misc)\n    }\n}\n\ntask setupVscode {\n    description = \"Configures VS Code settings with runtime directory exclusions\"\n    doLast {\n        def settingsFile = file(\"${rootDir}/.vscode/settings.json\")\n        mkdir settingsFile.parentFile\n        \n        def settings = (settingsFile.exists() && settingsFile.length() > 0) ? \n            new groovy.json.JsonSlurper().parseText(settingsFile.text) : [:]\n        \n        // Disable gitignore for search (so we can manually exclude build/runtime dirs)\n        settings['search.useIgnoreFiles'] = false\n        \n        // Add search.exclude if missing\n        if (!settings['search.exclude']) settings['search.exclude'] = [:]\n        \n        // Add exclusion patterns\n        settings['search.exclude']['**/build'] = true\n        settings['search.exclude']['runtime/{log,sessions,txlog,db,elasticsearch,opensearch}'] = true\n        \n        // Write formatted JSON\n        settingsFile.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(settings))\n        logger.lifecycle(\"VS Code settings updated at ${settingsFile}\")\n    }\n}\n\n// ========== test task ==========\n// NOTE1: to run startElasticSearch before the first test task add it as a dependency to all test tasks\n// NOTE2: to run stopElasticSearch after the last test task make all test tasks finalizedBy stopElasticSearch\ngetTasksByName('test', true).each {\n    if (it.path != ':test') {\n        // logger.lifecycle(\"Adding dependencies for test task ${it.getPath()}\")\n        it.dependsOn(startElasticSearch)\n        it.finalizedBy(stopElasticSearch)\n    }\n}\n\n// ========== check/update tasks ==========\n\ntask getRuntime {\n    description = \"If the runtime directory does not exist get it using settings in myaddons.xml or addons.xml; also check default components in myaddons.xml (addons.@default) and download any missing\"\n    doLast { checkRuntimeDirAndDefaults(project.hasProperty('locationType') ? locationType : null) }\n}\ntask checkRuntime { doLast {\n    if (!file('runtime').exists()) throw new GradleException(\"Required 'runtime' directory not found. Use 'gradle getRuntime' or 'gradle getComponent' or manually clone the moqui-runtime repository. This must be done in a separate Gradle run before a build so Gradle can find and run build tasks.\")\n} }\ntask gitPullAll {\n    description = \"Do a git pull to update moqui, runtime, and each installed component (for each where a .git directory is found)\"\n    doLast {\n        // framework and runtime\n        if (file(\".git\").exists()) { doGitPullWithStatus(file('.').path) }\n        if (file(\"runtime/.git\").exists()) { doGitPullWithStatus(file('runtime').path) }\n        // all directories under runtime/component\n        for (File compDir in file('runtime/component').listFiles().findAll { it.isDirectory() && it.listFiles().find { it.name == '.git' } }) {\n            doGitPullWithStatus(compDir.path)\n        }\n    }\n}\ndef doGitPullWithStatus(def gitDir) {\n    try {\n        def curGrgit = Grgit.open(dir: gitDir)\n        logger.lifecycle(\"\\nPulling ${gitDir} (branch:${curGrgit.branch.current()?.name}, tracking:${curGrgit.branch.current()?.trackingBranch?.name})\")\n\n        def beforeHead = curGrgit.head()\n        curGrgit.pull()\n        def afterHead = curGrgit.head()\n        if (beforeHead == afterHead) {\n            logger.lifecycle(\"Already up-to-date.\")\n        } else {\n            List<Commit> commits = curGrgit.log { range(beforeHead, afterHead) }\n            for (Commit commit in commits) logger.lifecycle(\"- ${commit.getAbbreviatedId(7)} by ${commit.committer?.name}: ${commit.shortMessage}\")\n        }\n    } catch (Throwable t) {\n        logger.error(t.message)\n    }\n}\ntask gitCheckoutAll {\n    description = \"Do a git checkout on moqui, runtime, and each installed component (for each where a .git directory is found); use -Pbranch= (required) to specify a branch, use -Pcreate=true to create branches with the given name\"\n    doLast {\n        if (!project.hasProperty('branch')) throw new InvalidUserDataException(\"No branch property specified (use -Pbranch=...)\")\n        String curBranch = branch\n        String curTag = (project.hasProperty('tag') ? tag : null) ?: curBranch\n        boolean createBranch = false\n        if (project.hasProperty('create') && create == 'true') createBranch = true\n\n        List<String> gitDirectories = []\n        if (file(\".git\").exists()) gitDirectories.add(file('.').path)\n        if (file(\"runtime/.git\").exists()) gitDirectories.add(file('runtime').path)\n        for (File compDir in file('runtime/component').listFiles().findAll { it.isDirectory() && it.listFiles().find { it.name == '.git' } })\n            gitDirectories.add(compDir.path)\n\n        for (String gitDir in gitDirectories) {\n            def curGrgit = Grgit.open(dir: gitDir)\n            def branchList = curGrgit.branch.list(mode: org.ajoberstar.grgit.operation.BranchListOp.Mode.ALL)\n            def tagList = curGrgit.tag.list()\n            def targetBranch = branchList.find({ it.name == curBranch })\n            def targetTag = tagList.find({ it.name == curTag })\n            if (targetBranch == null && targetTag == null) {\n                def originBranch = branchList.find({ it.name == 'origin/' + curBranch })\n                if (originBranch != null) {\n                    logger.lifecycle(\"In ${gitDir} branch ${curBranch} not found but found ${originBranch.name}, creating local branch tracking that branch\")\n                    targetBranch = curGrgit.branch.add(name: curBranch, startPoint: originBranch, mode: org.ajoberstar.grgit.operation.BranchAddOp.Mode.TRACK)\n                }\n            }\n            if (createBranch || targetBranch != null || targetTag != null) {\n                if (targetTag != null) {\n                    if (createBranch && curBranch != curTag) {\n                        logger.lifecycle(\"== Git checkout ${gitDir} tag ${curTag} and create branch ${curBranch}\")\n                        try { curGrgit.checkout(branch: curBranch, createBranch: true, startPoint: targetTag) }\n                        catch (Exception e) { logger.lifecycle(\"Checkout error\", e) }\n                    } else {\n                        logger.lifecycle(\"== Git checkout ${gitDir} tag ${curTag}\")\n                        try { curGrgit.checkout(branch: curTag, createBranch: false) }\n                        catch (Exception e) { logger.lifecycle(\"Checkout error\", e) }\n                    }\n                } else {\n                    logger.lifecycle(\"== Git checkout ${gitDir} branch ${curBranch} create ${createBranch}\")\n                    try { curGrgit.checkout(branch: curBranch, createBranch: createBranch) }\n                    catch (Exception e) { logger.lifecycle(\"Checkout error\", e) }\n                }\n            } else {\n                logger.lifecycle(\"* No branch or tag '${curBranch}' in ${gitDir}\\nBranches: ${branchList.collect({it.name})}\\nTags: ${tagList.collect({it.name})}\")\n            }\n            logger.lifecycle(\"\")\n        }\n    }\n}\ntask gitStatusAll {\n    description = \"Do a git status to check moqui, runtime, and each installed component (for each where a .git directory is found)\"\n    doLast {\n        List<String> gitDirectories = []\n        if (file(\".git\").exists()) gitDirectories.add(file('.').path)\n        if (file(\"runtime/.git\").exists()) gitDirectories.add(file('runtime').path)\n        for (File compDir in file('runtime/component').listFiles().findAll { it.isDirectory() && it.listFiles().find { it.name == '.git' } })\n            gitDirectories.add(compDir.path)\n        for (String gitDir in gitDirectories) {\n            def curGrgit = Grgit.open(dir: gitDir)\n            logger.lifecycle(\"\\nGit status for ${gitDir} (branch:${curGrgit.branch.current()?.name}, tracking:${curGrgit.branch.current()?.trackingBranch?.name})\")\n\n            try {\n                if (curGrgit.remote.list().find({ it.name == 'upstream'})) {\n                    def upstreamAhead = curGrgit.log { range curGrgit.resolve.toCommit('refs/remotes/upstream/master'), curGrgit.resolve.toCommit('refs/remotes/origin/master') }\n                    if (upstreamAhead) logger.lifecycle(\"- origin/master ${upstreamAhead.size()} commits ahead of upstream/master\")\n                }\n            } catch (Exception e) {\n                logger.error(\"Error finding commits ahead of upstream\", e)\n            }\n            try {\n                def masterLatest = curGrgit.resolve.toCommit('refs/remotes/origin/master')\n                if (masterLatest == null) {\n                    logger.error(\"No origin/master branch exists, can't determine unpushed commits\")\n                } else {\n                    def unpushed = curGrgit.log { range masterLatest, curGrgit.resolve.toCommit('HEAD') }\n                    if (unpushed) logger.lifecycle(\"--- ${unpushed.size()} commits unpushed (ahead of origin/master)\")\n                    for (Commit commit in unpushed) logger.lifecycle(\" - ${commit.getAbbreviatedId(8)} - ${commit.shortMessage}\")\n                }\n            } catch (Exception e) {\n                logger.error(\"Error finding unpushed commits\", e)\n            }\n            def curStatus = curGrgit.status()\n            if (curStatus.isClean()) logger.lifecycle(\"* nothing to commit, working directory clean\")\n            if (curStatus.staged.added || curStatus.staged.modified || curStatus.staged.removed) logger.lifecycle(\"--- Changes to be committed::\")\n            for (String fn in curStatus.staged.added)    logger.lifecycle(\"       added: ${fn}\")\n            for (String fn in curStatus.staged.modified) logger.lifecycle(\"    modified: ${fn}\")\n            for (String fn in curStatus.staged.removed)  logger.lifecycle(\"     removed: ${fn}\")\n            if (curStatus.unstaged.added || curStatus.unstaged.modified || curStatus.unstaged.removed) logger.lifecycle(\"--- Changes not staged for commit:\")\n            for (String fn in curStatus.unstaged.added)    logger.lifecycle(\"       added: ${fn}\")\n            for (String fn in curStatus.unstaged.modified) logger.lifecycle(\"    modified: ${fn}\")\n            for (String fn in curStatus.unstaged.removed)  logger.lifecycle(\"     removed: ${fn}\")\n        }\n    }\n}\ntask gitUpstreamAll {\n    description = \"Do a git pull upstream:master for moqui, runtime, and each installed component (for each where a .git directory is found and has a remote called upstream)\"\n    doLast {\n        String remoteName = project.hasProperty('remote') ? remote : 'upstream'\n\n        List<String> gitDirectories = []\n        if (file(\".git\").exists()) gitDirectories.add(file('.').path)\n        if (file(\"runtime/.git\").exists()) gitDirectories.add(file('runtime').path)\n        for (File compDir in file('runtime/component').listFiles().findAll { it.isDirectory() && it.listFiles().find { it.name == '.git' } })\n            gitDirectories.add(compDir.path)\n        for (String gitDir in gitDirectories) {\n            def curGrgit = Grgit.open(dir: gitDir)\n            if (curGrgit.remote.list().find({ it.name == remoteName})) {\n                logger.lifecycle(\"\\nGit merge ${remoteName} for ${gitDir}\")\n                curGrgit.pull(remote: remoteName, branch: 'master')\n            } else {\n                logger.lifecycle(\"\\nNo ${remoteName} remote for ${gitDir}\")\n            }\n\n        }\n    }\n}\n\ntask gitTagAll {\n    description = \"Do a git add or remove tag on the currently checked out commit in moqui, runtime, and each installed component\"\n    doLast {\n        def tagName = (project.hasProperty('tag')) ? tag : null;\n        def tagMessage = (project.hasProperty('message')) ? message : null;\n\n        boolean removeTags = (project.hasProperty('remove') && remove == 'true')\n        boolean pushTags = (project.hasProperty('push') && push == 'true')\n\n        // Users can simply push tags to the remote\n        if (tagName == null && pushTags == false)\n            throw new InvalidUserDataException(\"No tag property specified (use -Ptag=...) and No push tag specified (use -Ppush=true)\")\n\n        List<String> gitDirectories = []\n        if (file(\".git\").exists()) gitDirectories.add(file('.').path)\n        if (file(\"runtime/.git\").exists()) gitDirectories.add(file('runtime').path)\n        for (File compDir in file('runtime/component').listFiles().findAll { it.isDirectory() && it.listFiles().find { it.name == '.git' } })\n            gitDirectories.add(compDir.path)\n\n        def frameworkDir = gitDirectories.first()\n        for (String gitDir in gitDirectories) {\n            def relativePath = \".\"+gitDir.minus(frameworkDir)\n            def curGrgit = Grgit.open(dir: gitDir)\n            def branchName = curGrgit.branch.current().name\n            def commit = curGrgit.log(maxCommits: 1).find()\n\n            if (tagName != null) {\n                def tagList = curGrgit.tag.list()\n                def targetTag = tagList.find({ it.name == tagName })\n\n                if (targetTag == null) {\n                    if (removeTags) {\n                        logger.lifecycle(\"== Git tag '${tagName}' not found in ${branchName} of ${relativePath} ... skipping\")\n                    } else {\n                        curGrgit.tag.add(name: tagName, message: tagMessage ?: \"Tagging version ${tagName}\")\n                        logger.lifecycle(\"== Git tagging commit ${commit.abbreviatedId} - '${commit.shortMessage}' by '${commit.author.name}' in ${branchName} of ${relativePath}\")\n                    }\n                } else {\n                    if (removeTags) {\n                        curGrgit.tag.remove(names: [tagName])\n                        logger.lifecycle(\"== Git removing tag '${tagName}' in ${branchName} of ${relativePath}\")\n                    } else {\n                        logger.lifecycle(\"== Git tag '${tagName}' already exists in ${branchName} of ${relativePath}, skipping...\")\n                    }\n                }\n            }\n            if (pushTags) {\n                if (removeTags) {\n                    curGrgit.push(refsOrSpecs: [':refs/tags/'+tagName])\n                } else {\n                    curGrgit.push(tags: true)\n                }\n                logger.lifecycle(\"== Git pushing tag changes to remote of ${relativePath}\")\n            }\n        }\n    }\n}\ntask gitDiffTagsAll {\n    description = \"Do a git diff between two tags in the currently checked out branch in moqui, runtime, and each installed component\"\n    doLast {\n        if (!project.hasProperty('taga') || taga == null)\n            throw new InvalidUserDataException(\"No taga property specified (use -Ptaga=...)\")\n\n        // If tagb is not passed, we assume HEAD\n        def tagb = (project.hasProperty('tagb') && tagb != null) ? tagb : \"HEAD\";\n\n        logger.lifecycle(\"== Git diffing tags ${taga} and ${tagb}\")\n\n        List<String> gitDirectories = []\n        if (file(\".git\").exists()) gitDirectories.add(file('.').path)\n        if (file(\"runtime/.git\").exists()) gitDirectories.add(file('runtime').path)\n        for (File compDir in file('runtime/component').listFiles().findAll { it.isDirectory() && it.listFiles().find { it.name == '.git' } })\n            gitDirectories.add(compDir.path)\n\n        def frameworkDir = gitDirectories.first()\n        for (String gitDir in gitDirectories) {\n            def relativePath = \".\"+gitDir.minus(frameworkDir)\n            def grgit = Grgit.open(dir: gitDir)\n\n            def tagList = grgit.tag.list()\n            def tagaCommit = tagList.find({ it.name == taga })\n            def tagbCommit = tagList.find({ it.name == tagb })\n\n            logger.lifecycle(\"${relativePath}\")\n\n            if ((taga == \"HEAD\" || tagaCommit != null) && (tagb == \"HEAD\" || tagbCommit != null)) {\n                grgit.log {\n                    range taga, tagb\n                }.each {\n                    logger.lifecycle(\"    ${it.abbreviatedId} - ${it.shortMessage}\")\n                }\n            }\n        }\n    }\n}\n\ntask gitMergeAll {\n    description = \"Do a git diff between two tags in the currently checked out branch in moqui, runtime, and each installed component\"\n    doLast {\n        def branchName = (project.hasProperty('branch')) ? branch : null;\n        def tagName = (project.hasProperty('tag')) ? tag : null;\n        def mergeMode = (project.hasProperty('mode')) ? mode : null;\n        def mergeMessage = (project.hasProperty('message')) ? message : null;\n        def pushMerge = (project.hasProperty('push')) ? push : null;\n\n\n        List<String> gitDirectories = []\n        if (file(\".git\").exists()) gitDirectories.add(file('.').path)\n        if (file(\"runtime/.git\").exists()) gitDirectories.add(file('runtime').path)\n        for (File compDir in file('runtime/component').listFiles().findAll { it.isDirectory() && it.listFiles().find { it.name == '.git' } })\n            gitDirectories.add(compDir.path)\n\n        def frameworkDir = gitDirectories.first()\n        for (String gitDir in gitDirectories) {\n            def relativePath = \".\"+gitDir.minus(frameworkDir)\n            logger.lifecycle(\"${relativePath}\")\n            def grgit = Grgit.open(dir: gitDir)\n            def currentBranch = grgit.branch.current()?.name;\n            if (branchName == currentBranch)\n                continue\n\n            def doMerge = false;\n\n            if (branchName && grgit.branch.list().find({ it.name == branchName }) != null) {\n                doMerge = true;\n            }\n\n            if (tagName && grgit.tag.list().find({ it.name == tagName }) != null) {\n                doMerge = true;\n            }\n\n            if (doMerge) {\n                grgit.merge(head: branchName ?: tagName, mode: mergeMode, message: mergeMessage)\n                logger.lifecycle(\"    Merging ${branchName ?: tagName} into ${currentBranch}\")\n            }\n\n            if (pushMerge) {\n                grgit.push();\n                logger.lifecycle(\"    Pushing merge\")\n            }\n        }\n    }\n}\n\n// ========== run tasks ==========\n\ntask run(type: JavaExec) {\n    dependsOn checkRuntime, allBuildTasks, cleanTempDir\n    workingDir = '.'; jvmArgs = ['-server', '-XX:-OmitStackTraceInFastThrow']\n    systemProperties = ['moqui.conf':moquiConfDev, 'moqui.runtime':moquiRuntime]\n    // NOTE: this is a hack, using -jar instead of a class name, and then the first argument is the name of the jar file\n    mainClass = '-jar'; args = [warName]\n}\ntask runProduction(type: JavaExec) {\n    dependsOn checkRuntime, allBuildTasks, cleanTempDir\n    workingDir = '.'; jvmArgs = ['-server', '-Xms1024M']\n    systemProperties = ['moqui.conf':moquiConfProduction, 'moqui.runtime':moquiRuntime]\n    mainClass = '-jar'; args = [warName]\n}\n\ntask load(type: JavaExec) {\n    description = \"Run Moqui to load data; to specify data types use something like: gradle load -Ptypes=seed,seed-initial,install\"\n    dependsOn checkRuntime, allBuildTasks\n    systemProperties = ['moqui.conf':moquiConfDev, 'moqui.runtime':moquiRuntime]\n    workingDir = '.'; jvmArgs = ['-server']; mainClass = '-jar'\n    args = [warName, 'load', (project.properties.containsKey('types') ? \"types=${types}\" : \"types=all\")]\n}\ntask loadSeed(type: JavaExec) {\n    dependsOn checkRuntime, allBuildTasks\n    systemProperties = ['moqui.conf':moquiConfProduction, 'moqui.runtime':moquiRuntime]\n    workingDir = '.'; jvmArgs = ['-server']; mainClass = '-jar'\n    args = [warName, 'load', (project.properties.containsKey('types') ? \"types=${types}\" : \"types=seed\")]\n}\ntask loadSeedInitial(type: JavaExec) {\n    dependsOn checkRuntime, allBuildTasks\n    systemProperties = ['moqui.conf':moquiConfProduction, 'moqui.runtime':moquiRuntime]\n    workingDir = '.'; jvmArgs = ['-server']; mainClass = '-jar'\n    args = [warName, 'load', (project.properties.containsKey('types') ? \"types=${types}\" : \"types=seed,seed-initial\")]\n}\ntask loadProduction(type: JavaExec) {\n    dependsOn checkRuntime, allBuildTasks\n    systemProperties = ['moqui.conf':moquiConfProduction, 'moqui.runtime':moquiRuntime]\n    workingDir = '.'; jvmArgs = ['-server']; mainClass = '-jar'\n    args = [warName, 'load', (project.properties.containsKey('types') ? \"types=${types}\" : \"types=seed,seed-initial,install\")]\n}\n\ntask saveDb { doLast {\n    if (file(moquiRuntime+'/db/derby/moqui').exists())\n        ant.zip(destfile: 'SaveDerby.zip') { fileset(dir: moquiRuntime+'/db/derby/moqui') { include(name: '**/*') } }\n    if (file(moquiRuntime+'/db/h2').exists())\n        ant.zip(destfile: 'SaveH2.zip') { fileset(dir: moquiRuntime+'/db/h2') { include(name: '**/*') } }\n    if (file(moquiRuntime+'/db/orientdb/databases').exists())\n        ant.zip(destfile: 'SaveOrientDb.zip') { fileset(dir: moquiRuntime+'/db/orientdb/databases') { include(name: '**/*') } }\n\n    File osDir = file(moquiRuntime + '/opensearch')\n    String workDir = moquiRuntime + (osDir.exists() ? '/opensearch' : '/elasticsearch')\n    if (file(workDir+'/data').exists()) {\n        if (file(workDir+'/bin').exists()) {\n            def pidFile = file(workDir+'/pid')\n            if (pidFile.exists()) {\n                String pid = pidFile.getText()\n                logger.lifecycle(\"ElasticSearch running with pid ${pid}, stopping before saving data then restarting\")\n                ['kill', pid].execute(null, file(workDir)).waitFor()\n                ['tail', \"--pid=${pid}\", '-f', '/dev/null'].execute(null, file(workDir)).waitFor()\n                if (pidFile.exists()) delete pidFile\n\n                ant.zip(destfile: (osDir.exists() ? 'SaveOpenSearch.zip' : 'SaveElasticSearch.zip')) { fileset(dir: workDir+'/data') { include(name: '**/*') } }\n\n                startSearch(moquiRuntime)\n            } else {\n                logger.lifecycle(\"Found ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'} ${workDir}/bin directory but no pid, saving data without stop/start; WARNING if ElasticSearch is running this will cause problems!\")\n\n                ant.zip(destfile: (osDir.exists() ? 'SaveOpenSearch.zip' : 'SaveElasticSearch.zip')) { fileset(dir: workDir+'/data') { include(name: '**/*') } }\n            }\n        } else {\n            ant.zip(destfile: (osDir.exists() ? 'SaveOpenSearch.zip' : 'SaveElasticSearch.zip')) { fileset(dir: workDir+'/data') { include(name: '**/*') } }\n        }\n    }\n} }\ntask loadSave {\n    description = \"Clean all, build and load, then save database (H2, Derby), OrientDB, and OpenSearch/ElasticSearch files; to be used before reloadSave\"\n    dependsOn cleanAll, load, saveDb\n}\n\ntask reloadSave {\n    description = \"After a loadSave clean database (H2, Derby), OrientDB, and ElasticSearch files and reload from saved copy\"\n    dependsOn cleanTempDir, cleanDb, cleanLog, cleanSessions\n    dependsOn allBuildTasks\n    doLast {\n        if (file('SaveDerby.zip').exists()) copy { from zipTree('SaveDerby.zip'); into file(moquiRuntime+'/db/derby/moqui') }\n        if (file('SaveH2.zip').exists()) copy { from zipTree('SaveH2.zip'); into file(moquiRuntime+'/db/h2') }\n        if (file('SaveOrientDb.zip').exists()) copy { from zipTree('SaveOrientDb.zip'); into file(moquiRuntime+'/db/orientdb/databases') }\n\n        if (file('SaveElasticSearch.zip').exists()) {\n            String esDir = moquiRuntime+'/elasticsearch'\n            if (file(esDir+'/bin').exists()) {\n                def pidFile = file(esDir+'/pid')\n                if (pidFile.exists()) {\n                    String pid = pidFile.getText()\n                    logger.lifecycle(\"ElasticSearch running with pid ${pid}, stopping before restoring data then restarting\")\n                    ['kill', pid].execute(null, file(esDir)).waitFor()\n                    ['tail', \"--pid=${pid}\", '-f', '/dev/null'].execute(null, file(esDir)).waitFor()\n                    copy { from zipTree('SaveElasticSearch.zip'); into file(moquiRuntime+'/elasticsearch/data') }\n                    if (pidFile.exists()) delete pidFile\n                    ['./bin/elasticsearch', '-d', '-p', 'pid'].execute(null, file(esDir)).waitFor()\n                } else {\n                    logger.lifecycle(\"Found ElasticSearch ${esDir}/bin directory but no pid, saving data without stop/start; WARNING if ElasticSearch is running this will cause problems!\")\n                    copy { from zipTree('SaveElasticSearch.zip'); into file(moquiRuntime+'/elasticsearch/data') }\n                }\n            } else {\n                copy { from zipTree('SaveElasticSearch.zip'); into file(moquiRuntime+'/elasticsearch/data') }\n            }\n        }\n        if (file('SaveOpenSearch.zip').exists()) {\n            String esDir = moquiRuntime+'/opensearch'\n            if (file(esDir+'/bin').exists()) {\n                def pidFile = file(esDir+'/pid')\n                if (pidFile.exists()) {\n                    String pid = pidFile.getText()\n                    logger.lifecycle(\"OpenSearch running with pid ${pid}, stopping before restoring data then restarting\")\n                    ['kill', pid].execute(null, file(esDir)).waitFor()\n                    ['tail', \"--pid=${pid}\", '-f', '/dev/null'].execute(null, file(esDir)).waitFor()\n                    copy { from zipTree('SaveOpenSearch.zip'); into file(moquiRuntime+'/opensearch/data') }\n                    if (pidFile.exists()) delete pidFile\n                    ['./bin/opensearch', '-d', '-p', 'pid'].execute(null, file(esDir)).waitFor()\n                } else {\n                    logger.lifecycle(\"Found OpenSearch ${esDir}/bin directory but no pid, saving data without stop/start; WARNING if OpenSearch is running this will cause problems!\")\n                    copy { from zipTree('SaveOpenSearch.zip'); into file(moquiRuntime+'/opensearch/data') }\n                }\n            } else {\n                copy { from zipTree('SaveOpenSearch.zip'); into file(moquiRuntime+'/opensearch/data') }\n            }\n        }\n    }\n}\n\n// ========== deploy tasks ==========\n\ntask deployTomcat { doLast {\n    // remove runtime directory, may have been added for logs/etc\n    delete file(tomcatHome + '/runtime')\n    // remove ROOT directory and war to avoid conflicts\n    delete file(tomcatHome + '/webapps/ROOT')\n    delete file(tomcatHome + '/webapps/ROOT.war')\n    // copy the war file to ROOT.war\n    copy { from file(warName); into file(tomcatHome + '/webapps'); rename(warName, 'ROOT.war') }\n} }\n\ntask plusRuntimeWarTemp {\n    dependsOn checkRuntime, allBuildTasks\n\n    doLast {\n        File wartempFile = file('wartemp')\n        if (wartempFile.exists()) delete wartempFile\n\n        // make version detail files\n        makeVersionDetailFiles()\n        // unzip the \"moqui-${version}.war\" file to the wartemp directory\n        copy { from zipTree(warName); into wartempFile }\n        // copy runtime directory (with a few exceptions) into a runtime directory in the war\n        copy {\n            from fileTree(dir: '.', include: moquiRuntime+'/**',\n                    excludes: ['**/*.jar', '**/build', moquiRuntime+'/classes/**', moquiRuntime+'/lib/**', moquiRuntime+'/log/**', moquiRuntime+'/sessions/**'])\n            into wartempFile\n        }\n        // copy the jar files from runtime/lib\n        copy { from fileTree(dir: moquiRuntime+'/lib', include: '**/*.jar').files into 'wartemp/WEB-INF/lib' }\n        // copy the classpath resource files from runtime/classes\n        copy { from fileTree(dir: moquiRuntime+'/classes', include: '**/*') into 'wartemp/WEB-INF/classes' }\n        // copy the jar files from components\n        copy { from fileTree(dir: moquiRuntime+'/base-component', include: '**/*.jar').files into 'wartemp/WEB-INF/lib' }\n        copy {\n            from fileTree(dir: moquiRuntime+'/component', include: '**/*.jar', exclude: '**/librepo/*.jar').files\n            into 'wartemp/WEB-INF/lib'\n            duplicatesStrategy DuplicatesStrategy.WARN\n        }\n        copy {\n            from fileTree(dir: moquiRuntime+'/ecomponent', include: '**/*.jar', exclude: '**/librepo/*.jar').files\n            into 'wartemp/WEB-INF/lib'\n            duplicatesStrategy DuplicatesStrategy.WARN\n        }\n        // add MoquiInit.properties fresh copy, just in case it was changed\n        copy { from file('MoquiInit.properties') into 'wartemp/WEB-INF/classes' }\n        // add Procfile to root\n        copy { from file('Procfile') into 'wartemp' }\n        // special case: copy elasticsearch plugin/module jars (needed for ES installed in runtime/elasticsearch\n        if (file(moquiRuntime+'/elasticsearch').exists())\n            copy { from fileTree(dir: '.', include: moquiRuntime+'/elasticsearch/**/*.jar') into wartempFile }\n        // special case: copy opensearch plugin/module jars (needed for ES installed in runtime/opensearch\n        if (file(moquiRuntime+'/opensearch').exists())\n            copy { from fileTree(dir: '.', include: moquiRuntime+'/opensearch/**/*.jar') into wartempFile }\n        // special case: copy jackrabbit standalone jar (if exists)\n        copy { from fileTree(dir: moquiRuntime + '/jackrabbit', include: 'jackrabbit-standalone-*.jar').files; into 'wartemp/' + moquiRuntime + '/jackrabbit' }\n\n        // clean up version detail files\n        cleanVersionDetailFiles()\n    }\n}\ntask addRuntime(type: Zip) {\n    description = \"Create moqui-plus-runtime.war file from the moqui.war file and the runtime directory embedded in it\"\n    dependsOn checkRuntime, allBuildTasks, plusRuntimeWarTemp\n\n    archiveFileName = plusRuntimeName\n    destinationDirectory = file('.')\n    from file('wartemp')\n\n    doFirst { if (file(plusRuntimeName).exists()) delete file(plusRuntimeName) }\n    doLast { delete file('wartemp') }\n}\n\n// don't use this task directly, use addRuntimeTomcat which calls this\ntask deployTomcatRuntime { doLast {\n    delete file(tomcatHome + '/runtime'); delete file(tomcatHome + '/webapps/ROOT'); delete file(tomcatHome + '/webapps/ROOT.war')\n    copy { from file(plusRuntimeName); into file(tomcatHome + '/webapps'); rename(plusRuntimeName, 'ROOT.war') }\n} }\ntask addRuntimeTomcat {\n    dependsOn addRuntime\n    dependsOn deployTomcatRuntime\n}\n\n// ========== component tasks ==========\n\ntask getDefaults {\n    description = \"Get a component using specified location type, also check/get all components it depends on; requires component property; locationType property optional (defaults to git if there is a .git directory, otherwise to current)\"\n    doLast {\n        String curLocationType = file('.git').exists() ? 'git' : 'current'\n        if (project.hasProperty('locationType')) curLocationType = locationType\n        getComponentTop(curLocationType)\n    }\n}\ntask getComponent {\n    description = \"Get a component using specified location type, also check/get all components it depends on; requires component property; locationType property optional (defaults to git if there is a .git directory, otherwise to current)\"\n    doLast {\n        String curLocationType = file('.git').exists() ? 'git' : 'current'\n        if (project.hasProperty('locationType')) curLocationType = locationType\n        getComponentTop(curLocationType)\n    }\n}\ntask createComponent {\n    description = \"Create a new component. Set new component name with -Pcomponent=new_component_name (based on the moqui start component here: https://github.com/moqui/start)\"\n    doLast {\n        String curLocationType = file('.git').exists() ? 'git' : 'current'\n        if (project.hasProperty('locationType')) curLocationType = locationType\n\n        if (project.hasProperty('component')) {\n            checkRuntimeDirAndDefaults(curLocationType)\n            Set compsChecked = new TreeSet()\n\n            def startComponentName = 'start'\n\n            File componentDir = getComponent(startComponentName, curLocationType, parseAddons(), parseMyaddons(), compsChecked)\n            if (componentDir?.exists()) {\n                logger.lifecycle(\"Got component start, dependent components checked: ${compsChecked}\")\n\n                def newComponent = file(\"runtime/component/${component}\")\n                def renameSuccessful = componentDir.renameTo(newComponent)\n                if (!renameSuccessful) {\n                    logger.error(\"Failed to rename component start to ${component}. Try removing the existing component directory first or giving this program write permissions.\")\n                } else {\n                    logger.lifecycle(\"Renamed component start to ${component}\")\n                }\n\n                print \"Updated file: \"\n                newComponent.eachFileRecurse(groovy.io.FileType.FILES) { file ->\n                    try {\n                        // If file name is startComponentName.* rename to component.*\n                        if (file.name.startsWith(startComponentName)) {\n                            String newFileName = (file.name - startComponentName)\n                            newFileName = component + newFileName\n                            File newFile = new File(file.parent, newFileName)\n                            file.renameTo(newFile)\n                            file = newFile\n                            print \"${file.path - newComponent.path - '/'}, \"\n                        }\n\n                        String content = file.text\n                        if (content.contains(startComponentName)) {\n                            content = content.replaceAll(startComponentName, component)\n                            file.text = content\n                            print \"${file.path - newComponent.path - '/'}, \"\n                        }\n                    } catch (IOException e) {\n                        println \"Error processing file ${file.path}: ${e.message}\"\n                    }\n                }\n                print \"\\n\\n\"\n                println \"Select rest api (r), screens (s), or both (B):\"\n                def componentInput = System.in.newReader().readLine()\n\n                if (componentInput == 'r') {\n                    new File(newComponent, 'screen').deleteDir()\n                    new File(newComponent, 'template').deleteDir()\n                    new File(newComponent, 'data/AppSeedData.xml').delete()\n                    new File(newComponent, 'MoquiConf.xml').delete()\n                    def moquiConf = new File(newComponent, 'MoquiConf.xml')\n                    moquiConf.append(\"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\" ?>\\n\" +\n                            \"<!-- No copyright or license for configuration file, details here are not considered a creative work. -->\\n\" +\n                            \"<moqui-conf xmlns:xsi=\\\"http://www.w3.org/2001/XMLSchema-instance\\\" xsi:noNamespaceSchemaLocation=\\\"http://moqui.org/xsd/moqui-conf-3.xsd\\\">\\n\" +\n                            \"</moqui-conf>\")\n                    println \"Selected rest api so, deleted screen, template, and AppSeedData.xml\\n\"\n                } else if (componentInput == 's') {\n                    new File(newComponent, \"services/${component}.rest.xml\").delete()\n                    new File(newComponent, 'data/ApiSeedData.xml').delete()\n                    println \"Selected screens so, deleted rest api and ApiSeedData.xml\\n\"\n                } else if (componentInput == 'b' || componentInput == 'B' || componentInput == '') {\n                    println \"Selected both rest api and screens\\n\"\n                } else {\n                    println \"Invalid input. Try again\"\n                    newComponent.deleteDir()\n                    return\n                }\n\n                println \"Are you going to code or test in groovy or java [y/N]\"\n                def codeInput = System.in.newReader().readLine()\n\n                if (codeInput == 'y' || codeInput == 'Y') {\n                    println \"Keeping src folder\\n\"\n                } else if (codeInput == 'n' || codeInput == 'N' || codeInput == '') {\n                    new File(newComponent, 'src').deleteDir()\n                    new File(newComponent, 'build.grade').delete()\n                    println \"Selected no so, deleted src and build.grade\\n\"\n                } else {\n                    println \"Invalid input. Try again\"\n                    newComponent.deleteDir()\n                    return\n                }\n\n                println \"Setup a git repository [Y/n]\"\n                def gitInput = System.in.newReader().readLine()\n                if (gitInput == 'y' || gitInput == 'Y' || gitInput == '') {\n                    new File(newComponent, '.git').deleteDir()\n                    // Setup git repository\n\n                    def grgit = Grgit.init(dir: newComponent.path)\n                    grgit.add(patterns: ['.'])\n                    // Can't get signing to work easily. If signing works well then might as well commit\n//                    grgit.commit(message: 'Initial commit')\n                    println \"Selected yes, so git is initialized\\n\"\n                    println \"To setup the git remote origin, type the git remote url or enter to skip\"\n                    def remoteUrl = System.in.newReader().readLine()\n                    if (remoteUrl != '') {\n                        grgit.remote.add(name: 'origin', url: remoteUrl)\n                        println \"Run the following to push the git repository:\\ncd runtime/component/${component} && git commit -m 'Initial commit' && git push && cd ../../..\"\n                    } else {\n                        println \"Run the following to push the git repository:\\ncd runtime/component/${component} && git commit -m 'Initial commit' && git remote add origin git@github.com:yourgroup/${component} && git push && cd ../../..\"\n                    }\n                } else if (gitInput == 'n' || gitInput == 'N') {\n                    new File(newComponent, '.git').deleteDir()\n                    println \"Selected no, so git is not initialized\\n\"\n                    println \"Run the following to push the git repository:\\ncd runtime/component/${component} && git commit -m 'Initial commit' && git remote add origin git@github.com:yourgroup/${component} && git push && cd ../../..\"\n                } else {\n                    println \"Invalid input. Try again\"\n                    newComponent.deleteDir()\n                    return\n                }\n\n                println \"Add to myaddons.xml [Y/n]\"\n                def myaddonsInput = System.in.newReader().readLine()\n                if (myaddonsInput == 'y' || myaddonsInput == 'Y' || myaddonsInput == '') {\n                    def myaddonsFile = file('myaddons.xml')\n                    if (myaddonsFile.exists()){\n                        // Iterate through myaddons file and delete the lines that are </addons>\n                        // Read the lines from the file\n                        def lines = myaddonsFile.readLines()\n\n                        // Filter out the lines that contain </addons>\n                        def filteredLines = lines.findAll { !it.contains(\"</addons>\") }\n\n                        // Write the filtered lines back to the file\n                        myaddonsFile.text = filteredLines.join('\\n')\n                    } else {\n                        println \"myaddons.xml not found. Creating one\\nEnter repository github (g), github-ssh (GS), bitbucket (b), or bitbucket-ssh (bs)\"\n                        def repositoryInput = System.in.newReader().readLine()\n                        myaddonsFile.append(\"<addons default-repository=\\\"\")\n                        if (repositoryInput == 'g' || repositoryInput == 'G') {\n                            myaddonsFile.append('github')\n                        } else if (repositoryInput == 'gs' || repositoryInput == 'GS' || repositoryInput == '') {\n                            myaddonsFile.append('github-ssh')\n                        } else if (repositoryInput == 'b' || repositoryInput == 'B') {\n                            myaddonsFile.append('bitbucket')\n                        } else if (repositoryInput == 'bs' || repositoryInput == 'BS') {\n                            myaddonsFile.append('bitbucket-ssh')\n                        } else {\n                            println \"Invalid input. Setting to github-ssh\"\n                            myaddonsFile.append('github-ssh')\n                        }\n                        myaddonsFile.append(\"\\\">\")\n                    }\n\n                    println \"Enter the component git repository group\"\n                    def groupInput = System.in.newReader().readLine()\n\n                    println \"Enter the component git repository name\"\n                    def nameInput = System.in.newReader().readLine()\n\n                    // get git branch\n                    def grgit = Grgit.open(dir: newComponent.path)\n                    def branch = grgit.branch.current().name\n\n                    myaddonsFile.append(\"\\n    <component group=\\\"${groupInput}\\\" name=\\\"${nameInput}\\\" branch=\\\"${branch}\\\"/>\")\n                    myaddonsFile.append(\"\\n</addons>\")\n\n                } else if (myaddonsInput == 'n' || myaddonsInput == 'N') {\n                    println \"Selected no, so component not added to myaddons.xml\\n\"\n                } else {\n                    println \"Invalid input. Try again\"\n                    newComponent.deleteDir()\n                    return\n                }\n\n            }\n        } else {\n            throw new InvalidUserDataException(\"No component property specified\")\n        }\n\n    }\n}\ntask getCurrent {\n    description = \"Get the current archive for a component, also check each component it depends on and if not present get its current archive; requires component property\"\n    doLast { getComponentTop('current') }\n}\ntask getRelease {\n    description = \"Get the release archive for a component, also check each component it depends on and if not present get its configured release archive; requires component property\"\n    doLast { getComponentTop('release') }\n}\ntask getBinary {\n    description = \"Get the binary release archive for a component, also check each component it depends on and if not present get its configured release archive; requires component property\"\n    doLast { getComponentTop('binary') }\n}\ntask getGit {\n    description = \"Clone the git repository for a component, also check each component it depends on and if not present clone its git repository; requires component property\"\n    doLast { getComponentTop('git') }\n}\ntask getDepends {\n    description = \"Check/Get all dependencies for all components in runtime/component; locationType property optional (defaults to git if there is a .git directory, otherwise to current)\"\n    doLast {\n        String curLocationType = file('.git').exists() ? 'git' : 'current'\n        if (project.hasProperty('locationType')) curLocationType = locationType\n        checkAllComponentDependencies(curLocationType)\n    }\n}\n\ntask getComponentSet {\n    description = \"Gets all components in the specied componentSet using specified location type, also check/get all components it depends on; requires -Pcomponent property; -PlocationType property optional (defaults to git if there is a .git directory, otherwise to current)\"\n    doLast {\n        String curLocationType = file('.git').exists() ? 'git' : 'current'\n        if (project.hasProperty('locationType')) curLocationType = locationType\n        if (!project.hasProperty('componentSet')) throw new InvalidUserDataException(\"No componentSet property specified\")\n        checkRuntimeDirAndDefaults(curLocationType)\n        Set compsChecked = new TreeSet()\n        loadComponentSet((String) componentSet, curLocationType, parseAddons(), parseMyaddons(), compsChecked)\n        logger.lifecycle(\"Got component-set ${componentSet}, got or checked components: ${compsChecked}\")\n    }\n}\n\ntask zipComponents {\n    description = \"Create a .zip archive file for each component in runtime/component\"\n    dependsOn allBuildTasks\n    doLast { for (File compDir in findComponentDirs()) createComponentZip(compDir) }\n}\ntask zipComponent {\n    description = \"Create a .zip archive file a single component in runtime/component; requires component property\"\n    dependsOn allBuildTasks\n    doLast {\n        if (!project.hasProperty('component')) throw new InvalidUserDataException(\"No component property specified\")\n        createComponentZip(file('runtime/component/' + component))\n    }\n}\n\n// ========== utility methods ==========\n\ndef createComponentZip(File compDir) {\n    File compXmlFile = file(\"${compDir.path}/component.xml\")\n    if (!compXmlFile.exists()) {\n        logger.lifecycle(\"No component.xml file found at ${compXmlFile.path}, not creating component zip\")\n        return\n    }\n    Node compXml = new XmlParser().parse(compXmlFile)\n    File zipFile = file(\"${compDir.parentFile.path}/${compXml.'@name'}${compXml.'@version' ? '-' + compXml.'@version' : ''}.zip\")\n    if (zipFile.exists()) { logger.lifecycle(\"Deleting existing component zip file: ${zipFile.name}\"); zipFile.delete() }\n    // exclude build, src, librepo, build.gradle, defaultexcludes (which includes .git)\n    ant.zip(destfile: zipFile.path) { fileset(dir: compDir.parentFile.path, includes: \"${compDir.name}/**\", defaultexcludes: 'yes',\n            excludes: \"${compDir.name}/build/**,${compDir.name}/src/**,${compDir.name}/librepo/**,${compDir.name}/build.gradle\") }\n    logger.lifecycle(\"Created component zip file: ${zipFile.name}\")\n}\n\ndef checkRuntimeDirAndDefaults(String locType) {\n    Node addons = parseAddons()\n    Node myaddons = parseMyaddons()\n    if (!locType) locType = file('.git').exists() ? 'git' : 'current'\n\n    File runtimeDir = file('runtime')\n    if (!runtimeDir.exists()) {\n        Node runtimeNode = myaddons != null && myaddons.runtime ? (Node) myaddons.runtime[0] : null\n        if (runtimeNode == null) runtimeNode = addons != null && addons.runtime ? (Node) addons.runtime[0] : null\n        if (runtimeNode == null) throw new InvalidUserDataException(\"The runtime directory does not exist and no runtime element found in myaddons.xml or addons.xml\")\n        downloadComponent(\"runtime\", locType, runtimeNode, addons, myaddons)\n    }\n\n    // look for @default in myaddons.xml only\n    if (myaddons?.'@default') {\n        String defaultComps = myaddons.'@default'\n        Set compsChecked = new TreeSet()\n        Set defaultCompsDownloaded = new TreeSet()\n        for (String compName in defaultComps.split(',')) {\n            compName = compName.trim()\n            if (!compName) continue\n            File componentDir = file(\"runtime/component/${compName}\")\n            if (componentDir.exists()) continue\n            getComponent(compName, locType, addons, myaddons, compsChecked)\n            defaultCompsDownloaded.add(compName)\n        }\n        if (defaultCompsDownloaded)\n            logger.lifecycle(\"Got default components ${defaultCompsDownloaded}, dependent components checked: ${compsChecked}\")\n    }\n}\n\ndef loadComponentSet(String setName, String curLocationType, Node addons, Node myaddons, Set compsChecked) {\n    Node setNode = null\n    if (myaddons) setNode = myaddons.'component-set'.find({ it.\"@name\" == setName })\n    if (setNode == null) setNode = addons.'component-set'.find({ it.\"@name\" == setName })\n    if (setNode == null) throw new InvalidUserDataException(\"Could not find component-set with name ${setName}\")\n    String components = setNode.'@components'\n    if (components) for (String compName in components.split(\",\"))\n        getComponent(compName, curLocationType, addons, myaddons, compsChecked)\n    String sets = setNode.'@sets'\n    if (sets) for (String subsetName in sets.split(\",\"))\n        loadComponentSet(subsetName, curLocationType, addons, myaddons, compsChecked)\n}\n\nCollection<File> findComponentDirs() {\n    file('runtime/component').listFiles().findAll({ it.isDirectory() && it.listFiles().find({ it.name == 'component.xml' }) })\n}\nNode parseAddons() { new XmlParser().parse(file('addons.xml')) }\nNode parseMyaddons() { if (file('myaddons.xml').exists()) { new XmlParser().parse(file('myaddons.xml')) } else { null } }\nNode parseComponent(project) { new XmlParser().parse(project.file('component.xml')) }\n\ndef getComponentTop(String locationType) {\n    if (project.hasProperty('component')) {\n        checkRuntimeDirAndDefaults(locationType)\n        Set compsChecked = new TreeSet()\n        File componentDir = getComponent(component, locationType, parseAddons(), parseMyaddons(), compsChecked)\n        if (componentDir?.exists()) logger.lifecycle(\"Got component ${component}, dependent components checked: ${compsChecked}\")\n    } else {\n        throw new InvalidUserDataException(\"No component property specified\")\n    }\n}\nFile getComponent(String compName, String type, Node addons, Node myaddons, Set compsChecked) {\n    // get the component\n    Node component = myaddons != null ? (Node) myaddons.component.find({ it.\"@name\" == compName }) : null\n    if (component == null) component = (Node) addons.component.find({ it.\"@name\" == compName })\n    if (component == null) throw new InvalidUserDataException(\"Component ${compName} not found in myaddons.xml or addons.xml\")\n    if (component.'@skip-get' == 'true') { logger.lifecycle(\"Skipping get component ${compName} (skip-get=true)\"); return null }\n    File componentDir = downloadComponent(\"runtime/component/${compName}\", type, component, addons, myaddons)\n\n    checkComponentDependencies(compName, type, addons, myaddons, compsChecked)\n    return componentDir\n}\nFile downloadComponent(String targetDirPath, String type, Node component, Node addons, Node myaddons) {\n    String compName = component.'@name'\n    String branch = component.'@branch'\n    // fall back to 'current' (branch-based) if release/binary requested but version is empty\n    if (type in ['release', 'binary'] && !component.'@version') type = 'current'\n\n    String repositoryName = (component.'@repository' ?: myaddons?.'@default-repository' ?: addons.'@default-repository' ?: 'github')\n    Node repository = myaddons != null ? (Node) myaddons.repository.find({ it.\"@name\" == repositoryName }) : null\n    if (repository == null) repository = (Node) addons.repository.find({ it.\"@name\" == repositoryName })\n    if (repository == null) throw new InvalidUserDataException(\"Repository ${repositoryName} not found in myaddons.xml or addons.xml\")\n    Node location = (Node) repository.location.find({ it.\"@type\" == type })\n    if (location == null) throw new InvalidUserDataException(\"Location for type ${type} now found in repository ${repositoryName}\")\n\n    String url = Eval.me('component', component, '\"\"\"' + location.'@url' + '\"\"\"')\n    logger.lifecycle(\"Getting ${compName} (type ${type}) from ${url} at ${branch} to ${targetDirPath}\")\n\n    File targetDir = file(targetDirPath)\n    if (targetDir.exists()) { logger.lifecycle(\"Component ${compName} already exists at ${targetDir}\"); return targetDir }\n    if (type in ['current', 'release', 'binary']) {\n        File zipFile = file(\"${targetDirPath}.zip\")\n        ant.get(src: url, dest: zipFile)\n        // the eachFile closure removes the first path from each file, moving everything up a directory\n        copy { from zipTree(zipFile); into targetDir; eachFile { it.setPath((it.getRelativePath().getSegments() as List).tail().join(\"/\")); return it } }\n        delete zipFile\n        // delete the empty directories left over from zip expansion with first path removed\n        String archiveDirName = compName + '-'\n        if (type == 'current') { archiveDirName += component.'@branch' } else { archiveDirName += component.'@version' }\n        // logger.lifecycle(\"Deleting dir ${targetDirPath}/${archiveDirName}\")\n        delete file(\"${targetDirPath}/${archiveDirName}\")\n    } else if (type == 'git') {\n        Grgit.clone(dir: targetDir, uri: url, refToCheckout: branch)\n    }\n    logger.lifecycle(\"Downloaded ${compName} to ${targetDirPath}\")\n    return targetDir\n}\ndef checkComponentDependencies(String compName, String type, Node addons, Node myaddons, Set compsChecked) {\n    File componentDir = file(\"runtime/component/${compName}\")\n    if (!componentDir.exists()) return\n    compsChecked.add(compName)\n    File compXmlFile = file(\"${componentDir.path}/component.xml\")\n    if (!compXmlFile.exists()) return\n    Node compXml = new XmlParser().parse(compXmlFile)\n    for (Node dependsOn in compXml.'depends-on') {\n        String depCompName = dependsOn.'@name'\n        if (file(\"runtime/component/${depCompName}\").exists()) {\n            if (!compsChecked.contains(depCompName)) checkComponentDependencies(depCompName, type, addons, myaddons, compsChecked)\n        } else {\n            getComponent(depCompName, type, addons, myaddons, compsChecked)\n        }\n    }\n}\ndef checkAllComponentDependencies(String type) {\n    Node addons = parseAddons()\n    Node myaddons = parseMyaddons()\n    Set compsChecked = new TreeSet()\n    for (File compDir in findComponentDirs()) {\n        checkComponentDependencies(compDir.name, type, addons, myaddons, compsChecked)\n    }\n    logger.lifecycle(\"Dependent components checked: ${compsChecked}\")\n}\n\ndef makeVersionDetailFiles() {\n    if (file(\".git\").exists()) {\n        def topVersionMap = [framework:getVersionDetailMap(file('.'))]\n        if (file(\"runtime/.git\").exists()) topVersionMap.runtime = getVersionDetailMap(file('runtime'))\n        file('runtime/version.json').write(groovy.json.JsonOutput.toJson(topVersionMap), \"UTF-8\")\n    }\n    for (File compDir in file('runtime/component').listFiles().findAll { it.isDirectory() && it.listFiles().find { it.name == '.git' } }) {\n        def versionMap = getVersionDetailMap(compDir)\n        if (versionMap == null) continue\n        file(compDir.path + '/version.json').write(groovy.json.JsonOutput.toJson(versionMap), \"UTF-8\")\n    }\n}\nMap getVersionDetailMap(File gitDir) {\n    def curGrgit = Grgit.open(dir: gitDir)\n    if (curGrgit == null) return null\n    String trackingName = curGrgit.branch.current()?.trackingBranch?.name\n    String trackingUrl = \"\"\n    int trackingNameSlash = trackingName ? trackingName.indexOf('/') : -1\n    if (trackingNameSlash > 0) {\n        String remoteName = trackingName.substring(0, trackingNameSlash)\n        def trackingRemote = curGrgit.remote.list().find({ it.name == remoteName })\n        if (trackingRemote != null) trackingUrl = trackingRemote.url\n    }\n    try {\n        String headId = curGrgit.head()?.id\n        // tags come in order of oldest first so want to find last in case multiple tags refer to HEAD commit\n        def headTag = curGrgit.tag.list().reverse().find({ it.commit.id == headId })\n        return [branch:curGrgit.branch.current()?.name, tracking:trackingName, url:trackingUrl, head:headId?.take(10), tag:headTag?.name]\n    } catch (Throwable t) {\n        logger.lifecycle(\"Error getting git info for directory ${gitDir?.path}\", t)\n        return null\n    }\n}\ndef cleanVersionDetailFiles() {\n    def runtimeVersionFile = file(\"runtime/version.json\")\n    if (runtimeVersionFile.exists()) runtimeVersionFile.delete()\n    for (File compDir in file('runtime/component').listFiles().findAll { it.isDirectory() }) {\n        File versionDetailFile = file(compDir.path + '/version.json')\n        if (versionDetailFile.exists()) versionDetailFile.delete()\n    }\n}\n\n// ========== combined tasks ==========\n\ntask cleanPullLoad { dependsOn cleanAll, gitPullAll, load }\ntask cleanPullTest { dependsOn cleanAll, gitPullAll, load, allTestTasks }\ntask cleanPullCompTest { dependsOn cleanAll, gitPullAll, load, getComponentTestTasks() }\ntask compTest { dependsOn getComponentTestTasks() }\n"
  },
  {
    "path": "build.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n\n<!--\nREADME: These are just tools for using the Moqui WAR file.\n\nThe actual build is done with Gradle.\n -->\n\n<project name=\"Moqui WAR Tools\" default=\"run\" basedir=\".\">\n    <property environment=\"env\"/>\n    <property name=\"tomcat.home\" value=\"../apache-tomcat-8.5.14\"/>\n\n    <property name=\"moqui.runtime\" value=\"runtime\"/>\n    <property name=\"moqui.conf.dev\" value=\"conf/MoquiDevConf.xml\"/>\n    <property name=\"moqui.conf.production\" value=\"conf/MoquiProductionConf.xml\"/>\n\n    <target name=\"add-runtime\">\n        <!-- unzip the \"moqui.war\" file to the wartemp directory -->\n        <mkdir dir=\"wartemp\"/>\n        <unzip src=\"moqui.war\" dest=\"wartemp\"/>\n\n        <copy todir=\"wartemp\">\n            <fileset dir=\".\" includes=\"${moqui.runtime}/**\" excludes=\"**/*.jar,${moqui.runtime}/lib/**,${moqui.runtime}/classes/**,${moqui.runtime}/log/**\"/>\n        </copy>\n        <copy todir=\"wartemp/WEB-INF/lib\"><fileset dir=\"${moqui.runtime}/lib\" includes=\"*.jar\"/></copy>\n        <copy todir=\"wartemp/WEB-INF/classes\"><fileset dir=\"${moqui.runtime}/classes\" includes=\"**/*\"/></copy>\n        <copy todir=\"wartemp/WEB-INF/lib\" flatten=\"true\"><fileset dir=\"${moqui.runtime}/base-component\" includes=\"**/*.jar\"/></copy>\n        <copy todir=\"wartemp/WEB-INF/lib\" flatten=\"true\"><fileset dir=\"${moqui.runtime}/component\" includes=\"**/*.jar\"/></copy>\n        <copy todir=\"wartemp/WEB-INF/lib\" flatten=\"true\" failonerror=\"false\"><fileset dir=\"${moqui.runtime}/ecomponent\" includes=\"**/*.jar\"/></copy>\n        <copy file=\"MoquiInit.properties\" todir=\"wartemp/WEB-INF/classes\" overwrite=\"true\"/>\n        <copy todir=\"wartemp\"><fileset dir=\".\" includes=\"${moqui.runtime}/elasticsearch/**/*.jar\"/></copy>\n\n        <!-- zip it up again -->\n        <zip destfile=\"moqui-plus-runtime.war\" basedir=\"wartemp\"/>\n\n        <delete verbose=\"off\" failonerror=\"false\" dir=\"wartemp\"/>\n    </target>\n\n    <target name=\"deploy-tomcat\">\n        <delete verbose=\"on\" failonerror=\"false\" dir=\"${tomcat.home}/runtime\"/>\n        <delete verbose=\"on\" failonerror=\"false\" dir=\"${tomcat.home}/webapps/ROOT\"/>\n        <delete verbose=\"on\" failonerror=\"false\" file=\"${tomcat.home}/webapps/ROOT.war\"/>\n        <delete verbose=\"on\" failonerror=\"false\">\n            <fileset dir=\"${tomcat.home}/logs\" includes=\"*\"/>\n        </delete>\n        <copy file=\"moqui.war\" tofile=\"${tomcat.home}/webapps/ROOT.war\"/>\n    </target>\n\n    <target name=\"run\" description=\"Run Moqui Web server in dev/default mode with Embedded Winstone (run the executable war file)\">\n        <delete verbose=\"off\" failonerror=\"false\" dir=\"execwartmp\"/>\n        <java jar=\"moqui.war\" fork=\"true\">\n            <jvmarg value=\"-server\"/>\n            <jvmarg value=\"-Xmx256M\"/>\n            <jvmarg value=\"-Dmoqui.conf=${moqui.conf.dev}\"/>\n            <jvmarg value=\"-Dmoqui.runtime=${moqui.runtime}\"/>\n        </java>\n    </target>\n    <target name=\"run-production\" description=\"Run Moqui Web server in production mode\">\n        <delete verbose=\"off\" failonerror=\"false\" dir=\"execwartmp\"/>\n        <java jar=\"moqui.war\" fork=\"true\">\n            <jvmarg value=\"-server\"/>\n            <jvmarg value=\"-Xms512M\"/>\n            <jvmarg value=\"-Xmx512M\"/>\n            <jvmarg value=\"-Dmoqui.conf=${moqui.conf.production}\"/>\n            <jvmarg value=\"-Dmoqui.runtime=${moqui.runtime}\"/>\n        </java>\n    </target>\n    <target name=\"load\" description=\"Run Moqui data loader (run the executable war file with -load)\">\n        <java jar=\"moqui.war\" fork=\"true\">\n            <jvmarg value=\"-server\"/>\n            <jvmarg value=\"-Xmx256M\"/>\n            <jvmarg value=\"-Dmoqui.conf=${moqui.conf.dev}\"/>\n            <jvmarg value=\"-Dmoqui.runtime=${moqui.runtime}\"/>\n            <arg value=\"-load\"/>\n        </java>\n    </target>\n    <target name=\"load-production\" description=\"Run Moqui data loader in production mode\">\n        <java jar=\"moqui.war\" fork=\"true\">\n            <jvmarg value=\"-server\"/>\n            <jvmarg value=\"-Xmx256M\"/>\n            <jvmarg value=\"-Dmoqui.conf=${moqui.conf.production}\"/>\n            <jvmarg value=\"-Dmoqui.runtime=${moqui.runtime}\"/>\n            <arg value=\"-load\"/>\n        </java>\n    </target>\n</project>\n"
  },
  {
    "path": "docker/README.md",
    "content": "# Moqui On Docker\n\nThis directory contains everything needed to deploy moqui on docker.\n\n## Prerequisites\n\n- Docker.\n- Docker Compose Plugin.\n- Java 21.\n- Convenience scripts require bash.\n\n## Deployment Instructions\n\nTo deploy moqui on docker with all necessary services, follow below:\n\n- Choose a docker compose file in `docker/`. For example to deploy moqui on\n  postgres you can choose moqui-postgres-compose.yml.\n- Find and download a suitable JDBC driver for the target database, download its\n  jar file and place it in `runtime/lib`.\n- Generate moqui war file `./gradlew build`\n- Get into docker folder `cd docker`\n- Build chosen compose file, e.g. `./build-compose-up.sh moqui-postgres-compose.yml`\n\nThis last step would build the \"moqui\" image and deploy all services. You can\nconfirm by accessing the system on http://localhost\n\nFor a more secure and complete deployment, it is recommended to carefully review\nthe compose files and adjust as needed, including changing credentials and other\nsettings such as setting the host names, configuring for letsencrypt, etc ...\n\n## Compose Files\n\nThere are multiple compose files offered providing different services:\n\n- moqui-acme-postgres.yml: Moqui, nginx, postgres and automatically issues SSL\n  certificates from letsencrypt. Requires configuring variables including\n  `VIRTUAL_HOST` and `LETSENCRYPT_HOST` and postgres driver.\n- moqui-postgres-compose.yml: Moqui with postgres and nginx standard deployment.\n- moqui-mysql-compose.yml: Moqui with mysql and nginx standard deployment.\n- mysql-compose.yml: deploys all services with mysql except moqui itself. Useful\n  when deploying moqui elsewhere like on a servlet container.\n- postgres-compose.yml: Same as mysql-compose but replacing mysql with postgres\n- moqui-cluster1-compose.yml: Moqui with mysql. Designed to be deployed in a\n  hazelcast cluster for horizontal scaling. Requires preparing moqui with\n  the hazelcast component and mysql driver.\n\n## Helper Scripts\n\n- `build-compose-up.sh`: Given a certain compose file, build \"moqui\" and deploy\n  all services in the chosen yml file.\n- `clean.sh`: Clean the artifacts generated upon deployment including the\n  database, opensearch and runtime.\n- `compose-down.sh`: Tear down all services of a certain yml file\n- `compose-up.sh`: Deploy all services of a certain yml file. If the \"moqui\"\n  image exists in the yml file and it is not built, this script will fail, and\n  you should use the build-compose-up.sh instead.\n- `postgres_backup.sh`: Convenience script to create a database dump. Might need\n  adjusting the credentials.\n\n## Moqui Image\n\nThe actual image \"moqui\" is generated from the Dockerfile found in\n`docker/simple/Dockerfile`. All compose files depend on a \"moqui\" image\ngenerated by this file.\n\nNote: The deployment and tear down scripts can accept a container image name to\noverride the default name. For example, to use a hardened JDK image, a command\nlike the following can be used:\n\n`./build-compose-up.sh moqui-acme-postgres.yml .. eclipse-temurin:21-jdk-ubi10-minimal`\n"
  },
  {
    "path": "docker/build-compose-up.sh",
    "content": "#! /bin/bash\n\nif [[ ! $1 ]]; then\n  echo \"Usage: ./build-compose-up.sh <docker compose file> [<moqui directory like ..>] [<runtime image like eclipse-temurin:21-jdk>]\"\n  exit 1\nfi\n\nCOMP_FILE=\"${1}\"\nMOQUI_HOME=\"${2:-..}\"\nNAME_TAG=moqui\nRUNTIME_IMAGE=\"${3:-eclipse-temurin:21-jdk}\"\n\nif [ -f simple/docker-build.sh ]; then\n  cd simple\n  ./docker-build.sh ../.. $NAME_TAG $RUNTIME_IMAGE\n  # shellcheck disable=SC2103\n  cd ..\nfi\n\nif [ -f compose-up.sh ]; then\n  ./compose-up.sh $COMP_FILE $MOQUI_HOME $RUNTIME_IMAGE\nfi\n"
  },
  {
    "path": "docker/clean.sh",
    "content": "#! /bin/bash\n\nsearch_name=opensearch\nif [ -d runtime/opensearch/bin ]; then search_name=opensearch;\nelif [ -d runtime/elasticsearch/bin ]; then search_name=elasticsearch;\nfi\n\nrm -Rf runtime/\nrm -Rf runtime1/\nrm -Rf runtime2/\nrm -Rf db/\nrm -Rf $search_name/data/nodes\nrm -Rf $search_name/data/*.conf\nrm $search_name/logs/*.log\n\ndocker rm moqui-server\ndocker rm moqui-database\ndocker rm nginx-proxy\n"
  },
  {
    "path": "docker/compose-down.sh",
    "content": "#! /bin/bash\n\nif [[ ! $1 ]]; then\n  echo \"Usage: ./compose-down.sh <docker compose file>\"\n  exit 1\nfi\n\nCOMP_FILE=\"${1}\"\n\n# set the project name to 'moqui', network will be called 'moqui_default'\ndocker compose -f $COMP_FILE -p moqui down\n"
  },
  {
    "path": "docker/compose-up.sh",
    "content": "#! /bin/bash\n\nif [[ ! $1 ]]; then\n  echo \"Usage: ./compose-up.sh <docker compose file> [<moqui directory like ..>] [<runtime image like eclipse-temurin:21-jdk>]\"\n  exit 1\nfi\n\nCOMP_FILE=\"${1}\"\nMOQUI_HOME=\"${2:-..}\"\nNAME_TAG=moqui\nRUNTIME_IMAGE=\"${3:-eclipse-temurin:21-jdk}\"\n\n# Note: If you don't have access to your conf directory while running this:\n#   This will make it so that your docker/conf directory no longer has your configuration files in it.\n#      This is because when docker-compose provisions a volume on the host it applies the host's data before the image's data.\n#   - change docker compose's moqui-server conf volume path from ./runtime/conf to conf\n#   - add a top level volumes: tag with conf: below\n#   - remove the next block of if statements from this file and you should be good to go\nsearch_name=opensearch\nif [ -d runtime/opensearch/bin ]; then search_name=opensearch;\nelif [ -d runtime/elasticsearch/bin ]; then search_name=elasticsearch;\nfi\nif [ ! -e runtime ]; then mkdir runtime; fi\nif [ ! -e runtime/conf ]; then cp -R $MOQUI_HOME/runtime/conf runtime/; fi\nif [ ! -e runtime/lib ]; then cp -R $MOQUI_HOME/runtime/lib runtime/; fi\nif [ ! -e runtime/classes ]; then cp -R $MOQUI_HOME/runtime/classes runtime/; fi\nif [ ! -e runtime/log ]; then cp -R $MOQUI_HOME/runtime/log runtime/; fi\nif [ ! -e runtime/txlog ]; then cp -R $MOQUI_HOME/runtime/txlog runtime/; fi\nif [ ! -e runtime/db ]; then cp -R $MOQUI_HOME/runtime/db runtime/; fi\nif [ ! -e runtime/$search_name ]; then cp -R $MOQUI_HOME/runtime/$search_name runtime/; fi\n\n# set the project name to 'moqui', network will be called 'moqui_default'\ndocker compose -f $COMP_FILE -p moqui up -d\n"
  },
  {
    "path": "docker/elasticsearch/data/README",
    "content": "This directory must exist for mapping otherwise created as root in container and elasticsearch cannot access it.\n"
  },
  {
    "path": "docker/elasticsearch/moquiconfig/elasticsearch.yml",
    "content": "# ======================== Elasticsearch Configuration =========================\n#\n# NOTE: Elasticsearch comes with reasonable defaults for most settings.\n#       Before you set out to tweak and tune the configuration, make sure you\n#       understand what are you trying to accomplish and the consequences.\n#\n# The primary way of configuring a node is via this file. This template lists\n# the most important settings you may want to configure for a production cluster.\n#\n# Please see the documentation for further information on configuration options:\n# <http://www.elastic.co/guide/en/elasticsearch/reference/current/setup-configuration.html>\n#\n# ---------------------------------- Cluster -----------------------------------\n#\n# Use a descriptive name for your cluster:\n\ncluster.name: MoquiElasticSearch\n\n# ------------------------------------ Node ------------------------------------\n#\n# Use a descriptive name for the node:\n\n# NOTE: for cluster use auto generated\n# node.name: MoquiLocal\n\n# Add custom attributes to the node:\n#\n#node.attr.rack: r1\n\nnode.master: false\nnode.data: false\nnode.ingest: false\n\n# ----------------------------------- Paths ------------------------------------\n#\n# Path to directory where to store the data (separate multiple locations by comma):\n#\n#path.data: /path/to/data\n#\n# Path to log files:\n#\n#path.logs: /path/to/logs\n#\n# ----------------------------------- Memory -----------------------------------\n#\n# Lock the memory on startup:\n#\n#bootstrap.memory_lock: true\n#\n# Make sure that the heap size is set to about half the memory available\n# on the system and that the owner of the process is allowed to use this\n# limit.\n#\n# Elasticsearch performs poorly when the system is swapping the memory.\n#\n# ---------------------------------- Network -----------------------------------\n#\n# Set the bind address to a specific IP (IPv4 or IPv6):\n\n# By default use _local_ and _site_ for localhost or any local network including docker container virtual network\nnetwork.host:\n  - _site_\n  - _local_\n\n# transport.type: local\n# discovery.type: single-node\ndiscovery.type: zen\ndiscovery.zen.minimum_master_nodes: 1\n# use unicast discovery to find external elasticsearch server, multicast doesn't seem to work with docker bridge network\ndiscovery.zen.ping.unicast.hosts: elasticsearch\n\ntransport.host: 0.0.0.0\ntransport.tcp.port: 9300\n\n# CORS settings for local testing only\n# http.cors.enabled: true\n# http.cors.allow-origin: '*'\n\n# Set a port for HTTP (9200 is the default, or with no port specified looks at subsequent ports to find one open):\nhttp.port: 9200\n\n# For more information, see the documentation at:\n# <http://www.elastic.co/guide/en/elasticsearch/reference/current/modules-network.html>\n\n# --------------------------------- Discovery ----------------------------------\n#\n# Pass an initial list of hosts to perform discovery when new node is started:\n# The default list of hosts is [\"127.0.0.1\", \"[::1]\"]\n#\n#discovery.zen.ping.unicast.hosts: [\"host1\", \"host2\"]\n#\n# Prevent the \"split brain\" by configuring the majority of nodes (total number of nodes / 2 + 1):\n#\n#discovery.zen.minimum_master_nodes: 3\n#\n# For more information, see the documentation at:\n# <http://www.elastic.co/guide/en/elasticsearch/reference/current/modules-discovery.html>\n#\n# ---------------------------------- Gateway -----------------------------------\n#\n# Block initial recovery after a full cluster restart until N nodes are started:\n#\n#gateway.recover_after_nodes: 3\n#\n# For more information, see the documentation at:\n# <http://www.elastic.co/guide/en/elasticsearch/reference/current/modules-gateway.html>\n#\n# ---------------------------------- Various -----------------------------------\n#\n# Disable starting multiple nodes on a single system:\n#\n#node.max_local_storage_nodes: 1\n#\n# Require explicit names when deleting indices:\n#\n#action.destructive_requires_name: true\n\n# ---------------------------------- Script ------------------------------------\n\n# see: https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-scripting-security.html\n"
  },
  {
    "path": "docker/elasticsearch/moquiconfig/log4j2.properties",
    "content": "status = info\n\n# log action execution errors for easier debugging\nlogger.action.name = org.elasticsearch.action\nlogger.action.level = debug\n\nappender.console.type = Console\nappender.console.name = console\nappender.console.layout.type = PatternLayout\nappender.console.layout.pattern = [%d{ISO8601}][%-5p][%-25c{1.}] %marker%m%n\n\nappender.rolling.type = RollingFile\nappender.rolling.name = rolling\nappender.rolling.fileName = ${sys:es.logs}.log\nappender.rolling.layout.type = PatternLayout\nappender.rolling.layout.pattern = [%d{ISO8601}][%-5p][%-25c{1.}] %marker%.10000m%n\nappender.rolling.filePattern = ${sys:es.logs}-%d{yyyy-MM-dd}.log\nappender.rolling.policies.type = Policies\nappender.rolling.policies.time.type = TimeBasedTriggeringPolicy\nappender.rolling.policies.time.interval = 1\nappender.rolling.policies.time.modulate = true\n\nrootLogger.level = info\nrootLogger.appenderRef.console.ref = console\nrootLogger.appenderRef.rolling.ref = rolling\n\nappender.deprecation_rolling.type = RollingFile\nappender.deprecation_rolling.name = deprecation_rolling\nappender.deprecation_rolling.fileName = ${sys:es.logs}_deprecation.log\nappender.deprecation_rolling.layout.type = PatternLayout\nappender.deprecation_rolling.layout.pattern = [%d{ISO8601}][%-5p][%-25c{1.}] %marker%.10000m%n\nappender.deprecation_rolling.filePattern = ${sys:es.logs}_deprecation-%i.log.gz\nappender.deprecation_rolling.policies.type = Policies\nappender.deprecation_rolling.policies.size.type = SizeBasedTriggeringPolicy\nappender.deprecation_rolling.policies.size.size = 1GB\nappender.deprecation_rolling.strategy.type = DefaultRolloverStrategy\nappender.deprecation_rolling.strategy.max = 4\n\nlogger.deprecation.name = org.elasticsearch.deprecation\nlogger.deprecation.level = warn\nlogger.deprecation.appenderRef.deprecation_rolling.ref = deprecation_rolling\nlogger.deprecation.additivity = false\n\nappender.index_search_slowlog_rolling.type = RollingFile\nappender.index_search_slowlog_rolling.name = index_search_slowlog_rolling\nappender.index_search_slowlog_rolling.fileName = ${sys:es.logs}_index_search_slowlog.log\nappender.index_search_slowlog_rolling.layout.type = PatternLayout\nappender.index_search_slowlog_rolling.layout.pattern = [%d{ISO8601}][%-5p][%-25c] %marker%.10000m%n\nappender.index_search_slowlog_rolling.filePattern = ${sys:es.logs}_index_search_slowlog-%d{yyyy-MM-dd}.log\nappender.index_search_slowlog_rolling.policies.type = Policies\nappender.index_search_slowlog_rolling.policies.time.type = TimeBasedTriggeringPolicy\nappender.index_search_slowlog_rolling.policies.time.interval = 1\nappender.index_search_slowlog_rolling.policies.time.modulate = true\n\nlogger.index_search_slowlog_rolling.name = index.search.slowlog\nlogger.index_search_slowlog_rolling.level = trace\nlogger.index_search_slowlog_rolling.appenderRef.index_search_slowlog_rolling.ref = index_search_slowlog_rolling\nlogger.index_search_slowlog_rolling.additivity = false\n\nappender.index_indexing_slowlog_rolling.type = RollingFile\nappender.index_indexing_slowlog_rolling.name = index_indexing_slowlog_rolling\nappender.index_indexing_slowlog_rolling.fileName = ${sys:es.logs}_index_indexing_slowlog.log\nappender.index_indexing_slowlog_rolling.layout.type = PatternLayout\nappender.index_indexing_slowlog_rolling.layout.pattern = [%d{ISO8601}][%-5p][%-25c] %marker%.10000m%n\nappender.index_indexing_slowlog_rolling.filePattern = ${sys:es.logs}_index_indexing_slowlog-%d{yyyy-MM-dd}.log\nappender.index_indexing_slowlog_rolling.policies.type = Policies\nappender.index_indexing_slowlog_rolling.policies.time.type = TimeBasedTriggeringPolicy\nappender.index_indexing_slowlog_rolling.policies.time.interval = 1\nappender.index_indexing_slowlog_rolling.policies.time.modulate = true\n\nlogger.index_indexing_slowlog.name = index.indexing.slowlog.index\nlogger.index_indexing_slowlog.level = trace\nlogger.index_indexing_slowlog.appenderRef.index_indexing_slowlog_rolling.ref = index_indexing_slowlog_rolling\nlogger.index_indexing_slowlog.additivity = false\n"
  },
  {
    "path": "docker/kibana/kibana.yml",
    "content": "# Kibana is served by a back end server. This setting specifies the port to use.\nserver.port: 5601\n\n# Specifies the address to which the Kibana server will bind. IP addresses and host names are both valid values.\n# The default is 'localhost', which usually means remote machines will not be able to connect.\n# To allow connections from remote users, set this parameter to a non-loopback address.\nserver.host: \"kibana\"\n\n# Enables you to specify a path to mount Kibana at if you are running behind a proxy. This only affects\n# the URLs generated by Kibana, your proxy is expected to remove the basePath value before forwarding requests\n# to Kibana. This setting cannot end in a slash.\nserver.basePath: \"/kibana\"\n\n# The maximum payload size in bytes for incoming server requests.\n#server.maxPayloadBytes: 1048576\n\n# The Kibana server's name.  This is used for display purposes.\n#server.name: \"your-hostname\"\n\n# The URL of the Elasticsearch instance to use for all your queries.\nelasticsearch.url: \"http://moqui-server:9200\"\n\n# When this setting's value is true Kibana uses the hostname specified in the server.host\n# setting. When the value of this setting is false, Kibana uses the hostname of the host\n# that connects to this Kibana instance.\n#elasticsearch.preserveHost: true\n\n# Kibana uses an index in Elasticsearch to store saved searches, visualizations and\n# dashboards. Kibana creates a new index if the index doesn't already exist.\n#kibana.index: \".kibana\"\n\n# The default application to load.\n#kibana.defaultAppId: \"discover\"\n\n# If your Elasticsearch is protected with basic authentication, these settings provide\n# the username and password that the Kibana server uses to perform maintenance on the Kibana\n# index at startup. Your Kibana users still need to authenticate with Elasticsearch, which\n# is proxied through the Kibana server.\n#elasticsearch.username: \"user\"\n#elasticsearch.password: \"pass\"\n\n# Enables SSL and paths to the PEM-format SSL certificate and SSL key files, respectively.\n# These settings enable SSL for outgoing requests from the Kibana server to the browser.\n#server.ssl.enabled: false\n#server.ssl.certificate: /path/to/your/server.crt\n#server.ssl.key: /path/to/your/server.key\n\n# Optional settings that provide the paths to the PEM-format SSL certificate and key files.\n# These files validate that your Elasticsearch backend uses the same key files.\n#elasticsearch.ssl.certificate: /path/to/your/client.crt\n#elasticsearch.ssl.key: /path/to/your/client.key\n\n# Optional setting that enables you to specify a path to the PEM file for the certificate\n# authority for your Elasticsearch instance.\n#elasticsearch.ssl.certificateAuthorities: [ \"/path/to/your/CA.pem\" ]\n\n# To disregard the validity of SSL certificates, change this setting's value to 'none'.\n#elasticsearch.ssl.verificationMode: full\n\n# Time in milliseconds to wait for Elasticsearch to respond to pings. Defaults to the value of\n# the elasticsearch.requestTimeout setting.\n#elasticsearch.pingTimeout: 1500\n\n# Time in milliseconds to wait for responses from the back end or Elasticsearch. This value\n# must be a positive integer.\n#elasticsearch.requestTimeout: 30000\n\n# List of Kibana client-side headers to send to Elasticsearch. To send *no* client-side\n# headers, set this value to [] (an empty list).\n#elasticsearch.requestHeadersWhitelist: [ authorization ]\n\n# Header names and values that are sent to Elasticsearch. Any custom headers cannot be overwritten\n# by client-side headers, regardless of the elasticsearch.requestHeadersWhitelist configuration.\n#elasticsearch.customHeaders: {}\n\n# Time in milliseconds for Elasticsearch to wait for responses from shards. Set to 0 to disable.\n#elasticsearch.shardTimeout: 0\n\n# Time in milliseconds to wait for Elasticsearch at Kibana startup before retrying.\n#elasticsearch.startupTimeout: 5000\n\n# Specifies the path where Kibana creates the process ID file.\n#pid.file: /var/run/kibana.pid\n\n# Enables you specify a file where Kibana stores log output.\n#logging.dest: stdout\n\n# Set the value of this setting to true to suppress all logging output.\n#logging.silent: false\n\n# Set the value of this setting to true to suppress all logging output other than error messages.\n#logging.quiet: false\n\n# Set the value of this setting to true to log all events, including system usage information\n# and all requests.\n#logging.verbose: false\n\n# Set the interval in milliseconds to sample system and process performance\n# metrics. Minimum is 100ms. Defaults to 5000.\n#ops.interval: 5000\n\n# The default locale. This locale can be used in certain circumstances to substitute any missing\n# translations.\n#i18n.defaultLocale: \"en\"\n"
  },
  {
    "path": "docker/moqui-acme-postgres.yml",
    "content": "# A Docker Compose application with Moqui, Postgres, OpenSearch, OpenSearch Dashboards, and virtual hosting through\n# nginx-proxy supporting multiple moqui instances on different hostnames.\n\n# Run with something like this for detached mode:\n# $ docker compose -f moqui-postgres-compose.yml -p moqui up -d\n# Or to copy runtime directories for mounted volumes, set default settings, etc use something like this:\n# $ ./compose-run.sh moqui-postgres-compose.yml\n# This sets the project/app name to 'moqui' and the network will be 'moqui_default', to be used by external moqui containers\n\n# Test locally by adding the virtual host to /etc/hosts or with something like:\n# $ curl -H \"Host: moqui.local\" localhost/Login\n\n# To run an additional instance of moqui run something like this (but with\n# many more arguments for volume mapping, db setup, etc):\n# $ docker run -e VIRTUAL_HOST=moqui2.local --name moqui2_local --network moqui_default moqui\n\n# To import data from the docker host using port 5432 mapped for 127.0.0.1 only use something like this:\n# $ psql -h 127.0.0.1 -p 5432 -U moqui -W moqui < pg-dump.sql\n\nservices:\n  nginx-proxy:\n    # For documentation on SSL and other settings see:\n    # https://github.com/nginxproxy/nginx-proxy\n    image: nginxproxy/nginx-proxy\n    container_name: nginx-proxy\n    restart: always\n    ports:\n      - 80:80\n      - 443:443\n    labels:\n      com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy: \"true\"\n    volumes:\n      - /var/run/docker.sock:/tmp/docker.sock:ro\n      - /etc/localtime:/etc/localtime:ro\n      # note: .crt, .key, and .dhparam.pem files start with the domain name in VIRTUAL_HOST (ie 'acetousk.com.*') or use CERT_NAME env var\n      - ./certs:/etc/nginx/certs\n      - ./nginx/conf.d:/etc/nginx/conf.d\n      - ./nginx/vhost.d:/etc/nginx/vhost.d\n      - ./nginx/html:/usr/share/nginx/html\n    environment:\n      # change this for the default host to use when accessing directly by IP, etc\n      - DEFAULT_HOST=moqui.local\n      # use SSL_POLICY to disable TLSv1.0, etc in nginx-proxy\n      - SSL_POLICY=AWS-TLS-1-1-2017-01\n    networks:\n      - proxy-tier\n\n  acme-companion:\n    image: nginxproxy/acme-companion\n    container_name: acme-companion\n    restart: always\n    volumes:\n      - /var/run/docker.sock:/var/run/docker.sock:ro\n      - /etc/localtime:/etc/localtime:ro\n      - ./certs:/etc/nginx/certs\n      - ./nginx/conf.d:/etc/nginx/conf.d\n      - ./nginx/vhost.d:/etc/nginx/vhost.d\n      - ./nginx/html:/usr/share/nginx/html\n      - ./acme.sh:/etc/acme.sh\n    networks:\n      - proxy-tier\n    environment:\n      # TODO: For production change this to your email\n      - DEFAULT_EMAIL=mail@yourdomain.tld\n      # TODO: For production change this to false\n      - LETSENCRYPT_TEST=true\n    depends_on:\n      - nginx-proxy\n\n  moqui-server:\n    image: moqui\n    container_name: moqui-server\n    command: conf=conf/MoquiProductionConf.xml port=80 no-run-es\n    restart: always\n    links:\n      - moqui-database\n      - moqui-search\n    volumes:\n      - /etc/localtime:/etc/localtime:ro\n      - ./runtime/conf:/opt/moqui/runtime/conf\n      - ./runtime/lib:/opt/moqui/runtime/lib\n      - ./runtime/classes:/opt/moqui/runtime/classes\n      - ./runtime/ecomponent:/opt/moqui/runtime/ecomponent\n      - ./runtime/log:/opt/moqui/runtime/log\n      - ./runtime/txlog:/opt/moqui/runtime/txlog\n      - ./runtime/sessions:/opt/moqui/runtime/sessions\n      - ./runtime/db:/opt/moqui/runtime/db\n      - ./runtime/opensearch:/opt/moqui/runtime/opensearch\n    environment:\n      - \"JAVA_TOOL_OPTIONS=-Xms1024m -Xmx1024m\"\n      - instance_purpose=production\n      - entity_ds_db_conf=postgres\n      - entity_ds_host=moqui-database\n      - entity_ds_port=5432\n      - entity_ds_database=moqui\n      - entity_ds_schema=public\n      - entity_ds_user=moqui\n      - entity_ds_password='MOQUI_CHANGE_ME!!!'\n      - entity_ds_crypt_pass='DEFAULT_CHANGE_ME!!!'\n      # configuration for ElasticFacade.ElasticClient, make sure the old moqui-elasticsearch component is NOT included in the Moqui build\n      - elasticsearch_url=https://moqui-search:9200\n      # prefix for index names, use something distinct and not 'moqui_' or 'mantle_' which match the beginning of OOTB index names\n      - elasticsearch_index_prefix=default_\n      - elasticsearch_user=admin\n      - elasticsearch_password=MoquiElasticChangeMe@2026\n      # CHANGE ME - note that VIRTUAL_HOST is for nginx-proxy so it picks up this container as one it should reverse proxy\n      # this can be a comma separate list of hosts like 'example.com,www.example.com'\n      - VIRTUAL_HOST=moqui.local\n      - LETSENCRYPT_HOST=moqui.local\n      # moqui will accept traffic from other hosts but these are the values used for URL writing when specified:\n      # - webapp_http_host=moqui.local\n      - webapp_http_port=80\n      - webapp_https_port=443\n      - webapp_https_enabled=true\n      # nginx-proxy populates X-Real-IP with remote_addr by default, better option for outer proxy than X-Forwarded-For which defaults to proxy_add_x_forwarded_for\n      - webapp_client_ip_header=X-Real-IP\n      - default_locale=en_US\n      - default_time_zone=UTC\n    networks:\n      - proxy-tier\n      - default\n\n  moqui-database:\n    image: postgres:18.1\n    container_name: moqui-database\n    restart: always\n    ports:\n      # change this as needed to bind to any address or even comment to not expose port outside containers\n      - 127.0.0.1:5432:5432\n    volumes:\n      - /etc/localtime:/etc/localtime:ro\n      # edit these as needed to map configuration and data storage\n      - ./db/postgres:/var/lib/postgresql\n    environment:\n      - POSTGRES_DB=moqui\n      - POSTGRES_DB_SCHEMA=public\n      - POSTGRES_USER=moqui\n      - POSTGRES_PASSWORD='MOQUI_CHANGE_ME!!!'\n      # PGDATA, POSTGRES_INITDB_ARGS\n    networks:\n      default:\n\n  moqui-search:\n    image: opensearchproject/opensearch:3.4.0\n    container_name: moqui-search\n    restart: always\n    ports:\n      # change this as needed to bind to any address or even comment to not expose port outside containers\n      - 127.0.0.1:9200:9200\n      - 127.0.0.1:9300:9300\n    volumes:\n      - /etc/localtime:/etc/localtime:ro\n      # edit these as needed to map configuration and data storage\n      - ./opensearch/data/nodes:/usr/share/opensearch/data/nodes\n      # - ./opensearch/config/opensearch.yml:/usr/share/opensearch/config/opensearch.yml\n      # - ./opensearch/logs:/usr/share/opensearch/logs\n    environment:\n      - \"OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m\"\n      - OPENSEARCH_INITIAL_ADMIN_PASSWORD=MoquiElasticChangeMe@2026\n      - discovery.type=single-node\n      - network.host=_site_\n    ulimits:\n      memlock:\n        soft: -1\n        hard: -1\n      nofile:\n        soft: 65536\n        hard: 65536\n    networks:\n      proxy-tier:\n\n  opensearch-dashboards:\n    image: opensearchproject/opensearch-dashboards:3.4.0\n    container_name: opensearch-dashboards\n    volumes:\n      - /etc/localtime:/etc/localtime:ro\n    links:\n      - moqui-search\n    ports:\n      - 127.0.0.1:5601:5601\n    environment:\n      OPENSEARCH_HOSTS: '[\"https://moqui-search:9200\"]'\n    networks:\n      default:\n      proxy-tier:\n\nnetworks:\n  proxy-tier:\n"
  },
  {
    "path": "docker/moqui-cluster1-compose.yml",
    "content": "# NOTE: ElasticSearch uses odd user and directory setup for externally mapped data, etc directories, see:\n# https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html\n\n# Make sure vm.max_map_count=262144 is set in /etc/sysctl.conf on host (on live system run 'sudo sysctl -w vm.max_map_count=262144')\n\n# A Docker Compose application with 2 moqui instances, mysql, elasticsearch, kibana, and virtual hosting through\n# nginx-proxy supporting multiple moqui instances on different hosts.\n\n# The 'moqui' image should be prepared with the MySQL JDBC jar and the moqui-hazelcast and moqui-elasticsearch components.\n\n# This does virtual hosting instead of load balancing so that each moqui instance can be accessed consistently (moqui1.local, moqui2.local).\n\n# Run with something like this for detached mode:\n# $ docker-compose -f moqui-cluster1-compose.yml -p moqui up -d\n# Or to copy runtime directories for mounted volumes, set default settings, etc use something like this:\n# $ ./compose-run.sh moqui-cluster1-compose.yml\n# This sets the project/app name to 'moqui' and the network will be 'moqui_default', to be used by external moqui containers\n\n# Test locally by adding the virtual host to /etc/hosts or with something like:\n# $ curl -H \"Host: moqui1.local\" localhost/Login\n\nservices:\n  nginx-proxy:\n    # For documentation on SSL and other settings see:\n    # https://github.com/nginx-proxy/nginx-proxy\n    image: nginxproxy/nginx-proxy\n    container_name: nginx-proxy\n    restart: always\n    ports:\n      - 80:80\n      - 443:443\n    volumes:\n      - /var/run/docker.sock:/tmp/docker.sock:ro\n      # note: .crt, .key, and .dhparam.pem files start with the domain name in VIRTUAL_HOST (ie 'moqui.local.*') or use CERT_NAME env var\n      - ./certs:/etc/nginx/certs\n      - ./nginx/my_proxy.conf:/etc/nginx/conf.d/my_proxy.conf\n    environment:\n      # change this for the default host to use when accessing directly by IP, etc\n      - DEFAULT_HOST=moqui1.local\n      # use SSL_POLICY to disable TLSv1.0, etc in nginx-proxy\n      - SSL_POLICY=AWS-TLS-1-1-2017-01\n\n  moqui-server1:\n    image: moqui\n    container_name: moqui-server1\n    command: conf=conf/MoquiProductionConf.xml port=80\n    restart: always\n    links:\n      - moqui-database\n      - moqui-search\n    volumes:\n      - ./runtime/conf:/opt/moqui/runtime/conf\n      - ./runtime/lib:/opt/moqui/runtime/lib\n      - ./runtime/classes:/opt/moqui/runtime/classes\n      - ./runtime/ecomponent:/opt/moqui/runtime/ecomponent\n      - ./runtime/log:/opt/moqui/runtime/log\n      - ./runtime/txlog:/opt/moqui/runtime/txlog\n      - ./runtime/sessions:/opt/moqui/runtime/sessions\n      - ./runtime/db:/opt/moqui/runtime/db\n      - ./runtime/opensearch:/opt/moqui/runtime/opensearch\n    environment:\n      - \"JAVA_TOOL_OPTIONS=-Xms1024m -Xmx1024m\"\n      - instance_purpose=production\n      - entity_ds_db_conf=mysql8\n      - entity_ds_host=moqui-database\n      - entity_ds_port=3306\n      - entity_ds_database=moqui\n      # NOTE: using root user because for TX recovery MySQL requires the 'XA_RECOVER_ADMIN' and in version 8 that must be granted explicitly\n      - entity_ds_user=root\n      - entity_ds_password=moquiroot\n      - entity_ds_crypt_pass='DEFAULT_CHANGE_ME!!!'\n      # configuration for ElasticFacade.ElasticClient, make sure the old moqui-elasticsearch component is NOT included in the Moqui build\n      - elasticsearch_url=https://moqui-search:9200\n      # prefix for index names, use something distinct and not 'moqui_' or 'mantle_' which match the beginning of OOTB index names\n      - elasticsearch_index_prefix=default_\n      - elasticsearch_user=admin\n      - elasticsearch_password=MoquiElasticChangeMe@2026\n      # settings for kibana proxy\n      - kibana_host=opensearch-dashboards\n      # CHANGE ME - note that VIRTUAL_HOST is for nginx-proxy so it picks up this container as one it should reverse proxy\n      # this can be a comma separate list of hosts like 'example.com,www.example.com'\n      - VIRTUAL_HOST=moqui1.local\n      # moqui will accept traffic from other hosts but these are the values used for URL writing when specified:\n      - webapp_http_host=moqui1.local\n      - webapp_http_port=80\n      - webapp_https_port=443\n      - webapp_https_enabled=true\n      # nginx-proxy populates X-Real-IP with remote_addr by default, better option for outer proxy than X-Forwarded-For which defaults to proxy_add_x_forwarded_for\n      - webapp_client_ip_header=X-Real-IP\n      - default_locale=en_US\n      - default_time_zone=UTC\n      # hazelcast multicast setup\n      - hazelcast_multicast_enabled=true\n      - hazelcast_multicast_group=224.2.2.3\n      - hazelcast_multicast_port=54327\n      - hazelcast_group_name=test\n      - hazelcast_group_password=test-pass\n\n  moqui-server2:\n    image: moqui\n    container_name: moqui-server2\n    command: conf=conf/MoquiProductionConf.xml port=80\n    restart: always\n    links:\n      - moqui-database\n      - moqui-search\n    volumes:\n      - ./runtime/conf:/opt/moqui/runtime/conf\n      - ./runtime/lib:/opt/moqui/runtime/lib\n      - ./runtime/classes:/opt/moqui/runtime/classes\n      - ./runtime/ecomponent:/opt/moqui/runtime/ecomponent\n      - ./runtime/log:/opt/moqui/runtime/log\n      - ./runtime/txlog:/opt/moqui/runtime/txlog\n      - ./runtime/sessions:/opt/moqui/runtime/sessions\n      # this one isn't needed when not using H2/etc: - ./runtime/db:/opt/moqui/runtime/db\n    environment:\n      - \"JAVA_TOOL_OPTIONS=-Xms1024m -Xmx1024m\"\n      - instance_purpose=production\n      - entity_ds_db_conf=mysql8\n      - entity_ds_host=moqui-database\n      - entity_ds_port=3306\n      - entity_ds_database=moqui\n      # NOTE: using root user because for TX recovery MySQL requires the 'XA_RECOVER_ADMIN' and in version 8 that must be granted explicitly\n      - entity_ds_user=root\n      - entity_ds_password=moquiroot\n      - entity_ds_crypt_pass='DEFAULT_CHANGE_ME!!!'\n      # configuration for ElasticFacade.ElasticClient, make sure the old moqui-elasticsearch component is NOT included in the Moqui build\n      - elasticsearch_url=https://moqui-search:9200\n      # prefix for index names, use something distinct and not 'moqui_' or 'mantle_' which match the beginning of OOTB index names\n      - elasticsearch_index_prefix=default_\n      - elasticsearch_user=admin\n      - elasticsearch_password=MoquiElasticChangeMe@2026\n      # settings for kibana proxy\n      - kibana_host=opensearch-dashboards\n      # CHANGE ME - note that VIRTUAL_HOST is for nginx-proxy so it picks up this container as one it should reverse proxy\n      # this can be a comma separate list of hosts like 'example.com,www.example.com'\n      - VIRTUAL_HOST=moqui2.local\n      # moqui will accept traffic from other hosts but these are the values used for URL writing when specified:\n      - webapp_http_host=moqui2.local\n      - webapp_http_port=80\n      - webapp_https_port=443\n      - webapp_https_enabled=true\n      # nginx-proxy populates X-Real-IP with remote_addr by default, better option for outer proxy than X-Forwarded-For which defaults to proxy_add_x_forwarded_for\n      - webapp_client_ip_header=X-Real-IP\n      - default_locale=en_US\n      - default_time_zone=UTC\n      # hazelcast multicast setup\n      - hazelcast_multicast_enabled=true\n      - hazelcast_multicast_group=224.2.2.3\n      - hazelcast_multicast_port=54327\n      - hazelcast_group_name=test\n      - hazelcast_group_password=test-pass\n\n  moqui-database:\n    image: mysql:9.5\n    container_name: moqui-database\n    restart: always\n    ports:\n     # change this as needed to bind to any address or even comment to not expose port outside containers\n     - 127.0.0.1:3306:3306\n    volumes:\n     # edit these as needed to map configuration and data storage\n     - ./db/mysql/data:/var/lib/mysql\n     # - /my/mysql/conf.d:/etc/mysql/conf.d\n    environment:\n     - MYSQL_ROOT_PASSWORD=moquiroot\n     - MYSQL_DATABASE=moqui\n     - MYSQL_USER=moqui\n     - MYSQL_PASSWORD=moqui\n\n  moqui-search:\n    image: opensearchproject/opensearch:3.4.0\n    container_name: moqui-search\n    restart: always\n    ports:\n      # change this as needed to bind to any address or even comment to not expose port outside containers\n      - 127.0.0.1:9200:9200\n      - 127.0.0.1:9300:9300\n    volumes:\n      # edit these as needed to map configuration and data storage\n      - ./opensearch/data:/usr/share/opensearch/data\n      # - ./opensearch/config/opensearch.yml:/usr/share/opensearch/config/opensearch.yml\n      # - ./opensearch/logs:/usr/share/opensearch/logs\n    environment:\n      - \"OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m\"\n      - OPENSEARCH_INITIAL_ADMIN_PASSWORD=MoquiElasticChangeMe@2026\n      - discovery.type=single-node\n      - network.host=_site_\n    ulimits:\n      memlock:\n        soft: -1\n        hard: -1\n      nofile:\n        soft: 65536\n        hard: 65536\n\n  opensearch-dashboards:\n    image: opensearchproject/opensearch-dashboards:3.4.0\n    container_name: opensearch-dashboards\n    links:\n      - moqui-search\n    ports:\n      - 5601:5601\n    environment:\n      OPENSEARCH_HOSTS: '[\"https://moqui-search:9200\"]'\n"
  },
  {
    "path": "docker/moqui-mysql-compose.yml",
    "content": "# A Docker Compose application with Moqui, MySQL, OpenSearch, OpenSearch Dashboards, and virtual hosting through\n# nginx-proxy supporting multiple moqui instances on different hostnames.\n\n# Run with something like this for detached mode:\n# $ docker compose -f moqui-mysql-compose.yml -p moqui up -d\n# Or to copy runtime directories for mounted volumes, set default settings, etc use something like this:\n# $ ./compose-run.sh moqui-mysql-compose.yml\n# This sets the project/app name to 'moqui' and the network will be 'moqui_default', to be used by external moqui containers\n\n# Test locally by adding the virtual host to /etc/hosts or with something like:\n# $ curl -H \"Host: moqui.local\" localhost/Login\n\n# To run an additional instance of moqui run something like this (but with\n# many more arguments for volume mapping, db setup, etc):\n# $ docker run -e VIRTUAL_HOST=moqui2.local --name moqui2_local --network moqui_default moqui\n\n# To import data from the docker host using port 3306 mapped for 127.0.0.1 only use something like this:\n# $ mysql -p -u root -h 127.0.0.1 moqui < mysql-export.sql\n\nservices:\n  nginx-proxy:\n    # For documentation on SSL and other settings see:\n    # https://github.com/nginxproxy/nginx-proxy\n    image: nginxproxy/nginx-proxy\n    container_name: nginx-proxy\n    restart: always\n    ports:\n      - 80:80\n      - 443:443\n    labels:\n      com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy: \"true\"\n    volumes:\n      - /var/run/docker.sock:/tmp/docker.sock:ro\n      - /etc/localtime:/etc/localtime:ro\n      # note: .crt, .key, and .dhparam.pem files start with the domain name in VIRTUAL_HOST (ie 'moqui.local.*') or use CERT_NAME env var\n      - ./certs:/etc/nginx/certs\n      - ./nginx/conf.d:/etc/nginx/conf.d\n      - ./nginx/vhost.d:/etc/nginx/vhost.d\n      - ./nginx/html:/usr/share/nginx/html\n    environment:\n      # change this for the default host to use when accessing directly by IP, etc\n      - DEFAULT_HOST=moqui.local\n      # use SSL_POLICY to disable TLSv1.0, etc in nginx-proxy\n      - SSL_POLICY=AWS-TLS-1-1-2017-01\n\n  moqui-server:\n    image: moqui\n    container_name: moqui-server\n    command: conf=conf/MoquiProductionConf.xml port=80\n    restart: always\n    links:\n      - moqui-database\n      - moqui-search\n    volumes:\n      - ./runtime/conf:/opt/moqui/runtime/conf\n      - ./runtime/lib:/opt/moqui/runtime/lib\n      - ./runtime/classes:/opt/moqui/runtime/classes\n      - ./runtime/ecomponent:/opt/moqui/runtime/ecomponent\n      - ./runtime/log:/opt/moqui/runtime/log\n      - ./runtime/txlog:/opt/moqui/runtime/txlog\n      - ./runtime/sessions:/opt/moqui/runtime/sessions\n      - ./runtime/db:/opt/moqui/runtime/db\n      - ./runtime/opensearch:/opt/moqui/runtime/opensearch\n    environment:\n      - \"JAVA_TOOL_OPTIONS=-Xms1024m -Xmx1024m\"\n      - instance_purpose=production\n      - entity_ds_db_conf=mysql8\n      - entity_ds_host=moqui-database\n      - entity_ds_port=3306\n      - entity_ds_database=moqui\n      # NOTE: using root user because for TX recovery MySQL requires the 'XA_RECOVER_ADMIN' and in version 8 that must be granted explicitly\n      - entity_ds_user=root\n      - entity_ds_password=moquiroot\n      - entity_ds_crypt_pass='DEFAULT_CHANGE_ME!!!'\n      # configuration for ElasticFacade.ElasticClient, make sure the old moqui-elasticsearch component is NOT included in the Moqui build\n      - elasticsearch_url=https://moqui-search:9200\n      # prefix for index names, use something distinct and not 'moqui_' or 'mantle_' which match the beginning of OOTB index names\n      - elasticsearch_index_prefix=default_\n      - elasticsearch_user=admin\n      - elasticsearch_password=MoquiElasticChangeMe@2026\n      # CHANGE ME - note that VIRTUAL_HOST is for nginx-proxy so it picks up this container as one it should reverse proxy\n      # this can be a comma separate list of hosts like 'example.com,www.example.com'\n      - VIRTUAL_HOST=moqui.local\n      # moqui will accept traffic from other hosts but these are the values used for URL writing when specified:\n      # - webapp_http_host=moqui.local\n      - webapp_http_port=80\n      - webapp_https_port=443\n      - webapp_https_enabled=true\n      # nginx-proxy populates X-Real-IP with remote_addr by default, better option for outer proxy than X-Forwarded-For which defaults to proxy_add_x_forwarded_for\n      - webapp_client_ip_header=X-Real-IP\n      - default_locale=en_US\n      - default_time_zone=UTC\n\n  moqui-database:\n    image: mysql:9.5\n    container_name: moqui-database\n    restart: always\n    ports:\n     # change this as needed to bind to any address or even comment to not expose port outside containers\n     - 127.0.0.1:3306:3306\n    volumes:\n     # edit these as needed to map configuration and data storage\n     - ./db/mysql/data:/var/lib/mysql\n     # - /my/mysql/conf.d:/etc/mysql/conf.d\n    environment:\n     - MYSQL_ROOT_PASSWORD=moquiroot\n     - MYSQL_DATABASE=moqui\n     - MYSQL_USER=moqui\n     - MYSQL_PASSWORD=moqui\n\n  moqui-search:\n    image: opensearchproject/opensearch:3.4.0\n    container_name: moqui-search\n    restart: always\n    ports:\n      # change this as needed to bind to any address or even comment to not expose port outside containers\n      - 127.0.0.1:9200:9200\n      - 127.0.0.1:9300:9300\n    volumes:\n      # edit these as needed to map configuration and data storage\n      - ./opensearch/data:/usr/share/opensearch/data\n      # - ./opensearch/config/opensearch.yml:/usr/share/opensearch/config/opensearch.yml\n      # - ./opensearch/logs:/usr/share/opensearch/logs\n    environment:\n      - \"OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m\"\n      - OPENSEARCH_INITIAL_ADMIN_PASSWORD=MoquiElasticChangeMe@2026\n      - discovery.type=single-node\n      - network.host=_site_\n    ulimits:\n      memlock:\n        soft: -1\n        hard: -1\n      nofile:\n        soft: 65536\n        hard: 65536\n\n  opensearch-dashboards:\n    image: opensearchproject/opensearch-dashboards:3.4.0\n    container_name: opensearch-dashboards\n    links:\n      - moqui-search\n    ports:\n      - 5601:5601\n    environment:\n      OPENSEARCH_HOSTS: '[\"https://moqui-search:9200\"]'\n"
  },
  {
    "path": "docker/moqui-postgres-compose.yml",
    "content": "# A Docker Compose application with Moqui, Postgres, OpenSearch, OpenSearch Dashboards, and virtual hosting through\n# nginx-proxy supporting multiple moqui instances on different hostnames.\n\n# Run with something like this for detached mode:\n# $ docker compose -f moqui-postgres-compose.yml -p moqui up -d\n# Or to copy runtime directories for mounted volumes, set default settings, etc use something like this:\n# $ ./compose-run.sh moqui-postgres-compose.yml\n# This sets the project/app name to 'moqui' and the network will be 'moqui_default', to be used by external moqui containers\n\n# Test locally by adding the virtual host to /etc/hosts or with something like:\n# $ curl -H \"Host: moqui.local\" localhost/Login\n\n# To run an additional instance of moqui run something like this (but with\n# many more arguments for volume mapping, db setup, etc):\n# $ docker run -e VIRTUAL_HOST=moqui2.local --name moqui2_local --network moqui_default moqui\n\n# To import data from the docker host using port 5432 mapped for 127.0.0.1 only use something like this:\n# $ psql -h 127.0.0.1 -p 5432 -U moqui -W moqui < pg-dump.sql\n\nservices:\n  nginx-proxy:\n    # For documentation on SSL and other settings see:\n    # https://github.com/nginx-proxy/nginx-proxy\n    image: nginxproxy/nginx-proxy\n    container_name: nginx-proxy\n    restart: always\n    ports:\n      - 80:80\n      - 443:443\n    volumes:\n      - /var/run/docker.sock:/tmp/docker.sock:ro\n      # note: .crt, .key, and .dhparam.pem files start with the domain name in VIRTUAL_HOST (ie 'moqui.local.*') or use CERT_NAME env var\n      - ./certs:/etc/nginx/certs\n      - ./nginx/my_proxy.conf:/etc/nginx/conf.d/my_proxy.conf\n    environment:\n      # change this for the default host to use when accessing directly by IP, etc\n      - DEFAULT_HOST=moqui.local\n      # use SSL_POLICY to disable TLSv1.0, etc in nginx-proxy\n      - SSL_POLICY=AWS-TLS-1-1-2017-01\n\n  moqui-server:\n    image: moqui\n    container_name: moqui-server\n    command: conf=conf/MoquiProductionConf.xml port=80\n    restart: always\n    links:\n     - moqui-database\n     - moqui-search\n    volumes:\n     - ./runtime/conf:/opt/moqui/runtime/conf\n     - ./runtime/lib:/opt/moqui/runtime/lib\n     - ./runtime/classes:/opt/moqui/runtime/classes\n     - ./runtime/ecomponent:/opt/moqui/runtime/ecomponent\n     - ./runtime/log:/opt/moqui/runtime/log\n     - ./runtime/txlog:/opt/moqui/runtime/txlog\n     - ./runtime/sessions:/opt/moqui/runtime/sessions\n     - ./runtime/db:/opt/moqui/runtime/db\n     - ./runtime/opensearch:/opt/moqui/runtime/opensearch\n    environment:\n     - \"JAVA_TOOL_OPTIONS=-Xms1024m -Xmx1024m\"\n     - instance_purpose=production\n     - entity_ds_db_conf=postgres\n     - entity_ds_host=moqui-database\n     - entity_ds_port=5432\n     - entity_ds_database=moqui\n     - entity_ds_schema=public\n     - entity_ds_user=moqui\n     - entity_ds_password=moqui\n     - entity_ds_crypt_pass='DEFAULT_CHANGE_ME!!!'\n     # configuration for ElasticFacade.ElasticClient, make sure the old moqui-elasticsearch component is NOT included in the Moqui build\n     - elasticsearch_url=https://moqui-search:9200\n     # prefix for index names, use something distinct and not 'moqui_' or 'mantle_' which match the beginning of OOTB index names\n     - elasticsearch_index_prefix=default_\n     - elasticsearch_user=admin\n     - elasticsearch_password=MoquiElasticChangeMe@2026\n     # CHANGE ME - note that VIRTUAL_HOST is for nginx-proxy so it picks up this container as one it should reverse proxy\n     # this can be a comma separate list of hosts like 'example.com,www.example.com'\n     - VIRTUAL_HOST=moqui.local\n     # moqui will accept traffic from other hosts but these are the values used for URL writing when specified:\n     # - webapp_http_host=moqui.local\n     - webapp_http_port=80\n     - webapp_https_port=443\n     - webapp_https_enabled=true\n     # nginx-proxy populates X-Real-IP with remote_addr by default, better option for outer proxy than X-Forwarded-For which defaults to proxy_add_x_forwarded_for\n     - webapp_client_ip_header=X-Real-IP\n     - default_locale=en_US\n     - default_time_zone=UTC\n\n  moqui-database:\n    image: postgres:18.1\n    container_name: moqui-database\n    restart: always\n    ports:\n     # change this as needed to bind to any address or even comment to not expose port outside containers\n     - 127.0.0.1:5432:5432\n    volumes:\n     # edit these as needed to map configuration and data storage\n     - ./db/postgres:/var/lib/postgresql\n    environment:\n     - POSTGRES_DB=moqui\n     - POSTGRES_DB_SCHEMA=public\n     - POSTGRES_USER=moqui\n     - POSTGRES_PASSWORD=moqui\n     # PGDATA, POSTGRES_INITDB_ARGS\n\n  moqui-search:\n    image: opensearchproject/opensearch:3.4.0\n    container_name: moqui-search\n    restart: always\n    ports:\n      # change this as needed to bind to any address or even comment to not expose port outside containers\n      - 127.0.0.1:9200:9200\n      - 127.0.0.1:9300:9300\n    volumes:\n      # edit these as needed to map configuration and data storage\n      - ./opensearch/data:/usr/share/opensearch/data\n      # - ./opensearch/config/opensearch.yml:/usr/share/opensearch/config/opensearch.yml\n      # - ./opensearch/logs:/usr/share/opensearch/logs\n    environment:\n      - \"OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m\"\n      - OPENSEARCH_INITIAL_ADMIN_PASSWORD=MoquiElasticChangeMe@2026\n      - discovery.type=single-node\n      - network.host=_site_\n    ulimits:\n      memlock:\n        soft: -1\n        hard: -1\n      nofile:\n        soft: 65536\n        hard: 65536\n\n  opensearch-dashboards:\n    image: opensearchproject/opensearch-dashboards:3.4.0\n    container_name: opensearch-dashboards\n    links:\n     - moqui-search\n    ports:\n      - 5601:5601\n    environment:\n      OPENSEARCH_HOSTS: '[\"https://moqui-search:9200\"]'\n"
  },
  {
    "path": "docker/moqui-run.sh",
    "content": "#! /bin/bash\n\necho \"Usage: moqui-run.sh [<moqui directory like ..>] [<group/name:tag>] [<runtime image like eclipse-temurin:21-jdk>]\"\necho\n\nMOQUI_HOME=\"${1:-..}\"\nNAME_TAG=\"${2:-moqui}\"\nRUNTIME_IMAGE=\"${3:-eclipse-temurin:21-jdk}\"\n\nsearch_name=opensearch\nif [ -d \"$MOQUI_HOME/runtime/opensearch/bin\" ]; then search_name=opensearch;\nelif [ -d \"$MOQUI_HOME/runtime/elasticsearch/bin\" ]; then search_name=elasticsearch;\nfi\n\nif [ -f simple/docker-build.sh ]; then\n  cd simple\n  ./docker-build.sh ../.. $NAME_TAG $RUNTIME_IMAGE\n  # shellcheck disable=SC2103\n  cd ..\nfi\n\nif [ ! -e runtime ]; then mkdir runtime; fi\nif [ ! -e runtime/conf ]; then cp -R $MOQUI_HOME/runtime/conf runtime/; fi\nif [ ! -e runtime/lib ]; then cp -R $MOQUI_HOME/runtime/lib runtime/; fi\nif [ ! -e runtime/classes ]; then cp -R $MOQUI_HOME/runtime/classes runtime/; fi\nif [ ! -e runtime/log ]; then cp -R $MOQUI_HOME/runtime/log runtime/; fi\nif [ ! -e runtime/txlog ]; then cp -R $MOQUI_HOME/runtime/txlog runtime/; fi\nif [ ! -e runtime/db ]; then cp -R $MOQUI_HOME/runtime/db runtime/; fi\nif [ ! -e runtime/$search_name ]; then cp -R $MOQUI_HOME/runtime/$search_name runtime/; fi\n\ndocker run --rm -p 80:80 -v $PWD/runtime/conf:/opt/moqui/runtime/conf -v $PWD/runtime/lib:/opt/moqui/runtime/lib \\\n    -v $PWD/runtime/classes:/opt/moqui/runtime/classes -v $PWD/runtime/log:/opt/moqui/runtime/log \\\n    -v $PWD/runtime/txlog:/opt/moqui/runtime/txlog -v $PWD/runtime/db:/opt/moqui/runtime/db \\\n    -v $PWD/runtime/$search_name:/opt/moqui/runtime/$search_name \\\n    --name moqui-server $NAME_TAG\n# docker run -d -p 80:80 $NAME_TAG\n# docker run --rm -p 80:80 $NAME_TAG\n"
  },
  {
    "path": "docker/mysql-compose.yml",
    "content": "# A Docker Compose application with Moqui, MySQL, OpenSearch, OpenSearch Dashboards, and virtual hosting through\n# nginx-proxy supporting multiple moqui instances on different hostnames.\n\n# Run with something like this for detached mode:\n# $ docker compose -f mysql-compose.yml -p moqui up -d\n# Or to copy runtime directories for mounted volumes, set default settings, etc use something like this:\n# $ ./compose-run.sh mysql-compose.yml\n# This sets the project/app name to 'moqui' and the network will be 'moqui_default', to be used by external moqui containers\n\n# Test locally by adding the virtual host to /etc/hosts or with something like:\n# $ curl -H \"Host: moqui.local\" localhost/Login\n\n# To run an additional instance of moqui run something like this (but with\n# many more arguments for volume mapping, db setup, etc):\n# $ docker run -e VIRTUAL_HOST=moqui2.local --name moqui2_local --network moqui_default moqui\n\n# To import data from the docker host using port 3306 mapped for 127.0.0.1 only use something like this:\n# $ mysql -p -u root -h 127.0.0.1 moqui < mysql-export.sql\n\nservices:\n  nginx-proxy:\n    # For documentation on SSL and other settings see:\n    # https://github.com/nginx-proxy/nginx-proxy\n    image: nginxproxy/nginx-proxy\n    container_name: nginx-proxy\n    restart: always\n    ports:\n      - 80:80\n      - 443:443\n    volumes:\n      - /var/run/docker.sock:/tmp/docker.sock:ro\n      # note: .crt, .key, and .dhparam.pem files start with the domain name in VIRTUAL_HOST (ie 'moqui.local.*') or use CERT_NAME env var\n      - ./certs:/etc/nginx/certs\n      - ./nginx/my_proxy.conf:/etc/nginx/conf.d/my_proxy.conf\n    environment:\n      # use SSL_POLICY to disable TLSv1.0, etc in nginx-proxy\n      - SSL_POLICY=AWS-TLS-1-1-2017-01\n\n  moqui-database:\n    image: mysql:9.5\n    container_name: moqui-database\n    restart: always\n    # expose the port for use outside other containers, needed for external management (like Moqui Instance Management)\n    ports:\n     - 127.0.0.1:3306:3306\n    # edit these as needed to map configuration and data storage\n    volumes:\n     - ./db/mysql/data:/var/lib/mysql\n     # - /my/mysql/conf.d:/etc/mysql/conf.d\n    environment:\n     - MYSQL_ROOT_PASSWORD=moquiroot\n     - MYSQL_DATABASE=moqui\n     - MYSQL_USER=moqui\n     - MYSQL_PASSWORD=moqui\n\n  moqui-search:\n    image: opensearchproject/opensearch:3.4.0\n    container_name: moqui-search\n    restart: always\n    ports:\n      # change this as needed to bind to any address or even comment to not expose port outside containers\n      - 127.0.0.1:9200:9200\n      - 127.0.0.1:9300:9300\n    volumes:\n      # edit these as needed to map configuration and data storage\n      - ./opensearch/data:/usr/share/opensearch/data\n      # - ./opensearch/config/opensearch.yml:/usr/share/opensearch/config/opensearch.yml\n      # - ./opensearch/logs:/usr/share/opensearch/logs\n    environment:\n      - \"OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m\"\n      - OPENSEARCH_INITIAL_ADMIN_PASSWORD=MoquiElasticChangeMe@2026\n      - discovery.type=single-node\n      - network.host=_site_\n    ulimits:\n      memlock:\n        soft: -1\n        hard: -1\n      nofile:\n        soft: 65536\n        hard: 65536\n\n  opensearch-dashboards:\n    image: opensearchproject/opensearch-dashboards:3.4.0\n    container_name: opensearch-dashboards\n    links:\n      - moqui-search\n    ports:\n      - 5601:5601\n    environment:\n      OPENSEARCH_HOSTS: '[\"https://moqui-search:9200\"]'\n"
  },
  {
    "path": "docker/nginx/my_proxy.conf",
    "content": "client_max_body_size 20M;\n\nproxy_connect_timeout 3600s;\nproxy_read_timeout 3600s;\nproxy_send_timeout 3600s;\n\n# NOTE: this always sets X-Forwarded-For to the remote_addr instead of appending it.\n# The default behavior in nginx-proxy is to use $proxy_add_x_forwarded_for which\n#     appends the current upstream IP to any in an existing X-Forwarded-For header\n# If nginx-proxy is used and it is there is another reverse proxy in front of it\n#     (such as CloudFlare, AWS CloudFront, etc) this needs to be changed back to\n#     $proxy_add_x_forwarded_for or it will always pick up the other reverse\n#     proxy's IP address instead of the client IP address!\n# In other words, only the first proxy a client hits should set X-Forwarded-For\n#     this way, all others should append.\nproxy_set_header X-Forwarded-For $remote_addr;\n\nunderscores_in_headers on;\n"
  },
  {
    "path": "docker/opensearch/data/nodes/README",
    "content": "This directory must exist for mapping otherwise created as root in container and opensearch cannot access it.\n"
  },
  {
    "path": "docker/postgres-compose.yml",
    "content": "# A Docker Compose application with Moqui, Postgres, OpenSearch, OpenSearch Dashboards, and virtual hosting through\n# nginx-proxy supporting multiple moqui instances on different hostnames.\n\n# Run with something like this for detached mode:\n# $ docker compose -f postgres-compose.yml -p moqui up -d\n# Or to copy runtime directories for mounted volumes, set default settings, etc use something like this:\n# $ ./compose-run.sh postgres-compose.yml\n# This sets the project/app name to 'moqui' and the network will be 'moqui_default', to be used by external moqui containers\n\n# Test locally by adding the virtual host to /etc/hosts or with something like:\n# $ curl -H \"Host: moqui.local\" localhost/Login\n\n# To run an additional instance of moqui run something like this (but with\n# many more arguments for volume mapping, db setup, etc):\n# $ docker run -e VIRTUAL_HOST=moqui2.local --name moqui2_local --network moqui_default moqui\n\n# To import data from the docker host using port 5432 mapped for 127.0.0.1 only use something like this:\n# $ psql -h 127.0.0.1 -p 5432 -U moqui -W moqui < pg-dump.sql\n\nservices:\n  nginx-proxy:\n    # For documentation on SSL and other settings see:\n    # https://github.com/nginx-proxy/nginx-proxy\n    image: nginxproxy/nginx-proxy\n    container_name: nginx-proxy\n    restart: always\n    ports:\n      - 80:80\n      - 443:443\n    volumes:\n      - /var/run/docker.sock:/tmp/docker.sock:ro\n      # note: .crt, .key, and .dhparam.pem files start with the domain name in VIRTUAL_HOST (ie 'moqui.local.*') or use CERT_NAME env var\n      - ./certs:/etc/nginx/certs\n      - ./nginx/my_proxy.conf:/etc/nginx/conf.d/my_proxy.conf\n    environment:\n      # use SSL_POLICY to disable TLSv1.0, etc in nginx-proxy\n      - SSL_POLICY=AWS-TLS-1-1-2017-01\n\n  moqui-database:\n    image: postgres:18.1\n    container_name: moqui-database\n    restart: always\n    ports:\n      # change this as needed to bind to any address or even comment to not expose port outside containers\n      - 127.0.0.1:5432:5432\n    volumes:\n      # edit these as needed to map configuration and data storage\n      - ./db/postgres:/var/lib/postgresql\n    environment:\n      - POSTGRES_DB=moqui\n      - POSTGRES_DB_SCHEMA=public\n      - POSTGRES_USER=moqui\n      - POSTGRES_PASSWORD=moqui\n      # PGDATA, POSTGRES_INITDB_ARGS\n\n  moqui-search:\n    image: opensearchproject/opensearch:3.4.0\n    container_name: moqui-search\n    restart: always\n    ports:\n      # change this as needed to bind to any address or even comment to not expose port outside containers\n      - 127.0.0.1:9200:9200\n      - 127.0.0.1:9300:9300\n    volumes:\n      # edit these as needed to map configuration and data storage\n      - ./opensearch/data:/usr/share/opensearch/data\n      # - ./opensearch/config/opensearch.yml:/usr/share/opensearch/config/opensearch.yml\n      # - ./opensearch/logs:/usr/share/opensearch/logs\n    environment:\n      - \"OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m\"\n      - OPENSEARCH_INITIAL_ADMIN_PASSWORD=MoquiElasticChangeMe@2026\n      - discovery.type=single-node\n      - network.host=_site_\n    ulimits:\n      memlock:\n        soft: -1\n        hard: -1\n      nofile:\n        soft: 65536\n        hard: 65536\n\n  opensearch-dashboards:\n    image: opensearchproject/opensearch-dashboards:3.4.0\n    container_name: opensearch-dashboards\n    links:\n      - moqui-search\n    ports:\n      - 5601:5601\n    environment:\n      OPENSEARCH_HOSTS: '[\"https://moqui-search:9200\"]'\n"
  },
  {
    "path": "docker/postgres_backup.sh",
    "content": "#!/bin/bash\n\n# This is a simple script to do a rotating backup of PostgreSQL (default once per day, retain 30 days)\n# For a complete backup solution these backup files would be copied to a remote site, potentially with a different retention pattern\n\n# Database info\nuser=\"moqui\"\nhost=\"localhost\"\ndb_name=\"moqui\"\n# Other options\n# a full path from root should be used for backup_path or there will be issues running via crontab\nbackup_path=\"/opt/pgbackups\"\ndate=$(date +\"%Y%m%d\")\nbackup_file=$backup_path/$db_name-$date.sql.gz\n\n# for password for cron job one option is to use a .pgpass file in home directory, see: https://www.postgresql.org/docs/current/libpq-pgpass.html\n# each line in .pgpass should be like: hostname:port:database:username:password\n# for example: localhost:5432:moqui:moqui:CHANGEME\n# note that ~/.pgpass must have u=rw (0600) permission or less (or psql, pg_dump, etc will refuse to use it)\n\n# Remove file for same day if exists\nif [ -e $backup_file ]; then rm $backup_file; fi\n# Set default file permissions\numask 177\n# Dump database into SQL file\npg_dump -h $host -p 5432 -U $user -w $db_name | gzip > $backup_file\n# Remove all files not within 7 days, most recent per month for 6 months, or most recent of the year\necho \"removing:\"\nls \"$backup_path\"/moqui-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].sql.gz | awk -v now_epoch=\"$(date +%s)\" '\n{\n  date_string = substr($0, index($0,\"-\")+1, 8)\n  command = \"date -d \\\"\" date_string \"\\\" +%s\"\n  command | getline file_epoch\n  close(command)\n\n  files[NR] = $0\n  file_epoch_by_name[$0] = file_epoch\n\n  year_month = substr(date_string,1,6)\n  year_only  = substr(date_string,1,4)\n\n  age_in_months = int((now_epoch - file_epoch) / 2592000)\n\n  if (age_in_months < 6 &&\n      (!(year_month in newest_month_epoch) ||\n        file_epoch > newest_month_epoch[year_month])) {\n    newest_month_epoch[year_month] = file_epoch\n    newest_month_file[year_month]  = $0\n  }\n\n  if (!(year_only in newest_year_epoch) ||\n       file_epoch > newest_year_epoch[year_only]) {\n    newest_year_epoch[year_only] = file_epoch\n    newest_year_file[year_only]  = $0\n  }\n}\nEND {\n  for (i in files) {\n    file_name = files[i]\n    file_epoch = file_epoch_by_name[file_name]\n\n    date_string = substr(file_name, index(file_name,\"-\")+1, 8)\n    year_month  = substr(date_string,1,6)\n    year_only   = substr(date_string,1,4)\n\n    if (now_epoch - file_epoch <= 7*86400) continue\n    if (file_name == newest_month_file[year_month]) continue\n    if (file_name == newest_year_file[year_only]) continue\n\n    printf \"%s\\0\", file_name\n  }\n}' |\nxargs -0 --no-run-if-empty rm -v\n\n# update cloned test instance database using backup file from production/main database\n# docker stop moqui-test\n# dropdb -h localhost -p 5432 -U moqui -w moqui-test\n# createdb -h localhost -p 5432 -U moqui -w moqui-test\n# gunzip < $backup_file | psql -h localhost -p 5432 -U moqui -w moqui-test\n# docker start moqui-test\n\n# example for crontab (safe edit using: 'crontab -e'), each day at midnight: 00 00 * * * /opt/moqui/postgres_backup.sh\n"
  },
  {
    "path": "docker/simple/Dockerfile",
    "content": "# Builds a minimal docker image with openjdk and moqui with various volumes for configuration and persisted data outside the container\n# NOTE: add components, build and if needed load data before building a docker image with this\nARG RUNTIME_IMAGE=eclipse-temurin:21-jdk\nFROM ${RUNTIME_IMAGE}\nMAINTAINER Moqui Framework <moqui@googlegroups.com>\n\nWORKDIR /opt/moqui\n\n# for running from the war directly, preffered approach unzips war in advance (see docker-build.sh that does this)\n#COPY moqui.war .\n# copy files from unzipped moqui.war file\nCOPY WEB-INF WEB-INF\nCOPY META-INF META-INF\nCOPY *.class ./\nCOPY execlib execlib\n\n# always want the runtime directory\nCOPY runtime runtime\n\n# create user for search and chown corresponding files\nARG search_name=opensearch\n\nRUN if [ -d runtime/opensearch/bin ]; then echo \"Installing OpenSearch User\"; \\\n      search_name=opensearch; \\\n      groupadd -g 1000 opensearch 2>/dev/null || echo \"group 1000 already exists\" && \\\n      useradd -u 1000 -g 1000 -G 0 -d /opt/moqui/runtime/opensearch opensearch 2>/dev/null || echo \"user 1000 already exists\" && \\\n      chmod 0775 /opt/moqui/runtime/opensearch && \\\n      chown -R 1000:0 /opt/moqui/runtime/opensearch; \\\n    elif [ -d runtime/elasticsearch/bin ]; then echo \"Installing ElasticSearch User\"; \\\n      search_name=elasticsearch; \\\n      groupadd -r elasticsearch && \\\n      useradd --no-log-init -r -g elasticsearch -d /opt/moqui/runtime/elasticsearch elasticsearch && \\\n      chown -R elasticsearch:elasticsearch runtime/elasticsearch; \\\n    fi\n\n# exposed as volumes for configuration purposes\nVOLUME [\"/opt/moqui/runtime/conf\", \"/opt/moqui/runtime/lib\", \"/opt/moqui/runtime/classes\", \"/opt/moqui/runtime/ecomponent\"]\n# exposed as volumes to persist data outside the container, recommended\nVOLUME [\"/opt/moqui/runtime/log\", \"/opt/moqui/runtime/txlog\", \"/opt/moqui/runtime/sessions\", \"/opt/moqui/runtime/db\", \"/opt/moqui/runtime/$search_name\"]\n\n# Main Servlet Container Port\nEXPOSE 80\n# Search HTTP Port\nEXPOSE 9200\n# Search Cluster (TCP Transport) Port\nEXPOSE 9300\n# Hazelcast Cluster Port\nEXPOSE 5701\n\n# this is to run from the war file directly, preferred approach unzips war file in advance\n# ENTRYPOINT [\"java\", \"-jar\", \"moqui.war\"]\nENTRYPOINT [\"java\", \"-cp\", \".\", \"MoquiStart\"]\n\nHEALTHCHECK --interval=30s --timeout=600ms --start-period=120s CMD curl -f -H \"X-Forwarded-Proto: https\" -H \"X-Forwarded-Ssl: on\" http://localhost/status || exit 1\n# specify this as a default parameter if none are specified with docker exec/run, ie run production by default\nCMD [\"conf=conf/MoquiProductionConf.xml\", \"port=80\"]\n"
  },
  {
    "path": "docker/simple/docker-build.sh",
    "content": "#! /bin/bash\n\necho \"Usage: docker-build.sh [<moqui directory like ../..>] [<group/name:tag>] [<runtime image like eclipse-temurin:21-jdk>]\"\n\nMOQUI_HOME=\"${1:-../..}\"\nNAME_TAG=\"${2:-moqui}\"\nRUNTIME_IMAGE=\"${3:-eclipse-temurin:21-jdk}\"\n\nif [ ! \"$1\" ]; then\n  echo \"Usage: docker-build.sh [<moqui directory like ../..>] [<group/name:tag>] [<runtime image like eclipse-temurin:21-jdk>]\"\nelse\n  echo \"Running: docker-build.sh $MOQUI_HOME $NAME_TAG $RUNTIME_IMAGE\"\nfi\necho\n\nif [ -f $MOQUI_HOME/moqui-plus-runtime.war ]\nthen\n  echo \"Building docker image from moqui-plus-runtime.war\"\n  echo\n  unzip -q $MOQUI_HOME/moqui-plus-runtime.war\nelif [ -f $MOQUI_HOME/moqui.war ]\nthen\n  echo \"Building docker image from moqui.war and runtime directory\"\n  echo \"NOTE: this includes everything in the runtime directory, it is better to run 'gradle addRuntime' first and use the moqui-plus-runtime.war file for the docker image\"\n  echo\n  unzip -q $MOQUI_HOME/moqui.war\n  cp -R $MOQUI_HOME/runtime .\nelse\n    echo \"Could not find $MOQUI_HOME/moqui-plus-runtime.war or $MOQUI_HOME/moqui.war\"\n    echo \"Build moqui first, for example 'gradle build addRuntime' or 'gradle load addRuntime'\"\n    echo\n    exit 1\nfi\n\ndocker build -t $NAME_TAG --build-arg RUNTIME_IMAGE=$RUNTIME_IMAGE .\n\nif [ -d META-INF ]; then rm -Rf META-INF; fi\nif [ -d WEB-INF ]; then rm -Rf WEB-INF; fi\nif [ -d execlib ]; then rm -Rf execlib; fi\nrm *.class\nif [ -d runtime ]; then rm -Rf runtime; fi\nif [ -f Procfile ]; then rm Procfile; fi\n"
  },
  {
    "path": "framework/build.gradle",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\nplugins {\n    id 'java-library'\n    id 'groovy'\n    id 'war'\n}\n\nversion = '4.0.0'\n\nrepositories {\n    flatDir name: 'localLib', dirs: projectDir.absolutePath + '/lib'\n    mavenCentral()\n}\n\njava {\n    sourceCompatibility = 21\n    targetCompatibility = 21\n}\n\nbase {\n    archivesName.set('moqui')\n}\n\nsourceSets {\n    start\n    execWar\n}\n\ngroovydoc {\n    docTitle = \"Moqui Framework ${version}\"\n    source = sourceSets.main.allSource\n}\n\n//tasks.withType(JavaCompile) { options.compilerArgs << \"-Xlint:unchecked\" }\n//tasks.withType(JavaCompile) { options.compilerArgs << \"-Xlint:deprecation\" }\n//tasks.withType(GroovyCompile) { options.compilerArgs << \"-Xlint:unchecked\" }\n//tasks.withType(GroovyCompile) { options.compilerArgs << \"-Xlint:deprecation\" }\n\n// Log4J has annotation processors, disable to avoid warning\ntasks.withType(JavaCompile) { options.compilerArgs << \"-proc:none\" }\ntasks.withType(GroovyCompile) { options.compilerArgs << \"-proc:none\" }\n\n// NOTE: for dependency types and 'api' definition see: https://docs.gradle.org/current/userguide/java_library_plugin.html\ndependencies {\n    // Groovy\n    api 'org.apache.groovy:groovy:5.0.3' // Apache 2.0\n    api 'org.apache.groovy:groovy-dateutil:5.0.3' // Apache 2.0\n    api 'org.apache.groovy:groovy-json:5.0.3' // Apache 2.0\n    api 'org.apache.groovy:groovy-templates:5.0.3' // Apache 2.0\n    api 'org.apache.groovy:groovy-xml:5.0.3' // Apache 2.0\n\n    // Bitronix Transaction Manager, a modernized fork\n    api 'org.moqui:btm:4.0.1' // Apache 2.0\n\n    // Apache Commons\n    api 'org.apache.commons:commons-csv:1.14.1' // Apache 2.0\n    api('org.apache.commons:commons-email2-jakarta:2.0.0-M1') {\n        exclude group: 'com.sun.mail', module: 'jakarta.mail'\n        exclude group: 'com.sun.activation', module: 'jakarta.activation'\n    }\n    api 'org.apache.commons:commons-collections4:4.5.0' // Apache 2.0\n    api 'org.apache.commons:commons-fileupload2-jakarta-servlet6:2.0.0-M4' // Apache 2.0\n    api 'commons-codec:commons-codec:1.20.0' // Apache 2.0\n    api 'commons-io:commons-io:2.21.0' // Apache 2.0\n    api 'commons-logging:commons-logging:1.3.5' // Apache 2.0\n    api 'commons-validator:commons-validator:1.10.1' // Apache 2.0\n\n    // Cron Utils\n    api 'com.cronutils:cron-utils:9.2.1' // Apache 2.0\n\n    // Flexmark (markdown)\n    api 'com.vladsch.flexmark:flexmark:0.64.8'\n    api 'com.vladsch.flexmark:flexmark-ext-tables:0.64.8'\n    api 'com.vladsch.flexmark:flexmark-ext-toc:0.64.8'\n\n    // Freemarker\n    // Remember to change the version number in FtlTemplateRenderer and MNode class when upgrading\n    api 'org.freemarker:freemarker:2.3.34' // Apache 2.0\n\n    // H2 Database\n    api 'com.h2database:h2:2.4.240' // MPL 2.0, EPL 1.0\n\n    // Java Specifications\n    api 'jakarta.transaction:jakarta.transaction-api:2.0.1'\n    api 'javax.cache:cache-api:1.1.1'\n    api 'javax.jcr:jcr:2.0'\n    api('jakarta.xml.bind:jakarta.xml.bind-api:4.0.4') { transitive = false } // EPL 2.0\n    api 'jakarta.activation:jakarta.activation-api:2.1.4' // activation api\n    api 'org.eclipse.angus:angus-activation:2.0.3' // activation implementation\n    api 'jakarta.websocket:jakarta.websocket-api:2.2.0'\n    api 'jakarta.websocket:jakarta.websocket-client-api:2.2.0'\n\n    // servlet-api needed during both compile and test\n    compileOnlyApi 'jakarta.servlet:jakarta.servlet-api:6.1.0'\n    testImplementation 'jakarta.servlet:jakarta.servlet-api:6.1.0'\n\n    // Java TOTP\n    api 'dev.samstevens.totp:totp:1.7.1' // MIT\n    // dev.samstevens.totp:totp depends on com.google.zxing:javase which depends on com.beust:jcommander, but an older version with a CVE, so specify latest to fix\n    api 'com.beust:jcommander:1.82'\n\n    // Jackson Databind (JSON, etc)\n    api 'com.fasterxml.jackson.core:jackson-databind:2.20.1'\n\n    // Jetty HTTP Client and Proxy Servlet\n    api 'org.eclipse.jetty:jetty-client:12.1.5' // Apache 2.0\n    api 'org.eclipse.jetty.ee11:jetty-ee11-proxy:12.1.5' // Apache 2.0\n    api 'org.eclipse.jetty:jetty-jndi:12.1.5' // Apache 2.0\n\n    // jakarta.mail\n    api 'jakarta.mail:jakarta.mail-api:2.1.5' // mail api\n    api 'org.eclipse.angus:angus-mail:2.0.5' // mail implementation\n\n    // JSoup (HTML parser, cleaner)\n    api 'org.jsoup:jsoup:1.21.2' // MIT\n\n    // Apache Shiro\n    api('org.apache.shiro:shiro-core:2.0.6') { transitive = false } // Apache 2.0\n    api('org.apache.shiro:shiro-web:2.0.6:jakarta') { transitive = false } // Apache 2.0\n    api('org.apache.shiro:shiro-lang:2.0.6') { transitive = false } // Apache 2.0\n    api('org.apache.shiro:shiro-cache:2.0.6') { transitive = false } // Apache 2.0\n    api('org.apache.shiro:shiro-event:2.0.6') { transitive = false } // Apache 2.0\n    api('org.apache.shiro:shiro-config-core:2.0.6') { transitive = false } // Apache 2.0\n    api('org.apache.shiro:shiro-config-ogdl:2.0.6') { transitive = false } // Apache 2.0\n    api('org.apache.shiro:shiro-crypto-core:2.0.6') { transitive = false } // Apache 2.0\n    api('org.apache.shiro:shiro-crypto-hash:2.0.6') { transitive = false } // Apache 2.0\n    api('org.apache.shiro:shiro-crypto-cipher:2.0.6') { transitive = false } // Apache 2.0\n    api('org.owasp.encoder:encoder:1.4.0') // BSD - transitive dependency of shiro-web\n\n    // SLF4J, Log4j 2 (note Log4j 2 is used by various libraries, best not to replace it even if mostly possible with SLF4J)\n    api 'org.slf4j:slf4j-api:2.0.17'\n    implementation 'org.apache.logging.log4j:log4j-core:2.25.2'\n    implementation 'org.apache.logging.log4j:log4j-api:2.25.2'\n    runtimeOnly 'org.apache.logging.log4j:log4j-jcl:2.25.2'\n    runtimeOnly 'org.apache.logging.log4j:log4j-slf4j2-impl:2.25.2'\n\n    // SubEtha SMTP, depends on jakarta.mail-api which is provided\n    api(\"com.github.davidmoten:subethasmtp:7.2.0\") { transitive = false }\n\n    // Snake YAML\n    api 'org.yaml:snakeyaml:2.5' // Apache 2.0\n\n    // Apache Jackrabbit - uncomment here or include elsewhere when Jackrabbit repository configurations are used\n    // api 'org.apache.jackrabbit:jackrabbit-jcr-rmi:2.12.1' // Apache 2.0\n    // api 'org.apache.jackrabbit:jackrabbit-jcr2dav:2.12.1' // Apache 2.0\n\n    // Apache Commons JCS - Only needed when using JCSCacheToolFactory\n    // api 'org.apache.commons:commons-jcs-jcache:2.0-beta-1' // Apache 2.0\n\n    // Liquibase (for future reference, not used yet)\n    // api 'org.liquibase:liquibase-core:3.4.2' // Apache 2.0\n\n    // ========== test dependencies ==========\n\n    // junit-platform-launcher is a dependency from spock-core, included explicitly to get more recent version as needed\n    testImplementation 'org.junit.platform:junit-platform-launcher:6.0.1'\n    // junit-platform-suite required for test suites to specify test class order, etc\n    testImplementation 'org.junit.platform:junit-platform-suite:6.0.1'\n    // junit-jupiter-api for using JUnit directly, not generally needed for Spock based tests\n    testImplementation 'org.junit.jupiter:junit-jupiter-api:6.0.1'\n    // Spock Framework\n    testImplementation platform('org.spockframework:spock-bom:2.4-groovy-5.0') // Apache 2.0\n    testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0' // Apache 2.0\n    testImplementation 'org.spockframework:spock-junit4:2.4-groovy-5.0' // Apache 2.0\n    testImplementation 'org.hamcrest:hamcrest-core:3.0' // BSD 3-Clause\n\n    // ========== executable war dependencies ==========\n    // Jetty\n    execWarRuntimeOnly 'org.eclipse.jetty:jetty-server:12.1.5' // Apache 2.0\n    execWarRuntimeOnly 'org.eclipse.jetty.ee11:jetty-ee11-webapp:12.1.5' // Apache 2.0\n    execWarRuntimeOnly 'org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-jakarta-server:12.1.5' // Apache 2.0\n}\n\n// setup task dependencies to make sure the start sourceSets always get run\ncompileJava.dependsOn startClasses\ncompileTestGroovy.dependsOn classes\nsourceSets.test.compileClasspath += files(sourceSets.main.output.classesDirs)\n\n// by default the Java plugin runs test on build, change to not do that (only run test if explicit task)\n// no longer works as of gradle 4.8 or possibly earlier, use clear() instead: check.dependsOn.remove(test)\ncheck.dependsOn.clear()\n\ntest {\n    useJUnitPlatform()\n    testLogging { events \"passed\", \"skipped\", \"failed\" }\n    testLogging.showStandardStreams = true; testLogging.showExceptions = true\n    maxParallelForks = 1\n\n    dependsOn cleanTest\n    include '**/*MoquiSuite.class'\n\n    systemProperty 'moqui.runtime', '../runtime'\n    systemProperty 'moqui.conf', 'conf/MoquiDevConf.xml'\n    systemProperty 'moqui.init.static', 'true'\n\n    classpath += files(sourceSets.main.output.classesDirs); classpath += files(projectDir.absolutePath)\n    // filter out classpath entries that don't exist (gradle adds a bunch of these), or ElasticSearch JarHell will blow up\n    classpath = classpath.filter { it.exists() }\n\n    beforeTest { descriptor -> logger.lifecycle(\"Running test: ${descriptor}\") }\n}\n\njar {\n    // this is necessary otherwise jar won't build when war plugin is applied\n    enabled = true\n    archiveBaseName = 'moqui-framework'\n    manifest { attributes 'Implementation-Title': 'Moqui Framework', 'Implementation-Version': version, 'Implementation-Vendor': 'Moqui Ecosystem' }\n    from sourceSets.main.output\n    // get all of the \"resources\" that are in component-standard directories instead of src/main/resources\n    from fileTree(dir: projectDir.absolutePath, includes: ['data/**', 'entity/**', 'screen/**', 'service/**', 'template/**']) // 'xsd/**'\n}\n\ntasks.test {\n    inputs.files(tasks.jar)\n}\n\nwar {\n    dependsOn jar\n    // put the war file in the parent directory, ie the moqui dir instead of the framework dir\n    destinationDirectory.set(projectDir.parentFile)\n    archiveFileName = 'moqui.war'\n    // add MoquiInit.properties to the WEB-INF/classes dir for the deployed war mode of operation\n    from(fileTree(dir: projectDir.parentFile, includes: ['MoquiInit.properties'])) { into 'WEB-INF/classes' }\n    // this excludes the classes in sourceSets.main.output (better to have the jar file built above)\n    classpath = configurations.runtimeClasspath - configurations.providedCompile\n    classpath jar.archiveFile.get().asFile\n\n    // put start classes and Jetty jars in the root of the war file for the executable war/jar mode of operation\n    from sourceSets.start.output\n    from(files(configurations.execWarRuntimeClasspath)) { into 'execlib' }\n    // TODO some sort of config for Jetty? from file(projectDir.absolutePath + '/jetty/jetty.xml')\n    // setup the manifest for the executable war/jar mode\n    manifest { attributes 'Implementation-Title': 'Moqui Start', 'Implementation-Vendor': 'Moqui Ecosystem',\n            'Implementation-Version': version, 'Main-Class': 'MoquiStart' }\n}\n\ntask copyDependencies { doLast {\n    delete file(projectDir.absolutePath + '/dependencies')\n    copy { from configurations.runtime; into file(projectDir.absolutePath + '/dependencies') }\n    copy { from configurations.testCompile; into file(projectDir.absolutePath + '/dependencies') }\n} }\n"
  },
  {
    "path": "framework/data/CommonL10nData.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<entity-facade-xml type=\"seed-initial\">\n    <moqui.basic.LocalizedMessage original=\"Add\" locale=\"es\" localized=\"Añadir\"/>\n    <moqui.basic.LocalizedMessage original=\"Add\" locale=\"fr\" localized=\"Ajouter\"/>\n    <moqui.basic.LocalizedMessage original=\"Add\" locale=\"it\" localized=\"Aggiungi\"/>\n    <moqui.basic.LocalizedMessage original=\"Add\" locale=\"zh\" localized=\"新建\"/>\n\n    <moqui.basic.LocalizedMessage original=\"Create\" locale=\"es\" localized=\"Crear\"/>\n    <moqui.basic.LocalizedMessage original=\"Create\" locale=\"fr\" localized=\"Créer\"/>\n    <moqui.basic.LocalizedMessage original=\"Create\" locale=\"it\" localized=\"Creare\"/>\n    <moqui.basic.LocalizedMessage original=\"Create\" locale=\"zh\" localized=\"新建\"/>\n\n    <moqui.basic.LocalizedMessage original=\"Delete\" locale=\"es\" localized=\"Eliminar\"/>\n    <moqui.basic.LocalizedMessage original=\"Delete\" locale=\"fr\" localized=\"Supprimer\"/>\n    <moqui.basic.LocalizedMessage original=\"Delete\" locale=\"it\" localized=\"Cancella\"/>\n    <moqui.basic.LocalizedMessage original=\"Delete\" locale=\"zh\" localized=\"删除\"/>\n\n    <moqui.basic.LocalizedMessage original=\"Description\" locale=\"es\" localized=\"Descripción\"/>\n    <moqui.basic.LocalizedMessage original=\"Description\" locale=\"fr\" localized=\"Description\"/>\n    <moqui.basic.LocalizedMessage original=\"Description\" locale=\"it\" localized=\"Descrizione\"/>\n    <moqui.basic.LocalizedMessage original=\"Description\" locale=\"zh\" localized=\"描述\"/>\n\n    <moqui.basic.LocalizedMessage original=\"Find\" locale=\"es\" localized=\"Buscar\"/>\n    <moqui.basic.LocalizedMessage original=\"Find\" locale=\"fr\" localized=\"Rechercher\"/>\n    <moqui.basic.LocalizedMessage original=\"Find\" locale=\"it\" localized=\"Ricerca\"/>\n    <moqui.basic.LocalizedMessage original=\"Find\" locale=\"zh\" localized=\"查找\"/>\n\n    <moqui.basic.LocalizedMessage original=\"Name\" locale=\"es\" localized=\"Nombre\"/>\n    <moqui.basic.LocalizedMessage original=\"Name\" locale=\"fr\" localized=\"Nom\"/>\n    <moqui.basic.LocalizedMessage original=\"Name\" locale=\"it\" localized=\"Nome\"/>\n    <moqui.basic.LocalizedMessage original=\"Name\" locale=\"zh\" localized=\"名称\"/>\n\n    <moqui.basic.LocalizedMessage original=\"Status\" locale=\"es\" localized=\"Estado\"/>\n    <moqui.basic.LocalizedMessage original=\"Status\" locale=\"fr\" localized=\"Statut\"/>\n    <moqui.basic.LocalizedMessage original=\"Status\" locale=\"it\" localized=\"Stato\"/>\n    <moqui.basic.LocalizedMessage original=\"Status\" locale=\"zh\" localized=\"状态\"/>\n\n    <moqui.basic.LocalizedMessage original=\"Type\" locale=\"es\" localized=\"Tipo\"/>\n    <moqui.basic.LocalizedMessage original=\"Type\" locale=\"fr\" localized=\"Type\"/>\n    <moqui.basic.LocalizedMessage original=\"Type\" locale=\"it\" localized=\"Tipo\"/>\n    <moqui.basic.LocalizedMessage original=\"Type\" locale=\"zh\" localized=\"类型\"/>\n\n    <moqui.basic.LocalizedMessage original=\"Update\" locale=\"es\" localized=\"Actualizar\"/>\n    <moqui.basic.LocalizedMessage original=\"Update\" locale=\"fr\" localized=\"Màj\"/>\n    <moqui.basic.LocalizedMessage original=\"Update\" locale=\"it\" localized=\"Aggiorna\"/>\n    <moqui.basic.LocalizedMessage original=\"Update\" locale=\"zh\" localized=\"更新\"/>\n\n    <moqui.basic.LocalizedEntityField entityName=\"moqui.basic.Enumeration\" fieldName=\"description\" pkValue=\"GEOT_CITY\" locale=\"es\" localized=\"Ciudad\"/>\n    <moqui.basic.LocalizedEntityField entityName=\"moqui.basic.Enumeration\" fieldName=\"description\" pkValue=\"GEOT_CITY\" locale=\"fr\" localized=\"Ville\"/>\n    <moqui.basic.LocalizedEntityField entityName=\"moqui.basic.Enumeration\" fieldName=\"description\" pkValue=\"GEOT_CITY\" locale=\"it\" localized=\"Città\"/>\n    <moqui.basic.LocalizedEntityField entityName=\"moqui.basic.Enumeration\" fieldName=\"description\" pkValue=\"GEOT_CITY\" locale=\"zh\" localized=\"市\"/>\n\n    <moqui.basic.LocalizedEntityField entityName=\"moqui.basic.Enumeration\" fieldName=\"description\" pkValue=\"GEOT_STATE\" locale=\"es\" localized=\"Estado\"/>\n    <moqui.basic.LocalizedEntityField entityName=\"moqui.basic.Enumeration\" fieldName=\"description\" pkValue=\"GEOT_STATE\" locale=\"fr\" localized=\"Etat\"/>\n    <moqui.basic.LocalizedEntityField entityName=\"moqui.basic.Enumeration\" fieldName=\"description\" pkValue=\"GEOT_STATE\" locale=\"it\" localized=\"Stato\"/>\n    <moqui.basic.LocalizedEntityField entityName=\"moqui.basic.Enumeration\" fieldName=\"description\" pkValue=\"GEOT_STATE\" locale=\"zh\" localized=\"州\"/>\n\n    <moqui.basic.LocalizedEntityField entityName=\"moqui.basic.Enumeration\" fieldName=\"description\" pkValue=\"GEOT_COUNTRY\" locale=\"es\" localized=\"País\"/>\n    <moqui.basic.LocalizedEntityField entityName=\"moqui.basic.Enumeration\" fieldName=\"description\" pkValue=\"GEOT_COUNTRY\" locale=\"fr\" localized=\"Pays\"/>\n    <moqui.basic.LocalizedEntityField entityName=\"moqui.basic.Enumeration\" fieldName=\"description\" pkValue=\"GEOT_COUNTRY\" locale=\"it\" localized=\"Paese\"/>\n    <moqui.basic.LocalizedEntityField entityName=\"moqui.basic.Enumeration\" fieldName=\"description\" pkValue=\"GEOT_COUNTRY\" locale=\"zh\" localized=\"国家\"/>\n\n    <!-- Message templates for formatting common sets of entity fields (generally for a view-entity) -->\n    <moqui.basic.LocalizedMessage locale=\"default\" original=\"EnumerationNameTemplate\" localized=\"${description}\"/>\n    <moqui.basic.LocalizedMessage locale=\"default\" original=\"StatusItemNameTemplate\" localized=\"${description}\"/>\n    <moqui.basic.LocalizedMessage locale=\"default\" original=\"StatusTransitionNameTemplate\" localized=\"${transitionName}${description == transitionName ? '' : ' (' + description + ')'}\"/>\n    <moqui.basic.LocalizedMessage locale=\"default\" original=\"StatusFlowTransitionNotFoundTemplate\"\n            localized=\"Status change not allowed from ${lookedUpStatusName?:lookedUpStatusId} to ${parameterStatusName?:parameterStatusId}\"/>\n\n    <moqui.basic.LocalizedMessage locale=\"default\" original=\"UomNameTemplate\" localized=\"${description?:''} (${abbreviation?:uomId})\"/>\n    <moqui.basic.LocalizedMessage locale=\"default\" original=\"UserGroupNameTemplate\" localized=\"${description} [${userGroupId}]\"/>\n    <moqui.basic.LocalizedMessage locale=\"default\" original=\"UsernameTemplate\"\n            localized=\"${userFullName ?: (lastName ? (firstName ? firstName + ' ' : '') + lastName : '')} [${username?:userId?:''}]\"/>\n\n    <moqui.basic.LocalizedMessage locale=\"default\" original=\"UserAuthcOtpMessage\" localized=\"Your single use code is ${code}\"/>\n</entity-facade-xml>\n"
  },
  {
    "path": "framework/data/CurrencyData.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n  -->\n<entity-facade-xml type=\"seed-initial\">\n    <!-- For currency code reference see:\n        http://www.iso.org/iso/support/faqs/faqs_widely_used_standards/widely_used_standards_other/currency_codes/currency_codes_list-1.htm -->\n    <moqui.basic.Uom abbreviation=\"BTC\" description=\"Bitcoin\" uomId=\"BTC\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"CRC\" description=\"Costa Rica Colon\" uomId=\"CRC\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"COP\" description=\"Colombian Peso\" uomId=\"COP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"CNY\" description=\"Chinese Yuan Renminbi\" uomId=\"CNY\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"CLP\" description=\"Chilean Peso\" uomId=\"CLP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"CHF\" description=\"Swiss Franc\" uomId=\"CHF\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"CDP\" description=\"Santo Domiongo\" uomId=\"CDP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"CAD\" description=\"Canadian Dollar\" uomId=\"CAD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"BZD\" description=\"Belize Dollar\" uomId=\"BZD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"BYR\" description=\"Belorussian Ruble\" uomId=\"BYR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"BWP\" description=\"Botswana Pula\" uomId=\"BWP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"BSD\" description=\"Bahaman Dollar\" uomId=\"BSD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"BRR\" description=\"Brazil\" uomId=\"BRR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"BRL\" description=\"Brazilian Real\" uomId=\"BRL\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"BOB\" description=\"Bolivian Boliviano\" uomId=\"BOB\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"BND\" description=\"Brunei Dollar\" uomId=\"BND\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"BMD\" description=\"Bermudan Dollar\" uomId=\"BMD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"BIF\" description=\"Burundi Franc\" uomId=\"BIF\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"BHD\" description=\"Bahrain Dinar\" uomId=\"BHD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"BGN\" description=\"Bulgarian Lev\" uomId=\"BGN\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"BDT\" description=\"Bangladesh Taka\" uomId=\"BDT\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"BBD\" description=\"Barbados Dollar\" uomId=\"BBD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"BAD\" description=\"Bosnia-Herzogovinian Dinar\" uomId=\"BAD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"AZM\" description=\"Azerbaijan Manat\" uomId=\"AZM\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"AWG\" description=\"Aruban Guilder\" uomId=\"AWG\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"AUD\" description=\"Australian Dollar\" uomId=\"AUD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ARS\" description=\"Argentina Peso\" uomId=\"ARS\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ARA\" description=\"Argentinian Austral\" uomId=\"ARA\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"AOK\" description=\"Angolan Kwanza\" uomId=\"AOK\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ANG\" description=\"West Indian Guilder\" uomId=\"ANG\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"AMD\" description=\"Armenian Dram\" uomId=\"AMD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ALL\" description=\"Albanian Lek\" uomId=\"ALL\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"AFA\" description=\"Afghani\" uomId=\"AFA\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"AED\" description=\"United Arab Emirates Dirham\" uomId=\"AED\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ADP\" description=\"Andoran peseta\" uomId=\"ADP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"CUP\" description=\"Cuban Peso\" uomId=\"CUP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"CVE\" description=\"Cape Verde Escudo\" uomId=\"CVE\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"CYP\" description=\"Cyprus Pound\" uomId=\"CYP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"CZK\" description=\"Czech Krona\" uomId=\"CZK\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"DJF\" description=\"Djibouti Franc\" uomId=\"DJF\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"DKK\" description=\"Danish Krone\" uomId=\"DKK\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"DOP\" description=\"Dominican Peso\" uomId=\"DOP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"DRP\" description=\"Dominican Republic Peso\" uomId=\"DRP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"DZD\" description=\"Algerian Dinar\" uomId=\"DZD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ECS\" description=\"Ecuador Sucre\" uomId=\"ECS\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"EEK\" description=\"Estonian Krone\" uomId=\"EEK\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"EGP\" description=\"Egyptian Pound\" uomId=\"EGP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ETB\" description=\"Ethiopian Birr\" uomId=\"ETB\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"EUR\" description=\"Euro\" uomId=\"EUR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"FJD\" description=\"Fiji Dollar\" uomId=\"FJD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"FKP\" description=\"Falkland Pound\" uomId=\"FKP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"GBP\" description=\"British Pound\" uomId=\"GBP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"GEK\" description=\"Georgian Kupon\" uomId=\"GEK\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"GHC\" description=\"Ghanian Cedi\" uomId=\"GHC\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"GIP\" description=\"Gibraltar Pound\" uomId=\"GIP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"GMD\" description=\"Gambian Dalasi\" uomId=\"GMD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"GNF\" description=\"Guinea Franc\" uomId=\"GNF\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"GTQ\" description=\"Guatemalan Quedzal\" uomId=\"GTQ\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"GWP\" description=\"Guinea Peso\" uomId=\"GWP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"GYD\" description=\"Guyanese Dollar\" uomId=\"GYD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"HKD\" description=\"Hong Kong Dollar\" uomId=\"HKD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"HNL\" description=\"Honduran Lempira\" uomId=\"HNL\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"HRD\" description=\"Croatian Dinar\" uomId=\"HRD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"HTG\" description=\"Haitian Gourde\" uomId=\"HTG\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"HUF\" description=\"Hungarian forint\" uomId=\"HUF\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"IDR\" description=\"Indonesian Rupiah\" uomId=\"IDR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ILS\" description=\"Israeli Scheckel\" uomId=\"ILS\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"INR\" description=\"Indian Rupee\" uomId=\"INR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"IQD\" description=\"Iraqui Dinar\" uomId=\"IQD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"IRR\" description=\"Iranian Rial\" uomId=\"IRR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ISK\" description=\"Iceland Krona\" uomId=\"ISK\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"JMD\" description=\"Jamaican Dollar\" uomId=\"JMD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"JOD\" description=\"Jordanian Dinar\" uomId=\"JOD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"JPY\" description=\"Japanese Yen\" uomId=\"JPY\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"KES\" description=\"Kenyan Shilling\" uomId=\"KES\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"KHR\" description=\"Cambodian Riel\" uomId=\"KHR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"KIS\" description=\"Kirghizstan Som\" uomId=\"KIS\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"KMF\" description=\"Comoros Franc\" uomId=\"KMF\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"KPW\" description=\"North Korean Won\" uomId=\"KPW\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"KRW\" description=\"South Korean Won\" uomId=\"KRW\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"KWD\" description=\"Kuwaiti Dinar\" uomId=\"KWD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"KYD\" description=\"Cayman Dollar\" uomId=\"KYD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"KZT\" description=\"Kazakhstani Tenge\" uomId=\"KZT\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"LAK\" description=\"Laotian Kip\" uomId=\"LAK\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"LBP\" description=\"Lebanese Pound\" uomId=\"LBP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"LKR\" description=\"Sri Lankan Rupee\" uomId=\"LKR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"LRD\" description=\"Liberian Dollar\" uomId=\"LRD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"LSL\" description=\"Lesotho Loti\" uomId=\"LSL\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"LTL\" description=\"Lithuanian Lita\" uomId=\"LTL\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"LVL\" description=\"Latvian Lat\" uomId=\"LVL\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"LYD\" description=\"Libyan Dinar\" uomId=\"LYD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"MAD\" description=\"Moroccan Dirham\" uomId=\"MAD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"MDL\" description=\"Moldavian Lei\" uomId=\"MDL\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"MGF\" description=\"Madagascan Franc\" uomId=\"MGF\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"MNT\" description=\"Mongolian Tugrik\" uomId=\"MNT\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"MOP\" description=\"Macao Pataca\" uomId=\"MOP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"MRO\" description=\"Mauritanian Ouguiya\" uomId=\"MRO\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"MTL\" description=\"Maltese Lira\" uomId=\"MTL\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"MUR\" description=\"Mauritius Rupee\" uomId=\"MUR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"MVR\" description=\"Maldive Rufiyaa\" uomId=\"MVR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"MWK\" description=\"Malawi Kwacha\" uomId=\"MWK\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"MXN\" description=\"Mexican Peso (new)\" uomId=\"MXN\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"MXP\" description=\"Mexican Peso (old)\" uomId=\"MXP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"MYR\" description=\"Malaysian Ringgit\" uomId=\"MYR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"MZM\" description=\"Mozambique Metical\" uomId=\"MZM\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"NGN\" description=\"Nigerian Naira\" uomId=\"NGN\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"NIC\" description=\"Nicaragua\" uomId=\"NIC\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"NIO\" description=\"Nicaraguan Cordoba\" uomId=\"NIO\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"NIS\" description=\"New Israeli Shekel\" uomId=\"NIS\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"NOK\" description=\"Norwegian Krone\" uomId=\"NOK\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"NPR\" description=\"Nepalese Rupee\" uomId=\"NPR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"NZD\" description=\"New Zealand Dollar\" uomId=\"NZD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"OMR\" description=\"Omani Rial\" uomId=\"OMR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"PAB\" description=\"Panamanian Balboa\" uomId=\"PAB\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"PEI\" description=\"Peruvian Inti\" uomId=\"PEI\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"PEN\" description=\"Peruvian Sol - New\" uomId=\"PEN\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"PES\" description=\"Peruvian Sol\" uomId=\"PES\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"PGK\" description=\"Papua New Guinea Kina\" uomId=\"PGK\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"PHP\" description=\"Philippino Peso\" uomId=\"PHP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"PKR\" description=\"Pakistan Rupee\" uomId=\"PKR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"PLN\" description=\"Polish Zloty\" uomId=\"PLN\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"PLZ\" description=\"Poland\" uomId=\"PLZ\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"PYG\" description=\"Paraguayan Guarani\" uomId=\"PYG\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"QAR\" description=\"Qatar Riyal\" uomId=\"QAR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ROL\" description=\"Romanian Leu\" uomId=\"ROL\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"RUR\" description=\"Russian Rouble\" uomId=\"RUR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"RWF\" description=\"Rwanda Franc\" uomId=\"RWF\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"SAR\" description=\"Saudi Riyal\" uomId=\"SAR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"SBD\" description=\"Solomon Islands Dollar\" uomId=\"SBD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"SCR\" description=\"Seychelles Rupee\" uomId=\"SCR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"SDP\" description=\"Sudanese Pound\" uomId=\"SDP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"SEK\" description=\"Swedish Krona\" uomId=\"SEK\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"SGD\" description=\"Singapore Dollar\" uomId=\"SGD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"SHP\" description=\"St.Helena Pound\" uomId=\"SHP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"SLL\" description=\"Leone\" uomId=\"SLL\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"SOL\" description=\"Peru\" uomId=\"SOL\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"SOS\" description=\"Somalian Shilling\" uomId=\"SOS\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"SRG\" description=\"Surinam Guilder\" uomId=\"SRG\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"STD\" description=\"Sao Tome / Principe Dobra\" uomId=\"STD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"SUR\" description=\"Russian Ruble (old)\" uomId=\"SUR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"SVC\" description=\"El Salvador Colon\" uomId=\"SVC\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"SYP\" description=\"Syrian Pound\" uomId=\"SYP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"SZL\" description=\"Swaziland Lilangeni\" uomId=\"SZL\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"THB\" description=\"Thailand Baht\" uomId=\"THB\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"TJR\" description=\"Tadzhikistani Ruble\" uomId=\"TJR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"TMM\" description=\"Turkmenistani Manat\" uomId=\"TMM\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"TND\" description=\"Tunisian Dinar\" uomId=\"TND\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"TOP\" description=\"Tongan Pa&apos;anga\" uomId=\"TOP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"TPE\" description=\"Timor Escudo\" uomId=\"TPE\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"TRY\" description=\"Turkish Lira\" uomId=\"TRY\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"TTD\" description=\"Trinidad and Tobago Dollar\" uomId=\"TTD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"TWD\" description=\"New Taiwan Dollar\" uomId=\"TWD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"TZS\" description=\"Tanzanian Shilling\" uomId=\"TZS\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"UAH\" description=\"Ukrainian Hryvnia\" uomId=\"UAH\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"UGS\" description=\"Ugandan Shilling\" uomId=\"UGS\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"USD\" description=\"United States Dollar\" uomId=\"USD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"UYP\" description=\"Uruguayan New Peso\" uomId=\"UYP\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"UYU\" description=\"Uruguay\" uomId=\"UYU\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"VEB\" description=\"Venezuelan Bolivar\" uomId=\"VEB\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"VND\" description=\"Vietnamese Dong\" uomId=\"VND\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"VUV\" description=\"Vanuatu Vatu\" uomId=\"VUV\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"WST\" description=\"Samoan Tala\" uomId=\"WST\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"XAF\" description=\"Gabon C.f.A Franc\" uomId=\"XAF\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"XCD\" description=\"East Carribean Dollar\" uomId=\"XCD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"XOF\" description=\"Benin C.f.A. Franc\" uomId=\"XOF\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"YER\" description=\"Yemeni Ryal\" uomId=\"YER\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ZAR\" description=\"South African Rand\" uomId=\"ZAR\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ZMK\" description=\"Zambian Kwacha\" uomId=\"ZMK\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ZRZ\" description=\"Zaire\" uomId=\"ZRZ\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ZWD\" description=\"Zimbabwean Dollar\" uomId=\"ZWD\" uomTypeEnumId=\"UT_CURRENCY_MEASURE\"/>\n</entity-facade-xml>\n"
  },
  {
    "path": "framework/data/EntityTypeData.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<entity-facade-xml type=\"seed\">\n    <moqui.basic.EnumerationType description=\"Entity Comparison Operator\" enumTypeId=\"ComparisonOperator\"/>\n    <moqui.basic.Enumeration description=\"less\" enumCode=\"LESS\" enumId=\"ENTCO_LESS\" enumTypeId=\"ComparisonOperator\"/>\n    <moqui.basic.Enumeration description=\"greater\" enumCode=\"GREATER\" enumId=\"ENTCO_GREATER\" enumTypeId=\"ComparisonOperator\"/>\n    <moqui.basic.Enumeration description=\"less-equals\" enumCode=\"LESS_EQ\" enumId=\"ENTCO_LESS_EQ\" enumTypeId=\"ComparisonOperator\"/>\n    <moqui.basic.Enumeration description=\"greater-equals\" enumCode=\"GREATER_EQ\" enumId=\"ENTCO_GREATER_EQ\" enumTypeId=\"ComparisonOperator\"/>\n    <moqui.basic.Enumeration description=\"equals\" enumCode=\"EQUALS\" enumId=\"ENTCO_EQUALS\" enumTypeId=\"ComparisonOperator\"/>\n    <moqui.basic.Enumeration description=\"not-equals\" enumCode=\"NOT_EQUALS\" enumId=\"ENTCO_NOT_EQUALS\" enumTypeId=\"ComparisonOperator\"/>\n    <moqui.basic.Enumeration description=\"in\" enumCode=\"IN\" enumId=\"ENTCO_IN\" enumTypeId=\"ComparisonOperator\"/>\n    <moqui.basic.Enumeration description=\"not-in\" enumCode=\"NOT_IN\" enumId=\"ENTCO_NOT_IN\" enumTypeId=\"ComparisonOperator\"/>\n    <moqui.basic.Enumeration description=\"between\" enumCode=\"BETWEEN\" enumId=\"ENTCO_BETWEEN\" enumTypeId=\"ComparisonOperator\"/>\n    <moqui.basic.Enumeration description=\"not-between\" enumCode=\"NOT_BETWEEN\" enumId=\"ENTCO_NOT_BETWEEN\" enumTypeId=\"ComparisonOperator\"/>\n    <moqui.basic.Enumeration description=\"like\" enumCode=\"LIKE\" enumId=\"ENTCO_LIKE\" enumTypeId=\"ComparisonOperator\"/>\n    <moqui.basic.Enumeration description=\"not-like\" enumCode=\"NOT_LIKE\" enumId=\"ENTCO_NOT_LIKE\" enumTypeId=\"ComparisonOperator\"/>\n    <moqui.basic.Enumeration description=\"is-null\" enumCode=\"IS_NULL\" enumId=\"ENTCO_IS_NULL\" enumTypeId=\"ComparisonOperator\"/>\n    <moqui.basic.Enumeration description=\"is-not-null\" enumCode=\"IS_NOT_NULL\" enumId=\"ENTCO_IS_NOT_NULL\" enumTypeId=\"ComparisonOperator\"/>\n</entity-facade-xml>\n"
  },
  {
    "path": "framework/data/GeoCountryData.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<entity-facade-xml type=\"seed-initial\">\n    <!--\n        Based on ISO 3166: http://www.iso.org/iso/en/prods-services/iso3166ma/index.html\n        See also: http://en.wikipedia.org/wiki/ISO_3166-1_alpha-3\n                  http://en.wikipedia.org/wiki/ISO_3166-1_alpha-2\n                  http://en.wikipedia.org/wiki/ISO_3166-1_numeric\n    -->\n    <Geo geoCodeAlpha3=\"AFG\" geoCodeAlpha2=\"AF\" geoId=\"AFG\" geoName=\"Afghanistan\" geoCodeNumeric=\"004\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ALB\" geoCodeAlpha2=\"AL\" geoId=\"ALB\" geoName=\"Albania\" geoCodeNumeric=\"008\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"DZA\" geoCodeAlpha2=\"DZ\" geoId=\"DZA\" geoName=\"Algeria\" geoCodeNumeric=\"012\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ASM\" geoCodeAlpha2=\"AS\" geoId=\"ASM\" geoName=\"American Samoa\" geoCodeNumeric=\"016\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"AND\" geoCodeAlpha2=\"AD\" geoId=\"AND\" geoName=\"Andorra\" geoCodeNumeric=\"020\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"AGO\" geoCodeAlpha2=\"AO\" geoId=\"AGO\" geoName=\"Angola\" geoCodeNumeric=\"024\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"AIA\" geoCodeAlpha2=\"AI\" geoId=\"AIA\" geoName=\"Anguilla\" geoCodeNumeric=\"660\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ATA\" geoCodeAlpha2=\"AQ\" geoId=\"ATA\" geoName=\"Antarctica\" geoCodeNumeric=\"010\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ATG\" geoCodeAlpha2=\"AG\" geoId=\"ATG\" geoName=\"Antigua And Barbuda\" geoCodeNumeric=\"028\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ARG\" geoCodeAlpha2=\"AR\" geoId=\"ARG\" geoName=\"Argentina\" geoCodeNumeric=\"032\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ARM\" geoCodeAlpha2=\"AM\" geoId=\"ARM\" geoName=\"Armenia\" geoCodeNumeric=\"051\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ABW\" geoCodeAlpha2=\"AW\" geoId=\"ABW\" geoName=\"Aruba\" geoCodeNumeric=\"533\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"AUS\" geoCodeAlpha2=\"AU\" geoId=\"AUS\" geoName=\"Australia\" geoCodeNumeric=\"036\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"AUT\" geoCodeAlpha2=\"AT\" geoId=\"AUT\" geoName=\"Austria\" geoCodeNumeric=\"040\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"AZE\" geoCodeAlpha2=\"AZ\" geoId=\"AZE\" geoName=\"Azerbaijan\" geoCodeNumeric=\"031\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BHS\" geoCodeAlpha2=\"BS\" geoId=\"BHS\" geoName=\"Bahamas\" geoCodeNumeric=\"044\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BHR\" geoCodeAlpha2=\"BH\" geoId=\"BHR\" geoName=\"Bahrain\" geoCodeNumeric=\"048\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BGD\" geoCodeAlpha2=\"BD\" geoId=\"BGD\" geoName=\"Bangladesh\" geoCodeNumeric=\"050\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BRB\" geoCodeAlpha2=\"BB\" geoId=\"BRB\" geoName=\"Barbados\" geoCodeNumeric=\"052\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BLR\" geoCodeAlpha2=\"BY\" geoId=\"BLR\" geoName=\"Belarus\" geoCodeNumeric=\"112\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BEL\" geoCodeAlpha2=\"BE\" geoId=\"BEL\" geoName=\"Belgium\" geoCodeNumeric=\"056\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BLZ\" geoCodeAlpha2=\"BZ\" geoId=\"BLZ\" geoName=\"Belize\" geoCodeNumeric=\"084\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BEN\" geoCodeAlpha2=\"BJ\" geoId=\"BEN\" geoName=\"Benin\" geoCodeNumeric=\"204\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BMU\" geoCodeAlpha2=\"BM\" geoId=\"BMU\" geoName=\"Bermuda\" geoCodeNumeric=\"060\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BTN\" geoCodeAlpha2=\"BT\" geoId=\"BTN\" geoName=\"Bhutan\" geoCodeNumeric=\"064\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BOL\" geoCodeAlpha2=\"BO\" geoId=\"BOL\" geoName=\"Bolivia\" geoCodeNumeric=\"068\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BIH\" geoCodeAlpha2=\"BA\" geoId=\"BIH\" geoName=\"Bosnia And Herzegowina\" geoCodeNumeric=\"070\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BWA\" geoCodeAlpha2=\"BW\" geoId=\"BWA\" geoName=\"Botswana\" geoCodeNumeric=\"072\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BVT\" geoCodeAlpha2=\"BV\" geoId=\"BVT\" geoName=\"Bouvet Island\" geoCodeNumeric=\"074\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BRA\" geoCodeAlpha2=\"BR\" geoId=\"BRA\" geoName=\"Brazil\" geoCodeNumeric=\"076\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"IOT\" geoCodeAlpha2=\"IO\" geoId=\"IOT\" geoName=\"British Indian Ocean Territory\" geoCodeNumeric=\"086\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BRN\" geoCodeAlpha2=\"BN\" geoId=\"BRN\" geoName=\"Brunei Darussalam\" geoCodeNumeric=\"096\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BGR\" geoCodeAlpha2=\"BG\" geoId=\"BGR\" geoName=\"Bulgaria\" geoCodeNumeric=\"100\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BFA\" geoCodeAlpha2=\"BF\" geoId=\"BFA\" geoName=\"Burkina Faso\" geoCodeNumeric=\"854\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"BDI\" geoCodeAlpha2=\"BI\" geoId=\"BDI\" geoName=\"Burundi\" geoCodeNumeric=\"108\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"KHM\" geoCodeAlpha2=\"KH\" geoId=\"KHM\" geoName=\"Cambodia\" geoCodeNumeric=\"116\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"CMR\" geoCodeAlpha2=\"CM\" geoId=\"CMR\" geoName=\"Cameroon\" geoCodeNumeric=\"120\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"CAN\" geoCodeAlpha2=\"CA\" geoId=\"CAN\" geoName=\"Canada\" geoCodeNumeric=\"124\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"CPV\" geoCodeAlpha2=\"CV\" geoId=\"CPV\" geoName=\"Cape Verde\" geoCodeNumeric=\"132\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"CYM\" geoCodeAlpha2=\"KY\" geoId=\"CYM\" geoName=\"Cayman Islands\" geoCodeNumeric=\"136\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"CAF\" geoCodeAlpha2=\"CF\" geoId=\"CAF\" geoName=\"Central African Republic\" geoCodeNumeric=\"140\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"TCD\" geoCodeAlpha2=\"TD\" geoId=\"TCD\" geoName=\"Chad\" geoCodeNumeric=\"148\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"CHL\" geoCodeAlpha2=\"CL\" geoId=\"CHL\" geoName=\"Chile\" geoCodeNumeric=\"152\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"CHN\" geoCodeAlpha2=\"CN\" geoId=\"CHN\" geoName=\"China\" geoCodeNumeric=\"156\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"CXR\" geoCodeAlpha2=\"CX\" geoId=\"CXR\" geoName=\"Christmas Island\" geoCodeNumeric=\"162\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"CCK\" geoCodeAlpha2=\"CC\" geoId=\"CCK\" geoName=\"Cocos (keeling) Islands\" geoCodeNumeric=\"166\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"COL\" geoCodeAlpha2=\"CO\" geoId=\"COL\" geoName=\"Colombia\" geoCodeNumeric=\"170\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"COM\" geoCodeAlpha2=\"KM\" geoId=\"COM\" geoName=\"Comoros\" geoCodeNumeric=\"174\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"COG\" geoCodeAlpha2=\"CG\" geoId=\"COG\" geoName=\"Congo\" geoCodeNumeric=\"178\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"COD\" geoCodeAlpha2=\"CD\" geoId=\"COD\" geoName=\"Congo, The Democratic Republic Of The\" geoCodeNumeric=\"180\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"COK\" geoCodeAlpha2=\"CK\" geoId=\"COK\" geoName=\"Cook Islands\" geoCodeNumeric=\"184\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"CRI\" geoCodeAlpha2=\"CR\" geoId=\"CRI\" geoName=\"Costa Rica\" geoCodeNumeric=\"188\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"CIV\" geoCodeAlpha2=\"CI\" geoId=\"CIV\" geoName=\"Cote D&apos;ivoire\" geoCodeNumeric=\"384\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"HRV\" geoCodeAlpha2=\"HR\" geoId=\"HRV\" geoName=\"Croatia (local Name: Hrvatska)\" geoCodeNumeric=\"191\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"CUB\" geoCodeAlpha2=\"CU\" geoId=\"CUB\" geoName=\"Cuba\" geoCodeNumeric=\"192\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"CYP\" geoCodeAlpha2=\"CY\" geoId=\"CYP\" geoName=\"Cyprus\" geoCodeNumeric=\"196\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"CZE\" geoCodeAlpha2=\"CZ\" geoId=\"CZE\" geoName=\"Czech Republic\" geoCodeNumeric=\"203\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"DNK\" geoCodeAlpha2=\"DK\" geoId=\"DNK\" geoName=\"Denmark\" geoCodeNumeric=\"208\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"DJI\" geoCodeAlpha2=\"DJ\" geoId=\"DJI\" geoName=\"Djibouti\" geoCodeNumeric=\"262\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"DMA\" geoCodeAlpha2=\"DM\" geoId=\"DMA\" geoName=\"Dominica\" geoCodeNumeric=\"212\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"DOM\" geoCodeAlpha2=\"DO\" geoId=\"DOM\" geoName=\"Dominican Republic\" geoCodeNumeric=\"214\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"TLS\" geoCodeAlpha2=\"TL\" geoId=\"TLS\" geoName=\"East Timor\" geoCodeNumeric=\"626\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ECU\" geoCodeAlpha2=\"EC\" geoId=\"ECU\" geoName=\"Ecuador\" geoCodeNumeric=\"218\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"EGY\" geoCodeAlpha2=\"EG\" geoId=\"EGY\" geoName=\"Egypt\" geoCodeNumeric=\"818\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SLV\" geoCodeAlpha2=\"SV\" geoId=\"SLV\" geoName=\"El Salvador\" geoCodeNumeric=\"222\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"GNQ\" geoCodeAlpha2=\"GQ\" geoId=\"GNQ\" geoName=\"Equatorial Guinea\" geoCodeNumeric=\"226\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ERI\" geoCodeAlpha2=\"ER\" geoId=\"ERI\" geoName=\"Eritrea\" geoCodeNumeric=\"232\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"EST\" geoCodeAlpha2=\"EE\" geoId=\"EST\" geoName=\"Estonia\" geoCodeNumeric=\"233\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ETH\" geoCodeAlpha2=\"ET\" geoId=\"ETH\" geoName=\"Ethiopia\" geoCodeNumeric=\"231\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"FLK\" geoCodeAlpha2=\"FK\" geoId=\"FLK\" geoName=\"Falkland Islands (malvinas)\" geoCodeNumeric=\"238\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"FRO\" geoCodeAlpha2=\"FO\" geoId=\"FRO\" geoName=\"Faroe Islands\" geoCodeNumeric=\"234\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"FJI\" geoCodeAlpha2=\"FJ\" geoId=\"FJI\" geoName=\"Fiji\" geoCodeNumeric=\"242\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"FIN\" geoCodeAlpha2=\"FI\" geoId=\"FIN\" geoName=\"Finland\" geoCodeNumeric=\"246\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"FXX\" geoCodeAlpha2=\"FX\" geoId=\"FXX\" geoName=\"France, Metropolitan\" geoCodeNumeric=\"249\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"FRA\" geoCodeAlpha2=\"FR\" geoId=\"FRA\" geoName=\"France\" geoCodeNumeric=\"250\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"GUF\" geoCodeAlpha2=\"GF\" geoId=\"GUF\" geoName=\"French Guiana\" geoCodeNumeric=\"254\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"PYF\" geoCodeAlpha2=\"PF\" geoId=\"PYF\" geoName=\"French Polynesia\" geoCodeNumeric=\"258\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ATF\" geoCodeAlpha2=\"TF\" geoId=\"ATF\" geoName=\"French Southern Territories\" geoCodeNumeric=\"260\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"GAB\" geoCodeAlpha2=\"GA\" geoId=\"GAB\" geoName=\"Gabon\" geoCodeNumeric=\"266\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"GMB\" geoCodeAlpha2=\"GM\" geoId=\"GMB\" geoName=\"Gambia\" geoCodeNumeric=\"270\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"GEO\" geoCodeAlpha2=\"GE\" geoId=\"GEO\" geoName=\"Georgia\" geoCodeNumeric=\"268\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"DEU\" geoCodeAlpha2=\"DE\" geoId=\"DEU\" geoName=\"Germany\" geoCodeNumeric=\"276\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"GHA\" geoCodeAlpha2=\"GH\" geoId=\"GHA\" geoName=\"Ghana\" geoCodeNumeric=\"288\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"GIB\" geoCodeAlpha2=\"GI\" geoId=\"GIB\" geoName=\"Gibraltar\" geoCodeNumeric=\"292\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"GRC\" geoCodeAlpha2=\"GR\" geoId=\"GRC\" geoName=\"Greece\" geoCodeNumeric=\"300\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"GRL\" geoCodeAlpha2=\"GL\" geoId=\"GRL\" geoName=\"Greenland\" geoCodeNumeric=\"304\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"GRD\" geoCodeAlpha2=\"GD\" geoId=\"GRD\" geoName=\"Grenada\" geoCodeNumeric=\"308\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"GLP\" geoCodeAlpha2=\"GP\" geoId=\"GLP\" geoName=\"Guadeloupe\" geoCodeNumeric=\"312\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"GUM\" geoCodeAlpha2=\"GU\" geoId=\"GUM\" geoName=\"Guam\" geoCodeNumeric=\"316\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"GTM\" geoCodeAlpha2=\"GT\" geoId=\"GTM\" geoName=\"Guatemala\" geoCodeNumeric=\"320\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"GIN\" geoCodeAlpha2=\"GN\" geoId=\"GIN\" geoName=\"Guinea\" geoCodeNumeric=\"324\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"GNB\" geoCodeAlpha2=\"GW\" geoId=\"GNB\" geoName=\"Guinea-bissau\" geoCodeNumeric=\"624\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"GUY\" geoCodeAlpha2=\"GY\" geoId=\"GUY\" geoName=\"Guyana\" geoCodeNumeric=\"328\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"HTI\" geoCodeAlpha2=\"HT\" geoId=\"HTI\" geoName=\"Haiti\" geoCodeNumeric=\"332\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"HMD\" geoCodeAlpha2=\"HM\" geoId=\"HMD\" geoName=\"Heard And Mc Donald Islands\" geoCodeNumeric=\"334\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"VAT\" geoCodeAlpha2=\"VA\" geoId=\"VAT\" geoName=\"Holy See (vatican City State)\" geoCodeNumeric=\"336\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"HND\" geoCodeAlpha2=\"HN\" geoId=\"HND\" geoName=\"Honduras\" geoCodeNumeric=\"340\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"HKG\" geoCodeAlpha2=\"HK\" geoId=\"HKG\" geoName=\"Hong Kong\" geoCodeNumeric=\"344\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"HUN\" geoCodeAlpha2=\"HU\" geoId=\"HUN\" geoName=\"Hungary\" geoCodeNumeric=\"348\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ISL\" geoCodeAlpha2=\"IS\" geoId=\"ISL\" geoName=\"Iceland\" geoCodeNumeric=\"352\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"IND\" geoCodeAlpha2=\"IN\" geoId=\"IND\" geoName=\"India\" geoCodeNumeric=\"356\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"IDN\" geoCodeAlpha2=\"ID\" geoId=\"IDN\" geoName=\"Indonesia\" geoCodeNumeric=\"360\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"IRN\" geoCodeAlpha2=\"IR\" geoId=\"IRN\" geoName=\"Iran (islamic Republic Of)\" geoCodeNumeric=\"364\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"IRQ\" geoCodeAlpha2=\"IQ\" geoId=\"IRQ\" geoName=\"Iraq\" geoCodeNumeric=\"368\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"IRL\" geoCodeAlpha2=\"IE\" geoId=\"IRL\" geoName=\"Ireland\" geoCodeNumeric=\"372\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ISR\" geoCodeAlpha2=\"IL\" geoId=\"ISR\" geoName=\"Israel\" geoCodeNumeric=\"376\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ITA\" geoCodeAlpha2=\"IT\" geoId=\"ITA\" geoName=\"Italy\" geoCodeNumeric=\"380\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"JAM\" geoCodeAlpha2=\"JM\" geoId=\"JAM\" geoName=\"Jamaica\" geoCodeNumeric=\"388\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"JPN\" geoCodeAlpha2=\"JP\" geoId=\"JPN\" geoName=\"Japan\" geoCodeNumeric=\"392\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"JOR\" geoCodeAlpha2=\"JO\" geoId=\"JOR\" geoName=\"Jordan\" geoCodeNumeric=\"400\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"KAZ\" geoCodeAlpha2=\"KZ\" geoId=\"KAZ\" geoName=\"Kazakhstan\" geoCodeNumeric=\"398\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"KEN\" geoCodeAlpha2=\"KE\" geoId=\"KEN\" geoName=\"Kenya\" geoCodeNumeric=\"404\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"KIR\" geoCodeAlpha2=\"KI\" geoId=\"KIR\" geoName=\"Kiribati\" geoCodeNumeric=\"296\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"PRK\" geoCodeAlpha2=\"KP\" geoId=\"PRK\" geoName=\"Korea, Democratic People&apos;s Republic Of\" geoCodeNumeric=\"408\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"KOR\" geoCodeAlpha2=\"KR\" geoId=\"KOR\" geoName=\"Korea, Republic Of\" geoCodeNumeric=\"410\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"KWT\" geoCodeAlpha2=\"KW\" geoId=\"KWT\" geoName=\"Kuwait\" geoCodeNumeric=\"414\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"KGZ\" geoCodeAlpha2=\"KG\" geoId=\"KGZ\" geoName=\"Kyrgyzstan\" geoCodeNumeric=\"417\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"LAO\" geoCodeAlpha2=\"LA\" geoId=\"LAO\" geoName=\"Lao People&apos;s Democratic Republic\" geoCodeNumeric=\"418\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"LVA\" geoCodeAlpha2=\"LV\" geoId=\"LVA\" geoName=\"Latvia\" geoCodeNumeric=\"428\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"LBN\" geoCodeAlpha2=\"LB\" geoId=\"LBN\" geoName=\"Lebanon\" geoCodeNumeric=\"422\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"LSO\" geoCodeAlpha2=\"LS\" geoId=\"LSO\" geoName=\"Lesotho\" geoCodeNumeric=\"426\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"LBR\" geoCodeAlpha2=\"LR\" geoId=\"LBR\" geoName=\"Liberia\" geoCodeNumeric=\"430\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"LBY\" geoCodeAlpha2=\"LY\" geoId=\"LBY\" geoName=\"Libyan Arab Jamahiriya\" geoCodeNumeric=\"434\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"LIE\" geoCodeAlpha2=\"LI\" geoId=\"LIE\" geoName=\"Liechtenstein\" geoCodeNumeric=\"438\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"LTU\" geoCodeAlpha2=\"LT\" geoId=\"LTU\" geoName=\"Lithuania\" geoCodeNumeric=\"440\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"LUX\" geoCodeAlpha2=\"LU\" geoId=\"LUX\" geoName=\"Luxembourg\" geoCodeNumeric=\"442\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MAC\" geoCodeAlpha2=\"MO\" geoId=\"MAC\" geoName=\"Macau\" geoCodeNumeric=\"446\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MKD\" geoCodeAlpha2=\"MK\" geoId=\"MKD\" geoName=\"Macedonia, The Former Yugoslav Republic Of\" geoCodeNumeric=\"807\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MDG\" geoCodeAlpha2=\"MG\" geoId=\"MDG\" geoName=\"Madagascar\" geoCodeNumeric=\"450\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MWI\" geoCodeAlpha2=\"MW\" geoId=\"MWI\" geoName=\"Malawi\" geoCodeNumeric=\"454\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MYS\" geoCodeAlpha2=\"MY\" geoId=\"MYS\" geoName=\"Malaysia\" geoCodeNumeric=\"458\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MDV\" geoCodeAlpha2=\"MV\" geoId=\"MDV\" geoName=\"Maldives\" geoCodeNumeric=\"462\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MLI\" geoCodeAlpha2=\"ML\" geoId=\"MLI\" geoName=\"Mali\" geoCodeNumeric=\"466\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MLT\" geoCodeAlpha2=\"MT\" geoId=\"MLT\" geoName=\"Malta\" geoCodeNumeric=\"470\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MHL\" geoCodeAlpha2=\"MH\" geoId=\"MHL\" geoName=\"Marshall Islands\" geoCodeNumeric=\"584\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MTQ\" geoCodeAlpha2=\"MQ\" geoId=\"MTQ\" geoName=\"Martinique\" geoCodeNumeric=\"474\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MRT\" geoCodeAlpha2=\"MR\" geoId=\"MRT\" geoName=\"Mauritania\" geoCodeNumeric=\"478\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MUS\" geoCodeAlpha2=\"MU\" geoId=\"MUS\" geoName=\"Mauritius\" geoCodeNumeric=\"480\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MYT\" geoCodeAlpha2=\"YT\" geoId=\"MYT\" geoName=\"Mayotte\" geoCodeNumeric=\"175\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MEX\" geoCodeAlpha2=\"MX\" geoId=\"MEX\" geoName=\"Mexico\" geoCodeNumeric=\"484\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"FSM\" geoCodeAlpha2=\"FM\" geoId=\"FSM\" geoName=\"Micronesia, Federated States Of\" geoCodeNumeric=\"583\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MDA\" geoCodeAlpha2=\"MD\" geoId=\"MDA\" geoName=\"Moldova, Republic Of\" geoCodeNumeric=\"498\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MCO\" geoCodeAlpha2=\"MC\" geoId=\"MCO\" geoName=\"Monaco\" geoCodeNumeric=\"492\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MNG\" geoCodeAlpha2=\"MN\" geoId=\"MNG\" geoName=\"Mongolia\" geoCodeNumeric=\"496\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MNE\" geoCodeAlpha2=\"ME\" geoId=\"MNE\" geoName=\"Montenegro\" geoCodeNumeric=\"499\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MSR\" geoCodeAlpha2=\"MS\" geoId=\"MSR\" geoName=\"Montserrat\" geoCodeNumeric=\"500\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MAR\" geoCodeAlpha2=\"MA\" geoId=\"MAR\" geoName=\"Morocco\" geoCodeNumeric=\"504\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MOZ\" geoCodeAlpha2=\"MZ\" geoId=\"MOZ\" geoName=\"Mozambique\" geoCodeNumeric=\"508\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MMR\" geoCodeAlpha2=\"MM\" geoId=\"MMR\" geoName=\"Myanmar\" geoCodeNumeric=\"104\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"NAM\" geoCodeAlpha2=\"NA\" geoId=\"NAM\" geoName=\"Namibia\" geoCodeNumeric=\"516\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"NRU\" geoCodeAlpha2=\"NR\" geoId=\"NRU\" geoName=\"Nauru\" geoCodeNumeric=\"520\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"NPL\" geoCodeAlpha2=\"NP\" geoId=\"NPL\" geoName=\"Nepal\" geoCodeNumeric=\"524\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"NLD\" geoCodeAlpha2=\"NL\" geoId=\"NLD\" geoName=\"Netherlands\" geoCodeNumeric=\"528\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ANT\" geoCodeAlpha2=\"AN\" geoId=\"ANT\" geoName=\"Netherlands Antilles\" geoCodeNumeric=\"530\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"NCL\" geoCodeAlpha2=\"NC\" geoId=\"NCL\" geoName=\"New Caledonia\" geoCodeNumeric=\"540\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"NZL\" geoCodeAlpha2=\"NZ\" geoId=\"NZL\" geoName=\"New Zealand\" geoCodeNumeric=\"554\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"NIC\" geoCodeAlpha2=\"NI\" geoId=\"NIC\" geoName=\"Nicaragua\" geoCodeNumeric=\"558\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"NER\" geoCodeAlpha2=\"NE\" geoId=\"NER\" geoName=\"Niger\" geoCodeNumeric=\"562\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"NGA\" geoCodeAlpha2=\"NG\" geoId=\"NGA\" geoName=\"Nigeria\" geoCodeNumeric=\"566\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"NIU\" geoCodeAlpha2=\"NU\" geoId=\"NIU\" geoName=\"Niue\" geoCodeNumeric=\"570\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"NFK\" geoCodeAlpha2=\"NF\" geoId=\"NFK\" geoName=\"Norfolk Island\" geoCodeNumeric=\"574\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"MNP\" geoCodeAlpha2=\"MP\" geoId=\"MNP\" geoName=\"Northern Mariana Islands\" geoCodeNumeric=\"580\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"NOR\" geoCodeAlpha2=\"NO\" geoId=\"NOR\" geoName=\"Norway\" geoCodeNumeric=\"578\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"OMN\" geoCodeAlpha2=\"OM\" geoId=\"OMN\" geoName=\"Oman\" geoCodeNumeric=\"512\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"PAK\" geoCodeAlpha2=\"PK\" geoId=\"PAK\" geoName=\"Pakistan\" geoCodeNumeric=\"586\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"PLW\" geoCodeAlpha2=\"PW\" geoId=\"PLW\" geoName=\"Palau\" geoCodeNumeric=\"585\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"PSE\" geoCodeAlpha2=\"PS\" geoId=\"PSE\" geoName=\"Palestinian Territory, Occupied\" geoCodeNumeric=\"275\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"PAN\" geoCodeAlpha2=\"PA\" geoId=\"PAN\" geoName=\"Panama\" geoCodeNumeric=\"591\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"PNG\" geoCodeAlpha2=\"PG\" geoId=\"PNG\" geoName=\"Papua New Guinea\" geoCodeNumeric=\"598\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"PRY\" geoCodeAlpha2=\"PY\" geoId=\"PRY\" geoName=\"Paraguay\" geoCodeNumeric=\"600\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"PER\" geoCodeAlpha2=\"PE\" geoId=\"PER\" geoName=\"Peru\" geoCodeNumeric=\"604\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"PHL\" geoCodeAlpha2=\"PH\" geoId=\"PHL\" geoName=\"Philippines\" geoCodeNumeric=\"608\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"PCN\" geoCodeAlpha2=\"PN\" geoId=\"PCN\" geoName=\"Pitcairn\" geoCodeNumeric=\"612\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"POL\" geoCodeAlpha2=\"PL\" geoId=\"POL\" geoName=\"Poland\" geoCodeNumeric=\"616\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"PRT\" geoCodeAlpha2=\"PT\" geoId=\"PRT\" geoName=\"Portugal\" geoCodeNumeric=\"620\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"PRI\" geoCodeAlpha2=\"PR\" geoId=\"PRI\" geoName=\"Puerto Rico\" geoCodeNumeric=\"630\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"QAT\" geoCodeAlpha2=\"QA\" geoId=\"QAT\" geoName=\"Qatar\" geoCodeNumeric=\"634\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"REU\" geoCodeAlpha2=\"RE\" geoId=\"REU\" geoName=\"Reunion\" geoCodeNumeric=\"638\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ROU\" geoCodeAlpha2=\"RO\" geoId=\"ROU\" geoName=\"Romania\" geoCodeNumeric=\"642\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"RUS\" geoCodeAlpha2=\"RU\" geoId=\"RUS\" geoName=\"Russian Federation\" geoCodeNumeric=\"643\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"RWA\" geoCodeAlpha2=\"RW\" geoId=\"RWA\" geoName=\"Rwanda\" geoCodeNumeric=\"646\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"KNA\" geoCodeAlpha2=\"KN\" geoId=\"KNA\" geoName=\"Saint Kitts And Nevis\" geoCodeNumeric=\"659\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"LCA\" geoCodeAlpha2=\"LC\" geoId=\"LCA\" geoName=\"Saint Lucia\" geoCodeNumeric=\"662\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"VCT\" geoCodeAlpha2=\"VC\" geoId=\"VCT\" geoName=\"Saint Vincent And The Grenadines\" geoCodeNumeric=\"670\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"WSM\" geoCodeAlpha2=\"WS\" geoId=\"WSM\" geoName=\"Samoa\" geoCodeNumeric=\"882\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SMR\" geoCodeAlpha2=\"SM\" geoId=\"SMR\" geoName=\"San Marino\" geoCodeNumeric=\"674\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"STP\" geoCodeAlpha2=\"ST\" geoId=\"STP\" geoName=\"Sao Tome And Principe\" geoCodeNumeric=\"678\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SAU\" geoCodeAlpha2=\"SA\" geoId=\"SAU\" geoName=\"Saudi Arabia\" geoCodeNumeric=\"682\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SEN\" geoCodeAlpha2=\"SN\" geoId=\"SEN\" geoName=\"Senegal\" geoCodeNumeric=\"686\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SRB\" geoCodeAlpha2=\"RS\" geoId=\"SRB\" geoName=\"Serbia\" geoCodeNumeric=\"688\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SYC\" geoCodeAlpha2=\"SC\" geoId=\"SYC\" geoName=\"Seychelles\" geoCodeNumeric=\"690\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SLE\" geoCodeAlpha2=\"SL\" geoId=\"SLE\" geoName=\"Sierra Leone\" geoCodeNumeric=\"694\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SGP\" geoCodeAlpha2=\"SG\" geoId=\"SGP\" geoName=\"Singapore\" geoCodeNumeric=\"702\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SVK\" geoCodeAlpha2=\"SK\" geoId=\"SVK\" geoName=\"Slovakia (slovak Republic)\" geoCodeNumeric=\"703\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SVN\" geoCodeAlpha2=\"SI\" geoId=\"SVN\" geoName=\"Slovenia\" geoCodeNumeric=\"705\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SLB\" geoCodeAlpha2=\"SB\" geoId=\"SLB\" geoName=\"Solomon Islands\" geoCodeNumeric=\"090\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SOM\" geoCodeAlpha2=\"SO\" geoId=\"SOM\" geoName=\"Somalia\" geoCodeNumeric=\"706\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SGS\" geoCodeAlpha2=\"GS\" geoId=\"SGS\" geoName=\"South Georgia And The South Sandwich Islands\" geoCodeNumeric=\"239\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ZAF\" geoCodeAlpha2=\"ZA\" geoId=\"ZAF\" geoName=\"South Africa\" geoCodeNumeric=\"710\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ESP\" geoCodeAlpha2=\"ES\" geoId=\"ESP\" geoName=\"Spain\" geoCodeNumeric=\"724\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"LKA\" geoCodeAlpha2=\"LK\" geoId=\"LKA\" geoName=\"Sri Lanka\" geoCodeNumeric=\"144\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SHN\" geoCodeAlpha2=\"SH\" geoId=\"SHN\" geoName=\"St. Helena\" geoCodeNumeric=\"654\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SPM\" geoCodeAlpha2=\"PM\" geoId=\"SPM\" geoName=\"St. Pierre And Miquelon\" geoCodeNumeric=\"666\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SDN\" geoCodeAlpha2=\"SD\" geoId=\"SDN\" geoName=\"Sudan\" geoCodeNumeric=\"736\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SUR\" geoCodeAlpha2=\"SR\" geoId=\"SUR\" geoName=\"Suriname\" geoCodeNumeric=\"740\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SJM\" geoCodeAlpha2=\"SJ\" geoId=\"SJM\" geoName=\"Svalbard And Jan Mayen Islands\" geoCodeNumeric=\"744\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SWZ\" geoCodeAlpha2=\"SZ\" geoId=\"SWZ\" geoName=\"Swaziland\" geoCodeNumeric=\"748\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SWE\" geoCodeAlpha2=\"SE\" geoId=\"SWE\" geoName=\"Sweden\" geoCodeNumeric=\"752\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"CHE\" geoCodeAlpha2=\"CH\" geoId=\"CHE\" geoName=\"Switzerland\" geoCodeNumeric=\"756\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"SYR\" geoCodeAlpha2=\"SY\" geoId=\"SYR\" geoName=\"Syrian Arab Republic\" geoCodeNumeric=\"760\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"TWN\" geoCodeAlpha2=\"TW\" geoId=\"TWN\" geoName=\"Taiwan\" geoCodeNumeric=\"158\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"TJK\" geoCodeAlpha2=\"TJ\" geoId=\"TJK\" geoName=\"Tajikistan\" geoCodeNumeric=\"762\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"TZA\" geoCodeAlpha2=\"TZ\" geoId=\"TZA\" geoName=\"Tanzania, United Republic Of\" geoCodeNumeric=\"834\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"THA\" geoCodeAlpha2=\"TH\" geoId=\"THA\" geoName=\"Thailand\" geoCodeNumeric=\"764\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"TGO\" geoCodeAlpha2=\"TG\" geoId=\"TGO\" geoName=\"Togo\" geoCodeNumeric=\"768\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"TKL\" geoCodeAlpha2=\"TK\" geoId=\"TKL\" geoName=\"Tokelau\" geoCodeNumeric=\"772\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"TON\" geoCodeAlpha2=\"TO\" geoId=\"TON\" geoName=\"Tonga\" geoCodeNumeric=\"776\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"TTO\" geoCodeAlpha2=\"TT\" geoId=\"TTO\" geoName=\"Trinidad And Tobago\" geoCodeNumeric=\"780\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"TUN\" geoCodeAlpha2=\"TN\" geoId=\"TUN\" geoName=\"Tunisia\" geoCodeNumeric=\"788\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"TUR\" geoCodeAlpha2=\"TR\" geoId=\"TUR\" geoName=\"Turkey\" geoCodeNumeric=\"792\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"TKM\" geoCodeAlpha2=\"TM\" geoId=\"TKM\" geoName=\"Turkmenistan\" geoCodeNumeric=\"795\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"TCA\" geoCodeAlpha2=\"TC\" geoId=\"TCA\" geoName=\"Turks And Caicos Islands\" geoCodeNumeric=\"796\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"TUV\" geoCodeAlpha2=\"TV\" geoId=\"TUV\" geoName=\"Tuvalu\" geoCodeNumeric=\"798\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"UGA\" geoCodeAlpha2=\"UG\" geoId=\"UGA\" geoName=\"Uganda\" geoCodeNumeric=\"800\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"UKR\" geoCodeAlpha2=\"UA\" geoId=\"UKR\" geoName=\"Ukraine\" geoCodeNumeric=\"804\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"USA\" geoCodeAlpha2=\"US\" geoId=\"USA\" geoName=\"United States\" geoCodeNumeric=\"840\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"UMI\" geoCodeAlpha2=\"UM\" geoId=\"UMI\" geoName=\"United States Minor Outlying Islands\" geoCodeNumeric=\"581\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ARE\" geoCodeAlpha2=\"AE\" geoId=\"ARE\" geoName=\"United Arab Emirates\" geoCodeNumeric=\"784\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"GBR\" geoCodeAlpha2=\"GB\" geoId=\"GBR\" geoName=\"United Kingdom\" geoCodeNumeric=\"826\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"URY\" geoCodeAlpha2=\"UY\" geoId=\"URY\" geoName=\"Uruguay\" geoCodeNumeric=\"858\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"UZB\" geoCodeAlpha2=\"UZ\" geoId=\"UZB\" geoName=\"Uzbekistan\" geoCodeNumeric=\"860\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"VUT\" geoCodeAlpha2=\"VU\" geoId=\"VUT\" geoName=\"Vanuatu\" geoCodeNumeric=\"548\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"VEN\" geoCodeAlpha2=\"VE\" geoId=\"VEN\" geoName=\"Venezuela\" geoCodeNumeric=\"862\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"VNM\" geoCodeAlpha2=\"VN\" geoId=\"VNM\" geoName=\"Viet Nam\" geoCodeNumeric=\"704\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"VGB\" geoCodeAlpha2=\"VG\" geoId=\"VGB\" geoName=\"Virgin Islands (british)\" geoCodeNumeric=\"092\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"VIR\" geoCodeAlpha2=\"VI\" geoId=\"VIR\" geoName=\"Virgin Islands (u.s.)\" geoCodeNumeric=\"850\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"WLF\" geoCodeAlpha2=\"WF\" geoId=\"WLF\" geoName=\"Wallis And Futuna Islands\" geoCodeNumeric=\"876\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ESH\" geoCodeAlpha2=\"EH\" geoId=\"ESH\" geoName=\"Western Sahara\" geoCodeNumeric=\"732\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"YEM\" geoCodeAlpha2=\"YE\" geoId=\"YEM\" geoName=\"Yemen\" geoCodeNumeric=\"887\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"YUG\" geoCodeAlpha2=\"YU\" geoId=\"YUG\" geoName=\"Yugoslavia\" geoCodeNumeric=\"891\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ZMB\" geoCodeAlpha2=\"ZM\" geoId=\"ZMB\" geoName=\"Zambia\" geoCodeNumeric=\"894\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n    <Geo geoCodeAlpha3=\"ZWE\" geoCodeAlpha2=\"ZW\" geoId=\"ZWE\" geoName=\"Zimbabwe\" geoCodeNumeric=\"716\" geoTypeEnumId=\"GEOT_COUNTRY\"/>\n</entity-facade-xml>\n"
  },
  {
    "path": "framework/data/MoquiSetupData.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<entity-facade-xml type=\"seed-initial\">\n    <!-- EmailTemplate for password reset email -->\n    <moqui.basic.email.EmailTemplate emailTemplateId=\"PASSWORD_RESET\" description=\"Default Password Reset\" emailServerId=\"SYSTEM\"\n            emailTypeEnumId=\"EMT_PWD_RESET\" bodyScreenLocation=\"classpath://screen/PasswordReset.xml\" webappName=\"webroot\"\n            fromAddress=\"\" ccAddresses=\"\" bccAddresses=\"\" subject=\"Password Reset\" sendPartial=\"Y\"/>\n    <!-- EmailTemplate for single use codes -->\n    <moqui.basic.email.EmailTemplate emailTemplateId=\"SINGLE_USE_CODE\" description=\"Send single use code\" emailServerId=\"SYSTEM\"\n            emailTypeEnumId=\"EMT_SINGLE_USE_CODE\" bodyScreenLocation=\"classpath://screen/SingleUseCode.xml\" webappName=\"webroot\"\n            fromAddress=\"\" ccAddresses=\"\" bccAddresses=\"\" subject=\"Single Use Code\" sendPartial=\"Y\"/>\n    <!-- EmailTemplate for Added Email Authentication Type -->\n    <moqui.basic.email.EmailTemplate emailTemplateId=\"ADDED_EMAIL_AUTHC_FACTOR\" description=\"Added Email Authentication Type\" emailServerId=\"SYSTEM\"\n            emailTypeEnumId=\"EMT_ADDED_EMAIL_AUTHC_FACTOR\" bodyScreenLocation=\"classpath://screen/AddedEmailAuthcFactor.xml\" webappName=\"webroot\"\n            fromAddress=\"\" ccAddresses=\"\" bccAddresses=\"\" subject=\"Added Email Authentication Type\" sendPartial=\"Y\"/>\n    <!-- EmailTemplate for Email Authentication Code Sent -->\n    <moqui.basic.email.EmailTemplate emailTemplateId=\"EMAIL_AUTHC_FACTOR_SENT\" description=\"Email Authentication Code Sent\" emailServerId=\"SYSTEM\"\n            emailTypeEnumId=\"EMT_EMAIL_AUTHC_FACTOR_SENT\" bodyScreenLocation=\"classpath://screen/EmailAuthcFActorSent.xml\" webappName=\"webroot\"\n            fromAddress=\"\" ccAddresses=\"\" bccAddresses=\"\" subject=\"Email Authentication Code Sent\" sendPartial=\"Y\"/>\n\n    <!-- EmailTemplate for general notifications -->\n    <moqui.basic.email.EmailTemplate emailTemplateId=\"NOTIFICATION\" description=\"Default Notification\" emailServerId=\"SYSTEM\"\n            emailTypeEnumId=\"EMT_NOTIFICATION\" bodyScreenLocation=\"classpath://screen/NotificationEmail.xml\" webappName=\"webroot\"\n            fromAddress=\"\" fromName=\"\" ccAddresses=\"\" bccAddresses=\"\" subject=\"${title}\" sendPartial=\"Y\"/>\n    <!-- EmailTemplate for rendered screens (reports, etc) -->\n    <moqui.basic.email.EmailTemplate emailTemplateId=\"SCREEN_RENDER\" description=\"Default Screen Render\" emailServerId=\"SYSTEM\"\n            emailTypeEnumId=\"EMT_SCREEN_RENDER\" bodyScreenLocation=\"classpath://screen/ScreenRenderEmail.xml\" webappName=\"webroot\"\n            fromAddress=\"\" fromName=\"\" ccAddresses=\"\" bccAddresses=\"\" subject=\"${title}\" sendPartial=\"Y\"/>\n\n    <!-- Web Servlet Errors NotificationTopic -->\n    <moqui.security.user.NotificationTopic topic=\"WebServletError\" description=\"Web Servlet Error\"\n            typeString=\"danger\" showAlert=\"Y\" persistOnSend=\"Y\" receiveNotifications=\"N\" isPrivate=\"Y\" emailTemplateId=\"NOTIFICATION\"\n            titleTemplate=\"Web Error ${errorCode?:''} (${username?:'no user'}) ${path?:''} ${message?:'N/A'}\"/>\n    <moqui.security.user.NotificationTopic topic=\"ServiceJobError\" description=\"Service Job Error\"\n            typeString=\"danger\" showAlert=\"Y\" persistOnSend=\"Y\" receiveNotifications=\"N\" isPrivate=\"Y\" emailTemplateId=\"NOTIFICATION\"\n            titleTemplate=\"Job Error ${serviceCallRun.jobName?:''} [${serviceCallRun.jobRunId?:''}] ${serviceCallRun.errors?:'N/A'}\"\n            linkTemplate=\"/vapps/system/ServiceJob/JobRuns/JobRunDetail?jobRunId=${serviceCallRun.jobRunId}\"/>\n\n    <!-- ========== Framework Scheduled ServiceJob Data ========== -->\n    <!-- Handy cron strings: [0 0 2 * * ?] every night at 2:00 am, [0 0/15 * * * ?] every 15 minutes, [0 0/2 * * * ?] every 2 minutes -->\n\n    <moqui.service.job.ServiceJob jobName=\"clean_ArtifactData_daily\" description=\"Clean Artifact Data: ArtifactHit, ArtifactHitBin\"\n            serviceName=\"org.moqui.impl.ServerServices.clean#ArtifactData\" cronExpression=\"0 0 2 * * ?\" paused=\"N\">\n        <parameters parameterName=\"daysToKeep\" parameterValue=\"90\"/>\n    </moqui.service.job.ServiceJob>\n    <moqui.service.job.ServiceJob jobName=\"clean_PrintJobData_daily\" description=\"Clean PrintJob Data\"\n            serviceName=\"org.moqui.impl.ServerServices.clean#PrintJobData\" cronExpression=\"0 0 2 * * ?\" paused=\"N\">\n        <parameters parameterName=\"daysToKeep\" parameterValue=\"7\"/>\n    </moqui.service.job.ServiceJob>\n    <moqui.service.job.ServiceJob jobName=\"clean_ServiceJobRun_daily\" description=\"Clean ServiceJobRun Data\"\n            serviceName=\"org.moqui.impl.ServiceServices.clean#ServiceJobRun\" cronExpression=\"0 0 2 * * ?\" paused=\"N\">\n        <parameters parameterName=\"daysToKeep\" parameterValue=\"30\"/>\n    </moqui.service.job.ServiceJob>\n\n    <!-- Change paused to 'N' to run the EntitySync job -->\n    <moqui.service.job.ServiceJob jobName=\"run_EntitySyncAll_frequent\" description=\"Run All EntitySync\"\n            serviceName=\"org.moqui.impl.EntitySyncServices.run#EntitySyncAll\" cronExpression=\"0 0/15 * * * ?\" paused=\"Y\"/>\n\n    <!-- Change paused to 'N' to run SystemMessage send produced and consume received jobs -->\n    <moqui.service.job.ServiceJob jobName=\"send_AllProducedSystemMessages_frequent\" description=\"Send All Produced SystemMessages\"\n            serviceName=\"org.moqui.impl.SystemMessageServices.send#AllProducedSystemMessages\" cronExpression=\"0 0/15 * * * ?\" paused=\"Y\"/>\n    <moqui.service.job.ServiceJob jobName=\"consume_AllReceivedSystemMessages_frequent\" description=\"Consume All Received SystemMessages\"\n            serviceName=\"org.moqui.impl.SystemMessageServices.consume#AllReceivedSystemMessages\" cronExpression=\"0 0/15 * * * ?\" paused=\"Y\"/>\n\n    <!-- Change paused to 'N' to run the poll EmailServer job; NOTE emailServerId=SYSTEM, change if needed along with run frequency -->\n    <moqui.service.job.ServiceJob jobName=\"poll_EmailServer_frequent\" description=\"Poll EmailServer to get new messages\"\n            serviceName=\"org.moqui.impl.EmailServices.poll#EmailServer\" cronExpression=\"0 0/15 * * * ?\" paused=\"Y\">\n        <parameters parameterName=\"emailServerId\" parameterValue=\"SYSTEM\"/>\n    </moqui.service.job.ServiceJob>\n\n    <!-- Render ScheduledScreens -->\n    <moqui.service.job.ServiceJob jobName=\"render_ScheduledScreens_frequent\" description=\"Render Scheduled Screens\"\n            serviceName=\"org.moqui.impl.ScreenServices.render#ScheduledScreens\" cronExpression=\"0 0/15 * * * ?\" paused=\"N\"/>\n\n    <!-- Service Job: Index DataFeed Documents -->\n    <moqui.security.user.NotificationTopic topic=\"IndexDataFeedDocuments\" description=\"Index DataFeed Documents Completed\"\n            typeString=\"success\" showAlert=\"Y\" persistOnSend=\"Y\" receiveNotifications=\"Y\"\n            titleTemplate=\"Index Documents Completed for DataFeed ${parameters.dataFeedId}, indexed ${results.documentsIndexed} documents\"\n            errorTitleTemplate=\"Error in Index Documents for DataFeed ${parameters.dataFeedId}\"\n            linkTemplate=\"\"/>\n    <moqui.service.job.ServiceJob jobName=\"IndexDataFeedDocuments\" description=\"Index DataFeed Documents\"\n            serviceName=\"org.moqui.search.SearchServices.index#DataFeedDocuments\" topic=\"IndexDataFeedDocuments\"\n            transactionTimeout=\"3600\"/>\n\n    <!-- Service Job: Export and Import Entity Data Snapshot -->\n    <moqui.security.user.NotificationTopic topic=\"ExportEntityDataSnapshot\" description=\"Export Entity Data Snapshot Completed\"\n            typeString=\"success\" showAlert=\"Y\" persistOnSend=\"Y\" receiveNotifications=\"Y\" isPrivate=\"Y\" emailTemplateId=\"NOTIFICATION\"\n            titleTemplate=\"Exported ${results.recordsWritten} records to Entity Data Snapshot ${parameters.baseFilename}\"\n            errorTitleTemplate=\"Error in export of entity data snapshot ${parameters.baseFilename}\"\n            linkTemplate=\"\"/>\n    <moqui.service.job.ServiceJob jobName=\"ExportEntityDataSnapshot\" description=\"Export Entity Data Snapshot\"\n            serviceName=\"org.moqui.impl.EntityServices.export#EntityDataSnapshot\" topic=\"ExportEntityDataSnapshot\"\n            transactionTimeout=\"3600\"/>\n    <moqui.security.user.NotificationTopic topic=\"ImportEntityDataSnapshot\" description=\"Import Entity Data Snapshot Completed\"\n            typeString=\"success\" showAlert=\"Y\" persistOnSend=\"Y\" receiveNotifications=\"Y\" isPrivate=\"Y\" emailTemplateId=\"NOTIFICATION\"\n            titleTemplate=\"Imported ${results.recordsLoaded} records from Entity Data Snapshot ${parameters.zipFilename}\"\n            errorTitleTemplate=\"Error in import of entity data snapshot ${parameters.zipFilename}\"\n            linkTemplate=\"\"/>\n    <moqui.service.job.ServiceJob jobName=\"ImportEntityDataSnapshot\" description=\"Import Entity Data Snapshot\"\n            serviceName=\"org.moqui.impl.EntityServices.import#EntityDataSnapshot\" topic=\"ImportEntityDataSnapshot\"\n            transactionTimeout=\"3600\"/>\n\n    <!-- ========== ElasticSearch Setup Data ========== -->\n\n    <moqui.security.UserPermission userPermissionId=\"ElasticRemote\" description=\"ElasticSearch Remote Access\"/>\n    <moqui.security.UserGroupPermission userGroupId=\"ADMIN\" userPermissionId=\"ElasticRemote\" fromDate=\"0\"/>\n\n    <moqui.security.UserPermission userPermissionId=\"KibanaRemote\" description=\"Kibana Remote Access\"/>\n    <moqui.security.UserGroupPermission userGroupId=\"ADMIN\" userPermissionId=\"KibanaRemote\" fromDate=\"0\"/>\n\n    <!-- Handy cron strings: [0 0 2 * * ?] every night at 2:00 am, [0 0/15 * * * ?] every 15 minutes, [0 0/2 * * * ?] every 2 minutes -->\n    <moqui.service.job.ServiceJob jobName=\"clean_ElasticSearchLogMessages_daily\" description=\"Clean log messages stored in ElasticSearch\"\n            serviceName=\"org.moqui.search.SearchServices.delete#Documents\" cronExpression=\"0 0 2 * * ?\" paused=\"Y\">\n        <parameters parameterName=\"indexName\" parameterValue=\"moqui_logs\"/>\n        <parameters parameterName=\"timestampField\" parameterValue=\"@timestamp\"/>\n        <parameters parameterName=\"daysToKeep\" parameterValue=\"90\"/>\n    </moqui.service.job.ServiceJob>\n    <moqui.service.job.ServiceJob jobName=\"clean_ElasticSearchHttpLogMessages_daily\" description=\"Clean http request log messages stored in ElasticSearch\"\n            serviceName=\"org.moqui.search.SearchServices.delete#Documents\" cronExpression=\"0 0 2 * * ?\" paused=\"Y\">\n        <parameters parameterName=\"indexName\" parameterValue=\"moqui_http_log\"/>\n        <parameters parameterName=\"timestampField\" parameterValue=\"@timestamp\"/>\n        <parameters parameterName=\"daysToKeep\" parameterValue=\"180\"/>\n    </moqui.service.job.ServiceJob>\n</entity-facade-xml>\n"
  },
  {
    "path": "framework/data/SecurityTypeData.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<entity-facade-xml type=\"seed\">\n    <moqui.basic.Enumeration description=\"Moqui Administrators\" enumId=\"UgtMoquiAdmin\" enumTypeId=\"UserGroupType\"/>\n    <moqui.basic.Enumeration description=\"Remote Systems\" enumId=\"UgtRemoteSystems\" enumTypeId=\"UserGroupType\"/>\n\n    <!-- User Group for full-access Administrators -->\n    <moqui.security.UserGroup userGroupId=\"ADMIN\" description=\"Administrators (full access)\" groupTypeEnumId=\"UgtMoquiAdmin\"/>\n    <moqui.security.UserGroup userGroupId=\"ADMIN_ADV\" description=\"Administrators - Advanced\" groupTypeEnumId=\"UgtMoquiAdmin\"/>\n\n    <!-- A default/automatic group for all users -->\n    <moqui.security.UserGroup userGroupId=\"ALL_USERS\" description=\"All Users (all users members by default)\"/>\n\n    <!-- An artifact group for remote EntitySync calls; members of other groups like remote API only groups would use this as well -->\n    <moqui.security.ArtifactGroup artifactGroupId=\"EntitySyncServices\" description=\"EntitySync Services\"/>\n    <moqui.security.ArtifactGroupMember artifactGroupId=\"EntitySyncServices\" artifactName=\"org.moqui.impl.EntitySyncServices.put#EntitySyncData\"\n            nameIsPattern=\"N\" artifactTypeEnumId=\"AT_SERVICE\" inheritAuthz=\"Y\"/>\n    <moqui.security.ArtifactGroupMember artifactGroupId=\"EntitySyncServices\" artifactName=\"org.moqui.impl.EntitySyncServices.get#EntitySyncData\"\n            nameIsPattern=\"N\" artifactTypeEnumId=\"AT_SERVICE\" inheritAuthz=\"Y\"/>\n    <moqui.security.ArtifactAuthz artifactAuthzId=\"EntitySyncServicesADMIN\" userGroupId=\"ADMIN\" artifactGroupId=\"EntitySyncServices\"\n            authzTypeEnumId=\"AUTHZT_ALWAYS\" authzActionEnumId=\"AUTHZA_ALL\"/>\n\n    <!-- An artifact group for remote SystemMessage calls; members of other groups like remote API only groups would use this as well -->\n    <moqui.security.ArtifactGroup artifactGroupId=\"SystemMessageServices\" description=\"SystemMessage Services\"/>\n    <moqui.security.ArtifactGroupMember artifactGroupId=\"SystemMessageServices\"\n            artifactName=\"org.moqui.impl.SystemMessageServices.receive#IncomingSystemMessage\"\n            nameIsPattern=\"N\" artifactTypeEnumId=\"AT_SERVICE\" inheritAuthz=\"Y\"/>\n    <moqui.security.ArtifactAuthz artifactAuthzId=\"SystemMessageServicesADMIN\" userGroupId=\"ADMIN\" artifactGroupId=\"SystemMessageServices\"\n            authzTypeEnumId=\"AUTHZT_ALWAYS\" authzActionEnumId=\"AUTHZA_ALL\"/>\n\n    <moqui.security.UserGroup userGroupId=\"SYSMSG_RECEIVE\" description=\"System Message Receivers\" groupTypeEnumId=\"UgtRemoteSystems\"/>\n    <moqui.security.ArtifactAuthz artifactAuthzId=\"SystemMessageServicesSYSMSG\" userGroupId=\"SYSMSG_RECEIVE\" artifactGroupId=\"SystemMessageServices\"\n            authzTypeEnumId=\"AUTHZT_ALWAYS\" authzActionEnumId=\"AUTHZA_ALL\"/>\n\n    <!-- Special Permissions -->\n    <moqui.security.UserPermission userPermissionId=\"ADMIN_PASSWORD\" description=\"Admin Password Update\"/>\n    <moqui.security.UserGroupPermission userGroupId=\"ADMIN\" userPermissionId=\"ADMIN_PASSWORD\" fromDate=\"0\"/>\n\n    <moqui.security.UserPermission userPermissionId=\"ADMIN_LOGIN_AS\" description=\"Admin Login As User\"/>\n    <moqui.security.UserGroupPermission userGroupId=\"ADMIN_ADV\" userPermissionId=\"ADMIN_LOGIN_AS\" fromDate=\"0\"/>\n    <moqui.security.UserPermission userPermissionId=\"SQL_RUNNER_WEB\" description=\"Tools: SQL Runner\"/>\n    <moqui.security.UserGroupPermission userGroupId=\"ADMIN_ADV\" userPermissionId=\"SQL_RUNNER_WEB\" fromDate=\"0\"/>\n    <moqui.security.UserPermission userPermissionId=\"GROOVY_SHELL_WEB\" description=\"Tools: Groovy Shell\"/>\n    <moqui.security.UserGroupPermission userGroupId=\"ADMIN_ADV\" userPermissionId=\"GROOVY_SHELL_WEB\" fromDate=\"0\"/>\n    <moqui.security.UserPermission userPermissionId=\"SERVICE_LOAD_RUNNER\" description=\"Tools: Service LoadRunner\"/>\n    <moqui.security.UserGroupPermission userGroupId=\"ADMIN_ADV\" userPermissionId=\"SERVICE_LOAD_RUNNER\" fromDate=\"0\"/>\n</entity-facade-xml>\n"
  },
  {
    "path": "framework/data/UnitData.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<entity-facade-xml type=\"seed\">\n    <!-- =============== Data Size =============== -->\n    <moqui.basic.Uom abbreviation=\"b\" description=\"Bit\" uomId=\"DATA_b\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"Kb\" description=\"Kilobit\" uomId=\"DATA_Kb\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"Mb\" description=\"Megabit\" uomId=\"DATA_Mb\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"Gb\" description=\"Gigabit\" uomId=\"DATA_Gb\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"Tb\" description=\"Terabit\" uomId=\"DATA_Tb\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n\n    <moqui.basic.Uom abbreviation=\"B\" description=\"Byte\" uomId=\"DATA_B\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"KB\" description=\"Kilobyte\" uomId=\"DATA_KB\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"MB\" description=\"Megabyte\" uomId=\"DATA_MB\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"GB\" description=\"Gigabyte\" uomId=\"DATA_GB\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"TB\" description=\"Terabyte\" uomId=\"DATA_TB\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"PB\" description=\"Petabyte\" uomId=\"DATA_PB\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n\n    <moqui.basic.Uom abbreviation=\"Kib\" description=\"Kibit\" uomId=\"DATA_Kib\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"Mib\" description=\"Mibit\" uomId=\"DATA_Mib\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"Gib\" description=\"Gibit\" uomId=\"DATA_Gib\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"Tib\" description=\"Tibit\" uomId=\"DATA_Tib\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"Pib\" description=\"Pibit\" uomId=\"DATA_Pib\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n\n    <moqui.basic.Uom abbreviation=\"KiB\" description=\"Kibibyte\" uomId=\"DATA_KiB\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"MiB\" description=\"Mebibyte\" uomId=\"DATA_MiB\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"GiB\" description=\"Gibibyte\" uomId=\"DATA_GiB\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"TiB\" description=\"Tebibyte\" uomId=\"DATA_TiB\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"PiB\" description=\"Pebibyte\" uomId=\"DATA_PiB\" uomTypeEnumId=\"UT_DATA_MEASURE\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_Kb_b\" uomId=\"DATA_Kb\" toUomId=\"DATA_b\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_Mb_Kb\" uomId=\"DATA_Mb\" toUomId=\"DATA_Kb\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_Gb_Mb\" uomId=\"DATA_Gb\" toUomId=\"DATA_Mb\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_Tb_Gb\" uomId=\"DATA_Tb\" toUomId=\"DATA_Gb\" conversionFactor=\"1000\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_B_b\" uomId=\"DATA_B\" toUomId=\"DATA_b\" conversionFactor=\"8\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_KB_B\" uomId=\"DATA_KB\" toUomId=\"DATA_B\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_MB_KB\" uomId=\"DATA_MB\" toUomId=\"DATA_KB\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_GM_MB\" uomId=\"DATA_GB\" toUomId=\"DATA_MB\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_TB_GB\" uomId=\"DATA_TB\" toUomId=\"DATA_GB\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_PB_TB\" uomId=\"DATA_PB\" toUomId=\"DATA_TB\" conversionFactor=\"1000\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_Kib_b\" uomId=\"DATA_Kib\" toUomId=\"DATA_b\" conversionFactor=\"1024\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_Mib_Kib\" uomId=\"DATA_Mib\" toUomId=\"DATA_Kib\" conversionFactor=\"1024\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_Gib_Mib\" uomId=\"DATA_Gib\" toUomId=\"DATA_Mib\" conversionFactor=\"1024\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_Tib_Gib\" uomId=\"DATA_Tib\" toUomId=\"DATA_Gib\" conversionFactor=\"1024\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_KiB_B\" uomId=\"DATA_KiB\" toUomId=\"DATA_B\" conversionFactor=\"1024\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_MiB_KiB\" uomId=\"DATA_MiB\" toUomId=\"DATA_KiB\" conversionFactor=\"1024\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_GiM_MiB\" uomId=\"DATA_GiB\" toUomId=\"DATA_MiB\" conversionFactor=\"1024\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_TiB_GiB\" uomId=\"DATA_TiB\" toUomId=\"DATA_GiB\" conversionFactor=\"1024\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SIZE_PiB_TiB\" uomId=\"DATA_PiB\" toUomId=\"DATA_TiB\" conversionFactor=\"1024\"/>\n\n    <!-- =============== Data Speed =============== -->\n    <moqui.basic.Uom abbreviation=\"bps\" description=\"Bit-per-second\" uomId=\"DATASPD_bps\" uomTypeEnumId=\"UT_DATASPD_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"Kbps\" description=\"Kilobit-per-second\" uomId=\"DATASPD_Kbps\" uomTypeEnumId=\"UT_DATASPD_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"Mbps\" description=\"Megabit-per-second\" uomId=\"DATASPD_Mbps\" uomTypeEnumId=\"UT_DATASPD_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"Gbps\" description=\"Gigabit-per-second\" uomId=\"DATASPD_Gbps\" uomTypeEnumId=\"UT_DATASPD_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"Tbps\" description=\"Terabit-per-second\" uomId=\"DATASPD_Tbps\" uomTypeEnumId=\"UT_DATASPD_MEASURE\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SPD_Kbps_bps\" uomId=\"DATASPD_Kbps\" toUomId=\"DATASPD_bps\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SPD_Mbps_Kbps\" uomId=\"DATASPD_Mbps\" toUomId=\"DATASPD_Kbps\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SPD_Gbps_Mbps\" uomId=\"DATASPD_Gbps\" toUomId=\"DATASPD_Mbps\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DATA_SPD_Tbps_Gbps\" uomId=\"DATASPD_Tbps\" toUomId=\"DATASPD_Gbps\" conversionFactor=\"1000\"/>\n\n    <!-- =============== Time/Frequency =============== -->\n    <moqui.basic.Uom abbreviation=\"ms\" description=\"Milli-Second\" uomId=\"TF_ms\" uomTypeEnumId=\"UT_TIME_FREQ_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"s\" description=\"Second\" uomId=\"TF_s\" uomTypeEnumId=\"UT_TIME_FREQ_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"min\" description=\"Minute\" uomId=\"TF_min\" uomTypeEnumId=\"UT_TIME_FREQ_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"hr\" description=\"Hour\" uomId=\"TF_hr\" uomTypeEnumId=\"UT_TIME_FREQ_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"day\" description=\"Day\" uomId=\"TF_day\" uomTypeEnumId=\"UT_TIME_FREQ_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"wk\" description=\"Week\" uomId=\"TF_wk\" uomTypeEnumId=\"UT_TIME_FREQ_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"mon\" description=\"Month\" uomId=\"TF_mon\" uomTypeEnumId=\"UT_TIME_FREQ_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"yr\" description=\"Year\" uomId=\"TF_yr\" uomTypeEnumId=\"UT_TIME_FREQ_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"decade\" description=\"Decade\" uomId=\"TF_decade\" uomTypeEnumId=\"UT_TIME_FREQ_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"score\" description=\"Score\" uomId=\"TF_score\" uomTypeEnumId=\"UT_TIME_FREQ_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"century\" description=\"Century\" uomId=\"TF_century\" uomTypeEnumId=\"UT_TIME_FREQ_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"millenium\" description=\"Millenium\" uomId=\"TF_millenium\" uomTypeEnumId=\"UT_TIME_FREQ_MEASURE\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"TIME_FREQ_s_ms\" uomId=\"TF_s\" toUomId=\"TF_ms\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"TIME_FREQ_min_s\" uomId=\"TF_min\" toUomId=\"TF_s\" conversionFactor=\"60\"/>\n    <moqui.basic.UomConversion uomConversionId=\"TIME_FREQ_hr_min\" uomId=\"TF_hr\" toUomId=\"TF_min\" conversionFactor=\"60\"/>\n    <moqui.basic.UomConversion uomConversionId=\"TIME_FREQ_day_hr\" uomId=\"TF_day\" toUomId=\"TF_hr\" conversionFactor=\"24\"/>\n    <moqui.basic.UomConversion uomConversionId=\"TIME_FREQ_wk_day\" uomId=\"TF_wk\" toUomId=\"TF_day\" conversionFactor=\"7\"/>\n    <moqui.basic.UomConversion uomConversionId=\"TIME_FREQ_yr_mon\" uomId=\"TF_yr\" toUomId=\"TF_mon\" conversionFactor=\"12\"/>\n    <moqui.basic.UomConversion uomConversionId=\"TIME_FREQ_decade_yr\" uomId=\"TF_decade\" toUomId=\"TF_yr\" conversionFactor=\"10\"/>\n    <moqui.basic.UomConversion uomConversionId=\"TIME_FREQ_score_yr\" uomId=\"TF_score\" toUomId=\"TF_yr\" conversionFactor=\"20\"/>\n    <moqui.basic.UomConversion uomConversionId=\"TIME_FREQ_century_yr\" uomId=\"TF_century\" toUomId=\"TF_yr\" conversionFactor=\"100\"/>\n    <moqui.basic.UomConversion uomConversionId=\"TIME_FREQ_mill_yr\" uomId=\"TF_millenium\" toUomId=\"TF_yr\" conversionFactor=\"1000\"/>\n    <!-- these should really be dynamic for current year from Calendar class or something -->\n    <moqui.basic.UomConversion uomConversionId=\"TIME_FREQ_yr_day\" uomId=\"TF_yr\" toUomId=\"TF_day\" conversionFactor=\"365\"/>\n    <moqui.basic.UomConversion uomConversionId=\"TIME_FREQ_yr_wk\" uomId=\"TF_yr\" toUomId=\"TF_wk\" conversionFactor=\"52.14\"/>\n\n    <!-- =============== Length =============== -->\n    <moqui.basic.Uom abbreviation=\"A\" description=\"Angstrom\" uomId=\"LEN_A\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"cb\" description=\"Cable\" uomId=\"LEN_cb\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"cm\" description=\"Centimeter\" uomId=\"LEN_cm\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"chG\" description=\"Chain (Gunter's/surveyor's)\" uomId=\"LEN_chG\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"chR\" description=\"Chain (Ramden's/engineer's)\" uomId=\"LEN_chR\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"dm\" description=\"Decimeter\" uomId=\"LEN_dm\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"dam\" description=\"Dekameter\" uomId=\"LEN_dam\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"fm\" description=\"Fathom\" uomId=\"LEN_fm\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ft\" description=\"Foot\" uomId=\"LEN_ft\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"fur\" description=\"Furlong\" uomId=\"LEN_fur\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"hand\" description=\"Hand (horse's height)\" uomId=\"LEN_hand\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"in\" description=\"Inch\" uomId=\"LEN_in\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"km\" description=\"Kilometer\" uomId=\"LEN_km\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"league\" description=\"League\" uomId=\"LEN_league\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"lnG\" description=\"Link (Gunter's)\" uomId=\"LEN_lnG\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"lnR\" description=\"Link (Ramden's)\" uomId=\"LEN_lnR\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"m\" description=\"Meter\" uomId=\"LEN_m\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"u\" description=\"Micrometer (Micron)\" uomId=\"LEN_u\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"mi\" description=\"Mile (statute/land)\" uomId=\"LEN_mi\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"nmi\" description=\"Mile (nautical/sea)\" uomId=\"LEN_nmi\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"mm\" description=\"Millimeter\" uomId=\"LEN_mm\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"mil\" description=\"Mil (Milli-inch)\" uomId=\"LEN_mil\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"point\" description=\"Point (type size)\" uomId=\"LEN_point\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"pica\" description=\"Pica (type size)\" uomId=\"LEN_pica\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"rd\" description=\"Rod\" uomId=\"LEN_rd\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"yd\" description=\"Yard\" uomId=\"LEN_yd\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"LEN_A_m\" uomId=\"LEN_A\" toUomId=\"LEN_m\" conversionFactor=\"0.0000000001\"/> <!-- 10 to the -10 meters -->\n    <moqui.basic.UomConversion uomConversionId=\"LEN_A_in\" uomId=\"LEN_A\" toUomId=\"LEN_in\" conversionFactor=\"0.000000004\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"LEN_cb_fm\" uomId=\"LEN_cb\" toUomId=\"LEN_fm\" conversionFactor=\"120\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_cb_ft\" uomId=\"LEN_cb\" toUomId=\"LEN_ft\" conversionFactor=\"720\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"LEN_cm_m\" uomId=\"LEN_cm\" toUomId=\"LEN_m\" conversionFactor=\"0.01\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"LEN_chG_ft\" uomId=\"LEN_chG\" toUomId=\"LEN_ft\" conversionFactor=\"66\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_chG_rd\" uomId=\"LEN_chG\" toUomId=\"LEN_rd\" conversionFactor=\"4\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_chR_ft\" uomId=\"LEN_chR\" toUomId=\"LEN_ft\" conversionFactor=\"100\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"LEN_dm_m\" uomId=\"LEN_dm\" toUomId=\"LEN_m\" conversionFactor=\"0.1\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_dam_m\" uomId=\"LEN_dam\" toUomId=\"LEN_m\" conversionFactor=\"10\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"LEN_fm_ft\" uomId=\"LEN_fm\" toUomId=\"LEN_ft\" conversionFactor=\"6\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_ft_in\" uomId=\"LEN_ft\" toUomId=\"LEN_in\" conversionFactor=\"12\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_ft_m\" uomId=\"LEN_ft\" toUomId=\"LEN_m\" conversionFactor=\"0.3048\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_fur_mi\" uomId=\"LEN_fur\" toUomId=\"LEN_mi\" conversionFactor=\"0.125\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_mi_fur\" uomId=\"LEN_mi\" toUomId=\"LEN_fur\" conversionFactor=\"8\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_hand_in\" uomId=\"LEN_hand\" toUomId=\"LEN_in\" conversionFactor=\"4\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_in_cm\" uomId=\"LEN_in\" toUomId=\"LEN_cm\" conversionFactor=\"2.54\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_in_mm\" uomId=\"LEN_in\" toUomId=\"LEN_mm\" conversionFactor=\"25.4\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_in_u\" uomId=\"LEN_in\" toUomId=\"LEN_u\" conversionFactor=\"25400\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"LEN_km_m\" uomId=\"LEN_km\" toUomId=\"LEN_m\" conversionFactor=\"1000\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"LEN_league_mi\" uomId=\"LEN_league\" toUomId=\"LEN_mi\" conversionFactor=\"3\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_lnG_in\" uomId=\"LEN_lnG\" toUomId=\"LEN_in\" conversionFactor=\"7.92\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_lnR_in\" uomId=\"LEN_lnR\" toUomId=\"LEN_in\" conversionFactor=\"12\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"LEN_m_in\" uomId=\"LEN_m\" toUomId=\"LEN_in\" conversionFactor=\"39.37\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_u_mm\" uomId=\"LEN_u\" toUomId=\"LEN_mm\" conversionFactor=\"0.001\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_mm_u\" uomId=\"LEN_mm\" toUomId=\"LEN_u\" conversionFactor=\"1000\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"LEN_mi_km\" uomId=\"LEN_mi\" toUomId=\"LEN_km\" conversionFactor=\"1.609\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_mi_ft\" uomId=\"LEN_mi\" toUomId=\"LEN_ft\" conversionFactor=\"5280\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_nmi_km\" uomId=\"LEN_nmi\" toUomId=\"LEN_km\" conversionFactor=\"1.85\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_nmi_ft\" uomId=\"LEN_nmi\" toUomId=\"LEN_ft\" conversionFactor=\"6076.11549\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"LEN_mm_m\" uomId=\"LEN_mm\" toUomId=\"LEN_m\" conversionFactor=\"0.001\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_mil_in\" uomId=\"LEN_mil\" toUomId=\"LEN_in\" conversionFactor=\"0.001\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"LEN_point_mm\" uomId=\"LEN_point\" toUomId=\"LEN_mm\" conversionFactor=\"0.351\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_point_in\" uomId=\"LEN_point\" toUomId=\"LEN_in\" conversionFactor=\"0.0138\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_pica_point\" uomId=\"LEN_pica\" toUomId=\"LEN_point\" conversionFactor=\"12\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"LEN_rd_ft\" uomId=\"LEN_rd\" toUomId=\"LEN_ft\" conversionFactor=\"16.5\"/>\n    <moqui.basic.UomConversion uomConversionId=\"LEN_yd_ft\" uomId=\"LEN_yd\" toUomId=\"LEN_ft\" conversionFactor=\"3\"/>\n\n    <!-- =============== Velocity =============== -->\n    <moqui.basic.Uom abbreviation=\"m/h\" description=\"Meters per hour\" uomId=\"VEL_m_hr\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"m/min\" description=\"Meters per minute\" uomId=\"VEL_m_min\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"m/s\" description=\"Meters per second\" uomId=\"VEL_m_s\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ft/hr\" description=\"Feet per hour\" uomId=\"VEL_ft_hr\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ft/min\" description=\"Feet per minute\" uomId=\"VEL_ft_min\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ft/s\" description=\"Feet per second\" uomId=\"VEL_ft_s\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"fur/hr\" description=\"Furlongs per hour\" uomId=\"VEL_fur_hr\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"fur/min\" description=\"Furlongs per minute\" uomId=\"VEL_fur_min\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"fur/s\" description=\"Furlongs per second\" uomId=\"VEL_fur_s\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"in/hr\" description=\"Inches per hour\" uomId=\"VEL_in_hr\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"in/min\" description=\"Inches per minute\" uomId=\"VEL_in_min\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"in/s\" description=\"Inches per second\" uomId=\"VEL_in_s\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"kn\" description=\"Knots\" uomId=\"VEL_kn\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"M\" description=\"Mach\" uomId=\"VEL_M\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"MPH\" description=\"Miles per hour\" uomId=\"VEL_MPH\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"mi/min\" description=\"Miles per minute\" uomId=\"VEL_mi_min\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"mi/s\" description=\"Miles per second\" uomId=\"VEL_mi_s\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"KPH\" description=\"Kilometers per hour\" uomId=\"VEL_KPH\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"km/min\" description=\"Kilometers per minute\" uomId=\"VEL_km_min\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"km/s\" description=\"Kilometers per second\" uomId=\"VEL_km_s\" uomTypeEnumId=\"UT_VELOCITY_MEASURE\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"VEL_m_hr_m_s\" uomId=\"VEL_m_hr\" toUomId=\"VEL_m_s\" conversionFactor=\"0.0002777778\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VEL_m_min_m_s\" uomId=\"VEL_m_min\" toUomId=\"VEL_m_s\" conversionFactor=\"0.016666666667\"/>\n    <!-- <moqui.basic.UomConversion uomConversionId=\"VEL_m_s_m_s\" uomId=\"VEL_m_s\" toUomId=\"VEL_m_s\" conversionFactor=\"1\"/> -->\n    <moqui.basic.UomConversion uomConversionId=\"VEL_ft_hr_m_s\" uomId=\"VEL_ft_hr\" toUomId=\"VEL_m_s\" conversionFactor=\"0.00008466667\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VEL_ft_min_m_s\" uomId=\"VEL_ft_min\" toUomId=\"VEL_m_s\" conversionFactor=\"0.00508\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VEL_ft_s_m_s\" uomId=\"VEL_ft_s\" toUomId=\"VEL_m_s\" conversionFactor=\"0.3048\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VEL_fur_hr_m_s\" uomId=\"VEL_fur_hr\" toUomId=\"VEL_m_s\" conversionFactor=\"0.05588\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VEL_fur_min_m_s\" uomId=\"VEL_fur_min\" toUomId=\"VEL_m_s\" conversionFactor=\"3.3528\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VEL_fur_s_m_s\" uomId=\"VEL_fur_s\" toUomId=\"VEL_m_s\" conversionFactor=\"201.168\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VEL_kn_m_s\" uomId=\"VEL_kn\" toUomId=\"VEL_m_s\" conversionFactor=\"0.514444444444\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VEL_M_m_s\" uomId=\"VEL_M\" toUomId=\"VEL_m_s\" conversionFactor=\"0.514444444444\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VEL_MPH_m_s\" uomId=\"VEL_MPH\" toUomId=\"VEL_m_s\" conversionFactor=\"0.44704\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VEL_mi_min_m_s\" uomId=\"VEL_mi_min\" toUomId=\"VEL_m_s\" conversionFactor=\"26.8224\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VEL_mi_s_m_s\" uomId=\"VEL_mi_s\" toUomId=\"VEL_m_s\" conversionFactor=\"1609.344\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VEL_KPH_m_s\" uomId=\"VEL_KPH\" toUomId=\"VEL_m_s\" conversionFactor=\"0.277777777778\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VEL_km_min_m_s\" uomId=\"VEL_km_min\" toUomId=\"VEL_m_s\" conversionFactor=\"16.666666666667\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VEL_km_s_m_s\" uomId=\"VEL_km_s\" toUomId=\"VEL_m_s\" conversionFactor=\"1000\"/>\n\n    <!-- =============== Area =============== -->\n    <moqui.basic.Uom abbreviation=\"A\" description=\"Acre\" uomId=\"AREA_A\" uomTypeEnumId=\"UT_AREA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"a\" description=\"Are\" uomId=\"AREA_a\" uomTypeEnumId=\"UT_AREA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ha\" description=\"Hectare\" uomId=\"AREA_ha\" uomTypeEnumId=\"UT_AREA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"cm2\" description=\"Square Centimeter\" uomId=\"AREA_cm2\" uomTypeEnumId=\"UT_AREA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ft2\" description=\"Square Foot\" uomId=\"AREA_ft2\" uomTypeEnumId=\"UT_AREA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"in2\" description=\"Square Inch\" uomId=\"AREA_in2\" uomTypeEnumId=\"UT_AREA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"km2\" description=\"Square Kilometer\" uomId=\"AREA_km2\" uomTypeEnumId=\"UT_AREA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"m2\" description=\"Square Meter\" uomId=\"AREA_m2\" uomTypeEnumId=\"UT_AREA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"mi2\" description=\"Square Mile\" uomId=\"AREA_mi2\" uomTypeEnumId=\"UT_AREA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"mm2\" description=\"Square Millimeter\" uomId=\"AREA_mm2\" uomTypeEnumId=\"UT_AREA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"rd2\" description=\"Square Rod\" uomId=\"AREA_rd2\" uomTypeEnumId=\"UT_AREA_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"yd2\" description=\"Square Yard\" uomId=\"AREA_yd2\" uomTypeEnumId=\"UT_AREA_MEASURE\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"AREA_A_ft2\" uomId=\"AREA_A\" toUomId=\"AREA_ft2\" conversionFactor=\"43560\"/>\n    <moqui.basic.UomConversion uomConversionId=\"AREA_a_m2\" uomId=\"AREA_a\" toUomId=\"AREA_m2\" conversionFactor=\"100\"/>\n    <moqui.basic.UomConversion uomConversionId=\"AREA_ha_m2\" uomId=\"AREA_ha\" toUomId=\"AREA_m2\" conversionFactor=\"10000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"AREA_ha_A\" uomId=\"AREA_ha\" toUomId=\"AREA_A\" conversionFactor=\"2.471\"/>\n    <moqui.basic.UomConversion uomConversionId=\"AREA_cm2_mm2\" uomId=\"AREA_cm2\" toUomId=\"AREA_mm2\" conversionFactor=\"100\"/>\n    <moqui.basic.UomConversion uomConversionId=\"AREA_cm2_in2\" uomId=\"AREA_cm2\" toUomId=\"AREA_in2\" conversionFactor=\"0.155\"/>\n    <moqui.basic.UomConversion uomConversionId=\"AREA_ft2_in2\" uomId=\"AREA_ft2\" toUomId=\"AREA_in2\" conversionFactor=\"144\"/>\n    <moqui.basic.UomConversion uomConversionId=\"AREA_km2_m2\" uomId=\"AREA_km2\" toUomId=\"AREA_m2\" conversionFactor=\"1000000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"AREA_m2_cm2\" uomId=\"AREA_m2\" toUomId=\"AREA_cm2\" conversionFactor=\"10000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"AREA_mi2_A\" uomId=\"AREA_mi2\" toUomId=\"AREA_A\" conversionFactor=\"639.8\"/>\n    <moqui.basic.UomConversion uomConversionId=\"AREA_rd2_ft2\" uomId=\"AREA_rd2\" toUomId=\"AREA_ft2\" conversionFactor=\"272.25\"/>\n    <moqui.basic.UomConversion uomConversionId=\"AREA_yd2_ft2\" uomId=\"AREA_yd2\" toUomId=\"AREA_ft2\" conversionFactor=\"9\"/>\n\n    <!-- =============== Volume (Liquid) =============== -->\n    <moqui.basic.Uom abbreviation=\"bbl\" description=\"Barrel (US)\" uomId=\"VLIQ_bbl\" uomTypeEnumId=\"UT_VOLUME_LIQ_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"cup\" description=\"Cup\" uomId=\"VLIQ_cup\" uomTypeEnumId=\"UT_VOLUME_LIQ_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"dr\" description=\"Dram (US)\" uomId=\"VLIQ_dr\" uomTypeEnumId=\"UT_VOLUME_LIQ_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"gi\" description=\"Gill (1/4 UK pint)\" uomId=\"VLIQ_gi\" uomTypeEnumId=\"UT_VOLUME_LIQ_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"gal\" description=\"Gallon (UK)\" uomId=\"VLIQ_galUK\" uomTypeEnumId=\"UT_VOLUME_LIQ_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"gal\" description=\"Gallon (US)\" uomId=\"VLIQ_galUS\" uomTypeEnumId=\"UT_VOLUME_LIQ_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"L\" description=\"Liter\" uomId=\"VLIQ_L\" uomTypeEnumId=\"UT_VOLUME_LIQ_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"ml\" description=\"Milliliter\" uomId=\"VLIQ_ml\" uomTypeEnumId=\"UT_VOLUME_LIQ_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"fl. oz (UK)\" description=\"Ounce, fluid (UK)\" uomId=\"VLIQ_ozUK\" uomTypeEnumId=\"UT_VOLUME_LIQ_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"fl. oz (US)\" description=\"Ounce, fluid (US)\" uomId=\"VLIQ_ozUS\" uomTypeEnumId=\"UT_VOLUME_LIQ_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"pt (UK)\" description=\"Pint (UK)\" uomId=\"VLIQ_ptUK\" uomTypeEnumId=\"UT_VOLUME_LIQ_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"pt (US)\" description=\"Pint (US)\" uomId=\"VLIQ_ptUS\" uomTypeEnumId=\"UT_VOLUME_LIQ_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"qt\" description=\"Quart\" uomId=\"VLIQ_qt\" uomTypeEnumId=\"UT_VOLUME_LIQ_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"Tbs\" description=\"Tablespoon\" uomId=\"VLIQ_Tbs\" uomTypeEnumId=\"UT_VOLUME_LIQ_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"tsp\" description=\"Teaspoon\" uomId=\"VLIQ_tsp\" uomTypeEnumId=\"UT_VOLUME_LIQ_MEAS\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_bbl_galUS\" uomId=\"VLIQ_bbl\" toUomId=\"VLIQ_galUS\" conversionFactor=\"31.5\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_ozUS_dr\" uomId=\"VLIQ_ozUS\" toUomId=\"VLIQ_dr\" conversionFactor=\"8\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_ptUK_gi\" uomId=\"VLIQ_ptUK\" toUomId=\"VLIQ_gi\" conversionFactor=\"4\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_galUK_galUS\" uomId=\"VLIQ_galUK\" toUomId=\"VLIQ_galUS\" conversionFactor=\"1.2009\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_galUS_qt\" uomId=\"VLIQ_galUS\" toUomId=\"VLIQ_qt\" conversionFactor=\"4\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_galUS_L\" uomId=\"VLIQ_galUS\" toUomId=\"VLIQ_L\" conversionFactor=\"3.785\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_L_qt\" uomId=\"VLIQ_L\" toUomId=\"VLIQ_qt\" conversionFactor=\"1.056\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_ml_L\" uomId=\"VLIQ_ml\" toUomId=\"VLIQ_L\" conversionFactor=\"0.001\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_L_ml\" uomId=\"VLIQ_L\" toUomId=\"VLIQ_ml\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_ozUK_L\" uomId=\"VLIQ_ozUK\" toUomId=\"VLIQ_L\" conversionFactor=\"0.029\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_ozUK_ozUS\" uomId=\"VLIQ_ozUK\" toUomId=\"VLIQ_ozUS\" conversionFactor=\"0.96\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_ozUS_Tbs\" uomId=\"VLIQ_ozUS\" toUomId=\"VLIQ_Tbs\" conversionFactor=\"2\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_Tbs_tsp\" uomId=\"VLIQ_Tbs\" toUomId=\"VLIQ_tsp\" conversionFactor=\"3\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_cup_Tbs\" uomId=\"VLIQ_cup\" toUomId=\"VLIQ_Tbs\" conversionFactor=\"16\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_ptUK_ptUS\" uomId=\"VLIQ_ptUK\" toUomId=\"VLIQ_ptUS\" conversionFactor=\"1.2\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_ptUS_ozUS\" uomId=\"VLIQ_ptUS\" toUomId=\"VLIQ_ozUS\" conversionFactor=\"16\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_qt_ptUS\" uomId=\"VLIQ_qt\" toUomId=\"VLIQ_ptUS\" conversionFactor=\"2\"/>\n\n    <!-- =============== Volume (Dry) =============== -->\n    <moqui.basic.Uom abbreviation=\"cm3\" description=\"Cubic centimeter\" uomId=\"VDRY_cm3\" uomTypeEnumId=\"UT_VOLUME_DRY_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"ft3\" description=\"Cubic foot\" uomId=\"VDRY_ft3\" uomTypeEnumId=\"UT_VOLUME_DRY_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"in3\" description=\"Cubic inch\" uomId=\"VDRY_in3\" uomTypeEnumId=\"UT_VOLUME_DRY_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"m3\" description=\"Cubic meter\" uomId=\"VDRY_m3\" uomTypeEnumId=\"UT_VOLUME_DRY_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"mm3\" description=\"Cubic millimeter\" uomId=\"VDRY_mm3\" uomTypeEnumId=\"UT_VOLUME_DRY_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"ST\" description=\"Stere (cubic meter)\" uomId=\"VDRY_ST\" uomTypeEnumId=\"UT_VOLUME_DRY_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"yd3\" description=\"Cubic yard\" uomId=\"VDRY_yd3\" uomTypeEnumId=\"UT_VOLUME_DRY_MEAS\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_L_m3\" uomId=\"VLIQ_L\" toUomId=\"VDRY_m3\" conversionFactor=\"0.001\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VLIQ_galUK_m3\" uomId=\"VLIQ_galUK\" toUomId=\"VDRY_m3\" conversionFactor=\"0.4546\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VDRY_cm3_mm3\" uomId=\"VDRY_cm3\" toUomId=\"VDRY_mm3\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VDRY_cm3_in3\" uomId=\"VDRY_cm3\" toUomId=\"VDRY_in3\" conversionFactor=\"0.061\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VDRY_ft3_in3\" uomId=\"VDRY_ft3\" toUomId=\"VDRY_in3\" conversionFactor=\"1728\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VDRY_ST_m3\" uomId=\"VDRY_ST\" toUomId=\"VDRY_m3\" conversionFactor=\"1\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VDRY_m3_yd3\" uomId=\"VDRY_m3\" toUomId=\"VDRY_yd3\" conversionFactor=\"1.3\"/>\n    <moqui.basic.UomConversion uomConversionId=\"VDRY_yd3_ft3\" uomId=\"VDRY_yd3\" toUomId=\"VDRY_ft3\" conversionFactor=\"27\"/>\n\n    <!-- =============== Density =============== -->\n    <moqui.basic.Uom abbreviation=\"kg/m3\" description=\"Kilogram per cubic meter\" uomId=\"DENS_kg_m3\" uomTypeEnumId=\"UT_DENSITY_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"g/cm3\" description=\"Gram per cubic centimeter \" uomId=\"DENS_g_cm3\" uomTypeEnumId=\"UT_DENSITY_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"oz/in3\" description=\"Ounce per cubic inch \" uomId=\"DENS_oz_in3\" uomTypeEnumId=\"UT_DENSITY_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"oz/gal (UK)\" description=\"Ounce per gallon (UK)\" uomId=\"DENS_oz_galUK\" uomTypeEnumId=\"UT_DENSITY_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"oz/gal (US)\" description=\"Ounce per gallon (US)\" uomId=\"DENS_oz_galUS\" uomTypeEnumId=\"UT_DENSITY_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"lb/ft3\" description=\"Pound per cubic foot\" uomId=\"DENS_lb_ft3\" uomTypeEnumId=\"UT_DENSITY_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"lb/in3\" description=\"Pound per cubic inch\" uomId=\"DENS_lb_in3\" uomTypeEnumId=\"UT_DENSITY_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"lb/yd3\" description=\"Pound per cubic yard\" uomId=\"DENS_lb_yd3\" uomTypeEnumId=\"UT_DENSITY_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"lb/gal (UK)\" description=\"Pound per gallon (UK)\" uomId=\"DENS_lb_galUK\" uomTypeEnumId=\"UT_DENSITY_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"lb/gal (US)\" description=\"Pound per gallon (US)\" uomId=\"DENS_lb_galUS\" uomTypeEnumId=\"UT_DENSITY_MEAS\"/>\n    <moqui.basic.Uom abbreviation=\"ton/yd3\" description=\"Ton, long, per cubic yard\" uomId=\"DENS_ton_yd3\" uomTypeEnumId=\"UT_DENSITY_MEAS\"/>\n\n    <!--<moqui.basic.UomConversion uomConversionId=\"DENS_kg_m3_kg_m3\" uomId=\"DENS_kg_m3\" toUomId=\"DENS_kg_m3\" conversionFactor=\"1\"/>-->\n    <moqui.basic.UomConversion uomConversionId=\"DENS_g_cm3_kg_m3\" uomId=\"DENS_g_cm3\" toUomId=\"DENS_kg_m3\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DENS_oz_in3_kg_m3\" uomId=\"DENS_oz_in3\" toUomId=\"DENS_kg_m3\" conversionFactor=\"1730\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DENS_oz_galUK_kg_m3\" uomId=\"DENS_oz_galUK\" toUomId=\"DENS_kg_m3\" conversionFactor=\"6.24\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DENS_oz_galUS_kg_m3\" uomId=\"DENS_oz_galUS\" toUomId=\"DENS_kg_m3\" conversionFactor=\"7.49\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DENS_lb_ft3_kg_m3\" uomId=\"DENS_lb_ft3\" toUomId=\"DENS_kg_m3\" conversionFactor=\"16.02\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DENS_lb_in3_kg_m3\" uomId=\"DENS_lb_in3\" toUomId=\"DENS_kg_m3\" conversionFactor=\"27680\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DENS_lb_yd3_kg_m3\" uomId=\"DENS_lb_yd3\" toUomId=\"DENS_kg_m3\" conversionFactor=\"0.593\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DENS_lb_galUK_kg_m3\" uomId=\"DENS_lb_galUK\" toUomId=\"DENS_kg_m3\" conversionFactor=\"99.78\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DENS_lb_galUS_kg_m3\" uomId=\"DENS_lb_galUS\" toUomId=\"DENS_kg_m3\" conversionFactor=\"119.8\"/>\n    <moqui.basic.UomConversion uomConversionId=\"DENS_ton_yd3_kg_m3\" uomId=\"DENS_ton_yd3\" toUomId=\"DENS_kg_m3\" conversionFactor=\"1328.9\"/>\n\n    <!-- =============== Weight =============== -->\n    <moqui.basic.Uom abbreviation=\"dr avdp\" description=\"Dram (avdp)\" uomId=\"WT_dr_avdp\" uomTypeEnumId=\"UT_WEIGHT_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"gr\" description=\"Grain\" uomId=\"WT_gr\" uomTypeEnumId=\"UT_WEIGHT_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"g\" description=\"Gram\" uomId=\"WT_g\" uomTypeEnumId=\"UT_WEIGHT_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"kg\" description=\"Kilogram\" uomId=\"WT_kg\" uomTypeEnumId=\"UT_WEIGHT_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"mg\" description=\"Milligram\" uomId=\"WT_mg\" uomTypeEnumId=\"UT_WEIGHT_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"mcg\" description=\"Microgram\" uomId=\"WT_mcg\" uomTypeEnumId=\"UT_WEIGHT_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"oz\" description=\"Ounce (avdp)\" uomId=\"WT_oz\" uomTypeEnumId=\"UT_WEIGHT_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"oz tr\" description=\"Ounce (troy)\" uomId=\"WT_oz_tr\" uomTypeEnumId=\"UT_WEIGHT_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"dwt\" description=\"Pennyweight\" uomId=\"WT_dwt\" uomTypeEnumId=\"UT_WEIGHT_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"lb\" description=\"Pound (avdp)\" uomId=\"WT_lb\" uomTypeEnumId=\"UT_WEIGHT_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"st\" description=\"Stone\" uomId=\"WT_st\" uomTypeEnumId=\"UT_WEIGHT_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"lt\" description=\"Ton (long or British)\" uomId=\"WT_lt\" uomTypeEnumId=\"UT_WEIGHT_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"mt\" description=\"Ton (metric)\" uomId=\"WT_mt\" uomTypeEnumId=\"UT_WEIGHT_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"sh t\" description=\"Ton (short)\" uomId=\"WT_sh_t\" uomTypeEnumId=\"UT_WEIGHT_MEASURE\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"WT_dr_avdp_oz\" uomId=\"WT_dr_avdp\" toUomId=\"WT_oz\" conversionFactor=\"0.0625\"/>\n    <moqui.basic.UomConversion uomConversionId=\"WT_gr_oz\" uomId=\"WT_gr\" toUomId=\"WT_oz\" conversionFactor=\"0.00229\"/>\n    <moqui.basic.UomConversion uomConversionId=\"WT_gr_g\" uomId=\"WT_gr\" toUomId=\"WT_g\" conversionFactor=\"0.0648\"/>\n    <moqui.basic.UomConversion uomConversionId=\"WT_kg_g\" uomId=\"WT_kg\" toUomId=\"WT_g\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"WT_kg_lb\" uomId=\"WT_kg\" toUomId=\"WT_lb\" conversionFactor=\"2.2046226\"/>\n    <moqui.basic.UomConversion uomConversionId=\"WT_g_mg\" uomId=\"WT_g\" toUomId=\"WT_mg\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"WT_mg_mcg\" uomId=\"WT_mg\" toUomId=\"WT_mcg\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"WT_g_oz\" uomId=\"WT_g\" toUomId=\"WT_oz\" conversionFactor=\"0.035273962\"/>\n    <moqui.basic.UomConversion uomConversionId=\"WT_oz_lb\" uomId=\"WT_oz\" toUomId=\"WT_lb\" conversionFactor=\"0.0625\"/>\n    <moqui.basic.UomConversion uomConversionId=\"WT_oz_tr_lb\" uomId=\"WT_oz_tr\" toUomId=\"WT_lb\" conversionFactor=\"0.083333333\"/>\n    <moqui.basic.UomConversion uomConversionId=\"WT_oz_g\" uomId=\"WT_oz\" toUomId=\"WT_g\" conversionFactor=\"28.34952\"/>\n    <moqui.basic.UomConversion uomConversionId=\"WT_oz_tr_g\" uomId=\"WT_oz_tr\" toUomId=\"WT_g\" conversionFactor=\"31.1034768\"/>\n    <moqui.basic.UomConversion uomConversionId=\"WT_dwt_g\" uomId=\"WT_dwt\" toUomId=\"WT_g\" conversionFactor=\"1.555\"/>\n    <moqui.basic.UomConversion uomConversionId=\"WT_lb_oz\" uomId=\"WT_lb\" toUomId=\"WT_oz\" conversionFactor=\"16\"/>\n    <moqui.basic.UomConversion uomConversionId=\"WT_st_lb\" uomId=\"WT_st\" toUomId=\"WT_lb\" conversionFactor=\"14\"/>\n    <moqui.basic.UomConversion uomConversionId=\"WT_lt_mt\" uomId=\"WT_lt\" toUomId=\"WT_mt\" conversionFactor=\"1.02\"/>\n    <moqui.basic.UomConversion uomConversionId=\"WT_lt_lb\" uomId=\"WT_lt\" toUomId=\"WT_lb\" conversionFactor=\"2240\"/>\n    <moqui.basic.UomConversion uomConversionId=\"WT_mt_kg\" uomId=\"WT_mt\" toUomId=\"WT_kg\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"WT_sh_t_lb\" uomId=\"WT_sh_t\" toUomId=\"WT_lb\" conversionFactor=\"2000\"/>\n\n    <!-- =============== Power =============== -->\n    <moqui.basic.Uom abbreviation=\"kw\" description=\"Kilowatt\" uomId=\"PW_kw\" uomTypeEnumId=\"UT_POWER_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"w\" description=\"Watt\" uomId=\"PW_w\" uomTypeEnumId=\"UT_POWER_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"hp\" description=\"Horsepower (mechanical)\" uomId=\"PW_hp\" uomTypeEnumId=\"UT_POWER_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"BTU/hr\" description=\"BTU per hour\" uomId=\"PW_BTU_hr\" uomTypeEnumId=\"UT_POWER_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"cal/sec\" description=\"Calorie per second\" uomId=\"PW_cal_sec\" uomTypeEnumId=\"UT_POWER_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"bhp\" description=\"Boiler horsepower\" uomId=\"PW_bhp\" uomTypeEnumId=\"UT_POWER_MEASURE\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"PW_kw_w\" uomId=\"PW_kw\" toUomId=\"PW_w\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"PW_hp_w\" uomId=\"PW_hp\" toUomId=\"PW_w\" conversionFactor=\"745.7\"/>\n    <moqui.basic.UomConversion uomConversionId=\"PW_kw_hp\" uomId=\"PW_kw\" toUomId=\"PW_hp\" conversionFactor=\"1.341021858\"/>\n    <moqui.basic.UomConversion uomConversionId=\"PW_kw_BTU_hr\" uomId=\"PW_kw\" toUomId=\"PW_BTU_hr\" conversionFactor=\"3412.14245\"/>\n    <moqui.basic.UomConversion uomConversionId=\"PW_kw_cal_sec\" uomId=\"PW_kw\" toUomId=\"PW_cal_sec\" conversionFactor=\"238.845897\"/>\n    <moqui.basic.UomConversion uomConversionId=\"PW_kw_bhp\" uomId=\"PW_kw\" toUomId=\"PW_bhp\" conversionFactor=\"0.101929972\"/>\n\n    <!-- =============== Energy =============== -->\n    <moqui.basic.Uom abbreviation=\"J\" description=\"Joule (absolute)\" uomId=\"EN_J\" uomTypeEnumId=\"UT_ENERGY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"cal\" description=\"Calorie IT\" uomId=\"EN_cal\" uomTypeEnumId=\"UT_ENERGY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"kcal\" description=\"Kilocalorie\" uomId=\"EN_kcal\" uomTypeEnumId=\"UT_ENERGY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"BTU\" description=\"British Thermal Unit\" uomId=\"EN_BTU\" uomTypeEnumId=\"UT_ENERGY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"hp*h\" description=\"Horsepower-hour\" uomId=\"EN_hp_h\" uomTypeEnumId=\"UT_ENERGY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"kw*h\" description=\"Kilowatt-hour\" uomId=\"EN_kw_h\" uomTypeEnumId=\"UT_ENERGY_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ft*lbf\" description=\"Foot-pound(force)\" uomId=\"EN_ft_lbf\" uomTypeEnumId=\"UT_ENERGY_MEASURE\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"EN_cal_J\" uomId=\"EN_cal\" toUomId=\"EN_J\" conversionFactor=\"4.1868\"/>\n    <moqui.basic.UomConversion uomConversionId=\"EN_kcal_cal\" uomId=\"EN_kcal\" toUomId=\"EN_cal\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"EN_kw_h_hp_h\" uomId=\"EN_kw_h\" toUomId=\"EN_hp_h\" conversionFactor=\"1.34102209\"/>\n    <moqui.basic.UomConversion uomConversionId=\"EN_kw_h_BTU\" uomId=\"EN_kw_h\" toUomId=\"EN_BTU\" conversionFactor=\"3412.1416416\"/>\n    <moqui.basic.UomConversion uomConversionId=\"EN_kw_h_J\" uomId=\"EN_kw_h\" toUomId=\"EN_J\" conversionFactor=\"3600000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"EN_kw_h_kcal\" uomId=\"EN_kw_h\" toUomId=\"EN_kcal\" conversionFactor=\"859.845228\"/>\n    <moqui.basic.UomConversion uomConversionId=\"EN_kw_h_ft_lbf\" uomId=\"EN_kw_h\" toUomId=\"EN_ft_lbf\" conversionFactor=\"2655223.73748\"/>\n\n    <!-- =============== Pressure =============== -->\n    <moqui.basic.Uom abbreviation=\"atm\" description=\"Atmosphere\" uomId=\"PRES_atm\" uomTypeEnumId=\"UT_PRESSURE_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"at\" description=\"Atmosphere (Technical)\" uomId=\"PRES_at\" uomTypeEnumId=\"UT_PRESSURE_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"bar\" description=\"Bar\" uomId=\"PRES_bar\" uomTypeEnumId=\"UT_PRESSURE_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"mbar\" description=\"Millibar\" uomId=\"PRES_mbar\" uomTypeEnumId=\"UT_PRESSURE_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"hPa\" description=\"Hectopascal\" uomId=\"PRES_hPa\" uomTypeEnumId=\"UT_PRESSURE_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"inHg\" description=\"Inch of mercury\" uomId=\"PRES_inHg\" uomTypeEnumId=\"UT_PRESSURE_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"mmHg\" description=\"Millimeter of mercury\" uomId=\"PRES_mmHg\" uomTypeEnumId=\"UT_PRESSURE_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"kg/cm2\" description=\"Kilogram per square centimeter\" uomId=\"PRES_kg_cm2\" uomTypeEnumId=\"UT_PRESSURE_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"kg/m2\" description=\"Kilogram per square meter\" uomId=\"PRES_kg_m2\" uomTypeEnumId=\"UT_PRESSURE_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"kPa\" description=\"Kilopascal\" uomId=\"PRES_kPa\" uomTypeEnumId=\"UT_PRESSURE_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"Pa\" description=\"Pascal\" uomId=\"PRES_Pa\" uomTypeEnumId=\"UT_PRESSURE_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"psf\" description=\"Pound per square foot\" uomId=\"PRES_psf\" uomTypeEnumId=\"UT_PRESSURE_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"psi\" description=\"Pound per square inch\" uomId=\"PRES_psi\" uomTypeEnumId=\"UT_PRESSURE_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"torr\" description=\"Torr\" uomId=\"PRES_torr\" uomTypeEnumId=\"UT_PRESSURE_MEASURE\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"PRES_atm_Pa\" uomId=\"PRES_atm\" toUomId=\"PRES_Pa\" conversionFactor=\"101325\"/>\n    <moqui.basic.UomConversion uomConversionId=\"PRES_at_Pa\" uomId=\"PRES_at\" toUomId=\"PRES_Pa\" conversionFactor=\"98066.49999787736\"/>\n    <moqui.basic.UomConversion uomConversionId=\"PRES_bar_Pa\" uomId=\"PRES_bar\" toUomId=\"PRES_Pa\" conversionFactor=\"100000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"PRES_hPa_Pa\" uomId=\"PRES_hPa\" toUomId=\"PRES_Pa\" conversionFactor=\"100\"/>\n    <moqui.basic.UomConversion uomConversionId=\"PRES_inHg_Pa\" uomId=\"PRES_inHg\" toUomId=\"PRES_Pa\" conversionFactor=\"3386.39\"/>\n    <moqui.basic.UomConversion uomConversionId=\"PRES_kg_cm2_Pa\" uomId=\"PRES_kg_cm2\" toUomId=\"PRES_Pa\" conversionFactor=\"98066.5\"/>\n    <moqui.basic.UomConversion uomConversionId=\"PRES_kg_m2_Pa\" uomId=\"PRES_kg_m2\" toUomId=\"PRES_Pa\" conversionFactor=\"9.80665\"/>\n    <moqui.basic.UomConversion uomConversionId=\"PRES_kPa_Pa\" uomId=\"PRES_kPa\" toUomId=\"PRES_Pa\" conversionFactor=\"1000\"/>\n    <moqui.basic.UomConversion uomConversionId=\"PRES_mbar_Pa\" uomId=\"PRES_mbar\" toUomId=\"PRES_Pa\" conversionFactor=\"100\"/>\n    <moqui.basic.UomConversion uomConversionId=\"PRES_mmHg_Pa\" uomId=\"PRES_mmHg\" toUomId=\"PRES_Pa\" conversionFactor=\"133.3223684211\"/>\n    <!--<moqui.basic.UomConversion uomConversionId=\"PRES_Pa_Pa\" uomId=\"PRES_Pa\" toUomId=\"PRES_Pa\" conversionFactor=\"1\"/>-->\n    <moqui.basic.UomConversion uomConversionId=\"PRES_psf_Pa\" uomId=\"PRES_psf\" toUomId=\"PRES_Pa\" conversionFactor=\"47.88020833333\"/>\n    <moqui.basic.UomConversion uomConversionId=\"PRES_psi_Pa\" uomId=\"PRES_psi\" toUomId=\"PRES_Pa\" conversionFactor=\"6894.75\"/>\n    <moqui.basic.UomConversion uomConversionId=\"PRES_torr_Pa\" uomId=\"PRES_torr\" toUomId=\"PRES_Pa\" conversionFactor=\"133.3223684211\"/>\n\n    <!-- =============== Temperature =============== -->\n    <moqui.basic.Uom abbreviation=\"K\" description=\"Kelvin\" uomId=\"TEMP_K\" uomTypeEnumId=\"UT_TEMP_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"C\" description=\"Degrees Celsius\" uomId=\"TEMP_C\" uomTypeEnumId=\"UT_TEMP_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"F\" description=\"Degrees Fahrenheit\" uomId=\"TEMP_F\" uomTypeEnumId=\"UT_TEMP_MEASURE\"/>\n\n    <moqui.basic.UomConversion uomConversionId=\"TEMP_C_K\" uomId=\"TEMP_C\" toUomId=\"TEMP_K\" conversionFactor=\"1\" conversionOffset=\"273.15\"/>\n    <moqui.basic.UomConversion uomConversionId=\"TEMP_C_F\" uomId=\"TEMP_C\" toUomId=\"TEMP_F\" conversionFactor=\"1.8\" conversionOffset=\"32\"/>\n\n    <!-- =============== Other =============== -->\n    <moqui.basic.Uom abbreviation=\"A\" description=\"Ampere - Electric current\" uomId=\"OTH_A\" uomTypeEnumId=\"UT_OTHER_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"cd\" description=\"Candela - Luminosity (intensity of light)\" uomId=\"OTH_cd\" uomTypeEnumId=\"UT_OTHER_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"mol\" description=\"Mole - Substance (molecule)\" uomId=\"OTH_mol\" uomTypeEnumId=\"UT_OTHER_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"RPM\" description=\"Revolutions Per Minute\" uomId=\"OTH_RPM\" uomTypeEnumId=\"UT_OTHER_MEASURE\"/>\n\n    <moqui.basic.Uom abbreviation=\"ct\" description=\"Count\" uomId=\"OTH_ct\" uomTypeEnumId=\"UT_OTHER_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"ea\" description=\"Each/Piece\" uomId=\"OTH_ea\" uomTypeEnumId=\"UT_OTHER_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"pp\" description=\"Per Person\" uomId=\"OTH_pp\" uomTypeEnumId=\"UT_OTHER_MEASURE\"/>\n    <moqui.basic.Uom abbreviation=\"pct\" description=\"Percent\" uomId=\"OTH_pct\" uomTypeEnumId=\"UT_OTHER_MEASURE\"/>\n\n    <!-- =============== UOM Dimension Types and Type Groups =============== -->\n    <moqui.basic.UomDimensionType description=\"Quantity Included\" uomDimensionTypeId=\"QuantityIncluded\" defaultUomId=\"OTH_ct\"/>\n    <moqui.basic.UomDimensionType description=\"Pieces Included\" uomDimensionTypeId=\"PiecesIncluded\" defaultUomId=\"OTH_ea\"/>\n    <moqui.basic.UomDimensionType description=\"Weight\" uomDimensionTypeId=\"Weight\" uomTypeEnumId=\"UT_WEIGHT_MEASURE\"/>\n    <moqui.basic.UomDimensionType description=\"Height\" uomDimensionTypeId=\"Height\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.UomDimensionType description=\"Width\" uomDimensionTypeId=\"Width\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.UomDimensionType description=\"Depth\" uomDimensionTypeId=\"Depth\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.UomDimensionType description=\"Volume - Dry\" uomDimensionTypeId=\"Volume\" uomTypeEnumId=\"UT_VOLUME_DRY_MEAS\"/>\n    <moqui.basic.UomDimensionType description=\"Volume - Liquid\" uomDimensionTypeId=\"VolumeLiquid\" uomTypeEnumId=\"UT_VOLUME_LIQ_MEAS\"/>\n    <moqui.basic.UomDimensionType description=\"Diameter\" uomDimensionTypeId=\"Diameter\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.UomDimensionType description=\"Shipping Weight\" uomDimensionTypeId=\"ShippingWeight\" uomTypeEnumId=\"UT_WEIGHT_MEASURE\"/>\n    <moqui.basic.UomDimensionType description=\"Shipping Height\" uomDimensionTypeId=\"ShippingHeight\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.UomDimensionType description=\"Shipping Width\" uomDimensionTypeId=\"ShippingWidth\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n    <moqui.basic.UomDimensionType description=\"Shipping Depth\" uomDimensionTypeId=\"ShippingDepth\" uomTypeEnumId=\"UT_LENGTH_MEASURE\"/>\n\n    <moqui.basic.UomDimTypeGroupMember uomDimTypeGroupEnumId=\"UdtgProduct\" uomDimensionTypeId=\"QuantityIncluded\" sequenceNum=\"5\"/>\n    <moqui.basic.UomDimTypeGroupMember uomDimTypeGroupEnumId=\"UdtgProduct\" uomDimensionTypeId=\"PiecesIncluded\" sequenceNum=\"6\"/>\n    <moqui.basic.UomDimTypeGroupMember uomDimTypeGroupEnumId=\"UdtgProduct\" uomDimensionTypeId=\"Weight\" sequenceNum=\"11\"/>\n    <moqui.basic.UomDimTypeGroupMember uomDimTypeGroupEnumId=\"UdtgProduct\" uomDimensionTypeId=\"Height\" sequenceNum=\"12\"/>\n    <moqui.basic.UomDimTypeGroupMember uomDimTypeGroupEnumId=\"UdtgProduct\" uomDimensionTypeId=\"Width\" sequenceNum=\"13\"/>\n    <moqui.basic.UomDimTypeGroupMember uomDimTypeGroupEnumId=\"UdtgProduct\" uomDimensionTypeId=\"Depth\" sequenceNum=\"14\"/>\n    <moqui.basic.UomDimTypeGroupMember uomDimTypeGroupEnumId=\"UdtgProduct\" uomDimensionTypeId=\"Volume\" sequenceNum=\"15\"/>\n    <moqui.basic.UomDimTypeGroupMember uomDimTypeGroupEnumId=\"UdtgProduct\" uomDimensionTypeId=\"VolumeLiquid\" sequenceNum=\"16\"/>\n    <moqui.basic.UomDimTypeGroupMember uomDimTypeGroupEnumId=\"UdtgProduct\" uomDimensionTypeId=\"Diameter\" sequenceNum=\"17\"/>\n    <moqui.basic.UomDimTypeGroupMember uomDimTypeGroupEnumId=\"UdtgProduct\" uomDimensionTypeId=\"ShippingWeight\" sequenceNum=\"21\"/>\n    <moqui.basic.UomDimTypeGroupMember uomDimTypeGroupEnumId=\"UdtgProduct\" uomDimensionTypeId=\"ShippingHeight\" sequenceNum=\"22\"/>\n    <moqui.basic.UomDimTypeGroupMember uomDimTypeGroupEnumId=\"UdtgProduct\" uomDimensionTypeId=\"ShippingWidth\" sequenceNum=\"23\"/>\n    <moqui.basic.UomDimTypeGroupMember uomDimTypeGroupEnumId=\"UdtgProduct\" uomDimensionTypeId=\"ShippingDepth\" sequenceNum=\"24\"/>\n\n    <moqui.basic.UomDimTypeGroupMember uomDimTypeGroupEnumId=\"UdtgPerson\" uomDimensionTypeId=\"Weight\" sequenceNum=\"5\"/>\n    <moqui.basic.UomDimTypeGroupMember uomDimTypeGroupEnumId=\"UdtgPerson\" uomDimensionTypeId=\"Height\" sequenceNum=\"6\"/>\n    <moqui.basic.UomDimTypeGroupMember uomDimTypeGroupEnumId=\"UdtgPerson\" uomDimensionTypeId=\"Volume\" sequenceNum=\"7\"/>\n</entity-facade-xml>\n"
  },
  {
    "path": "framework/entity/BasicEntities.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<entities xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/entity-definition-3.xsd\">\n\n    <!-- ========================================================= -->\n    <!-- moqui.basic -->\n    <!-- moqui.basic.email -->\n    <!-- moqui.basic.print -->\n    <!-- ========================================================= -->\n\n    <!-- ========== DataSource ========== -->\n    <entity entity-name=\"DataSource\" package=\"moqui.basic\" use=\"nontransactional\" cache=\"true\">\n        <field name=\"dataSourceId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"dataSourceTypeEnumId\" type=\"id\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <relationship type=\"one\" title=\"DataSourceType\" related=\"moqui.basic.Enumeration\">\n            <key-map field-name=\"dataSourceTypeEnumId\"/></relationship>\n        <seed-data>\n            <!-- =========================== Data Source Type Data ==================== -->\n            <moqui.basic.EnumerationType description=\"Data Source Type\" enumTypeId=\"DataSourceType\"/>\n            <moqui.basic.Enumeration description=\"Purchased Data\" enumId=\"DST_PURCHASED_DATA\" enumTypeId=\"DataSourceType\"/>\n            <moqui.basic.Enumeration description=\"Customer Data Entry\" enumId=\"DST_CUSTOMER_ENTRY\" enumTypeId=\"DataSourceType\"/>\n            <moqui.basic.Enumeration description=\"Internal Data Entry (employees, etc)\" enumId=\"DST_INTERNAL_ENTRY\" enumTypeId=\"DataSourceType\"/>\n            <moqui.basic.Enumeration description=\"Mailing List Sign-up\" enumId=\"DST_MAILING_SIGNUP\" enumTypeId=\"DataSourceType\"/>\n        </seed-data>\n    </entity>\n\n    <!-- ========== Enumeration ========== -->\n    <entity entity-name=\"Enumeration\" package=\"moqui.basic\" use=\"configuration\" short-alias=\"enums\" cache=\"true\">\n        <field name=\"enumId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"enumTypeId\" type=\"id\"/>\n        <field name=\"parentEnumId\" type=\"id\"/>\n        <field name=\"enumCode\" type=\"text-medium\"/>\n        <field name=\"sequenceNum\" type=\"number-integer\"/>\n        <field name=\"description\" type=\"text-medium\" enable-localization=\"true\"/>\n        <field name=\"optionValue\" type=\"text-medium\"><description>Usage depends on enum type such as an ID format/mask</description></field>\n        <field name=\"optionIndicator\" type=\"text-indicator\"><description>Indicator flag with meaning depending on enum type</description></field>\n        <field name=\"relatedEnumId\" type=\"id\"/>\n        <field name=\"relatedEnumTypeId\" type=\"id\"/>\n        <field name=\"statusFlowId\" type=\"id\"/>\n        <relationship type=\"one\" related=\"moqui.basic.EnumerationType\" short-alias=\"type\"/>\n        <relationship type=\"one-nofk\" title=\"Parent\" related=\"moqui.basic.Enumeration\" short-alias=\"parent\">\n            <key-map field-name=\"parentEnumId\"/></relationship>\n        <relationship type=\"one-nofk\" title=\"Related\" related=\"moqui.basic.Enumeration\" short-alias=\"related\">\n            <key-map field-name=\"relatedEnumId\"/></relationship>\n        <relationship type=\"one-nofk\" title=\"Related\" related=\"moqui.basic.EnumerationType\" short-alias=\"relatedType\">\n            <key-map field-name=\"relatedEnumTypeId\"/></relationship>\n        <relationship type=\"one\" related=\"moqui.basic.StatusFlow\" short-alias=\"statusFlow\"/>\n        <relationship type=\"many\" related=\"moqui.basic.EnumGroupMember\" short-alias=\"groupMembers\">\n            <key-map field-name=\"enumId\" related=\"enumGroupEnumId\"/></relationship>\n        <seed-data>\n            <moqui.basic.EnumerationType enumTypeId=\"_NA_\" description=\"Not Applicable\"/>\n            <moqui.basic.Enumeration enumId=\"_NA_\" enumTypeId=\"_NA_\" description=\"Not Applicable\"/>\n\n            <moqui.basic.EnumerationType description=\"Boolean (Yes/No)\" enumTypeId=\"BooleanYN\"/>\n            <moqui.basic.Enumeration description=\"Yes\" enumId=\"BlY\" enumTypeId=\"BooleanYN\"/>\n            <moqui.basic.Enumeration description=\"No\" enumId=\"BlN\" enumTypeId=\"BooleanYN\"/>\n\n            <!-- NOTE: It is very important that these remain in the sequence order (1-Monday through 7-Sunday)\n                 so that ZDT calculations (which return ISO8601 values for the day of the week) work properly. -->\n            <moqui.basic.EnumerationType description=\"Day of Week\" enumTypeId=\"DayOfWeek\"/>\n            <moqui.basic.Enumeration description=\"Monday\" enumId=\"DowMonday\" sequenceNum=\"1\" enumCode=\"MONDAY\" enumTypeId=\"DayOfWeek\"/>\n            <moqui.basic.Enumeration description=\"Tuesday\" enumId=\"DowTuesday\" sequenceNum=\"2\" enumCode=\"TUESDAY\" enumTypeId=\"DayOfWeek\"/>\n            <moqui.basic.Enumeration description=\"Wednesday\" enumId=\"DowWednesday\" sequenceNum=\"3\" enumCode=\"WEDNESDAY\" enumTypeId=\"DayOfWeek\"/>\n            <moqui.basic.Enumeration description=\"Thursday\" enumId=\"DowThursday\" sequenceNum=\"4\" enumCode=\"THURSDAY\" enumTypeId=\"DayOfWeek\"/>\n            <moqui.basic.Enumeration description=\"Friday\" enumId=\"DowFriday\" sequenceNum=\"5\" enumCode=\"FRIDAY\" enumTypeId=\"DayOfWeek\"/>\n            <moqui.basic.Enumeration description=\"Saturday\" enumId=\"DowSaturday\" sequenceNum=\"6\" enumCode=\"SATURDAY\" enumTypeId=\"DayOfWeek\"/>\n            <moqui.basic.Enumeration description=\"Sunday\" enumId=\"DowSunday\" sequenceNum=\"7\" enumCode=\"SUNDAY\" enumTypeId=\"DayOfWeek\"/>\n        </seed-data>\n        <master><detail relationship=\"type\"/><detail relationship=\"parent\"/><detail relationship=\"related\"/></master>\n    </entity>\n    <entity entity-name=\"EnumerationType\" package=\"moqui.basic\" use=\"configuration\" short-alias=\"enumerationTypes\" cache=\"true\">\n        <field name=\"enumTypeId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n\n        <relationship type=\"many\" related=\"moqui.basic.Enumeration\" short-alias=\"enums\">\n            <key-map field-name=\"enumTypeId\"/></relationship>\n    </entity>\n    <view-entity entity-name=\"EnumerationAndType\" package=\"moqui.basic\">\n        <member-entity entity-alias=\"ENUM\" entity-name=\"moqui.basic.Enumeration\"/>\n        <member-relationship entity-alias=\"ETP\" join-from-alias=\"ENUM\" relationship=\"type\"/>\n        <alias-all entity-alias=\"ENUM\"/>\n        <alias entity-alias=\"ETP\" name=\"typeDescription\" field=\"description\"/>\n    </view-entity>\n    <entity entity-name=\"EnumGroupMember\" package=\"moqui.basic\" use=\"configuration\" cache=\"true\">\n        <field name=\"enumGroupEnumId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"enumId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"sequenceNum\" type=\"number-integer\"/>\n        <field name=\"memberInfo\" type=\"text-short\"><description>General info about the member, usage is specific to the enum group</description></field>\n        <relationship type=\"one\" title=\"EnumGroup\" related=\"moqui.basic.Enumeration\">\n            <key-map field-name=\"enumGroupEnumId\"/></relationship>\n        <relationship type=\"one\" related=\"moqui.basic.Enumeration\"/>\n        <seed-data>\n            <moqui.basic.EnumerationType description=\"Enumeration Group\" enumTypeId=\"EnumGroup\"/>\n        </seed-data>\n    </entity>\n    <view-entity entity-name=\"EnumAndGroup\" package=\"moqui.basic\" cache=\"true\">\n        <description>For getting EnumGroup members by enumGroupEnumId</description>\n        <member-entity entity-alias=\"EGM\" entity-name=\"moqui.basic.EnumGroupMember\"/>\n        <member-entity entity-alias=\"ENUM\" entity-name=\"moqui.basic.Enumeration\" join-from-alias=\"EGM\">\n            <key-map field-name=\"enumId\"/></member-entity>\n        <alias-all entity-alias=\"EGM\"/>\n        <alias-all entity-alias=\"ENUM\"><exclude field=\"sequenceNum\"/></alias-all>\n        <!-- better not to do this, sequence in group member records: <alias name=\"enumSequenceNum\" entity-alias=\"ENUM\" field=\"sequenceNum\"/> -->\n    </view-entity>\n    <view-entity entity-name=\"ParentEnum\" package=\"moqui.basic\" cache=\"true\">\n        <member-entity entity-alias=\"ENUM\" entity-name=\"moqui.basic.Enumeration\"/>\n        <member-entity entity-alias=\"ENUMP\" entity-name=\"moqui.basic.Enumeration\" join-from-alias=\"ENUM\">\n            <key-map field-name=\"parentEnumId\" related=\"enumId\"/>\n        </member-entity>\n        <alias-all entity-alias=\"ENUMP\"/>\n    </view-entity>\n    <view-entity entity-name=\"EnumAndParent\" package=\"moqui.basic\" cache=\"true\">\n        <member-entity entity-alias=\"ENUM\" entity-name=\"moqui.basic.Enumeration\"/>\n        <member-entity entity-alias=\"ENUMP\" entity-name=\"moqui.basic.Enumeration\" join-from-alias=\"ENUM\" join-optional=\"true\">\n            <key-map field-name=\"parentEnumId\" related=\"enumId\"/>\n        </member-entity>\n        <alias-all entity-alias=\"ENUM\"><exclude field=\"description\"/></alias-all>\n        <alias-all entity-alias=\"ENUMP\" prefix=\"parent\"><exclude field=\"enumId\"/></alias-all>\n        <alias name=\"enumDescription\" entity-alias=\"ENUM\" field=\"description\"/>\n        <alias name=\"description\" type=\"text-medium\">\n            <complex-alias operator=\"||\">\n                <complex-alias-field entity-alias=\"ENUMP\" field=\"description\"/>\n                <complex-alias expression=\"' - '\"/>\n                <complex-alias-field entity-alias=\"ENUM\" field=\"description\"/>\n            </complex-alias>\n        </alias>\n    </view-entity>\n    <view-entity entity-name=\"EnumGroupEnumAndParent\" package=\"moqui.basic\" cache=\"true\">\n        <member-entity entity-alias=\"EGM\" entity-name=\"moqui.basic.EnumGroupMember\"/>\n        <member-entity entity-alias=\"ENUM\" entity-name=\"moqui.basic.Enumeration\" join-from-alias=\"EGM\">\n            <key-map field-name=\"enumId\"/></member-entity>\n        <member-entity entity-alias=\"ENUMP\" entity-name=\"moqui.basic.Enumeration\" join-from-alias=\"ENUM\" join-optional=\"true\">\n            <key-map field-name=\"parentEnumId\" related=\"enumId\"/>\n        </member-entity>\n        <alias-all entity-alias=\"EGM\"/>\n        <alias-all entity-alias=\"ENUM\">\n            <exclude field=\"sequenceNum\"/>\n            <exclude field=\"description\"/>\n        </alias-all>\n        <alias-all entity-alias=\"ENUMP\" prefix=\"parent\"><exclude field=\"enumId\"/></alias-all>\n        <alias name=\"enumDescription\" entity-alias=\"ENUM\" field=\"description\"/>\n        <alias name=\"description\" type=\"text-medium\">\n            <complex-alias operator=\"||\">\n                <complex-alias-field entity-alias=\"ENUMP\" field=\"description\"/>\n                <complex-alias expression=\"' - '\"/>\n                <complex-alias-field entity-alias=\"ENUM\" field=\"description\"/>\n            </complex-alias>\n        </alias>\n    </view-entity>\n\n    <!-- ========== Geo ========== -->\n    <entity entity-name=\"Geo\" package=\"moqui.basic\" use=\"configuration\" short-alias=\"geos\" cache=\"true\">\n        <field name=\"geoId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"geoTypeEnumId\" type=\"id\"/>\n        <field name=\"geoName\" type=\"text-medium\"/>\n        <field name=\"geoNameLocal\" type=\"text-medium\"/>\n        <field name=\"geoCodeAlpha2\" type=\"text-short\"/>\n        <field name=\"geoCodeAlpha3\" type=\"text-short\"/>\n        <field name=\"geoCodeNumeric\" type=\"text-short\"/>\n        <field name=\"wellKnownText\" type=\"text-very-long\"/>\n        <relationship type=\"one\" title=\"GeoType\" related=\"moqui.basic.Enumeration\" short-alias=\"type\">\n            <key-map field-name=\"geoTypeEnumId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.basic.GeoAssoc\" short-alias=\"assocs\">\n            <key-map field-name=\"geoId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.basic.GeoAssoc\" short-alias=\"toAssocs\">\n            <key-map field-name=\"geoId\" related=\"toGeoId\"/></relationship>\n        <seed-data>\n            <!-- A placeholder for when there is no Geo -->\n            <moqui.basic.Geo geoId=\"_NA_\" geoName=\"Not Applicable\" geoCodeAlpha3=\"_NA\" geoCodeAlpha2=\"_N\"/>\n\n            <!-- =========================== Geo Type Data ============================ -->\n            <moqui.basic.EnumerationType description=\"Geo Type\" enumTypeId=\"GeoType\"/>\n\n            <!-- General Geographic Groupings -->\n            <moqui.basic.Enumeration description=\"Group\" enumId=\"GEOT_GROUP\" enumTypeId=\"GeoType\"/>\n            <moqui.basic.Enumeration description=\"Region\" enumId=\"GEOT_REGION\" enumTypeId=\"GeoType\"/>\n            <moqui.basic.Enumeration description=\"Sales Region\" enumId=\"GEOT_SALES_REGION\" enumTypeId=\"GeoType\"/>\n            <moqui.basic.Enumeration description=\"Service Region\" enumId=\"GEOT_SERVICE_REGION\" enumTypeId=\"GeoType\"/>\n\n            <!-- Legal/Governmental Geographic Groupings based on Juris-diction -->\n            <moqui.basic.Enumeration description=\"City\" enumId=\"GEOT_CITY\" enumTypeId=\"GeoType\"/>\n            <moqui.basic.Enumeration description=\"State\" enumId=\"GEOT_STATE\" enumTypeId=\"GeoType\"/>\n            <moqui.basic.Enumeration description=\"Postal Code\" enumId=\"GEOT_POSTAL_CODE\" enumTypeId=\"GeoType\"/>\n            <moqui.basic.Enumeration description=\"Country\" enumId=\"GEOT_COUNTRY\" enumTypeId=\"GeoType\"/>\n            <moqui.basic.Enumeration description=\"County\" enumId=\"GEOT_COUNTY\" enumTypeId=\"GeoType\"/>\n            <moqui.basic.Enumeration description=\"County-City\" enumId=\"GEOT_COUNTY_CITY\" enumTypeId=\"GeoType\"/>\n            <moqui.basic.Enumeration description=\"Municipality\" enumId=\"GEOT_MUNICIPALITY\" enumTypeId=\"GeoType\"/>\n            <moqui.basic.Enumeration description=\"Province\" enumId=\"GEOT_PROVINCE\" enumTypeId=\"GeoType\"/>\n            <moqui.basic.Enumeration description=\"Territory\" enumId=\"GEOT_TERRITORY\" enumTypeId=\"GeoType\"/>\n        </seed-data>\n        <master>\n            <detail relationship=\"type\"/>\n            <detail relationship=\"assocs\"><detail relationship=\"toGeo\"/><detail relationship=\"type\"/></detail>\n            <detail relationship=\"toAssocs\"><detail relationship=\"geo\"/><detail relationship=\"type\"/></detail>\n        </master>\n    </entity>\n    <view-entity entity-name=\"GeoAndType\" package=\"moqui.basic\">\n        <member-entity entity-alias=\"GEO\" entity-name=\"moqui.basic.Geo\"/>\n        <member-entity entity-alias=\"GTE\" entity-name=\"moqui.basic.Enumeration\" join-from-alias=\"GEO\">\n            <key-map field-name=\"geoTypeEnumId\" related=\"enumId\"/></member-entity>\n        <alias-all entity-alias=\"GEO\"/>\n        <alias-all entity-alias=\"GTE\" prefix=\"type\"/>\n    </view-entity>\n    <entity entity-name=\"GeoAssoc\" package=\"moqui.basic\" use=\"configuration\" cache=\"true\">\n        <field name=\"geoId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"toGeoId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"geoAssocTypeEnumId\" type=\"id\"/>\n        <relationship type=\"one\" title=\"Main\" related=\"moqui.basic.Geo\" short-alias=\"geo\"/>\n        <relationship type=\"one\" title=\"Assoc\" related=\"moqui.basic.Geo\" short-alias=\"toGeo\">\n            <key-map field-name=\"toGeoId\"/></relationship>\n        <relationship type=\"one\" title=\"GeoAssocType\" related=\"moqui.basic.Enumeration\" short-alias=\"type\">\n            <key-map field-name=\"geoAssocTypeEnumId\"/></relationship>\n        <seed-data>\n            <moqui.basic.EnumerationType description=\"Geo Assoc Type\" enumTypeId=\"GeoAssocType\"/>\n            <moqui.basic.Enumeration description=\"Geo Group Member\" enumId=\"GAT_GROUP_MEMBER\" enumTypeId=\"GeoAssocType\"/>\n            <moqui.basic.Enumeration description=\"Region of a Larger Geo\" enumId=\"GAT_REGIONS\" enumTypeId=\"GeoAssocType\"/>\n            <moqui.basic.Enumeration description=\"Administrative City\" enumId=\"GAT_COUNTY_SEAT\" enumTypeId=\"GeoAssocType\"/>\n        </seed-data>\n    </entity>\n    <view-entity entity-name=\"GeoAssocAndToDetail\" package=\"moqui.basic\">\n        <member-entity entity-alias=\"GEOA\" entity-name=\"moqui.basic.GeoAssoc\"/>\n        <member-entity entity-alias=\"GEOTO\" entity-name=\"moqui.basic.Geo\" join-from-alias=\"GEOA\">\n            <key-map field-name=\"toGeoId\" related=\"geoId\"/></member-entity>\n        <alias-all entity-alias=\"GEOA\"/>\n        <alias-all entity-alias=\"GEOTO\"><exclude field=\"geoId\"/></alias-all>\n    </view-entity>\n    <view-entity entity-name=\"GeoAssocAndFromDetail\" package=\"moqui.basic\">\n        <member-entity entity-alias=\"GEOA\" entity-name=\"moqui.basic.GeoAssoc\"/>\n        <member-entity entity-alias=\"GEOFR\" entity-name=\"moqui.basic.Geo\" join-from-alias=\"GEOA\">\n            <key-map field-name=\"geoId\"/></member-entity>\n        <alias-all entity-alias=\"GEOA\"/>\n        <alias-all entity-alias=\"GEOFR\"><exclude field=\"geoId\"/></alias-all>\n    </view-entity>\n    <view-entity entity-name=\"GeoTypeAndAssocFromDetail\" package=\"moqui.basic\">\n        <member-entity entity-alias=\"GEO\" entity-name=\"moqui.basic.Geo\"/>\n        <member-entity entity-alias=\"GTE\" entity-name=\"moqui.basic.Enumeration\" join-from-alias=\"GEO\">\n            <key-map field-name=\"geoTypeEnumId\" related=\"enumId\"/></member-entity>\n        <member-entity entity-alias=\"GEOA\" entity-name=\"moqui.basic.GeoAssoc\" join-from-alias=\"GEO\" join-optional=\"true\">\n            <key-map field-name=\"geoId\" related=\"toGeoId\"/></member-entity>\n        <member-entity entity-alias=\"GEOFR\" entity-name=\"moqui.basic.Geo\" join-from-alias=\"GEOA\" join-optional=\"true\">\n            <key-map field-name=\"geoId\"/></member-entity>\n        <alias-all entity-alias=\"GEO\"/>\n        <alias-all entity-alias=\"GTE\"/>\n        <alias-all entity-alias=\"GEOA\"/>\n        <alias-all entity-alias=\"GEOFR\" prefix=\"from\"/>\n    </view-entity>\n    <entity entity-name=\"GeoPoint\" package=\"moqui.basic\" use=\"configuration\" short-alias=\"geoPoints\" cache=\"true\">\n        <field name=\"geoPointId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"geoPointTypeEnumId\" type=\"id\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"dataSourceId\" type=\"id\"/>\n        <field name=\"latitude\" type=\"number-float\"/>\n        <field name=\"longitude\" type=\"number-float\"/>\n        <field name=\"elevation\" type=\"number-float\"/>\n        <field name=\"elevationUomId\" type=\"id\"/>\n        <field name=\"information\" type=\"text-medium\"/>\n        <relationship type=\"one\" title=\"GeoPointType\" related=\"moqui.basic.Enumeration\" short-alias=\"type\">\n            <key-map field-name=\"geoPointTypeEnumId\"/></relationship>\n        <relationship type=\"one\" related=\"moqui.basic.DataSource\" short-alias=\"dataSource\"/>\n        <relationship type=\"one\" title=\"Elevation\" related=\"moqui.basic.Uom\" short-alias=\"elevationUom\">\n            <key-map field-name=\"elevationUomId\"/></relationship>\n        <seed-data>\n            <moqui.basic.EnumerationType description=\"Geo Point Type\" enumTypeId=\"GeoPointType\"/>\n        </seed-data>\n        <master><detail relationship=\"type\"/><detail relationship=\"dataSource\"/><detail relationship=\"elevationUom\"/></master>\n    </entity>\n\n    <!-- ========== Localized ========== -->\n    <entity entity-name=\"LocalizedMessage\" package=\"moqui.basic\" use=\"configuration\" authorize-skip=\"view\" cache=\"true\">\n        <field name=\"original\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"locale\" type=\"text-short\" is-pk=\"true\"/>\n        <field name=\"localized\" type=\"text-long\"/>\n    </entity>\n    <entity entity-name=\"LocalizedEntityField\" package=\"moqui.basic\" use=\"configuration\" authorize-skip=\"view\" cache=\"true\">\n        <field name=\"entityName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"fieldName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"pkValue\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"locale\" type=\"text-short\" is-pk=\"true\"/>\n        <field name=\"localized\" type=\"text-long\"/>\n    </entity>\n\n    <!-- ========== Status ========== -->\n    <entity entity-name=\"StatusItem\" package=\"moqui.basic\" use=\"configuration\" short-alias=\"statuses\" cache=\"true\">\n        <field name=\"statusId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"statusTypeId\" type=\"id\"/>\n        <field name=\"statusCode\" type=\"text-medium\"/>\n        <field name=\"sequenceNum\" type=\"number-integer\"/>\n        <field name=\"description\" type=\"text-medium\" enable-localization=\"true\"/>\n        <relationship type=\"one\" related=\"moqui.basic.StatusType\" short-alias=\"type\"/>\n        <relationship type=\"many\" related=\"moqui.basic.StatusFlowItem\" short-alias=\"flows\">\n            <key-map field-name=\"statusId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.basic.StatusFlowTransition\" short-alias=\"transitions\">\n            <key-map field-name=\"statusId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.basic.StatusFlowTransition\" short-alias=\"toTransitions\">\n            <key-map field-name=\"statusId\" related=\"toStatusId\"/></relationship>\n        <seed-data>\n            <moqui.basic.StatusType statusTypeId=\"_NA_\" description=\"Not Applicable\"/>\n            <moqui.basic.StatusItem statusId=\"_NA_\" statusTypeId=\"_NA_\" description=\"Not Applicable\"/>\n        </seed-data>\n        <master>\n            <detail relationship=\"type\"/>\n            <detail relationship=\"flows\"><detail relationship=\"flow\"/></detail>\n            <detail relationship=\"transitions\"><detail relationship=\"flow\"/><detail relationship=\"toStatus\"/></detail>\n            <detail relationship=\"toTransitions\"><detail relationship=\"flow\"/><detail relationship=\"status\"/></detail>\n        </master>\n    </entity>\n    <entity entity-name=\"StatusType\" package=\"moqui.basic\" use=\"configuration\" short-alias=\"statusTypes\">\n        <field name=\"statusTypeId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"parentTypeId\" type=\"id\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <relationship type=\"one\" title=\"Parent\" related=\"moqui.basic.StatusType\">\n            <!-- NOTE: related is necessary here because it is a reference back to the same entity and\n                 will find the parentTypeId as the related field because it matches by name. -->\n            <key-map field-name=\"parentTypeId\" related=\"statusTypeId\"/>\n        </relationship>\n    </entity>\n    <entity entity-name=\"StatusFlow\" package=\"moqui.basic\" use=\"configuration\">\n        <field name=\"statusFlowId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"statusTypeId\" type=\"id\"><description>Optional. If specified uses status items with this type.</description></field>\n        <field name=\"description\" type=\"text-medium\"/>\n        <relationship type=\"one\" related=\"moqui.basic.StatusType\" short-alias=\"type\"/>\n        <seed-data>\n            <moqui.basic.StatusFlow statusFlowId=\"Default\" description=\"Default status flow across entire system.\"/>\n        </seed-data>\n    </entity>\n    <entity entity-name=\"StatusFlowItem\" package=\"moqui.basic\" use=\"configuration\">\n        <field name=\"statusFlowId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"statusId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"isInitial\" type=\"text-indicator\"><description>If true can be an initial status in this flow.</description></field>\n        <relationship type=\"one\" related=\"moqui.basic.StatusFlow\" short-alias=\"flow\"/>\n        <relationship type=\"one\" related=\"moqui.basic.StatusItem\" short-alias=\"status\"/>\n    </entity>\n    <entity entity-name=\"StatusFlowTransition\" package=\"moqui.basic\" use=\"configuration\" cache=\"true\">\n        <field name=\"statusFlowId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"statusId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"toStatusId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"transitionSequence\" type=\"number-integer\"/>\n        <field name=\"transitionName\" type=\"text-medium\" enable-localization=\"true\"/>\n        <field name=\"conditionExpression\" type=\"text-medium\"><description>Not currently supported, may be removed, issue with what is the context for the expression</description></field>\n        <field name=\"userPermissionId\" type=\"id-long\"/>\n        <relationship type=\"one\" related=\"moqui.basic.StatusFlow\" short-alias=\"flow\"/>\n        <relationship type=\"one\" related=\"moqui.basic.StatusItem\" short-alias=\"status\"/>\n        <relationship type=\"one\" title=\"To\" related=\"moqui.basic.StatusItem\" short-alias=\"toStatus\">\n            <key-map field-name=\"toStatusId\" related=\"statusId\"/></relationship>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserPermission\" short-alias=\"permission\">\n            <description>No FK in order to allow arbitrary permissions (ie not pre-configured).</description></relationship>\n    </entity>\n    <view-entity entity-name=\"StatusFlowItemDetail\" package=\"moqui.basic\">\n        <member-entity entity-alias=\"SFI\" entity-name=\"moqui.basic.StatusFlowItem\"/>\n        <member-entity entity-alias=\"SI\" entity-name=\"moqui.basic.StatusItem\" join-from-alias=\"SFI\">\n            <key-map field-name=\"statusId\"/></member-entity>\n        <alias-all entity-alias=\"SFI\"/>\n        <alias-all entity-alias=\"SI\"><exclude field=\"statusId\"/></alias-all>\n    </view-entity>\n    <view-entity entity-name=\"StatusFlowTransitionToDetail\" package=\"moqui.basic\">\n        <member-entity entity-alias=\"SFT\" entity-name=\"moqui.basic.StatusFlowTransition\"/>\n        <member-entity entity-alias=\"SI\" entity-name=\"moqui.basic.StatusItem\" join-from-alias=\"SFT\">\n            <key-map field-name=\"toStatusId\" related=\"statusId\"/></member-entity>\n        <alias-all entity-alias=\"SFT\"/>\n        <alias-all entity-alias=\"SI\"><exclude field=\"statusId\"/></alias-all>\n        <entity-condition><order-by field-name=\"sequenceNum\"/></entity-condition>\n    </view-entity>\n    <view-entity entity-name=\"StatusFlowTransitionFromAndTo\" package=\"moqui.basic\">\n        <member-entity entity-alias=\"SFT\" entity-name=\"moqui.basic.StatusFlowTransition\"/>\n        <member-entity entity-alias=\"SIFR\" entity-name=\"moqui.basic.StatusItem\" join-from-alias=\"SFT\">\n            <key-map field-name=\"statusId\" related=\"statusId\"/></member-entity>\n        <member-entity entity-alias=\"SITO\" entity-name=\"moqui.basic.StatusItem\" join-from-alias=\"SFT\">\n            <key-map field-name=\"toStatusId\" related=\"statusId\"/></member-entity>\n        <alias-all entity-alias=\"SFT\"/>\n        <alias-all entity-alias=\"SIFR\" prefix=\"from\"><exclude field=\"statusId\"/></alias-all>\n        <alias-all entity-alias=\"SITO\" prefix=\"to\"><exclude field=\"statusId\"/></alias-all>\n    </view-entity>\n\n    <!-- ========== Uom ========== -->\n    <entity entity-name=\"Uom\" package=\"moqui.basic\" use=\"configuration\" short-alias=\"uoms\" cache=\"true\">\n        <field name=\"uomId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"uomTypeEnumId\" type=\"id\"/>\n        <field name=\"abbreviation\" type=\"text-short\"/>\n        <field name=\"description\" type=\"text-medium\" enable-localization=\"true\"/>\n        <field name=\"fractionDigits\" type=\"number-integer\"/>\n        <field name=\"symbol\" type=\"text-short\" enable-localization=\"true\"/>\n        <relationship type=\"one\" title=\"UomType\" related=\"moqui.basic.Enumeration\" short-alias=\"type\">\n            <key-map field-name=\"uomTypeEnumId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.basic.UomConversion\" short-alias=\"conversions\">\n            <key-map field-name=\"uomId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.basic.UomConversion\" short-alias=\"toConversions\">\n            <key-map field-name=\"uomId\" related=\"toUomId\"/></relationship>\n        <seed-data>\n            <!-- =========================== UOM Type Data ============================ -->\n            <moqui.basic.EnumerationType description=\"UOM Type\" enumTypeId=\"UomType\"/>\n            <moqui.basic.Enumeration description=\"Currency\" enumId=\"UT_CURRENCY_MEASURE\" enumTypeId=\"UomType\"/>\n            <moqui.basic.Enumeration description=\"Data Size\" enumId=\"UT_DATA_MEASURE\" enumTypeId=\"UomType\"/>\n            <moqui.basic.Enumeration description=\"Data Speed\" enumId=\"UT_DATASPD_MEASURE\" enumTypeId=\"UomType\"/>\n            <moqui.basic.Enumeration description=\"Time/Frequency\" enumId=\"UT_TIME_FREQ_MEASURE\" enumTypeId=\"UomType\"/>\n            <moqui.basic.Enumeration description=\"Length\" enumId=\"UT_LENGTH_MEASURE\" enumTypeId=\"UomType\"/>\n            <moqui.basic.Enumeration description=\"Velocity\" enumId=\"UT_VELOCITY_MEASURE\" enumTypeId=\"UomType\"/>\n            <moqui.basic.Enumeration description=\"Area\" enumId=\"UT_AREA_MEASURE\" enumTypeId=\"UomType\"/>\n            <moqui.basic.Enumeration description=\"Liquid Volume\" enumId=\"UT_VOLUME_LIQ_MEAS\" enumTypeId=\"UomType\"/>\n            <moqui.basic.Enumeration description=\"Dry Volume\" enumId=\"UT_VOLUME_DRY_MEAS\" enumTypeId=\"UomType\"/>\n            <moqui.basic.Enumeration description=\"Density\" enumId=\"UT_DENSITY_MEAS\" enumTypeId=\"UomType\"/>\n            <moqui.basic.Enumeration description=\"Weight\" enumId=\"UT_WEIGHT_MEASURE\" enumTypeId=\"UomType\"/>\n            <moqui.basic.Enumeration description=\"Energy\" enumId=\"UT_ENERGY_MEASURE\" enumTypeId=\"UomType\"/>\n            <moqui.basic.Enumeration description=\"Power\" enumId=\"UT_POWER_MEASURE\" enumTypeId=\"UomType\"/>\n            <moqui.basic.Enumeration description=\"Pressure\" enumId=\"UT_PRESSURE_MEASURE\" enumTypeId=\"UomType\"/>\n            <moqui.basic.Enumeration description=\"Temperature\" enumId=\"UT_TEMP_MEASURE\" enumTypeId=\"UomType\"/>\n            <moqui.basic.Enumeration description=\"Other\" enumId=\"UT_OTHER_MEASURE\" enumTypeId=\"UomType\"/>\n\n            <!-- see additional seed data in UnitData.xml -->\n        </seed-data>\n        <master>\n            <detail relationship=\"type\"/>\n            <detail relationship=\"conversions\"><detail relationship=\"toUom\"/></detail>\n            <detail relationship=\"toConversions\"><detail relationship=\"uom\"/></detail>\n        </master>\n    </entity>\n    <view-entity entity-name=\"UomAndType\" package=\"moqui.basic\" cache=\"true\">\n        <member-entity entity-alias=\"UOM\" entity-name=\"moqui.basic.Uom\"/>\n        <member-entity entity-alias=\"UTE\" entity-name=\"moqui.basic.Enumeration\" join-from-alias=\"UOM\">\n            <key-map field-name=\"uomTypeEnumId\" related=\"enumId\"/></member-entity>\n        <alias entity-alias=\"UOM\" name=\"uomId\"/>\n        <alias entity-alias=\"UOM\" name=\"uomTypeEnumId\"/>\n        <alias entity-alias=\"UOM\" name=\"description\"/>\n        <alias entity-alias=\"UOM\" name=\"abbreviation\"/>\n        <alias entity-alias=\"UTE\" name=\"typeDescription\" field=\"description\"/>\n    </view-entity>\n    <entity entity-name=\"UomConversion\" package=\"moqui.basic\" use=\"configuration\" cache=\"true\">\n        <field name=\"uomConversionId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"uomId\" type=\"id\"/>\n        <field name=\"toUomId\" type=\"id\"/>\n        <field name=\"fromDate\" type=\"date-time\"/>\n        <field name=\"thruDate\" type=\"date-time\"/>\n        <field name=\"conversionFactor\" type=\"number-float\"/>\n        <field name=\"conversionOffset\" type=\"number-decimal\">\n            <description>The factor is multiplied first, then the offset is added. When converting in the reverse\n                direction the offset is subtracted first, then divided by the factor.</description>\n        </field>\n        <field name=\"purposeEnumId\" type=\"id\"/>\n        <relationship type=\"one\" related=\"moqui.basic.Uom\" short-alias=\"uom\"/>\n        <relationship type=\"one\" title=\"To\" related=\"moqui.basic.Uom\" short-alias=\"toUom\">\n            <key-map field-name=\"toUomId\" related=\"uomId\"/></relationship>\n        <relationship type=\"one\" related=\"moqui.basic.Enumeration\" title=\"UomConversionPurpose\">\n            <key-map field-name=\"purposeEnumId\" related=\"enumId\"/></relationship>\n        <seed-data>\n            <moqui.basic.EnumerationType description=\"Uom Conversion Purpose\" enumTypeId=\"UomConversionPurpose\"/>\n\n            <!-- see additional seed data in UnitData.xml -->\n        </seed-data>\n    </entity>\n    <view-entity entity-name=\"UomConversionAndToDetail\" package=\"moqui.basic\">\n        <member-entity entity-alias=\"UOMC\" entity-name=\"moqui.basic.UomConversion\"/>\n        <member-entity entity-alias=\"UOMTO\" entity-name=\"moqui.basic.Uom\" join-from-alias=\"UOMC\">\n            <key-map field-name=\"toUomId\" related=\"uomId\"/></member-entity>\n        <alias-all entity-alias=\"UOMC\"/>\n        <alias-all entity-alias=\"UOMTO\"><exclude field=\"uomId\"/></alias-all>\n    </view-entity>\n    <view-entity entity-name=\"UomConversionAndFromDetail\" package=\"moqui.basic\">\n        <member-entity entity-alias=\"UOMC\" entity-name=\"moqui.basic.UomConversion\"/>\n        <member-entity entity-alias=\"UOMFR\" entity-name=\"moqui.basic.Uom\" join-from-alias=\"UOMC\">\n            <key-map field-name=\"uomId\"/></member-entity>\n        <alias-all entity-alias=\"UOMC\"/>\n        <alias-all entity-alias=\"UOMFR\"><exclude field=\"uomId\"/></alias-all>\n    </view-entity>\n\n    <entity entity-name=\"UomGroupMember\" package=\"moqui.basic\" use=\"configuration\" cache=\"true\">\n        <field name=\"uomGroupEnumId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"uomId\" type=\"id\" is-pk=\"true\"/>\n        <relationship type=\"one\" title=\"UomGroup\" related=\"moqui.basic.Enumeration\">\n            <key-map field-name=\"uomGroupEnumId\"/></relationship>\n        <relationship type=\"one\" related=\"moqui.basic.Uom\"/>\n        <seed-data>\n            <moqui.basic.EnumerationType description=\"UOM Group\" enumTypeId=\"UomGroup\"/>\n        </seed-data>\n    </entity>\n    <view-entity entity-name=\"UomAndGroup\" package=\"moqui.basic\" cache=\"true\">\n        <member-entity entity-alias=\"UOM\" entity-name=\"moqui.basic.Uom\"/>\n        <member-entity entity-alias=\"UGM\" entity-name=\"moqui.basic.UomGroupMember\" join-from-alias=\"UOM\">\n            <key-map field-name=\"uomId\"/></member-entity>\n        <alias-all entity-alias=\"UOM\"/>\n        <alias-all entity-alias=\"UGM\"/>\n    </view-entity>\n    <entity entity-name=\"UomDimensionType\" package=\"moqui.basic\" use=\"configuration\" cache=\"true\">\n        <field name=\"uomDimensionTypeId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"uomTypeEnumId\" type=\"id\"/>\n        <field name=\"defaultUomId\" type=\"id\"/>\n        <relationship type=\"one\" title=\"UomType\" related=\"moqui.basic.Enumeration\" short-alias=\"uomType\">\n            <key-map field-name=\"uomTypeEnumId\"/></relationship>\n        <relationship type=\"one\" title=\"Default\" related=\"moqui.basic.Uom\" short-alias=\"defaultUom\">\n            <key-map field-name=\"defaultUomId\"/></relationship>\n        <!-- see seed data in UnitData.xml -->\n    </entity>\n    <entity entity-name=\"UomDimTypeGroupMember\" package=\"moqui.basic\" use=\"configuration\" cache=\"true\">\n        <field name=\"uomDimTypeGroupEnumId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"uomDimensionTypeId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"sequenceNum\" type=\"number-integer\"/>\n        <relationship type=\"one\" title=\"UomDimTypeGroup\" related=\"moqui.basic.Enumeration\" short-alias=\"group\">\n            <key-map field-name=\"uomDimTypeGroupEnumId\"/></relationship>\n        <relationship type=\"one\" related=\"moqui.basic.UomDimensionType\" short-alias=\"type\"/>\n        <seed-data>\n            <moqui.basic.EnumerationType description=\"UOM Dimension Type Group\" enumTypeId=\"UomDimTypeGroup\"/>\n            <moqui.basic.Enumeration description=\"Product\" enumId=\"UdtgProduct\" enumTypeId=\"UomDimTypeGroup\"/>\n            <moqui.basic.Enumeration description=\"Party\" enumId=\"UdtgParty\" enumTypeId=\"UomDimTypeGroup\"/>\n            <moqui.basic.Enumeration description=\"Person\" enumId=\"UdtgPerson\" parentEnumId=\"UdtgParty\" enumTypeId=\"UomDimTypeGroup\"/>\n            <moqui.basic.Enumeration description=\"Organization\" enumId=\"UdtgOrg\" parentEnumId=\"UdtgParty\" enumTypeId=\"UomDimTypeGroup\"/>\n        </seed-data>\n    </entity>\n    <view-entity entity-name=\"UomDimensionTypeAndGroup\" package=\"moqui.basic\" cache=\"true\">\n        <member-entity entity-alias=\"UDTGM\" entity-name=\"moqui.basic.UomDimTypeGroupMember\"/>\n        <member-relationship entity-alias=\"UDT\" join-from-alias=\"UDTGM\" relationship=\"type\"/>\n        <alias-all entity-alias=\"UDTGM\"/>\n        <alias-all entity-alias=\"UDT\"/>\n    </view-entity>\n\n    <!-- ========================================================= -->\n    <!-- moqui.basic.email -->\n    <!-- ========================================================= -->\n\n    <entity entity-name=\"EmailMessage\" package=\"moqui.basic.email\" use=\"nontransactional\" short-alias=\"emailMessages\" cache=\"never\">\n        <field name=\"emailMessageId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"rootEmailMessageId\" type=\"id\">\n            <description>For threaded messages, this points to the message that started the thread.</description></field>\n        <field name=\"parentEmailMessageId\" type=\"id\">\n            <description>For threaded messages, this points to the previous message in the thread.</description></field>\n        <field name=\"statusId\" type=\"id\" enable-audit-log=\"true\"/>\n        <field name=\"emailTypeEnumId\" type=\"id\"/>\n        <field name=\"sentDate\" type=\"date-time\"/>\n        <field name=\"receivedDate\" type=\"date-time\"/>\n        <field name=\"subject\" type=\"text-medium\"/>\n        <field name=\"body\" type=\"text-very-long\"/>\n        <field name=\"bodyText\" type=\"text-very-long\"><description>The plain text variation of the body</description></field>\n        <!-- Removed because unused and causes row size to be too long (in MySQL, others?): <field name=\"note\" type=\"text-long\"/> -->\n        <field name=\"headersString\" type=\"text-very-long\"/>\n        <field name=\"fromAddress\" type=\"text-medium\"/>\n        <field name=\"fromName\" type=\"text-medium\"/>\n        <field name=\"toAddresses\" type=\"text-long\"/>\n        <field name=\"ccAddresses\" type=\"text-long\"/>\n        <field name=\"bccAddresses\" type=\"text-long\"/>\n        <field name=\"contentType\" type=\"text-medium\"/>\n        <field name=\"messageId\" type=\"text-medium\"/>\n        <field name=\"fromUserId\" type=\"id\"/>\n        <field name=\"toUserId\" type=\"id\"/>\n        <field name=\"emailTemplateId\" type=\"id\"><description>For outgoing messages that came from an EmailTemplate.</description></field>\n        <field name=\"emailServerId\" type=\"id\"/>\n\n        <relationship type=\"one\" title=\"EmailMessage\" related=\"moqui.basic.StatusItem\" short-alias=\"status\"/>\n        <relationship type=\"one\" title=\"EmailType\" related=\"moqui.basic.Enumeration\" short-alias=\"type\">\n            <key-map field-name=\"emailTypeEnumId\"/></relationship>\n        <relationship type=\"one\" title=\"Root\" related=\"moqui.basic.email.EmailMessage\" short-alias=\"root\">\n            <key-map field-name=\"rootEmailMessageId\" related=\"emailMessageId\"/></relationship>\n        <relationship type=\"one\" title=\"Parent\" related=\"moqui.basic.email.EmailMessage\" short-alias=\"parent\">\n            <key-map field-name=\"parentEmailMessageId\" related=\"emailMessageId\"/></relationship>\n        <relationship type=\"many\" title=\"Child\" related=\"moqui.basic.email.EmailMessage\" short-alias=\"children\">\n            <key-map field-name=\"emailMessageId\" related=\"parentEmailMessageId\"/></relationship>\n        <relationship type=\"one\" title=\"From\" related=\"moqui.security.UserAccount\" short-alias=\"fromUser\">\n            <key-map field-name=\"fromUserId\"/></relationship>\n        <relationship type=\"one\" title=\"To\" related=\"moqui.security.UserAccount\" short-alias=\"toUser\">\n            <key-map field-name=\"toUserId\"/></relationship>\n        <relationship type=\"one\" related=\"moqui.basic.email.EmailTemplate\" short-alias=\"template\"/>\n        <relationship type=\"one\" related=\"moqui.basic.email.EmailServer\" short-alias=\"server\"/>\n\n        <index name=\"EMAIL_MSG_ID\"><index-field name=\"messageId\"/></index>\n\n        <seed-data>\n            <!-- Email Message Status -->\n            <moqui.basic.StatusType description=\"Email Message Status\" statusTypeId=\"EmailMessage\"/>\n            <moqui.basic.StatusItem description=\"Draft\" sequenceNum=\"1\" statusId=\"ES_DRAFT\" statusTypeId=\"EmailMessage\"/>\n            <moqui.basic.StatusItem description=\"Ready\" sequenceNum=\"2\" statusId=\"ES_READY\" statusTypeId=\"EmailMessage\"/>\n            <moqui.basic.StatusItem description=\"Sent\" sequenceNum=\"3\" statusId=\"ES_SENT\" statusTypeId=\"EmailMessage\"/>\n            <moqui.basic.StatusItem description=\"Received\" sequenceNum=\"4\" statusId=\"ES_RECEIVED\" statusTypeId=\"EmailMessage\"/>\n            <moqui.basic.StatusItem description=\"Viewed\" sequenceNum=\"5\" statusId=\"ES_VIEWED\" statusTypeId=\"EmailMessage\"/>\n            <moqui.basic.StatusItem description=\"Bounced\" sequenceNum=\"8\" statusId=\"ES_BOUNCED\" statusTypeId=\"EmailMessage\"/>\n            <moqui.basic.StatusItem description=\"Cancelled\" sequenceNum=\"9\" statusId=\"ES_CANCELLED\" statusTypeId=\"EmailMessage\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"ES_DRAFT\" toStatusId=\"ES_READY\" transitionName=\"Ready\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"ES_READY\" toStatusId=\"ES_SENT\" transitionName=\"Send\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"ES_READY\" toStatusId=\"ES_VIEWED\" transitionName=\"View\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"ES_READY\" toStatusId=\"ES_CANCELLED\" transitionName=\"Cancel\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"ES_SENT\" toStatusId=\"ES_RECEIVED\" transitionName=\"Received\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"ES_SENT\" toStatusId=\"ES_VIEWED\" transitionName=\"View\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"ES_SENT\" toStatusId=\"ES_BOUNCED\" transitionName=\"Bounce\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"ES_RECEIVED\" toStatusId=\"ES_VIEWED\" transitionName=\"View\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"ES_BOUNCED\" toStatusId=\"ES_SENT\" transitionName=\"Send\"/>\n\n            <!-- Email Type -->\n            <moqui.basic.EnumerationType description=\"Email Type\" enumTypeId=\"EmailType\"/>\n            <moqui.basic.Enumeration description=\"System\" enumId=\"EMT_SYSTEM\" enumTypeId=\"EmailType\"/>\n            <moqui.basic.Enumeration description=\"Password Reset\" enumId=\"EMT_PWD_RESET\" parentEnumId=\"EMT_SYSTEM\" enumTypeId=\"EmailType\"/>\n            <moqui.basic.Enumeration description=\"Single Use Code\" enumId=\"EMT_SINGLE_USE_CODE\" parentEnumId=\"EMT_SYSTEM\" enumTypeId=\"EmailType\"/>\n            <moqui.basic.Enumeration description=\"Added Email Authentication Type\" enumId=\"EMT_ADDED_EMAIL_AUTHC_FACTOR\" parentEnumId=\"EMT_SYSTEM\" enumTypeId=\"EmailType\"/>\n            <moqui.basic.Enumeration description=\"Email Authentication Code Sent\" enumId=\"EMT_EMAIL_AUTHC_FACTOR_SENT\" parentEnumId=\"EMT_SYSTEM\" enumTypeId=\"EmailType\"/>\n            <moqui.basic.Enumeration description=\"Notification\" enumId=\"EMT_NOTIFICATION\" parentEnumId=\"EMT_SYSTEM\" enumTypeId=\"EmailType\"/>\n            <moqui.basic.Enumeration description=\"Screen Render\" enumId=\"EMT_SCREEN_RENDER\" parentEnumId=\"EMT_SYSTEM\" enumTypeId=\"EmailType\"/>\n            <!-- <moqui.basic.Enumeration description=\"Error Notice\" enumId=\"EMT_ERROR_NOTICE\" parentEnumId=\"EMT_SYSTEM\" enumTypeId=\"EmailType\"/> -->\n\n            <moqui.basic.Enumeration description=\"Registration Confirmation\" enumId=\"EMT_REG_CONFIRM\" enumTypeId=\"EmailType\"/>\n            <moqui.basic.Enumeration description=\"Update Personal Info Confirmation\" enumId=\"EMT_UPD_INFO_CONFIRM\" enumTypeId=\"EmailType\"/>\n            <moqui.basic.Enumeration description=\"Email Address Verification\" enumId=\"EMT_EMAIL_VERIFY\" enumTypeId=\"EmailType\"/>\n            <moqui.basic.Enumeration description=\"Account Invitation\" enumId=\"EMT_ACCOUNT_INVITE\" enumTypeId=\"EmailType\"/>\n            <!-- <moqui.basic.Enumeration description=\"Contact Us Notification\" enumId=\"EMT_CONACT_US_NOT\" enumTypeId=\"EmailType\"/> -->\n        </seed-data>\n        <master>\n            <detail relationship=\"status\"/><detail relationship=\"type\"/>\n            <detail relationship=\"root\" use-master=\"default\"/><detail relationship=\"parent\" use-master=\"default\"/>\n            <detail relationship=\"children\" use-master=\"default\"/>\n            <detail relationship=\"fromUser\"/><detail relationship=\"toUser\"/>\n            <detail relationship=\"template\"/><detail relationship=\"server\"/>\n        </master>\n    </entity>\n    <entity entity-name=\"EmailServer\" package=\"moqui.basic.email\" use=\"configuration\" cache=\"true\">\n        <field name=\"emailServerId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"smtpHost\" type=\"text-medium\" enable-audit-log=\"update\"/>\n        <field name=\"smtpPort\" type=\"text-short\"/>\n        <field name=\"smtpStartTls\" type=\"text-indicator\"/>\n        <field name=\"smtpSsl\" type=\"text-indicator\"/>\n        <field name=\"storeHost\" type=\"text-medium\" enable-audit-log=\"update\"/>\n        <field name=\"storePort\" type=\"text-short\"/>\n        <field name=\"storeProtocol\" type=\"text-short\"/>\n        <field name=\"storeFolder\" type=\"text-medium\"><description>Defaults to INBOX</description></field>\n        <field name=\"storeDelete\" type=\"text-indicator\"/>\n        <field name=\"storeMarkSeen\" type=\"text-indicator\"/>\n        <field name=\"storeSkipSeen\" type=\"text-indicator\"/>\n        <field name=\"mailUsername\" type=\"text-medium\" enable-audit-log=\"update\"/>\n        <field name=\"mailPassword\" type=\"text-medium\" encrypt=\"true\" enable-audit-log=\"update\"/>\n        <field name=\"allowedToDomains\" type=\"text-long\" enable-audit-log=\"update\">\n            <description>Comma separated list of domain names to allow (like: moqui.org, dejc.com), matched by ends with; if no value specified all domains allowed</description></field>\n        <seed-data>\n            <!-- The MOQUI_LOCAL EmailServer is used by SubEthaSmtpToolFactory for the local SMTP server to setup the server, and\n                for use by send#EmailTemplate if this emailServerId is used to send email -->\n            <moqui.basic.email.EmailServer emailServerId=\"MOQUI_LOCAL\"\n                    smtpHost=\"localhost\" smtpPort=\"2525\" smtpStartTls=\"N\" smtpSsl=\"N\"\n                    storeHost=\"\" storePort=\"\" storeProtocol=\"\" storeFolder=\"\"\n                    storeDelete=\"N\" storeMarkSeen=\"Y\" storeSkipSeen=\"Y\"\n                    mailUsername=\"email.root\" mailPassword=\"EMAIL_CHANGEME\"/>\n\n            <!-- NOTE: these are skeleton settings and some fields need to be filled in (probably best with another data file)\n                for your specific installation in order to be functional -->\n            <moqui.basic.email.EmailServer emailServerId=\"SYSTEM\"/>\n            <!-- Example values, not actually loaded because would overwrite updated values on seed reload:\n                smtpHost=\"smtp.gmail.com\" smtpPort=\"587\" smtpStartTls=\"Y\" smtpSsl=\"N\"\n                storeHost=\"imap.gmail.com\" storePort=\"993\" storeProtocol=\"imaps\" storeFolder=\"\"\n                storeDelete=\"N\" storeMarkSeen=\"Y\" storeSkipSeen=\"Y\"\n                mailUsername=\"CHANGEME@gmail.com\" mailPassword=\"CHANGEME\"\n            -->\n            <!-- Other common values:\n                smtpPort=\"25\" (std) smtpPort=\"465\" (ssmtp) smtpPort=\"587\" (ssmtp?)\n                storeProtocol=\"imap\" storePort=\"143\" (std) storePort=\"585\" (imap4-ssl) storePort=\"993\" (imaps)\n                storeProtocol=\"pop3\" storePort=\"110\" (std) storePort=\"995\" (ssl-pop)\n            -->\n        </seed-data>\n    </entity>\n    <entity entity-name=\"EmailTemplate\" package=\"moqui.basic.email\" use=\"configuration\" short-alias=\"emailTemplates\" cache=\"true\">\n        <field name=\"emailTemplateId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"emailServerId\" type=\"id\" enable-audit-log=\"update\"/>\n        <field name=\"emailTypeEnumId\" type=\"id\"/>\n        <field name=\"fromAddress\" type=\"text-medium\" enable-audit-log=\"update\"/>\n        <field name=\"fromName\" type=\"text-medium\"/>\n        <field name=\"replyToAddresses\" type=\"text-medium\" enable-audit-log=\"update\">\n            <description>Comma separated list of reply to email addresses</description></field>\n        <field name=\"bounceAddress\" type=\"text-medium\" enable-audit-log=\"update\"/>\n        <field name=\"ccAddresses\" type=\"text-medium\" enable-audit-log=\"update\">\n            <description>Comma separated list of CC email addresses</description></field>\n        <field name=\"bccAddresses\" type=\"text-medium\" enable-audit-log=\"update\">\n            <description>Comma separated list of BCC email addresses</description></field>\n        <field name=\"subject\" type=\"text-long\"/>\n        <field name=\"bodyScreenLocation\" type=\"text-medium\"/>\n        <field name=\"webappName\" type=\"text-medium\"/>\n        <field name=\"webHostName\" type=\"text-medium\"/>\n        <field name=\"sendPartial\" type=\"text-indicator\"/>\n        <relationship type=\"one\" related=\"moqui.basic.email.EmailServer\" short-alias=\"server\"/>\n        <relationship type=\"one\" title=\"EmailType\" related=\"moqui.basic.Enumeration\" short-alias=\"type\">\n            <key-map field-name=\"emailTypeEnumId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.basic.email.EmailTemplateAttachment\" short-alias=\"attachments\">\n            <key-map field-name=\"emailTemplateId\"/></relationship>\n        <master><detail relationship=\"server\"/><detail relationship=\"attachments\"/></master>\n    </entity>\n    <entity entity-name=\"EmailTemplateAttachment\" package=\"moqui.basic.email\" use=\"configuration\" cache=\"true\">\n        <field name=\"emailTemplateId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"fileName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"attachmentLocation\" type=\"text-medium\">\n            <description>ResourceFacade location for attachment content or screen (if screenRenderMode specified)</description></field>\n        <field name=\"screenPath\" type=\"text-medium\"><description>\n            Alternative to attachmentLocation as a location for the screen rendered on its own. If specified is used to\n            determine the screen by path from the root screen looked up using the webappName and webHostName values from EmailTemplate.\n        </description></field>\n        <field name=\"screenRenderMode\" type=\"text-short\"><description>\n            Used to determine the MIME/content type, and which screen render template to use. Can be used to generate\n            XSL:FO that is transformed to a PDF and attached to the email with screenRenderMode=xsl-fo.\n\n            If empty the content at attachmentLocation will be sent over without rendering and its MIME type will be based on its extension.\n        </description></field>\n        <field name=\"forEachIn\" type=\"text-medium\"><description>\n            A Groovy expression that evaluates to a Collection in the context to iterate over and add an attachment for each.\n            If entries are Map objects puts all entries in context for each (pushed/isolated context), otherwise puts Collection entry in 'forEachEntry' field.\n            Only applicable if screenRenderMode is specified so that there is a render of the attachment.\n        </description></field>\n        <field name=\"attachmentCondition\" type=\"text-medium\"><description>Optional Groovy expression evaluated as a boolean,\n            if specified and evaluates to false attachment will be skipped.</description></field>\n        <relationship type=\"one\" related=\"moqui.basic.email.EmailTemplate\"/>\n    </entity>\n\n    <!-- ========================================================= -->\n    <!-- moqui.basic.print -->\n    <!-- ========================================================= -->\n\n    <entity entity-name=\"NetworkPrinter\" package=\"moqui.basic.print\" use=\"configuration\">\n        <field name=\"networkPrinterId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"serverHost\" type=\"text-medium\"/>\n        <field name=\"serverPort\" type=\"number-integer\"><description>Defaults to 631</description></field>\n        <field name=\"printerName\" type=\"text-medium\"><description>Leave empty to use default printer on print server</description></field>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"location\" type=\"text-medium\"/>\n    </entity>\n    <entity entity-name=\"PrintJob\" package=\"moqui.basic.print\" use=\"nontransactional\" short-alias=\"printJobs\" cache=\"never\">\n        <field name=\"printJobId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"statusId\" type=\"id\" enable-audit-log=\"true\"/>\n        <field name=\"createdDate\" type=\"date-time\"/>\n        <field name=\"errorMessage\" type=\"text-long\"/>\n        <field name=\"networkPrinterId\" type=\"id\"/>\n        <field name=\"username\" type=\"text-short\"/>\n        <field name=\"jobId\" type=\"number-integer\"/>\n        <field name=\"jobName\" type=\"text-medium\"/>\n        <field name=\"copies\" type=\"number-integer\"/>\n        <field name=\"duplex\" type=\"text-indicator\"/>\n        <field name=\"pageRanges\" type=\"text-short\"/>\n        <field name=\"contentType\" type=\"text-short\"/>\n        <field name=\"document\" type=\"binary-very-long\"/>\n        <relationship type=\"one\" related=\"moqui.basic.print.NetworkPrinter\" short-alias=\"printer\"/>\n        <relationship type=\"one\" title=\"PrintJob\" related=\"moqui.basic.StatusItem\" short-alias=\"status\"/>\n        <seed-data>\n            <!-- Print Job Status -->\n            <moqui.basic.StatusType description=\"Print Job Status\" statusTypeId=\"PrintJob\"/>\n            <moqui.basic.StatusItem description=\"Not Sent\" sequenceNum=\"1\" statusId=\"PtjNotSent\" statusTypeId=\"PrintJob\"/>\n            <moqui.basic.StatusItem description=\"Send Failed\" sequenceNum=\"2\" statusId=\"PtjSendFailed\" statusTypeId=\"PrintJob\"/>\n\n            <moqui.basic.StatusItem description=\"Pending\" sequenceNum=\"11\" statusId=\"PtjPending\" statusTypeId=\"PrintJob\"/>\n            <moqui.basic.StatusItem description=\"Pending Held\" sequenceNum=\"12\" statusId=\"PtjPendingHeld\" statusTypeId=\"PrintJob\"/>\n            <moqui.basic.StatusItem description=\"Processing\" sequenceNum=\"13\" statusId=\"PtjProcessing\" statusTypeId=\"PrintJob\"/>\n            <moqui.basic.StatusItem description=\"Processing Stopped\" sequenceNum=\"14\" statusId=\"PtjProcessingStopped\" statusTypeId=\"PrintJob\"/>\n            <moqui.basic.StatusItem description=\"Completed\" sequenceNum=\"15\" statusId=\"PtjCompleted\" statusTypeId=\"PrintJob\"/>\n            <moqui.basic.StatusItem description=\"Aborted\" sequenceNum=\"18\" statusId=\"PtjAborted\" statusTypeId=\"PrintJob\"/>\n            <moqui.basic.StatusItem description=\"Canceled\" sequenceNum=\"19\" statusId=\"PtjCanceled\" statusTypeId=\"PrintJob\"/>\n\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"PtjNotSent\" toStatusId=\"PtjPending\" transitionName=\"Send\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"PtjNotSent\" toStatusId=\"PtjSendFailed\" transitionName=\"Failed\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"PtjSendFailed\" toStatusId=\"PtjPending\" transitionName=\"Send\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"PtjPending\" toStatusId=\"PtjProcessing\" transitionName=\"Process\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"PtjProcessing\" toStatusId=\"PtjCompleted\" transitionName=\"Complete\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"PtjPending\" toStatusId=\"PtjCompleted\" transitionName=\"Complete\"/>\n\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"PtjPending\" toStatusId=\"PtjPendingHeld\" transitionName=\"Hold\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"PtjPendingHeld\" toStatusId=\"PtjPending\" transitionName=\"Release\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"PtjProcessing\" toStatusId=\"PtjProcessingStopped\" transitionName=\"Stop\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"PtjProcessingStopped\" toStatusId=\"PtjProcessing\" transitionName=\"Resume\"/>\n\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"PtjPending\" toStatusId=\"PtjAborted\" transitionName=\"Abort\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"PtjPendingHeld\" toStatusId=\"PtjAborted\" transitionName=\"Abort\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"PtjProcessing\" toStatusId=\"PtjAborted\" transitionName=\"Abort\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"PtjProcessingStopped\" toStatusId=\"PtjAborted\" transitionName=\"Abort\"/>\n\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"PtjPending\" toStatusId=\"PtjCanceled\" transitionName=\"Cancel\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"PtjPendingHeld\" toStatusId=\"PtjCanceled\" transitionName=\"Cancel\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"PtjProcessing\" toStatusId=\"PtjCanceled\" transitionName=\"Cancel\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"PtjProcessingStopped\" toStatusId=\"PtjCanceled\" transitionName=\"Cancel\"/>\n        </seed-data>\n        <master><detail relationship=\"printer\"/><detail relationship=\"status\"/></master>\n    </entity>\n</entities>\n"
  },
  {
    "path": "framework/entity/EntityEntities.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<entities xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/entity-definition-3.xsd\">\n\n    <!-- ========================================================= -->\n    <!-- moqui.entity -->\n    <!-- ========================================================= -->\n\n    <entity entity-name=\"EntityAuditLog\" package=\"moqui.entity\" use=\"transactional\" authorize-skip=\"create\" cache=\"never\" create-only=\"true\">\n        <field name=\"auditHistorySeqId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"changedEntityName\" type=\"text-medium\"/>\n        <field name=\"changedFieldName\" type=\"text-short\"/>\n        <field name=\"pkPrimaryValue\" type=\"text-medium\"/>\n        <field name=\"pkSecondaryValue\" type=\"text-medium\"/>\n        <field name=\"pkRestCombinedValue\" type=\"text-medium\"/>\n        <field name=\"oldValueText\" type=\"text-long\"/>\n        <field name=\"newValueText\" type=\"text-long\"/>\n        <field name=\"changeReason\" type=\"text-medium\"/>\n        <field name=\"changedDate\" type=\"date-time\"/>\n        <field name=\"changedByUserId\" type=\"text-medium\"/>\n        <field name=\"changedInVisitId\" type=\"text-medium\"/>\n        <field name=\"artifactStack\" type=\"text-long\"/>\n        <!-- index for common query looking for changes to a certain field on a certain entity -->\n        <index name=\"ENTAUDLOG_FLD1PK\"><index-field name=\"changedEntityName\"/><index-field name=\"changedFieldName\"/>\n            <index-field name=\"pkPrimaryValue\"/></index>\n        <index name=\"ENTAUDLOG_ENTPKPR\"><index-field name=\"changedEntityName\"/><index-field name=\"pkPrimaryValue\"/></index>\n        <index name=\"ENTAUDLOG_PKPRIM\"><index-field name=\"pkPrimaryValue\"/></index>\n    </entity>\n\n    <entity entity-name=\"SequenceValueItem\" package=\"moqui.entity\" use=\"transactional\" cache=\"never\">\n        <field name=\"seqName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"seqNum\" type=\"number-integer\"/>\n    </entity>\n\n    <!-- ========================================================= -->\n    <!-- moqui.entity.view -->\n    <!-- ========================================================= -->\n\n    <entity entity-name=\"DbViewEntity\" package=\"moqui.entity.view\" use=\"configuration\" cache=\"true\" authorize-skip=\"view\">\n        <field name=\"dbViewEntityName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"packageName\" type=\"text-medium\"/>\n        <field name=\"cache\" type=\"text-indicator\"/>\n        <field name=\"isDataView\" type=\"text-indicator\"/>\n    </entity>\n    <entity entity-name=\"DbViewEntityMember\" package=\"moqui.entity.view\" use=\"configuration\" cache=\"true\" authorize-skip=\"view\">\n        <field name=\"dbViewEntityName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"entityAlias\" type=\"text-short\" is-pk=\"true\"/>\n        <field name=\"entityName\" type=\"text-medium\"/>\n        <field name=\"joinFromAlias\" type=\"text-short\"/>\n        <field name=\"joinOptional\" type=\"text-indicator\"/>\n        <relationship type=\"one\" related=\"moqui.entity.view.DbViewEntity\"/>\n        <relationship type=\"one\" title=\"JoinFrom\" related=\"moqui.entity.view.DbViewEntityMember\">\n            <key-map field-name=\"dbViewEntityName\"/>\n            <key-map field-name=\"joinFromAlias\" related=\"entityAlias\"/>\n        </relationship>\n    </entity>\n    <entity entity-name=\"DbViewEntityAlias\" package=\"moqui.entity.view\" use=\"configuration\" cache=\"true\" authorize-skip=\"view\">\n        <field name=\"dbViewEntityName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"fieldAlias\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"entityAlias\" type=\"text-short\"/>\n        <field name=\"fieldName\" type=\"text-medium\"/>\n        <field name=\"functionName\" type=\"text-medium\"/>\n        <relationship type=\"one\" related=\"moqui.entity.view.DbViewEntity\"/>\n        <relationship type=\"one\" related=\"moqui.entity.view.DbViewEntityMember\"/>\n    </entity>\n    <entity entity-name=\"DbViewEntityKeyMap\" package=\"moqui.entity.view\" use=\"configuration\" cache=\"true\" authorize-skip=\"view\">\n        <field name=\"dbViewEntityName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"joinFromAlias\" type=\"text-short\" is-pk=\"true\"/>\n        <field name=\"entityAlias\" type=\"text-short\" is-pk=\"true\"/>\n        <field name=\"fieldName\" type=\"text-medium\" is-pk=\"true\">\n            <description>The name of the field corresponding to the joinFromAlias.</description></field>\n        <field name=\"relatedFieldName\" type=\"text-medium\">\n            <description>The name of the field corresponding to the entityAlias, ie the related field.</description></field>\n        <relationship type=\"one\" related=\"moqui.entity.view.DbViewEntity\"/>\n        <relationship type=\"one\" related=\"moqui.entity.view.DbViewEntityMember\"/>\n    </entity>\n\n    <!-- ========================================================= -->\n    <!-- moqui.entity.document -->\n    <!-- ========================================================= -->\n\n    <entity entity-name=\"DataDocument\" package=\"moqui.entity.document\" use=\"configuration\" short-alias=\"dataDocuments\">\n        <field name=\"dataDocumentId\" type=\"id\" is-pk=\"true\"><description>For ElasticSearch compatibility, and as a general good\n            practice for use as a dynamic view-entity, must follow entity name convention of camel case and starting with a capital letter.</description></field>\n        <field name=\"documentName\" type=\"text-medium\"><description>The name of the document for display in search\n            results and such. Is generally expanded on display and may use any field in the DataDocument.</description></field>\n        <field name=\"documentTitle\" type=\"text-medium\"><description>A title for each document instance for display in\n            search results or other places. Meant to be string expanded using a \"flattened\" version of the document\n            (see the CollectionUtilities.flattenNestedMap() method).</description></field>\n        <field name=\"indexName\" type=\"text-medium\"><description>This should be specified for documents that will be indexed by\n            ElasticSearch and must be lower-case (ElasticSearch requires all lower-case). Because of changes in ElasticSearch 5\n            this is no longer the actual index name and is instead an alias for each index from a DataDocument.</description></field>\n        <field name=\"primaryEntityName\" type=\"text-medium\"/>\n        <field name=\"manualDataServiceName\" type=\"text-medium\"><description>Name of a service to call to get additional\n            data to include in the document. This service should implement the\n            org.moqui.EntityServices.add#ManualDocumentData interface.</description></field>\n        <field name=\"manualMappingServiceName\" type=\"text-medium\"><description>Name of a service to call to alter the generated\n            elasticsearch mapping for the data document. This service should implement the\n            org.moqui.EntityServices.transform#DocumentMapping interface.</description></field>\n\n        <relationship type=\"many\" related=\"moqui.entity.document.DataDocumentField\" short-alias=\"fields\">\n            <key-map field-name=\"dataDocumentId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.entity.document.DataDocumentRelAlias\" short-alias=\"relAliases\">\n            <key-map field-name=\"dataDocumentId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.entity.document.DataDocumentCondition\" short-alias=\"conditions\">\n            <key-map field-name=\"dataDocumentId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.entity.document.DataDocumentLink\" short-alias=\"links\">\n            <key-map field-name=\"dataDocumentId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.entity.feed.DataFeedDocument\" short-alias=\"feeds\">\n            <key-map field-name=\"dataDocumentId\"/></relationship>\n        <master>\n            <detail relationship=\"fields\"/><detail relationship=\"relAliases\"/>\n            <detail relationship=\"conditions\"/><detail relationship=\"links\"/>\n            <detail relationship=\"feeds\"><detail relationship=\"feed\"/></detail>\n        </master>\n    </entity>\n    <entity entity-name=\"DataDocumentField\" package=\"moqui.entity.document\" use=\"configuration\">\n        <field name=\"dataDocumentId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"fieldSeqId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"fieldPath\" type=\"text-medium\"><description>\n            String formatted like \"RelationshipName:RelationshipName:fieldName\" with zero or more relationship names.\n            If there is no relationship name the field is on the primary entity. More than one relationship names means\n            follow that path of relationships to get to the field.\n\n            This may also contain a Groovy expression using other fields in the current Map/Object in the document by\n            the path or any parent Map/Object above it in the document. When an expression is used a fieldNameAlias is required.\n        </description></field>\n        <field name=\"fieldNameAlias\" type=\"text-medium\"><description>Alias to put in document output for field name\n            (ie final part of fieldPath only). Defaults to final part of fieldPath. Must be unique within the document\n            and can be used in EntityCondition passed into the EntityFacade.findDataDocuments() method.</description></field>\n        <field name=\"fieldType\" type=\"text-short\"><description>The ElasticSearch field type to use, default is based on entity\n            field type or for expression fields defaults to 'double'.</description></field>\n        <field name=\"sortable\" type=\"text-indicator\"><description>Indicates the field should be sortable. This is needed because\n            in ElasticSearch we have two string types to work with: text (tokenized for search, not sortable) and keyword (sortable\n            but not tokenized for search). In ElasticSearch this adds [field name].keyword field of type keyword to sort on if the\n            entity field is a 'text' type ElasticSearch field.</description></field>\n        <field name=\"defaultDisplay\" type=\"text-indicator\"><description>Fields displayed by default, set to N to not display in output.</description></field>\n        <field name=\"functionName\" type=\"text-short\"><description>If specific the field is queried with the given function.\n            Must be one of the functions available in the view-entity.alias.@function attribute.</description></field>\n        <field name=\"sequenceNum\" type=\"number-integer\" default=\"fieldSeqId as int\"/>\n        <relationship type=\"one\" related=\"moqui.entity.document.DataDocument\"/>\n    </entity>\n    <entity entity-name=\"DataDocumentRelAlias\" package=\"moqui.entity.document\" use=\"configuration\">\n        <field name=\"dataDocumentId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"relationshipName\" type=\"text-medium\" is-pk=\"true\"><description>The name of a relationship used in\n            any fieldPath to be aliased in the output document.</description></field>\n        <field name=\"documentAlias\" type=\"text-medium\"><description>Alias to put in document output instead of the full\n            relationship name.</description></field>\n        <relationship type=\"one\" related=\"moqui.entity.document.DataDocument\"/>\n    </entity>\n    <entity entity-name=\"DataDocumentCondition\" package=\"moqui.entity.document\" use=\"configuration\">\n        <description>This is a very simple sort of condition to constrain data document output.</description>\n        <field name=\"dataDocumentId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"conditionSeqId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"fieldNameAlias\" type=\"text-medium\"/>\n        <field name=\"operator\" type=\"text-short\"><description>Must be a valid value like those in the\n            econdition.@operator attribute. Ignored if postQuery=Y. Defaults (like that attribute) to 'equals'.</description></field>\n        <field name=\"fieldValue\" type=\"text-medium\"/>\n        <field name=\"toFieldNameAlias\" type=\"text-medium\"/>\n        <field name=\"postQuery\" type=\"text-indicator\"><description>If Y condition is applied after the query is done\n            instead of being added to the query as a condition. Must match at least one nested field with the specified\n            fieldNameAlias. The fieldValue String will be compared to the Object from the database field after\n            conversion using the Groovy asType() method.</description></field>\n        <relationship type=\"one\" related=\"moqui.entity.document.DataDocument\"/>\n    </entity>\n    <entity entity-name=\"DataDocumentLink\" package=\"moqui.entity.document\" use=\"configuration\">\n        <description>Associate links with a DataDocument to use in applications for links to details, edit screens, etc for search results.</description>\n        <field name=\"dataDocumentId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"linkSeqId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"linkSet\" type=\"text-short\"/>\n        <field name=\"label\" type=\"text-medium\"/>\n        <field name=\"linkUrl\" type=\"text-medium\"/>\n        <field name=\"urlType\" type=\"text-short\" default=\"'plain'\">\n            <description>Must match an option for the XML Screen link.@url-type attribute. Defaults to 'plain'.</description></field>\n        <field name=\"linkCondition\" type=\"text-long\"/>\n        <relationship type=\"one\" related=\"moqui.entity.document.DataDocument\"/>\n    </entity>\n    <entity entity-name=\"DataDocumentUserGroup\" package=\"moqui.entity.document\" use=\"configuration\">\n        <description>Use this entity to allow a user group access to the DataDocument (for reports, etc). For all users use userGroupId=\"ALL_USERS\".</description>\n        <field name=\"dataDocumentId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"userGroupId\" type=\"id\" is-pk=\"true\"/>\n        <relationship type=\"one\" related=\"moqui.entity.document.DataDocument\" short-alias=\"document\"/>\n        <relationship type=\"one\" related=\"moqui.security.UserGroup\" short-alias=\"group\"/>\n    </entity>\n    <view-entity entity-name=\"DataDocumentAndUserGroup\" package=\"moqui.entity.document\">\n        <member-entity entity-alias=\"DDUG\" entity-name=\"moqui.entity.document.DataDocumentUserGroup\"/>\n        <member-relationship entity-alias=\"DDOC\" join-from-alias=\"DDUG\" relationship=\"document\"/>\n        <alias-all entity-alias=\"DDOC\"/><alias-all entity-alias=\"DDUG\"/>\n    </view-entity>\n\n\n    <!-- ========================================================= -->\n    <!-- moqui.entity.feed -->\n    <!-- ========================================================= -->\n\n    <entity entity-name=\"DataFeed\" package=\"moqui.entity.feed\" use=\"configuration\">\n        <field name=\"dataFeedId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"dataFeedTypeEnumId\" type=\"id\"/>\n        <field name=\"indexOnStartEmpty\" type=\"text-indicator\"><description>If Y index the feed on start if the index does not yet\n            exist (for servers where ES data not persisted between restarts)</description></field>\n        <field name=\"feedName\" type=\"text-medium\"/>\n        <field name=\"feedReceiveServiceName\" type=\"text-medium\"><description>The service named here should implement the\n            org.moqui.EntityServices.receive#DataFeed interface; defaults in some cases to 'org.moqui.search.SearchServices.index#DataDocuments'</description></field>\n        <field name=\"feedDeleteServiceName\" type=\"text-medium\"><description>The service named here should implement the\n            org.moqui.EntityServices.receive#DataFeedDelete interface; defaults in some cases to 'org.moqui.search.SearchServices.delete#DataDocument'</description></field>\n        <field name=\"lastFeedStamp\" type=\"date-time\"><description>Used only for periodic feeds.</description></field>\n        <relationship type=\"one\" title=\"DataFeedType\" related=\"moqui.basic.Enumeration\" short-alias=\"type\">\n            <key-map field-name=\"dataFeedTypeEnumId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.entity.feed.DataFeedDocument\" short-alias=\"documents\">\n            <key-map field-name=\"dataFeedId\"/></relationship>\n        <seed-data>\n            <moqui.basic.EnumerationType description=\"Data Feed Type\" enumTypeId=\"DataFeedType\"/>\n            <moqui.basic.Enumeration description=\"Real-time Service Push\" enumId=\"DTFDTP_RT_PUSH\" enumTypeId=\"DataFeedType\"/>\n            <moqui.basic.Enumeration description=\"Manual Pull (through API)\" enumId=\"DTFDTP_MAN_PULL\" enumTypeId=\"DataFeedType\"/>\n            <!-- <moqui.basic.Enumeration description=\"Periodic Service Push\" enumId=\"DTFDTP_PER_PUSH\" enumTypeId=\"DataFeedType\"/> -->\n        </seed-data>\n    </entity>\n    <entity entity-name=\"DataFeedDocument\" package=\"moqui.entity.feed\" use=\"configuration\">\n        <field name=\"dataFeedId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"dataDocumentId\" type=\"id\" is-pk=\"true\"/>\n        <relationship type=\"one\" related=\"moqui.entity.feed.DataFeed\" short-alias=\"feed\"/>\n        <relationship type=\"one\" related=\"moqui.entity.document.DataDocument\" short-alias=\"document\"/>\n    </entity>\n    <view-entity entity-name=\"DataFeedAndDocument\" package=\"moqui.entity.feed\">\n        <member-entity entity-alias=\"DTFD\" entity-name=\"moqui.entity.feed.DataFeed\"/>\n        <member-entity entity-alias=\"DFD\" entity-name=\"moqui.entity.feed.DataFeedDocument\" join-from-alias=\"DTFD\">\n            <key-map field-name=\"dataFeedId\"/></member-entity>\n        <alias-all entity-alias=\"DTFD\"/>\n        <alias entity-alias=\"DFD\" name=\"dataDocumentId\"/>\n    </view-entity>\n    <view-entity entity-name=\"DataFeedDocumentDetail\" package=\"moqui.entity.feed\">\n        <member-entity entity-alias=\"DFD\" entity-name=\"moqui.entity.feed.DataFeedDocument\"/>\n        <member-entity entity-alias=\"DDOC\" entity-name=\"moqui.entity.document.DataDocument\" join-from-alias=\"DFD\">\n            <key-map field-name=\"dataDocumentId\"/></member-entity>\n        <alias-all entity-alias=\"DDOC\"/>\n        <alias entity-alias=\"DFD\" name=\"dataFeedId\"/>\n    </view-entity>\n\n    <!-- ========================================================= -->\n    <!-- moqui.entity.sync -->\n    <!-- ========================================================= -->\n\n    <entity entity-name=\"EntitySync\" package=\"moqui.entity.sync\" use=\"configuration\" short-alias=\"entitySyncs\">\n        <field name=\"entitySyncId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"statusId\" type=\"id\"/>\n        <field name=\"lastStartDate\" type=\"date-time\"/>\n        <field name=\"lastSuccessfulSyncTime\" type=\"date-time\"/>\n        <field name=\"syncSplitMillis\" type=\"number-integer\"/>\n        <field name=\"recordThreshold\" type=\"number-integer\"><description>Keep retrieving time splits until the number of\n            records is greater then this threshold.</description></field>\n        <field name=\"delayBufferMillis\" type=\"number-integer\"><description>Newer retrieve records newer than this many\n            milliseconds in the past (leave a delay buffer for transactions in progress).</description></field>\n        <field name=\"targetServerUrl\" type=\"text-medium\"/>\n        <field name=\"targetUsername\" type=\"text-medium\"/>\n        <field name=\"targetPassword\" type=\"text-medium\" encrypt=\"true\"/>\n        <field name=\"targetPath\" type=\"text-medium\">\n            <description>For sending to via file the path and filename to use, or path/filename pattern using Groovy string expand</description></field>\n        <field name=\"keepRemoveInfoHours\" type=\"number-decimal\"/>\n        <field name=\"forPull\" type=\"text-indicator\"><description>If Y this record tracks data pulled from a remote\n            system, otherwise it tracks data pushed from this system.</description></field>\n        <relationship type=\"one\" title=\"EntitySync\" related=\"moqui.basic.StatusItem\" short-alias=\"status\"/>\n        <relationship type=\"many\" related=\"moqui.entity.sync.EntitySyncArtifact\" short-alias=\"artifacts\">\n            <key-map field-name=\"entitySyncId\"/></relationship>\n        <seed-data>\n            <moqui.basic.StatusType description=\"Entity Sync\" parentTypeId=\"\" statusTypeId=\"EntitySync\"/>\n            <moqui.basic.StatusItem description=\"Not Started\" sequenceNum=\"1\" statusId=\"EsNotStarted\" statusTypeId=\"EntitySync\"/>\n            <moqui.basic.StatusItem description=\"Running\" sequenceNum=\"2\" statusId=\"EsRunning\" statusTypeId=\"EntitySync\"/>\n            <moqui.basic.StatusItem description=\"Complete\" sequenceNum=\"4\" statusId=\"EsComplete\" statusTypeId=\"EntitySync\"/>\n            <moqui.basic.StatusItem description=\"Other Error\" sequenceNum=\"98\" statusId=\"EsOtherError\" statusTypeId=\"EntitySync\"/>\n            <!-- support these later?\n            <moqui.basic.StatusItem description=\"Offline Pending\" sequenceNum=\"3\" statusId=\"EsOfflinePending\" statusTypeId=\"EntitySync\"/>\n            <moqui.basic.StatusItem description=\"Data Error\" sequenceNum=\"99\" statusId=\"EsDataError\" statusTypeId=\"EntitySync\"/>\n            -->\n\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"EsNotStarted\" toStatusId=\"EsRunning\" transitionName=\"Running\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"EsRunning\" toStatusId=\"EsComplete\" transitionName=\"Complete\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"EsRunning\" toStatusId=\"EsOtherError\" transitionName=\"Error\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"EsRunning\" toStatusId=\"EsNotStarted\" transitionName=\"Reset Not Started\"/>\n\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"EsComplete\" toStatusId=\"EsRunning\" transitionName=\"Running\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"EsOtherError\" toStatusId=\"EsRunning\" transitionName=\"Running\"/>\n        </seed-data>\n        <master>\n            <detail relationship=\"status\"/>\n            <detail relationship=\"artifacts\">\n                <detail relationship=\"group\"><detail relationship=\"artifacts\"/></detail>\n                <detail relationship=\"applType\"/>\n            </detail>\n        </master>\n    </entity>\n    <entity entity-name=\"EntitySyncArtifact\" package=\"moqui.entity.sync\" use=\"configuration\">\n        <description>\n            Associates a set of entities through ArtifactGroupMember records associated with an ArtifactGroup.\n            ArtifactGroupMember records may have filterMap value and may have nameIsPattern=Y.\n            The filterMap is ignored when the application type is Exclude, it simply excludes the entity altogether.\n            To exclude records use a filterMap on an include.\n            If there are multiple ArtifactGroupMember records with filterMap value for an entity it will OR them together.\n            If include and exclude filters create condition with combined include AND NOT combined exclude.\n        </description>\n        <field name=\"entitySyncId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"artifactGroupId\" type=\"id\" is-pk=\"true\"><description>Only entity artifacts (artifactTypeEnumId=AT_ENTITY) will\n            be used, all others ignored.</description></field>\n        <field name=\"applEnumId\" type=\"id\"/>\n        <field name=\"dependents\" type=\"text-indicator\"><description>If Y also include dependents of records, will apply\n            to all records for applicable entities.</description></field>\n        <relationship type=\"one\" related=\"moqui.entity.sync.EntitySync\" short-alias=\"entitySync\"/>\n        <relationship type=\"one\" related=\"moqui.security.ArtifactGroup\" short-alias=\"group\" mutable=\"true\"/>\n        <relationship type=\"one\" title=\"EntitySyncArtifactAppl\" related=\"moqui.basic.Enumeration\" short-alias=\"applType\">\n            <key-map field-name=\"applEnumId\"/></relationship>\n        <seed-data>\n            <moqui.basic.EnumerationType description=\"Entity Sync Artifact Application Type\" enumTypeId=\"EntitySyncArtifactAppl\"/>\n            <moqui.basic.Enumeration description=\"Include\" enumId=\"EsaaInclude\" sequenceNum=\"1\" enumTypeId=\"EntitySyncArtifactAppl\"/>\n            <moqui.basic.Enumeration description=\"Exclude\" enumId=\"EsaaExclude\" sequenceNum=\"2\" enumTypeId=\"EntitySyncArtifactAppl\"/>\n            <moqui.basic.Enumeration description=\"Always Include\" enumId=\"EsaaAlways\" sequenceNum=\"3\" enumTypeId=\"EntitySyncArtifactAppl\"/>\n        </seed-data>\n    </entity>\n    <view-entity entity-name=\"EntitySyncArtifactDetail\" package=\"moqui.entity.sync\">\n        <member-entity entity-alias=\"ESA\" entity-name=\"moqui.entity.sync.EntitySyncArtifact\"/>\n        <member-entity entity-alias=\"AGM\" entity-name=\"moqui.security.ArtifactGroupMember\" join-from-alias=\"ESA\">\n            <key-map field-name=\"artifactGroupId\"/></member-entity>\n        <alias-all entity-alias=\"ESA\"/>\n        <alias-all entity-alias=\"AGM\"/>\n    </view-entity>\n    <entity entity-name=\"EntitySyncHistory\" package=\"moqui.entity.sync\" use=\"nontransactional\" cache=\"never\">\n        <field name=\"entitySyncId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"startDate\" type=\"date-time\" is-pk=\"true\"/>\n        <field name=\"finishDate\" type=\"date-time\"/>\n        <field name=\"statusId\" type=\"id\"/>\n        <field name=\"exclusiveFromTime\" type=\"date-time\"/>\n        <field name=\"inclusiveThruTime\" type=\"date-time\"/>\n        <field name=\"recordsStored\" type=\"number-integer\"/>\n        <field name=\"toRemoveDeleted\" type=\"number-integer\"/>\n        <field name=\"toRemoveAlreadyDeleted\" type=\"number-integer\"/>\n        <field name=\"runningTimeMillis\" type=\"number-integer\"/>\n        <field name=\"errorMessage\" type=\"text-long\"/>\n        <relationship type=\"one\" related=\"moqui.entity.sync.EntitySync\"/>\n        <relationship type=\"one\" title=\"EntitySync\" related=\"moqui.basic.StatusItem\"/>\n    </entity>\n    <entity entity-name=\"EntitySyncRemove\" package=\"moqui.entity.sync\" use=\"nontransactional\" cache=\"never\">\n        <field name=\"entitySyncRemoveId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"entityName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"primaryKeyRemoved\" type=\"text-long\"/>\n    </entity>\n</entities>\n"
  },
  {
    "path": "framework/entity/OlapEntities.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<entities xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/entity-definition-3.xsd\">\n    <entity entity-name=\"DateDayDimension\" package=\"moqui.olap\" use=\"analytical\">\n        <description>Date Day Dimension. The natural key is [dateValue]</description>\n        <field name=\"dimensionId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"dateValue\" type=\"date\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"dayName\" type=\"text-medium\"/>\n        <field name=\"dayOfMonth\" type=\"number-integer\"/>\n        <field name=\"dayOfYear\" type=\"number-integer\"/>\n        <field name=\"monthName\" type=\"text-medium\"/>\n        <field name=\"monthOfYear\" type=\"number-integer\"/>\n        <field name=\"yearName\" type=\"number-integer\"/>\n        <field name=\"weekOfMonth\" type=\"number-integer\"/>\n        <field name=\"weekOfYear\" type=\"number-integer\"/>\n        <field name=\"yearMonthDay\" type=\"text-medium\"><description>Format: YYYY-MM-DD</description></field>\n        <field name=\"yearAndMonth\" type=\"text-medium\"><description>Format: YYYY-MM</description></field>\n        <field name=\"isWeekEnd\" type=\"text-indicator\"/>\n    </entity>\n    <entity entity-name=\"CurrencyDimension\" package=\"moqui.olap\" use=\"analytical\">\n        <description>Currency Dimension. The natural key is [currencyId]</description>\n        <field name=\"dimensionId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"currencyId\" type=\"id\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n    </entity>\n</entities>\n"
  },
  {
    "path": "framework/entity/ResourceEntities.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<entities xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/entity-definition-3.xsd\">\n\n    <!-- ========================================================= -->\n    <!-- moqui.resource -->\n    <!-- ========================================================= -->\n\n    <entity entity-name=\"DbResource\" package=\"moqui.resource\" use=\"nontransactional\" short-alias=\"dbResources\">\n        <field name=\"resourceId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"parentResourceId\" type=\"id\"/>\n        <field name=\"filename\" type=\"text-medium\"/>\n        <field name=\"isFile\" type=\"text-indicator\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.resource.DbResourceFile\" short-alias=\"file\" mutable=\"true\"/>\n        <relationship type=\"many\" related=\"moqui.resource.DbResourceFileHistory\" short-alias=\"histories\">\n            <key-map field-name=\"resourceId\"/></relationship>\n        <index name=\"DB_RES_PARENT\"><index-field name=\"parentResourceId\"/></index>\n        <index name=\"DB_RES_PAR_FN\" unique=\"true\"><index-field name=\"parentResourceId\"/><index-field name=\"filename\"/></index>\n    </entity>\n    <entity entity-name=\"DbResourceFile\" package=\"moqui.resource\" use=\"nontransactional\">\n        <field name=\"resourceId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"mimeType\" type=\"text-medium\"/>\n        <field name=\"versionName\" type=\"text-short\"/>\n        <field name=\"rootVersionName\" type=\"text-short\"/>\n        <field name=\"fileData\" type=\"binary-very-long\"/>\n        <relationship type=\"one\" related=\"moqui.resource.DbResource\" short-alias=\"dbResource\"/>\n        <relationship type=\"many\" related=\"moqui.resource.DbResourceFileHistory\" short-alias=\"histories\">\n            <key-map field-name=\"resourceId\"/></relationship>\n    </entity>\n    <entity entity-name=\"DbResourceFileHistory\" package=\"moqui.resource\" use=\"nontransactional\">\n        <field name=\"resourceId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"versionName\" type=\"text-short\" is-pk=\"true\"/>\n        <field name=\"previousVersionName\" type=\"text-short\"/>\n        <field name=\"versionDate\" type=\"date-time\"/>\n        <field name=\"userId\" type=\"id\"/>\n        <field name=\"isDiff\" type=\"text-indicator\"/>\n        <field name=\"fileData\" type=\"binary-very-long\"/>\n        <relationship type=\"one\" related=\"moqui.resource.DbResourceFile\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\" short-alias=\"user\"/>\n    </entity>\n\n    <!-- ========================================================= -->\n    <!-- moqui.resource.wiki -->\n    <!-- ========================================================= -->\n\n    <entity entity-name=\"WikiPage\" package=\"moqui.resource.wiki\" use=\"nontransactional\">\n        <field name=\"wikiPageId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"wikiSpaceId\" type=\"id\"/>\n        <field name=\"pagePath\" type=\"text-medium\"/>\n        <field name=\"parentWikiPageId\" type=\"id\"/>\n        <field name=\"sequenceNum\" type=\"number-integer\"/>\n        <field name=\"createdByUserId\" type=\"id\"/>\n        <field name=\"publishedVersionName\" type=\"text-short\"/>\n        <field name=\"restrictView\" type=\"text-indicator\"/>\n        <field name=\"restrictUpdate\" type=\"text-indicator\"/>\n        <relationship type=\"one\" related=\"moqui.resource.wiki.WikiSpace\" short-alias=\"space\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\" short-alias=\"user\">\n            <key-map field-name=\"createdByUserId\" related=\"userId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.resource.wiki.WikiPageHistory\" short-alias=\"histories\">\n            <key-map field-name=\"wikiPageId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.resource.wiki.WikiPageCategoryMember\" short-alias=\"categories\">\n            <key-map field-name=\"wikiPageId\"/></relationship>\n        <index name=\"WIKIPAGE_SPCPTH\" unique=\"true\"><index-field name=\"wikiSpaceId\"/><index-field name=\"pagePath\"/></index>\n    </entity>\n    <entity entity-name=\"WikiPageAlias\" package=\"moqui.resource.wiki\" use=\"nontransactional\">\n        <field name=\"wikiSpaceId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"aliasPath\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"wikiPageId\" type=\"id\"/>\n        <relationship type=\"one\" related=\"moqui.resource.wiki.WikiSpace\" short-alias=\"space\"/>\n        <relationship type=\"one\" related=\"moqui.resource.wiki.WikiPage\" short-alias=\"page\"/>\n    </entity>\n    <entity entity-name=\"WikiPageCategory\" package=\"moqui.resource.wiki\" use=\"nontransactional\">\n        <field name=\"wikiPageCategoryId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"categoryName\" type=\"text-medium\"/>\n    </entity>\n    <entity entity-name=\"WikiPageCategoryMember\" package=\"moqui.resource.wiki\" use=\"nontransactional\">\n        <field name=\"wikiPageCategoryId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"wikiPageId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"fromDate\" type=\"date-time\"/>\n        <field name=\"thruDate\" type=\"date-time\"/>\n        <relationship type=\"one\" related=\"moqui.resource.wiki.WikiPageCategory\" short-alias=\"category\"/>\n        <relationship type=\"one\" related=\"moqui.resource.wiki.WikiPage\" short-alias=\"page\"/>\n    </entity>\n    <entity entity-name=\"WikiPageHistory\" package=\"moqui.resource.wiki\" use=\"nontransactional\" cache=\"never\">\n        <field name=\"wikiPageId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"historySeqId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"oldPagePath\" type=\"text-medium\"/>\n        <field name=\"userId\" type=\"id\"/>\n        <field name=\"changeDateTime\" type=\"date-time\"/>\n        <field name=\"versionName\" type=\"text-short\"/>\n        <relationship type=\"one\" related=\"moqui.resource.wiki.WikiPage\" short-alias=\"page\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\" short-alias=\"user\"/>\n    </entity>\n    <entity entity-name=\"WikiPageUser\" package=\"moqui.resource.wiki\" use=\"nontransactional\">\n        <field name=\"wikiPageId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"userId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"receiveNotifications\" type=\"text-indicator\"/>\n        <field name=\"allowView\" type=\"text-indicator\"/>\n        <field name=\"allowUpdate\" type=\"text-indicator\"/>\n        <relationship type=\"one\" related=\"moqui.resource.wiki.WikiPage\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\"/>\n    </entity>\n    <view-entity entity-name=\"WikiPageAndUser\" package=\"moqui.resource.wiki\">\n        <member-entity entity-alias=\"WKPG\" entity-name=\"moqui.resource.wiki.WikiPage\"/>\n        <member-entity entity-alias=\"WPU\" entity-name=\"moqui.resource.wiki.WikiPageUser\" join-from-alias=\"WKPG\" join-optional=\"true\">\n            <key-map field-name=\"wikiPageId\"/>\n        </member-entity>\n        <alias-all entity-alias=\"WKPG\"/>\n        <alias-all entity-alias=\"WPU\"/>\n    </view-entity>\n\n    <entity entity-name=\"WikiSpace\" package=\"moqui.resource.wiki\" use=\"nontransactional\">\n        <field name=\"wikiSpaceId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"description\" type=\"text-long\"/>\n        <field name=\"rootPageLocation\" type=\"text-medium\"/>\n        <field name=\"decoratorScreenLocation\" type=\"text-medium\"/>\n        <field name=\"publicPageUrl\" type=\"text-medium\"/>\n        <field name=\"publicAttachmentUrl\" type=\"text-medium\"/>\n        <field name=\"publicBlogUrl\" type=\"text-medium\"/>\n        <field name=\"restrictView\" type=\"text-indicator\"/>\n        <field name=\"restrictUpdate\" type=\"text-indicator\"/>\n        <field name=\"allowAnyHtml\" type=\"text-indicator\"/>\n        <field name=\"screenThemeId\" type=\"id\"/>\n        <relationship type=\"one\" related=\"moqui.screen.ScreenTheme\" short-alias=\"screenTheme\"/>\n        <seed-data>\n            <moqui.resource.DbResource resourceId=\"WIKI_SPACE_ROOT\" parentResourceId=\"\" filename=\"WikiSpace\" isFile=\"N\"/>\n        </seed-data>\n    </entity>\n    <entity entity-name=\"WikiSpaceUser\" package=\"moqui.resource.wiki\" use=\"nontransactional\">\n        <field name=\"wikiSpaceId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"userId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"receiveNotifications\" type=\"text-indicator\"/>\n        <field name=\"allowAdmin\" type=\"text-indicator\"/>\n        <field name=\"allowView\" type=\"text-indicator\"/>\n        <field name=\"allowUpdate\" type=\"text-indicator\"/>\n        <relationship type=\"one\" related=\"moqui.resource.wiki.WikiSpace\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\"/>\n    </entity>\n    <view-entity entity-name=\"WikiSpaceAndUser\" package=\"moqui.resource.wiki\">\n        <member-entity entity-alias=\"WKSP\" entity-name=\"moqui.resource.wiki.WikiSpace\"/>\n        <member-entity entity-alias=\"WSU\" entity-name=\"moqui.resource.wiki.WikiSpaceUser\" join-from-alias=\"WKSP\" join-optional=\"true\">\n            <key-map field-name=\"wikiSpaceId\"/>\n        </member-entity>\n        <alias-all entity-alias=\"WKSP\"/>\n        <alias-all entity-alias=\"WSU\"/>\n    </view-entity>\n\n    <entity entity-name=\"WikiBlog\" package=\"moqui.resource.wiki\" use=\"nontransactional\">\n        <description>Each record represents a single blog article, grouped as needed by categories (WikiBlogCategory)</description>\n        <field name=\"wikiBlogId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"wikiPageId\" type=\"id\"/>\n        <field name=\"title\" type=\"text-medium\"/>\n        <field name=\"author\" type=\"text-medium\"/>\n        <field name=\"summary\" type=\"text-long\"/>\n        <field name=\"publishDate\" type=\"date-time\"/>\n        <field name=\"metaKeywords\" type=\"text-long\"/>\n        <field name=\"metaDescription\" type=\"text-long\"/>\n        <field name=\"smallImageLocation\" type=\"text-medium\"/>\n        <relationship type=\"one\" related=\"moqui.resource.wiki.WikiPage\" short-alias=\"page\"/>\n        <relationship type=\"many\" related=\"moqui.resource.wiki.WikiBlogCategory\" short-alias=\"categories\">\n            <key-map field-name=\"wikiBlogId\"/></relationship>\n    </entity>\n    <view-entity entity-name=\"WikiBlogFindView\" package=\"moqui.resource.wiki\">\n        <member-entity entity-alias=\"WKBG\" entity-name=\"moqui.resource.wiki.WikiBlog\"/>\n        <member-relationship entity-alias=\"WKPG\" join-from-alias=\"WKBG\" relationship=\"page\"/>\n        <member-relationship entity-alias=\"WKBC\" join-from-alias=\"WKBG\" relationship=\"categories\"/>\n        <alias-all entity-alias=\"WKBG\"/>\n        <alias-all entity-alias=\"WKBC\"/>\n        <alias entity-alias=\"WKPG\" name=\"wikiSpaceId\"/>\n        <alias entity-alias=\"WKPG\" name=\"parentWikiPageId\"/>\n        <alias entity-alias=\"WKPG\" name=\"publishedVersionName\"/>\n        <alias entity-alias=\"WKPG\" name=\"pagePath\"/>\n    </view-entity>\n    <entity entity-name=\"WikiBlogCategory\" package=\"moqui.resource.wiki\" use=\"nontransactional\">\n        <field name=\"wikiPageCategoryId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"wikiBlogId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"sentDate\" type=\"date-time\"><description>The date/time a blog post within a category was sent by email or other means.</description></field>\n        <relationship type=\"one\" related=\"moqui.resource.wiki.WikiPageCategory\" short-alias=\"category\"/>\n        <relationship type=\"one\" related=\"moqui.resource.wiki.WikiBlog\" short-alias=\"blog\"/>\n    </entity>\n    <view-entity entity-name=\"WikiBlogCategoryDetail\" package=\"moqui.resource.wiki\">\n        <member-entity entity-alias=\"WKBC\" entity-name=\"moqui.resource.wiki.WikiBlogCategory\"/>\n        <member-relationship entity-alias=\"WPC\" join-from-alias=\"WKBC\" relationship=\"category\"/>\n        <alias-all entity-alias=\"WKBC\"/>\n        <alias-all entity-alias=\"WPC\"/>\n    </view-entity>\n</entities>\n"
  },
  {
    "path": "framework/entity/Screen.eecas.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a \nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<eecas xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/entity-eca-3.xsd\">\n    <eeca id=\"DbFormCache\" entity=\"moqui.screen.form.DbForm\" on-create=\"true\" on-update=\"true\" on-delete=\"true\">\n        <actions><script>ec.cache.getCache('screen.form.db.node').remove(formId)</script></actions></eeca>\n    <eeca id=\"DbFormFieldCache\" entity=\"moqui.screen.form.DbFormField\" on-create=\"true\" on-update=\"true\" on-delete=\"true\">\n        <actions><script>ec.cache.getCache('screen.form.db.node').remove(formId)</script></actions></eeca>\n    <eeca id=\"DbFormFieldOptionCache\" entity=\"moqui.screen.form.DbFormFieldOption\" on-create=\"true\" on-update=\"true\" on-delete=\"true\">\n        <actions><script>ec.cache.getCache('screen.form.db.node').remove(formId)</script></actions></eeca>\n    <eeca id=\"DbFormFieldEntOptsCache\" entity=\"moqui.screen.form.DbFormFieldEntOpts\" on-create=\"true\" on-update=\"true\" on-delete=\"true\">\n        <actions><script>ec.cache.getCache('screen.form.db.node').remove(formId)</script></actions></eeca>\n    <eeca id=\"DbFormFieldEntOptsCondCache\" entity=\"moqui.screen.form.DbFormFieldEntOptsCond\" on-create=\"true\" on-update=\"true\" on-delete=\"true\">\n        <actions><script>ec.cache.getCache('screen.form.db.node').remove(formId)</script></actions></eeca>\n    <eeca id=\"DbFormFieldEntOptsOrderCache\" entity=\"moqui.screen.form.DbFormFieldEntOptsOrder\" on-create=\"true\" on-update=\"true\" on-delete=\"true\">\n        <actions><script>ec.cache.getCache('screen.form.db.node').remove(formId)</script></actions></eeca>\n</eecas>\n"
  },
  {
    "path": "framework/entity/ScreenEntities.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<entities xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/entity-definition-3.xsd\">\n    <!-- ========================================================= -->\n    <!-- moqui.screen -->\n    <!-- ========================================================= -->\n\n    <entity entity-name=\"ScreenPathAlias\" package=\"moqui.screen\" use=\"configuration\" cache=\"true\">\n        <field name=\"aliasPath\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"fromDate\" type=\"date-time\" is-pk=\"true\"/>\n        <field name=\"thruDate\" type=\"date-time\"/>\n        <field name=\"screenPath\" type=\"text-medium\"/>\n    </entity>\n    <entity entity-name=\"ScreenScheduled\" package=\"moqui.screen\" use=\"configuration\">\n        <description>For scheduled screen renders to send by email and/or write to a resource location. Primarily intended for use\n            on report screens with a form-list and saved-finds enabled, referencing the formListFindId for saved columns, parameters, etc.</description>\n        <field name=\"screenScheduledId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"screenPath\" type=\"text-medium\"/>\n        <field name=\"formListFindId\" type=\"id\"/>\n        <field name=\"renderMode\" type=\"text-short\"><description>Defaults to 'csv', can also use 'xsl-fo' with PDF rendering, 'xlsx' if moqui-poi component in place</description></field>\n        <field name=\"noResultsAbort\" type=\"text-indicator\">\n            <description>Set to Y to abort (not send or write) if there are no results in form-list on screen</description></field>\n        <field name=\"cronExpression\" type=\"text-medium\"/>\n        <field name=\"fromDate\" type=\"date-time\"/>\n        <field name=\"thruDate\" type=\"date-time\"/>\n        <field name=\"saveToLocation\" type=\"text-long\"><description>Expandable String for resource location to save to, only save to location if specified</description></field>\n        <field name=\"emailTemplateId\" type=\"id\"><description>EmailTemplate to use to send by email, generally of type EMT_SCREEN_RENDER,\n            for default use the Default Screen Render template (set to 'SCREEN_RENDER'); only sends email if specified</description></field>\n        <field name=\"emailSubject\" type=\"text-long\"/>\n        <field name=\"userId\" type=\"id\"><description>Send email to UserAccount.emailAddress for the user</description></field>\n        <field name=\"userGroupId\" type=\"id\"><description>Send email to UserAccount.emailAddress for each user in the group</description></field>\n        <!-- FUTURE: consider adding 'topic' field for sending a Notification when a screen render is done, along with info about\n            email or location; would be useful for more secure reports so user gets a notification (internal or email) and then can\n            view the saved report through a browser if they have access -->\n        <relationship type=\"one\" related=\"moqui.screen.form.FormListFind\" short-alias=\"formListFind\"/>\n        <relationship type=\"one\" related=\"moqui.basic.email.EmailTemplate\" short-alias=\"emailTemplate\"/>\n        <relationship type=\"one\" related=\"moqui.security.UserAccount\" short-alias=\"user\"/>\n        <relationship type=\"one\" related=\"moqui.security.UserGroup\" short-alias=\"userGroup\"/>\n    </entity>\n    <entity entity-name=\"ScreenScheduledLock\" package=\"moqui.screen\" use=\"transactional\" cache=\"never\">\n        <description>Runtime data for a scheduled ServiceJob (with a cronExpression), managed automatically by the service job runner.</description>\n        <field name=\"screenScheduledId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"lastRunTime\" type=\"date-time\"/>\n        <relationship type=\"one\" related=\"moqui.screen.ScreenScheduled\"/>\n    </entity>\n    <view-entity entity-name=\"ScreenScheduledAndFind\" package=\"moqui.screen\">\n        <member-entity entity-alias=\"SSCH\" entity-name=\"moqui.screen.ScreenScheduled\"/>\n        <member-relationship entity-alias=\"FLF\" join-from-alias=\"SSCH\" relationship=\"formListFind\"/>\n        <alias-all entity-alias=\"SSCH\"/>\n        <alias-all entity-alias=\"FLF\"/>\n    </view-entity>\n\n    <!-- ========== Subscreen ========== -->\n    <entity entity-name=\"SubscreensItem\" package=\"moqui.screen\" use=\"configuration\" cache=\"true\">\n        <field name=\"screenLocation\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"subscreenName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"userGroupId\" type=\"id\" is-pk=\"true\" default=\"'ALL_USERS'\">\n            <description>DEPRECATED. While still supported, to control access to subscreens use ArtifactAuthz and\n                related records instead.</description></field>\n        <field name=\"subscreenLocation\" type=\"text-medium\"/>\n        <field name=\"menuTitle\" type=\"text-medium\">\n            <description>The title to show for this subscreen in the menu. Can be used to override subscreen titles in the\n                screen.default-menu-title attribute and the subscreens-item.menu-title attribute.</description>\n        </field>\n        <field name=\"menuIndex\" type=\"number-integer\">\n            <description>Insert this item in subscreens menu at this index (1-based).</description></field>\n        <field name=\"menuInclude\" type=\"text-indicator\">\n            <description>Defaults to Y. Set to N to not include in the menu for the subscreens. This can be used to hide\n                subscreens from the directory structure or even explicitly declared in the Screen XML file.</description>\n        </field>\n        <field name=\"makeDefault\" type=\"text-indicator\">\n            <description>If Y will be set at the default subscreen (replacing screen.subscreens.@default-item)</description></field>\n        <field name=\"noSubPath\" type=\"text-indicator\">\n            <description>If Y the sub-screens of the sub-screen may be referenced directly under this screen, skipping the\n                screen path element for the sub-screen</description></field>\n        <relationship type=\"one\" related=\"moqui.security.UserGroup\"/>\n    </entity>\n    <entity entity-name=\"SubscreensDefault\" package=\"moqui.screen\" use=\"configuration\" cache=\"true\">\n        <field name=\"screenLocation\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"defaultSeqId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"conditionExpression\" type=\"text-medium\"/>\n        <field name=\"subscreenName\" type=\"text-medium\"/>\n    </entity>\n\n    <!-- ========== Screen Documentation ========== -->\n    <entity entity-name=\"ScreenDocument\" package=\"moqui.screen\" use=\"configuration\" cache=\"true\">\n        <field name=\"screenLocation\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"docIndex\" type=\"number-integer\" is-pk=\"true\">\n            <description>Part of the key (used to reference within a screen) and for sort order</description></field>\n        <field name=\"locale\" type=\"text-short\"/>\n        <field name=\"docTitle\" type=\"text-medium\"/>\n        <field name=\"docLocation\" type=\"text-medium\"/>\n    </entity>\n\n    <!-- ========== Theme ========== -->\n    <entity entity-name=\"ScreenTheme\" package=\"moqui.screen\" use=\"configuration\" cache=\"true\">\n        <field name=\"screenThemeId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"screenThemeTypeEnumId\" type=\"id\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <relationship type=\"one\" title=\"ScreenThemeType\" related=\"moqui.basic.Enumeration\" short-alias=\"screenThemeTypeEnum\">\n            <key-map field-name=\"screenThemeTypeEnumId\"/></relationship>\n        <seed-data>\n            <!-- Screen Themes -->\n            <moqui.basic.EnumerationType description=\"Screen Theme Type\" enumTypeId=\"ScreenThemeType\"/>\n            <moqui.basic.Enumeration description=\"Internal Applications\" enumId=\"STT_INTERNAL\" enumTypeId=\"ScreenThemeType\" enumCode=\"DEFAULT\"/>\n            <moqui.basic.Enumeration description=\"Public Web Site/etc\" enumId=\"STT_PUBLIC\" enumTypeId=\"ScreenThemeType\" enumCode=\"PUBLIC\"/>\n            <moqui.basic.Enumeration description=\"Error Screens\" enumId=\"STT_ERROR\" enumTypeId=\"ScreenThemeType\" enumCode=\"ERROR\"/>\n\n            <!-- Default Theme -->\n            <moqui.screen.ScreenTheme screenThemeId=\"DEFAULT\" screenThemeTypeEnumId=\"STT_INTERNAL\"\n                                      description=\"Moqui Default Theme: simple, flat, default\"/>\n            <!-- NOTE: the default webroot component extends this theme, see the WebrootThemeData.xml file for more -->\n            <moqui.screen.ScreenTheme screenThemeId=\"ERROR\" screenThemeTypeEnumId=\"STT_ERROR\"\n                                      description=\"Minimal Error Theme\"/>\n        </seed-data>\n    </entity>\n    <entity entity-name=\"ScreenThemeResource\" package=\"moqui.screen\" use=\"configuration\" cache=\"true\">\n        <field name=\"screenThemeId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"sequenceNum\" type=\"number-integer\" is-pk=\"true\"/>\n        <field name=\"resourceTypeEnumId\" type=\"id\"/>\n        <field name=\"resourceValue\" type=\"text-long\">\n            <description>The location, name or other value description the resource.</description></field>\n        <relationship type=\"one\" related=\"moqui.screen.ScreenTheme\" short-alias=\"screenTheme\"/>\n        <relationship type=\"one\" title=\"ScreenThemeResourceType\" related=\"moqui.basic.Enumeration\" short-alias=\"resourceTypeEnum\">\n            <key-map field-name=\"resourceTypeEnumId\" related=\"enumId\"/></relationship>\n        <seed-data>\n            <moqui.basic.EnumerationType description=\"Screen Theme Resource Type\" enumTypeId=\"ScreenThemeResourceType\"/>\n            <moqui.basic.Enumeration description=\"Style Sheet (CSS) URL\" enumId=\"STRT_STYLESHEET\" enumTypeId=\"ScreenThemeResourceType\"/>\n            <moqui.basic.Enumeration description=\"Script URL\" enumId=\"STRT_SCRIPT\" enumTypeId=\"ScreenThemeResourceType\"/>\n            <moqui.basic.Enumeration description=\"Script Footer URL\" enumId=\"STRT_SCRIPT_FOOTER\" enumTypeId=\"ScreenThemeResourceType\"/>\n            <moqui.basic.Enumeration description=\"Shortcut Icon URL\" enumId=\"STRT_SHORTCUT_ICON\" enumTypeId=\"ScreenThemeResourceType\"/>\n            <moqui.basic.Enumeration description=\"Header Logo URL\" enumId=\"STRT_HEADER_LOGO\" enumTypeId=\"ScreenThemeResourceType\"/>\n            <moqui.basic.Enumeration description=\"Header Title\" enumId=\"STRT_HEADER_TITLE\" enumTypeId=\"ScreenThemeResourceType\"/>\n            <moqui.basic.Enumeration description=\"Header Navbar Item\" enumId=\"STRT_HEADER_NAVBAR_ITEM\" enumTypeId=\"ScreenThemeResourceType\"/>\n            <moqui.basic.Enumeration description=\"Header Navbar Component\" enumId=\"STRT_HEADER_NAVBAR_COMP\" enumTypeId=\"ScreenThemeResourceType\"/>\n            <moqui.basic.Enumeration description=\"Header Account Component\" enumId=\"STRT_HEADER_ACCOUNT_COMP\" enumTypeId=\"ScreenThemeResourceType\"/>\n            <moqui.basic.Enumeration description=\"Footer Item\" enumId=\"STRT_FOOTER_ITEM\" enumTypeId=\"ScreenThemeResourceType\"/>\n            <moqui.basic.Enumeration description=\"HTML Body CSS Class\" enumId=\"STRT_BODY_CLASS\" enumTypeId=\"ScreenThemeResourceType\"/>\n            <moqui.basic.Enumeration description=\"Theme Preview Screenshot\" enumId=\"STRT_SCREENSHOT\" enumTypeId=\"ScreenThemeResourceType\"/>\n        </seed-data>\n    </entity>\n    <entity entity-name=\"ScreenThemeIcon\" package=\"moqui.screen\" use=\"configuration\" cache=\"true\">\n        <field name=\"screenThemeId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"textPattern\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"iconClass\" type=\"text-medium\"/>\n        <relationship type=\"one\" related=\"moqui.screen.ScreenTheme\" short-alias=\"screenTheme\"/>\n    </entity>\n\n    <!-- ========================================================= -->\n    <!-- moqui.screen.form -->\n    <!-- ========================================================= -->\n\n    <!-- ========== Form Configuration ========== -->\n\n    <entity entity-name=\"FormConfig\" package=\"moqui.screen.form\" use=\"configuration\">\n        <field name=\"formConfigId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"formLocation\" type=\"text-medium\"/>\n        <field name=\"configTypeEnumId\" type=\"id\"/>\n        <relationship type=\"one\" title=\"FormConfigType\" related=\"moqui.basic.Enumeration\">\n            <key-map field-name=\"configTypeEnumId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.screen.form.FormConfigField\" short-alias=\"fields\">\n            <key-map field-name=\"formConfigId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.screen.form.FormConfigUser\" short-alias=\"users\">\n            <key-map field-name=\"formConfigId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.screen.form.FormConfigUserGroup\" short-alias=\"userGroups\">\n            <key-map field-name=\"formConfigId\"/></relationship>\n        <seed-data>\n            <moqui.basic.EnumerationType description=\"Form Config Type\" enumTypeId=\"FormConfigType\"/>\n            <moqui.basic.Enumeration description=\"Desktop\" enumCode=\"desktop\" enumId=\"FctDesktop\" enumTypeId=\"FormConfigType\"/>\n            <moqui.basic.Enumeration description=\"Mobile\" enumCode=\"mobile\" enumId=\"FctMobile\" enumTypeId=\"FormConfigType\"/>\n        </seed-data>\n    </entity>\n    <entity entity-name=\"FormConfigField\" package=\"moqui.screen.form\" use=\"configuration\">\n        <field name=\"formConfigId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"fieldName\" type=\"text-short\" is-pk=\"true\"/>\n        <field name=\"positionIndex\" type=\"number-integer\">\n            <description>The position (row for form-single, column for form-list) number to put the field in</description></field>\n        <field name=\"positionSequence\" type=\"number-integer\"><description>The sequence within the row or column</description></field>\n        <!-- FUTURE: <field name=\"fieldDisabled\" type=\"text-indicator\"><description>For displaying the field but not allowing edit</description></field> -->\n        <relationship type=\"one\" related=\"moqui.screen.form.FormConfig\" short-alias=\"formConfig\"/>\n    </entity>\n    <entity entity-name=\"FormConfigUser\" package=\"moqui.screen.form\" use=\"configuration\">\n        <description>Structured to have a single FormConfig per form and user.</description>\n        <field name=\"formLocation\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"userId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"formConfigId\" type=\"id\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\" short-alias=\"userAccount\"/>\n        <relationship type=\"one\" related=\"moqui.screen.form.FormConfig\" short-alias=\"formConfig\"/>\n    </entity>\n    <entity entity-name=\"FormConfigUserType\" package=\"moqui.screen.form\" use=\"configuration\">\n        <!-- NOTE: the combination of FormConfigUser and FormConfigUserType is far from ideal in terms of modeling but has 2 key\n            benefits: avoid deprecating and migrating data from FormConfigUser, handle the default FormConfig with no (null) type without workaround -->\n        <description>Structured to have a single FormConfig per form and user.</description>\n        <field name=\"formLocation\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"userId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"configTypeEnumId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"formConfigId\" type=\"id\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\" short-alias=\"userAccount\"/>\n        <relationship type=\"one\" title=\"FormConfigType\" related=\"moqui.basic.Enumeration\">\n            <key-map field-name=\"configTypeEnumId\"/></relationship>\n        <relationship type=\"one\" related=\"moqui.screen.form.FormConfig\" short-alias=\"formConfig\"/>\n    </entity>\n    <entity entity-name=\"FormConfigUserGroup\" package=\"moqui.screen.form\" use=\"configuration\">\n        <field name=\"formConfigId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"userGroupId\" type=\"id\" is-pk=\"true\"/>\n        <relationship type=\"one\" related=\"moqui.screen.form.FormConfig\" short-alias=\"formConfig\"/>\n        <relationship type=\"one\" related=\"moqui.security.UserGroup\" short-alias=\"userGroup\"/>\n    </entity>\n    <view-entity entity-name=\"FormConfigUserGroupView\" package=\"moqui.screen.form\">\n        <member-entity entity-alias=\"FCNF\" entity-name=\"moqui.screen.form.FormConfig\"/>\n        <member-entity entity-alias=\"FCUG\" entity-name=\"moqui.screen.form.FormConfigUserGroup\" join-from-alias=\"FCNF\">\n            <key-map field-name=\"formConfigId\"/></member-entity>\n        <alias-all entity-alias=\"FCNF\"/><alias-all entity-alias=\"FCUG\"/>\n    </view-entity>\n\n    <entity entity-name=\"FormListFind\" package=\"moqui.screen.form\" use=\"configuration\">\n        <field name=\"formListFindId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"formLocation\" type=\"text-medium\"/>\n        <field name=\"orderByField\" type=\"text-medium\"/>\n        <field name=\"formConfigId\" type=\"id\"/>\n        <!-- may be useful for certain search backed forms: <field name=\"searchString\" type=\"text-medium\"/> -->\n        <relationship type=\"one\" related=\"moqui.screen.form.FormConfig\" short-alias=\"formConfig\"/>\n        <relationship type=\"many\" related=\"moqui.screen.form.FormListFindField\" short-alias=\"fields\">\n            <key-map field-name=\"formListFindId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.screen.form.FormListFindUser\" short-alias=\"users\">\n            <key-map field-name=\"formListFindId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.screen.form.FormListFindUserGroup\" short-alias=\"userGroups\">\n            <key-map field-name=\"formListFindId\"/></relationship>\n    </entity>\n    <entity entity-name=\"FormListFindField\" package=\"moqui.screen.form\" use=\"configuration\">\n        <description>Has fields for the various options in search-form-inputs/searchFormInputs()/searchFormMap()</description>\n        <field name=\"formListFindId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"fieldName\" type=\"text-short\" is-pk=\"true\"/>\n        <field name=\"fieldValue\" type=\"text-medium\"/>\n        <field name=\"fieldOperator\" type=\"text-short\"/>\n        <field name=\"fieldNot\" type=\"text-indicator\"/>\n        <field name=\"fieldIgnoreCase\" type=\"text-indicator\"/>\n        <field name=\"fieldFrom\" type=\"text-short\"/>\n        <field name=\"fieldThru\" type=\"text-short\"/>\n        <field name=\"fieldPeriod\" type=\"text-short\"/>\n        <field name=\"fieldPerOffset\" type=\"number-integer\"/>\n        <relationship type=\"one\" related=\"moqui.screen.form.FormListFind\" short-alias=\"formListFind\"/>\n    </entity>\n    <entity entity-name=\"FormListFindUser\" package=\"moqui.screen.form\" use=\"configuration\">\n        <field name=\"formListFindId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"userId\" type=\"id\" is-pk=\"true\"/>\n        <relationship type=\"one\" related=\"moqui.screen.form.FormListFind\" short-alias=\"formListFind\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\" short-alias=\"userAccount\"/>\n    </entity>\n    <entity entity-name=\"FormListFindUserDefault\" package=\"moqui.screen.form\" use=\"configuration\">\n        <description>Per-User default FormListFind by screen location and not form location because must be handled\n            very early in screen rendering so parameters are available to actions, etc</description>\n        <field name=\"screenLocation\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"userId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"formListFindId\" type=\"id\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\" short-alias=\"userAccount\"/>\n        <relationship type=\"one\" related=\"moqui.screen.form.FormListFind\" short-alias=\"formListFind\"/>\n    </entity>\n    <view-entity entity-name=\"FormListFindUserView\" package=\"moqui.screen.form\">\n        <member-entity entity-alias=\"FLF\" entity-name=\"moqui.screen.form.FormListFind\"/>\n        <member-entity entity-alias=\"FLFU\" entity-name=\"moqui.screen.form.FormListFindUser\" join-from-alias=\"FLF\">\n            <key-map field-name=\"formListFindId\"/></member-entity>\n        <alias-all entity-alias=\"FLF\"/><alias-all entity-alias=\"FLFU\"/>\n    </view-entity>\n    <entity entity-name=\"FormListFindUserGroup\" package=\"moqui.screen.form\" use=\"configuration\">\n        <field name=\"formListFindId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"userGroupId\" type=\"id\" is-pk=\"true\"/>\n        <relationship type=\"one\" related=\"moqui.screen.form.FormListFind\" short-alias=\"formListFind\"/>\n        <relationship type=\"one\" related=\"moqui.security.UserGroup\" short-alias=\"userGroup\"/>\n    </entity>\n    <view-entity entity-name=\"FormListFindUserGroupView\" package=\"moqui.screen.form\">\n        <member-entity entity-alias=\"FLF\" entity-name=\"moqui.screen.form.FormListFind\"/>\n        <member-entity entity-alias=\"FLFUG\" entity-name=\"moqui.screen.form.FormListFindUserGroup\" join-from-alias=\"FLF\">\n            <key-map field-name=\"formListFindId\"/></member-entity>\n        <alias-all entity-alias=\"FLF\"/><alias-all entity-alias=\"FLFUG\"/>\n    </view-entity>\n\n    <!-- ========== Database Defined Forms ========== -->\n\n    <entity entity-name=\"DbForm\" package=\"moqui.screen.form\" use=\"configuration\">\n        <field name=\"formId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"purposeEnumId\" type=\"id\"/>\n        <field name=\"isListForm\" type=\"text-indicator\"/>\n        <field name=\"modifyXmlScreenForm\" type=\"text-medium\"><description>The screen location and form name (separated\n            by a hash/pound sign) of XML Screen Form to modify.</description></field>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"comments\" type=\"text-long\"/>\n        <field name=\"printTemplateLocation\" type=\"text-medium\"/>\n        <field name=\"acroFormLocation\" type=\"text-medium\"/>\n        <field name=\"printFontSize\" type=\"text-short\"/>\n        <field name=\"printFontFamily\" type=\"text-short\"/>\n        <field name=\"printContainerWidth\" type=\"text-short\"/>\n        <field name=\"printContainerHeight\" type=\"text-short\"/>\n        <field name=\"printRepeatCount\" type=\"number-integer\"/>\n        <field name=\"printRepeatNewPage\" type=\"text-indicator\"/>\n        <relationship type=\"one\" title=\"DbFormPurpose\" related=\"moqui.basic.Enumeration\" short-alias=\"purposeEnum\">\n            <key-map field-name=\"purposeEnumId\"/></relationship>\n        <seed-data>\n            <moqui.basic.EnumerationType description=\"DB Form Purpose\" enumTypeId=\"DbFormPurpose\"/>\n            <moqui.basic.Enumeration description=\"Other\" enumId=\"DbfpOther\" enumTypeId=\"DbFormPurpose\"/>\n            <moqui.basic.Enumeration description=\"Survey\" enumId=\"DbfpSurvey\" enumTypeId=\"DbFormPurpose\"/>\n        </seed-data>\n    </entity>\n    <entity entity-name=\"DbFormField\" package=\"moqui.screen.form\" use=\"configuration\">\n        <field name=\"formId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"fieldName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"condition\" type=\"text-medium\">\n            <description>Only show this field if the condition evaluates to true (Groovy expression)</description></field>\n        <field name=\"entryName\" type=\"text-medium\"/>\n        <field name=\"title\" type=\"text-medium\"/>\n        <field name=\"tooltip\" type=\"text-medium\"/>\n        <field name=\"fieldTypeEnumId\" type=\"id\"><description>Field type for presentation, validation;\n            always stored as plain text FormResponseAnswer</description></field>\n        <field name=\"layoutSequenceNum\" type=\"number-integer\"/>\n        <field name=\"isRequired\" type=\"text-indicator\"/>\n\n        <field name=\"printPageNumber\" type=\"number-integer\">\n            <description>Defaults to 1, ie one page/section for all fields if nothing higher than 1 is specified.</description></field>\n        <field name=\"printTop\" type=\"text-short\"/>\n        <field name=\"printLeft\" type=\"text-short\"/>\n        <field name=\"printBottom\" type=\"text-short\"/>\n        <field name=\"printRight\" type=\"text-short\"/>\n        <field name=\"printWidth\" type=\"text-short\"/>\n        <field name=\"printHeight\" type=\"text-short\"/>\n        <field name=\"printTextAlign\" type=\"text-short\"/>\n        <field name=\"printVerticalAlign\" type=\"text-short\"/>\n        <field name=\"printFontSize\" type=\"text-short\"/>\n        <field name=\"printFontFamily\" type=\"text-short\"/>\n\n        <relationship type=\"one\" related=\"moqui.screen.form.DbForm\" short-alias=\"dbForm\"/>\n        <relationship type=\"one\" title=\"DbFormFieldType\" related=\"moqui.basic.Enumeration\" short-alias=\"fieldTypeEnum\">\n            <key-map field-name=\"fieldTypeEnumId\" related=\"enumId\"/></relationship>\n        <seed-data>\n            <moqui.basic.EnumerationType description=\"DB Form Field Type\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"link\" enumId=\"DBFFT_link\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"image\" enumId=\"DBFFT_image\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"label\" enumId=\"DBFFT_label\" enumTypeId=\"DbFormFieldType\"/>\n\n            <moqui.basic.Enumeration description=\"check\" enumId=\"DBFFT_check\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"date-find\" enumId=\"DBFFT_date-find\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"date-time\" enumId=\"DBFFT_date-time\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"display\" enumId=\"DBFFT_display\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"display-entity\" enumId=\"DBFFT_display-entity\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"drop-down\" enumId=\"DBFFT_drop-down\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"file\" enumId=\"DBFFT_file\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"hidden\" enumId=\"DBFFT_hidden\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"ignored\" enumId=\"DBFFT_ignored\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"password\" enumId=\"DBFFT_password\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"radio\" enumId=\"DBFFT_radio\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"range-find\" enumId=\"DBFFT_range-find\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"reset\" enumId=\"DBFFT_reset\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"submit\" enumId=\"DBFFT_submit\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"text-line\" enumId=\"DBFFT_text-line\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"text-area\" enumId=\"DBFFT_text-area\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"text-find\" enumId=\"DBFFT_text-find\" enumTypeId=\"DbFormFieldType\"/>\n            <!-- add special support for these? \n            <moqui.basic.Enumeration description=\"Number - Integer\" enumId=\"FftNumberInteger\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"Number - Decimal\" enumId=\"FftNumberDecimal\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"Boolean (Y/N)\" enumId=\"FftBoolean\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"Enumeration\" enumId=\"FftEnumeration\" enumTypeId=\"DbFormFieldType\"/>\n            <moqui.basic.Enumeration description=\"Resource\" enumId=\"FftResource\" enumTypeId=\"DbFormFieldType\"/>\n            -->\n        </seed-data>\n    </entity>\n    <entity entity-name=\"DbFormFieldAttribute\" package=\"moqui.screen.form\" use=\"configuration\">\n        <description>Used to provide attribute values. For a reference of attributes available for each field type, see\n            the corresponding element in the xml-form-?.xsd file.</description>\n        <field name=\"formId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"fieldName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"attributeName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"value\" type=\"text-medium\"/>\n        <relationship type=\"one\" related=\"moqui.screen.form.DbFormField\" short-alias=\"dbFormField\"/>\n    </entity>\n    <entity entity-name=\"DbFormFieldOption\" package=\"moqui.screen.form\" use=\"configuration\">\n        <field name=\"formId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"fieldName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"sequenceNum\" type=\"number-integer\" is-pk=\"true\"/>\n        <field name=\"keyValue\" type=\"text-medium\"/>\n        <field name=\"text\" type=\"text-medium\"/>\n        <relationship type=\"one\" related=\"moqui.screen.form.DbFormField\" short-alias=\"dbFormField\"/>\n    </entity>\n    <entity entity-name=\"DbFormFieldEntOpts\" package=\"moqui.screen.form\" use=\"configuration\">\n        <field name=\"formId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"fieldName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"sequenceNum\" type=\"number-integer\" is-pk=\"true\"/>\n        <field name=\"entityName\" type=\"text-medium\"/>\n        <field name=\"text\" type=\"text-medium\"/>\n        <relationship type=\"one\" related=\"moqui.screen.form.DbFormField\" short-alias=\"dbFormField\"/>\n    </entity>\n    <entity entity-name=\"DbFormFieldEntOptsCond\" package=\"moqui.screen.form\" use=\"configuration\">\n        <field name=\"formId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"fieldName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"sequenceNum\" type=\"number-integer\" is-pk=\"true\"/>\n        <field name=\"entityFieldName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"value\" type=\"text-medium\"/>\n        <relationship type=\"one\" related=\"moqui.screen.form.DbFormFieldEntOpts\" short-alias=\"dbFormFieldEntOpts\"/>\n    </entity>\n    <entity entity-name=\"DbFormFieldEntOptsOrder\" package=\"moqui.screen.form\" use=\"configuration\">\n        <field name=\"formId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"fieldName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"sequenceNum\" type=\"number-integer\" is-pk=\"true\"/>\n        <field name=\"orderSequenceNum\" type=\"number-integer\" is-pk=\"true\"/>\n        <field name=\"entityFieldName\" type=\"text-medium\"/>\n        <relationship type=\"one\" related=\"moqui.screen.form.DbFormFieldEntOpts\" short-alias=\"dbFormFieldEntOpts\"/>\n    </entity>\n    <entity entity-name=\"DbFormUserGroup\" package=\"moqui.screen.form\" use=\"configuration\">\n        <field name=\"formId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"userGroupId\" type=\"id\" is-pk=\"true\"><description>These settings are for a UserGroup. To apply to\n            all users just use the ALL_USERS UserGroup.</description></field>\n        <relationship type=\"one\" related=\"moqui.screen.form.DbForm\" short-alias=\"dbForm\"/>\n        <relationship type=\"one\" related=\"moqui.security.UserGroup\" short-alias=\"userGroup\"/>\n    </entity>\n    <view-entity entity-name=\"DbFormLookup\" package=\"moqui.screen.form\">\n        <member-entity entity-alias=\"DBF\" entity-name=\"DbForm\"/>\n        <member-entity entity-alias=\"DBFUG\" entity-name=\"DbFormUserGroup\" join-from-alias=\"DBF\">\n            <key-map field-name=\"formId\"/></member-entity>\n        <alias name=\"formId\" entity-alias=\"DBF\"/>\n        <alias name=\"modifyXmlScreenForm\" entity-alias=\"DBF\"/>\n        <alias name=\"userGroupId\" entity-alias=\"DBFUG\"/>\n    </view-entity>\n\n    <view-entity entity-name=\"DbFormAndUserGroup\" package=\"moqui.screen.form\">\n        <member-entity entity-alias=\"DBFUG\" entity-name=\"moqui.screen.form.DbFormUserGroup\">\n            <key-map field-name=\"formId\"/></member-entity>\n        <member-entity entity-alias=\"USRGRP\" entity-name=\"moqui.security.UserGroup\" join-from-alias=\"DBFUG\">\n            <key-map field-name=\"userGroupId\"/></member-entity>\n        <alias-all entity-alias=\"USRGRP\"/>\n        <alias name=\"formId\" entity-alias=\"DBFUG\"/>\n    </view-entity>\n\n    <entity entity-name=\"FormResponse\" package=\"moqui.screen.form\" use=\"nontransactional\" cache=\"never\">\n        <field name=\"formResponseId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"formLocation\" type=\"text-medium\"/>\n        <field name=\"formId\" type=\"id\"/>\n        <field name=\"userId\" type=\"id\"/>\n        <field name=\"responseDate\" type=\"date-time\"/>\n        <relationship type=\"one\" related=\"moqui.screen.form.DbForm\" short-alias=\"dbForm\"/>\n        <relationship type=\"one\" related=\"moqui.security.UserAccount\" short-alias=\"userAccount\"/>\n    </entity>\n    <entity entity-name=\"FormResponseAnswer\" package=\"moqui.screen.form\" use=\"nontransactional\" cache=\"never\">\n        <field name=\"formResponseAnswerId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"formResponseId\" type=\"id\"/>\n        <field name=\"formId\" type=\"id\"/>\n        <field name=\"fieldName\" type=\"text-medium\"/>\n        <field name=\"sequenceNum\" type=\"number-integer\"/>\n        <field name=\"valueText\" type=\"text-long\"/>\n        <relationship type=\"one\" related=\"moqui.screen.form.FormResponse\"/>\n        <relationship type=\"one\" related=\"moqui.screen.form.DbForm\" short-alias=\"dbForm\"/>\n        <!-- this is nofk because for forms other than DbForms fieldName may be populated but formId null -->\n        <relationship type=\"one-nofk\" related=\"moqui.screen.form.DbFormField\" short-alias=\"dbFormField\"/>\n    </entity>\n\n    <view-entity entity-name=\"FormResponseAnsAndDbFormField\" package=\"moqui.screen.form\">\n        <member-entity entity-alias=\"FRA\" entity-name=\"moqui.screen.form.FormResponseAnswer\"/>\n        <member-entity entity-alias=\"DBFF\" entity-name=\"moqui.screen.form.DbFormField\" join-from-alias=\"FRA\">\n            <key-map field-name=\"formId\"/>\n            <key-map field-name=\"fieldName\"/></member-entity>\n        <alias-all entity-alias=\"FRA\"><exclude field=\"sequenceNum\"/></alias-all>\n        <alias-all entity-alias=\"DBFF\"/>\n    </view-entity>\n\n    <!-- ========================================================= -->\n    <!-- moqui.screen.dynscreen -->\n    <!-- ========================================================= -->\n\n    <!-- Tabled for now, not to be part of 1.0:\n    <entity entity-name=\"DynamicScreen\" package=\"moqui.screen.dynscreen\">\n        <field name=\"screenId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"screenName\" type=\"text-medium\"/>\n        <field name=\"userGroupId\" type=\"id\">\n            <description>These settings are for a UserGroup. To apply to all users just use the ALL_USERS UserGroup.</description>\n        </field>\n        <field name=\"modifyXmlScreen\" type=\"text-medium\">\n            <description>The location of XML Screen to modify.</description>\n        </field>\n    </entity>\n    <entity entity-name=\"DynamicScreenInclude\" package=\"moqui.screen.dynscreen\">\n        <field name=\"screenId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"includeSeqId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"sequenceNum\" type=\"number-integer\"/>\n        <field name=\"panelSeqId\" type=\"id\"/>\n        <field name=\"areaEnumId\" type=\"id\">\n            <description>Options include: header, left, center, right, and footer.</description>\n        </field>\n        <field name=\"screenLocation\" type=\"text-medium\"/>\n    </entity>\n    <entity entity-name=\"DynamicScreenIncludeParam\" package=\"moqui.screen.dynscreen\">\n        <field name=\"screenId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"includeSeqId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"parameterName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"parameterValue\" type=\"text-medium\"/>\n    </entity>\n    <entity entity-name=\"DynamicScreenPanel\" package=\"moqui.screen.dynscreen\">\n        <field name=\"screenId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"panelSeqId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"areaSizeUnit\" type=\"id\">\n            <description>Options include: px, and em (defaults to px).</description>\n        </field>\n    </entity>\n    <entity entity-name=\"DynamicScreenPanelArea\" package=\"moqui.screen.dynscreen\">\n        <field name=\"screenId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"panelSeqId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"areaEnumId\" type=\"id\" is-pk=\"true\">\n            <description>Options include: header, left, center, right, and footer.</description>\n        </field>\n        <field name=\"areaSize\" type=\"number-decimal\"/>\n        <field name=\"draggable\" type=\"text-indicator\">\n            <description>Options include: Y or N, defaults to N.</description>\n        </field>\n    </entity>\n    -->\n</entities>\n"
  },
  {
    "path": "framework/entity/SecurityEntities.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<entities xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/entity-definition-3.xsd\">\n\n    <!-- ========================================================= -->\n    <!-- moqui.security -->\n    <!-- moqui.security.user -->\n    <!-- ========================================================= -->\n\n    <!-- ========== Artifact Group ========== -->\n    <entity entity-name=\"ArtifactGroup\" package=\"moqui.security\" use=\"configuration\" short-alias=\"artifactGroups\">\n        <field name=\"artifactGroupId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <relationship type=\"many\" related=\"moqui.security.ArtifactGroupMember\" short-alias=\"artifacts\">\n            <key-map field-name=\"artifactGroupId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.security.ArtifactAuthz\" short-alias=\"authz\">\n            <key-map field-name=\"artifactGroupId\"/></relationship>\n    </entity>\n    <entity entity-name=\"ArtifactGroupMember\" package=\"moqui.security\" use=\"configuration\">\n        <field name=\"artifactGroupId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"artifactName\" type=\"text-medium\" is-pk=\"true\">\n            <description>Full artifact location/name, or a pattern if nameIsPattern=Y.</description></field>\n        <field name=\"artifactTypeEnumId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"nameIsPattern\" type=\"text-indicator\"/>\n        <field name=\"inheritAuthz\" type=\"text-indicator\">\n            <description>If Y then the user will have authorization for anything called by the artifact.\n                If N user will need to have authorization for anything else called by the artifact.\n\n                Note that in some cases (like in screen-sets) inheritance in the other direction is on by default, or\n                in other words permission to access an artifact implies permission to access everything needed to get\n                to that artifact.\n\n                Defaults to Y.\n            </description>\n        </field>\n        <field name=\"filterMap\" type=\"text-long\"><description>A Groovy expression that evaluates to a Map that\n            will be used to constrain if the member is part of the group based on fields/parameters for Entity\n            operations and Service calls.</description></field>\n        <relationship type=\"one\" related=\"moqui.security.ArtifactGroup\"/>\n        <relationship type=\"one\" title=\"ArtifactType\" related=\"moqui.basic.Enumeration\">\n            <key-map field-name=\"artifactTypeEnumId\"/></relationship>\n        <seed-data>\n            <moqui.basic.EnumerationType description=\"Artifact Types\" enumTypeId=\"ArtifactType\"/>\n            <moqui.basic.Enumeration description=\"Screen\" enumId=\"AT_XML_SCREEN\" enumTypeId=\"ArtifactType\"/>\n            <moqui.basic.Enumeration description=\"Screen Transition\" enumId=\"AT_XML_SCREEN_TRANS\" enumTypeId=\"ArtifactType\"/>\n            <moqui.basic.Enumeration description=\"Screen Content\" enumId=\"AT_XML_SCREEN_CONTENT\" enumTypeId=\"ArtifactType\"/>\n            <moqui.basic.Enumeration description=\"Service\" enumId=\"AT_SERVICE\" enumTypeId=\"ArtifactType\"/>\n            <moqui.basic.Enumeration description=\"Entity\" enumId=\"AT_ENTITY\" enumTypeId=\"ArtifactType\"/>\n            <moqui.basic.Enumeration description=\"REST API Path\" enumId=\"AT_REST_PATH\" enumTypeId=\"ArtifactType\"/>\n            <moqui.basic.Enumeration description=\"Other\" enumId=\"AT_OTHER\" enumTypeId=\"ArtifactType\"/>\n            <!-- not yet pushed/tracked/checked types below here\n                some of these are experimental ideas and may not ever be implemented, or useful\n            <moqui.basic.Enumeration description=\"Component\" enumId=\"AT_COMPONENT\" enumTypeId=\"ArtifactType\"/>\n            <moqui.basic.Enumeration description=\"WebApp\" enumId=\"AT_WEB_APP\" enumTypeId=\"ArtifactType\"/>\n            <moqui.basic.Enumeration description=\"Screen Section\" enumId=\"AT_XML_SCREEN_SECTN\" enumTypeId=\"ArtifactType\"/><!- - if no permission don't throw error, just don't display - ->\n            <moqui.basic.Enumeration description=\"Screen Form\" enumId=\"AT_XML_SCREEN_FORM\" enumTypeId=\"ArtifactType\"/><!- - if no permission don't throw error, just don't display - ->\n            <moqui.basic.Enumeration description=\"Screen Form Field\" enumId=\"AT_XML_SCREEN_FFLD\" enumTypeId=\"ArtifactType\"/><!- - if no permission don't throw error, just don't display - ->\n            <moqui.basic.Enumeration description=\"Template\" enumId=\"AT_TEMPLATE\" enumTypeId=\"ArtifactType\"/>\n            <moqui.basic.Enumeration description=\"Script\" enumId=\"AT_SCRIPT\" enumTypeId=\"ArtifactType\"/>\n            <moqui.basic.Enumeration description=\"EntityField\" enumId=\"AT_ENTITY_FIELD\" enumTypeId=\"ArtifactType\"/>\n            -->\n        </seed-data>\n    </entity>\n\n    <!-- ========== Artifact Authz ========== -->\n\n    <entity entity-name=\"ArtifactAuthz\" package=\"moqui.security\" use=\"configuration\">\n        <description>If an artifact in the group specified is accessed by any user in the AuthzGroup maxHitsCount\n            times in maxHitsDuration seconds then the user/artifact will be blocked fir tarputDuration seconds.\n        </description>\n        <field name=\"artifactAuthzId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"userGroupId\" type=\"id\"/>\n        <field name=\"artifactGroupId\" type=\"id\"/>\n        <field name=\"authzTypeEnumId\" type=\"id\"/>\n        <field name=\"authzActionEnumId\" type=\"id\"/>\n        <field name=\"authzServiceName\" type=\"text-medium\">\n            <description>If specified this service will be called and it should return a authzTypeEnumId with the\n                result of the authorization.\n                Will try to pass the following fields to this service: userId, authzActionEnumId,  artifactTypeEnumId,\n                and artifactName. The service will also have access to the ArtifactExecutionFacade (ec.artifactExecution)\n                which you can use to get the current artifact stack, etc.\n                The service must return an authzTypeEnumId (AUTHZT_ALLOW, AUTHZT_ALWAYS, or AUTHZT_DENY).\n            </description>\n        </field>\n        <relationship type=\"one\" related=\"moqui.security.UserGroup\"/>\n        <relationship type=\"one\" related=\"moqui.security.ArtifactGroup\"/>\n        <relationship type=\"one\" title=\"AuthzType\" related=\"moqui.basic.Enumeration\">\n            <key-map field-name=\"authzTypeEnumId\"/></relationship>\n        <relationship type=\"one\" title=\"AuthzAction\" related=\"moqui.basic.Enumeration\">\n            <key-map field-name=\"authzActionEnumId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.security.ArtifactAuthzFilter\" short-alias=\"filters\">\n            <key-map field-name=\"artifactAuthzId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.security.ArtifactGroupMember\" short-alias=\"groupMembers\">\n            <key-map field-name=\"artifactGroupId\"/></relationship>\n        <seed-data>\n            <moqui.basic.EnumerationType description=\"Authorization Type\" enumTypeId=\"AuthzType\"/>\n            <moqui.basic.Enumeration description=\"Allow\" enumId=\"AUTHZT_ALLOW\" enumTypeId=\"AuthzType\"/>\n            <moqui.basic.Enumeration description=\"Deny (overrides Allow)\" enumId=\"AUTHZT_DENY\" enumTypeId=\"AuthzType\"/>\n            <moqui.basic.Enumeration description=\"Always Allow (overrides Deny)\" enumId=\"AUTHZT_ALWAYS\" enumTypeId=\"AuthzType\"/>\n            <!-- needed? <moqui.basic.Enumeration description=\"Not Applicable\" enumId=\"AUTHZT_NA\" enumTypeId=\"AuthzType\"/> -->\n\n            <moqui.basic.EnumerationType description=\"Authorization Action\" enumTypeId=\"AuthzAction\"/>\n            <moqui.basic.Enumeration description=\"View\" enumCode=\"VIEW\" enumId=\"AUTHZA_VIEW\" enumTypeId=\"AuthzAction\"/>\n            <moqui.basic.Enumeration description=\"Create\" enumCode=\"CREATE\" enumId=\"AUTHZA_CREATE\" enumTypeId=\"AuthzAction\"/>\n            <moqui.basic.Enumeration description=\"Update\" enumCode=\"UPDATE\" enumId=\"AUTHZA_UPDATE\" enumTypeId=\"AuthzAction\"/>\n            <moqui.basic.Enumeration description=\"Delete\" enumCode=\"DELETE\" enumId=\"AUTHZA_DELETE\" enumTypeId=\"AuthzAction\"/>\n            <moqui.basic.Enumeration description=\"All\" enumCode=\"ALL\" enumId=\"AUTHZA_ALL\" enumTypeId=\"AuthzAction\"/>\n        </seed-data>\n    </entity>\n    <entity entity-name=\"ArtifactAuthzFailure\" package=\"moqui.security\" use=\"nontransactional\" cache=\"never\">\n        <field name=\"failureId\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"artifactName\" type=\"text-medium\"/>\n        <field name=\"artifactTypeEnumId\" type=\"id\"/>\n        <field name=\"authzActionEnumId\" type=\"id\"/>\n        <field name=\"userId\" type=\"id\"/>\n        <field name=\"failureDate\" type=\"date-time\"/>\n        <field name=\"isDeny\" type=\"text-indicator\"/>\n        <relationship type=\"one\" title=\"ArtifactType\" related=\"moqui.basic.Enumeration\">\n            <key-map field-name=\"artifactTypeEnumId\"/></relationship>\n        <relationship type=\"one\" title=\"AuthzAction\" related=\"moqui.basic.Enumeration\">\n            <key-map field-name=\"authzActionEnumId\"/></relationship>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\">\n            <description>No FK in order to allow externally authenticated users.</description></relationship>\n    </entity>\n    <view-entity entity-name=\"ArtifactAuthzCheckView\" package=\"moqui.security\">\n        <member-entity entity-alias=\"AAZ\" entity-name=\"moqui.security.ArtifactAuthz\"/>\n        <member-relationship entity-alias=\"AGM\" join-from-alias=\"AAZ\" relationship=\"groupMembers\"/>\n        <alias entity-alias=\"AAZ\" name=\"userGroupId\"/>\n        <alias entity-alias=\"AAZ\" name=\"artifactAuthzId\"/>\n        <alias entity-alias=\"AAZ\" name=\"authzActionEnumId\"/>\n        <alias entity-alias=\"AAZ\" name=\"authzTypeEnumId\"/>\n        <alias entity-alias=\"AAZ\" name=\"authzServiceName\"/>\n        <alias entity-alias=\"AGM\" name=\"artifactGroupId\"/>\n        <alias entity-alias=\"AGM\" name=\"artifactName\"/>\n        <alias entity-alias=\"AGM\" name=\"artifactTypeEnumId\"/>\n        <alias entity-alias=\"AGM\" name=\"nameIsPattern\"/>\n        <alias entity-alias=\"AGM\" name=\"inheritAuthz\"/>\n        <alias entity-alias=\"AGM\" name=\"filterMap\"/>\n    </view-entity>\n\n    <entity entity-name=\"ArtifactAuthzFilter\" package=\"moqui.security\" use=\"configuration\">\n        <field name=\"artifactAuthzId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"entityFilterSetId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"applyCond\" type=\"text-medium\">\n            <description>Groovy boolean (if) expression, if specified checked before applying filters in the set</description></field>\n        <relationship type=\"one\" related=\"moqui.security.ArtifactAuthz\" short-alias=\"authz\"/>\n        <relationship type=\"one\" related=\"moqui.security.EntityFilterSet\" short-alias=\"filterSet\"/>\n    </entity>\n    <entity entity-name=\"EntityFilterSet\" package=\"moqui.security\" use=\"configuration\">\n        <field name=\"entityFilterSetId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"applyCond\" type=\"text-medium\">\n            <description>Groovy boolean (if) expression, if specified checked before applying filters in the set</description></field>\n        <field name=\"allowMissingAlias\" type=\"text-indicator\">\n            <description>By default if a filterMap refers to a field not aliased in a view-entity there will be an error, set this to Y to do the query anyway</description></field>\n        <relationship type=\"many\" related=\"moqui.security.EntityFilter\" short-alias=\"filters\">\n            <key-map field-name=\"entityFilterSetId\"/></relationship>\n    </entity>\n    <entity entity-name=\"EntityFilter\" package=\"moqui.security\" use=\"configuration\">\n        <field name=\"entityFilterId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"entityFilterSetId\" type=\"id\"/>\n        <field name=\"entityName\" type=\"text-medium\"><description>The name of the entity to filter when queried. May be\n            queried directly or as part of a view-entity.</description></field>\n        <field name=\"filterMap\" type=\"text-long\"><description>\n            A Groovy expression that evaluates to a Map that will be added to queries to filter/constrain visible data.\n            Values can be constants or variables that come from user context (ec.user.context) or execution context\n            (ec.context). It is up to code to set values for use by these filters.\n\n            If the value evaluates to a Collection the default comparison operator is IN, otherwise default is EQUALS.\n            If a Collection is empty results in a false constraint, unless using a NOT* comparison operator.\n        </description></field>\n        <field name=\"comparisonEnumId\" type=\"id\"/>\n        <field name=\"joinOr\" type=\"text-indicator\"><description>If Y then OR filterMap entries, default AND.</description></field>\n        <!-- FUTURE: EntityFilterField with field path like DataDocument to join other entities/fields into queries for filtering -->\n\n        <relationship type=\"one\" related=\"moqui.security.EntityFilterSet\" short-alias=\"filterSet\"/>\n        <relationship type=\"one\" title=\"ComparisonOperator\" related=\"moqui.basic.Enumeration\" short-alias=\"comparison\">\n            <key-map field-name=\"comparisonEnumId\"/></relationship>\n    </entity>\n    <view-entity entity-name=\"ArtifactAuthzFilterAndSet\" package=\"moqui.security\">\n        <member-entity entity-alias=\"AAF\" entity-name=\"moqui.security.ArtifactAuthzFilter\"/>\n        <member-entity entity-alias=\"EFS\" entity-name=\"moqui.security.EntityFilterSet\" join-from-alias=\"AAF\">\n            <key-map field-name=\"entityFilterSetId\"/></member-entity>\n        <alias-all entity-alias=\"AAF\"><exclude field=\"applyCond\"/></alias-all>\n        <alias-all entity-alias=\"EFS\"/>\n        <alias entity-alias=\"AAF\" name=\"authzApplyCond\" field=\"applyCond\"/>\n    </view-entity>\n\n    <!-- ========== Artifact Tarpit ========== -->\n\n    <entity entity-name=\"ArtifactTarpit\" package=\"moqui.security\" use=\"configuration\">\n        <description>If an artifact in the group specified is accessed by any user in the UserGroup maxHitsCount\n            times in maxHitsDuration seconds then the user/artifact will be blocked for tarpitDuration seconds.\n        </description>\n        <field name=\"userGroupId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"artifactGroupId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"maxHitsCount\" type=\"number-integer\"/>\n        <field name=\"maxHitsDuration\" type=\"number-integer\"/>\n        <field name=\"tarpitDuration\" type=\"number-integer\"/>\n        <relationship type=\"one\" related=\"moqui.security.UserGroup\"/>\n        <relationship type=\"one\" related=\"moqui.security.ArtifactGroup\"/>\n    </entity>\n    <view-entity entity-name=\"ArtifactTarpitCheckView\" package=\"moqui.security\">\n        <member-entity entity-alias=\"ATP\" entity-name=\"moqui.security.ArtifactTarpit\"/>\n        <member-entity entity-alias=\"AGM\" entity-name=\"moqui.security.ArtifactGroupMember\" join-from-alias=\"ATP\">\n            <key-map field-name=\"artifactGroupId\"/></member-entity>\n        <alias entity-alias=\"ATP\" name=\"userGroupId\"/>\n        <alias entity-alias=\"ATP\" name=\"artifactGroupId\"/>\n        <alias entity-alias=\"ATP\" name=\"maxHitsCount\"/>\n        <alias entity-alias=\"ATP\" name=\"maxHitsDuration\"/>\n        <alias entity-alias=\"ATP\" name=\"tarpitDuration\"/>\n        <alias entity-alias=\"AGM\" name=\"artifactName\"/>\n        <alias entity-alias=\"AGM\" name=\"artifactTypeEnumId\"/>\n        <alias entity-alias=\"AGM\" name=\"nameIsPattern\"/>\n    </view-entity>\n    <entity entity-name=\"ArtifactTarpitLock\" package=\"moqui.security\" use=\"transactional\" cache=\"never\">\n        <description>Deny access to the artifact for the user until releaseDateTime is reached.</description>\n        <field name=\"artifactTarpitLockId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"userId\" type=\"id\"/>\n        <field name=\"artifactName\" type=\"text-medium\"/>\n        <field name=\"artifactTypeEnumId\" type=\"id\"/>\n        <field name=\"releaseDateTime\" type=\"date-time\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\">\n            <description>No FK in order to allow externally authenticated users.</description></relationship>\n        <relationship type=\"one\" title=\"ArtifactType\" related=\"moqui.basic.Enumeration\">\n            <key-map field-name=\"artifactTypeEnumId\"/></relationship>\n    </entity>\n\n    <!-- ========== User ========== -->\n\n    <entity entity-name=\"UserAccount\" package=\"moqui.security\" use=\"transactional\" short-alias=\"users\">\n        <field name=\"userId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"username\" type=\"text-medium\" enable-audit-log=\"true\">\n            <description>The username used along with the password to login</description></field>\n        <field name=\"userFullName\" type=\"text-medium\" enable-audit-log=\"update\">\n            <description>User's first, middle, last, etc name</description></field>\n        <field name=\"currentPassword\" type=\"text-medium\">\n            <description>NOTE: not an encrypted field because one way hash encryption used for it</description></field>\n        <field name=\"resetPassword\" type=\"text-medium\">\n            <description>Set to random password for password reset, can be used only to update password</description></field>\n        <field name=\"passwordSalt\" type=\"text-medium\"/>\n        <field name=\"passwordHashType\" type=\"text-short\"/>\n        <field name=\"passwordBase64\" type=\"text-indicator\">\n            <description>Set to Y is currentPassword Base64 encoded, defaults to Hex encoded</description></field>\n        <field name=\"passwordSetDate\" type=\"date-time\"/>\n        <field name=\"passwordHint\" type=\"text-medium\"/>\n        <field name=\"publicKey\" type=\"text-long\"><description>RSA public key for key based authentication</description></field>\n        <field name=\"hasLoggedOut\" type=\"text-indicator\"><description>Set to Y when user logs out and to N when user logs in.\n            If user is session authenticated on request and this is Y then treat as if user not authenticated.</description></field>\n        <field name=\"disabled\" type=\"text-indicator\" default=\"'N'\" enable-audit-log=\"update\"/>\n        <field name=\"disabledDateTime\" type=\"date-time\"/>\n        <field name=\"terminateDate\" type=\"date-time\" enable-audit-log=\"true\">\n            <description>If set then user may not login after this date, and no notifications will be sent after this date.</description></field>\n        <field name=\"successiveFailedLogins\" type=\"number-integer\"/>\n        <field name=\"requirePasswordChange\" type=\"text-indicator\"/>\n        <field name=\"currencyUomId\" type=\"id\"/>\n        <field name=\"locale\" type=\"text-short\"/>\n        <field name=\"timeZone\" type=\"text-short\"/>\n        <field name=\"externalUserId\" type=\"text-medium\"/>\n        <field name=\"emailAddress\" type=\"text-medium\" enable-audit-log=\"update\">\n            <description>The email address to use for forgot password emails and other system messages.</description></field>\n        <field name=\"ipAllowed\" type=\"text-medium\">\n            <description>If specified only allow login from matching IP4 address. Comma separated patterns where each pattern has 4\n                dot separated segments each segment may be number, '*' for wildcard, or '-' separate number range (like '0-31').</description>\n        </field>\n        <relationship type=\"one\" title=\"Currency\" related=\"moqui.basic.Uom\" short-alias=\"currencyUom\">\n            <key-map field-name=\"currencyUomId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.security.UserGroupMember\" short-alias=\"groups\">\n            <key-map field-name=\"userId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.security.UserLoginKey\" short-alias=\"loginKeys\">\n            <key-map field-name=\"userId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.security.UserLoginHistory\" short-alias=\"loginHistories\">\n            <key-map field-name=\"userId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.security.UserPreference\" short-alias=\"preferences\">\n            <key-map field-name=\"userId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.security.user.NotificationTopicUser\" short-alias=\"notificationTopics\">\n            <key-map field-name=\"userId\"/></relationship>\n\n        <!-- NOTE: both username and emailAddress must be unique -->\n        <index name=\"USERACCT_USERNAME\" unique=\"true\"><index-field name=\"username\"/></index>\n        <index name=\"USERACCT_EMAILADDR\" unique=\"true\"><index-field name=\"emailAddress\"/></index>\n        <seed-data>\n            <moqui.security.UserAccount userId=\"_NA_\" username=\"_NA_\" userFullName=\"Not Applicable\" currentPassword=\"\" disabled=\"Y\"/>\n        </seed-data>\n    </entity>\n    <view-entity entity-name=\"UserAccountAndGroup\" package=\"moqui.security\">\n        <member-entity entity-alias=\"UAC\" entity-name=\"moqui.security.UserAccount\"/>\n        <member-relationship entity-alias=\"UGM\" join-from-alias=\"UAC\" relationship=\"groups\" join-optional=\"true\"/>\n        <member-relationship entity-alias=\"ULH\" join-from-alias=\"UAC\" relationship=\"loginHistories\" join-optional=\"true\"/>\n        <member-relationship entity-alias=\"NTU\" join-from-alias=\"UAC\" relationship=\"notificationTopics\" join-optional=\"true\"/>\n        <alias-all entity-alias=\"UAC\"/>\n        <alias-all entity-alias=\"UGM\"/>\n        <alias name=\"loginFromDate\" entity-alias=\"ULH\" field=\"fromDate\"/>\n        <alias name=\"topic\" entity-alias=\"NTU\"/>\n        <alias name=\"receiveNotifications\" entity-alias=\"NTU\"/>\n        <alias name=\"emailNotifications\" entity-alias=\"NTU\"/>\n    </view-entity>\n    <entity entity-name=\"UserAuthcFactor\" package=\"moqui.security\" use=\"configuration\">\n        <description>This entity is for recording User Authentication Factors.</description>\n        <field name=\"factorId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"userId\" type=\"id\" not-null=\"true\"/>\n        <field name=\"fromFactorId\" type=\"id\"/>\n        <field name=\"factorTypeEnumId\" type=\"id\"/>\n        <field name=\"fromDate\" type=\"date-time\"/>\n        <field name=\"thruDate\" type=\"date-time\"/>\n        <field name=\"factorOption\" type=\"text-medium\" encrypt=\"true\">\n            <description>Use varies based on factor type: TOTP is shared secret, Single Use is the code, Email Code is email address</description></field>\n        <field name=\"needsValidation\" type=\"text-indicator\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\" short-alias=\"user\">\n            <description>No FK in order to allow externally authenticated users.</description></relationship>\n        <relationship type=\"one\" title=\"UserAuthcFactorType\" related=\"moqui.basic.Enumeration\" short-alias=\"factorTypeEnum\">\n            <key-map field-name=\"factorTypeEnumId\"/></relationship>\n        <seed-data>\n            <moqui.basic.EnumerationType description=\"User Authentication Factor Type\" enumTypeId=\"UserAuthcFactorType\"/>\n            <moqui.basic.Enumeration description=\"Authenticator App (TOTP)\" enumId=\"UafTotp\" enumTypeId=\"UserAuthcFactorType\"/>\n            <moqui.basic.Enumeration description=\"Single Use Code\" enumId=\"UafSingleUse\" enumTypeId=\"UserAuthcFactorType\"/>\n            <moqui.basic.Enumeration description=\"Email Code\" enumId=\"UafEmail\" enumTypeId=\"UserAuthcFactorType\"/>\n            <moqui.basic.Enumeration description=\"SMS Code\" enumId=\"UafSms\" enumTypeId=\"UserAuthcFactorType\"/>\n            <!-- <moqui.basic.Enumeration description=\"Authenticator App (HMAC OTP)\" enumId=\"UafHotp\" enumTypeId=\"UserAuthcFactorType\"/> -->\n\n            <!-- generic factor record for UserAccount.emailAddress as an authc factor -->\n            <moqui.security.UserAuthcFactor factorId=\"UserAccountEmail\" userId=\"_NA_\" factorTypeEnumId=\"UafEmail\" fromDate=\"0\" thruDate=\"0\"/>\n        </seed-data>\n    </entity>\n    <entity entity-name=\"UserGroup\" package=\"moqui.security\" use=\"configuration\" short-alias=\"userGroups\">\n        <field name=\"userGroupId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"groupTypeEnumId\" type=\"id\"/>\n        <field name=\"ipAllowed\" type=\"text-medium\"><description>See UserAccout.ipAllowed</description></field>\n        <field name=\"requireAuthcFactor\" type=\"text-indicator\"/>\n        <relationship type=\"one\" title=\"UserGroupType\" related=\"moqui.basic.Enumeration\" short-alias=\"groupTypeEnum\">\n            <key-map field-name=\"groupTypeEnumId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.security.UserGroupPermission\" short-alias=\"permissions\">\n            <key-map field-name=\"userGroupId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.security.UserGroupPreference\" short-alias=\"preferences\">\n            <key-map field-name=\"userGroupId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.security.ArtifactAuthz\" short-alias=\"authz\">\n            <key-map field-name=\"userGroupId\"/></relationship>\n        <seed-data>\n            <moqui.basic.EnumerationType description=\"User Group Type\" enumTypeId=\"UserGroupType\"/>\n        </seed-data>\n    </entity>\n    <entity entity-name=\"UserGroupMember\" package=\"moqui.security\" use=\"configuration\" cache=\"true\">\n        <field name=\"userGroupId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"userId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"fromDate\" type=\"date-time\" is-pk=\"true\"/>\n        <field name=\"thruDate\" type=\"date-time\"/>\n        <relationship type=\"one\" related=\"moqui.security.UserGroup\" short-alias=\"group\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\" short-alias=\"user\">\n            <description>No FK in order to allow externally authenticated users.</description></relationship>\n        <relationship type=\"many\" related=\"moqui.security.UserGroupPermission\" short-alias=\"permissions\">\n            <key-map field-name=\"userGroupId\"/></relationship>\n    </entity>\n    <view-entity entity-name=\"UserGroupMemberUser\" package=\"moqui.security\">\n        <member-entity entity-alias=\"UGM\" entity-name=\"moqui.security.UserGroupMember\"/>\n        <member-relationship entity-alias=\"UAC\" join-from-alias=\"UGM\" relationship=\"user\"/>\n        <alias-all entity-alias=\"UGM\"/>\n        <alias-all entity-alias=\"UAC\"/>\n    </view-entity>\n    <view-entity entity-name=\"UserGroupAndMember\" package=\"moqui.security\">\n        <member-entity entity-alias=\"UGM\" entity-name=\"moqui.security.UserGroupMember\"/>\n        <member-relationship entity-alias=\"UG\" join-from-alias=\"UGM\" relationship=\"group\"/>\n        <alias-all entity-alias=\"UGM\"/>\n        <alias-all entity-alias=\"UG\"/>\n    </view-entity>\n    <entity entity-name=\"UserGroupPermission\" package=\"moqui.security\" use=\"configuration\" cache=\"true\" short-alias=\"userGroupPermissions\">\n        <field name=\"userGroupId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"userPermissionId\" type=\"id-long\" is-pk=\"true\"/>\n        <field name=\"fromDate\" type=\"date-time\" is-pk=\"true\"/>\n        <field name=\"thruDate\" type=\"date-time\"/>\n        <relationship type=\"one\" related=\"moqui.security.UserGroup\" short-alias=\"group\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserPermission\" short-alias=\"permission\">\n            <description>No FK in order to allow arbitrary permissions (ie not pre-configured).</description></relationship>\n    </entity>\n    <view-entity entity-name=\"UserPermissionCheck\" package=\"moqui.security\">\n        <member-entity entity-alias=\"UGM\" entity-name=\"moqui.security.UserGroupMember\"/>\n        <member-relationship entity-alias=\"UGP\" join-from-alias=\"UGM\" relationship=\"permissions\"/>\n        <alias name=\"userGroupId\" entity-alias=\"UGM\"/>\n        <alias name=\"userId\" entity-alias=\"UGM\"/>\n        <alias name=\"userPermissionId\" entity-alias=\"UGP\"/>\n        <alias name=\"groupFromDate\" entity-alias=\"UGM\" field=\"fromDate\"/>\n        <alias name=\"groupThruDate\" entity-alias=\"UGM\" field=\"thruDate\"/>\n        <alias name=\"permissionFromDate\" entity-alias=\"UGP\" field=\"fromDate\"/>\n        <alias name=\"permissionThruDate\" entity-alias=\"UGP\" field=\"thruDate\"/>\n    </view-entity>\n\n    <entity entity-name=\"UserLoginKey\" package=\"moqui.security\" use=\"transactional\" cache=\"never\">\n        <description>A login key is an alternate way to authenticate a user, generally issued for temporary use sort of\n            like a session.</description>\n        <field name=\"loginKey\" type=\"text-medium\" is-pk=\"true\"><description>NOTE: not an encrypted field because one way\n            hash encryption used for it (uses login-key.@encrypt-hash-type, no salt, hash before lookup on verify)</description></field>\n        <field name=\"userId\" type=\"id\"/>\n        <field name=\"fromDate\" type=\"date-time\"/>\n        <field name=\"thruDate\" type=\"date-time\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <relationship type=\"one\" related=\"moqui.security.UserAccount\" short-alias=\"user\"/>\n    </entity>\n    <entity entity-name=\"UserLoginHistory\" package=\"moqui.security\" use=\"nontransactional\" cache=\"never\">\n        <field name=\"userId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"fromDate\" type=\"date-time\" is-pk=\"true\"/>\n        <field name=\"thruDate\" type=\"date-time\"/>\n        <field name=\"visitId\" type=\"id\"/>\n        <field name=\"passwordUsed\" type=\"text-medium\" encrypt=\"true\"/>\n        <field name=\"successfulLogin\" type=\"text-indicator\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\" short-alias=\"user\">\n            <description>No FK in order to allow externally authenticated users.</description></relationship>\n        <relationship type=\"one\" related=\"moqui.server.Visit\" short-alias=\"visit\"/>\n    </entity>\n    <entity entity-name=\"UserPasswordHistory\" package=\"moqui.security\" use=\"nontransactional\" cache=\"never\">\n        <field name=\"userId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"fromDate\" type=\"date-time\" is-pk=\"true\"/>\n        <field name=\"password\" type=\"text-medium\"/>\n        <field name=\"passwordSalt\" type=\"text-medium\"/>\n        <field name=\"passwordHashType\" type=\"text-short\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\" short-alias=\"user\">\n            <description>No FK in order to allow externally authenticated users.</description></relationship>\n    </entity>\n    <entity entity-name=\"UserPermission\" package=\"moqui.security\" use=\"configuration\" short-alias=\"userPermissions\">\n        <field name=\"userPermissionId\" type=\"id-long\" is-pk=\"true\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n    </entity>\n    <entity entity-name=\"UserPreference\" package=\"moqui.security\" use=\"configuration\">\n        <description>Use this entity for user-specific preferences (or properties). For default preferences use userId=\"_NA_\".</description>\n        <field name=\"userId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"preferenceKey\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"preferenceValue\" type=\"text-long\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\" short-alias=\"user\">\n            <description>No FK in order to allow externally authenticated users.</description></relationship>\n        <relationship type=\"one-nofk\" title=\"UserPreferenceKey\" related=\"moqui.basic.Enumeration\" short-alias=\"keyEnum\">\n            <description>No FK because any key can be used whether or not there is an Enumeration record for it.</description>\n            <key-map field-name=\"preferenceKey\"/></relationship>\n        <seed-data>\n            <!-- User Preference Keys can be declared in Enumeration records, but they don't have to be -->\n            <moqui.basic.EnumerationType description=\"User Preference Key (optional)\" enumTypeId=\"UserPreferenceKey\"/>\n        </seed-data>\n    </entity>\n    <entity entity-name=\"UserGroupPreference\" package=\"moqui.security\" use=\"configuration\">\n        <description>Use this entity for user group preferences (or properties). For all users use userGroupId=\"ALL_USERS\".</description>\n        <field name=\"userGroupId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"preferenceKey\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"preferenceValue\" type=\"text-long\"/>\n        <field name=\"groupPriority\" type=\"number-integer\">\n            <description>For deciding which value to use when a user is a member of multiple groups with preferences with the same key.</description></field>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserGroup\" short-alias=\"group\">\n            <description>No FK in order to allow externally authenticated users.</description></relationship>\n        <relationship type=\"one-nofk\" title=\"UserPreferenceKey\" related=\"moqui.basic.Enumeration\" short-alias=\"keyEnum\">\n            <description>No FK because any key can be used whether or not there is an Enumeration record for it.</description>\n            <key-map field-name=\"preferenceKey\"/></relationship>\n    </entity>\n    <entity entity-name=\"UserScreenTheme\" package=\"moqui.security\" use=\"configuration\" cache=\"true\">\n        <field name=\"userId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"screenThemeTypeEnumId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"screenThemeId\" type=\"id\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\" short-alias=\"user\">\n            <description>No FK in order to allow externally authenticated users.</description></relationship>\n        <relationship type=\"one\" title=\"ScreenThemeType\" related=\"moqui.basic.Enumeration\">\n            <key-map field-name=\"screenThemeTypeEnumId\"/></relationship>\n        <relationship type=\"one\" related=\"moqui.screen.ScreenTheme\"/>\n    </entity>\n    <entity entity-name=\"UserGroupScreenTheme\" package=\"moqui.security\" use=\"configuration\" cache=\"true\">\n        <field name=\"userGroupId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"screenThemeTypeEnumId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"screenThemeId\" type=\"id\"/>\n        <field name=\"sequenceNum\" type=\"number-integer\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserGroup\" short-alias=\"group\">\n            <description>No FK in order to allow externally authenticated users.</description></relationship>\n        <relationship type=\"one\" title=\"ScreenThemeType\" related=\"moqui.basic.Enumeration\">\n            <key-map field-name=\"screenThemeTypeEnumId\"/></relationship>\n        <relationship type=\"one\" related=\"moqui.screen.ScreenTheme\"/>\n    </entity>\n\n    <!-- ========== User Notifications ========== -->\n\n    <entity entity-name=\"NotificationTopic\" package=\"moqui.security.user\" use=\"configuration\" cache=\"true\">\n        <field name=\"topic\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"titleTemplate\" type=\"text-long\"/>\n        <field name=\"errorTitleTemplate\" type=\"text-long\"><description>If populated used when type=danger</description></field>\n        <field name=\"linkTemplate\" type=\"text-long\"/>\n        <field name=\"typeString\" type=\"text-short\"><description>One of: info, success, warning, danger</description></field>\n        <field name=\"showAlert\" type=\"text-indicator\"/>\n        <field name=\"alertNoAutoHide\" type=\"text-indicator\"/>\n        <field name=\"persistOnSend\" type=\"text-indicator\"/>\n        <field name=\"isPrivate\" type=\"text-indicator\"><description>If Y user must be associated to see it or receive notifications.</description></field>\n        <field name=\"receiveNotifications\" type=\"text-indicator\"><description>For each User if there is no NotificationTopicUser.receiveNotifications value then use this as the default, this defaults to N.</description></field>\n        <field name=\"emailNotifications\" type=\"text-indicator\"><description>For each User if there is no NotificationTopicUser.emailNotifications value then use this as the default, this defaults to N.</description></field>\n        <field name=\"emailTemplateId\" type=\"id\"><description>If is specified use this template to send a notification email to each user with emailNotifications=Y</description></field>\n        <field name=\"emailMessageSave\" type=\"text-indicator\"/>\n        <relationship type=\"one\" related=\"moqui.basic.email.EmailTemplate\"/>\n    </entity>\n    <entity entity-name=\"NotificationTopicUser\" package=\"moqui.security.user\" use=\"configuration\" cache=\"true\">\n        <field name=\"topic\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"userId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"receiveNotifications\" type=\"text-indicator\"><description>If notification sent to user only actually notify if this is Y</description></field>\n        <field name=\"allNotifications\" type=\"text-indicator\"><description>If Y user receives all notifications on topic even if not sent directly</description></field>\n        <field name=\"emailNotifications\" type=\"text-indicator\"><description>If Y sends an email to user using UserAccount.emailAddress</description></field>\n        <relationship type=\"one-nofk\" related=\"moqui.security.user.NotificationTopic\" short-alias=\"notificationTopic\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\" short-alias=\"user\"/>\n    </entity>\n\n    <entity entity-name=\"NotificationMessage\" package=\"moqui.security.user\" use=\"nontransactional\" cache=\"never\">\n        <field name=\"notificationMessageId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"topic\" type=\"text-medium\"/>\n        <field name=\"subTopic\" type=\"text-medium\"/>\n        <field name=\"userGroupId\" type=\"id\"/>\n        <field name=\"sentDate\" type=\"date-time\"/>\n        <field name=\"messageJson\" type=\"text-very-long\"/>\n        <field name=\"titleText\" type=\"text-long\"/>\n        <field name=\"linkText\" type=\"text-long\"/>\n        <field name=\"typeString\" type=\"text-short\"/>\n        <field name=\"showAlert\" type=\"text-indicator\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.user.NotificationTopic\" short-alias=\"notificationTopic\"/>\n        <relationship type=\"one\" related=\"moqui.security.UserGroup\" short-alias=\"userGroup\"/>\n        <relationship type=\"many\" related=\"moqui.security.user.NotificationMessageUser\" short-alias=\"users\">\n            <key-map field-name=\"notificationMessageId\"/></relationship>\n    </entity>\n    <entity entity-name=\"NotificationMessageUser\" package=\"moqui.security.user\" use=\"nontransactional\" cache=\"never\">\n        <field name=\"notificationMessageId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"userId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"sentDate\" type=\"date-time\"/>\n        <field name=\"viewedDate\" type=\"date-time\"/>\n        <field name=\"emailMessageId\" type=\"id\"/>\n        <relationship type=\"one\" related=\"moqui.security.user.NotificationMessage\" short-alias=\"notification\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\" short-alias=\"user\"/>\n        <relationship type=\"one\" related=\"moqui.basic.email.EmailMessage\" short-alias=\"emailMessage\"/>\n        <index name=\"NOTMSGUSR_UID_VD\" unique=\"false\"><index-field name=\"userId\"/><index-field name=\"viewedDate\"/></index>\n    </entity>\n    <view-entity entity-name=\"NotificationMessageByUser\" package=\"moqui.security.user\" cache=\"never\">\n        <member-entity entity-alias=\"NMSG\" entity-name=\"moqui.security.user.NotificationMessage\"/>\n        <member-relationship entity-alias=\"NMU\" join-from-alias=\"NMSG\" relationship=\"users\"/>\n        <alias-all entity-alias=\"NMSG\"/>\n        <alias entity-alias=\"NMU\" name=\"userId\"/>\n        <alias entity-alias=\"NMU\" name=\"userSentDate\" field=\"sentDate\"/>\n        <alias entity-alias=\"NMU\" name=\"viewedDate\"/>\n    </view-entity>\n</entities>\n"
  },
  {
    "path": "framework/entity/ServerEntities.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<entities xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/entity-definition-3.xsd\">\n\n    <!-- ========================================================= -->\n    <!-- moqui.server -->\n    <!-- ========================================================= -->\n\n    <entity entity-name=\"ArtifactHit\" package=\"moqui.server\" use=\"logging\" cache=\"never\" sequence-bank-size=\"100\">\n        <field name=\"hitId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"visitId\" type=\"id\"/>\n        <field name=\"userId\" type=\"id\"/>\n        <field name=\"artifactType\" type=\"text-medium\"/>\n        <field name=\"artifactSubType\" type=\"text-medium\"/>\n        <field name=\"artifactName\" type=\"text-medium\">\n            <description>The name of the artifact hit. For XML Screen request it is \"${webapp-name}.${screen-path}\"</description></field>\n        <field name=\"parameterString\" type=\"text-long\"/>\n        <field name=\"startDateTime\" type=\"date-time\"/>\n        <field name=\"runningTimeMillis\" type=\"number-float\"/>\n        <field name=\"isSlowHit\" type=\"text-indicator\"/>\n        <field name=\"outputSize\" type=\"number-integer\"/>\n        <field name=\"wasError\" type=\"text-indicator\"/>\n        <field name=\"errorMessage\" type=\"text-long\"/>\n        <field name=\"requestUrl\" type=\"text-long\"/>\n        <field name=\"referrerUrl\" type=\"text-long\"/>\n        <field name=\"serverIpAddress\" type=\"id\"/>\n        <field name=\"serverHostName\" type=\"text-medium\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.server.Visit\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\"/>\n        <index name=\"ARTIFACT_HIT_VST\"><index-field name=\"visitId\"/></index>\n        <index name=\"ARTIFACT_HIT_USR\"><index-field name=\"userId\"/></index>\n    </entity>\n    <!-- to use Elastic/OpenSearch for ArtifactHit: add to logging group which is configured by default to use the 'default' cluster via ElasticFacade -->\n    <!-- TODO: make sure this is commented this before merging!! (not great as default config) -->\n    <!-- <extend-entity entity-name=\"ArtifactHit\" package=\"moqui.server\" group=\"logging\"/> -->\n    <!--\n        TODO for migration:\n        - somehow have ArtifactHitDb always in 'transactional' group to read from and write to ArtifactHit in the logging group\n        - some way to clone entity to new name, with different group/etc?\n        - or some way to do entity operations and override the group name at runtime? might be handy for other things too...\n    -->\n\n    <entity entity-name=\"ArtifactHitBin\" package=\"moqui.server\" use=\"nontransactional\" cache=\"never\" sequence-bank-size=\"100\">\n        <field name=\"hitBinId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"artifactType\" type=\"text-medium\"/>\n        <field name=\"artifactSubType\" type=\"text-medium\"/>\n        <field name=\"artifactName\" type=\"text-medium\"/>\n        <field name=\"serverIpAddress\" type=\"id\"/>\n        <field name=\"serverHostName\" type=\"text-medium\"/>\n        <field name=\"binStartDateTime\" type=\"date-time\"/>\n        <field name=\"binEndDateTime\" type=\"date-time\"/>\n        <field name=\"hitCount\" type=\"number-integer\"/>\n        <field name=\"totalTimeMillis\" type=\"number-decimal\"/>\n        <field name=\"totalSquaredTime\" type=\"number-decimal\"><description>Total (sum) of the squared running times for\n            calculating incremental standard deviation.</description></field>\n        <field name=\"minTimeMillis\" type=\"number-decimal\"/>\n        <field name=\"maxTimeMillis\" type=\"number-decimal\"/>\n        <field name=\"slowHitCount\" type=\"number-integer\"><description>After 100 hits count of hits more that 2.6\n            standard deviations above average (both avg and std dev adjusted incrementally).</description></field>\n    </entity>\n    <view-entity entity-name=\"ArtifactHitReport\" package=\"moqui.server\" cache=\"never\">\n        <member-entity entity-alias=\"AHB\" entity-name=\"moqui.server.ArtifactHitBin\"/>\n        <alias entity-alias=\"AHB\" name=\"artifactType\"/><!-- this will group by automatically -->\n        <alias entity-alias=\"AHB\" name=\"artifactSubType\"/><!-- this will group by automatically -->\n        <alias entity-alias=\"AHB\" name=\"artifactName\"/><!-- this will group by automatically -->\n        <alias entity-alias=\"AHB\" name=\"hitCount\" function=\"sum\"/>\n        <alias entity-alias=\"AHB\" name=\"totalTimeMillis\" function=\"sum\"/>\n        <alias entity-alias=\"AHB\" name=\"totalSquaredTime\" function=\"sum\"/>\n        <alias entity-alias=\"AHB\" name=\"firstHitDateTime\" field=\"binStartDateTime\" function=\"min\"/>\n        <alias entity-alias=\"AHB\" name=\"lastHitDateTime\" field=\"binEndDateTime\" function=\"max\"/>\n        <alias entity-alias=\"AHB\" name=\"minTimeMillis\" function=\"min\"/>\n        <alias entity-alias=\"AHB\" name=\"maxTimeMillis\" function=\"max\"/>\n        <alias entity-alias=\"AHB\" name=\"slowHitCount\" function=\"sum\"/>\n    </view-entity>\n\n    <entity entity-name=\"Visit\" package=\"moqui.server\" sequence-bank-size=\"100\" use=\"nontransactional\" cache=\"never\">\n        <field name=\"visitId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"visitorId\" type=\"id\"/>\n        <field name=\"userId\" type=\"id\"/>\n        <field name=\"userCreated\" type=\"text-indicator\"/>\n        <field name=\"sessionId\" type=\"text-medium\"/>\n        <field name=\"serverIpAddress\" type=\"id\"/>\n        <field name=\"serverHostName\" type=\"text-medium\"/>\n        <field name=\"webappName\" type=\"text-medium\"/>\n        <field name=\"initialLocale\" type=\"text-short\"/>\n        <field name=\"initialRequest\" type=\"text-long\"/>\n        <field name=\"initialReferrer\" type=\"text-long\"/>\n        <field name=\"initialUserAgent\" type=\"text-medium\"/>\n        <field name=\"clientIpAddress\" type=\"text-short\"/>\n        <field name=\"clientHostName\" type=\"text-medium\"/>\n        <field name=\"clientUser\" type=\"text-medium\"/>\n        <field name=\"clientIpIspName\" type=\"text-short\"/>\n        <field name=\"clientIpPostalCode\" type=\"text-short\"/>\n        <field name=\"clientIpCity\" type=\"text-short\"/>\n        <field name=\"clientIpMetroCode\" type=\"text-short\"/>\n        <field name=\"clientIpRegionCode\" type=\"text-short\"/>\n        <field name=\"clientIpRegionName\" type=\"text-short\"/>\n        <field name=\"clientIpStateProvGeoId\" type=\"id\"/>\n        <field name=\"clientIpCountryGeoId\" type=\"id\"/>\n        <field name=\"clientIpLatitude\" type=\"text-short\"/>\n        <field name=\"clientIpLongitude\" type=\"text-short\"/>\n        <field name=\"clientIpTimeZone\" type=\"text-short\"/>\n        <field name=\"fromDate\" type=\"date-time\"/>\n        <field name=\"thruDate\" type=\"date-time\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.server.Visitor\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\"/>\n        <relationship type=\"one-nofk\" title=\"ClientIpStateProv\" related=\"moqui.basic.Geo\">\n            <key-map field-name=\"clientIpStateProvGeoId\" related=\"geoId\"/></relationship>\n        <relationship type=\"one-nofk\" title=\"ClientIpCountry\" related=\"moqui.basic.Geo\">\n            <key-map field-name=\"clientIpCountryGeoId\" related=\"geoId\"/></relationship>\n        <!-- expensive for updates <index name=\"VisitThruIndex\" unique=\"false\"><index-field name=\"thruDate\"/></index> -->\n        <index name=\"VISIT_USER_ACC\"><index-field name=\"userId\"/></index>\n        <index name=\"VISIT_VISITOR\"><index-field name=\"visitorId\"/></index>\n    </entity>\n    <view-entity entity-name=\"VisitUserSummary\" package=\"moqui.server\">\n        <member-entity entity-alias=\"VST\" entity-name=\"moqui.server.Visit\"/>\n        <alias entity-alias=\"VST\" name=\"userId\"/>\n        <alias entity-alias=\"VST\" name=\"visitCount\" field=\"visitId\" function=\"count\"/>\n        <alias entity-alias=\"VST\" name=\"fromDateMin\" field=\"fromDate\" function=\"min\"/>\n        <alias entity-alias=\"VST\" name=\"fromDateMax\" field=\"fromDate\" function=\"max\"/>\n        <alias entity-alias=\"VST\" name=\"fromDate\"/><!-- filter only, don't select -->\n    </view-entity>\n    <entity entity-name=\"Visitor\" package=\"moqui.server\" cache=\"never\" sequence-primary-use-uuid=\"true\">\n        <!-- NOTE: using uuid for PK instead of sequence because of annoying false positive in web security scan that thinks the cookie for it is a session cookie -->\n        <field name=\"visitorId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"createdDate\" type=\"date-time\"/>\n    </entity>\n\n    <!-- ========================================================= -->\n    <!-- moqui.server.instance -->\n    <!-- ========================================================= -->\n\n    <entity entity-name=\"InstanceHost\" package=\"moqui.server.instance\" use=\"configuration\">\n        <field name=\"instanceHostId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"hostTypeId\" type=\"id\"/>\n        <field name=\"hostProtocol\" type=\"text-short\"/>\n        <field name=\"hostAddress\" type=\"text-medium\"/>\n        <field name=\"adminPort\" type=\"number-integer\"/>\n        <field name=\"username\" type=\"text-short\"/>\n        <field name=\"password\" type=\"text-medium\" encrypt=\"true\"/>\n        <relationship type=\"one\" related=\"moqui.server.instance.InstanceHostType\" short-alias=\"hostType\"/>\n    </entity>\n    <entity entity-name=\"InstanceHostType\" package=\"moqui.server.instance\" use=\"configuration\">\n        <field name=\"hostTypeId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"initService\" type=\"text-medium\"/>\n        <field name=\"startService\" type=\"text-medium\"/>\n        <field name=\"stopService\" type=\"text-medium\"/>\n        <field name=\"removeService\" type=\"text-medium\"/>\n        <field name=\"checkService\" type=\"text-medium\"/>\n\n        <seed-data>\n            <moqui.server.instance.InstanceHostType hostTypeId=\"docker\" description=\"Docker\"\n                    initService=\"org.moqui.impl.InstanceServices.init#InstanceDocker\"\n                    startService=\"org.moqui.impl.InstanceServices.start#InstanceDocker\"\n                    stopService=\"org.moqui.impl.InstanceServices.stop#InstanceDocker\"\n                    removeService=\"org.moqui.impl.InstanceServices.remove#InstanceDocker\"\n                    checkService=\"org.moqui.impl.InstanceServices.check#InstanceDocker\"/>\n\n            <!-- see: https://docs.docker.com/engine/reference/commandline/dockerd/#bind-docker-to-another-host-port-or-a-unix-socket -->\n            <moqui.server.instance.InstanceHost instanceHostId=\"LocalDocker\" hostTypeId=\"docker\"\n                    hostProtocol=\"http\" hostAddress=\"127.0.0.1\" adminPort=\"2375\"/>\n            <!-- for future reference, connect with TLS; need some way to specify and pass the cert\n            <moqui.server.instance.InstanceHost instanceHostId=\"LocalDockerTLS\" hostTypeId=\"docker\"\n                    hostProtocol=\"https\" hostAddress=\"127.0.0.1\" adminPort=\"2376\"/>\n            -->\n        </seed-data>\n    </entity>\n    <view-entity entity-name=\"InstanceHostDetail\" package=\"moqui.server.instance\">\n        <member-entity entity-alias=\"INH\" entity-name=\"moqui.server.instance.InstanceHost\"/>\n        <member-entity entity-alias=\"IHT\" entity-name=\"moqui.server.instance.InstanceHostType\" join-from-alias=\"INH\">\n            <key-map field-name=\"hostTypeId\"/></member-entity>\n        <alias-all entity-alias=\"INH\"/>\n        <alias name=\"typeDescription\" entity-alias=\"IHT\" field=\"description\"/>\n    </view-entity>\n\n    <entity entity-name=\"InstanceImage\" package=\"moqui.server.instance\" use=\"configuration\">\n        <field name=\"instanceImageId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"imageTypeId\" type=\"id\"/>\n        <field name=\"hostTypeId\" type=\"id\"/>\n        <field name=\"imageName\" type=\"text-medium\"/>\n        <!-- better as part of the name? <field name=\"imageDigest\" type=\"text-medium\"/> -->\n        <field name=\"registryLocation\" type=\"text-medium\"/>\n        <field name=\"username\" type=\"text-short\"/>\n        <field name=\"password\" type=\"text-medium\" encrypt=\"true\"/>\n        <field name=\"emailAddress\" type=\"text-medium\"/>\n        <!-- To pull an image from a remote registryLocation, you may need to generate a token using a command example: AWS-ECR -->\n        <!-- Note: if you are using authTokenCmd to authenticate on registry then must keep the password field empty simply-->\n        <field name=\"authTokenCmd\" type=\"text-medium\"/>\n        <relationship type=\"one\" related=\"moqui.server.instance.InstanceImageType\" short-alias=\"imageType\"/>\n        <relationship type=\"one\" related=\"moqui.server.instance.InstanceHostType\" short-alias=\"hostType\"/>\n    </entity>\n    <entity entity-name=\"InstanceImageType\" package=\"moqui.server.instance\" use=\"configuration\">\n        <field name=\"imageTypeId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"defaultInitCommand\" type=\"text-medium\"/>\n        <field name=\"defaultNetworkMode\" type=\"text-short\"/>\n        <relationship type=\"many\" related=\"moqui.server.instance.InstanceImageTypeEnv\" short-alias=\"envs\">\n            <key-map field-name=\"imageTypeId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.server.instance.InstanceImageTypeLink\" short-alias=\"links\">\n            <key-map field-name=\"imageTypeId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.server.instance.InstanceImageTypeVolume\" short-alias=\"vols\">\n            <key-map field-name=\"imageTypeId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.server.instance.InstanceImageTypeHostConfig\" short-alias=\"hostConfigs\">\n            <key-map field-name=\"imageTypeId\"/></relationship>\n\n        <seed-data>\n            <!-- matches configuration in nginx-mysql-compose.yml -->\n            <moqui.server.instance.InstanceImageType imageTypeId=\"moqui\" description=\"Moqui Default Virtual Host\"\n                    defaultInitCommand=\"conf=conf/MoquiProductionConf.xml\" defaultNetworkMode=\"moqui_default\">\n                <!-- NOTE: defaultNetworkMode set to a network name, using docker compose use the '-p moqui' argument so moqui\n                    is the app/project name and moqui_default is the network name; use plain 'bridge' or other instead of a network\n                    name to use the default docker bridge or other network -->\n                <envs envName=\"VIRTUAL_HOST\" envValue=\"${appInstance.hostName}\"/>\n                <envs envName=\"webapp_http_host\" envValue=\"${appInstance.hostName}\"/>\n                <envs envName=\"webapp_http_port\" envValue=\"80\"/>\n                <envs envName=\"webapp_https_port\" envValue=\"443\"/>\n                <envs envName=\"webapp_https_enabled\" envValue=\"true\"/><!-- NOTE: change this to false for dev/test without https -->\n\n                <envs envName=\"entity_ds_db_conf\" envValue=\"${appInstance.database?.type?.confName?:''}\"/>\n                <envs envName=\"entity_ds_host\" envValue=\"${appInstance.database?.instanceAddress ?: appInstance.database?.hostAddress ?: ''}\"/>\n                <envs envName=\"entity_ds_port\" envValue=\"${appInstance.database?.hostPort?:''}\"/>\n                <envs envName=\"entity_ds_database\" envValue=\"${appInstance.hostName.replace('.', '_')}\"/>\n                <!-- <envs envName=\"entity_ds_schema\" envValue=\"\"/> -->\n                <envs envName=\"entity_ds_user\" envValue=\"${appInstance.hostName.replace('.', '_')}\"/>\n                <envs envName=\"entity_ds_password\" envValue=\"${org.moqui.util.StringUtilities.getRandomString(15)}\"/>\n\n                <links instanceName=\"moqui-database\"/>\n\n                <vols mountPoint=\"/opt/moqui/runtime/log\" volumeName=\"${appInstance.hostName.replace('.', '_')}-runtime_log\"/>\n                <vols mountPoint=\"/opt/moqui/runtime/txlog\" volumeName=\"${appInstance.hostName.replace('.', '_')}-runtime_txlog\"/>\n                <vols mountPoint=\"/opt/moqui/runtime/sessions\" volumeName=\"${appInstance.hostName.replace('.', '_')}-runtime_sessions\"/>\n                <vols mountPoint=\"/opt/moqui/runtime/db\" volumeName=\"${appInstance.hostName.replace('.', '_')}-runtime_db\"/>\n                <vols mountPoint=\"/opt/moqui/runtime/elasticsearch\" volumeName=\"${appInstance.hostName.replace('.', '_')}-runtime_elasticsearch\"/>\n            </moqui.server.instance.InstanceImageType>\n\n            <!-- these have no registryLocation, username, etc as they are intended to be pulled from Docker Hub -->\n            <moqui.server.instance.InstanceImage instanceImageId=\"DockerMoquiFramework\" imageTypeId=\"moqui\" hostTypeId=\"docker\"\n                    imageName=\"moqui/moquiframework:latest\"/>\n            <moqui.server.instance.InstanceImage instanceImageId=\"DockerMoquiDemo\" imageTypeId=\"moqui\" hostTypeId=\"docker\"\n                    imageName=\"moqui/moquidemo:latest\"/>\n            <moqui.server.instance.InstanceImage instanceImageId=\"DockerHiveMind\" imageTypeId=\"moqui\" hostTypeId=\"docker\"\n                    imageName=\"moqui/hivemind:latest\"/>\n            <moqui.server.instance.InstanceImage instanceImageId=\"DockerPopCommerce\" imageTypeId=\"moqui\" hostTypeId=\"docker\"\n                    imageName=\"moqui/popcommerce:latest\"/>\n            <!-- this is mainly for local testing -->\n            <moqui.server.instance.InstanceImage instanceImageId=\"DockerMoquiLocal\" imageTypeId=\"moqui\" hostTypeId=\"docker\"\n                    imageName=\"moqui\"/>\n        </seed-data>\n    </entity>\n    <entity entity-name=\"InstanceImageTypeEnv\" package=\"moqui.server.instance\" use=\"configuration\">\n        <field name=\"imageTypeId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"envName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"envValue\" type=\"text-medium\" encrypt=\"true\"/>\n        <relationship type=\"one\" related=\"moqui.server.instance.InstanceImageType\"/>\n    </entity>\n    <entity entity-name=\"InstanceImageTypeLink\" package=\"moqui.server.instance\" use=\"configuration\">\n        <field name=\"imageTypeId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"instanceName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"aliasName\" type=\"text-medium\"/>\n        <relationship type=\"one\" related=\"moqui.server.instance.InstanceImageType\"/>\n    </entity>\n    <entity entity-name=\"InstanceImageTypeVolume\" package=\"moqui.server.instance\" use=\"configuration\">\n        <field name=\"imageTypeId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"mountPoint\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"volumeName\" type=\"text-medium\"/>\n        <relationship type=\"one\" related=\"moqui.server.instance.InstanceImageType\"/>\n    </entity>\n    <entity entity-name=\"InstanceImageTypeHostConfig\" package=\"moqui.server.instance\" use=\"configuration\">\n        <field name=\"imageTypeId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"hostConfigName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"hostConfigValue\" type=\"text-medium\"/>\n        <field name=\"type\" type=\"text-short\"/>\n        <relationship type=\"one\" related=\"moqui.server.instance.InstanceImageType\"/>\n    </entity>\n    <view-entity entity-name=\"InstanceImageDetail\" package=\"moqui.server.instance\">\n        <member-entity entity-alias=\"IIMG\" entity-name=\"moqui.server.instance.InstanceImage\"/>\n        <member-entity entity-alias=\"IIT\" entity-name=\"moqui.server.instance.InstanceImageType\" join-from-alias=\"IIMG\">\n            <key-map field-name=\"imageTypeId\"/></member-entity>\n        <member-entity entity-alias=\"IHT\" entity-name=\"moqui.server.instance.InstanceHostType\" join-from-alias=\"IIMG\">\n            <key-map field-name=\"hostTypeId\"/></member-entity>\n        <alias-all entity-alias=\"IIMG\"/>\n        <alias name=\"imageTypeDescription\" entity-alias=\"IIT\" field=\"description\"/>\n        <alias name=\"hostTypeDescription\" entity-alias=\"IHT\" field=\"description\"/>\n    </view-entity>\n\n    <entity entity-name=\"DatabaseHost\" package=\"moqui.server.instance\" use=\"configuration\">\n        <field name=\"databaseHostId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"databaseTypeId\" type=\"id\"/>\n        <field name=\"hostAddress\" type=\"text-medium\"/>\n        <field name=\"hostPort\" type=\"number-integer\"/>\n        <field name=\"instanceAddress\" type=\"text-medium\"><description>If instance accesses DB by different address specify here</description></field>\n        <field name=\"adminUser\" type=\"text-short\"/>\n        <field name=\"adminPassword\" type=\"text-medium\" encrypt=\"true\"/>\n        <relationship type=\"one\" related=\"moqui.server.instance.DatabaseType\" short-alias=\"type\"/>\n    </entity>\n    <entity entity-name=\"DatabaseType\" package=\"moqui.server.instance\" use=\"configuration\">\n        <field name=\"databaseTypeId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"confName\" type=\"text-short\"/>\n        <field name=\"createService\" type=\"text-medium\"/>\n        <field name=\"checkService\" type=\"text-medium\"/>\n\n        <seed-data>\n            <moqui.server.instance.DatabaseType databaseTypeId=\"mysql\" description=\"MySQL\" confName=\"mysql\"\n                    createService=\"org.moqui.impl.InstanceServices.create#DatabaseMySQL\"\n                    checkService=\"org.moqui.impl.InstanceServices.check#DatabaseMySQL\"/>\n            <moqui.server.instance.DatabaseType databaseTypeId=\"postgres\" description=\"Postgres\" confName=\"postgres\"\n                    createService=\"org.moqui.impl.InstanceServices.create#DatabasePostgres\" checkService=\"org.moqui.impl.InstanceServices.check#DatabasePostgres\"/>\n            <!-- matches configuration in nginx-mysql-compose.yml -->\n            <moqui.server.instance.DatabaseHost databaseHostId=\"LocalMySQL\" databaseTypeId=\"mysql\"\n                    hostAddress=\"127.0.0.1\" hostPort=\"3306\" instanceAddress=\"moqui-database\"\n                    adminUser=\"root\" adminPassword=\"moquiroot\"/>\n        </seed-data>\n    </entity>\n    <view-entity entity-name=\"DatabaseHostDetail\" package=\"moqui.server.instance\">\n        <member-entity entity-alias=\"DBH\" entity-name=\"moqui.server.instance.DatabaseHost\"/>\n        <member-entity entity-alias=\"DBTP\" entity-name=\"moqui.server.instance.DatabaseType\" join-from-alias=\"DBH\">\n            <key-map field-name=\"databaseTypeId\"/></member-entity>\n        <alias-all entity-alias=\"DBH\"/>\n        <alias name=\"typeDescription\" entity-alias=\"DBTP\" field=\"description\"/>\n    </view-entity>\n\n    <entity entity-name=\"AppInstance\" package=\"moqui.server.instance\" use=\"configuration\">\n        <field name=\"appInstanceId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"instanceImageId\" type=\"id\"/>\n        <field name=\"instanceHostId\" type=\"id\"/>\n        <field name=\"databaseHostId\" type=\"id\"/>\n        <field name=\"hostName\" type=\"text-medium\"/>\n        <field name=\"instanceName\" type=\"text-medium\"/>\n        <field name=\"instanceUuid\" type=\"text-medium\"/>\n        <field name=\"initCommand\" type=\"text-medium\"/>\n        <field name=\"jsonConfig\" type=\"text-long\"/>\n        <field name=\"networkMode\" type=\"text-short\"/>\n\n        <relationship type=\"one\" related=\"moqui.server.instance.InstanceImage\" short-alias=\"image\"/>\n        <relationship type=\"one\" related=\"moqui.server.instance.InstanceHost\" short-alias=\"instanceHost\"/>\n        <relationship type=\"one\" related=\"moqui.server.instance.DatabaseHost\" short-alias=\"database\"/>\n        <relationship type=\"many\" related=\"moqui.server.instance.AppInstanceEnv\" short-alias=\"envs\">\n            <key-map field-name=\"appInstanceId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.server.instance.AppInstanceLink\" short-alias=\"links\">\n            <key-map field-name=\"appInstanceId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.server.instance.AppInstanceVolume\" short-alias=\"vols\">\n            <key-map field-name=\"appInstanceId\"/></relationship>\n        <relationship type=\"many\" related=\"moqui.server.instance.AppInstanceHostConfig\" short-alias=\"hostConfigs\">\n            <key-map field-name=\"appInstanceId\"/></relationship>\n    </entity>\n    <entity entity-name=\"AppInstanceEnv\" package=\"moqui.server.instance\" use=\"configuration\">\n        <field name=\"appInstanceId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"envName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"envValue\" type=\"text-medium\" encrypt=\"true\"/>\n        <relationship type=\"one\" related=\"moqui.server.instance.AppInstance\"/>\n    </entity>\n    <entity entity-name=\"AppInstanceLink\" package=\"moqui.server.instance\" use=\"configuration\">\n        <field name=\"appInstanceId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"instanceName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"aliasName\" type=\"text-medium\"/>\n        <relationship type=\"one\" related=\"moqui.server.instance.AppInstance\"/>\n    </entity>\n    <entity entity-name=\"AppInstanceVolume\" package=\"moqui.server.instance\" use=\"configuration\">\n        <field name=\"appInstanceId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"mountPoint\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"volumeName\" type=\"text-medium\"/>\n        <relationship type=\"one\" related=\"moqui.server.instance.AppInstance\"/>\n    </entity>\n    <entity entity-name=\"AppInstanceHostConfig\" package=\"moqui.server.instance\" use=\"configuration\">\n        <field name=\"appInstanceId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"hostConfigName\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"hostConfigValue\" type=\"text-medium\"/>\n        <field name=\"type\" type=\"text-short\"/>\n        <relationship type=\"one\" related=\"moqui.server.instance.AppInstance\"/>\n    </entity>\n</entities>\n"
  },
  {
    "path": "framework/entity/ServiceEntities.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<entities xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/entity-definition-3.xsd\">\n\n    <entity entity-name=\"ServiceRegister\" package=\"moqui.service\" use=\"configuration\" cache=\"true\">\n        <field name=\"serviceRegisterId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"serviceTypeEnumId\" type=\"id\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"serviceName\" type=\"text-medium\"/>\n        <field name=\"configParameters\" type=\"text-medium\"/>\n        <relationship type=\"one\" title=\"ServiceRegisterType\" related=\"moqui.basic.Enumeration\" short-alias=\"serviceTypeEnum\">\n            <key-map field-name=\"serviceTypeEnumId\"/></relationship>\n        <seed-data><moqui.basic.EnumerationType description=\"Service Register Type\" enumTypeId=\"ServiceRegisterType\"/></seed-data>\n    </entity>\n\n    <!-- ========================================================= -->\n    <!-- moqui.service.job -->\n    <!-- ========================================================= -->\n\n    <entity entity-name=\"ServiceJob\" package=\"moqui.service.job\" use=\"configuration\" cache=\"true\">\n        <description>For ad-hoc (explicitly run) or scheduled service jobs. If cronExpression is null the job will only\n            be run ad-hoc, when explicitly called using ServiceCallJob. If a topic is specified results will be sent to\n            the topic (can be configured using a NotificationTopic record) as a NotificationMessage to the user that\n            called the job explicitly (if applicable) and to users associated with ServiceJobUser records.</description>\n        <field name=\"jobName\" type=\"text-short\" is-pk=\"true\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"serviceName\" type=\"text-medium\"/>\n        <field name=\"transactionTimeout\" type=\"number-integer\"/>\n        <field name=\"topic\" type=\"text-medium\"><description>On completion send a notification to this topic</description></field>\n        <field name=\"localOnly\" type=\"text-indicator\"><description>If Y this will be run local only. By default runs on any\n            server in a cluster listening for async distributed services (if an async distributed executor is configured).</description></field>\n\n        <field name=\"cronExpression\" type=\"text-short\"><description>\n            An extended cron expression like Unix crontab but with extended syntax options (L, W, etc) similar to Quartz Scheduler. See:\n\n            http://cron-parser.com\n            http://www.quartz-scheduler.org/documentation/quartz-2.2.x/tutorials/tutorial-lesson-06.html\n        </description></field>\n        <field name=\"fromDate\" type=\"date-time\"><description>Only run scheduled after this date/time. Ignored for ad-hoc/explicit runs.</description></field>\n        <field name=\"thruDate\" type=\"date-time\"><description>Only run scheduled before this date/time. Ignored for ad-hoc/explicit runs.</description></field>\n        <field name=\"repeatCount\" type=\"number-integer\"><description>If specified only run this many times. Must specify\n            a cronExpression for the job to repeat. When this count is reached thruDate will be set to now and paused set to Y.\n            Ignored for ad-hoc/explicit runs.</description></field>\n        <field name=\"paused\" type=\"text-indicator\" enable-audit-log=\"update\"><description>If Y this job is inactive and won't be\n            run on a schedule even if cronExpression is not null. Ignored for ad-hoc/explicit runs.</description></field>\n        <field name=\"expireLockTime\" type=\"number-integer\"><description>Ignore lock and run anyway after this many\n            minutes. This should generally be much greater than the longest time the service is expected to run. This is\n            the mechanism for recovering jobs after a run failed in a way that did not clean up the ServiceJobRunLock\n            record. Defaults to 24 hours (1440 minutes) to make sure jobs get recovered.</description></field>\n        <field name=\"minRetryTime\" type=\"number-integer\">\n            <description>Minimum time between retries after an error (based on most recent ServiceJobRun record), in minutes</description></field>\n        <field name=\"priority\" type=\"number-integer\">\n            <description>Job execution priority, lower numbers run first among jobs that need to be run regardless of scheduled start time</description></field>\n\n        <relationship type=\"one-nofk\" related=\"moqui.security.user.NotificationTopic\"/>\n        <relationship type=\"many\" related=\"moqui.service.job.ServiceJobParameter\" short-alias=\"parameters\"/>\n        <relationship type=\"many\" related=\"moqui.service.job.ServiceJobUser\" short-alias=\"users\"/>\n    </entity>\n    <entity entity-name=\"ServiceJobParameter\" package=\"moqui.service.job\" use=\"configuration\" cache=\"true\">\n        <description>Parameters automatically added when the job service is called. Always stored as a String and will\n            be converted based on the corresponding service in-parameter.parameter.@type attribute (just as with any\n            service call).</description>\n        <field name=\"jobName\" type=\"text-short\" is-pk=\"true\"/>\n        <field name=\"parameterName\" type=\"text-short\" is-pk=\"true\"/>\n        <field name=\"parameterValue\" type=\"text-medium\"/>\n        <relationship type=\"one\" related=\"moqui.service.job.ServiceJob\"/>\n    </entity>\n    <entity entity-name=\"ServiceJobUser\" package=\"moqui.service.job\" use=\"configuration\" cache=\"true\">\n        <field name=\"jobName\" type=\"text-short\" is-pk=\"true\"/>\n        <field name=\"userId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"receiveNotifications\" type=\"text-indicator\"/>\n        <relationship type=\"one\" related=\"moqui.service.job.ServiceJob\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\"/>\n    </entity>\n    <view-entity entity-name=\"ServiceJobUserDetail\" package=\"moqui.service.job\">\n        <member-entity entity-alias=\"SJU\" entity-name=\"moqui.service.job.ServiceJobUser\"/>\n        <member-entity entity-alias=\"UAC\" entity-name=\"moqui.security.UserAccount\" join-from-alias=\"SJU\">\n            <key-map field-name=\"userId\"/></member-entity>\n        <alias-all entity-alias=\"SJU\"/>\n        <alias name=\"username\" entity-alias=\"UAC\"/>\n        <alias name=\"userFullName\" entity-alias=\"UAC\"/>\n        <alias name=\"emailAddress\" entity-alias=\"UAC\"/>\n    </view-entity>\n    <entity entity-name=\"ServiceJobRun\" package=\"moqui.service.job\" use=\"transactional\" cache=\"never\">\n        <field name=\"jobRunId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"jobName\" type=\"text-short\"/>\n        <field name=\"userId\" type=\"id\"><description>The user that initiated the job run</description></field>\n        <field name=\"parameters\" type=\"text-long\"/>\n        <field name=\"results\" type=\"text-very-long\"/>\n        <field name=\"messages\" type=\"text-long\"/>\n        <field name=\"hasError\" type=\"text-indicator\"/>\n        <field name=\"errors\" type=\"text-long\"/>\n        <field name=\"hostAddress\" type=\"text-short\"/>\n        <field name=\"hostName\" type=\"text-medium\"/>\n        <field name=\"runThread\" type=\"text-medium\"/>\n        <field name=\"startTime\" type=\"date-time\"/>\n        <field name=\"endTime\" type=\"date-time\"/>\n        <relationship type=\"one\" related=\"moqui.service.job.ServiceJob\"/>\n        <relationship type=\"one-nofk\" related=\"moqui.security.UserAccount\"/>\n        <index name=\"SVC_JOBRUN_NAME\" unique=\"false\"><index-field name=\"jobName\"/></index>\n    </entity>\n    <entity entity-name=\"ServiceJobRunLock\" package=\"moqui.service.job\" use=\"transactional\" cache=\"never\">\n        <description>Runtime data for a scheduled ServiceJob (with a cronExpression), managed automatically by the service job runner.</description>\n        <field name=\"jobName\" type=\"text-short\" is-pk=\"true\"/>\n        <field name=\"jobRunId\" type=\"id\"><description>If not null this is the currently running job instance.</description></field>\n        <field name=\"lastRunTime\" type=\"date-time\"/>\n        <relationship type=\"one\" related=\"moqui.service.job.ServiceJob\"/>\n        <relationship type=\"one\" related=\"moqui.service.job.ServiceJobRun\"/>\n    </entity>\n\n    <!-- ========================================================= -->\n    <!-- moqui.service.semaphore -->\n    <!-- ========================================================= -->\n\n    <entity entity-name=\"ServiceParameterSemaphore\" package=\"moqui.service.semaphore\" use=\"transactional\" cache=\"never\">\n        <field name=\"serviceName\" type=\"text-medium\" is-pk=\"true\">\n            <description>May be semaphore-name if that attribute is used, otherwise is service name</description></field>\n        <field name=\"parameterValue\" type=\"text-medium\" is-pk=\"true\"/>\n        <field name=\"lockThread\" type=\"text-medium\"/>\n        <field name=\"lockTime\" type=\"date-time\"/>\n    </entity>\n\n    <!-- ========================================================= -->\n    <!-- moqui.service.message -->\n    <!-- ========================================================= -->\n\n    <entity entity-name=\"SystemMessage\" package=\"moqui.service.message\" short-alias=\"systemMessages\"\n            use=\"transactional\" cache=\"never\">\n        <field name=\"systemMessageId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"systemMessageTypeId\" type=\"id\"/>\n        <field name=\"systemMessageRemoteId\" type=\"id\"><description>Reference to the SystemMessageRemote record for the remote system\n            this message came from for incoming messages or should be sent to for outgoing messages.</description></field>\n        <field name=\"statusId\" type=\"id\" enable-audit-log=\"true\"/>\n        <field name=\"isOutgoing\" type=\"text-indicator\"/>\n        <field name=\"initDate\" type=\"date-time\"><description>For incoming the received date, for outgoing the produced date</description></field>\n        <field name=\"processedDate\" type=\"date-time\"><description>For incoming the consumed date, for outgoing the sent date</description></field>\n        <field name=\"lastAttemptDate\" type=\"date-time\"/>\n        <field name=\"failCount\" type=\"number-integer\"/>\n        <field name=\"parentMessageId\" type=\"id\"><description>If a received message is split this is the original message</description></field>\n        <field name=\"ackMessageId\" type=\"id\"><description>The message received or sent to acknowledge this message</description></field>\n        <field name=\"remoteMessageId\" type=\"text-medium\"><description>For messages to/from another Moqui system, the\n            systemMessageId on the remote system; may also be used for other system level message IDs (as opposed to\n            messageId which is for the ID in the envelope of the message)</description></field>\n        <field name=\"messageText\" type=\"text-very-long\"/>\n\n        <!-- these fields are from the message envelope, populated by receive or consume service (after initial parse) -->\n        <field name=\"senderId\" type=\"text-short\"><description>ID of the sender (for OAGIS may be broken down into\n            logicalId, component, task, referenceId; for EDI X12 this is ISA06)</description></field>\n        <field name=\"receiverId\" type=\"text-short\"><description>ID of the receiver (for OAGIS may also be broken down;\n            for EDI X12 this is ISA08)</description></field>\n        <field name=\"messageId\" type=\"text-short\"><description>ID of the message; this may be globally unique (like\n            the OAGIS BODID, a GUID) or only unique relative to the senderId and the receiverId (like EDI X12 ISA13 in\n            the context of ISA06, ISA08), and may only be unique within a certain time period (ID may be reused since in\n            EDI X12 limited to 9 digits)</description></field>\n        <field name=\"messageDate\" type=\"date-time\"><description>Date/time from message (for EDI X12 this is GS04 (date)\n            and GS05 (time))</description></field>\n        <field name=\"docType\" type=\"text-short\"><description>For OAGIS the BSR Noun; For X12 GS01 (functional ID code)</description></field>\n        <field name=\"docSubType\" type=\"text-short\"><description>For OAGIS the BSR Verb; For X12 ST01 (tx set ID code)</description></field>\n        <field name=\"docControl\" type=\"text-short\"><description>Control number of the message when applicable (such as\n            GS06 in EDI X12 messages)</description></field>\n        <field name=\"docSubControl\" type=\"text-short\"><description>Sub-Control number of the message when applicable (such as\n            ST02 in EDI X12 messages)</description></field>\n        <field name=\"docVersion\" type=\"text-short\"><description>The document version (for OAGIS BSR Revision, for X12\n            GS08 (version/revision))</description></field>\n\n        <field name=\"triggerVisitId\" type=\"id\"><description>Active visit when SystemMessage triggered (mainly produced) to track\n            the user who did so; independent of the message transport which could have separate remote system and other Visit-like data.</description></field>\n\n        <!-- the follow may be useful for other types of messages, but come specifically from the OAGIS specification;\n            leave out for now, senderId and messageId with combined fields may be adequate and are more generic\n        <field name=\"logicalId\" type=\"text-medium\"/>\n        <field name=\"component\" type=\"text-medium\"/>\n        <field name=\"task\" type=\"text-medium\"/>\n        <field name=\"referenceId\" type=\"text-medium\"/>\n        <field name=\"authId\" type=\"text-medium\"/>\n        -->\n\n        <relationship type=\"one\" related=\"moqui.service.message.SystemMessageType\" short-alias=\"type\"/>\n        <relationship type=\"one\" related=\"moqui.service.message.SystemMessageRemote\" short-alias=\"remote\"/>\n        <relationship type=\"one\" title=\"SystemMessage\" related=\"moqui.basic.StatusItem\" short-alias=\"status\"/>\n        <relationship type=\"one\" title=\"Parent\" related=\"moqui.service.message.SystemMessage\" short-alias=\"parent\">\n            <key-map field-name=\"parentMessageId\" related=\"systemMessageId\"/></relationship>\n        <relationship type=\"one\" title=\"Ack\" related=\"moqui.service.message.SystemMessage\" short-alias=\"ackMessage\">\n            <key-map field-name=\"ackMessageId\" related=\"systemMessageId\"/></relationship>\n        <relationship type=\"one\" title=\"Trigger\" related=\"moqui.server.Visit\" short-alias=\"triggerVisit\">\n            <key-map field-name=\"triggerVisitId\"/></relationship>\n\n        <relationship type=\"many\" related=\"moqui.service.message.SystemMessageError\" short-alias=\"errors\">\n            <key-map field-name=\"systemMessageId\"/></relationship>\n\n        <index name=\"SYS_MSG_MSGID\"><index-field name=\"messageId\"/></index>\n        <index name=\"SYS_MSG_RMSGID\"><index-field name=\"remoteMessageId\"/></index>\n\n        <seed-data>\n            <moqui.basic.StatusType description=\"System Message\" statusTypeId=\"SystemMessage\"/>\n            <!-- Is this needed? <moqui.basic.StatusItem description=\"Triggered\" sequenceNum=\"1\" statusId=\"SmsgTriggered\" statusTypeId=\"SystemMessage\"/> -->\n\n            <moqui.basic.StatusItem description=\"Produced\" sequenceNum=\"10\" statusId=\"SmsgProduced\" statusTypeId=\"SystemMessage\"/>\n            <moqui.basic.StatusItem description=\"Sending\" sequenceNum=\"11\" statusId=\"SmsgSending\" statusTypeId=\"SystemMessage\"/>\n            <moqui.basic.StatusItem description=\"Sent\" sequenceNum=\"12\" statusId=\"SmsgSent\" statusTypeId=\"SystemMessage\"/>\n\n            <moqui.basic.StatusItem description=\"Received\" sequenceNum=\"20\" statusId=\"SmsgReceived\" statusTypeId=\"SystemMessage\"/>\n            <moqui.basic.StatusItem description=\"Consuming\" sequenceNum=\"21\" statusId=\"SmsgConsuming\" statusTypeId=\"SystemMessage\"/>\n            <moqui.basic.StatusItem description=\"Consumed\" sequenceNum=\"22\" statusId=\"SmsgConsumed\" statusTypeId=\"SystemMessage\"/>\n\n            <!-- Confirmation sent/received -->\n            <moqui.basic.StatusItem description=\"Confirmed\" sequenceNum=\"90\" statusId=\"SmsgConfirmed\" statusTypeId=\"SystemMessage\"/>\n            <!-- Rejected by receiver (functional reject, ie syntax/etc errors) -->\n            <moqui.basic.StatusItem description=\"Rejected\" sequenceNum=\"97\" statusId=\"SmsgRejected\" statusTypeId=\"SystemMessage\"/>\n            <!-- Cancelled by sender -->\n            <moqui.basic.StatusItem description=\"Cancelled\" sequenceNum=\"98\" statusId=\"SmsgCancelled\" statusTypeId=\"SystemMessage\"/>\n            <!-- Error, generally after retry limit -->\n            <moqui.basic.StatusItem description=\"Error\" sequenceNum=\"99\" statusId=\"SmsgError\" statusTypeId=\"SystemMessage\"/>\n\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgProduced\" toStatusId=\"SmsgSending\" transitionName=\"Sending\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgSending\" toStatusId=\"SmsgSent\" transitionName=\"Send\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgSending\" toStatusId=\"SmsgProduced\" transitionName=\"Back to Produced\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgProduced\" toStatusId=\"SmsgSent\" transitionName=\"Send\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgSent\" toStatusId=\"SmsgConfirmed\" transitionName=\"Confirm\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgSent\" toStatusId=\"SmsgRejected\" transitionName=\"Reject\"/>\n\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgReceived\" toStatusId=\"SmsgConsuming\" transitionName=\"Consuming\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgConsuming\" toStatusId=\"SmsgConsumed\" transitionName=\"Consume\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgConsuming\" toStatusId=\"SmsgReceived\" transitionName=\"Back to Received\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgReceived\" toStatusId=\"SmsgConsumed\" transitionName=\"Consume\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgConsumed\" toStatusId=\"SmsgConfirmed\" transitionName=\"Confirm\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgConsumed\" toStatusId=\"SmsgRejected\" transitionName=\"Reject\"/>\n\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgConsuming\" toStatusId=\"SmsgError\" transitionName=\"Error\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgProduced\" toStatusId=\"SmsgError\" transitionName=\"Error\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgError\" toStatusId=\"SmsgProduced\" transitionName=\"Back to Produced\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgError\" toStatusId=\"SmsgSending\" transitionName=\"Sending\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgError\" toStatusId=\"SmsgSent\" transitionName=\"Send\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgSent\" toStatusId=\"SmsgError\" transitionName=\"Error\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgReceived\" toStatusId=\"SmsgError\" transitionName=\"Error\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgError\" toStatusId=\"SmsgReceived\" transitionName=\"Back to Received\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgError\" toStatusId=\"SmsgConsuming\" transitionName=\"Consuming\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgError\" toStatusId=\"SmsgConsumed\" transitionName=\"Consume\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgConsumed\" toStatusId=\"SmsgError\" transitionName=\"Error\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgConfirmed\" toStatusId=\"SmsgError\" transitionName=\"Error\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgError\" toStatusId=\"SmsgError\" transitionName=\"Error\"/>\n\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgProduced\" toStatusId=\"SmsgCancelled\" transitionName=\"Cancel\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgSending\" toStatusId=\"SmsgCancelled\" transitionName=\"Cancel\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgSent\" toStatusId=\"SmsgCancelled\" transitionName=\"Cancel\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgReceived\" toStatusId=\"SmsgCancelled\" transitionName=\"Cancel\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgConsuming\" toStatusId=\"SmsgCancelled\" transitionName=\"Cancel\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgConsumed\" toStatusId=\"SmsgCancelled\" transitionName=\"Cancel\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgError\" toStatusId=\"SmsgCancelled\" transitionName=\"Cancel\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgRejected\" toStatusId=\"SmsgCancelled\" transitionName=\"Cancel\"/>\n\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgCancelled\" toStatusId=\"SmsgProduced\" transitionName=\"Back to Produced\"/>\n            <moqui.basic.StatusFlowTransition statusFlowId=\"Default\" statusId=\"SmsgCancelled\" toStatusId=\"SmsgReceived\" transitionName=\"Back to Received\"/>\n        </seed-data>\n        <master>\n            <detail relationship=\"type\"/><detail relationship=\"status\"/><detail relationship=\"parent\"/>\n            <detail relationship=\"remote\"/><detail relationship=\"errors\"/>\n        </master>\n    </entity>\n    <entity entity-name=\"SystemMessageType\" package=\"moqui.service.message\" use=\"configuration\" cache=\"true\">\n        <field name=\"systemMessageTypeId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"produceServiceName\" type=\"text-medium\"><description>Not used in automated processing, but useful\n            for documentation and tools in some cases.</description></field>\n        <field name=\"consumeServiceName\" type=\"text-medium\"><description>The service to call after a message is\n            received to consume it. Should implement the org.moqui.impl.SystemMessageServices.consume#SystemMessage\n            interface (just a systemMessageId in-parameter). Used by the consume#ReceivedSystemMessage service.</description></field>\n        <field name=\"produceAckServiceName\" type=\"text-medium\"><description>The service to call to produce an async\n            acknowledgement of a message. Should implement the org.moqui.impl.SystemMessageServices.produce#AckSystemMessage.\n            Once the message is produced should call the org.moqui.impl.SystemMessageServices.queue#SystemMessage service.</description></field>\n        <field name=\"produceAckOnConsumed\" type=\"text-indicator\"/>\n        <field name=\"sendServiceName\" type=\"text-medium\"><description>The service to call to send queued messages.\n            Should implement the org.moqui.impl.SystemMessageServices.send#SystemMessage interface (just a\n            systemMessageId in-parameter and remoteMessageId out-parameter). Used by the send#ProducedSystemMessage service,\n            and for that service must be specified or will result in an error.</description></field>\n        <field name=\"receiveServiceName\" type=\"text-medium\"><description>The service to call to save a received message.\n            Should implement the org.moqui.impl.SystemMessageServices.receive#SystemMessage interface.\n            If not specified receive#IncomingSystemMessage just saves the message directly.\n            When applicable, used by the send service as the service on the remote server to call to receive the message.</description></field>\n        <field name=\"contentType\" type=\"text-short\"/>\n        <field name=\"receivePath\" type=\"text-medium\"><description>Where to look for files on a remote server, syntax is implementation specific</description></field>\n        <field name=\"receiveFilePattern\" type=\"text-medium\"><description>Regular expression to match filenames in receivePath (optional)</description></field>\n        <field name=\"receiveResponseEnumId\" type=\"id\"/>\n        <field name=\"receiveMovePath\" type=\"text-medium\"><description>After successful receive move file to this path if receiveResponseEnumId = MsgRrMove</description></field>\n        <field name=\"sendPath\" type=\"text-medium\"><description>Where to put files on a remote server, syntax is implementation\n            specific and may include both path and a filename pattern</description></field>\n\n        <relationship type=\"one\" title=\"MessageReceiveResponse\" related=\"moqui.basic.Enumeration\" short-alias=\"receiveResponseEnum\">\n            <key-map field-name=\"receiveResponseEnumId\"/></relationship>\n\n        <seed-data>\n            <moqui.basic.EnumerationType description=\"Message Receive Response\" enumTypeId=\"MessageReceiveResponse\"/>\n            <moqui.basic.Enumeration enumId=\"MsgRrNone\" description=\"None\" enumTypeId=\"MessageReceiveResponse\"/>\n            <moqui.basic.Enumeration enumId=\"MsgRrDelete\" description=\"Delete\" enumTypeId=\"MessageReceiveResponse\"/>\n            <moqui.basic.Enumeration enumId=\"MsgRrMove\" description=\"Move\" enumTypeId=\"MessageReceiveResponse\"/>\n        </seed-data>\n    </entity>\n    <entity entity-name=\"SystemMessageRemote\" package=\"moqui.service.message\" use=\"configuration\" cache=\"true\">\n        <field name=\"systemMessageRemoteId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"description\" type=\"text-medium\"/>\n        <field name=\"sendUrl\" type=\"text-medium\" enable-audit-log=\"update\"/>\n        <field name=\"receiveUrl\" type=\"text-medium\" enable-audit-log=\"update\"/>\n        <field name=\"remoteCharset\" type=\"text-short\"/>\n        <field name=\"remoteAttributes\" type=\"text-indicator\"><description>May be useful for other transports, for SFTP servers\n            that do not support setting file attributes after put/upload set to N</description></field>\n        <field name=\"sendServiceName\" type=\"text-medium\"><description>Override for SystemMessageType.sendServiceName</description></field>\n        <field name=\"username\" type=\"text-medium\" enable-audit-log=\"update\"><description>\n            Username for basic auth when sending to the remote system.\n            This user needs permission to run the remote service or whatever on the remote system receives the message.\n\n            Note: For a Moqui remote server the user needs authz for the org.moqui.impl.SystemMessageServices.receive#IncomingSystemMessage\n            service, ie the user should be in a group that has authz for the SystemMessageServices ArtifactGroup such as the\n            SYSMSG_RECEIVE user group (see SecurityTypeData.xml).\n        </description></field>\n        <field name=\"password\" type=\"text-medium\" encrypt=\"true\" enable-audit-log=\"update\">\n            <description>Username for basic auth when sending to the remote system.</description></field>\n        <field name=\"publicKey\" type=\"text-long\" enable-audit-log=\"update\">\n            <description>Public Key for key based authentication, generally RSA PEM format</description></field>\n        <field name=\"privateKey\" type=\"text-long\" encrypt=\"true\" enable-audit-log=\"update\">\n            <description>Private Key for key based authentication, generally RSA PEM PKCS #8 format like OpenSSH</description></field>\n        <field name=\"remotePublicKey\" type=\"text-long\" enable-audit-log=\"update\">\n            <description>Remote System's Public Key for decryption, signature validation, etc; generally RSA PEM or X.509 Certificate format</description></field>\n        <!-- potential future use, restrict receive message: <field name=\"authorizedIpAddresses\" type=\"text-medium\"/> -->\n        <field name=\"sharedSecret\" type=\"text-medium\" encrypt=\"true\" enable-audit-log=\"update\">\n            <description>Shared secret for auth on receive and/or sign on send.</description></field>\n        <field name=\"sendSharedSecret\" type=\"text-medium\" encrypt=\"true\" enable-audit-log=\"update\">\n            <description>Shared secret for auth on send if different from secret used to authorize on receive.</description></field>\n        <field name=\"authHeaderName\" type=\"text-medium\"/>\n        <field name=\"messageAuthEnumId\" type=\"id\"/>\n        <field name=\"sendAuthEnumId\" type=\"id\"><description>If send and receive auth mechanisms are different specify send auth method here</description></field>\n\n        <field name=\"systemMessageTypeId\" type=\"id\"><description>Optional. May be used when this remote is for one\n            type of message.</description></field>\n        <!-- for incoming message internal is the receiver, remote the sender; for outgoing messages internal is the sender, remote the receiver -->\n        <field name=\"internalId\" type=\"text-short\" enable-audit-log=\"update\">\n            <description>Sender (outgoing) or receiver (incoming) ID (EDI: in ISA06/08; OAGIS in ApplicationArea.Sender/Receiver.ID)</description></field>\n        <field name=\"internalIdType\" type=\"text-short\"/>\n        <field name=\"internalAppCode\" type=\"text-medium\"><description>Application code (EDI: in GS02/03; OAGIS: in\n            ApplicationArea.Sender/Receiver elements, split among sub-elements)</description></field>\n        <field name=\"remoteId\" type=\"text-short\" enable-audit-log=\"update\">\n            <description>Sender (incoming) or receiver (outgoing) ID (EDI: in ISA06/08; OAGIS in ApplicationArea.Sender/Receiver.ID)</description></field>\n        <field name=\"remoteIdType\" type=\"text-short\"/>\n        <field name=\"remoteAppCode\" type=\"text-medium\"><description>Application code (EDI: in GS02/03; OAGIS: in\n            ApplicationArea.Sender/Receiver elements, split among sub-elements)</description></field>\n\n        <field name=\"ackRequested\" type=\"text-short\"><description>Request acknowledgement? Possible values dependent on\n            message standard.</description></field>\n        <field name=\"usageCode\" type=\"text-short\"><description>Used for production versus test/etc. Possible values\n            dependent on message standard.</description></field>\n\n        <!-- for formatting EDI and other character delimited files -->\n        <field name=\"segmentTerminator\" type=\"text-indicator\"/>\n        <field name=\"elementSeparator\" type=\"text-indicator\"/>\n        <field name=\"componentDelimiter\" type=\"text-indicator\"/>\n        <field name=\"escapeCharacter\" type=\"text-indicator\"/>\n\n        <field name=\"preAuthMessageRemoteId\" type=\"id\">\n            <description>Remote system related to this remote system but for pre-auth purposes, like a separate single sign on server</description></field>\n\n        <relationship type=\"one\" title=\"SystemMessageAuthType\" related=\"moqui.basic.Enumeration\" short-alias=\"messageAuthEnum\">\n            <key-map field-name=\"messageAuthEnumId\"/></relationship>\n        <relationship type=\"one\" title=\"SendMessageAuthType\" related=\"moqui.basic.Enumeration\" short-alias=\"sendAuthEnum\">\n            <key-map field-name=\"sendAuthEnumId\"/></relationship>\n\n        <relationship type=\"one\" related=\"moqui.service.message.SystemMessageType\" short-alias=\"systemMessageType\"/>\n        <relationship type=\"one\" title=\"PreAuth\" related=\"moqui.service.message.SystemMessageRemote\" short-alias=\"preAuthMessageRemote\">\n            <key-map field-name=\"preAuthMessageRemoteId\"/></relationship>\n\n        <seed-data>\n            <moqui.basic.EnumerationType description=\"System Message Auth Type\" enumTypeId=\"SystemMessageAuthType\"/>\n            <moqui.basic.Enumeration enumId=\"SmatNone\" description=\"No Auth\" enumTypeId=\"SystemMessageAuthType\"/>\n            <moqui.basic.Enumeration enumId=\"SmatLogin\" description=\"User Login (basic, api_key, etc)\" enumTypeId=\"SystemMessageAuthType\"/>\n            <moqui.basic.Enumeration enumId=\"SmatHmacSha256\" description=\"HMAC SHA-256\" enumTypeId=\"SystemMessageAuthType\"/>\n            <moqui.basic.Enumeration enumId=\"SmatHmacSha256Timestamp\" description=\"HMAC SHA-256 with Timestamp\" enumTypeId=\"SystemMessageAuthType\"/>\n        </seed-data>\n    </entity>\n    <entity entity-name=\"SystemMessageEnumMap\" package=\"moqui.service.message\" use=\"configuration\">\n        <description>For runtime configurable enum mappings for a particular remote system. For bi-directional\n            integrations enumerated value mappings should be one to one or round trip results will be inconsistent.\n            The PK structure forces one mapped value for each enumId.</description>\n        <field name=\"systemMessageRemoteId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"enumId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"mappedValue\" type=\"text-short\"/>\n        <relationship type=\"one\" related=\"moqui.service.message.SystemMessageRemote\" short-alias=\"remote\"/>\n        <relationship type=\"one\" related=\"moqui.basic.Enumeration\" short-alias=\"remote\"/>\n    </entity>\n    <entity entity-name=\"SystemMessageError\" package=\"moqui.service.message\" use=\"transactional\" cache=\"never\">\n        <field name=\"systemMessageId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"errorDate\" type=\"date-time\" is-pk=\"true\"/>\n        <field name=\"attemptedStatusId\" type=\"id\"/>\n        <!-- maybe for future use: <field name=\"reasonCode\" type=\"text-short\"/> -->\n        <field name=\"errorText\" type=\"text-very-long\"/>\n        <relationship type=\"one\" related=\"moqui.service.message.SystemMessage\"/>\n    </entity>\n    <!--\n        one possible approach for web/etc hooks: trigger send to potentially multiple SystemMessageRemote based on change to status for a certain message type\n        need more flexible configuration for web hooks for general system events, not just on generate SystemMessage meant to be sent to a different SystemMessageRemote?\n    <entity entity-name=\"SystemMessageHook\" package=\"moqui.service.message\">\n        <field name=\"systemMessageHookId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"systemMessageTypeId\" type=\"id\"/>\n        <field name=\"statusId\" type=\"id\"/>\n        <field name=\"isOutgoing\" type=\"text-indicator\"/>\n        <field name=\"systemMessageRemoteId\" type=\"id\"/>\n        <relationship type=\"one\" related=\"moqui.service.message.SystemMessageType\" short-alias=\"type\"/>\n        <relationship type=\"one\" title=\"SystemMessage\" related=\"moqui.basic.StatusItem\" short-alias=\"status\"/>\n        <relationship type=\"one\" related=\"moqui.service.message.SystemMessageRemote\" short-alias=\"remote\"/>\n    </entity>\n    <entity entity-name=\"SystemMessageHookStatus\" package=\"moqui.service.message\">\n        <field name=\"systemMessageHookId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"systemMessageId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"sentDate\" type=\"text-indicator\"><description>null until successfully sent</description></field>\n        <relationship type=\"one\" related=\"moqui.service.message.SystemMessageHook\" short-alias=\"hook\"/>\n        <relationship type=\"one\" related=\"moqui.service.message.SystemMessage\" short-alias=\"message\"/>\n    </entity>\n    -->\n</entities>\n"
  },
  {
    "path": "framework/entity/TestEntities.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a \nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<entities xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/entity-definition-3.xsd\">\n\n    <!-- ========================================================= -->\n    <!-- moqui.test -->\n    <!-- ========================================================= -->\n\n    <entity entity-name=\"TestEntity\" package=\"moqui.test\" sequence-bank-size=\"100\">\n        <field name=\"testId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"testMedium\" type=\"text-medium\"/>\n        <field name=\"testLong\" type=\"text-long\"/>\n        <field name=\"testIndicator\" type=\"text-indicator\"/>\n        <field name=\"testDate\" type=\"date\"/>\n        <field name=\"testDateTime\" type=\"date-time\"/>\n        <field name=\"testTime\" type=\"time\"/>\n        <field name=\"testNumberInteger\" type=\"number-integer\"/>\n        <field name=\"testNumberDecimal\" type=\"number-decimal\"/>\n        <field name=\"testNumberFloat\" type=\"number-float\"/>\n        <field name=\"testCurrencyAmount\" type=\"currency-amount\"/>\n        <field name=\"testCurrencyPrecise\" type=\"currency-precise\"/>\n    </entity>\n    <entity entity-name=\"Foo\" package=\"moqui.test\" sequence-bank-size=\"100\">\n        <field name=\"fooId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"fooText\" type=\"text-medium\"/>\n    </entity>\n    <entity entity-name=\"Bar\" package=\"moqui.test\" sequence-bank-size=\"100\">\n        <field name=\"barId\" type=\"id\" is-pk=\"true\" />\n        <field name=\"fooId\" type=\"id\"/>\n        <!-- NOTE: 'RANK' is a reserved word in MySQL 8 -->\n        <field name=\"barRank\" type=\"number-integer\"/>\n        <field name=\"score\" column-name=\"BAR_SCORE\" type=\"number-decimal\"/>\n    </entity>\n    <view-entity entity-name=\"FooBar\" package=\"moqui.test\">\n        <member-entity entity-alias=\"T1\" entity-name=\"moqui.test.Foo\"/>\n        <member-entity entity-alias=\"T2\" entity-name=\"moqui.test.Bar\" sub-select=\"true\" join-optional=\"true\" join-from-alias=\"T1\">\n            <key-map field-name=\"fooId\"/></member-entity>\n        <alias-all entity-alias=\"T1\"/>\n        <alias name=\"score\" function=\"sum\" entity-alias=\"T2\"/>\n        <alias name=\"barRank\" entity-alias=\"T2\"/>\n    </view-entity>\n    <entity entity-name=\"TestNoSqlEntity\" package=\"moqui.test\" sequence-bank-size=\"100\" use=\"logging\" group=\"logging\" cache=\"never\">\n        <field name=\"testId\" type=\"id\" is-pk=\"true\"/>\n        <field name=\"testMedium\" type=\"text-medium\"/>\n        <field name=\"testLong\" type=\"text-long\"/>\n        <field name=\"testIndicator\" type=\"text-indicator\"/>\n        <field name=\"testDate\" type=\"date\"/>\n        <field name=\"testDateTime\" type=\"date-time\"/>\n        <field name=\"testTime\" type=\"time\"/>\n        <field name=\"testNumberInteger\" type=\"number-integer\"/>\n        <field name=\"testNumberDecimal\" type=\"number-decimal\"/>\n        <field name=\"testNumberFloat\" type=\"number-float\"/>\n        <field name=\"testCurrencyAmount\" type=\"currency-amount\"/>\n        <field name=\"testCurrencyPrecise\" type=\"currency-precise\"/>\n    </entity>\n    <entity entity-name=\"TestIntPk\" package=\"moqui.test\">\n        <field name=\"intId\" type=\"number-integer\" is-pk=\"true\"/>\n        <field name=\"testMedium\" type=\"text-medium\"/>\n    </entity>\n</entities>\n"
  },
  {
    "path": "framework/screen/AddedEmailAuthcFactor.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<screen xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/xml-screen-3.xsd\">\n    <widgets>\n        <render-mode><text type=\"html\"><![CDATA[<html><body>]]></text></render-mode>\n        <container><label text=\"An email method for authentication has been added for ${userEmail}\"/></container>\n        <container><label text=\"This email is valid until ${thruDateString}\"/></container>\n        <container><label text=\"If you didn't request this email, please contact administrator and reset your password.\"/></container>\n        <render-mode><text type=\"html\"><![CDATA[</body></html>]]></text></render-mode>\n    </widgets>\n</screen>\n"
  },
  {
    "path": "framework/screen/EmailAuthcFactorSent.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<screen xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/xml-screen-3.xsd\">\n    <widgets>\n        <render-mode><text type=\"html\"><![CDATA[<html><body>]]></text></render-mode>\n        <container><label text=\"A one time code was sent to ${userEmail}.\"/></container>\n        <container><label text=\"If you didn't request this email, please contact administrator and reset your password.\"/></container>\n        <render-mode><text type=\"html\"><![CDATA[</body></html>]]></text></render-mode>\n    </widgets>\n</screen>\n"
  },
  {
    "path": "framework/screen/NotificationEmail.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<screen xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/xml-screen-3.xsd\">\n    <!-- for fields available see NotificationMessageImpl.getWrappedMessageMap(), this is generic so nothing from the 'message' Map within it -->\n    <actions>\n        <if condition=\"link\">\n            <if condition=\"webHostName\"><then>\n                <set field=\"linkUrl\" from=\"'https://' + webHostName + (link.startsWith('/') ? '' : '/') + link\"/>\n            </then><else>\n                <set field=\"rootUrl\" from=\"org.moqui.impl.context.WebFacadeImpl.getWebappRootUrl(sri?.webappName ?: 'webroot', null, true, false, ec)\"/>\n                <set field=\"linkUrl\" from=\"rootUrl &amp;&amp; !rootUrl.contains('localhost') ? rootUrl + '/' + link : link\"/>\n                <!-- <log level=\"warn\" message=\"rootUrl ${rootUrl} link ${link} linkUrl ${linkUrl}\"/> -->\n            </else></if>\n        </if>\n    </actions>\n    <widgets><render-mode>\n        <text type=\"html\"><![CDATA[\n<html><body>\n    <h2>${topicDescription!\"\"} Notification (${type!\"info\"})</h2>\n    <h4>${title}</h4>\n    <#if linkUrl?has_content><p><#if linkUrl?starts_with(\"http\")><a href=\"${linkUrl}\">${linkUrl}</a><#else>${linkUrl}</#if></p></#if>\n    <p><#if notificationMessageId?has_content>Notification Message ID ${notificationMessageId} </#if>Sent ${ec.l10n.format(sentDate, \"\")} to topic '${topic}'</p>\n</body></html>\n        ]]></text>\n        <text type=\"text\"><![CDATA[\n${topicDescription!\"\"} Notification (${type!\"info\"})\n\n${title}\n\n${linkUrl!\"\"}\n\n<#if notificationMessageId?has_content>Notification Message ID ${notificationMessageId} </#if>Sent ${ec.l10n.format(sentDate, '')} to topic '${topic}'\n        ]]></text>\n    </render-mode></widgets>\n</screen>\n"
  },
  {
    "path": "framework/screen/PasswordReset.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<screen xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/xml-screen-3.xsd\">\n    <widgets>\n        <render-mode><text type=\"html\"><![CDATA[<html><body>]]></text></render-mode>\n        <container><label text=\"Your reset password has been set to: ${resetPassword}\"/></container>\n        <container><label text=\"Please use this reset password to change your password immediately.\"/></container>\n        <container><label text=\"Your current password is still valid, this reset password may only be used to change your password.\"/></container>\n        <section name=\"RequireChangeSection\" condition=\"userAccount.requirePasswordChange == 'Y'\"><widgets>\n            <container><label text=\"Your password must be changed before login.\"/></container>\n        </widgets></section>\n        <render-mode><text type=\"html\"><![CDATA[</body></html>]]></text></render-mode>\n    </widgets>\n</screen>\n"
  },
  {
    "path": "framework/screen/ScreenRenderEmail.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<screen xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/xml-screen-3.xsd\">\n    <actions>\n        <set field=\"title\" from=\"title ?: 'Rendered screen attached'\"/>\n        <set field=\"bodyParmKeys\" from=\"new TreeSet()\"/>\n        <if condition=\"bodyParameters\">\n            <script>bodyParmKeys.addAll(bodyParameters.keySet())</script>\n            <set field=\"excludeParmKeys\" from=\"['moquiSessionToken', 'screenRenderMode', 'moquiFormName', 'title', 'emailSubject',\n                    'toAddresses', 'findButton', 'submitButton', 'lastStandalone', 'pageNoLimit', 'screenPath', 'webHostName']\"/>\n            <iterate list=\"excludeParmKeys\" entry=\"excludeParmKey\"><script>bodyParmKeys.remove(excludeParmKey)</script></iterate>\n        </if>\n        <if condition=\"webHostName &amp;&amp; screenPath\">\n            <script><![CDATA[\n                if (!screenPath.startsWith('/')) screenPath = '/' + screenPath\n                screenPath = screenPath.replace('/apps/', '/vapps/')\n                curScreenUrl = \"https://\" + webHostName + screenPath\n                parmSb = new StringBuilder()\n                for (bodyParmKey in bodyParmKeys) {\n                    if (parmSb.length()) parmSb.append('&')\n                    String bodyParmValue = bodyParameters.get(bodyParmKey)?.toString()\n                    parmSb.append(URLEncoder.encode(bodyParmKey, \"UTF-8\")).append('=').append(URLEncoder.encode(bodyParmValue, \"UTF-8\"))\n                }\n                if (parmSb.length()) curScreenUrl += '?' + parmSb.toString()\n            ]]></script>\n        </if>\n    </actions>\n    <widgets><render-mode>\n        <text type=\"html\"><![CDATA[\n<html><body>\n    <h2>${title}</h2>\n\n    <#if curScreenUrl?has_content>\n        <p><a href=\"${curScreenUrl}\">${curScreenUrl}</a></p>\n    </#if>\n\n    <#list bodyParmKeys as parmName>\n        <#assign parmValue = bodyParameters.get(parmName)!>\n        <#if parmValue?has_content>\n            <div><strong>${parmName}</strong>: ${parmValue}</div>\n        </#if>\n    </#list>\n</body></html>\n        ]]></text>\n        <text type=\"text\"><![CDATA[\n${title}\n\n<#if curScreenUrl?has_content>\n${curScreenUrl}\n\n</#if>\n<#list bodyParmKeys as parmName>\n    <#assign parmValue = bodyParameters.get(parmName)!>\n    <#if parmValue?has_content>\n${parmName}: ${parmValue}\n    </#if>\n</#list>\n        ]]></text>\n    </render-mode></widgets>\n</screen>\n"
  },
  {
    "path": "framework/screen/SingleUseCode.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<screen xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/xml-screen-3.xsd\">\n    <widgets>\n        <render-mode><text type=\"html\"><![CDATA[<html><body>]]></text></render-mode>\n        <container><label text=\"Your single use code is: ${code}\"/></container>\n        <container><label text=\"If you didn't request this email, please contact administrator and reset your password.\"/></container>\n        <render-mode><text type=\"html\"><![CDATA[</body></html>]]></text></render-mode>\n    </widgets>\n</screen>\n"
  },
  {
    "path": "framework/service/org/moqui/EmailServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n\n    <service verb=\"send\" noun=\"EmailTemplate\" type=\"interface\">\n        <description>Send Email with settings in EmailTemplate entity record</description>\n        <in-parameters>\n            <parameter name=\"emailTemplateId\" required=\"true\"/>\n            <parameter name=\"toAddresses\" required=\"true\"><description>Comma separated list of to email addresses</description></parameter>\n            <parameter name=\"ccAddresses\"><description>Comma separated list of CC email addresses</description></parameter>\n            <parameter name=\"bccAddresses\"><description>Comma separated list of BCC email addresses</description></parameter>\n            <parameter name=\"bodyParameters\" type=\"Map\"/>\n            <parameter name=\"createEmailMessage\" type=\"Boolean\" default=\"true\"/>\n            <parameter name=\"emailTypeEnumId\"/>\n            <parameter name=\"fromUserId\"/><parameter name=\"toUserId\"/>\n            <parameter name=\"attachments\" type=\"List\"><parameter name=\"attachment\" type=\"Map\">\n                <parameter name=\"fileName\"/>\n                <!-- specify mime type with contentType or for screen renders determine from render mode, for others from fileName extension -->\n                <parameter name=\"contentType\"/>\n                <!-- use contentText or contentBytes for direct attachments (not a screen to render) -->\n                <parameter name=\"contentText\" allow-html=\"any\"/>\n                <parameter name=\"contentBytes\" type=\"byte[]\"/>\n                <!-- for more details on these see the matching field descriptions on EmailTemplateAttachment -->\n                <parameter name=\"screenPath\"/>\n                <parameter name=\"screenRenderMode\"/>\n                <parameter name=\"attachmentLocation\"/>\n            </parameter></parameter>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"messageId\"><description>From the Message-ID email header field.</description></parameter>\n            <parameter name=\"emailMessageId\"><description>If createEmailMessage=true the ID of the EmailMessage record.</description></parameter>\n        </out-parameters>\n    </service>\n    \n    <service verb=\"process\" noun=\"EmailEca\" type=\"interface\">\n        <description>Defines input parameters matching what is available when an Email ECA rule is called.</description>\n        <in-parameters>\n            <parameter name=\"emailServerId\"/>\n            <parameter name=\"message\" type=\"jakarta.mail.internet.MimeMessage\"/>\n            <parameter name=\"fields\" type=\"Map\">\n                <parameter name=\"toList\" type=\"List\" allow-html=\"any\"/>\n                <parameter name=\"ccList\" type=\"List\" allow-html=\"any\"/>\n                <parameter name=\"bccList\" type=\"List\" allow-html=\"any\"/>\n                <parameter name=\"from\" allow-html=\"any\"/>\n                <parameter name=\"subject\"/>\n                <parameter name=\"sentDate\" type=\"Timestamp\"/>\n                <parameter name=\"receivedDate\" type=\"Timestamp\"/>\n            </parameter>\n            <parameter name=\"bodyPartList\" type=\"List\">\n                <description>List of Map for each body part. If the message is not multi-part will have a single entry.</description>\n                <parameter name=\"bodyPartMap\" type=\"Map\">\n                    <parameter name=\"contentType\"/>\n                    <parameter name=\"filename\"/>\n                    <parameter name=\"disposition\"/>\n                    <parameter name=\"contentText\" allow-html=\"any\"/>\n                    <parameter name=\"contentBytes\" type=\"byte[]\"/>\n                </parameter>\n            </parameter>\n            <parameter name=\"headers\" type=\"Map\" allow-html=\"any\"><description>All header names (keys) are converted to\n                lower case for consistency. If multiple values for a header name are found they will be put in a List.</description></parameter>\n            <parameter name=\"flags\" type=\"Map\">\n                <parameter name=\"answered\" type=\"Boolean\"/>\n                <parameter name=\"deleted\" type=\"Boolean\"/>\n                <parameter name=\"draft\" type=\"Boolean\"/>\n                <parameter name=\"flagged\" type=\"Boolean\"/>\n                <parameter name=\"recent\" type=\"Boolean\"/>\n                <parameter name=\"seen\" type=\"Boolean\"/>\n            </parameter>\n        </in-parameters>\n    </service>\n</services>\n"
  },
  {
    "path": "framework/service/org/moqui/EntityServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n    <service verb=\"receive\" noun=\"DataFeed\" type=\"interface\" no-remember-parameters=\"true\">\n        <description>Services named in the DataFeed.feedReceiveServiceName field should implement this interface.</description>\n        <in-parameters>\n            <parameter name=\"dataFeedId\"/>\n            <parameter name=\"feedStamp\" type=\"Timestamp\"/>\n            <parameter name=\"documentList\" type=\"List\" required=\"true\">\n                <parameter name=\"document\" type=\"Map\">\n                    <parameter name=\"_id\" required=\"true\"><description>The combined PK field values of the primary\n                        entity in the DataDocument. If there is more than one PK field the values are separated with a\n                        double-colon (\"::\").</description></parameter>\n                    <parameter name=\"_type\" required=\"true\"><description>The DataDocument.dataDocumentId that defines\n                        the document structure, etc.</description></parameter>\n                    <parameter name=\"_index\"><description>From DataDocument.indexName, if specified.</description></parameter>\n                    <parameter name=\"_timestamp\"><description>Document timestamp in the format yyyy-MM-dd'T'HH:mm:ss</description></parameter>\n                </parameter>\n            </parameter>\n        </in-parameters>\n    </service>\n    <service verb=\"receive\" noun=\"DataFeedDelete\" type=\"interface\">\n        <in-parameters>\n            <parameter name=\"dataFeedId\"/>\n            <parameter name=\"feedStamp\" type=\"Timestamp\"/>\n            <parameter name=\"dataDocumentId\" required=\"true\"/>\n            <parameter name=\"documentId\" required=\"true\"><description>The combined PK field values of the primary\n                entity in the DataDocument. If there is more than one PK field the values are separated with a\n                double-colon (\"::\").</description></parameter>\n        </in-parameters>\n    </service>\n\n    <service verb=\"add\" noun=\"ManualDocumentData\" type=\"interface\" no-remember-parameters=\"true\">\n        <description>Services named in the DataDocument.manualDataServiceName field should implement this interface.</description>\n        <in-parameters>\n            <parameter name=\"dataDocumentId\" type=\"String\" required=\"true\"/>\n            <parameter name=\"document\" type=\"Map\"><description>For details see document parameter in receive#DataFeed.</description></parameter>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"document\" type=\"Map\"><description>For details see document parameter in receive#DataFeed.</description></parameter>\n        </out-parameters>\n    </service>\n    <service verb=\"transform\" noun=\"DocumentMapping\" type=\"interface\">\n        <description>Services named in the DataDocument.manualMappingServiceName field should implement this interface.</description>\n        <in-parameters>\n            <parameter name=\"mapping\" type=\"Map\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"mapping\" type=\"Map\"/>\n            <parameter name=\"settings\" type=\"Map\"/>\n        </out-parameters>\n    </service>\n</services>\n"
  },
  {
    "path": "framework/service/org/moqui/SmsServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n    <service verb=\"send\" noun=\"SmsMessage\" authenticate=\"anonymous-view\">\n        <in-parameters>\n            <parameter name=\"countryCode\" default-value=\"1\"/>\n            <parameter name=\"areaCode\"/>\n            <parameter name=\"contactNumber\" required=\"true\">\n                <description>If there is a single string for phone number pass it here, may include a country code if begins with '+'</description></parameter>\n            <parameter name=\"message\" required=\"true\"/>\n            <parameter name=\"isPromotional\" default=\"false\" type=\"Boolean\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"successful\" type=\"Boolean\"/>\n            <parameter name=\"messageId\"/>\n            <parameter name=\"errorMessage\"/>\n        </out-parameters>\n        <actions>\n            <log level=\"warn\" message=\"No SMS implementation in place, attempt to send SMS message [${message}] to ${countryCode}-${areaCode}-${contactNumber}\"/>\n            <return type=\"danger\" message=\"No SMS implementation in place, not sending message\"/>\n        </actions>\n    </service>\n</services>\n"
  },
  {
    "path": "framework/service/org/moqui/impl/BasicServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n    <service verb=\"noop\" authenticate=\"anonymous-all\" validate=\"false\">\n        <actions><!-- do nothing, it's a noop service --></actions>\n    </service>\n\n    <!-- ========== Geo Services ========== -->\n    <service verb=\"get\" noun=\"GeoRegionsForDropDown\" allow-remote=\"true\">\n        <in-parameters>\n            <parameter name=\"geoId\"/>\n            <parameter name=\"geoAssocTypeEnumId\" default-value=\"GAT_REGIONS\"/>\n            <parameter name=\"geoTypeEnumId\"/>\n            <parameter name=\"term\"><description>For current Find Options will be only geoId to include in output</description></parameter>\n        </in-parameters>\n        <out-parameters><parameter name=\"resultList\" type=\"List\"><parameter name=\"result\" type=\"Map\"/></parameter></out-parameters>\n        <actions>\n            <set field=\"resultList\" from=\"[]\"/>\n            <if condition=\"term\">\n                <entity-find-one entity-name=\"moqui.basic.Geo\" value-field=\"termGeo\">\n                    <field-map field-name=\"geoId\" from=\"term\"/></entity-find-one>\n                <if condition=\"termGeo != null\"><script>resultList.add([geoId:termGeo.geoId, label:(termGeo.geoCodeAlpha2 ? termGeo.geoCodeAlpha2 + ' - ' : '') + termGeo.geoName,\n                        geoName:termGeo.geoName, geoCodeAlpha2:termGeo.geoCodeAlpha2])</script></if>\n            </if>\n            <if condition=\"geoId &amp;&amp; !resultList\">\n                <entity-find entity-name=\"moqui.basic.GeoAssocAndToDetail\" list=\"geoList\">\n                    <econdition field-name=\"geoId\"/><econdition field-name=\"geoAssocTypeEnumId\"/>\n                    <econdition field-name=\"geoTypeEnumId\" ignore-if-empty=\"true\"/>\n                    <order-by field-name=\"geoName\"/>\n                </entity-find>\n                <script>for (geo in geoList) resultList.add([geoId:geo.toGeoId, label:(geo.geoCodeAlpha2 ? geo.geoCodeAlpha2 + ' - ' : '') + geo.geoName,\n                        geoName:geo.geoName, geoCodeAlpha2:geo.geoCodeAlpha2])</script>\n            </if>\n        </actions>\n    </service>\n\n    <!-- ========== Status Services ========== -->\n    <service verb=\"find\" noun=\"StatusItem\" allow-remote=\"true\">\n        <in-parameters>\n            <parameter name=\"statusTypeId\" required=\"true\"/>\n            <parameter name=\"orderByField\" default-value=\"sequenceNum\"/>\n        </in-parameters>\n        <out-parameters><parameter name=\"statusItemList\" type=\"List\"/></out-parameters>\n        <actions>\n            <entity-find entity-name=\"moqui.basic.StatusItem\" list=\"statusItemList\" cache=\"true\">\n                <econdition field-name=\"statusTypeId\"/>\n                <order-by field-name=\"${orderByField}\"/>\n            </entity-find>\n        </actions>\n    </service>\n    <service verb=\"find\" noun=\"StatusFlowTransitionToDetail\" allow-remote=\"true\">\n        <description>Key in results is toStatusId. Recommended display expression is \"${transitionName} (${description})\".</description>\n        <in-parameters>\n            <parameter name=\"statusId\" required=\"true\"/>\n            <parameter name=\"statusFlowId\"/>\n            <parameter name=\"orderByField\" default-value=\"sequenceNum\"/>\n        </in-parameters>\n        <out-parameters><parameter name=\"statusDetailList\"/></out-parameters>\n        <actions>\n            <entity-find entity-name=\"moqui.basic.StatusFlowTransitionToDetail\" list=\"statusDetailList\">\n                <econdition field-name=\"statusId\"/>\n                <econdition field-name=\"statusFlowId\" ignore-if-empty=\"true\"/>\n                <order-by field-name=\"${orderByField}\"/>\n            </entity-find>\n        </actions>\n    </service>\n\n    <!-- ========== Enumeration Services ========== -->\n    <service verb=\"find\" noun=\"Enumeration\" allow-remote=\"true\">\n        <in-parameters>\n            <parameter name=\"enumTypeId\" required=\"true\"/>\n            <parameter name=\"orderByField\" default-value=\"description\"/>\n        </in-parameters>\n        <out-parameters><parameter name=\"enumerationList\" type=\"List\"/></out-parameters>\n        <actions>\n            <entity-find entity-name=\"moqui.basic.Enumeration\" list=\"enumerationList\" cache=\"true\">\n                <econdition field-name=\"enumTypeId\"/>\n                <order-by field-name=\"${orderByField}\"/>\n            </entity-find>\n        </actions>\n    </service>\n    <service verb=\"find\" noun=\"EnumerationByParent\" allow-remote=\"true\">\n        <in-parameters>\n            <parameter name=\"parentEnumId\" required=\"true\"/>\n            <parameter name=\"includeParent\" type=\"Boolean\" default=\"true\"/>\n            <parameter name=\"includeNested\" type=\"Boolean\" default=\"false\"/>\n            <parameter name=\"orderByField\" default-value=\"description\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"enumerationList\" type=\"List\"/>\n            <parameter name=\"enumIdSet\" type=\"Set\"/>\n        </out-parameters>\n        <actions><script><![CDATA[\n            import groovy.transform.CompileStatic\n            import org.moqui.context.ExecutionContext\n            import org.moqui.entity.EntityList\n            import org.moqui.entity.EntityValue\n            import org.moqui.impl.entity.EntityListImpl\n\n            ExecutionContext ec = context.ec\n            enumerationList = new EntityListImpl(ec.entity)\n            enumIdSet = new TreeSet()\n\n            if (includeParent) {\n                EntityValue parentEnum = ec.entity.find(\"moqui.basic.Enumeration\").condition(\"enumId\", parentEnumId).useCache(true).one()\n                enumerationList.add(0, parentEnum)\n                enumIdSet.add(parentEnumId)\n            }\n\n            findChildren(parentEnumId, enumerationList, enumIdSet, includeNested, ec)\n            enumerationList.orderByFields([orderByField])\n\n            @CompileStatic\n            void findChildren(String parentEnumId, EntityList enumerationList, Set enumIdSet, Boolean includeNested, ExecutionContext ec) {\n                EntityList curList = ec.entity.find(\"moqui.basic.Enumeration\").condition(\"parentEnumId\", parentEnumId).useCache(true).list()\n                if (curList.size() == 0) return\n                int curListSize = curList.size()\n                for (int i = 0; i < curListSize; i++) {\n                    EntityValue curEnum = curList.get(i)\n                    enumerationList.add(curEnum)\n                    enumIdSet.add(curEnum.enumId)\n                    // recurse to find nested\n                    if (includeNested) findChildren((String) curEnum.enumId, enumerationList, enumIdSet, true, ec)\n                }\n            }\n            ]]></script></actions>\n    </service>\n    <service verb=\"get\" noun=\"EnumsByTypeForDropDown\" allow-remote=\"true\">\n        <in-parameters><parameter name=\"enumTypeId\"/></in-parameters>\n        <out-parameters><parameter name=\"resultList\" type=\"List\"/></out-parameters>\n        <actions>\n            <set field=\"resultList\" from=\"[]\"/>\n            <if condition=\"enumTypeId\">\n                <entity-find entity-name=\"moqui.basic.Enumeration\" list=\"enumList\">\n                    <econdition field-name=\"enumTypeId\"/>\n                    <order-by field-name=\"sequenceNum,description\"/>\n                </entity-find>\n                <script>for (def enumEntry in enumList)\n                    resultList.add([enumId:enumEntry.enumId, label:\"${enumEntry.description} [${enumEntry.enumId}]\"])</script>\n            </if>\n        </actions>\n    </service>\n\n    <!-- ========== Uom Services ========== -->\n    <service verb=\"create\" noun=\"UomConversion\" type=\"entity-auto\">\n        <in-parameters><auto-parameters entity-name=\"moqui.basic.UomConversion\" include=\"nonpk\"/></in-parameters>\n        <out-parameters><parameter name=\"uomConversionId\"/></out-parameters>\n    </service>\n    <service verb=\"update\" noun=\"UomConversion\" type=\"entity-auto\">\n        <in-parameters><auto-parameters entity-name=\"moqui.basic.UomConversion\" include=\"all\"/></in-parameters>\n    </service>\n\n    <service verb=\"convert\" noun=\"Uom\">\n        <description>Converts amount and returns convertedAmount. The factor is multiplied first, then the offset is added.\n            If no UomConversion record is found for uomId and toUomId tries the reverse. When converting in the reverse\n            direction the offset is subtracted first, then divided by the factor.</description>\n        <in-parameters>\n            <parameter name=\"uomId\" required=\"true\"/>\n            <parameter name=\"toUomId\" required=\"true\"/>\n            <parameter name=\"effectiveDate\" default=\"ec.user.nowTimestamp\" type=\"Timestamp\"/>\n            <parameter name=\"amount\" type=\"BigDecimal\" required=\"true\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"convertedAmount\" type=\"BigDecimal\"/>\n        </out-parameters>\n        <actions>\n            <if condition=\"uomId == toUomId\">\n                <set field=\"convertedAmount\" from=\"amount\"/>\n                <return/>\n            </if>\n\n            <entity-find entity-name=\"moqui.basic.UomConversion\" list=\"uomConversionList\">\n                <date-filter valid-date=\"effectiveDate\"/>\n                <econdition field-name=\"uomId\"/><econdition field-name=\"toUomId\"/>\n                <order-by field-name=\"-fromDate\"/><order-by field-name=\"-uomConversionId\"/>\n            </entity-find>\n            <if condition=\"uomConversionList\">\n                <set field=\"uomConversion\" from=\"uomConversionList.first\"/>\n                <set field=\"convertedAmount\" from=\"amount\"/>\n                <if condition=\"uomConversion.conversionFactor\">\n                    <set field=\"convertedAmount\" from=\"(convertedAmount * uomConversion.conversionFactor) as BigDecimal\"/></if>\n                <if condition=\"uomConversion.conversionOffset\">\n                    <set field=\"convertedAmount\" from=\"(convertedAmount + uomConversion.conversionOffset) as BigDecimal\"/></if>\n                <return/>\n            </if>\n            <if condition=\"!uomConversionList\">\n                <!-- try going backwards -->\n                <entity-find entity-name=\"moqui.basic.UomConversion\" list=\"uomConversionList\">\n                    <date-filter valid-date=\"effectiveDate\"/>\n                    <econdition field-name=\"uomId\" from=\"toUomId\"/><econdition field-name=\"toUomId\" from=\"uomId\"/>\n                    <order-by field-name=\"-fromDate\"/><order-by field-name=\"-uomConversionId\"/>\n                </entity-find>\n                <if condition=\"uomConversionList\">\n                    <set field=\"uomConversion\" from=\"uomConversionList.first\"/>\n                    <set field=\"convertedAmount\" from=\"amount\"/>\n                    <if condition=\"uomConversion.conversionOffset\">\n                        <set field=\"convertedAmount\" from=\"(convertedAmount - uomConversion.conversionOffset) as BigDecimal\"/></if>\n                    <if condition=\"uomConversion.conversionFactor\">\n                        <set field=\"convertedAmount\" from=\"(convertedAmount / uomConversion.conversionFactor) as BigDecimal\"/></if>\n                    <return/>\n                </if>\n            </if>\n            <if condition=\"!uomConversionList\">\n                <entity-find-one entity-name=\"moqui.basic.Uom\" value-field=\"uom\"/>\n                <entity-find-one entity-name=\"moqui.basic.Uom\" value-field=\"toUom\">\n                    <field-map field-name=\"uomId\" from=\"toUomId\"/></entity-find-one>\n                <return error=\"true\" message=\"Could not convert from ${uom?.description ?: uomId} to ${toUom?.description ?: toUomId}, no UOM Conversion found.\"/>\n            </if>\n        </actions>\n    </service>\n\n    <service verb=\"echo\" noun=\"Data\">\n        <in-parameters>\n            <parameter name=\"textIn1\"/>\n            <parameter name=\"textIn2\" default-value=\"ping\"/>\n            <parameter name=\"numberIn\" type=\"BigDecimal\"/>\n            <parameter name=\"timestampIn\" type=\"Timestamp\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"textOut1\"/>\n            <parameter name=\"textOut2\"/>\n            <parameter name=\"numberOut\" type=\"BigDecimal\"/>\n            <parameter name=\"timestampOut\" type=\"Timestamp\"/>\n        </out-parameters>\n        <actions>\n            <set field=\"textOut1\" from=\"textIn1\"/>\n            <set field=\"textOut2\" from=\"textIn2\"/>\n            <set field=\"numberOut\" from=\"numberIn\"/>\n            <set field=\"timestampOut\" from=\"timestampIn\"/>\n        </actions>\n    </service>\n</services>\n"
  },
  {
    "path": "framework/service/org/moqui/impl/ElFinderServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n    <service verb=\"run\" noun=\"Command\">\n        <description>See elFinder API docs at: https://github.com/Studio-42/elFinder/wiki/Client-Server-API-2.0</description>\n        <in-parameters>\n            <parameter name=\"cmd\" required=\"true\"/>\n            <parameter name=\"target\"/>\n            <parameter name=\"otherParameters\" type=\"Map\"/>\n            <parameter name=\"resourceRoot\" default-value=\"dbresource://\"/>\n            <!-- TODO: map to resourceRoot somehow? is a 3 char string that is a prefix to the hash -->\n            <parameter name=\"volumeId\" default-value=\"db_\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"responseMap\" type=\"Map\"/>\n            <parameter name=\"fileLocation\"/>\n            <parameter name=\"fileInline\" type=\"Boolean\"/>\n        </out-parameters>\n        <actions>\n            <set field=\"elFinderConnector\" from=\"new org.moqui.impl.util.ElFinderConnector(ec, resourceRoot, volumeId)\"/>\n            <log level=\"info\" message=\"elFinder cmd=${cmd}, target=${target}:${elFinderConnector.unhash(target)}:${elFinderConnector.getLocation(target)}\"/>\n            <!-- <log message=\"elFinder otherParameters: ${otherParameters}\"/> -->\n            <script>elFinderConnector.runCommand()</script>\n            <!-- <log message=\"elFinder fileLocation=${fileLocation}, responseMap=${responseMap}\"/> -->\n        </actions>\n    </service>\n</services>\n"
  },
  {
    "path": "framework/service/org/moqui/impl/EmailServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n\n    <service verb=\"send\" noun=\"Email\" type=\"script\"\n             location=\"classpath://org/moqui/impl/sendEmailTemplate.groovy\" allow-remote=\"false\">\n        <implements service=\"org.moqui.EmailServices.send#EmailTemplate\"/>\n    </service>\n    <service verb=\"send\" noun=\"EmailTemplate\" authenticate=\"anonymous-view\" type=\"script\"\n             location=\"classpath://org/moqui/impl/sendEmailTemplate.groovy\" allow-remote=\"false\">\n        <description>NOTE: this service is meant for internal use and authentication is not required. Do not export or\n            allow this service to be called remotely.</description>\n        <implements service=\"org.moqui.EmailServices.send#EmailTemplate\"/>\n    </service>\n    <service verb=\"send\" noun=\"EmailMessage\" type=\"script\" location=\"classpath://org/moqui/impl/sendEmailMessage.groovy\">\n        <in-parameters><parameter name=\"emailMessageId\" required=\"true\"/></in-parameters>\n    </service>\n\n    <service verb=\"poll\" noun=\"EmailServer\" authenticate=\"anonymous-all\" type=\"script\" transaction-timeout=\"600\"\n            location=\"classpath://org/moqui/impl/pollEmailServer.groovy\"\n            semaphore=\"fail\" semaphore-parameter=\"emailServerId\">\n        <!-- org.moqui.impl.EmailServices.poll#EmailServer -->\n        <description>\n            Poll an email server (IMAP or POP3) to receive messages. Each new message is processed using\n            the Email-ECA rules. Messages are flagged as seen (if supported). Messages are deleted if the storeDelete\n            flag is set on the moqui.basic.email.EmailServer record.\n\n            This is meant to be called as a scheduled service, run as often as you want to poll for new messages on a\n            particular server (configured in the corresponding moqui.basic.email.EmailServer record).\n\n            Add a job in the quartz_data.xml file for this service to run this scheduled.\n        </description>\n        <in-parameters>\n            <parameter name=\"emailServerId\" required=\"true\"/>\n        </in-parameters>\n    </service>\n    \n    <service verb=\"save\" noun=\"EcaEmailMessage\">\n        <!-- org.moqui.impl.EmailServices.save#EcaEmailMessage -->\n        <implements service=\"org.moqui.EmailServices.process#EmailEca\"/>\n        <in-parameters>\n            <parameter name=\"statusId\" default-value=\"ES_RECEIVED\"/>\n        </in-parameters>\n        <out-parameters><parameter name=\"emailMessageId\"/></out-parameters>\n        <actions>\n            <set field=\"messageId\" from=\"headers.get('message-id')\"/>\n            <if condition=\"messageId\">\n                <entity-find entity-name=\"moqui.basic.email.EmailMessage\" list=\"emailMessageList\">\n                    <econdition field-name=\"emailServerId\" ignore-if-empty=\"true\"/>\n                    <econdition field-name=\"messageId\"/>\n                </entity-find>\n                <if condition=\"emailMessageList\">\n                    <return message=\"Found duplicate message with Message-ID [${messageId}] from server [${emailServerId}]\"/></if>\n            </if>\n\n            <set field=\"body\" from=\"bodyPartList ? bodyPartList[0].contentText : null\"/>\n            <service-call name=\"create#moqui.basic.email.EmailMessage\" out-map=\"context\"\n                in-map=\"[sentDate:fields.sentDate, receivedDate:fields.receivedDate, statusId:statusId,\n                    subject:fields.subject, body:body,\n                    fromAddress:fields.from, toAddresses:fields.toList?.toString(),\n                    ccAddresses:fields.ccList?.toString(), bccAddresses:fields.bccList?.toString(),\n                    messageId:messageId, emailServerId:emailServerId]\"/>\n        </actions>\n    </service>\n\n    <service verb=\"send\" noun=\"ScreenRenderEmail\">\n        <in-parameters>\n            <parameter name=\"screenLocation\" required=\"true\"/>\n            <parameter name=\"screenRenderMode\" required=\"true\"/>\n            <parameter name=\"fileName\"/>\n\n            <parameter name=\"toAddresses\" required=\"true\"><description>Comma separated list of to email addresses</description></parameter>\n            <parameter name=\"ccAddresses\"><description>Comma separated list of CC email addresses</description></parameter>\n            <parameter name=\"bccAddresses\"><description>Comma separated list of BCC email addresses</description></parameter>\n\n            <parameter name=\"emailTemplateId\" default-value=\"SCREEN_RENDER\"/>\n            <parameter name=\"createEmailMessage\" type=\"Boolean\" default=\"false\"/>\n            <parameter name=\"bodyParameters\" type=\"Map\"/>\n            <parameter name=\"title\"/>\n        </in-parameters>\n        <actions>\n            <set field=\"attachments\" from=\"[[fileName:fileName, attachmentLocation:screenLocation, screenRenderMode:screenRenderMode]]\"/>\n            <if condition=\"bodyParameters == null\"><set field=\"bodyParameters\" from=\"[:]\"/></if>\n            <if condition=\"title\"><set field=\"bodyParameters.title\" from=\"title\"/></if>\n\n            <!-- default pageNoLimit=true and lastStandalone=true -->\n            <if condition=\"!bodyParameters.pageNoLimit\"><set field=\"bodyParameters.pageNoLimit\" value=\"true\"/></if>\n            <if condition=\"!bodyParameters.lastStandalone\"><set field=\"bodyParameters.lastStandalone\" value=\"true\"/></if>\n\n            <service-call name=\"org.moqui.impl.EmailServices.send#EmailTemplate\" in-map=\"context\"/>\n        </actions>\n    </service>\n</services>\n"
  },
  {
    "path": "framework/service/org/moqui/impl/EntityServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n\n    <!-- ==================================================== -->\n    <!-- ========== DataDocument and Feed Services ========== -->\n    <!-- ==================================================== -->\n\n    <!-- org.moqui.impl.EntityServices.index#DataDocuments moved to org.moqui.search.SearchServices.index#DataDocuments -->\n    <!-- org.moqui.impl.EntityServices.put#DataDocumentMappings moved to org.moqui.search.SearchServices.put#DataDocumentMappings -->\n    <!-- org.moqui.impl.EntityServices.index#DataFeedDocuments moved to org.moqui.search.SearchServices.index#DataFeedDocuments -->\n    <!-- org.moqui.impl.EntityServices.search#DataDocuments moved to org.moqui.search.SearchServices.search#DataDocuments -->\n    <!-- org.moqui.impl.EntityServices.search#CountBySource moved to org.moqui.search.SearchServices.search#CountBySource -->\n\n    <service verb=\"get\" noun=\"DataFeedDocuments\">\n        <in-parameters>\n            <parameter name=\"dataFeedId\" required=\"true\"/>\n            <parameter name=\"fromUpdateStamp\" type=\"Timestamp\"/>\n            <parameter name=\"thruUpdateStamp\" type=\"Timestamp\"/>\n        </in-parameters>\n        <out-parameters><parameter name=\"documentList\" type=\"List\"><parameter name=\"document\" type=\"Map\"/></parameter></out-parameters>\n        <actions>\n            <set field=\"documentList\" from=\"ec.entity.getEntityDataFeed().getFeedDocuments(dataFeedId, fromUpdateStamp, thruUpdatedStamp)\"/>\n        </actions>\n    </service>\n    <service verb=\"get\" noun=\"DataFeedLatestDocuments\">\n        <description>This service gets the latest documents for a DataFeed based on DataFeed.lastFeedStamp, and updates\n            lastFeedStamp to the current time.</description>\n        <in-parameters><parameter name=\"dataFeedId\" required=\"true\"/></in-parameters>\n        <out-parameters><parameter name=\"documentList\" type=\"List\"><parameter name=\"document\" type=\"Map\"/></parameter></out-parameters>\n        <actions>\n            <set field=\"documentList\" from=\"ec.entity.getEntityDataFeed().getFeedLatestDocuments(dataFeedId)\"/>\n        </actions>\n    </service>\n\n    <service verb=\"create\" noun=\"DataDocument\">\n        <in-parameters>\n            <auto-parameters include=\"nonpk\"/>\n            <parameter name=\"dataDocumentId\" required=\"true\"><matches regexp=\"[A-Z]\\w*\" message=\"Must start with a upper case letter and contain only letters and digits\"/></parameter>\n            <parameter name=\"indexName\"><matches regexp=\"[a-z][a-z0-9_]*\" message=\"Must contain only lower case letters, digits, and underscore\"/></parameter>\n            <parameter name=\"userGroupId\"/>\n        </in-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.entity.document.DataDocument\" value-field=\"existing\"/>\n            <if condition=\"existing != null || ec.entity.isEntityDefined(dataDocumentId)\"><return error=\"true\" message=\"ID '${dataDocumentId}' already in use\"/></if>\n\n            <service-call name=\"create#moqui.entity.document.DataDocument\" in-map=\"context\" out-map=\"context\"/>\n            <if condition=\"userGroupId\"><service-call name=\"create#moqui.entity.document.DataDocumentUserGroup\"\n                    in-map=\"[dataDocumentId:dataDocumentId, userGroupId:userGroupId]\"/></if>\n        </actions>\n    </service>\n    <service verb=\"update\" noun=\"DataDocument\">\n        <implements service=\"org.moqui.impl.EntityServices.create#DataDocument\"/>\n        <actions>\n            <service-call name=\"update#moqui.entity.document.DataDocument\" in-map=\"context\" out-map=\"context\"/>\n            <if condition=\"userGroupId\">\n                <entity-find entity-name=\"moqui.entity.document.DataDocumentUserGroup\" list=\"ddugList\">\n                    <econdition field-name=\"dataDocumentId\"/></entity-find>\n                <if condition=\"ddugList.size() == 1 &amp;&amp; ddugList[0].userGroupId != userGroupId\">\n                    <service-call name=\"delete#moqui.entity.document.DataDocumentUserGroup\"\n                            in-map=\"[dataDocumentId:dataDocumentId, userGroupId:ddugList[0].userGroupId]\"/>\n                    <service-call name=\"create#moqui.entity.document.DataDocumentUserGroup\"\n                            in-map=\"[dataDocumentId:dataDocumentId, userGroupId:userGroupId]\"/>\n                </if>\n            </if>\n        </actions>\n    </service>\n    <service verb=\"clone\" noun=\"DataDocument\">\n        <in-parameters>\n            <parameter name=\"dataDocumentId\" required=\"true\"/>\n            <parameter name=\"newDataDocumentId\" required=\"true\"><matches regexp=\"[A-Z]\\w*\" message=\"Must start with a upper case letter and contain only letters and digits\"/></parameter>\n            <parameter name=\"newIndexName\"><matches regexp=\"[a-z][a-z0-9_]*\" message=\"Must contain only lower case letters, digits, and underscore\"/></parameter>\n            <parameter name=\"copyConditions\" default=\"false\" type=\"Boolean\"/>\n            <parameter name=\"copyLinks\" default=\"false\" type=\"Boolean\"/>\n        </in-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.entity.document.DataDocument\" value-field=\"newDataDocument\" cache=\"false\">\n                <field-map field-name=\"dataDocumentId\" from=\"newDataDocumentId\"/></entity-find-one>\n            <if condition=\"newDataDocument\">\n                <log level=\"info\" message=\"Not cloning DataDocument with ID [${dataDocumentId}] to new ID [${newDataDocumentId}], already exists\"/>\n                <return/>\n            </if>\n\n            <entity-find-one entity-name=\"moqui.entity.document.DataDocument\" value-field=\"dataDocument\" cache=\"false\"/>\n            <set field=\"dataDocument.dataDocumentId\" from=\"newDataDocumentId\"/>\n            <if condition=\"newIndexName\"><set field=\"dataDocument.indexName\" from=\"newIndexName\"/></if>\n            <entity-create value-field=\"dataDocument\"/>\n\n            <entity-find entity-name=\"moqui.entity.document.DataDocumentField\" list=\"ddfList\">\n                <econdition field-name=\"dataDocumentId\"/></entity-find>\n            <iterate list=\"ddfList\" entry=\"ddf\">\n                <set field=\"ddf.dataDocumentId\" from=\"newDataDocumentId\"/>\n                <entity-create value-field=\"ddf\"/>\n            </iterate>\n\n            <entity-find entity-name=\"moqui.entity.document.DataDocumentRelAlias\" list=\"ddraList\">\n                <econdition field-name=\"dataDocumentId\"/></entity-find>\n            <iterate list=\"ddraList\" entry=\"ddra\">\n                <set field=\"ddra.dataDocumentId\" from=\"newDataDocumentId\"/>\n                <entity-create value-field=\"ddra\"/>\n            </iterate>\n\n            <if condition=\"copyConditions\">\n                <entity-find entity-name=\"moqui.entity.document.DataDocumentCondition\" list=\"ddcList\">\n                    <econdition field-name=\"dataDocumentId\"/></entity-find>\n                <iterate list=\"ddcList\" entry=\"ddc\">\n                    <set field=\"ddc.dataDocumentId\" from=\"newDataDocumentId\"/>\n                    <entity-create value-field=\"ddc\"/>\n                </iterate>\n            </if>\n\n            <if condition=\"copyLinks\">\n                <entity-find entity-name=\"moqui.entity.document.DataDocumentLink\" list=\"ddlList\">\n                    <econdition field-name=\"dataDocumentId\"/></entity-find>\n                <iterate list=\"ddlList\" entry=\"ddl\">\n                    <set field=\"ddl.dataDocumentId\" from=\"newDataDocumentId\"/>\n                    <entity-create value-field=\"ddl\"/>\n                </iterate>\n            </if>\n        </actions>\n    </service>\n    <service verb=\"create\" noun=\"DataDocumentField\">\n        <in-parameters>\n            <parameter name=\"dataDocumentId\" required=\"true\"/>\n            <parameter name=\"fieldPath\" required=\"true\"/>\n            <auto-parameters entity-name=\"moqui.entity.document.DataDocumentField\" include=\"nonpk\"/>\n        </in-parameters>\n        <actions>\n            <if condition=\"fieldNameAlias\"><set field=\"fieldNameAlias\" from=\"prettyToCamelCase(fieldNameAlias, false)\"/></if>\n            <service-call name=\"create#moqui.entity.document.DataDocumentField\" in-map=\"context\"/>\n        </actions>\n    </service>\n    <service verb=\"update\" noun=\"DataDocumentField\">\n        <in-parameters>\n            <parameter name=\"dataDocumentId\" required=\"true\"/>\n            <parameter name=\"fieldSeqId\"/>\n            <parameter name=\"fieldPath\" required=\"true\"/>\n            <auto-parameters entity-name=\"moqui.entity.document.DataDocumentField\" include=\"nonpk\"/>\n        </in-parameters>\n        <actions>\n            <if condition=\"fieldNameAlias\"><set field=\"fieldNameAlias\" from=\"prettyToCamelCase(fieldNameAlias, false)\"/></if>\n            <if condition=\"!fieldSeqId\">\n                <entity-find entity-name=\"moqui.entity.document.DataDocumentField\" list=\"ddfList\">\n                    <econdition field-name=\"dataDocumentId\"/><econdition field-name=\"fieldPath\"/></entity-find>\n                <set field=\"fieldSeqId\" from=\"ddfList ? ddfList[0].fieldSeqId : null\"/>\n            </if>\n            <service-call name=\"update#moqui.entity.document.DataDocumentField\" in-map=\"context\"/>\n        </actions>\n    </service>\n\n    <service verb=\"send\" noun=\"DataDocumentNotifications\" authenticate=\"false\">\n        <description>\n            Send a NotificationMessage for each in documentList. Finds all userId fields nested somewhere in\n            the document. Only sent to users where a userId is found, and only sent if userId values are found.\n\n            The topic will be the dataDocumentId (document._type). If no NotificationTopic record is found for the topic\n            will set the NotificationMessage title to DataDocument.documentTitle and will find the first DataDocumentLink\n            and set NotificationMessage link to its linkUrl.\n        </description>\n        <implements service=\"org.moqui.EntityServices.receive#DataFeed\"/>\n        <actions>\n            <iterate list=\"documentList\" entry=\"document\">\n                <set field=\"userIdSet\" from=\"new HashSet()\"/>\n                <script>findAllFieldsNestedMap(\"userId\", document, userIdSet)</script>\n                <script>findAllFieldsNestedMap(\"fromUserId\", document, userIdSet)</script>\n                <script>findAllFieldsNestedMap(\"toUserId\", document, userIdSet)</script>\n                <if condition=\"!userIdSet\">\n                    <log level=\"info\" message=\"Not sending ${document._type} DataDocument notification, no userIds found\"/>\n                    <continue/>\n                </if>\n\n                <script><![CDATA[\n                    def nm = ec.makeNotificationMessage()\n                    nm.topic((String) document._type).message((Map<String, Object>) document).userIds((Set) userIdSet)\n                    def notificationTopic = nm.getNotificationTopic()\n                    if (notificationTopic == null && document._type) {\n                        def dataDocument = ec.entity.find(\"moqui.entity.document.DataDocument\").condition(\"dataDocumentId\", document._type).useCache(true).one()\n                        if (dataDocument.documentTitle) nm.title(dataDocument.documentTitle)\n                        def dataDocumentLinks = dataDocument.findRelated(\"links\", null, null, true, false)\n                        if (dataDocumentLinks) nm.link(dataDocumentLinks[0].linkUrl)\n                    }\n                    nm.send()\n                ]]></script>\n            </iterate>\n        </actions>\n    </service>\n\n    <!-- 2017-06: clean out DataDocument and DataFeed tables for update/recreate:\n        - DROP TABLE DATA_FEED_DOCUMENT\n        - DROP TABLE DATA_FEED\n\n        - DROP TABLE DATA_DOCUMENT_LINK\n        - DROP TABLE DATA_DOCUMENT_CONDITION\n        - DROP TABLE DATA_DOCUMENT_REL_ALIAS\n        - DROP TABLE DATA_DOCUMENT_FIELD\n        - DROP TABLE DATA_DOCUMENT_USER_GROUP\n        - DROP TABLE DATA_DOCUMENT\n        - restart, load seed, index feeds\n    -->\n    <service verb=\"delete\" noun=\"AllDataDocumentsAndFeeds\">\n        <description>To fully update data documents and feeds from seed data: run this, import 'seed' data, run feed indexes.\n            If any custom data documents (not in seed data) are in place they will be lost.</description>\n        <actions>\n            <entity-delete-by-condition entity-name=\"moqui.entity.feed.DataFeedDocument\"/>\n            <entity-delete-by-condition entity-name=\"moqui.entity.feed.DataFeed\"/>\n\n            <entity-delete-by-condition entity-name=\"moqui.entity.document.DataDocumentUserGroup\"/>\n            <entity-delete-by-condition entity-name=\"moqui.entity.document.DataDocumentLink\"/>\n            <entity-delete-by-condition entity-name=\"moqui.entity.document.DataDocumentCondition\"/>\n            <entity-delete-by-condition entity-name=\"moqui.entity.document.DataDocumentRelAlias\"/>\n            <entity-delete-by-condition entity-name=\"moqui.entity.document.DataDocumentField\"/>\n            <entity-delete-by-condition entity-name=\"moqui.entity.document.DataDocument\"/>\n        </actions>\n    </service>\n\n    <!-- =========================================== -->\n    <!-- ========== DbViewEntity Services ========== -->\n    <!-- =========================================== -->\n\n    <service verb=\"create\" noun=\"DbViewEntity\">\n        <in-parameters>\n            <auto-parameters include=\"nonpk\"/>\n            <parameter name=\"dbViewEntityName\" required=\"true\"><matches regexp=\"[A-Z]\\w*\" message=\"Must start with a upper case letter and contain only letters and digits\"/></parameter>\n            <parameter name=\"packageName\"><matches regexp=\"[a-z][a-z0-9\\.]*\" message=\"Must start with a lower case letter and contain only lower case letters, digits, and dot/period\"/></parameter>\n        </in-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.entity.view.DbViewEntity\" value-field=\"existing\"/>\n            <if condition=\"existing || ec.entity.isEntityDefined(dbViewEntityName)\"><return error=\"true\" message=\"Name '${dbViewEntityName}' already in use\"/></if>\n\n            <service-call name=\"create#moqui.entity.view.DbViewEntity\" in-map=\"context\" out-map=\"context\"/>\n        </actions>\n    </service>\n\n    <!-- ==================================================== -->\n    <!-- ========== Entity Data Snapshots Services ========== -->\n    <!-- ==================================================== -->\n\n    <service verb=\"export\" noun=\"EntityDataSnapshot\" transaction-timeout=\"3600\">\n        <in-parameters>\n            <parameter name=\"fromDate\" type=\"Timestamp\"/>\n            <parameter name=\"thruDate\" type=\"Timestamp\"/>\n            <parameter name=\"entitiesToInclude\" type=\"List\"><parameter name=\"entityName\"/></parameter>\n            <parameter name=\"entitiesToSkip\" type=\"List\"><parameter name=\"entityName\"/></parameter>\n            <parameter name=\"baseFilename\"/>\n            <parameter name=\"fileType\" default-value=\"XML\"/>\n            <parameter name=\"filePerEntity\" type=\"Boolean\" default=\"true\"/>\n            <parameter name=\"isoDateTime\" type=\"Boolean\" default=\"false\"/>\n            <parameter name=\"tableColumnNames\" type=\"Boolean\" default=\"false\"/>\n        </in-parameters>\n        <out-parameters><parameter name=\"recordsWritten\" type=\"Integer\"/></out-parameters>\n        <actions>\n            <script><![CDATA[\n                import org.moqui.entity.EntityDataWriter\n                import org.moqui.context.ExecutionContext\n                ExecutionContext ec = context.ec\n                EntityDataWriter edw = ec.entity.makeDataWriter()\n\n                if (fromDate) edw.fromDate(ec.l10n.parseTimestamp(fromDate, null))\n                if (thruDate) edw.thruDate(ec.l10n.parseTimestamp(thruDate, null))\n                if (entitiesToInclude) {\n                    edw.entityNames(entitiesToInclude)\n                } else {\n                    edw.allEntities()\n                }\n                if (entitiesToSkip) edw.skipEntityNames(entitiesToSkip)\n\n                edw.fileType((String) fileType)\n                edw.isoDateTime(isoDateTime)\n                edw.tableColumnNames(tableColumnNames)\n\n                String baseName = baseFilename\n                if (!baseName) baseName = \"MoquiSnapshot-${ec.l10n.format(ec.user.nowTimestamp, 'yyyyMMdd-HHmm')}\"\n\n                if (\"CSV\".equals(fileType)) filePerEntity = true\n\n                if (filePerEntity) {\n                    recordsWritten = edw.zipDirectory(baseName, ec.factory.getRuntimePath() + \"/db/snapshot/\" + baseName + \".zip\")\n                } else {\n                    recordsWritten = edw.zipFile(baseName + \".\" + fileType.toLowerCase(), ec.factory.getRuntimePath() + \"/db/snapshot/\" + baseName + \".zip\")\n                }\n            ]]></script>\n        </actions>\n    </service>\n    <service verb=\"import\" noun=\"EntityDataSnapshot\" transaction-timeout=\"3600\">\n        <in-parameters>\n            <parameter name=\"zipFilename\" required=\"true\"/>\n            <parameter name=\"dummyFks\" type=\"Boolean\" default=\"false\"/>\n            <parameter name=\"useTryInsert\" type=\"Boolean\" default=\"false\"/>\n            <parameter name=\"disableEntityEca\" type=\"Boolean\" default=\"true\"/>\n            <parameter name=\"disableAuditLog\" type=\"Boolean\" default=\"true\"/>\n            <parameter name=\"disableFkCreate\" type=\"Boolean\" default=\"true\"/>\n            <parameter name=\"disableDataFeed\" type=\"Boolean\" default=\"true\"/>\n            <parameter name=\"transactionTimeout\" type=\"Integer\" default=\"3600\"/>\n        </in-parameters>\n        <out-parameters><parameter name=\"recordsLoaded\" type=\"Long\"/></out-parameters>\n        <actions>\n            <script><![CDATA[\n                import org.moqui.entity.EntityDataLoader\n                import org.moqui.context.ExecutionContext\n                ExecutionContext ec = context.ec\n                EntityDataLoader edl = ec.entity.makeDataLoader().dummyFks(dummyFks).useTryInsert(useTryInsert)\n                        .disableEntityEca(disableEntityEca).disableAuditLog(disableAuditLog)\n                        .disableFkCreate(disableFkCreate).disableDataFeed(disableDataFeed)\n                        .transactionTimeout(transactionTimeout)\n                edl.location(ec.factory.getRuntimePath() + \"/db/snapshot/\" + (String) zipFilename)\n                recordsLoaded = edl.load()\n                ec.cache.getCache(\"entity.sequence.bank\")?.clear()\n            ]]></script>\n        </actions>\n    </service>\n\n    <!-- ================================================ -->\n    <!-- ========== Entity Re-Encrypt Services ========== -->\n    <!-- ================================================ -->\n\n    <service verb=\"rewrite\" noun=\"EntityEncryptedFieldsAll\" authenticate=\"false\" transaction=\"ignore\">\n        <in-parameters>\n            <parameter name=\"entityNameRegex\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"entitiesUpdated\" type=\"Long\"/>\n            <parameter name=\"recordsUpdated\" type=\"Long\"/>\n        </out-parameters>\n        <actions>\n            <set field=\"entityNamePattern\" from=\"entityNameRegex ? java.util.regex.Pattern.compile(entityNameRegex, (java.util.regex.Pattern.CASE_INSENSITIVE | java.util.regex.Pattern.DOTALL)) : null\"/>\n            <set field=\"entitiesUpdated\" from=\"0L\"/>\n            <set field=\"recordsUpdated\" from=\"0L\"/>\n            <iterate list=\"ec.entity.getAllEntityNames()\" entry=\"entityName\">\n                <if condition=\"entityNamePattern != null &amp;&amp; !entityNamePattern.matcher(entityName).matches()\"><continue/></if>\n\n                <set field=\"entityDef\" from=\"ec.entity.getEntityDefinition(entityName)\"/>\n                <if condition=\"entityDef.isViewEntity\"><continue/></if>\n                <if condition=\"entityDef.entityInfo.needsEncrypt\">\n                    <log message=\"Starting encrypted fields rewrite for entity ${entityName}\"/>\n                    <service-call name=\"org.moqui.impl.EntityServices.rewrite#EntityEncryptedFields\" transaction=\"force-new\"\n                            out-map=\"rewriteOut\" out-map-add-to-existing=\"false\" in-map=\"[entityName:entityName]\"/>\n                    <log message=\"Finished encrypted fields rewrite for entity ${entityName}, updated ${rewriteOut.recordsUpdated} records\"/>\n                    <set field=\"entitiesUpdated\" from=\"entitiesUpdated + 1L\"/>\n                    <set field=\"recordsUpdated\" from=\"recordsUpdated + (rewriteOut.recordsUpdated ?: 0L)\"/>\n                </if>\n            </iterate>\n        </actions>\n    </service>\n    <service verb=\"rewrite\" noun=\"EntityEncryptedFields\" authenticate=\"anonymous-all\" transaction-timeout=\"900\">\n        <in-parameters>\n            <parameter name=\"entityName\" required=\"true\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"recordsUpdated\" type=\"Long\"/>\n        </out-parameters>\n        <actions><script><![CDATA[\n            import org.moqui.context.ExecutionContext\n            import org.moqui.entity.EntityCondition\n            import org.moqui.entity.EntityValue\n            import org.moqui.impl.entity.EntityDefinition\n            import java.sql.ResultSet\n\n            ExecutionContext ec = context.ec\n            EntityDefinition entityDef = ec.entity.getEntityDefinition(entityName)\n            def pkFieldNames = entityDef.getPkFieldNames()\n            def encryptFieldNames = entityDef.allFieldInfoList.findAll({ it.encrypt }).collect({ it.name })\n            def encryptFieldNamesSize = encryptFieldNames.size()\n\n            if (encryptFieldNamesSize == 0) {\n                ec.logger.warn(\"Not rewriting encrypted fields for entity ${entityName}, has no encrypted fields\")\n                return\n            }\n\n            def condFactory = ec.entity.getConditionFactory()\n            def notNullCondList = new ArrayList(encryptFieldNames.size())\n            for (String fieldName in encryptFieldNames) notNullCondList.add(condFactory.makeCondition(fieldName, EntityCondition.IS_NOT_NULL, null))\n\n            recordsUpdated = 0L\n            def recordIterator = ec.entity.find(entityDef.fullEntityName).selectFields(pkFieldNames).selectFields(encryptFieldNames)\n                    .condition(condFactory.makeCondition(notNullCondList, EntityCondition.OR))\n                    .resultSetConcurrency(ResultSet.CONCUR_UPDATABLE).iterator()\n            try {\n                EntityValue curValue\n                while ((curValue = recordIterator.next()) != null) {\n                    boolean foundFail = false\n                    boolean foundNonNull = false\n                    for (int i = 0; i < encryptFieldNamesSize; i++) {\n                        def fieldName = encryptFieldNames.get(i)\n                        def fieldValue = curValue.get(fieldName)\n                        if (fieldValue == null) continue\n\n                        if (org.moqui.impl.entity.FieldInfo.decryptFailedMagicString.equals(fieldValue)) {\n                            ec.logger.error(\"rewrite#EntityEncryptedFields decrypt failed for ${entityName} pk ${curValue.getPrimaryKeysString()} field ${fieldName}, skipping record\")\n                            foundFail = true\n                            break\n                        }\n                        foundNonNull = true\n                        curValue.touchField(fieldName)\n                    }\n                    if (foundNonNull && !foundFail) {\n                        // ec.logger.info(\"Rewriting encrypted fields for entity ${entityName} PK ${curValue.getPrimaryKeysString()}\")\n                        curValue.update()\n                        recordsUpdated += 1L\n                    }\n                }\n            } finally {\n                recordIterator.close()\n            }\n        ]]></script></actions>\n    </service>\n</services>\n"
  },
  {
    "path": "framework/service/org/moqui/impl/EntitySyncServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n    <service verb=\"run\" noun=\"EntitySyncAll\" transaction=\"ignore\" authenticate=\"anonymous-all\">\n        <actions>\n            <entity-find entity-name=\"moqui.entity.sync.EntitySync\" list=\"entitySyncList\">\n                <econdition field-name=\"statusId\" operator=\"not-equals\" value=\"EsRunning\"/></entity-find>\n            <iterate list=\"entitySyncList\" entry=\"entitySync\">\n                <!-- NOTE: currently calls async, consider transaction=\"force-new\" if there are multiple large syncs to\n                    avoid running out of memory, etc -->\n                <service-call name=\"org.moqui.impl.EntitySyncServices.run#EntitySync\"\n                        in-map=\"[entitySyncId:entitySync.entitySyncId]\" async=\"true\"/>\n            </iterate>\n        </actions>\n    </service>\n    <service verb=\"run\" noun=\"EntitySync\" authenticate=\"anonymous-all\">\n        <in-parameters><parameter name=\"entitySyncId\" required=\"true\"/></in-parameters>\n        <actions>\n            <set field=\"startDate\" from=\"new Timestamp(System.currentTimeMillis())\"/>\n\n            <!-- lock the EntitySync record, only one sync at a time -->\n            <entity-find-one entity-name=\"moqui.entity.sync.EntitySync\" value-field=\"entitySync\" for-update=\"true\"/>\n            <!-- if already running quit, unless lastStartDate was more than 24 hours ago -->\n            <if condition=\"entitySync.statusId == 'EsRunning' &amp;&amp;\n                    entitySync.lastStartDate.getTime() &gt; (System.currentTimeMillis() - (24*60*60*1000))\">\n                <return/></if>\n\n            <!-- make sure delayBufferMillis have passed since lastSuccessfulSyncTime -->\n            <if condition=\"entitySync.lastSuccessfulSyncTime &amp;&amp;\n                    entitySync.lastSuccessfulSyncTime.getTime() &gt; (System.currentTimeMillis() - (entitySync.delayBufferMillis ?: 300000))\">\n                <return/></if>\n\n            <!-- validations done, save that we're running and get started! -->\n            <set field=\"entitySync.lastStartDate\" from=\"startDate\"/>\n            <set field=\"entitySync.statusId\" value=\"EsRunning\"/>\n            <entity-update value-field=\"entitySync\"/>\n\n            <!-- the main stuff should be done in a separate transaction so it is independent of updating status, etc -->\n            <service-call name=\"org.moqui.impl.EntitySyncServices.internalRun#EntitySync\" out-map=\"context\" in-map=\"context\"/>\n\n            <!-- save results, update status -->\n            <if condition=\"errorMessage\">\n                <set field=\"entitySync.statusId\" value=\"EsOtherError\"/>\n\n                <else>\n                    <set field=\"entitySync.statusId\" value=\"EsComplete\"/>\n                    <set field=\"entitySync.lastSuccessfulSyncTime\" from=\"inclusiveThruTime\"/>\n                </else>\n            </if>\n            <entity-update value-field=\"entitySync\"/>\n\n            <set field=\"finishLong\" from=\"System.currentTimeMillis()\"/>\n            <set field=\"runningTimeMillis\" from=\"finishLong - startDate.getTime()\"/>\n            <service-call name=\"create#moqui.entity.sync.EntitySyncHistory\"\n                    in-map=\"[entitySyncId:entitySyncId, statusId:entitySync.statusId, startDate:startDate,\n                        finishDate:new Timestamp(finishLong), exclusiveFromTime:exclusiveFromTime,\n                        inclusiveThruTime:inclusiveThruTime, recordsStored:recordsStored,\n                        runningTimeMillis:runningTimeMillis, errorMessage:errorMessage]\"/>\n\n            <log level=\"info\" message=\"EntitySync [${entitySyncId}] finished: startDate=${startDate}, lastSuccessfulSyncTime=${entitySync.lastSuccessfulSyncTime}, recordsStored=${recordsStored}, errorMessage=${errorMessage}\"/>\n\n            <!-- if not yet up to delayBufferMillis trigger another async run -->\n            <if condition=\"entitySync.lastSuccessfulSyncTime &amp;&amp;\n                    entitySync.lastSuccessfulSyncTime.getTime() &lt; (System.currentTimeMillis() - (entitySync.delayBufferMillis ?: 300000))\">\n                <service-call name=\"org.moqui.impl.EntitySyncServices.run#EntitySync\"\n                        in-map=\"[entitySyncId:entitySyncId]\" async=\"true\"/>\n            </if>\n        </actions>\n    </service>\n    <service verb=\"internalRun\" noun=\"EntitySync\" transaction=\"force-new\">\n        <in-parameters>\n            <parameter name=\"entitySync\" type=\"EntityValue\" required=\"true\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"errorMessage\"/>\n            <parameter name=\"exclusiveFromTime\" type=\"Timestamp\"/>\n            <parameter name=\"inclusiveThruTime\" type=\"Timestamp\"/>\n            <parameter name=\"recordsStored\" type=\"Long\"/>\n        </out-parameters>\n        <actions>\n            <set field=\"entitySyncId\" from=\"entitySync.entitySyncId\"/>\n\n            <set field=\"remoteInMap\" from=\"[authUsername:entitySync.targetUsername, authPassword:entitySync.targetPassword]\"/>\n\n            <service-call name=\"org.moqui.impl.EntitySyncServices.get#EntitySyncIncludeList\" out-map=\"context\" in-map=\"context\"/>\n            <set field=\"getInMap\" from=\"[entityIncludeList:entityIncludeList, lastSuccessfulSyncTime:entitySync.lastSuccessfulSyncTime,\n                    syncSplitMillis:entitySync.syncSplitMillis, recordThreshold:entitySync.recordThreshold,\n                    delayBufferMillis:entitySync.delayBufferMillis]\"/>\n\n            <if condition=\"entitySync.forPull == 'Y'\">\n                <!-- get remotely, set locally -->\n                <!-- remote call -->\n                <set field=\"inMap\" from=\"remoteInMap + getInMap\"/>\n                <set field=\"serviceName\" value=\"org.moqui.impl.EntitySyncServices.get#EntitySyncData\"/>\n                <script>\n                    Map outMap = ec.service.callJsonRpc(entitySync.targetServerUrl, serviceName, inMap)\n                    if (outMap) context.putAll(outMap)\n                </script>\n\n                <if condition=\"recordCount\">\n                    <service-call name=\"org.moqui.impl.EntitySyncServices.put#EntitySyncData\" out-map=\"context\"\n                            in-map=\"[entityData:entityData]\"/>\n\n                    <else>\n                        <!-- no records to store, don't do the remote call -->\n                        <set field=\"recordsStored\" from=\"0\"/>\n                    </else>\n                </if>\n\n                <else>\n                    <!-- get locally, set remotely -->\n                    <service-call name=\"org.moqui.impl.EntitySyncServices.get#EntitySyncData\" out-map=\"context\"\n                            in-map=\"getInMap\"/>\n\n                    <!-- remote call -->\n                    <if condition=\"recordCount\">\n                        <set field=\"inMap\" from=\"remoteInMap + [entityData:entityData]\"/>\n                        <set field=\"serviceName\" value=\"org.moqui.impl.EntitySyncServices.put#EntitySyncData\"/>\n                        <!-- <log level=\"warn\" message=\"======= internalRun#EntitySync remote call inMap=${inMap}\"/> -->\n                        <script>\n                            Map outMap = ec.service.callJsonRpc(entitySync.targetServerUrl, serviceName, inMap)\n                            if (outMap) context.putAll(outMap)\n                        </script>\n\n                        <else>\n                            <!-- no records to store, don't do the remote call -->\n                            <set field=\"recordsStored\" from=\"0\"/>\n                        </else>\n                    </if>\n                </else>\n            </if>\n\n            <if condition=\"ec.message.hasError()\">\n                <set field=\"errorMessage\" from=\"ec.message.getErrorsString()\"/>\n                <script>ec.message.clearErrors()</script>\n            </if>\n        </actions>\n    </service>\n\n    <service verb=\"put\" noun=\"EntitySyncData\" transaction=\"ignore\" allow-remote=\"true\">\n        <in-parameters>\n            <parameter name=\"entityData\" required=\"true\" allow-html=\"any\"/><!-- this is XML coming in, don't validate as HTML -->\n            <parameter name=\"timeout\" type=\"Integer\" default=\"600\"/>\n            <parameter name=\"dummyFks\" type=\"Boolean\" default=\"true\"/>\n            <parameter name=\"useTryInsert\" type=\"Boolean\" default=\"false\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"recordsStored\" type=\"Long\"/>\n        </out-parameters>\n        <actions>\n            <script><![CDATA[\n                org.moqui.entity.EntityDataLoader edl = ec.entity.makeDataLoader()\n                edl.xmlText(entityData)\n                edl.transactionTimeout(timeout)\n                edl.dummyFks(dummyFks)\n                edl.useTryInsert(useTryInsert)\n                recordsStored = edl.load()\n            ]]></script>\n        </actions>\n    </service>\n    <service verb=\"get\" noun=\"EntitySyncData\" allow-remote=\"true\">\n        <in-parameters>\n            <parameter name=\"entityIncludeList\" type=\"List\">\n                <parameter name=\"entryMap\" type=\"Map\">\n                    <parameter name=\"entityName\"/>\n                    <parameter name=\"includeFilterList\" type=\"List\">\n                        <description>List of Maps to be ORed together</description>\n                        <parameter name=\"filterMap\" type=\"Map\"/>\n                    </parameter>\n                    <parameter name=\"dependents\" type=\"Boolean\"/>\n                </parameter>\n            </parameter>\n            <parameter name=\"lastSuccessfulSyncTime\" type=\"Timestamp\"/>\n            <parameter name=\"syncSplitMillis\" type=\"Long\" default=\"1000\"/>\n            <parameter name=\"recordThreshold\" type=\"Long\" default=\"1000\"/>\n            <parameter name=\"delayBufferMillis\" type=\"Long\" default=\"300000\"/><!-- default to 5 minutes -->\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"entityData\"/>\n            <parameter name=\"recordCount\" type=\"Long\"/>\n            <parameter name=\"exclusiveFromTime\" type=\"Timestamp\"/>\n            <parameter name=\"inclusiveThruTime\" type=\"Timestamp\"/>\n        </out-parameters>\n        <actions>\n            <script><![CDATA[\n                import org.moqui.context.ExecutionContext\n                import org.moqui.entity.EntityCondition\n                import org.moqui.entity.EntityFind\n                import org.moqui.entity.EntityList\n                import org.moqui.entity.EntityListIterator\n\n                // TODO: current approach gets data for all time up to now - delayBufferMillis if no lastSuccessfulSyncTime\n                // TODO:    for bootstrapping, consider a better approach, maybe find oldest record and do splits from\n                // TODO:    there to avoid a huge chunk of data? issue: start too early do a LOT of empty splits\n                long maxSyncLong = System.currentTimeMillis() - delayBufferMillis\n                long startingLong = lastSuccessfulSyncTime?.getTime() ?: 0\n                long lastSyncLong = startingLong\n                long splitFromLong = lastSyncLong\n                long splitThruLong = lastSuccessfulSyncTime ? (splitFromLong + syncSplitMillis) : maxSyncLong\n\n                Writer entityWriter = new StringWriter()\n                entityWriter.append(\"<entity-facade-xml>\")\n\n                ExecutionContext ec = context.ec\n\n                recordCount = 0\n                // ec.logger.warn(\"======== recordThreshold=${recordThreshold}, splitThruLong=${splitThruLong}, maxSyncLong=${maxSyncLong}, beforeMaxSync=${splitThruLong <= maxSyncLong}, entityIncludeList=${entityIncludeList}\")\n                while (recordCount < recordThreshold && splitThruLong <= maxSyncLong) {\n                    for (Map entryMap in entityIncludeList) {\n                        EntityFind find = ec.entity.find((String) entryMap.entityName)\n                        find.condition(\"lastUpdatedStamp\", EntityCondition.GREATER_THAN, new Timestamp(splitFromLong))\n                        find.condition(\"lastUpdatedStamp\", EntityCondition.LESS_THAN_EQUAL_TO, new Timestamp(splitThruLong))\n\n                        List includeCondList = []\n                        for (Map filterMap in includeFilterList)\n                            includeCondList.add(ec.entity.conditionFactory.makeCondition(filterMap))\n                        if (includeCondList)\n                            find.condition(ec.entity.conditionFactory.makeCondition(includeCondList, EntityCondition.OR))\n\n                        long currentCount = find.count()\n                        // TODO: see if currentCount is way too big and abort in advance?\n                        recordCount += currentCount\n\n                        // ec.logger.warn(\"=========== get#EntitySyncData entityName=${entryMap.entityName} count=${currentCount} find=${find}\")\n\n                        if (currentCount > 0) {\n                            find.iterator().withCloseable ({resultEli ->\n                                int levels = entryMap.dependents ? 2 : 0\n                                resultEli.writeXmlText((Writer) entityWriter, null, levels)\n                            })\n                        }\n                    }\n\n                    // increment the split times\n                    splitFromLong = splitThruLong\n                    splitThruLong = splitFromLong + syncSplitMillis\n                }\n\n                entityWriter.append(\"</entity-facade-xml>\")\n                entityData = entityWriter.toString()\n\n                exclusiveFromTime = new Timestamp(startingLong)\n                inclusiveThruTime = new Timestamp(splitThruLong)\n            ]]></script>\n        </actions>\n    </service>\n\n    <service verb=\"get\" noun=\"EntitySyncIncludeList\">\n        <in-parameters><parameter name=\"entitySyncId\" required=\"true\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"entityIncludeList\" type=\"List\">\n                <parameter name=\"entryMap\" type=\"Map\">\n                    <parameter name=\"entityName\"/>\n                    <parameter name=\"includeFilterList\" type=\"List\">\n                        <description>List of Maps to be ORed together</description>\n                        <parameter name=\"filterMap\" type=\"Map\"/>\n                    </parameter>\n                    <parameter name=\"dependents\" type=\"Boolean\"/>\n                </parameter>\n            </parameter>\n        </out-parameters>\n        <actions>\n            <entity-find entity-name=\"moqui.entity.sync.EntitySyncArtifactDetail\" list=\"esadList\">\n                <econdition field-name=\"artifactTypeEnumId\" value=\"AT_ENTITY\"/>\n                <econdition field-name=\"entitySyncId\"/></entity-find>\n            <!-- <log level=\"warn\" message=\"====== EntitySyncIncludeList esadList=${esadList}\"/> -->\n\n            <!-- Maps with entity name as key, value as List or filter Maps (or empty List for no filter) -->\n            <set field=\"includeMap\" from=\"new HashMap()\"/>\n            <set field=\"excludeMap\" from=\"new HashMap()\"/>\n            <set field=\"alwaysMap\" from=\"new HashMap()\"/>\n            <set field=\"withDependentsSet\" from=\"new HashSet()\"/>\n            <!-- get this once, iterate as needed for nameIsPattern -->\n            <set field=\"allEntitySet\" from=\"ec.entity.getAllNonViewEntityNames()\"/>\n            <iterate list=\"esadList\" entry=\"esad\">\n                <set field=\"nameSet\" from=\"new HashSet()\"/>\n                <if condition=\"esad.nameIsPattern == 'Y'\">\n                    <iterate list=\"allEntitySet\" entry=\"entityName\"><if condition=\"entityName.matches(esad.artifactName)\">\n                        <script>nameSet.add(entityName)</script></if></iterate>\n                    <else><script>nameSet.add(esad.artifactName)</script></else>\n                </if>\n                <!-- <log level=\"warn\" message=\"======== nameSet=${nameSet}, esad=${esad}\"/> -->\n\n                <iterate list=\"nameSet\" entry=\"entityName\">\n                    <!-- add to Map based on applEnumId of EsaaInclude, EsaaExclude, EsaaAlways -->\n                    <set field=\"curMap\" from=\"esad.applEnumId == 'EsaaExclude' ? excludeMap : (esad.applEnumId == 'EsaaAlways' ? alwaysMap : includeMap)\"/>\n                    <set field=\"curMapList\" from=\"curMap.get(entityName) ?: []\"/>\n                    <if condition=\"esad.filterMap\">\n                        <script>curMapList.add(ec.resource.expression(esad.filterMap, null))</script></if>\n                    <script>curMap.put(entityName, curMapList)</script>\n                    <script>if (esad.dependents == 'Y') withDependentsSet.add(entityName)</script>\n                </iterate>\n            </iterate>\n\n            <!-- <log level=\"warn\" message=\"========= PRE includeMap=${includeMap}, excludeMap=${excludeMap}, alwaysMap=${alwaysMap}\"/> -->\n            <!-- remove excludeMap entries from includeMap -->\n            <iterate list=\"excludeMap\" entry=\"curMapList\" key=\"entityName\">\n                <script>includeMap.remove(entityName)</script></iterate>\n            <!-- now add always entries to the includeMap and use it to create the entityIncludeList -->\n            <iterate list=\"alwaysMap\" entry=\"curMapList\" key=\"entityName\">\n                <script>\n                    if (includeMap.containsKey(entityName)) {\n                        List incMapList = includeMap.get(entityName)\n                        incMapList.addAll(curMapList)\n                    } else {\n                        includeMap.put(entityName, curMapList)\n                    }\n                </script>\n            </iterate>\n            <!-- <log level=\"warn\" message=\"========= POST includeMap=${includeMap}\"/> -->\n\n\n            <set field=\"entityIncludeList\" from=\"[]\"/>\n            <iterate list=\"includeMap\" entry=\"incMapList\" key=\"entityName\">\n                <script>entityIncludeList.add([entityName:entityName, includeFilterList:incMapList,\n                                               dependents:(withDependentsSet.contains(entityName) ? 'Y' : 'N')])</script>\n            </iterate>\n            <!-- <log level=\"warn\" message=\"====== EntitySyncIncludeList entityIncludeList=${entityIncludeList}\"/> -->\n        </actions>\n    </service>\n</services>\n"
  },
  {
    "path": "framework/service/org/moqui/impl/GoogleServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n\n    <service verb=\"verify\" noun=\"ReCaptcha\">\n        <!-- for documentation see: https://developers.google.com/recaptcha/docs/verify -->\n        <in-parameters>\n            <parameter name=\"reCaptchaToken\"/>\n            <parameter name=\"reCaptchaSecret\" default=\"ec.user.getPreference('reCAPTCHA.secret.key')\"/>\n        </in-parameters>\n        <actions>\n            <if condition=\"!reCaptchaSecret\">\n                <log level=\"warn\" message=\"In verify#ReCaptcha no secret key found, not verifying\"/>\n                <return/>\n            </if>\n            <if condition=\"!reCaptchaToken\">\n                <message public=\"true\" type=\"warning\">reCAPTCHA token required, confirm you are not a robot</message>\n                <return error=\"true\" message=\"No reCAPTCHA token\"/>\n            </if>\n            <script><![CDATA[\n                org.moqui.util.RestClient restClient = ec.service.rest().method(org.moqui.util.RestClient.POST)\n                        .uri(\"https://www.google.com/recaptcha/api/siteverify\").addHeader(\"Content-Type\", \"application/json\")\n                        .addBodyParameter(\"secret\", reCaptchaSecret).addBodyParameter(\"response\", reCaptchaToken)\n                org.moqui.util.RestClient.RestResponse restResponse = restClient.call()\n                Map respMap = (Map) restResponse.jsonObject()\n                if (restResponse.statusCode < 200 || restResponse.statusCode >= 300 || !respMap?.success) {\n                    ec.logger.warn(\"Unsuccessful reCAPTCHA verify: ${respMap}\")\n                    ec.message.addPublic(\"Could not validate reCAPTCHA response, confirm you are not a robot\", \"warning\")\n                    ec.message.addError(\"Could not verify reCAPTCHA token\")\n                    return\n                }\n                ec.logger.info(\"Successful reCAPTCHA verify: ${respMap}\")\n            ]]></script>\n        </actions>\n    </service>\n</services>\n"
  },
  {
    "path": "framework/service/org/moqui/impl/InstanceServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n\n    <!-- ========== Instance Interfaces ========== -->\n\n    <service verb=\"init\" noun=\"Instance\" type=\"interface\">\n        <in-parameters><parameter name=\"appInstanceId\" required=\"true\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"connectSuccess\" type=\"Boolean\"/>\n            <parameter name=\"instanceCreated\" type=\"Boolean\"/>\n            <parameter name=\"instanceExists\" type=\"Boolean\"/>\n            <parameter name=\"instanceUuid\"/>\n            <parameter name=\"warnings\"/>\n        </out-parameters>\n    </service>\n    <service verb=\"start\" noun=\"Instance\" type=\"interface\">\n        <in-parameters><parameter name=\"appInstanceId\" required=\"true\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"instanceStarted\" type=\"Boolean\"/>\n            <parameter name=\"instanceRunning\" type=\"Boolean\"/>\n        </out-parameters>\n    </service>\n    <service verb=\"stop\" noun=\"Instance\" type=\"interface\">\n        <in-parameters><parameter name=\"appInstanceId\" required=\"true\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"instanceStopped\" type=\"Boolean\"/>\n            <parameter name=\"instanceRunning\" type=\"Boolean\"/>\n        </out-parameters>\n    </service>\n    <service verb=\"remove\" noun=\"Instance\" type=\"interface\">\n        <in-parameters><parameter name=\"appInstanceId\" required=\"true\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"instanceRemoved\" type=\"Boolean\"/>\n            <parameter name=\"instanceExists\" type=\"Boolean\"/>\n        </out-parameters>\n    </service>\n    <service verb=\"check\" noun=\"Instance\" type=\"interface\">\n        <in-parameters><parameter name=\"appInstanceId\" required=\"true\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"connectSuccess\" type=\"Boolean\"/>\n            <parameter name=\"instanceExists\" type=\"Boolean\"/>\n            <parameter name=\"instanceRunning\" type=\"Boolean\"/>\n            <parameter name=\"statusString\"/>\n            <parameter name=\"errorString\"/>\n            <parameter name=\"startedAt\" type=\"Timestamp\"/>\n            <parameter name=\"finishedAt\" type=\"Timestamp\"/>\n            <parameter name=\"inspectMap\" type=\"Map\"/>\n            <parameter name=\"instanceLog\"/>\n        </out-parameters>\n    </service>\n\n    <!-- ========== Database Interfaces ========== -->\n\n    <service verb=\"create\" noun=\"Database\" type=\"interface\">\n        <in-parameters><parameter name=\"appInstanceId\" required=\"true\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"databaseCreated\" type=\"Boolean\"/>\n            <parameter name=\"userCreated\" type=\"Boolean\"/>\n        </out-parameters>\n    </service>\n    <service verb=\"check\" noun=\"Database\" type=\"interface\">\n        <in-parameters><parameter name=\"appInstanceId\" required=\"true\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"dbConnectSuccess\" type=\"Boolean\"/>\n            <parameter name=\"databaseExists\" type=\"Boolean\"/>\n            <parameter name=\"dbUserExists\" type=\"Boolean\"/>\n        </out-parameters>\n    </service>\n\n    <!-- ========== AppInstance Management Services ========== -->\n\n    <service verb=\"create\" noun=\"AppInstance\">\n        <in-parameters>\n            <parameter name=\"instanceImageId\" required=\"true\"/>\n            <parameter name=\"instanceHostId\"/>\n            <parameter name=\"databaseHostId\"/>\n            <parameter name=\"hostName\" required=\"true\"/>\n            <parameter name=\"instanceName\" default=\"hostName.replace('.', '_')\"/>\n        </in-parameters>\n        <out-parameters><parameter name=\"appInstanceId\"/></out-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.server.instance.InstanceImage\" value-field=\"instanceImage\"/>\n            <if condition=\"instanceImage == null\"><return error=\"true\" message=\"InstanceImage not found with ID ${instanceImageId}\"/></if>\n            <set field=\"imageType\" from=\"instanceImage.imageType\"/>\n            <if condition=\"imageType == null\"><return error=\"true\" message=\"InstanceImageType not found for InstanceImage with ID ${instanceImageId}\"/></if>\n\n            <set field=\"initCommand\" from=\"imageType.defaultInitCommand\"/>\n            <set field=\"networkMode\" from=\"imageType.defaultNetworkMode\"/>\n\n            <service-call name=\"create#moqui.server.instance.AppInstance\" in-map=\"context\" out-map=\"context\"/>\n            <entity-find-one entity-name=\"moqui.server.instance.AppInstance\" value-field=\"appInstance\"/>\n\n            <set field=\"imageTypeEnvList\" from=\"imageType.envs\"/>\n            <iterate list=\"imageTypeEnvList\" entry=\"imageTypeEnv\">\n                <set field=\"envValue\" from=\"ec.resource.expand(imageTypeEnv.envValue, '')\"/>\n                <service-call name=\"create#moqui.server.instance.AppInstanceEnv\"\n                        in-map=\"[appInstanceId:appInstanceId, envName:imageTypeEnv.envName, envValue:envValue]\"/>\n            </iterate>\n            <set field=\"imageTypeLinkList\" from=\"imageType.links\"/>\n            <iterate list=\"imageTypeLinkList\" entry=\"imageTypeLink\">\n                <service-call name=\"create#moqui.server.instance.AppInstanceLink\"\n                        in-map=\"[appInstanceId:appInstanceId, instanceName:imageTypeLink.instanceName, aliasName:imageTypeLink.aliasName]\"/>\n            </iterate>\n            <set field=\"imageTypeVolumeList\" from=\"imageType.vols\"/>\n            <iterate list=\"imageTypeVolumeList\" entry=\"imageTypeVolume\">\n                <set field=\"volumeName\" from=\"ec.resource.expand(imageTypeVolume.volumeName, '')\"/>\n                <service-call name=\"create#moqui.server.instance.AppInstanceVolume\"\n                        in-map=\"[appInstanceId:appInstanceId, mountPoint:imageTypeVolume.mountPoint, volumeName:volumeName]\"/>\n            </iterate>\n            <set field=\"imageTypeHostConfigList\" from=\"imageType.hostConfigs\"/>\n            <iterate list=\"imageTypeHostConfigList\" entry=\"imageTypeHostConfig\">\n                <set field=\"hostConfigValue\" from=\"ec.resource.expand(imageTypeHostConfig.hostConfigValue, '')\"/>\n                <service-call name=\"create#moqui.server.instance.AppInstanceHostConfig\"\n                        in-map=\"[appInstanceId:appInstanceId, hostConfigName:imageTypeHostConfig.hostConfigName, hostConfigValue:hostConfigValue, type:imageTypeHostConfig.type]\"/>\n            </iterate>\n        </actions>\n    </service>\n\n    <service verb=\"provision\" noun=\"AppInstance\">\n        <in-parameters><parameter name=\"appInstanceId\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"databaseCreated\" type=\"Boolean\"/>\n            <parameter name=\"userCreated\" type=\"Boolean\"/>\n            <parameter name=\"instanceCreated\" type=\"Boolean\"/>\n            <parameter name=\"instanceExists\" type=\"Boolean\"/>\n            <parameter name=\"instanceUuid\"/>\n            <parameter name=\"instanceStarted\" type=\"Boolean\"/>\n            <parameter name=\"instanceRunning\" type=\"Boolean\"/>\n        </out-parameters>\n        <actions>\n            <service-call name=\"org.moqui.impl.InstanceServices.create#AppDatabase\" in-map=\"context\" out-map=\"context\"/>\n            <service-call name=\"org.moqui.impl.InstanceServices.init#AppInstance\" in-map=\"context\" out-map=\"context\"/>\n            <service-call name=\"org.moqui.impl.InstanceServices.start#AppInstance\" in-map=\"context\" out-map=\"context\"/>\n        </actions>\n    </service>\n    <service verb=\"check\" noun=\"AppInstanceAndDatabase\">\n        <in-parameters><parameter name=\"appInstanceId\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"databaseExists\" type=\"Boolean\"/>\n            <parameter name=\"dbUserExists\" type=\"Boolean\"/>\n            <parameter name=\"instanceExists\" type=\"Boolean\"/>\n            <parameter name=\"instanceRunning\" type=\"Boolean\"/>\n            <parameter name=\"statusString\"/>\n            <parameter name=\"errorString\"/>\n            <parameter name=\"startedAt\" type=\"Timestamp\"/>\n            <parameter name=\"finishedAt\" type=\"Timestamp\"/>\n            <parameter name=\"moquiStatusMap\" type=\"Map\"/>\n            <parameter name=\"instanceLog\"/>\n        </out-parameters>\n        <actions><script><![CDATA[\n            import org.moqui.context.ExecutionContext\n            import java.util.concurrent.Future\n            ExecutionContext ec = context.ec\n\n            Future<Map> dbFuture = ec.service.async().name(\"org.moqui.impl.InstanceServices.check#AppDatabase\").parameter(\"appInstanceId\", appInstanceId).callFuture()\n            Future<Map> instFuture = ec.service.async().name(\"org.moqui.impl.InstanceServices.check#AppInstance\").parameter(\"appInstanceId\", appInstanceId).callFuture()\n            Future<Map> msFuture = ec.service.async().name(\"org.moqui.impl.InstanceServices.check#MoquiServer\").parameter(\"appInstanceId\", appInstanceId).callFuture()\n\n            context.putAll(dbFuture.get())\n            context.putAll(instFuture.get())\n            context.putAll(msFuture.get())\n            ]]></script></actions>\n    </service>\n\n    <service verb=\"check\" noun=\"MoquiServer\">\n        <in-parameters><parameter name=\"appInstanceId\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"moquiConnectSuccess\" type=\"Boolean\"/>\n            <parameter name=\"moquiStatusMap\" type=\"Map\"/>\n        </out-parameters>\n        <actions><script><![CDATA[\n            import groovy.json.JsonSlurper\n            import org.moqui.context.ExecutionContext\n            import org.moqui.entity.EntityValue\n            import org.moqui.util.RestClient\n            ExecutionContext ec = context.ec\n\n            EntityValue appInstance = ec.entity.find(\"moqui.server.instance.AppInstance\").condition(\"appInstanceId\", appInstanceId).one()\n            RestClient restClient = ec.service.rest().method(\"GET\").uri().host(appInstance.hostName as String).path(\"status\").build()\n\n            try {\n                RestClient.RestResponse restResponse = restClient.call()\n                if (restResponse.statusCode == 200) {\n                    moquiConnectSuccess = true\n                    moquiStatusMap = (Map) restResponse.jsonObject()\n                } else {\n                    moquiConnectSuccess = false\n                    ec.message.addMessage(\"Server error on Moqui server: ${restResponse.getReasonPhrase()}\")\n                }\n            } catch (Exception e) {\n                moquiConnectSuccess = false\n                ec.logger.log(ec.logger.ERROR_INT, \"Error connecting to Moqui server at ${restClient.getUri()?.toString()}\", e)\n                ec.message.addMessage(\"Error connecting to Moqui server at ${restClient.getUri()?.toString()}: ${e.toString()}\")\n            }\n        ]]></script></actions>\n    </service>\n\n    <service verb=\"init\" noun=\"AppInstance\">\n        <implements service=\"org.moqui.impl.InstanceServices.init#Instance\"/>\n        <actions>\n            <entity-find-one entity-name=\"moqui.server.instance.AppInstance\" value-field=\"appInstance\"/>\n            <set field=\"hostType\" from=\"appInstance?.instanceHost?.hostType\"/>\n            <if condition=\"hostType == null\"><return error=\"true\" message=\"No InstanceHostType found for AppInstance with ID ${appInstanceId}\"/></if>\n            <!-- <service-call name=\"${hostType.initService}\" in-map=\"context\" out-map=\"context\"/>-->\n            <!--For async call -->\n            <script><![CDATA[\n            import org.moqui.context.ExecutionContext\n            import java.util.concurrent.Future\n            Future<Map> instanceFuture = ec.service.async().name(\"${hostType.initService}\").parameters([context:context,appInstanceId:appInstanceId]).callFuture()\n        ]]></script></actions>\n    </service>\n    <service verb=\"start\" noun=\"AppInstance\">\n        <implements service=\"org.moqui.impl.InstanceServices.start#Instance\"/>\n        <actions>\n            <entity-find-one entity-name=\"moqui.server.instance.AppInstance\" value-field=\"appInstance\"/>\n            <set field=\"hostType\" from=\"appInstance?.instanceHost?.hostType\"/>\n            <if condition=\"hostType == null\"><return error=\"true\" message=\"No InstanceHostType found for AppInstance with ID ${appInstanceId}\"/></if>\n            <service-call name=\"${hostType.startService}\" in-map=\"context\" out-map=\"context\"/>\n        </actions>\n    </service>\n    <service verb=\"stop\" noun=\"AppInstance\">\n        <implements service=\"org.moqui.impl.InstanceServices.stop#Instance\"/>\n        <actions>\n            <entity-find-one entity-name=\"moqui.server.instance.AppInstance\" value-field=\"appInstance\"/>\n            <set field=\"hostType\" from=\"appInstance?.instanceHost?.hostType\"/>\n            <if condition=\"hostType == null\"><return error=\"true\" message=\"No InstanceHostType found for AppInstance with ID ${appInstanceId}\"/></if>\n            <service-call name=\"${hostType.stopService}\" in-map=\"context\" out-map=\"context\"/>\n        </actions>\n    </service>\n    <service verb=\"remove\" noun=\"AppInstance\">\n        <implements service=\"org.moqui.impl.InstanceServices.remove#Instance\"/>\n        <actions>\n            <entity-find-one entity-name=\"moqui.server.instance.AppInstance\" value-field=\"appInstance\"/>\n            <set field=\"hostType\" from=\"appInstance?.instanceHost?.hostType\"/>\n            <if condition=\"hostType == null\"><return error=\"true\" message=\"No InstanceHostType found for AppInstance with ID ${appInstanceId}\"/></if>\n            <service-call name=\"${hostType.removeService}\" in-map=\"context\" out-map=\"context\"/>\n        </actions>\n    </service>\n    <service verb=\"check\" noun=\"AppInstance\">\n        <implements service=\"org.moqui.impl.InstanceServices.check#Instance\"/>\n        <actions>\n            <entity-find-one entity-name=\"moqui.server.instance.AppInstance\" value-field=\"appInstance\"/>\n            <set field=\"hostType\" from=\"appInstance?.instanceHost?.hostType\"/>\n            <if condition=\"hostType == null\"><return message=\"No InstanceHostType found for AppInstance with ID ${appInstanceId}\"/></if>\n            <service-call name=\"${hostType.checkService}\" in-map=\"context\" out-map=\"context\"/>\n        </actions>\n    </service>\n\n    <service verb=\"create\" noun=\"AppDatabase\">\n        <implements service=\"org.moqui.impl.InstanceServices.create#Database\"/>\n        <actions>\n            <entity-find-one entity-name=\"moqui.server.instance.AppInstance\" value-field=\"appInstance\"/>\n            <set field=\"databaseType\" from=\"appInstance.database?.type\"/>\n            <if condition=\"databaseType == null\"><return error=\"true\" message=\"No DatabaseType found for AppInstance with ID ${appInstanceId}\"/></if>\n            <service-call name=\"${databaseType.createService}\" in-map=\"context\" out-map=\"context\"/>\n        </actions>\n    </service>\n    <service verb=\"check\" noun=\"AppDatabase\">\n        <implements service=\"org.moqui.impl.InstanceServices.check#Database\"/>\n        <actions>\n            <entity-find-one entity-name=\"moqui.server.instance.AppInstance\" value-field=\"appInstance\"/>\n            <set field=\"databaseType\" from=\"appInstance.database?.type\"/>\n            <if condition=\"databaseType == null\"><return message=\"No DatabaseType found for AppInstance with ID ${appInstanceId}\"/></if>\n            <service-call name=\"${databaseType.checkService}\" in-map=\"context\" out-map=\"context\"/>\n        </actions>\n    </service>\n\n    <!-- ========== Docker Management Services ========== -->\n    <!--\n        - https://docs.docker.com/engine/reference/api/docker_remote_api/\n        - https://docs.docker.com/engine/reference/api/docker_remote_api_v1.24/\n        - https://docs.docker.com/engine/reference/commandline/dockerd/#bind-docker-to-another-host-port-or-a-unix-socket\n        - https://docs.docker.com/engine/security/https/\n    -->\n\n    <service verb=\"init\" noun=\"InstanceDocker\" transaction-timeout=\"1500\" >\n        <!-- see https://docs.docker.com/engine/reference/api/docker_remote_api_v1.24/#/create-a-container -->\n        <!-- https://docs.docker.com/engine/api/v1.24/#create-an-image -->\n        <implements service=\"org.moqui.impl.InstanceServices.init#Instance\"/>\n        <actions><script><![CDATA[\n            import groovy.json.JsonSlurper\n            import org.moqui.BaseException\n            import org.moqui.context.ExecutionContext\n            import org.moqui.entity.EntityValue\n            import org.moqui.util.RestClient\n            import java.util.concurrent.Future\n            import java.io.BufferedReader\n            import java.io.InputStreamReader\n            import java.io.IOException\n            import groovy.json.JsonOutput\n\n            connectSuccess = null\n            instanceCreated = false\n            instanceExists = null\n            instanceUuid = null\n            warnings = null\n\n            ExecutionContext ec = context.ec\n            EntityValue appInstance = ec.entity.find(\"moqui.server.instance.AppInstance\").condition(\"appInstanceId\", appInstanceId).one()\n            EntityValue instanceHost = appInstance.findRelatedOne(\"instanceHost\", null, null)\n            if (instanceHost == null) {\n                ec.message.addError(\"No InstanceHost found for AppInstance ${appInstanceId}\"); return;\n            }\n            EntityValue instanceImage = appInstance.findRelatedOne(\"image\", null, null)\n            if (instanceImage == null) {\n                ec.message.addError(\"No InstanceImage found for AppInstance ${appInstanceId}\"); return;\n            }\n            EntityValue instanceImageType = instanceImage.findRelatedOne(\"imageType\", null, null)\n\n            RestClient restClient = ec.service.rest().method(\"POST\")\n            restClient.uri().protocol(instanceHost.hostProtocol ? (String) instanceHost.hostProtocol : \"http\")\n                    .host(instanceHost.hostAddress as String).port(instanceHost.adminPort ? (instanceHost.adminPort as int) : 2375)\n                    .path(\"containers\").path(\"create\").parameter(\"name\", (String) appInstance.instanceName).build()\n\n            Map hostConfigMap = [RestartPolicy: [Name: \"unless-stopped\"]] as Map<String, Object>\n            List<String> linkList = []\n            for (Map link in appInstance.links) linkList.add(\"${link.instanceName}:${link.aliasName ?: link.instanceName}\")\n            if (linkList) hostConfigMap.Links = linkList\n            String networkMode = appInstance.networkMode ?: instanceImageType.defaultNetworkMode\n            if (networkMode) hostConfigMap.NetworkMode = networkMode\n\n            List<String> envList = []\n            for (Map env in appInstance.envs) if (env.envValue) envList.add(\"${env.envName}=${env.envValue}\")\n\n            List<String> bindList = []\n            for (Map vol in appInstance.vols) if (vol.mountPoint && vol.volumeName) bindList.add(\"${vol.volumeName}:${vol.mountPoint}\")\n            if (bindList) hostConfigMap.Binds = bindList\n\n            for (Map hostConf in appInstance.hostConfigs) if (hostConf.hostConfigName && hostConf.hostConfigValue) {\n                if (hostConf.type == null || hostConf.type == 'String') hostConfigValue = hostConf.hostConfigValue\n                else if (hostConf.type == 'Number') hostConfigValue = ec.l10n.parseNumber(hostConf.hostConfigValue, null)\n                else throw new BaseException(\"Unknown type '${hostConf.type}' for hostConfig\")\n                hostConfigMap.put(hostConf.hostConfigName, hostConfigValue)\n            }\n\n            Map jsonBody = [Image: instanceImage.imageName, Env: envList, HostConfig: hostConfigMap]\n            String initCommand = appInstance.initCommand ?: instanceImageType.defaultInitCommand\n            if (initCommand) jsonBody.Cmd = initCommand\n\n            if (appInstance.jsonConfig) {\n                Map baseConfigMap = (Map) new JsonSlurper().parseText((String) appInstance.jsonConfig)\n                org.moqui.util.CollectionUtilities.mergeNestedMap(baseConfigMap, jsonBody, false)\n                jsonBody = baseConfigMap\n            }\n\n            ec.logger.info(\"Initializing docker container for ${appInstance.instanceName} with:\\n${jsonBody}\")\n            restClient.jsonObject(jsonBody)\n\n            try {\n                authTokenPass = null\n                if (instanceImage.authTokenCmd) {\n                    try {\n                        Process processCmd = Runtime.getRuntime().exec((instanceImage.authTokenCmd).split(\" \"), null)\n                        BufferedReader readToken = new BufferedReader(new InputStreamReader(processCmd.getInputStream()))\n                        authTokenPass = readToken.readLine()\n                    }\n                    catch (IOException io) {\n                        authTokenPass = null\n                        ec.logger.log(ec.logger.ERROR_INT, \"Error connecting to Docker host in processing aws cmd\", io)\n                        ec.message.addError(\"Error connecting to Docker host: ${io.toString()}\")\n                    }\n\n                }\n                else {\n                    authTokenPass = instanceImage.password\n                }\n                Map jsonAuth = [username: instanceImage.username , password: authTokenPass , email: instanceImage.emailAddress , serveraddress: instanceImage.registryLocation ]\n                //Registry-Location now using as Server-Address\n                String json = (String) JsonOutput.toJson(jsonAuth)\n                String authEncoded = Base64.getEncoder().encodeToString(json.getBytes())\n                Map param = [fromImage:(String) instanceImage.imageName ,repo : (String)instanceImage.imageName]\n                RestClient restClientImageCreate = ec.service.rest().method(\"POST\").addHeader((String)(\"X-Registry-Auth\"),(String) authEncoded)\n                restClientImageCreate.uri().protocol(instanceHost.hostProtocol ? (String) instanceHost.hostProtocol : \"http\")\n                .host(instanceHost.hostAddress as String).port(instanceHost.adminPort ? (instanceHost.adminPort as int) : 2375)\n                .path(\"images\").path(\"create\")\n                .parameters(param).build()\n                restClientImageCreate.timeout(1000)\n\n                RestClient.RestResponse restResponseImageCreate = restClientImageCreate.call()\n                if (restResponseImageCreate.statusCode != 200) {\n                     ec.message.addMessage(\"Error Image-pull-fail problem image not-exists either or credential is wrong (${restResponseImageCreate.statusCode}): ${restResponseImageCreate.getReasonPhrase()}\")\n                }\n            } catch (Exception e) {\n                connectSuccess = false\n                ec.logger.log(ec.logger.ERROR_INT, \"Error connecting to Docker host\", e)\n                ec.message.addError(\"Error connecting to Docker host: ${e.toString()}\")\n            }\n\n            try {\n                RestClient.RestResponse restResponse = restClient.call()\n                connectSuccess = true\n\n                if (restResponse.statusCode == 201) {\n                    instanceCreated = true\n                    instanceExists = true\n\n                    Map jsonObj = (Map) restResponse.jsonObject()\n                    instanceUuid = jsonObj.Id\n                    warnings = jsonObj.Warnings\n\n                    if (instanceUuid) {\n                        appInstance.instanceUuid = instanceUuid\n                        appInstance.update()\n                    }\n\n                    ec.message.addMessage(\"Created Docker container ${appInstance.instanceName} (${appInstance.instanceUuid})${warnings ? ' warnings: ' + (warnings as String) : ''}\")\n                } else if (restResponse.statusCode == 409) {\n                    instanceExists = true\n                    ec.logger.warn(\"Docker container ${appInstance.instanceName} (${appInstance.instanceUuid}) conflict [${restResponse.getReasonPhrase()}]:\\n${restResponse.text()}\")\n                    ec.message.addMessage(\"Docker container ${appInstance.instanceName} (${appInstance.instanceUuid}) got a conflict on create [${restResponse.getReasonPhrase()}]\")\n                } else if (restResponse.statusCode == 404) {\n                    // POST /v1.24/images/create?fromImage=busybox&tag=latest HTTP/1.1\n                    ec.message.addMessage(\"Image not Found ,or may be Docker login fail\")\n                } else {\n                    ec.logger.error(\"Docker container ${appInstance.instanceName} (${appInstance.instanceUuid}) ERROR [${restResponse.getReasonPhrase()}]:\\n${restResponse.text()}\")\n                    ec.message.addMessage(\"Error on create container for ${appInstance.instanceName} (${restResponse.statusCode}): ${restResponse.getReasonPhrase()}\")\n                }\n            } catch (Exception e) {\n                connectSuccess = false\n                ec.logger.log(ec.logger.ERROR_INT, \"Error connecting to Docker host\", e)\n                ec.message.addError(\"Error connecting to Docker host: ${e.toString()}\")\n            }\n            ]]></script></actions>\n    </service>\n    <service verb=\"start\" noun=\"InstanceDocker\">\n        <!-- see https://docs.docker.com/engine/reference/api/docker_remote_api_v1.24/#/start-a-container -->\n        <implements service=\"org.moqui.impl.InstanceServices.start#Instance\"/>\n        <actions><script><![CDATA[\n            import org.moqui.context.ExecutionContext\n            import org.moqui.entity.EntityValue\n            import org.moqui.util.RestClient\n\n            instanceStarted = false\n            instanceRunning = null\n\n            ExecutionContext ec = context.ec\n            EntityValue appInstance = ec.entity.find(\"moqui.server.instance.AppInstance\").condition(\"appInstanceId\", appInstanceId).one()\n            EntityValue instanceHost = appInstance.findRelatedOne(\"instanceHost\", null, null)\n\n            RestClient restClient = ec.service.rest().method(\"POST\")\n            restClient.uri().protocol(instanceHost.hostProtocol ? (String) instanceHost.hostProtocol : \"http\")\n                    .host(instanceHost.hostAddress as String).port(instanceHost.adminPort ? (instanceHost.adminPort as int) : 2375)\n                    .path(\"containers\").path(appInstance.instanceUuid ? (String) appInstance.instanceUuid : (String) appInstance.instanceName)\n                    .path(\"start\").build()\n\n            try {\n                RestClient.RestResponse restResponse = restClient.call()\n                if (restResponse.statusCode == 204) {\n                    instanceStarted = true\n                    instanceRunning = true\n                    ec.message.addMessage(\"Docker container ${appInstance.instanceName} (${appInstance.instanceUuid}) started\")\n                } else if (restResponse.statusCode == 304) {\n                    instanceStarted = false\n                    instanceRunning = true\n                    ec.message.addMessage(\"Docker container ${appInstance.instanceName} (${appInstance.instanceUuid}) already started\")\n                } else if (restResponse.statusCode == 404) {\n                    instanceStarted = false\n                    instanceRunning = false\n                    ec.message.addMessage(\"Docker container ${appInstance.instanceName} (${appInstance.instanceUuid}) does not exist, init first\")\n                } else {\n                    errorString = restResponse.getReasonPhrase()\n                    ec.message.addMessage(\"Server error on Docker host: ${errorString}\")\n                }\n            } catch (Exception e) {\n                ec.logger.log(ec.logger.ERROR_INT, \"Error connecting to Docker host\", e)\n                ec.message.addError(\"Error connecting to Docker host: ${e.toString()}\")\n            }\n        ]]></script></actions>\n    </service>\n    <service verb=\"stop\" noun=\"InstanceDocker\">\n        <!-- see https://docs.docker.com/engine/reference/api/docker_remote_api_v1.24/#/stop-a-container -->\n        <implements service=\"org.moqui.impl.InstanceServices.stop#Instance\"/>\n        <actions><script><![CDATA[\n            import org.moqui.context.ExecutionContext\n            import org.moqui.entity.EntityValue\n            import org.moqui.util.RestClient\n\n            instanceStopped = false\n            instanceRunning = null\n\n            ExecutionContext ec = context.ec\n            EntityValue appInstance = ec.entity.find(\"moqui.server.instance.AppInstance\").condition(\"appInstanceId\", appInstanceId).one()\n            EntityValue instanceHost = appInstance.findRelatedOne(\"instanceHost\", null, null)\n\n            RestClient restClient = ec.service.rest().method(\"POST\")\n            restClient.uri().protocol(instanceHost.hostProtocol ? (String) instanceHost.hostProtocol : \"http\")\n                    .host(instanceHost.hostAddress as String).port(instanceHost.adminPort ? (instanceHost.adminPort as int) : 2375)\n                    .path(\"containers\").path(appInstance.instanceUuid ? (String) appInstance.instanceUuid : (String) appInstance.instanceName)\n                    .path(\"stop\").build()\n\n            try {\n                RestClient.RestResponse restResponse = restClient.call()\n                if (restResponse.statusCode == 204) {\n                    instanceStopped = true\n                    instanceRunning = false\n                    ec.message.addMessage(\"Docker container ${appInstance.instanceName} (${appInstance.instanceUuid}) stopped\")\n                } else if (restResponse.statusCode == 304) {\n                    instanceStopped = false\n                    instanceRunning = false\n                    ec.message.addMessage(\"Docker container ${appInstance.instanceName} (${appInstance.instanceUuid}) was not running\")\n                } else if (restResponse.statusCode == 404) {\n                    instanceStopped = false\n                    instanceRunning = false\n                    ec.message.addMessage(\"Docker container ${appInstance.instanceName} (${appInstance.instanceUuid}) does not exist, init first\")\n                } else {\n                    errorString = restResponse.getReasonPhrase()\n                    ec.message.addMessage(\"Server error on Docker host: ${errorString}\")\n                }\n            } catch (Exception e) {\n                ec.logger.log(ec.logger.ERROR_INT, \"Error connecting to Docker host\", e)\n                ec.message.addError(\"Error connecting to Docker host: ${e.toString()}\")\n            }\n        ]]></script></actions>\n    </service>\n    <service verb=\"remove\" noun=\"InstanceDocker\">\n        <!-- see https://docs.docker.com/engine/reference/api/docker_remote_api_v1.24/#/remove-a-container -->\n        <implements service=\"org.moqui.impl.InstanceServices.remove#Instance\"/>\n        <actions>\n            <actions><script><![CDATA[\n            import org.moqui.context.ExecutionContext\n            import org.moqui.entity.EntityValue\n            import org.moqui.util.RestClient\n\n            instanceRemoved = false\n            instanceExists = null\n\n            ExecutionContext ec = context.ec\n            EntityValue appInstance = ec.entity.find(\"moqui.server.instance.AppInstance\").condition(\"appInstanceId\", appInstanceId).one()\n            EntityValue instanceHost = appInstance.findRelatedOne(\"instanceHost\", null, null)\n\n            RestClient restClient = ec.service.rest().method(\"DELETE\")\n            restClient.uri().protocol(instanceHost.hostProtocol ? (String) instanceHost.hostProtocol : \"http\")\n                    .host(instanceHost.hostAddress as String).port(instanceHost.adminPort ? (instanceHost.adminPort as int) : 2375)\n                    .path(\"containers\").path(appInstance.instanceUuid ? (String) appInstance.instanceUuid : (String) appInstance.instanceName)\n                    .build()\n\n            try {\n                RestClient.RestResponse restResponse = restClient.call()\n                if (restResponse.statusCode == 204) {\n                    instanceRemoved = true\n                    instanceExists = false\n                    ec.message.addMessage(\"Docker container ${appInstance.instanceName} (${appInstance.instanceUuid}) removed\")\n                } else if (restResponse.statusCode == 404) {\n                    instanceRemoved = false\n                    instanceExists = false\n                    ec.message.addMessage(\"Docker container ${appInstance.instanceName} (${appInstance.instanceUuid}) does not exist, init first\")\n                } else {\n                    errorString = restResponse.getReasonPhrase()\n                    ec.message.addMessage(\"Server error on Docker host: ${errorString}\")\n                }\n            } catch (Exception e) {\n                ec.logger.log(ec.logger.ERROR_INT, \"Error connecting to Docker host\", e)\n                ec.message.addError(\"Error connecting to Docker host: ${e.toString()}\")\n            }\n        ]]></script></actions>\n        </actions>\n    </service>\n    <service verb=\"check\" noun=\"InstanceDocker\">\n        <implements service=\"org.moqui.impl.InstanceServices.check#Instance\"/>\n        <actions>\n            <service-call name=\"org.moqui.impl.InstanceServices.get#DockerStatus\" in-map=\"context\" out-map=\"context\"/>\n            <service-call name=\"org.moqui.impl.InstanceServices.get#DockerLog\" in-map=\"context\" out-map=\"context\"/>\n        </actions>\n    </service>\n    <service verb=\"get\" noun=\"DockerStatus\">\n        <!-- see https://docs.docker.com/engine/reference/api/docker_remote_api_v1.24/#/inspect-a-container -->\n        <implements service=\"org.moqui.impl.InstanceServices.check#Instance\"/>\n        <actions><script><![CDATA[\n            import org.moqui.context.ExecutionContext\n            import org.moqui.entity.EntityValue\n            import org.moqui.util.RestClient\n\n            connectSuccess = null\n            instanceExists = null\n            instanceRunning = null\n            statusString = \"\"\n            errorString = \"\"\n            startedAt = null\n            finishedAt = null\n\n            ExecutionContext ec = context.ec\n            EntityValue appInstance = ec.entity.find(\"moqui.server.instance.AppInstance\").condition(\"appInstanceId\", appInstanceId).one()\n            EntityValue instanceHost = appInstance.findRelatedOne(\"instanceHost\", null, null)\n            if (instanceHost == null) { ec.message.addMessage(\"No InstanceHost found for AppInstance ${appInstanceId}\"); return; }\n\n            RestClient restClient = ec.service.rest().method(\"GET\")\n            restClient.uri().protocol(instanceHost.hostProtocol ? (String) instanceHost.hostProtocol : \"http\")\n                    .host(instanceHost.hostAddress as String).port(instanceHost.adminPort ? (instanceHost.adminPort as int) : 2375)\n                    .path(\"containers\").path(appInstance.instanceUuid ? (String) appInstance.instanceUuid : (String) appInstance.instanceName)\n                    .path(\"json\").build()\n\n            try {\n                RestClient.RestResponse restResponse = restClient.call()\n                connectSuccess = true\n\n                if (restResponse.statusCode == 200) {\n                    instanceExists = true\n\n                    inspectMap = (Map) restResponse.jsonObject()\n                    Map stateMap = inspectMap.State\n                    instanceRunning = stateMap.Running as Boolean\n                    statusString = stateMap.Status as String\n                    errorString = stateMap.Error as String\n                    startedAt = ec.l10n.parseTimestamp(stateMap.StartedAt as String, \"\")\n                    if (stateMap.FinishedAt) finishedAt = ec.l10n.parseTimestamp(stateMap.FinishedAt as String, \"\")\n\n                    if (!appInstance.instanceUuid) {\n                        appInstance.instanceUuid = inspectMap.Id\n                        appInstance.update()\n                    }\n\n                    ec.message.addMessage(\"Docker container ${appInstance.instanceName} (${appInstance.instanceUuid}) ${instanceRunning ? 'running' : 'NOT running'}, status ${statusString}, ${errorString ? 'error: ' + errorString + ', ' : ''}started at ${ec.l10n.format(startedAt, '')}${finishedAt ? ', finished at ' + ec.l10n.format(finishedAt, '') : ''}\")\n                } else if (restResponse.statusCode == 404) {\n                    instanceExists = false\n                    ec.message.addMessage(\"Docker container ${appInstance.instanceName} (${appInstance.instanceUuid}) does not exist\")\n                } else {\n                    errorString = restResponse.getReasonPhrase()\n                    ec.message.addMessage(\"Server error on Docker host: ${errorString}\")\n                }\n            } catch (Exception e) {\n                connectSuccess = false\n                ec.logger.log(ec.logger.ERROR_INT, \"Error connecting to Docker host\", e)\n                ec.message.addError(\"Error connecting to Docker host: ${e.toString()}\")\n            }\n        ]]></script></actions>\n    </service>\n    <service verb=\"get\" noun=\"DockerLog\">\n        <!-- see https://docs.docker.com/engine/reference/api/docker_remote_api_v1.24/#/inspect-a-container -->\n        <in-parameters><parameter name=\"appInstanceId\"/></in-parameters>\n        <out-parameters><parameter name=\"instanceLog\"/></out-parameters>\n        <actions>\n            <script><![CDATA[\n            import org.moqui.context.ExecutionContext\n            import org.moqui.entity.EntityValue\n            import org.moqui.util.RestClient\n\n            instanceLog = null\n\n            ExecutionContext ec = context.ec\n            EntityValue appInstance = ec.entity.find(\"moqui.server.instance.AppInstance\").condition(\"appInstanceId\", appInstanceId).one()\n            EntityValue instanceHost = appInstance.findRelatedOne(\"instanceHost\", null, null)\n            if (instanceHost == null) { ec.message.addMessage(\"No InstanceHost found for AppInstance ${appInstanceId}\"); return; }\n\n            RestClient restClient = ec.service.rest().method(\"GET\")\n            restClient.uri().protocol(instanceHost.hostProtocol ? (String) instanceHost.hostProtocol : \"http\")\n                    .host(instanceHost.hostAddress as String).port(instanceHost.adminPort ? (instanceHost.adminPort as int) : 2375)\n                    .path(\"containers\").path(appInstance.instanceUuid ? (String) appInstance.instanceUuid : (String) appInstance.instanceName)\n                    .path(\"logs\").parameter(\"stdout\", \"true\").parameter(\"tail\", \"50\").build()\n\n            try {\n                RestClient.RestResponse restResponse = restClient.call()\n                connectSuccess = true\n\n                if (restResponse.statusCode == 200) {\n                    instanceExists = true\n\n                    instanceLog = restResponse.text().replaceAll(\"\\u0001\\u0000{5}[\\u0000\\u0001][\\u003c\\ufffd]\", \"\").replaceAll(\"\\u001b\\\\[[0-9]{0,2}m\", \"\")\n\n                } else if (restResponse.statusCode == 404) {\n                    instanceExists = false\n                    ec.message.addMessage(\"Docker container ${appInstance.instanceName} (${appInstance.instanceUuid}) does not exist\")\n                } else {\n                    errorString = restResponse.getReasonPhrase()\n                    ec.message.addMessage(\"Server error on Docker host: ${errorString}\")\n                }\n            } catch (Exception e) {\n                connectSuccess = false\n                ec.logger.log(ec.logger.ERROR_INT, \"Error connecting to Docker host\", e)\n                ec.message.addError(\"Error connecting to Docker host: ${e.toString()}\")\n            }\n        ]]></script></actions>\n    </service>\n\n    <!-- ========== Database Management Services ========== -->\n\n    <service verb=\"get\" noun=\"AppInstanceEnv\">\n        <in-parameters><parameter name=\"appInstanceId\" required=\"true\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"envMap\" type=\"Map\"/>\n            <parameter name=\"adminMap\" type=\"Map\"/>\n        </out-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.server.instance.AppInstance\" value-field=\"appInstance\"/>\n            <set field=\"databaseHost\" from=\"appInstance.database\"/>\n            <if condition=\"databaseHost == null\"><return message=\"No DatabaseHost found for AppInstance ${appInstanceId}\"/></if>\n            <set field=\"envList\" from=\"appInstance.envs\"/>\n            <set field=\"envMap\" from=\"[:]\"/>\n            <iterate list=\"envList\" entry=\"envItem\"><script>envMap.put(envItem.envName, envItem.envValue)</script></iterate>\n\n            <!-- setup adminMap directly from databaseHost settings, may differ from how instance accesses the database -->\n            <set field=\"adminMap\" from=\"[entity_ds_db_conf:databaseHost.type.confName, entity_ds_host:databaseHost.hostAddress,\n                    entity_ds_port:databaseHost.hostPort, entity_ds_user:databaseHost.adminUser, entity_ds_password:databaseHost.adminPassword]\"/>\n        </actions>\n    </service>\n\n    <!-- MySQL Services -->\n    <service verb=\"check\" noun=\"DatabaseMySQL\">\n        <implements service=\"org.moqui.impl.InstanceServices.check#Database\"/>\n        <actions>\n            <service-call name=\"org.moqui.impl.InstanceServices.get#AppInstanceEnv\" in-map=\"context\" out-map=\"context\"/>\n            <if condition=\"!envMap\"><return/></if>\n            <!-- connect with the mysql database which is always there in MySQL -->\n            <set field=\"adminMap.entity_ds_database\" value=\"mysql\"/>\n\n            <script><![CDATA[\n                org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(\"checkDatabaseMySQL\")\n\n                dbConnectSuccess = false\n                databaseExists = false\n                dbUserExists = false\n\n                javax.sql.XAConnection testXaCon = null\n                java.sql.Connection testCon = null\n                try {\n                    testXaCon = ec.entity.getConfConnection(adminMap)\n                    testCon = testXaCon.getConnection()\n                    dbConnectSuccess = true\n                } catch (Exception e) {\n                    logger.warn(\"Test connection failed\", e)\n                } finally {\n                    if (testCon != null) testCon.close()\n                    if (testXaCon != null) testXaCon.close()\n                }\n\n                if (dbConnectSuccess) {\n                    long dbCount = ec.entity.runSqlCountConf(\"information_schema.schemata\", \"schema_name='${envMap.entity_ds_database}'\", adminMap)\n                    if (dbCount > 0L) databaseExists = true\n\n                    long userCount = ec.entity.runSqlCountConf(\"mysql.user\", \"user='${envMap.entity_ds_user}'\", adminMap)\n                    if (userCount > 0L) dbUserExists = true\n                }\n            ]]></script>\n\n            <message>Checked database ${envMap.entity_ds_database} at ${adminMap.entity_ds_host}: connect ${dbConnectSuccess ? 'successful' : 'failed'}, database ${databaseExists ? 'exists' : 'does not exist'}, user ${envMap.entity_ds_user} ${dbUserExists ? 'exists' : 'does not exist'}</message>\n        </actions>\n    </service>\n    <service verb=\"create\" noun=\"DatabaseMySQL\">\n        <implements service=\"org.moqui.impl.InstanceServices.create#Database\"/>\n        <actions>\n            <service-call name=\"org.moqui.impl.InstanceServices.get#AppInstanceEnv\" in-map=\"context\" out-map=\"context\"/>\n            <if condition=\"!envMap\"><return/></if>\n            <!-- connect with the mysql database which is always there in MySQL -->\n            <set field=\"adminMap.entity_ds_database\" value=\"mysql\"/>\n\n            <script><![CDATA[\n                databaseCreated = false\n                userCreated = false\n\n                // see if database exists (SELECT * FROM information_schema.schemata WHERE schema_name='moqui')\n                long dbCount = ec.entity.runSqlCountConf(\"information_schema.schemata\", \"schema_name='${envMap.entity_ds_database}'\", adminMap)\n                if (dbCount > 0L) {\n                    ec.message.addMessage(\"Database ${envMap.entity_ds_database} already exists on host ${adminMap.entity_ds_host}\")\n                } else {\n                    int dbRows = ec.entity.runSqlUpdateConf(\"CREATE DATABASE `${envMap.entity_ds_database}` DEFAULT CHARACTER SET utf8\", adminMap)\n                    if (dbRows > 0) {\n                        databaseCreated = true\n                        ec.message.addMessage(\"Created database ${envMap.entity_ds_database} on host ${adminMap.entity_ds_host}\")\n                    }\n                }\n\n                // see if user exists (SELECT * FROM mysql.user WHERE user='root')\n                long userCount = ec.entity.runSqlCountConf(\"mysql.user\", \"user='${envMap.entity_ds_user}'\", adminMap)\n                if (userCount > 0L) {\n                    ec.message.addMessage(\"User ${envMap.entity_ds_user} already exists on host ${envMap.entity_ds_host}\")\n                } else {\n                    // NOTE: calling CREATE USER with IF NOT EXISTS returns an error instead of warning in MySQL 5.7.* (at least 5.7.10)\n                    // NOTE: always seems to return 0 rows updated so ignore\n                    ec.entity.runSqlUpdateConf(\"CREATE USER '${envMap.entity_ds_user}' IDENTIFIED BY '${envMap.entity_ds_password}'\", adminMap)\n                    userCreated = true\n                    ec.message.addMessage(\"Created user ${envMap.entity_ds_user} on host ${adminMap.entity_ds_host}\")\n                }\n                // NOTE: because of issue above user must already exist, often the case (using same user to access all tenant DBs; may create separate users to access only a single tenant DB)\n                ec.entity.runSqlUpdateConf(\"GRANT ALL ON ${envMap.entity_ds_database}.* TO '${envMap.entity_ds_user}'\", adminMap)\n            ]]></script>\n        </actions>\n    </service>\n\n    <service verb=\"check\" noun=\"DatabasePostgres\">\n        <implements service=\"org.moqui.impl.InstanceServices.check#Database\"/>\n        <actions>\n            <service-call name=\"org.moqui.impl.InstanceServices.get#AppInstanceEnv\" in-map=\"context\" out-map=\"context\"/>\n            <if condition=\"!envMap\"><return/></if>\n\n            <set field=\"adminMap.entity_ds_database\" from=\"envMap.entity_ds_database\"/>\n\n            <script><![CDATA[\n                org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(\"checkDatabasePostgres\")\n\n                dbConnectSuccess = false\n                databaseExists = false\n                dbUserExists = false\n\n                javax.sql.XAConnection testXaCon = null\n                java.sql.Connection testCon = null\n                try {\n                    testXaCon = ec.entity.getConfConnection(adminMap)\n                    testCon = testXaCon.getConnection()\n                    dbConnectSuccess = true\n                } catch (Exception e) {\n                    logger.warn(\"Test connection to Postgres failed\", e)\n                } finally {\n                    if (testCon != null) testCon.close()\n                    if (testXaCon != null) testXaCon.close()\n                }\n\n                if (dbConnectSuccess) {\n                    long dbCount = ec.entity.runSqlCountConf(\"pg_database\", \"pg_database.datname='${envMap.entity_ds_database}'\", adminMap)\n                    if (dbCount > 0L) databaseExists = true\n\n                    long userCount = ec.entity.runSqlCountConf(\"pg_catalog.pg_user\", \"usename='${envMap.entity_ds_user}'\", adminMap)\n                    if (userCount > 0L) dbUserExists = true\n                }\n            ]]></script>\n        </actions>\n    </service>\n\n    <service verb=\"create\" noun=\"DatabasePostgres\">\n        <implements service=\"org.moqui.impl.InstanceServices.create#Database\"/>\n        <actions>\n            <log message=\"Creating database in Postgres!\"/>\n            <service-call name=\"org.moqui.impl.InstanceServices.get#AppInstanceEnv\" in-map=\"context\" out-map=\"context\"/>\n            <if condition=\"!envMap\"><return/></if>\n\n            <set field=\"adminMap.entity_ds_database\" from=\"adminMap.entity_ds_db_conf\"/>\n            <script><![CDATA[\n                databaseCreated = false\n                userCreated = false\n\n                long dbCount = ec.entity.runSqlCountConf(\"pg_database\", \"pg_database.datname='${envMap.entity_ds_database}'\", adminMap)\n                if (dbCount > 0L) {\n                    ec.message.addMessage(\"Database ${envMap.entity_ds_database} already exists on host ${adminMap.entity_ds_host}\")\n                } else {\n                    int dbRows = ec.entity.runSqlUpdateConf(\"CREATE DATABASE ${envMap.entity_ds_database}\", adminMap)\n                    if (dbRows > 0) {\n                        databaseCreated = true\n                        ec.message.addMessage(\"Created database ${envMap.entity_ds_database} on host ${adminMap.entity_ds_host}\")\n                    }\n                }\n\n                long userCount = ec.entity.runSqlCountConf(\"pg_catalog.pg_user\", \"usename='${envMap.entity_ds_user}'\", adminMap)\n                if (userCount > 0L) {\n                    ec.message.addMessage(\"User ${envMap.entity_ds_user} already exists on host ${envMap.entity_ds_host}\")\n                } else {\n                    // NOTE: calling CREATE USER with IF NOT EXISTS returns an error instead of warning in MySQL 5.7.* (at least 5.7.10)\n                    // NOTE: always seems to return 0 rows updated so ignore\n                    ec.entity.runSqlUpdateConf(\"CREATE USER ${envMap.entity_ds_user} WITH PASSWORD '${envMap.entity_ds_password}'\", adminMap)\n                    userCreated = true\n                    ec.message.addMessage(\"Created user ${envMap.entity_ds_user} on host ${adminMap.entity_ds_host}\")\n                }\n                // NOTE: because of issue above user must already exist, often the case (using same user to access all tenant DBs; may create separate users to access only a single tenant DB)\n                ec.entity.runSqlUpdateConf(\"GRANT ALL PRIVILEGES ON DATABASE ${envMap.entity_ds_database} TO ${envMap.entity_ds_user}\", adminMap)\n            ]]></script>\n        </actions>\n    </service>\n\n</services>\n"
  },
  {
    "path": "framework/service/org/moqui/impl/PrintServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n\n    <service verb=\"get\" noun=\"ServerPrinters\">\n        <!-- org.moqui.impl.PrintServices.get#ServerPrinters -->\n        <description>Get printers from print server and create a moqui.basic.print.NetworkPrinter record for each.</description>\n        <in-parameters>\n            <parameter name=\"serverHost\" required=\"true\"/>\n            <parameter name=\"serverPort\" type=\"Integer\" default=\"631\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"networkPrinterIdList\" type=\"List\"><parameter name=\"networkPrinterId\"/></parameter>\n        </out-parameters>\n        <actions>\n            <return error=\"true\" message=\"Network printing support not installed (add moqui-cups component)\"/>\n        </actions>\n    </service>\n    <service verb=\"print\" noun=\"DocumentInterface\">\n        <in-parameters>\n            <parameter name=\"networkPrinterId\" required=\"true\"/>\n            <parameter name=\"createdDate\" type=\"Timestamp\" default=\"ec.user.nowTimestamp\"/>\n            <parameter name=\"username\"/>\n            <parameter name=\"jobName\"/>\n            <parameter name=\"copies\" type=\"Integer\" default=\"1\"/>\n            <parameter name=\"duplex\" default-value=\"N\"/>\n            <parameter name=\"pageRanges\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"printJobId\"/>\n            <parameter name=\"jobId\" type=\"Integer\"/>\n        </out-parameters>\n    </service>\n    <service verb=\"print\" noun=\"Document\">\n        <description>Create a moqui.basic.print.PrintJob record and send it to the specified NetworkPrinter</description>\n        <implements service=\"org.moqui.impl.PrintServices.print#DocumentInterface\"/>\n        <in-parameters>\n            <parameter name=\"storeDocument\" type=\"Boolean\" default=\"true\"/>\n            <parameter name=\"contentType\"/>\n\n            <parameter name=\"inputStream\" type=\"java.io.InputStream\"><description>The document may be passed in this\n                parameter as an InputStream or in the serialBlob field as a wrapped byte[].</description></parameter>\n            <parameter name=\"serialBlob\" type=\"javax.sql.rowset.serial.SerialBlob\">\n                <description>Use SerialBlob as a wrapper for byte[].</description></parameter>\n        </in-parameters>\n        <actions>\n            <if condition=\"serialBlob == null &amp;&amp; inputStream == null\">\n                <return error=\"true\" message=\"Both inputStream and serialBlob are null, must specify one or the other\"/></if>\n\n            <!-- for storing the document we'll always use a byte[] (not SerialBlob, so can be used below too) -->\n            <if condition=\"serialBlob != null\"><then>\n                <set field=\"docBytes\" from=\"serialBlob.getBytes(1, (int) serialBlob.length())\"/>\n            </then><else>\n                <set field=\"docBytes\" from=\"org.apache.commons.io.IOUtils.toByteArray(inputStream)\"/>\n            </else></if>\n            <if condition=\"storeDocument\"><set field=\"document\" from=\"docBytes\"/></if>\n\n            <set field=\"statusId\" value=\"PtjNotSent\"/>\n            <service-call name=\"create#moqui.basic.print.PrintJob\" in-map=\"context\" out-map=\"context\" transaction=\"force-new\"/>\n\n            <if condition=\"!storeDocument\"><set field=\"document\" from=\"docBytes\"/></if>\n            <service-call name=\"org.moqui.impl.PrintServices.send#PrintJobInternal\"\n                    in-map=\"[printJob:new HashMap(context)]\" out-map=\"context\"/>\n        </actions>\n    </service>\n    <service verb=\"print\" noun=\"ResourceDocument\">\n        <!-- org.moqui.impl.PrintServices.print#ResourceDocument -->\n        <implements service=\"org.moqui.impl.PrintServices.print#DocumentInterface\"/>\n        <in-parameters>\n            <parameter name=\"resourceLocation\" required=\"true\"/>\n        </in-parameters>\n        <actions>\n            <set field=\"resourceReference\" from=\"ec.resource.getLocationReference(resourceLocation)\"/>\n            <if condition=\"resourceReference == null || !resourceReference.getExists()\">\n                <return error=\"true\" message=\"Could not find resource at [${resourceLocation}]\"/></if>\n            <set field=\"inputStream\" from=\"resourceReference.openStream()\"/>\n            <service-call name=\"org.moqui.impl.PrintServices.print#Document\" out-map=\"context\"\n                    in-map=\"context + [inputStream:inputStream, contentType:resourceReference.getContentType(), storeDocument:false]\"/>\n        </actions>\n    </service>\n    <service verb=\"print\" noun=\"ScreenDocument\">\n        <!-- org.moqui.impl.PrintServices.print#ScreenDocument -->\n        <implements service=\"org.moqui.impl.PrintServices.print#DocumentInterface\"/>\n        <in-parameters>\n            <parameter name=\"screenLocation\" required=\"true\"/>\n            <parameter name=\"screenParameters\" type=\"Map\"/>\n            <parameter name=\"screenParametersStr\"><description>Groovy expression that evaluates to a Map</description></parameter>\n            <parameter name=\"contentType\" default-value=\"application/pdf\"/>\n            <parameter name=\"webappName\" default-value=\"webroot\"/>\n            <parameter name=\"storeDocument\" type=\"Boolean\" default=\"true\"/>\n        </in-parameters>\n        <actions>\n            <script>\n                import org.moqui.context.ExecutionContext\n                import org.moqui.impl.screen.ScreenDefinition\n                import javax.sql.rowset.serial.SerialBlob\n                import javax.xml.transform.stream.StreamSource\n\n                ExecutionContext ec = context.ec\n                ScreenDefinition screedDef = ec.getScreen().getScreenDefinition(screenLocation)\n                if (screedDef == null) {\n                    ec.message.addError(ec.resource.expand('Screen not found at [${screenLocation}]',''))\n                    return\n                }\n                Map parmMap = [:]\n                if (screenParameters) parmMap.putAll(screenParameters)\n                if (screenParametersStr) parmMap.putAll(ec.resource.expression(screenParametersStr, \"\"))\n                context.putAll(parmMap)\n                String xslFoText = ec.screen.makeRender().rootScreen(screenLocation).webappName(webappName).renderMode(\"xsl-fo\").render()\n                ByteArrayOutputStream baos = new ByteArrayOutputStream()\n                ec.resource.xslFoTransform(new StreamSource(new StringReader(xslFoText)), null, baos, contentType)\n                serialBlob = new SerialBlob(baos.toByteArray())\n\n                if (!jobName) {\n                    StringBuilder jobNameSb = new StringBuilder()\n                    jobNameSb.append(screedDef.getScreenName())\n                    for (Map.Entry entry in parmMap) jobNameSb.append(\" \").append(entry.getValue())\n                    jobName = jobNameSb.toString()\n                }\n\n                /* some test code to write a PDF to a file\n                File testFile = new File('test.pdf')\n                testFile.createNewFile()\n                FileOutputStream fos = new FileOutputStream(testFile)\n                org.apache.commons.io.IOUtils.write(baos.toByteArray(), fos)\n                */\n            </script>\n            <service-call name=\"org.moqui.impl.PrintServices.print#Document\" out-map=\"context\" in-map=\"context\"/>\n        </actions>\n    </service>\n\n    <service verb=\"send\" noun=\"PrintJob\">\n        <in-parameters><parameter name=\"printJobId\" required=\"true\"/></in-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.basic.print.PrintJob\" value-field=\"printJob\"/>\n            <service-call name=\"org.moqui.impl.PrintServices.send#PrintJobInternal\"\n                    in-map=\"[printJob:printJob]\" out-map=\"context\"/>\n        </actions>\n    </service>\n    <service verb=\"send\" noun=\"PrintJobInternal\">\n        <in-parameters><parameter name=\"printJob\" type=\"EntityValue\" required=\"true\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"jobId\" type=\"Integer\"/>\n        </out-parameters>\n        <actions>\n            <return error=\"true\" message=\"Network printing support not installed (add moqui-cups component)\"/>\n        </actions>\n    </service>\n\n    <service verb=\"get\" noun=\"PrintJobDetailsFromServer\">\n        <!-- org.moqui.impl.PrintServices.get#PrintJobDetailsFromServer -->\n        <description>Gets known local job details (from PrintJob record) job details/attributes from the print server,\n            updating PrintJob record for status and just returning the rest.</description>\n        <in-parameters><parameter name=\"printJobId\"/></in-parameters>\n        <out-parameters>\n            <auto-parameters entity-name=\"moqui.basic.print.PrintJob\" include=\"nonpk\"/>\n\n            <parameter name=\"completeTime\" type=\"Timestamp\"/>\n            <parameter name=\"createTime\" type=\"Timestamp\"/>\n            <parameter name=\"jobUrl\"/>\n            <parameter name=\"pagesPrinted\" type=\"Integer\"/>\n            <parameter name=\"printerUrl\"/>\n            <parameter name=\"size\" type=\"Integer\"/>\n        </out-parameters>\n        <actions>\n            <return error=\"true\" message=\"Network printing support not installed (add moqui-cups component)\"/>\n        </actions>\n    </service>\n\n    <service verb=\"hold\" noun=\"PrintJob\">\n        <!-- org.moqui.impl.PrintServices.hold#PrintJob -->\n        <in-parameters><parameter name=\"printJobId\"/></in-parameters>\n        <actions>\n            <return error=\"true\" message=\"Network printing support not installed (add moqui-cups component)\"/>\n        </actions>\n    </service>\n    <service verb=\"release\" noun=\"PrintJob\">\n        <!-- org.moqui.impl.PrintServices.release#PrintJob -->\n        <in-parameters><parameter name=\"printJobId\"/></in-parameters>\n        <actions>\n            <return error=\"true\" message=\"Network printing support not installed (add moqui-cups component)\"/>\n        </actions>\n    </service>\n    <service verb=\"cancel\" noun=\"PrintJob\">\n        <!-- org.moqui.impl.PrintServices.cancel#PrintJob -->\n        <in-parameters><parameter name=\"printJobId\"/></in-parameters>\n        <actions>\n            <return error=\"true\" message=\"Network printing support not installed (add moqui-cups component)\"/>\n        </actions>\n    </service>\n</services>\n"
  },
  {
    "path": "framework/service/org/moqui/impl/ScreenServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n\n    <!-- ========== ScheduledScreen Services ========== -->\n\n    <service verb=\"render\" noun=\"ScheduledScreens\" authenticate=\"anonymous-all\">\n        <actions>\n            <entity-find entity-name=\"moqui.screen.ScreenScheduled\" list=\"screenScheduledList\" cache=\"true\">\n                <date-filter/></entity-find>\n            <iterate list=\"screenScheduledList\" entry=\"screenScheduled\">\n                <if condition=\"!screenScheduled.cronExpression\">\n                    <log level=\"warn\" message=\"ScreenScheduled ${screenScheduled.screenScheduledId} has no cronExpression, not running\"/>\n                    <continue/>\n                </if>\n\n                <!-- check cronExpression vs lastRunTime to see if we need to run -->\n                <entity-find-one entity-name=\"moqui.screen.ScreenScheduledLock\" value-field=\"screenScheduledLock\">\n                    <field-map field-name=\"screenScheduledId\" from=\"screenScheduled.screenScheduledId\"/></entity-find-one>\n                <set field=\"shouldRun\" from=\"org.moqui.impl.service.ScheduledJobRunner.isLastRunBeforeLastSchedule(\n                        screenScheduled.cronExpression, screenScheduledLock?.lastRunTime, 'ScreenScheduled ' + screenScheduled.screenScheduledId, null)\"/>\n\n                <if condition=\"shouldRun\">\n                    <service-call name=\"org.moqui.impl.ScreenServices.render#ScheduledScreen\"\n                            in-map=\"[screenScheduledId:screenScheduled.screenScheduledId]\" async=\"distribute\"/>\n                </if>\n            </iterate>\n        </actions>\n    </service>\n    <service verb=\"render\" noun=\"ScheduledScreen\" authenticate=\"anonymous-all\">\n        <in-parameters>\n            <parameter name=\"screenScheduledId\" required=\"true\"/>\n        </in-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.screen.ScreenScheduled\" value-field=\"screenScheduled\" cache=\"true\"/>\n            <set field=\"screenPath\" from=\"screenScheduled.screenPath\"/>\n            <set field=\"renderMode\" from=\"screenScheduled.renderMode ?: 'csv'\"/>\n            <set field=\"formListFindId\" from=\"screenScheduled.formListFindId\"/>\n            <set field=\"saveToLocation\" from=\"screenScheduled.saveToLocation\"/>\n\n            <!-- NOTE: emailTemplate may be null, if so don't sent email -->\n            <set field=\"emailTemplate\" from=\"screenScheduled.emailTemplate\"/>\n            <set field=\"webappName\" from=\"emailTemplate?.webappName ?: 'webroot'\"/>\n            <set field=\"webHostName\" from=\"emailTemplate?.webHostName\"/>\n            <if condition=\"!webHostName\"><set field=\"webHostName\" from=\"ec.factory.getWebappInfo(webappName)?.httpsHost\"/></if>\n\n            <!-- return here if validations fail -->\n            <if condition=\"!screenPath\"><log level=\"error\" message=\"ScreenScheduled ${screenScheduledId} has no screenPath, not running\"/><return/></if>\n            <if condition=\"emailTemplate == null &amp;&amp; !saveToLocation\"><log level=\"error\" message=\"ScreenScheduled ${screenScheduledId} has no emailTemplate or saveToLocation, not running\"/><return/></if>\n\n            <if condition=\"screenScheduled.userId\">\n                <entity-find-one entity-name=\"moqui.security.UserAccount\" value-field=\"userAccount\">\n                    <field-map field-name=\"userId\" from=\"screenScheduled.userId\"/></entity-find-one>\n            </if>\n\n            <!-- get the lock record with for-update to make sure we're the only one running, if no record create one to lock -->\n            <entity-find-one entity-name=\"moqui.screen.ScreenScheduledLock\" value-field=\"screenScheduledLock\" for-update=\"true\"/>\n            <if condition=\"screenScheduledLock == null\">\n                <service-call name=\"create#moqui.screen.ScreenScheduledLock\"\n                        in-map=\"[screenScheduledId:screenScheduledId, lastRunTime:ec.user.nowTimestamp]\"/>\n            </if>\n\n            <!-- check again to make sure should run, now that all is locked -->\n            <set field=\"shouldRun\" from=\"org.moqui.impl.service.ScheduledJobRunner.isLastRunBeforeLastSchedule(\n                    screenScheduled.cronExpression, screenScheduledLock?.lastRunTime, 'ScreenScheduled ' + screenScheduled.screenScheduledId, null)\"/>\n            <if condition=\"!shouldRun\"><return type=\"warning\" message=\"ScreenScheduled ${screenScheduled.screenScheduledId} is not yet scheduled to run since last run time ${screenScheduledLock?.lastRunTime}\"/></if>\n\n            <!-- render screen -->\n            <script><![CDATA[\n                // TODO FUTURE: support renderMode == 'html' with inline content instead of attached\n\n                Map attachmentInfo = null\n\n                String extension = renderMode == \"xsl-fo\" ? \"pdf\" : renderMode\n                String lastPath = screenPath.substring(screenPath.lastIndexOf(\"/\")+1)\n                String filename = lastPath + \"-\" + ec.l10n.format(ec.user.nowTimestamp, \"yyyy-MM-dd-HH-mm\") + \".\" + extension\n\n                emailSubject = ec.resource.expand(screenScheduled.emailSubject, null)\n                if (!emailSubject) emailSubject = \"Scheduled Report ${lastPath} ${ec.l10n.format(ec.user.nowTimestamp, null)}\"\n\n                bodyParameters = [pageNoLimit:'true', emailSubject:emailSubject, title:emailSubject, screenPath:screenPath, webHostName:webHostName]\n                ec.context.putAll(bodyParameters)\n\n                // handle formListFindId parameters (formListFindId already in context to set columns, etc)\n                if (formListFindId) {\n                    def flfParameters = org.moqui.impl.screen.ScreenForm.makeFormListFindParameters(formListFindId, ec)\n                    ec.context.putAll(flfParameters)\n                    bodyParameters.putAll(flfParameters)\n                }\n\n                // login user before rendering screen, render as user\n                if (userAccount != null) ec.user.internalLoginUser(userAccount.username, false)\n\n                def screenRender = ec.screen.makeRender().webappName(webappName).rootScreenFromHost(webHostName ?: 'localhost')\n                        .screenPath(screenPath).renderMode(renderMode)\n                // TODO: consider config or something for protocol and port\n                if (webHostName) screenRender.baseLinkUrl(\"https://${webHostName}\")\n                if (renderMode == \"xsl-fo\") {\n                    // TODO: consider making this configurable on ScreenScheduled or something\n                    layoutMaster = \"letter-landscape\"\n                } else {\n                    // xsl-fo needs the full render path for header/footer, others better just the last screen\n                    screenRender.lastStandalone(\"true\")\n                }\n\n                if (ec.screen.isRenderModeText(renderMode)) {\n                    String screenText = screenRender.render()\n                    if (screenText != null && screenText.trim().length() > 0) {\n                        if (renderMode == \"xsl-fo\") {\n                            // use ResourceFacade.xslFoTransform() to change to PDF\n                            try {\n                                ByteArrayOutputStream baos = new ByteArrayOutputStream()\n                                ec.resource.xslFoTransform(new javax.xml.transform.stream.StreamSource(\n                                        new java.io.StringReader(screenText)), null, baos, \"application/pdf\")\n\n                                attachmentInfo = [fileName:filename, contentType:\"application/pdf\", contentBytes:baos.toByteArray()]\n                            } catch (Exception e) {\n                                ec.logger.warn(\"Error generating PDF from XSL-FO: ${e.toString()}\")\n                            }\n                        } else {\n                            String mimeType = ec.screen.getMimeTypeByMode(renderMode)\n                            attachmentInfo = [fileName:filename, contentType:mimeType, contentText:screenText]\n                        }\n                    }\n                } else {\n                    ByteArrayOutputStream baos = new ByteArrayOutputStream()\n                    screenRender.render(baos)\n\n                    String mimeType = ec.screen.getMimeTypeByMode(renderMode)\n                    attachmentInfo = [fileName:filename, contentType:mimeType, contentBytes:baos.toByteArray()]\n                }\n            ]]></script>\n\n            <!-- if noResultsAbort == 'Y' don't write file or send email -->\n            <if condition=\"screenScheduled.noResultsAbort != 'Y' || !ec.context.getSharedMap().get('_formListRendered') || ec.context.getSharedMap().get('_formListResultCount')\"><then>\n                <!-- send email -->\n                <if condition=\"emailTemplate != null\">\n                    <set field=\"toAddressSet\" from=\"new HashSet()\"/>\n                    <if condition=\"userAccount != null\">\n                        <if condition=\"userAccount?.emailAddress\"><then>\n                            <if condition=\"userAccount.disabled != 'Y'\"><then>\n                                <script>toAddressSet.add(userAccount.emailAddress)</script>\n                            </then><else>\n                                <log level=\"warn\" message=\"User ${userAccount.username} [${userAccount.userId}] is disabled, not sending email for ScreenScheduled ${screenScheduledId}\"/>\n                            </else></if>\n                        </then><else>\n                            <log level=\"warn\" message=\"User ${userAccount.username} [${userAccount.userId}] has no emailAddress, tried to use in ScreenScheduled ${screenScheduledId}\"/>\n                        </else></if>\n                    </if>\n                    <if condition=\"screenScheduled.userGroupId\">\n                        <entity-find entity-name=\"moqui.security.UserGroupMemberUser\" list=\"groupUserList\">\n                            <date-filter/>\n                            <econdition field-name=\"userGroupId\" from=\"screenScheduled.userGroupId\"/>\n                            <select-field field-name=\"userId,username,emailAddress,disabled\"/>\n                        </entity-find>\n                        <iterate list=\"groupUserList\" entry=\"groupUser\">\n                            <if condition=\"groupUser.emailAddress\"><then>\n                                <if condition=\"groupUser.disabled != 'Y'\"><then>\n                                    <script>toAddressSet.add(groupUser.emailAddress)</script>\n                                </then><else>\n                                    <log level=\"warn\" message=\"User ${groupUser.username} [${groupUser.userId}] in group ${screenScheduled.userGroupId} is disabled, not sending email for ScreenScheduled ${screenScheduledId}\"/>\n                                </else></if>\n                            </then><else>\n                                <log level=\"warn\" message=\"User ${groupUser.username} [${groupUser.userId}] in group ${screenScheduled.userGroupId} has no emailAddress, tried to use in ScreenScheduled ${screenScheduledId}\"/>\n                            </else></if>\n                        </iterate>\n                    </if>\n                    <if condition=\"toAddressSet\"><then>\n                        <set field=\"toAddresses\" from=\"toAddressSet.join(',')\"/>\n                        <service-call name=\"org.moqui.impl.EmailServices.send#EmailTemplate\" ignore-error=\"true\" transaction=\"force-new\"\n                                in-map=\"[emailTemplateId:emailTemplate.emailTemplateId, bodyParameters:bodyParameters,\n                                    attachments:[attachmentInfo], toAddresses:toAddresses, createEmailMessage:false]\"/>\n                    </then><else>\n                        <log level=\"error\" message=\"No email addresses found trying to send email for ScreenScheduled ${screenScheduledId} user ${screenScheduled.userId} group ${screenScheduled.userGroupId}\"/>\n                    </else></if>\n                </if>\n\n                <!-- saveToLocation -->\n                <if condition=\"saveToLocation\">\n                    <script>\n                        String saveToLocationExp = ec.resource.expand(saveToLocation, null)\n                        try {\n                            saveToRr = ec.resource.getLocationReference(saveToLocationExp)\n                            if (attachmentInfo.contentText) {\n                                saveToRr.putText(attachmentInfo.contentText)\n                            } else if (attachmentInfo.contentBytes) {\n                                saveToRr.putBytes(attachmentInfo.contentBytes)\n                            }\n                        } catch (Throwable t) {\n                            ec.logger.log(200, \"Error saving to saveToLocation ${saveToLocationExp} for ScreenScheduled ${screenScheduledId}\", t)\n                        }\n                    </script>\n                </if>\n            </then><else>\n                <log level=\"warn\" message=\"Not saving or sending email for ScheduledScreen ${screenScheduledId}: noResultsAbort ${screenScheduled.noResultsAbort} _formListRendered ${ec.context.getSharedMap().get('_formListRendered')} _formListResultCount ${ec.context.getSharedMap().get('_formListResultCount')}\"/>\n            </else></if>\n\n            <!-- save lastRunTime -->\n            <service-call name=\"update#moqui.screen.ScreenScheduledLock\"\n                    in-map=\"[screenScheduledId:screenScheduledId, lastRunTime:ec.user.nowTimestamp]\"/>\n        </actions>\n    </service>\n\n    <!-- ========== DB Form Services ========== -->\n\n    <service verb=\"get\" noun=\"FormResponse\">\n        <in-parameters><parameter name=\"formResponseId\" required=\"true\"/></in-parameters>\n        <out-parameters>\n            <auto-parameters entity-name=\"moqui.screen.form.FormResponse\"/>\n            <parameter name=\"responseMap\" type=\"Map\"/>\n            <parameter name=\"dbForm\" type=\"Map\"/>\n            <parameter name=\"dbFormFieldList\" type=\"List\"/>\n            <parameter name=\"dbFormFieldPages\" type=\"Integer\"/>\n        </out-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.screen.form.FormResponse\" value-field=\"formResponse\"/>\n            <if condition=\"formResponse.formId\">\n                <entity-find-one entity-name=\"moqui.screen.form.DbForm\" value-field=\"dbForm\">\n                    <field-map field-name=\"formId\" from=\"formResponse.formId\"/></entity-find-one>\n                <entity-find entity-name=\"moqui.screen.form.DbFormField\" list=\"dbFormFieldList\">\n                    <econdition field-name=\"formId\" from=\"formResponse.formId\"/>\n                    <order-by field-name=\"layoutSequenceNum\"/>\n                </entity-find>\n                <set field=\"dbFormFieldPages\" from=\"1\"/>\n                <iterate list=\"dbFormFieldList\" entry=\"dbFormField\">\n                    <set field=\"printPageNumber\" from=\"dbFormField.printPageNumber ?: 1\"/>\n                    <if condition=\"printPageNumber &gt; dbFormFieldPages\"><set field=\"dbFormFieldPages\" from=\"printPageNumber\"/></if>\n                </iterate>\n            </if>\n            <script>context.putAll(formResponse)</script>\n            <entity-find entity-name=\"moqui.screen.form.FormResponseAnswer\" list=\"answerList\">\n                <econdition field-name=\"formResponseId\"/></entity-find>\n            <set field=\"responseMap\" from=\"[:]\"/>\n            <iterate list=\"answerList\" entry=\"answer\">\n                <script>responseMap.put(answer.fieldName, answer.valueText)</script>\n            </iterate>\n        </actions>\n    </service>\n    <service verb=\"create\" noun=\"FormResponse\">\n        <in-parameters>\n            <parameter name=\"formLocation\" required=\"true\" default-value=\"DbForm#${formId}\"/>\n            <parameter name=\"formId\"/>\n            <parameter name=\"userId\" default=\"ec.user.userId\"/>\n            <parameter name=\"responseDate\" type=\"Timestamp\" default=\"ec.user.nowTimestamp\"/>\n            <parameter name=\"responseMap\" type=\"Map\"/>\n        </in-parameters>\n        <out-parameters><parameter name=\"formResponseId\"/></out-parameters>\n        <actions>\n            <service-call name=\"create#moqui.screen.form.FormResponse\" in-map=\"context\" out-map=\"context\"/>\n\n            <if condition=\"responseMap\">\n                <set field=\"formNode\" from=\"ec.screen.getFormNode(formLocation)\"/>\n                <iterate list=\"formNode.children('field')\" entry=\"fieldNode\">\n                    <set field=\"fieldName\" from=\"fieldNode.attribute('name')\"/>\n                    <if condition=\"responseMap.containsKey(fieldName)\">\n                        <service-call name=\"create#moqui.screen.form.FormResponseAnswer\"\n                                in-map=\"[formResponseId:formResponseId, formId:formId, fieldName:fieldName,\n                                    valueText:org.moqui.util.ObjectUtilities.toPlainString(responseMap.get(fieldName))]\"/>\n                    </if>\n                </iterate>\n            </if>\n        </actions>\n    </service>\n    <service verb=\"update\" noun=\"FormResponse\">\n        <description>Updates existing FormResponseAnswer or adds new ones as needed. Note that this doesn't work with\n            fields that have multiple responses (it will update the first response).</description>\n        <in-parameters>\n            <parameter name=\"formResponseId\" required=\"true\"/>\n            <!-- QUESTION: update userId and responseDate? -->\n            <parameter name=\"responseMap\" type=\"Map\" required=\"true\"/>\n        </in-parameters>\n        <out-parameters><parameter name=\"formResponseId\"/></out-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.screen.form.FormResponse\" value-field=\"formResponse\"/>\n            <set field=\"formNode\" from=\"ec.screen.getFormNode(formResponse.formLocation)\"/>\n            <iterate list=\"formNode.children('field')\" entry=\"fieldNode\">\n                <set field=\"fieldName\" from=\"fieldNode.attribute('name')\"/>\n                <if condition=\"responseMap.containsKey(fieldName)\">\n                    <entity-find-one entity-name=\"moqui.screen.form.FormResponseAnswer\" value-field=\"answer\">\n                        <field-map field-name=\"formResponseId\"/><field-map field-name=\"fieldName\"/></entity-find-one>\n                    <if condition=\"answer\">\n                        <service-call name=\"update#moqui.screen.form.FormResponseAnswer\"\n                                in-map=\"[formResponseAnswerId:answer.formResponseAnswerId,\n                                    valueText:org.moqui.util.ObjectUtilities.toPlainString(responseMap.get(fieldName))]\"/>\n                        <else>\n                            <service-call name=\"create#moqui.screen.form.FormResponseAnswer\"\n                                    in-map=\"[formResponseId:formResponseId, fieldName:fieldName,\n                                        valueText:org.moqui.util.ObjectUtilities.toPlainString(responseMap.get(fieldName))]\"/>\n                        </else>\n                    </if>\n                </if>\n            </iterate>\n        </actions>\n    </service>\n    <service verb=\"delete\" noun=\"FormResponse\">\n        <in-parameters><parameter name=\"formResponseId\" required=\"true\"/></in-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.screen.form.FormResponse\" value-field=\"formResponse\"/>\n            <if condition=\"formResponse == null\"><return message=\"Form Response ${formResponseId} not found\"/></if>\n            <entity-delete-by-condition entity-name=\"moqui.screen.form.FormResponseAnswer\">\n                <econdition field-name=\"formResponseId\"/></entity-delete-by-condition>\n            <entity-delete value-field=\"formResponse\"/>\n        </actions>\n    </service>\n    <service verb=\"update\" noun=\"DbFormField\">\n        <in-parameters>\n            <auto-parameters entity-name=\"moqui.screen.form.DbFormField\" include=\"nonpk\"/>\n            <auto-parameters entity-name=\"moqui.screen.form.DbFormField\" include=\"pk\" required=\"true\"/>\n        </in-parameters>\n        <actions>\n            <entity-find entity-name=\"moqui.screen.form.DbFormFieldOption\" list=\"dbFormFieldOptionList\">\n                <econdition field-name=\"formId\" from=\"formId\"/>\n                <econdition field-name=\"fieldName\" from=\"fieldName\"/>\n            </entity-find>\n            <entity-delete-by-condition entity-name=\"moqui.screen.form.DbFormFieldOption\">\n                <econdition field-name=\"formId\" from=\"formId\"/>\n                <econdition field-name=\"fieldName\" from=\"fieldName\"/>\n                <econdition field-name=\"sequenceNum\" from=\"dbFormFieldOptionList.sequenceNum\" operator=\"in\"/>\n            </entity-delete-by-condition>\n            <service-call name=\"delete#moqui.screen.form.DbFormField\" in-map=\"context\"/>\n            <set field=\"fieldName\" from=\"org.moqui.util.StringUtilities.prettyToCamelCase(title, false)\"/>\n            <service-call name=\"create#moqui.screen.form.DbFormField\" in-map=\"context\"/>\n            <iterate list=\"dbFormFieldOptionList\" entry=\"dbFormFieldOption\">\n                <set field=\"dbFormFieldOption.formId\" from=\"formId\"/>\n                <set field=\"dbFormFieldOption.fieldName\" from=\"fieldName\"/>\n                <service-call name=\"create#moqui.screen.form.DbFormFieldOption\" in-map=\"dbFormFieldOption\"/>\n            </iterate>\n        </actions>\n    </service>\n    <service verb=\"delete\" noun=\"DbFormField\">\n        <in-parameters>\n            <auto-parameters entity-name=\"moqui.screen.form.DbFormField\" include=\"nonpk\"/>\n            <auto-parameters entity-name=\"moqui.screen.form.DbFormField\" include=\"pk\" required=\"true\"/>\n        </in-parameters>\n        <actions>\n            <entity-find entity-name=\"moqui.screen.form.DbFormFieldOption\" list=\"dbFormFieldOptionList\">\n                <econdition field-name=\"formId\" from=\"formId\"/>\n                <econdition field-name=\"fieldName\" from=\"fieldName\"/>\n            </entity-find>\n            <entity-delete-by-condition entity-name=\"moqui.screen.form.DbFormFieldOption\">\n                <econdition field-name=\"formId\" from=\"formId\"/>\n                <econdition field-name=\"fieldName\" from=\"fieldName\"/>\n                <econdition field-name=\"sequenceNum\" from=\"dbFormFieldOptionList.sequenceNum\" operator=\"in\"/>\n            </entity-delete-by-condition>\n            <service-call name=\"delete#moqui.screen.form.DbFormField\" in-map=\"context\"/>\n        </actions>\n    </service>\n    <service verb=\"get\" noun=\"FormResponseStats\">\n        <in-parameters>\n            <parameter name=\"formId\" required=\"true\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"textDbFormFieldResponseCountList\" type=\"List\"/>\n            <parameter name=\"enumDbFormFieldResponseCountList\" type=\"List\"/>\n            <parameter name=\"formResponseCount\" type=\"List\"/>\n        </out-parameters>\n        <actions>\n            <script>enumFieldTypeEnumIdList = ['DBFFT_check', 'DBFFT_drop-down', 'DBFFT_radio'];</script>\n            <entity-find entity-name=\"moqui.screen.form.DbFormField\" list=\"textDbFormFieldList\">\n                <econdition field-name=\"formId\" from=\"formId\"/>\n                <econdition field-name=\"fieldTypeEnumId\" from=\"enumFieldTypeEnumIdList\" operator=\"not-in\" />\n                <order-by field-name=\"layoutSequenceNum\"/>\n            </entity-find>\n            <entity-find entity-name=\"moqui.screen.form.DbFormField\" list=\"enumDbFormFieldList\">\n                <econdition field-name=\"formId\" from=\"formId\"/>\n                <econdition field-name=\"fieldTypeEnumId\" from=\"enumFieldTypeEnumIdList\" operator=\"in\"/>\n                <order-by field-name=\"layoutSequenceNum\"/>\n            </entity-find>\n            <set field=\"textDbFormFieldResponseCountList\" from=\"[]\"/>\n            <iterate list=\"textDbFormFieldList\" entry=\"textDbFormField\">\n                <set field=\"textDbFormFieldResponseObj\" from=\"[:]\"/>\n                <entity-find-count entity-name=\"moqui.screen.form.FormResponseAnsAndDbFormField\" count-field=\"textDbFormFieldResponseCount\">\n                    <econdition field-name=\"formId\" from=\"formId\"/>\n                    <econdition field-name=\"fieldName\" from=\"textDbFormField.fieldName\"/>\n                    <econditions combine=\"and\">\n                        <econdition field-name=\"valueText\" value=\"\" operator=\"not-equals\"/>\n                        <econdition field-name=\"valueText\" operator=\"is-not-null\"/>\n                    </econditions>\n                </entity-find-count>\n                <script>textDbFormFieldResponseObj.put(\"title\", textDbFormField.title)\n                    textDbFormFieldResponseObj.put(\"count\", textDbFormFieldResponseCount)\n                    textDbFormFieldResponseCountList.add(textDbFormFieldResponseObj)</script>\n            </iterate>\n            <set field=\"enumDbFormFieldResponseCountList\" from=\"[]\"/>\n            <iterate list=\"enumDbFormFieldList\" entry=\"enumDbFormField\">\n                <set field=\"enumDbFormFieldOptionResponseObj\" from=\"[:]\"/>\n                <set field=\"enumDbFormFieldOptionResponseList\" from=\"[]\"/>\n                <entity-find entity-name=\"moqui.screen.form.DbFormFieldOption\" list=\"dbFormFieldOptionList\">\n                    <econdition field-name=\"formId\" from=\"formId\"/>\n                    <econdition field-name=\"fieldName\" from=\"enumDbFormField.fieldName\"/>\n                </entity-find>\n                <iterate list=\"dbFormFieldOptionList\" entry=\"dbFormFieldOption\">\n                    <set field=\"dbFormFieldOptionResponseObj\" from=\"[:]\"/>\n                    <entity-find-count entity-name=\"moqui.screen.form.FormResponseAnsAndDbFormField\" count-field=\"enumDbFormFieldOptionResponseCount\">\n                        <econdition field-name=\"formId\" from=\"formId\"/>\n                        <econdition field-name=\"fieldName\" from=\"enumDbFormField.fieldName\"/>\n                        <econdition field-name=\"valueText\" from=\"dbFormFieldOption.keyValue\" operator=\"equals\"/>\n                    </entity-find-count>\n                    <script>dbFormFieldOptionResponseObj.put(\"text\", dbFormFieldOption.text)\n                        dbFormFieldOptionResponseObj.put(\"count\", enumDbFormFieldOptionResponseCount)\n                        enumDbFormFieldOptionResponseList.add(dbFormFieldOptionResponseObj)</script>\n                </iterate>\n                <script>enumDbFormFieldOptionResponseObj.put(\"title\", enumDbFormField.title)\n                    enumDbFormFieldOptionResponseObj.put(\"fieldOptionList\", enumDbFormFieldOptionResponseList)\n                    enumDbFormFieldResponseCountList.add(enumDbFormFieldOptionResponseObj)</script>\n            </iterate>\n            <entity-find-count entity-name=\"moqui.screen.form.FormResponse\" count-field=\"formResponseCount\">\n                <econdition field-name=\"formId\" from=\"formId\"/>\n            </entity-find-count>\n        </actions>\n    </service>\n</services>\n"
  },
  {
    "path": "framework/service/org/moqui/impl/ServerServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n    <service verb=\"get\" noun=\"VisitClientIpData\" authenticate=\"anonymous-all\">\n        <description>Gets data from freegeoip.net for client IP address and populates in Visit record</description>\n        <in-parameters><parameter name=\"visitId\" required=\"true\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"clientIpPostalCode\"/><parameter name=\"clientIpCity\"/><parameter name=\"clientIpMetroCode\"/>\n            <parameter name=\"clientIpRegionCode\"/><parameter name=\"clientIpRegionName\"/><parameter name=\"clientIpStateProvGeoId\"/>\n            <parameter name=\"clientIpCountryGeoId\"/><parameter name=\"clientIpLatitude\"/><parameter name=\"clientIpLongitude\"/>\n            <parameter name=\"clientIpTimeZone\"/>\n        </out-parameters>\n        <actions>\n            <!-- freegeoip.net now always returns a 403 error, disable until another solution found:\n{\n  \"0\": \"#################################################################################################################################\",\n  \"1\": \"#                                                                                                                               #\",\n  \"2\": \"# IMPORTANT - PLEASE UPDATE YOUR API ENDPOINT                                                                                   #\",\n  \"3\": \"#                                                                                                                               #\",\n  \"4\": \"# This API endpoint is deprecated and has now been shut down. To keep using the freegeoip API, please update your integration   #\",\n  \"5\": \"# to use the new ipstack API endpoint, designed as a simple drop-in replacement.                                                #\",\n  \"6\": \"# You will be required to create an account at https://ipstack.com and obtain an API access key.                                #\",\n  \"7\": \"#                                                                                                                               #\",\n  \"8\": \"# For more information on how to upgrade please visit our Github Tutorial at: https://github.com/apilayer/freegeoip#readme      #\",\n  \"9\": \"#                                                                                                                               #\",\n  \"a\": \"#################################################################################################################################\"\n}\n            -->\n            <return message=\"Geo IP lookup disabled\"/>\n\n            <entity-find-one entity-name=\"moqui.server.Visit\" value-field=\"visit\"/>\n            <!-- don't lock the Visit, ie no for-update=\"true\" just in case this takes a long time to run -->\n            <if condition=\"visit == null\"><return message=\"No visit found with ID ${visitId}\"/></if>\n            <if condition=\"!visit.clientIpAddress\"><return message=\"Visit with ID ${visitId} has no clientIpAddress\"/></if>\n\n            <if condition=\"!visit.clientIpAddress.contains('.')\"><return message=\"IP address not a IPv4 address\"/></if>\n            <set field=\"address\" from=\"InetAddress.getByName(visit.clientIpAddress)\"/>\n            <if condition=\"address.isSiteLocalAddress() || address.isAnyLocalAddress() || address.isLinkLocalAddress() ||\n                    address.isLoopbackAddress() || address.isMulticastAddress()\">\n                <return message=\"IP address is not a public address\"/></if>\n\n            <!-- get from http://freegeoip.net/json/${clientIpAddress} -->\n            <script>geoIp = ec.service.rest().method(\"get\").uri(\"http://freegeoip.net/json/${visit.clientIpAddress}\").call().checkError().jsonObject()</script>\n\n            <if condition=\"!geoIp\"><return message=\"No return from freegeoip.net for IP ${visit.clientIpAddress}\"/></if>\n            <set field=\"mapped\" from=\"[clientIpPostalCode:geoIp.zip_code, clientIpCity:geoIp.city,\n                    clientIpMetroCode:geoIp.metro_code, clientIpRegionCode:geoIp.region_code, clientIpRegionName:geoIp.region_name,\n                    clientIpLatitude:geoIp.latitude, clientIpLongitude:geoIp.longitude, clientIpTimeZone:geoIp.time_zone]\"/>\n            <if condition=\"geoIp.country_code\">\n                <entity-find entity-name=\"moqui.basic.Geo\" list=\"geoList\">\n                    <econdition field-name=\"geoTypeEnumId\" value=\"GEOT_COUNTRY\"/>\n                    <econdition field-name=\"geoCodeAlpha2\" from=\"geoIp.country_code\"/></entity-find>\n                <if condition=\"geoList\"><set field=\"mapped.clientIpCountryGeoId\" from=\"geoList[0].geoId\"/></if>\n            </if>\n            <if condition=\"geoIp.region_code\">\n                <entity-find entity-name=\"moqui.basic.Geo\" list=\"geoList\">\n                    <econdition field-name=\"geoTypeEnumId\" operator=\"in\" value=\"GEOT_STATE,GEOT_PROVINCE,GEOT_TERRITORY\"/>\n                    <econdition field-name=\"geoCodeAlpha2\" from=\"geoIp.region_code\"/></entity-find>\n                <if condition=\"geoList\"><set field=\"mapped.clientIpStateProvGeoId\" from=\"geoList[0].geoId\"/></if>\n            </if>\n\n            <script>ec.context.putAll(mapped); visit.putAll(mapped)</script>\n            <entity-update value-field=\"visit\"/>\n        </actions>\n    </service>\n\n    <service verb=\"clean\" noun=\"ArtifactData\" authenticate=\"false\" transaction-timeout=\"600\">\n        <in-parameters><parameter name=\"daysToKeep\" type=\"Integer\" default=\"90\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"artifactHitsRemoved\" type=\"Long\"/>\n            <parameter name=\"artifactHitBinsRemoved\" type=\"Long\"/>\n        </out-parameters>\n        <actions><script>\n            import org.moqui.context.ExecutionContext\n            import org.moqui.entity.EntityCondition\n            ExecutionContext ec = context.ec\n            Calendar basisCal = ec.user.getCalendarSafe()\n            basisCal.add(Calendar.DAY_OF_YEAR, (int) -daysToKeep)\n            basisTimestamp = new Timestamp(basisCal.getTimeInMillis())\n            artifactHitsRemoved = ec.entity.find(\"moqui.server.ArtifactHit\")\n                    .condition(\"startDateTime\", EntityCondition.LESS_THAN, basisTimestamp)\n                    .disableAuthz().deleteAll()\n            artifactHitBinsRemoved = ec.entity.find(\"moqui.server.ArtifactHitBin\")\n                    .condition(\"binEndDateTime\", EntityCondition.LESS_THAN, basisTimestamp)\n                    .disableAuthz().deleteAll()\n            ec.logger.info(\"Removed ${artifactHitsRemoved} ArtifactHit records and ${artifactHitBinsRemoved} ArtifactHitBin records more than ${daysToKeep} days old\")\n        </script></actions>\n    </service>\n\n    <service verb=\"clean\" noun=\"PrintJobData\" authenticate=\"false\" transaction-timeout=\"600\">\n        <in-parameters><parameter name=\"daysToKeep\" type=\"Integer\" default=\"7\"/></in-parameters>\n        <out-parameters><parameter name=\"printJobsRemoved\" type=\"Long\"/></out-parameters>\n        <actions><script>\n            import org.moqui.context.ExecutionContext\n            import org.moqui.entity.EntityCondition\n            ExecutionContext ec = context.ec\n            Calendar basisCal = ec.user.getCalendarSafe()\n            basisCal.add(Calendar.DAY_OF_YEAR, (int) -daysToKeep)\n            basisTimestamp = new Timestamp(basisCal.getTimeInMillis())\n            printJobsRemoved = ec.entity.find(\"moqui.basic.print.PrintJob\")\n                    .condition(\"createdDate\", EntityCondition.LESS_THAN, basisTimestamp)\n                    .disableAuthz().deleteAll()\n            ec.logger.info(\"Removed ${printJobsRemoved} PrintJob records more than ${daysToKeep} days old\")\n        </script></actions>\n    </service>\n</services>\n"
  },
  {
    "path": "framework/service/org/moqui/impl/ServiceServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n\n    <service verb=\"clean\" noun=\"ServiceJobRun\" authenticate=\"false\" transaction-timeout=\"600\">\n        <in-parameters><parameter name=\"daysToKeep\" type=\"Integer\" default=\"90\"/></in-parameters>\n        <out-parameters><parameter name=\"recordsRemoved\" type=\"Long\"/></out-parameters>\n        <actions>\n            <script>\n                import org.moqui.context.ExecutionContext\n                import org.moqui.entity.EntityCondition\n                ExecutionContext ec = context.ec\n                Calendar basisCal = ec.user.getCalendarSafe()\n                basisCal.add(Calendar.DAY_OF_YEAR, (int) -daysToKeep)\n                basisTimestamp = new Timestamp(basisCal.getTimeInMillis())\n                recordsRemoved = ec.entity.find(\"moqui.service.job.ServiceJobRun\")\n                        .condition(\"startTime\", EntityCondition.LESS_THAN, basisTimestamp)\n                        .disableAuthz().deleteAll()\n            </script>\n            <log level=\"info\" message=\"Removed ${recordsRemoved} ServiceJobRun records.\"/>\n        </actions>\n    </service>\n</services>\n"
  },
  {
    "path": "framework/service/org/moqui/impl/SystemMessageServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n\n    <!-- =========== Interfaces =========== -->\n\n    <service verb=\"receive\" noun=\"SystemMessage\" type=\"interface\">\n        <in-parameters>\n            <parameter name=\"systemMessageTypeId\" required=\"true\"/>\n            <parameter name=\"messageText\" required=\"true\"/>\n            <parameter name=\"systemMessageRemoteId\"/>\n            <parameter name=\"remoteMessageId\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"systemMessageIdList\" type=\"List\"><parameter name=\"systemMessageId\"/></parameter>\n        </out-parameters>\n    </service>\n    <service verb=\"consume\" noun=\"SystemMessage\" type=\"interface\">\n        <in-parameters><parameter name=\"systemMessageId\" required=\"true\"/></in-parameters>\n        <out-parameters><parameter name=\"noStatusUpdate\" type=\"Boolean\"/></out-parameters>\n    </service>\n    <service verb=\"produce\" noun=\"AckSystemMessage\" type=\"interface\">\n        <in-parameters>\n            <parameter name=\"systemMessageId\" required=\"true\"/>\n            <parameter name=\"systemMessageRemoteId\"><description>If not specified comes from SystemMessage.systemMessageRemoteId</description></parameter>\n        </in-parameters>\n        <out-parameters><parameter name=\"systemMessageId\"/></out-parameters>\n    </service>\n    <service verb=\"send\" noun=\"SystemMessage\" type=\"interface\">\n        <in-parameters><parameter name=\"systemMessageId\" required=\"true\"/></in-parameters>\n        <out-parameters><parameter name=\"remoteMessageId\"/></out-parameters>\n    </service>\n\n    <service verb=\"cancel\" noun=\"SystemMessage\">\n        <in-parameters><parameter name=\"systemMessageId\" required=\"true\"/></in-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.service.message.SystemMessage\" value-field=\"systemMessage\" for-update=\"true\"/>\n            <if condition=\"systemMessage.statusId == 'SmsgCancelled'\"><return/></if>\n            <if condition=\"systemMessage.statusId in ['SmsgSending', 'SmsgSent', 'SmsgConsuming', 'SmsgConsumed', 'SmsgConfirmed']\">\n                <return error=\"true\" message=\"Cannot cancel SystemMessage ${systemMessageId} in status ${systemMessage.statusId}\"/>\n            </if>\n            <!-- at this point statusId should be in 'SmsgProduced', 'SmsgReceived', 'SmsgError', 'SmsgRejected' -->\n\n            <set field=\"systemMessage.statusId\" value=\"SmsgCancelled\"/>\n            <entity-update value-field=\"systemMessage\"/>\n            <!-- could use update server but no real point, all other could should call this to cancel:\n            <service-call name=\"update#moqui.service.message.SystemMessage\" in-map=\"[systemMessageId:systemMessageId, statusId:'SmsgCancelled']\"/>\n            -->\n        </actions>\n    </service>\n\n    <!-- =========== Send Services =========== -->\n\n    <service verb=\"queue\" noun=\"SystemMessage\">\n        <description>Queue an outgoing message. Creates a SystemMessage record for the outgoing message in the\n            Produced status. If sendNow=true (default) will attempt to send it immediately (though asynchronously),\n            otherwise the message will be picked up the next time the send#ProducedSystemMessages service runs.</description>\n        <in-parameters>\n            <parameter name=\"systemMessageId\"><description>Sequenced if null, may be passed in (sequenced value\n                determined in advance) because sometimes this is needed as a reference ID inside a message.</description></parameter>\n\n            <auto-parameters entity-name=\"moqui.service.message.SystemMessage\" include=\"nonpk\"/>\n\n            <parameter name=\"systemMessageTypeId\" required=\"true\"/>\n            <parameter name=\"messageText\" required=\"true\"/>\n            <parameter name=\"systemMessageRemoteId\"><description>Required if the send service\n                (SystemMessageType.sendServiceName) requires it. The send#SystemMessageJsonRpc service does require it.</description></parameter>\n            <parameter name=\"statusId\" default-value=\"SmsgProduced\"/>\n            <parameter name=\"isOutgoing\" default-value=\"Y\"/>\n            <parameter name=\"initDate\" type=\"Timestamp\" default=\"ec.user.nowTimestamp\"/>\n\n            <parameter name=\"sendNow\" type=\"Boolean\" default=\"true\"/>\n        </in-parameters>\n        <out-parameters><parameter name=\"systemMessageId\"/></out-parameters>\n        <actions>\n            <service-call name=\"create#moqui.service.message.SystemMessage\" in-map=\"context\" out-map=\"context\" transaction=\"force-new\"/>\n\n            <if condition=\"sendNow\">\n                <service-call name=\"org.moqui.impl.SystemMessageServices.send#ProducedSystemMessage\"\n                        in-map=\"[systemMessageId:systemMessageId]\" async=\"true\"/>\n            </if>\n        </actions>\n    </service>\n    <service verb=\"queue\" noun=\"AckSystemMessage\">\n        <description>Call the service to produce an async acknowledgement message (SystemMessageType.produceAckServiceName).\n            If sendNow=true (default) will attempt to send it immediately (though asynchronously),\n            otherwise the message will be picked up the next time the send#ProducedSystemMessages service runs.</description>\n        <implements service=\"org.moqui.impl.SystemMessageServices.produce#AckSystemMessage\"/>\n        <actions>\n            <entity-find-one entity-name=\"moqui.service.message.SystemMessage\" value-field=\"origMessage\"/>\n            <if condition=\"!systemMessageRemoteId\"><set field=\"systemMessageRemoteId\" from=\"origMessage.systemMessageRemoteId\"/></if>\n\n            <if condition=\"origMessage.ackMessageId\">\n                <entity-find-one entity-name=\"moqui.service.message.SystemMessage\" value-field=\"ackMessage\">\n                    <field-map field-name=\"systemMessageId\" from=\"origMessage.ackMessageId\"/></entity-find-one>\n                <if condition=\"ackMessage.statusId != 'SmsgCancelled'\">\n                    <return error=\"true\" message=\"Ack message already sent [${origMessage.ackMessageId}] for SystemMessage [${systemMessageId}]\"/></if>\n            </if>\n            <if condition=\"!(origMessage.statusId in ['SmsgConsuming', 'SmsgConsumed', 'SmsgRejected', 'SmsgError'])\">\n                <return error=\"true\" message=\"Cannot send ack for SystemMessage [${systemMessageId}], in status [${origMessage.statusId}] and must be in Consuming, Consumed, Rejected, or Error\"/></if>\n\n            <set field=\"systemMessageType\" from=\"origMessage.'moqui.service.message.SystemMessageType'\"/>\n            <set field=\"produceAckServiceName\" from=\"systemMessageType.produceAckServiceName\"/>\n\n            <if condition=\"!produceAckServiceName\">\n                <return error=\"true\" message=\"While queueing ack message for system message [${systemMessageId}], type [${systemMessageType.systemMessageTypeId}] has no produceAckServiceName, not queueing.\"/></if>\n\n            <service-call name=\"${produceAckServiceName}\" out-map=\"produceOut\"\n                    in-map=\"[systemMessageId:systemMessageId, systemMessageRemoteId:systemMessageRemoteId]\"/>\n            <set field=\"systemMessageId\" from=\"produceOut.systemMessageId\"/>\n\n            <if condition=\"sendNow\">\n                <service-call name=\"org.moqui.impl.SystemMessageServices.send#ProducedSystemMessage\" async=\"true\"\n                        in-map=\"[systemMessageId:systemMessageId]\"/>\n            </if>\n        </actions>\n    </service>\n\n\n    <!-- TODO: this service called async by an anonymous-all scheduled job so needs anonymous-all here too, but should\n        find a way to restrict it when not called through the scheduled service so no anonymous-all service exists to do\n        this, tighten security on it a bit -->\n    <service verb=\"send\" noun=\"ProducedSystemMessage\" authenticate=\"anonymous-all\">\n        <description>Calls the send service (SystemMessageType.sendServiceName). Sets the SystemMessage status to\n            SmsgSending while sending, then to SmsgSent if successful or back to original status if not. If the initial\n            status is not SmsgProduced or SmsgError returns an error (generally means message already sent). If you\n            want to resend a message that is in a later status, first change the status to SmsgProduced.</description>\n        <in-parameters><parameter name=\"systemMessageId\" required=\"true\"/></in-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.service.message.SystemMessage\" value-field=\"systemMessage\"/>\n            <set field=\"initialStatusId\" from=\"systemMessage.statusId\"/>\n\n            <if condition=\"systemMessage.statusId != 'SmsgProduced' &amp;&amp; systemMessage.statusId != 'SmsgError'\">\n                <return error=\"true\" message=\"System message ${systemMessageId} has status ${systemMessage.statusId} and must be either SmsgProduced or SmsgError, not sending.\"/></if>\n\n            <if condition=\"!systemMessage.systemMessageTypeId\">\n                <return error=\"true\" message=\"System message ${systemMessageId} has no type, not sending\"/></if>\n            <set field=\"systemMessageType\" from=\"systemMessage.'moqui.service.message.SystemMessageType'\"/>\n            <set field=\"sendServiceName\" from=\"systemMessageType.sendServiceName\"/>\n\n            <if condition=\"systemMessage.systemMessageRemoteId\">\n                <set field=\"systemMessageRemote\" from=\"systemMessage.'moqui.service.message.SystemMessageRemote'\"/>\n                <if condition=\"systemMessageRemote.sendServiceName\">\n                    <set field=\"sendServiceName\" from=\"systemMessageRemote.sendServiceName\"/></if>\n            </if>\n\n            <if condition=\"!sendServiceName\">\n                <return error=\"true\" message=\"While sending system message ${systemMessageId} type ${systemMessageType.systemMessageTypeId} has no sendServiceName, not sending.\"/></if>\n\n            <!-- update the status to SmsgSending, in a separate TX -->\n            <service-call name=\"update#moqui.service.message.SystemMessage\" transaction=\"force-new\"\n                    in-map=\"[systemMessageId:systemMessageId, statusId:'SmsgSending', lastAttemptDate:ec.user.nowTimestamp]\"/>\n\n            <!-- put this in a try block with the follow up below in a finally and a Throwable catch to add message facade errors -->\n            <script>try {</script>\n            <service-call name=\"${sendServiceName}\" in-map=\"[systemMessageId:systemMessageId]\" out-map=\"sendOut\" transaction=\"force-new\"/>\n            <script>} catch (Throwable t) { ec.message.addError(t.toString()) } finally {</script>\n\n            <!-- if successful set status to SmsgSent, otherwise set back to previous status -->\n            <set field=\"nowDate\" from=\"ec.user.nowTimestamp\"/>\n            <if condition=\"ec.message.hasError()\"><then>\n                <set field=\"errorText\" from=\"ec.message.getErrorsString()\"/>\n                <!-- clear errors before calling services so they'll go through, and so this service won't blow up -->\n                <script>ec.message.clearErrors()</script>\n                <service-call name=\"update#moqui.service.message.SystemMessage\" transaction=\"force-new\"\n                        in-map=\"[systemMessageId:systemMessageId, statusId:initialStatusId, lastAttemptDate:nowDate,\n                            failCount:((systemMessage.failCount ?: 0) + 1)]\"/>\n                <service-call name=\"create#moqui.service.message.SystemMessageError\" transaction=\"force-new\"\n                        in-map=\"[systemMessageId:systemMessageId, errorDate:nowDate, attemptedStatusId:'SmsgSent',\n                            errorText:errorText]\"/>\n            </then><else>\n                <service-call name=\"update#moqui.service.message.SystemMessage\" transaction=\"force-new\"\n                        in-map=\"[systemMessageId:systemMessageId, statusId:'SmsgSent',\n                            remoteMessageId:sendOut?.remoteMessageId, processedDate:nowDate, lastAttemptDate:nowDate]\"/>\n            </else></if>\n            <script>}</script>\n        </actions>\n    </service>\n    <service verb=\"send\" noun=\"SystemMessageJsonRpc\">\n        <implements service=\"org.moqui.impl.SystemMessageServices.send#SystemMessage\"/>\n        <actions>\n            <entity-find-one entity-name=\"moqui.service.message.SystemMessage\" value-field=\"systemMessage\"/>\n            <set field=\"systemMessageType\" from=\"systemMessage.'moqui.service.message.SystemMessageType'\"/>\n\n            <if condition=\"!systemMessage.systemMessageRemoteId\">\n                <return error=\"true\" message=\"System message ${systemMessageId} has no systemMessageRemoteId, not sending.\"/></if>\n            <set field=\"systemMessageRemote\" from=\"systemMessage.'moqui.service.message.SystemMessageRemote'\"/>\n\n            <set field=\"serviceName\" from=\"systemMessageType.receiveServiceName ?: 'org.moqui.impl.SystemMessageServices.receive#IncomingSystemMessage'\"/>\n            <set field=\"inMap\" from=\"[systemMessageTypeId:systemMessageType.systemMessageTypeId,\n                    remoteMessageId:systemMessageId, messageText:systemMessage.messageText,\n                    authUsername:systemMessageRemote.username, authPassword:systemMessageRemote.password]\"/>\n            <script>receiveOutMap = ec.service.callJsonRpc(systemMessageRemote.sendUrl, serviceName, inMap)</script>\n            <set field=\"remoteMessageId\" from=\"receiveOutMap?.systemMessageIdList?.first()\"/>\n        </actions>\n    </service>\n    <service verb=\"send\" noun=\"SystemMessageRest\">\n        <implements service=\"org.moqui.impl.SystemMessageServices.send#SystemMessage\"/>\n        <actions>\n            <entity-find-one entity-name=\"moqui.service.message.SystemMessage\" value-field=\"systemMessage\"/>\n            <set field=\"systemMessageType\" from=\"systemMessage.'moqui.service.message.SystemMessageType'\"/>\n\n            <if condition=\"!systemMessage.systemMessageRemoteId\">\n                <return error=\"true\" message=\"System message ${systemMessageId} has no systemMessageRemoteId, not sending.\"/></if>\n            <set field=\"systemMessageRemote\" from=\"systemMessage.'moqui.service.message.SystemMessageRemote'\"/>\n\n            <script><![CDATA[\n                String urlExpand = ec.resource.expand(systemMessageRemote.sendUrl, \"systemMessage\", [remoteMessageId:systemMessage.systemMessageId,\n                        systemMessageTypeId:systemMessage.systemMessageTypeId, systemMessageRemoteId:systemMessage.systemMessageRemoteId], false)\n\n                org.moqui.util.RestClient restClient = ec.service.rest().method(org.moqui.util.RestClient.POST).uri(urlExpand)\n                        .addHeader(\"Content-Type\", systemMessageType.contentType ?: \"text/plain\").text(systemMessage.messageText)\n                if (!systemMessageRemote.messageAuthEnumId || \"SmatLogin\".equals(systemMessageRemote.messageAuthEnumId)) {\n                    restClient.basicAuth((String) systemMessageRemote.username, (String) systemMessageRemote.password)\n                } else {\n                    // TODO: support SmatHmacSha256\n                    ec.message.addError(\"Send REST message auth type ${systemMessageRemote.messageAuthEnumId} not supported for remote ${systemMessageRemote.systemMessageRemoteId}\")\n                    return\n                }\n\n                org.moqui.util.RestClient.RestResponse restResponse = restClient.call()\n                if (restResponse.statusCode < 200 || restResponse.statusCode >= 300) {\n                    String errMsg = restResponse.text()\n                    ec.message.addError(\"System message ${systemMessageId} send error response (${restResponse.statusCode}): ${errMsg}\")\n                    return\n                }\n                // responseText = restResponse.text()\n            ]]></script>\n\n            <!-- TODO: WebFacadeImpl.handleSystemMessage() will need to return a message ID before we can do this: <set field=\"remoteMessageId\" from=\"\"/> -->\n        </actions>\n    </service>\n    <service verb=\"send\" noun=\"SystemMessageDirectLocal\">\n        <implements service=\"org.moqui.impl.SystemMessageServices.send#SystemMessage\"/>\n        <actions>\n            <entity-find-one entity-name=\"moqui.service.message.SystemMessage\" value-field=\"systemMessage\"/>\n            <set field=\"systemMessageType\" from=\"systemMessage.'moqui.service.message.SystemMessageType'\"/>\n\n            <if condition=\"!systemMessage.systemMessageRemoteId\">\n                <return error=\"true\" message=\"System message ${systemMessageId} has no systemMessageRemoteId, not sending.\"/></if>\n            <set field=\"systemMessageRemote\" from=\"systemMessage.'moqui.service.message.SystemMessageRemote'\"/>\n\n            <set field=\"serviceName\" from=\"systemMessageType.receiveServiceName ?: 'org.moqui.impl.SystemMessageServices.receive#IncomingSystemMessage'\"/>\n            <set field=\"inMap\" from=\"[systemMessageTypeId:systemMessageType.systemMessageTypeId,\n                    remoteMessageId:systemMessageId, messageText:systemMessage.messageText,\n                    authUsername:systemMessageRemote.username, authPassword:systemMessageRemote.password]\"/>\n            <script>receiveOutMap = ec.service.sync().name(serviceName).parameters(inMap).call()</script>\n            <set field=\"remoteMessageId\" from=\"receiveOutMap?.systemMessageIdList?.first()\"/>\n        </actions>\n    </service>\n\n    <!-- =========== Receive Services =========== -->\n\n    <service verb=\"receive\" noun=\"IncomingSystemMessage\" allow-remote=\"true\">\n        <description>Call to receive a message (often through a remote interface). If there is a\n            SystemMessageType.receiveServiceName calls that service to save the message, otherwise creates a\n            SystemMessage record for the incoming message (in the Received status). Either way after saving\n            asynchronously calls the consume service based on the message type.</description>\n        <implements service=\"org.moqui.impl.SystemMessageServices.receive#SystemMessage\"/>\n        <actions>\n            <entity-find-one entity-name=\"moqui.service.message.SystemMessageType\" value-field=\"systemMessageType\"/>\n            <if condition=\"systemMessageType == null\">\n                <return error=\"true\" message=\"Message type ${systemMessageTypeId} not valid\"/></if>\n\n            <if condition=\"systemMessageType.receiveServiceName &amp;&amp;\n                    systemMessageType.receiveServiceName != 'org.moqui.impl.SystemMessageServices.receive#IncomingSystemMessage'\"><then>\n                <service-call name=\"${systemMessageType.receiveServiceName}\" out-map=\"context\" transaction=\"force-new\"\n                    in-map=\"context\"/>\n            </then><else>\n                <!-- while it shouldn't happen that the consume service is called before the tx for this service is\n                    committed, run it in a separate tx to make sure -->\n                <service-call name=\"create#moqui.service.message.SystemMessage\" out-map=\"context\" transaction=\"force-new\"\n                    in-map=\"context + [statusId:'SmsgReceived', isOutgoing:'N', initDate:ec.user.nowTimestamp]\"/>\n                <set field=\"systemMessageIdList\" from=\"[systemMessageId]\"/>\n            </else></if>\n\n            <!-- run consume async for each message -->\n            <iterate list=\"systemMessageIdList\" entry=\"systemMessageId\">\n                <service-call name=\"org.moqui.impl.SystemMessageServices.consume#ReceivedSystemMessage\"\n                    in-map=\"[systemMessageId:systemMessageId, allowError:false]\" async=\"true\"/>\n            </iterate>\n        </actions>\n    </service>\n\n    <!-- TODO: this service called async by an anonymous-all scheduled job so needs anonymous-all here too, but should\n        find a way to restrict it when not called through the scheduled service so no anonymous-all service exists to do\n        this, tighten security on it a bit -->\n    <service verb=\"consume\" noun=\"ReceivedSystemMessage\" authenticate=\"anonymous-all\" allow-remote=\"false\" transaction-timeout=\"1800\">\n        <description>\n            Calls the consume service (SystemMessageType.consumeServiceName). Sets the SystemMessage status to\n            SmsgConsuming while consuming, then to SmsgConsumed if successful or back to original status if not. If the initial\n            status is not SmsgReceived or SmsgError returns an error (generally means message already consumed). If you\n            want to resend a message that is in a later status, first change the status to SmsgReceived.\n\n            This uses a transaction timeout of 1800 seconds (30 minutes) as the default for the service and as the default for the\n            consume service configured on the SystemMessageType. For incoming messages that require even more processing\n            time it is best to break up the processing in a separate ServiceJob or other async service calls.\n        </description>\n        <in-parameters>\n            <parameter name=\"systemMessageId\" required=\"true\"/>\n            <parameter name=\"allowError\" type=\"Boolean\" default=\"false\"/>\n        </in-parameters>\n        <actions>\n            <!-- NOTE: don't use for-update, status updates are done in separate transactions so they are always done regardless of errors -->\n            <entity-find-one entity-name=\"moqui.service.message.SystemMessage\" value-field=\"systemMessage\"/>\n            <set field=\"initialStatusId\" from=\"systemMessage.statusId\"/>\n\n            <if condition=\"!(systemMessage.statusId == 'SmsgReceived' || (allowError &amp;&amp; systemMessage.statusId == 'SmsgError'))\">\n                <return error=\"true\" message=\"System message [${systemMessageId}] has status [${systemMessage.statusId}] and must be either SmsgReceived (or SmsgError if allowed), not consuming.\"/></if>\n\n            <if condition=\"!systemMessage.systemMessageTypeId\">\n                <return error=\"true\" message=\"System message [${systemMessageId}] has no systemMessageTypeId, not consuming.\"/></if>\n            <set field=\"systemMessageType\" from=\"systemMessage.'moqui.service.message.SystemMessageType'\"/>\n\n            <if condition=\"!systemMessageType.consumeServiceName\">\n                <return error=\"true\" message=\"While consuming system message [${systemMessageId}] system message type [${systemMessageType.systemMessageTypeId}] has no consumeServiceName, not consuming.\"/></if>\n\n            <!-- update the status to SmsgSending, in a separate TX -->\n            <service-call name=\"update#moqui.service.message.SystemMessage\" transaction=\"force-new\"\n                    in-map=\"[systemMessageId:systemMessageId, statusId:'SmsgConsuming', lastAttemptDate:ec.user.nowTimestamp]\"/>\n\n            <!-- put this in a try block with the follow up below in a finally and a Throwable catch to add message facade errors -->\n            <script>try {</script>\n            <service-call name=\"${systemMessageType.consumeServiceName}\" in-map=\"[systemMessageId:systemMessageId]\"\n                    out-map=\"consumeOut\" transaction=\"force-new\" transaction-timeout=\"1800\"/>\n            <script>} catch (Throwable t) { ec.message.addError(t.toString()) } finally {</script>\n\n            <!-- if successful set status to SmsgConsumed, otherwise set back to previous status -->\n            <set field=\"nowDate\" from=\"ec.user.nowTimestamp\"/>\n            <if condition=\"ec.message.hasError()\"><then>\n                <set field=\"errorText\" from=\"ec.message.getErrorsString()\"/>\n                <!-- clear errors before calling services so they'll go through, and so this service won't blow up -->\n                <script>ec.message.clearErrors()</script>\n                <service-call name=\"update#moqui.service.message.SystemMessage\" transaction=\"force-new\"\n                        in-map=\"[systemMessageId:systemMessageId, statusId:initialStatusId, lastAttemptDate:nowDate,\n                            failCount:((systemMessage.failCount ?: 0) + 1)]\"/>\n                <service-call name=\"create#moqui.service.message.SystemMessageError\" transaction=\"force-new\"\n                        in-map=\"[systemMessageId:systemMessageId, errorDate:nowDate, attemptedStatusId:'SmsgConsumed',\n                            errorText:errorText]\"/>\n            </then><else>\n                <if condition=\"!consumeOut.noStatusUpdate\">\n                    <service-call name=\"update#moqui.service.message.SystemMessage\" transaction=\"force-new\"\n                            in-map=\"[systemMessageId:systemMessageId, statusId:'SmsgConsumed',\n                                processedDate:nowDate, lastAttemptDate:nowDate]\"/>\n                    <if condition=\"systemMessageType.produceAckServiceName &amp;&amp; systemMessageType.produceAckOnConsumed == 'Y'\">\n                        <service-call name=\"${systemMessageType.produceAckServiceName}\"\n                                in-map=\"[systemMessageId:systemMessageId]\" transaction=\"force-new\"/>\n                    </if>\n                </if>\n            </else></if>\n            <script>}</script>\n        </actions>\n    </service>\n\n    <!-- ========== Scheduled services to handle incoming and outgoing messages ========== -->\n\n    <service verb=\"send\" noun=\"AllProducedSystemMessages\" authenticate=\"anonymous-all\">\n        <description>Meant to be run scheduled, this service tries to send outgoing (isOutgoing=Y) messages in the\n            SmsgProduced status. After retryLimit attempts will change the status to SmsgError.</description>\n        <in-parameters>\n            <parameter name=\"retryMinutes\" type=\"BigDecimal\" default=\"60\"/>\n            <parameter name=\"retryLimit\" type=\"Integer\" default=\"24\"/><!-- by default try for 1 day -->\n        </in-parameters>\n        <actions>\n            <set field=\"retryTimestamp\" from=\"new Timestamp((System.currentTimeMillis() - (retryMinutes * 60000)) as long)\"/>\n            <entity-find entity-name=\"moqui.service.message.SystemMessage\" list=\"smList\" limit=\"200\">\n                <econdition field-name=\"statusId\" value=\"SmsgProduced\"/>\n                <econdition field-name=\"isOutgoing\" value=\"Y\"/>\n                <econdition field-name=\"lastAttemptDate\" operator=\"less\" from=\"retryTimestamp\" or-null=\"true\"/>\n                <order-by field-name=\"initDate\"/><!-- get oldest first -->\n            </entity-find>\n            <iterate list=\"smList\" entry=\"sm\">\n                <if condition=\"sm.failCount &lt; retryLimit\">\n                    <service-call name=\"org.moqui.impl.SystemMessageServices.send#ProducedSystemMessage\"\n                            in-map=\"[systemMessageId:sm.systemMessageId]\" async=\"true\"/>\n                    <else>\n                        <service-call name=\"update#moqui.service.message.SystemMessage\" transaction=\"force-new\"\n                                in-map=\"[systemMessageId:sm.systemMessageId, statusId:'SmsgError',\n                                    lastAttemptDate:ec.user.nowTimestamp]\"/>\n                    </else>\n                </if>\n            </iterate>\n        </actions>\n    </service>\n    <service verb=\"consume\" noun=\"AllReceivedSystemMessages\" authenticate=\"anonymous-all\">\n        <description>Consume incoming (isOutgoing=N) SystemMessage records not already consumed (in the SmsgReceived\n            status). Messages in this state will normally have had an error in consuming. After retryLimit attempts\n            will change the status to SmsgError.</description>\n        <in-parameters>\n            <parameter name=\"retryMinutes\" type=\"BigDecimal\" default=\"10\"/>\n            <parameter name=\"retryLimit\" type=\"Integer\" default=\"3\"/>\n        </in-parameters>\n        <actions>\n            <set field=\"retryTimestamp\" from=\"new Timestamp((System.currentTimeMillis() - (retryMinutes * 60000)) as long)\"/>\n            <entity-find entity-name=\"moqui.service.message.SystemMessage\" list=\"smList\" limit=\"200\">\n                <econdition field-name=\"statusId\" value=\"SmsgReceived\"/>\n                <econdition field-name=\"isOutgoing\" value=\"N\"/>\n                <econdition field-name=\"lastAttemptDate\" operator=\"less\" from=\"retryTimestamp\" or-null=\"true\"/>\n                <order-by field-name=\"initDate\"/><!-- get oldest first -->\n            </entity-find>\n            <iterate list=\"smList\" entry=\"sm\">\n                <if condition=\"sm.failCount &lt; retryLimit\">\n                    <service-call name=\"org.moqui.impl.SystemMessageServices.consume#ReceivedSystemMessage\"\n                            in-map=\"[systemMessageId:sm.systemMessageId]\" async=\"true\"/>\n                    <else>\n                        <service-call name=\"update#moqui.service.message.SystemMessage\" transaction=\"force-new\"\n                                in-map=\"[systemMessageId:sm.systemMessageId, statusId:'SmsgError',\n                                    lastAttemptDate:ec.user.nowTimestamp]\"/>\n                    </else>\n                </if>\n            </iterate>\n        </actions>\n    </service>\n\n    <service verb=\"reset\" noun=\"SystemMessageInError\">\n        <in-parameters><parameter name=\"systemMessageId\" required=\"true\"/></in-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.service.message.SystemMessage\" value-field=\"systemMessage\"/>\n            <if condition=\"systemMessage.statusId != 'SmsgError'\"><return/></if>\n            <service-call name=\"update#moqui.service.message.SystemMessage\" in-map=\"[systemMessageId:systemMessageId,\n                    statusId:(systemMessage.isOutgoing == 'Y' ? 'SmsgProduced' : 'SmsgReceived'), failCount:0]\"/>\n        </actions>\n    </service>\n</services>\n"
  },
  {
    "path": "framework/service/org/moqui/impl/UserServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n\n    <service verb=\"set\" noun=\"Preference\" allow-remote=\"true\">\n        <in-parameters>\n            <parameter name=\"preferenceKey\" required=\"true\"/>\n            <parameter name=\"preferenceValue\"/>\n        </in-parameters>\n        <actions><script>ec.user.setPreference(preferenceKey, preferenceValue)</script></actions>\n    </service>\n\n    <service verb=\"login\" noun=\"UserAccount\" authenticate=\"anonymous-all\" allow-remote=\"false\">\n        <implements service=\"org.moqui.impl.UserServices.get#ExternalUserAuthcFactorInfo\"/>\n        <in-parameters>\n            <parameter name=\"username\"/>\n            <parameter name=\"password\"/>\n            <parameter name=\"code\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"loggedIn\" type=\"Boolean\"/>\n        </out-parameters>\n        <actions>\n            <if condition=\"ec.web?.sessionAttributes?.moquiPreAuthcUsername\"><then>\n                <!-- already pre-auth'ed, verify code below -->\n                <set field=\"username\" from=\"ec.web?.sessionAttributes?.moquiPreAuthcUsername\"/>\n            </then><else>\n                <!-- no pre-auth, try logging in (pre-auth if 2nd factor required) -->\n                <set field=\"loggedIn\" from=\"ec.user.loginUser(username, password)\"/>\n            </else></if>\n            <if condition=\"ec.web.sessionAttributes.moquiAuthcFactorRequired\"><then>\n                <if condition=\"code\"><then>\n                    <service-call name=\"org.moqui.impl.UserServices.validate#ExternalUserAuthcCode\"\n                            in-map=\"[code:code]\" out-map=\"validateOut\"/>\n                    <if condition=\"validateOut.verified\"><then>\n                        <set field=\"loggedIn\" from=\"ec.user.internalLoginUser(validateOut.username)\"/>\n                    </then><else>\n                        <message type=\"danger\" public=\"true\">Authentication code is not valid</message>\n                    </else></if>\n                </then><else>\n                    <service-call name=\"org.moqui.impl.UserServices.get#ExternalUserAuthcFactorInfo\" out-map=\"context\"/>\n                </else></if>\n            </then></if>\n        </actions>\n    </service>\n\n    <service verb=\"create\" noun=\"UserAccount\" authenticate=\"anonymous-all\" allow-remote=\"false\">\n        <in-parameters>\n            <auto-parameters entity-name=\"moqui.security.UserAccount\" include=\"nonpk\"><exclude field-name=\"currentPassword\"/>\n                <exclude field-name=\"resetPassword\"/><exclude field-name=\"passwordSalt\"/><exclude field-name=\"passwordHashType\"/>\n                <exclude field-name=\"passwordBase64\"/><exclude field-name=\"passwordSetDate\"/><exclude field-name=\"hasLoggedOut\"/>\n                <exclude field-name=\"disabledDateTime\"/><exclude field-name=\"successiveFailedLogins\"/>\n            </auto-parameters>\n            <parameter name=\"username\" required=\"true\"/>\n            <parameter name=\"newPassword\" required=\"true\"/>\n            <parameter name=\"newPasswordVerify\" required=\"true\"/>\n            <parameter name=\"requirePasswordChange\" default-value=\"N\"/>\n            <parameter name=\"disabled\" default-value=\"N\"/>\n            <parameter name=\"emailAddress\"><text-email/></parameter>\n        </in-parameters>\n        <out-parameters><parameter name=\"userId\" required=\"true\"/></out-parameters>\n        <actions>\n            <!-- see if username already in use (instead of catching on unique index) -->\n            <entity-find entity-name=\"moqui.security.UserAccount\" list=\"existingUaList\">\n                <econdition field-name=\"username\" ignore-case=\"true\"/></entity-find>\n            <if condition=\"existingUaList\"><return error=\"true\" message=\"Username [${username}] is already in use. Please choose another.\"/></if>\n\n            <if condition=\"emailAddress\">\n                <!-- see if emailAddress already in use (instead of catching on unique index) -->\n                <entity-find entity-name=\"moqui.security.UserAccount\" list=\"existingUaList\">\n                    <econdition field-name=\"emailAddress\" ignore-case=\"true\"/></entity-find>\n                <if condition=\"existingUaList\"><return error=\"true\" message=\"Email ${emailAddress} is already in use. Login with username ${existingUaList[0].username}\"/></if>\n            </if>\n\n            <service-call name=\"create#moqui.security.UserAccount\" out-map=\"context\" in-map=\"context\"/>\n            <service-call name=\"org.moqui.impl.UserServices.update#PasswordInternal\" out-map=\"updateOut\"\n                in-map=\"[userId:userId, newPassword:newPassword, newPasswordVerify:newPasswordVerify,\n                    requirePasswordChange:requirePasswordChange,disabled:disabled]\"/>\n            <if condition=\"updateOut.updateSuccessful &amp;&amp; !ec.message.hasError()\"><then>\n                <message public=\"true\" type=\"success\">Account created with username ${username}</message>\n            </then><else-if condition=\"updateOut.passwordIssues\">\n                <message public=\"true\" type=\"danger\">Because of password issues not creating account with username ${username}</message>\n                <return error=\"true\" message=\"Removed temporary account with username ${username} for password issues\"/>\n            </else-if></if>\n        </actions>\n    </service>\n    <service verb=\"update\" noun=\"UserAccount\">\n        <in-parameters>\n            <parameter name=\"userId\" required=\"true\"/>\n            <auto-parameters entity-name=\"moqui.security.UserAccount\" include=\"nonpk\">\n                <exclude field-name=\"currentPassword\"/><exclude field-name=\"resetPassword\"/><exclude field-name=\"passwordSalt\"/>\n                <exclude field-name=\"passwordHashType\"/><exclude field-name=\"passwordBase64\"/><exclude field-name=\"passwordSetDate\"/>\n                <exclude field-name=\"hasLoggedOut\"/><exclude field-name=\"disabledDateTime\"/><exclude field-name=\"successiveFailedLogins\"/></auto-parameters>\n        </in-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.security.UserAccount\" value-field=\"userAccount\"/>\n            <if condition=\"userAccount == null\"><return error=\"true\" message=\"User Account not found for User ID ${userId}\"/></if>\n\n            <if condition=\"username &amp;&amp; username != userAccount.username\">\n                <!-- see if username already in use -->\n                <entity-find entity-name=\"moqui.security.UserAccount\" list=\"existingUaList\">\n                    <econdition field-name=\"username\" ignore-case=\"true\"/></entity-find>\n                <if condition=\"existingUaList\"><return error=\"true\" message=\"Username [${username}] is already in use. Please choose another.\"/></if>\n            </if>\n            <if condition=\"emailAddress &amp;&amp; emailAddress != userAccount.emailAddress\">\n                <!-- see if emailAddress already in use (instead of catching on unique index) -->\n                <entity-find entity-name=\"moqui.security.UserAccount\" list=\"existingUaList\">\n                    <econdition field-name=\"emailAddress\" ignore-case=\"true\"/></entity-find>\n                <if condition=\"existingUaList\"><return error=\"true\" message=\"Email ${emailAddress} is already in use. Login with username ${existingUaList[0].username}\"/></if>\n            </if>\n\n            <service-call name=\"update#moqui.security.UserAccount\" in-map=\"context\"/>\n        </actions>\n    </service>\n\n    <service verb=\"increment\" noun=\"UserAccountFailedLogins\" authenticate=\"anonymous-all\" allow-remote=\"false\">\n        <in-parameters><parameter name=\"userId\" required=\"true\"/></in-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.security.UserAccount\" value-field=\"userAccount\"/>\n            <set field=\"userAccount.successiveFailedLogins\"\n                 from=\"userAccount.successiveFailedLogins ? userAccount.successiveFailedLogins + 1 : 1\"/>\n            <set field=\"maxFailures\" from=\"(ec.ecfi.confXmlRoot.first('user-facade').first('login').attribute('max-failures') ?: 3) as Integer\"/>\n            <!-- if successiveFailedLogins is greater than max in conf then disable account -->\n            <if condition=\"userAccount.successiveFailedLogins > maxFailures &amp;&amp; userAccount.disabled != 'Y'\">\n                <set field=\"userAccount.disabled\" value=\"Y\"/>\n                <set field=\"userAccount.disabledDateTime\" from=\"ec.user.nowTimestamp\"/>\n            </if>\n            <log level=\"warn\" message=\"User ${userId} failed logins [${userAccount.successiveFailedLogins}], maxFailures [${maxFailures}], disabled [${userAccount.disabled}]\"/>\n            <entity-update value-field=\"userAccount\"/>\n        </actions>\n    </service>\n\n    <service verb=\"update\" noun=\"Password\" authenticate=\"anonymous-all\" allow-remote=\"true\">\n        <description>Set a user's password. The userId must match the current user and the oldPassword must match the\n            user's currentPassword or special permission is required, or user has already pre-authenticated and specified an authz code.</description>\n        <in-parameters>\n            <parameter name=\"userId\"><description>Defaults to the current userId in the ExecutionContext.</description></parameter>\n            <parameter name=\"username\"><description>May be used instead of userId to identify user.</description></parameter>\n            <parameter name=\"oldPassword\" required=\"true\"><description>Ignored if user has password admin permissions.</description></parameter>\n            <parameter name=\"newPassword\" required=\"true\"/>\n            <parameter name=\"newPasswordVerify\" required=\"true\"/>\n            <parameter name=\"code\"><description>Second factor authentication, required if second factor required for user (via group or authc factors configured)</description></parameter>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"username\"/>\n            <parameter name=\"passwordIssues\" type=\"Boolean\"/>\n            <parameter name=\"updateSuccessful\" type=\"Boolean\"/>\n        </out-parameters>\n        <actions>\n            <set field=\"hasPwAdminPermission\" from=\"ec.user.hasPermission('ADMIN_PASSWORD')\"/>\n            <set field=\"passwordIssues\" from=\"false\"/>\n            <set field=\"updateSuccessful\" from=\"false\"/>\n\n            <if condition=\"userId\"><then>\n                <entity-find-one entity-name=\"moqui.security.UserAccount\" value-field=\"userAccount\"/>\n            </then><else-if condition=\"username\">\n                <!-- NOTE: not using ignore-case here, for PW update required exact username match -->\n                <entity-find-one entity-name=\"moqui.security.UserAccount\" value-field=\"userAccount\">\n                    <field-map field-name=\"username\"/></entity-find-one>\n            </else-if><else>\n                <entity-find-one entity-name=\"moqui.security.UserAccount\" value-field=\"userAccount\">\n                    <field-map field-name=\"userId\" from=\"ec.user.userId\"/></entity-find-one>\n            </else></if>\n            <if condition=\"userAccount == null\"><return message=\"Could not find user with name ${username}\" public=\"true\" type=\"danger\"/></if>\n            <set field=\"userId\" from=\"userAccount.userId\"/>\n            <set field=\"username\" from=\"userAccount.username\"/>\n\n            <if condition=\"!hasPwAdminPermission\">\n                <if condition=\"ec.user.userId &amp;&amp; userId != ec.user.userId\">\n                    <return message=\"Cannot update the password of another user without password admin permission\" public=\"true\" type=\"danger\"/></if>\n\n                <!--\n                - if code verified and pre-auth don't require oldPassword\n                - if oldPassword and code check oldPassword first, if wrong don't want to check code (might use up single use code)\n                approach: if pre-auth and code specified check code first, otherwise check after oldPassword\n                -->\n                <!-- see if a second authc factor is required for this user, if so require authc code to update password -->\n                <service-call name=\"org.moqui.impl.UserServices.get#UserAuthcFactorRequired\" out-map=\"context\">\n                    <field-map field-name=\"userId\"/></service-call>\n                <set field=\"codeVerified\" from=\"false\"/>\n                <if condition=\"secondFactorRequired\">\n                    <!-- NOTE: don't check for missing code here, do that after oldPassword verify so if it verifies we can pre-auth -->\n                    <!-- if pre-auth and code specified check code first, otherwise check after oldPassword -->\n                    <if condition=\"ec.web?.sessionAttributes?.moquiPreAuthcUsername || ec.user.username\">\n                        <service-call name=\"org.moqui.impl.UserServices.validate#UserAuthcFactorCode\"\n                                in-map=\"[userId:userId, code:code]\" out-map=\"validateCodeOut\"/>\n                        <if condition=\"!validateCodeOut.verified\">\n                            <return message=\"Authentication code not valid\" public=\"true\" type=\"danger\"/></if>\n                        <set field=\"codeVerified\" from=\"true\"/>\n                    </if>\n                </if>\n\n                <!-- compare the passwords, encrypted, skip in special case for pre-auth user if a second factor required -->\n                <!-- codeVerified is only true if user pre-auth'ed and code verified above -->\n                <if condition=\"!codeVerified\">\n                    <if condition=\"!oldPassword\">\n                        <return message=\"Please enter current password\" public=\"true\" type=\"danger\"/></if>\n\n                    <script>\n                        def token = new org.apache.shiro.authc.UsernamePasswordToken((String) userAccount.username, (String) oldPassword)\n                        def info = new org.apache.shiro.authc.SimpleAuthenticationInfo(userAccount.username, userAccount.currentPassword,\n                                userAccount.passwordSalt ? new org.apache.shiro.lang.util.SimpleByteSource((String) userAccount.passwordSalt) : '', \"moquiRealm\")\n                    </script>\n                    <if condition=\"!userAccount.currentPassword || !ec.ecfi.getCredentialsMatcher(userAccount.passwordHashType, 'Y'.equals(userAccount.passwordBase64)).doCredentialsMatch(token, info)\">\n                        <if condition=\"userAccount.resetPassword\"><then>\n                            <!-- try the resetPassword -->\n                            <script>\n                                info = new org.apache.shiro.authc.SimpleAuthenticationInfo(userAccount.username, userAccount.resetPassword,\n                                        userAccount.passwordSalt ? new org.apache.shiro.lang.util.SimpleByteSource((String) userAccount.passwordSalt) : '', \"moquiRealm\")\n                            </script>\n                            <if condition=\"!ec.ecfi.getCredentialsMatcher(userAccount.passwordHashType, 'Y'.equals(userAccount.passwordBase64)).doCredentialsMatch(token, info)\">\n                                <return message=\"Password did not match current password or reset password for user ${username}\" public=\"true\" type=\"danger\"/></if>\n                        </then><else>\n                            <return message=\"Password incorrect for user ${username}\" public=\"true\" type=\"danger\"/>\n                        </else></if>\n                    </if>\n\n                    <if condition=\"secondFactorRequired\">\n                        <if condition=\"!code\">\n                            <!-- at this point oldPassword verified but no code specified, so pre-auth so they don't have to specify oldPassword again -->\n                            <if condition=\"ec.web?.sessionAttributes\">\n                                <set field=\"ec.web?.sessionAttributes?.moquiPreAuthcUsername\" from=\"username\"/>\n                                <!-- TODO: consider calling internalLoginUser for other logic like maybe pw change required or pw expired, etc... -->\n                            </if>\n                            <return message=\"Authentication code required for user ${username}\" public=\"true\" error=\"true\"/>\n                        </if>\n\n                        <service-call name=\"org.moqui.impl.UserServices.validate#UserAuthcFactorCode\"\n                                in-map=\"[userId:userId, code:code]\" out-map=\"validateCodeOut\"/>\n                        <if condition=\"!validateCodeOut.verified\">\n                            <return message=\"Authentication code not valid\" public=\"true\" type=\"danger\"/></if>\n                    </if>\n                </if>\n            </if>\n\n            <service-call name=\"org.moqui.impl.UserServices.update#PasswordInternal\" out-map=\"context\"\n                    in-map=\"[userId:userId, newPassword:newPassword, newPasswordVerify:newPasswordVerify]\"/>\n            <if condition=\"updateSuccessful &amp;&amp; !ec.message.hasError()\">\n                <message public=\"true\" type=\"success\">Password updated for user ${userAccount.username}</message></if>\n        </actions>\n    </service>\n    <service verb=\"update\" noun=\"PasswordInternal\" authenticate=\"false\" allow-remote=\"false\">\n        <in-parameters>\n            <parameter name=\"userId\" required=\"true\"/>\n            <parameter name=\"newPassword\" required=\"true\"/>\n            <parameter name=\"newPasswordVerify\" required=\"true\"/>\n            <parameter name=\"requirePasswordChange\" default-value=\"N\"/>\n            <parameter name=\"disabled\" default-value=\"N\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"passwordIssues\" type=\"Boolean\"/>\n            <parameter name=\"updateSuccessful\" type=\"Boolean\"/>\n        </out-parameters>\n        <actions>\n            <set field=\"passwordIssues\" from=\"false\"/>\n            <set field=\"updateSuccessful\" from=\"false\"/>\n            <entity-find-one entity-name=\"moqui.security.UserAccount\" value-field=\"userAccount\"/>\n            <if condition=\"userAccount == null\">\n                <return message=\"Cannot update password, Account not found with ID ${userId}\" public=\"true\" type=\"danger\"/></if>\n\n            <!-- check a bunch of stuff about the new password -->\n            <set field=\"passwordNode\" from=\"ec.ecfi.confXmlRoot.first('user-facade').first('password')\"/>\n\n            <if condition=\"newPassword != newPasswordVerify\">\n                <message public=\"true\" type=\"danger\">New Password and New Password Verify do not match</message>\n                <set field=\"passwordIssues\" from=\"true\"/>\n            </if>\n\n            <set field=\"minLength\" from=\"passwordNode.attribute('min-length')\" default-value=\"8\" type=\"Integer\"/>\n            <if condition=\"newPassword.length() &lt; minLength\">\n                <message public=\"true\" type=\"warning\">Password shorter than ${minLength} characters</message>\n                <set field=\"passwordIssues\" from=\"true\"/>\n            </if>\n\n            <set field=\"minDigits\" from=\"passwordNode.attribute('min-digits')\" default-value=\"1\" type=\"Integer\"/>\n            <set field=\"digits\" from=\"countChars(newPassword, true, false, false)\"/>\n            <if condition=\"digits &lt; minDigits\">\n                <message public=\"true\" type=\"warning\">Password needs ${minDigits} digit/number characters</message>\n                <set field=\"passwordIssues\" from=\"true\"/>\n            </if>\n\n            <set field=\"minOthers\" from=\"passwordNode.attribute('min-others')\" default-value=\"1\" type=\"Integer\"/>\n            <set field=\"others\" from=\"countChars(newPassword, false, false, true)\"/>\n            <if condition=\"others &lt; minOthers\">\n                <message public=\"true\" type=\"warning\">Password needs ${minOthers} other characters (not letter or digit)</message>\n                <set field=\"passwordIssues\" from=\"true\"/>\n            </if>\n\n            <!-- don't log this by default, security hole: <log level=\"info\" message=\"newPassword=${newPassword}, length=${newPassword.length()}, digits=${digits}, others=${others}\"/> -->\n\n            <!-- if password is same as current don't allow it -->\n            <set field=\"hashedNewPassword\" from=\"ec.ecfi.getSimpleHash(newPassword, userAccount.passwordSalt, userAccount.passwordHashType, 'Y'.equals(userAccount.passwordBase64))\"/>\n            <!-- <log level=\"warn\" message=\"cur ${userAccount.currentPassword} : new ${hashedNewPassword}\"/> -->\n            <if condition=\"userAccount.currentPassword == hashedNewPassword\">\n                <message public=\"true\" type=\"danger\">New password is same as current password</message>\n                <set field=\"passwordIssues\" from=\"true\"/>\n            </if>\n\n            <!-- if password is in the history don't allow it -->\n            <set field=\"historyLimit\" from=\"passwordNode.attribute('history-limit')\" default-value=\"5\" type=\"Integer\"/>\n            <entity-find entity-name=\"moqui.security.UserPasswordHistory\" list=\"duplicateUserPasswordHistoryList\">\n                <econdition field-name=\"userId\" from=\"userId\"/>\n                <!-- can't query by this field since it is encrypted: <econdition field-name=\"password\" from=\"newPassword\"/> -->\n            </entity-find>\n            <iterate list=\"duplicateUserPasswordHistoryList\" entry=\"duplicateUserPasswordHistory\">\n                <set field=\"hashedNewPassword\" from=\"ec.ecfi.getSimpleHash(newPassword, duplicateUserPasswordHistory.passwordSalt, duplicateUserPasswordHistory.passwordHashType, 'Y'.equals(userAccount.passwordBase64))\"/>\n                <if condition=\"duplicateUserPasswordHistory.password == hashedNewPassword\">\n                    <message public=\"true\" type=\"warning\">Password was used in last ${historyLimit} passwords</message>\n                    <set field=\"passwordIssues\" from=\"true\"/>\n                </if>\n            </iterate>\n\n            <if condition=\"passwordIssues\"><return error=\"true\" message=\"Found issues with password so not updating\"/></if>\n            <!-- from here on the newPassword is considered okay -->\n\n            <!-- save history, then while more in history than password.@history-limit default 5 then remove oldest -->\n            <service-call name=\"create#moqui.security.UserPasswordHistory\"\n                    in-map=\"[userId:userId, password:userAccount.currentPassword, passwordSalt:userAccount.passwordSalt,\n                        passwordHashType:userAccount.passwordHashType, fromDate:ec.user.nowTimestamp]\"/>\n            <entity-find entity-name=\"moqui.security.UserPasswordHistory\" list=\"existingUserPasswordHistoryList\">\n                <econdition field-name=\"userId\" from=\"userId\"/>\n                <order-by field-name=\"fromDate\"/>\n            </entity-find>\n            <while condition=\"existingUserPasswordHistoryList.size() &gt; historyLimit\">\n                <entity-delete value-field=\"existingUserPasswordHistoryList.remove(0)\"/>\n            </while>\n\n            <!-- encrypt password (using password.@encrypt-hash-type default SHA-256) and save -->\n            <set field=\"salt\" from=\"ec.ecfi.randomSalt\"/>\n            <service-call name=\"update#moqui.security.UserAccount\">\n                <field-map field-name=\"userId\"/>\n                <field-map field-name=\"currentPassword\" from=\"ec.ecfi.getSimpleHash(newPassword, salt)\"/>\n                <field-map field-name=\"passwordSalt\" from=\"salt\"/>\n                <field-map field-name=\"passwordHashType\" from=\"ec.ecfi.passwordHashType\"/>\n                <field-map field-name=\"passwordBase64\" value=\"N\"/>\n                <field-map field-name=\"passwordSetDate\" from=\"ec.user.nowTimestamp\"/>\n                <field-map field-name=\"requirePasswordChange\"/>\n                <field-map field-name=\"resetPassword\" from=\"null\"/>\n                <field-map field-name=\"disabled\" from=\"disabled\"/>\n            </service-call>\n            <set field=\"updateSuccessful\" from=\"true\"/>\n        </actions>\n    </service>\n\n    <service verb=\"enable\" noun=\"UserAccount\">\n        <description>Enable a disabled account (set disabled=N, disabledDateTime=null, successiveFailedLogins=0)</description>\n        <in-parameters><parameter name=\"userId\" required=\"true\"/></in-parameters>\n        <actions>\n            <service-call name=\"update#moqui.security.UserAccount\"\n                    in-map=\"[userId:userId, disabled:'N', disabledDateTime:null, successiveFailedLogins:0]\"/>\n        </actions>\n    </service>\n    <service verb=\"disable\" noun=\"UserAccount\">\n        <description>Disable an account (set disabled=Y, disabledDateTime=now)</description>\n        <in-parameters><parameter name=\"userId\" required=\"true\"/></in-parameters>\n        <actions>\n            <!-- set disabledDateTime to null so that account is permanently disabled (won't auto enable after wait period) -->\n            <service-call name=\"update#moqui.security.UserAccount\" in-map=\"[userId:userId, disabled:'Y', disabledDateTime:null]\"/>\n        </actions>\n    </service>\n    <service verb=\"reset\" noun=\"Password\" authenticate=\"anonymous-all\" allow-remote=\"true\">\n        <in-parameters>\n            <parameter name=\"userId\"/>\n            <parameter name=\"username\"><description>May be used instead of userId to identify user.</description></parameter>\n            <parameter name=\"bodyParameters\" type=\"Map\" default=\"[:]\"/>\n            <parameter name=\"emailTemplateId\" default-value=\"PASSWORD_RESET\"/>\n        </in-parameters>\n        <actions>\n            <!-- find by userId -->\n            <entity-find-one entity-name=\"moqui.security.UserAccount\" value-field=\"userAccount\" for-update=\"true\"/>\n            <if condition=\"userAccount == null\">\n                <!-- find by username, no ignore-case here to require exact match for PW reset -->\n                <entity-find-one entity-name=\"moqui.security.UserAccount\" value-field=\"userAccount\" for-update=\"true\">\n                    <field-map field-name=\"username\"/></entity-find-one>\n            </if>\n            <if condition=\"userAccount == null\">\n                <!-- find by emailAddress in case it was entered instead of username -->\n                <entity-find-one entity-name=\"moqui.security.UserAccount\" value-field=\"userAccount\" for-update=\"true\">\n                    <field-map field-name=\"emailAddress\" from=\"username\"/></entity-find-one>\n            </if>\n            <if condition=\"userAccount == null\">\n                <message public=\"true\" type=\"danger\">Could not find account with username or email address ${username}</message>\n                <return error=\"true\" message=\"Account not found\"/>\n            </if>\n            <if condition=\"!userAccount.emailAddress\">\n                <message public=\"true\" type=\"danger\">Account with username ${username} does not have an email address</message>\n                <return error=\"true\" message=\"Account has no email address\"/>\n            </if>\n\n            <!-- reset the password to a random value -->\n            <set field=\"resetPassword\" from=\"getRandomString(12)\"/>\n            <set field=\"passwordNode\" from=\"ec.ecfi.confXmlRoot.first('user-facade').first('password')\"/>\n            <set field=\"userAccount.resetPassword\" from=\"ec.ecfi.getSimpleHash(resetPassword, userAccount.passwordSalt, userAccount.passwordHashType, 'Y'.equals(userAccount.passwordBase64))\"/>\n            <set field=\"userAccount.requirePasswordChange\" from=\"(passwordNode.attribute('email-require-change') == 'true') ? 'Y' : 'N'\"/>\n            <entity-update value-field=\"userAccount\"/>\n\n            <!-- send an email with the new password -->\n            <service-call name=\"org.moqui.impl.EmailServices.send#EmailTemplate\" async=\"true\">\n                <field-map field-name=\"emailTemplateId\"/>\n                <field-map field-name=\"toAddresses\" from=\"userAccount.emailAddress\"/>\n                <field-map field-name=\"bodyParameters\" from=\"bodyParameters + [userAccount:userAccount, resetPassword:resetPassword]\"/>\n            </service-call>\n            <message public=\"true\" type=\"success\">A reset password was sent to the email of username ${userAccount.username}. This password may only be used to change your password. Your current password is still valid.</message>\n            <if condition=\"userAccount.requirePasswordChange == 'Y'\"><message public=\"true\" type=\"info\">You must change your password before login.</message></if>\n        </actions>\n    </service>\n\n    <service verb=\"create\" noun=\"InitialAdminAccount\" authenticate=\"anonymous-all\">\n        <in-parameters>\n            <parameter name=\"username\" required=\"true\"/>\n            <parameter name=\"newPassword\" required=\"true\"/>\n            <parameter name=\"newPasswordVerify\" required=\"true\"/>\n            <parameter name=\"userFullName\"/>\n            <parameter name=\"emailAddress\"><text-email/></parameter>\n        </in-parameters>\n        <out-parameters><parameter name=\"userId\" required=\"true\"/></out-parameters>\n        <actions>\n            <!-- only allow this if there are no user accounts, other than the _NA_ UserAccount which is in seed data -->\n            <if condition=\"ec.entity.find('moqui.security.UserAccount').count() > 1\">\n                <return error=\"true\" message=\"Can only create initial admin account if there are no UserAccount records\"/></if>\n            <service-call name=\"org.moqui.impl.UserServices.create#UserAccount\" in-map=\"context\" out-map=\"context\"/>\n            <service-call name=\"create#moqui.security.UserGroupMember\" in-map=\"[userId:userId, userGroupId:'ADMIN']\"/>\n        </actions>\n    </service>\n\n    <!-- ================================================= -->\n    <!-- ====== User Authentication Factor Services ====== -->\n    <!-- ================================================= -->\n\n    <service verb=\"get\" noun=\"ExternalUserAuthcFactorInfo\" authenticate=\"anonymous-view\">\n        <out-parameters>\n            <parameter name=\"secondFactorRequired\" type=\"Boolean\"/>\n            <parameter name=\"factorTypeEnumIds\" type=\"Set\"/>\n            <parameter name=\"factorTypeDescriptions\" type=\"List\"/>\n            <parameter name=\"sendableFactors\" type=\"List\"><parameter name=\"sendableFactor\" type=\"Map\">\n                <parameter name=\"factorId\"/>\n                <parameter name=\"factorTypeEnumId\"/>\n                <parameter name=\"factorOption\"/>\n            </parameter></parameter>\n        </out-parameters>\n        <actions>\n            <set field=\"username\" from=\"ec.web?.sessionAttributes?.moquiPreAuthcUsername ?: ec.user.username\"/>\n            <entity-find-one entity-name=\"moqui.security.UserAccount\" value-field=\"userAccount\">\n                <field-map field-name=\"username\"/></entity-find-one>\n            <if condition=\"userAccount == null\"><return error=\"true\" message=\"No user pre-authenticated\"/></if>\n\n            <service-call name=\"org.moqui.impl.UserServices.get#UserAuthcFactorInfo\" out-map=\"context\">\n                <field-map field-name=\"userId\" from=\"userAccount.userId\"/></service-call>\n        </actions>\n    </service>\n    <service verb=\"get\" noun=\"UserAuthcFactorInfo\">\n        <in-parameters>\n            <parameter name=\"userId\" required=\"true\"/>\n            <parameter name=\"getFactorsIfNotRequired\" type=\"Boolean\" default=\"true\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"secondFactorRequired\" type=\"Boolean\"/>\n            <parameter name=\"factorTypeEnumIds\" type=\"Set\"/>\n            <parameter name=\"factorTypeDescriptions\" type=\"List\"/>\n            <parameter name=\"sendableFactors\" type=\"List\"><parameter name=\"sendableFactor\" type=\"Map\">\n                <parameter name=\"factorId\"/>\n                <parameter name=\"factorTypeEnumId\"/>\n                <parameter name=\"factorOption\"/>\n            </parameter></parameter>\n        </out-parameters>\n        <actions>\n            <service-call name=\"org.moqui.impl.UserServices.get#UserAuthcFactorRequired\" out-map=\"context\">\n                <field-map field-name=\"userId\"/></service-call>\n\n            <!-- getFactorsIfNotRequired supported here, return now if false AND secondFactorRequired is false -->\n            <if condition=\"!getFactorsIfNotRequired &amp;&amp; !secondFactorRequired\">\n                <return/></if>\n\n            <entity-find entity-name=\"moqui.security.UserAuthcFactor\" list=\"userAuthcFactorList\">\n                <date-filter/><econdition field-name=\"userId\"/>\n                <econdition field-name=\"fromFactorId\" operator=\"is-null\"/>\n                <!-- was condition in post-find iterate: userAuthcFactor.factorTypeEnumId != UafSingleUse &amp;&amp; !userAuthcFactor.fromFactorId -->\n            </entity-find>\n\n            <!-- if user has no authc factors configured allow code via UserAccount.emailAddress -->\n            <!-- NOTE: org.moqui.impl.UserServices.send#AuthcCode looks for a special factorId of 'UserAccountEmail' for this purpose -->\n            <if condition=\"userAuthcFactorList.size() == 0\">\n                <entity-find-one entity-name=\"moqui.security.UserAccount\" value-field=\"userAccount\"/>\n                <if condition=\"userAccount?.emailAddress\">\n                    <set field=\"userAuthcFactorList\" from=\"[[factorId:'UserAccountEmail', userId:userId,\n                            factorTypeEnumId:'UafEmail', factorOption:userAccount.emailAddress]]\"/>\n                </if>\n            </if>\n\n            <set field=\"factorTypeEnumIds\" from=\"new TreeSet(userAuthcFactorList*.factorTypeEnumId)\"/>\n            <set field=\"factorTypeDescriptions\" from=\"new LinkedList(factorTypeEnumIds.collect({\n                ec.entity.find('moqui.basic.Enumeration').condition('enumId', it).one()?.description ?: it }))\"/>\n\n            <set field=\"sendableFactors\" from=\"[]\"/>\n            <iterate list=\"userAuthcFactorList\" entry=\"factor\">\n                <if condition=\"factor.factorTypeEnumId in ['UafEmail', 'UafSms']\">\n                    <script>sendableFactors.add([factorId:factor.factorId, factorTypeEnumId:factor.factorTypeEnumId,\n                                                 factorOption:factor.factorOption])</script>\n                </if>\n            </iterate>\n        </actions>\n    </service>\n    <service verb=\"get\" noun=\"UserAuthcFactorRequired\" authenticate=\"false\">\n        <!-- NOTE: has authenticate=\"false\" for use in MoquiShiroRealm during login, not anonymous-view/-all to not open it up that much -->\n        <description>This service is the definition of when a 2nd factor is required for a user, used in MoquiShiroRealm and elsewhere</description>\n        <in-parameters><parameter name=\"userId\" required=\"true\"/></in-parameters>\n        <out-parameters><parameter name=\"secondFactorRequired\" type=\"Boolean\"/></out-parameters>\n        <actions>\n            <entity-find-count entity-name=\"moqui.security.UserAuthcFactor\" count-field=\"uafCount\">\n                <date-filter/><econdition field-name=\"userId\"/></entity-find-count>\n            <if condition=\"uafCount == 0\"><then>\n                <entity-find-count entity-name=\"moqui.security.UserGroup\" count-field=\"userGroupCount\">\n                    <econdition field-name=\"userGroupId\" operator=\"in\" from=\"ec.user.getUserGroupIdSet(userId)\"/>\n                    <econdition field-name=\"requireAuthcFactor\" value=\"Y\"/>\n                </entity-find-count>\n                <set field=\"secondFactorRequired\" from=\"userGroupCount &gt; 0\"/>\n            </then><else>\n                <set field=\"secondFactorRequired\" from=\"true\"/>\n            </else></if>\n        </actions>\n    </service>\n\n    <service verb=\"validate\" noun=\"ExternalUserAuthcCode\" authenticate=\"anonymous-all\">\n        <in-parameters><parameter name=\"code\" required=\"true\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"verified\" type=\"Boolean\"/>\n            <parameter name=\"factorId\"/>\n            <parameter name=\"username\"/>\n        </out-parameters>\n        <actions>\n            <set field=\"username\" from=\"ec.web.sessionAttributes.moquiPreAuthcUsername\"/>\n            <if condition=\"!username\"><return error=\"true\" message=\"No user pre-authenticated, cannot send code\"/></if>\n\n            <entity-find-one entity-name=\"moqui.security.UserAccount\" value-field=\"userAccount\">\n                <field-map field-name=\"username\"/></entity-find-one>\n            <if condition=\"userAccount == null\"><return error=\"true\" message=\"User ${username} not found\"/></if>\n\n            <service-call name=\"org.moqui.impl.UserServices.validate#UserAuthcFactorCode\"\n                    in-map=\"[userId:userAccount.userId, code:code]\" out-map=\"context\"/>\n        </actions>\n    </service>\n    <service verb=\"validate\" noun=\"UserAuthcFactorCode\">\n        <!-- TODO: Limit the amount of attempts to validate authc code. Similar to Tarpit Locks. -->\n        <description>Determine whether a user inputted code is valid based on the current UserAuthcFactor entries for that user.</description>\n        <in-parameters>\n            <parameter name=\"userId\" required=\"true\"/>\n            <parameter name=\"code\" required=\"true\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"verified\" type=\"Boolean\"/>\n            <parameter name=\"factorId\"/>\n        </out-parameters>\n        <actions>\n            <set field=\"verified\" from=\"false\"/>\n\n            <!-- This is the setup script for verifying TOTP codes with the samstevens library. -->\n            <script>\n                import dev.samstevens.totp.time.*\n                import dev.samstevens.totp.code.*\n                TimeProvider timeProvider = new SystemTimeProvider()\n                CodeGenerator codeGenerator = new DefaultCodeGenerator()\n                CodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider)\n            </script>\n\n            <entity-find entity-name=\"moqui.security.UserAuthcFactor\" list=\"userAuthcFactorList\">\n                <date-filter/>\n                <econdition field-name=\"userId\"/>\n                <econdition field-name=\"factorTypeEnumId\" operator=\"not-equals\" value=\"UafEmail\"/>\n            </entity-find>\n\n            <iterate list=\"userAuthcFactorList\" entry=\"userAuthcFactor\">\n                <if condition=\"userAuthcFactor.factorTypeEnumId == 'UafSingleUse'\">\n                    <then>\n                        <!-- This checks to see whether the hashed single use code in the database is the input code hashed. -->\n                        <if condition=\"userAuthcFactor.factorOption == ec.ecfi.getSimpleHash(code, 'SaltySalt')\">\n                            <set field=\"factorId\" from=\"userAuthcFactor.factorId\"/>\n                            <set field=\"verified\" from=\"true\"/>\n                            <service-call name=\"update#moqui.security.UserAuthcFactor\" in-map=\"[factorId:userAuthcFactor.factorId,thruDate:ec.user.nowTimestamp]\"/>\n                            <if condition=\"userAuthcFactor.fromFactorId\"><service-call name=\"update#moqui.security.UserAuthcFactor\" in-map=\"[factorId:userAuthcFactor.fromFactorId,needsValidation:'N']\"/></if>\n                        </if>\n                    </then>\n                    <else-if condition=\"userAuthcFactor.factorTypeEnumId == 'UafTotp'\">\n                        <!-- This checks to see whether the secret key stored in the database matches the input code at this time -->\n                        <if condition=\"verifier.isValidCode(userAuthcFactor.factorOption, code)\">\n                            <set field=\"factorId\" from=\"userAuthcFactor.factorId\"/>\n                            <set field=\"verified\" from=\"true\"/>\n                            <service-call name=\"update#moqui.security.UserAuthcFactor\" in-map=\"[factorId:userAuthcFactor.factorId,needsValidation:'N']\"/>\n                        </if>\n                    </else-if>\n                </if>\n            </iterate>\n        </actions>\n    </service>\n\n    <service verb=\"create\" noun=\"SingleUseAuthcCodes\">\n        <description>Create multiple single use authentication factors.</description>\n        <in-parameters>\n            <parameter name=\"userId\" required=\"true\"/>\n            <parameter name=\"fromFactorId\" default=\"\"/>\n            <parameter name=\"thruDate\" type=\"Timestamp\" default=\"ec.user.nowTimestamp + 365\"/>\n            <parameter name=\"numberOfCodes\" type=\"Integer\" default=\"6\"/>\n        </in-parameters>\n        <out-parameters><parameter name=\"singleUseCodes\" type=\"List\"/></out-parameters>\n        <actions>\n            <if condition=\"numberOfCodes &gt; 21\"><return error=\"true\" message=\"Cannot create more than 21 codes at a time\"/></if>\n            <set field=\"factorTypeEnumId\" value=\"UafSingleUse\"/>\n            <set field=\"fromDate\" from=\"ec.user.nowTimestamp\"/>\n            <entity-find entity-name=\"moqui.security.UserAuthcFactor\" list=\"userAuthcFactorList\">\n                <date-filter/>\n                <econdition field-name=\"userId\"/>\n                <econdition field-name=\"fromFactorId\"/>\n                <econdition field-name=\"factorTypeEnumId\" value=\"UafSingleUse\"/>\n            </entity-find>\n\n            <!-- This automatically invalidates any current single use authentication codes that are from the fromFactorId -->\n            <iterate list=\"userAuthcFactorList\" entry=\"userAuthcFactor\">\n                <service-call name=\"update#moqui.security.UserAuthcFactor\"\n                        in-map=\"[factorId:userAuthcFactor.factorId, thruDate:ec.user.nowTimestamp]\"/>\n            </iterate>\n            <set field=\"singleUseCodes\" from=\"[]\"/>\n\n            <!-- This generates the actual codes using java's SecureRandom library (currently 8 integer digits) -->\n            <script>\n                for (int i = 0; i &lt; numberOfCodes; i++)\n                    singleUseCodes.add(new java.security.SecureRandom().nextInt(99999999).toString().padLeft(8,'0'))\n            </script>\n            <iterate list=\"singleUseCodes\" entry=\"code\">\n                <set field=\"factorOption\" from=\"ec.ecfi.getSimpleHash(code, 'SaltySalt')\"/>\n                <service-call name=\"create#moqui.security.UserAuthcFactor\" in-map=\"context\"/>\n            </iterate>\n        </actions>\n    </service>\n    <service verb=\"create\" noun=\"SingleUseAuthcFactor\">\n        <description>Create a single use authentication factor.</description>\n        <in-parameters>\n            <parameter name=\"userId\" required=\"true\"/>\n            <parameter name=\"fromFactorId\"/>\n            <parameter name=\"thruDate\" type=\"Timestamp\" default=\"ec.user.nowTimestamp + 1\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"factorId\"/>\n            <parameter name=\"code\"/>\n            <parameter name=\"thruDate\"/>\n        </out-parameters>\n        <actions>\n            <!-- If there is a fromFactorId, this automatically invalidates any current single use authentication codes that are from the fromFactorId -->\n            <if condition=\"fromFactorId\">\n                <entity-find entity-name=\"moqui.security.UserAuthcFactor\" list=\"userAuthcFactorList\">\n                    <date-filter/><econdition field-name=\"userId\"/><econdition field-name=\"fromFactorId\"/>\n                    <econdition field-name=\"factorTypeEnumId\" value=\"UafSingleUse\"/></entity-find>\n                <iterate list=\"userAuthcFactorList\" entry=\"userAuthcFactor\">\n                    <service-call name=\"update#moqui.security.UserAuthcFactor\" in-map=\"[factorId:userAuthcFactor.factorId, thruDate:ec.user.nowTimestamp]\"/>\n                </iterate>\n            </if>\n\n            <set field=\"factorTypeEnumId\" value=\"UafSingleUse\"/>\n            <set field=\"fromDate\" from=\"ec.user.nowTimestamp\"/>\n            <set field=\"code\" from=\"new java.security.SecureRandom().nextInt(999999).toString().padLeft(6,'0')\"/>\n            <set field=\"factorOption\" from=\"ec.ecfi.getSimpleHash(code, 'SaltySalt')\"/>\n            <service-call name=\"create#moqui.security.UserAuthcFactor\" in-map=\"context\" out-map=\"context\"/>\n        </actions>\n    </service>\n\n    <service verb=\"send\" noun=\"ExternalAuthcCode\" authenticate=\"anonymous-all\">\n        <description>Send authentication code for the factorId, looks up service to use based on factorTypeEnumId</description>\n        <in-parameters><parameter name=\"factorId\" required=\"true\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"singleUseFactorId\"/>\n            <!-- this service is for external use, don't return the code: <parameter name=\"code\"/> -->\n        </out-parameters>\n        <actions>\n            <set field=\"username\" from=\"ec.web?.sessionAttributes?.moquiPreAuthcUsername ?: ec.user.username\"/>\n            <if condition=\"!username\"><return error=\"true\" message=\"No user pre-authenticated, cannot send code\"/></if>\n            <entity-find-one entity-name=\"moqui.security.UserAccount\" value-field=\"userAccount\">\n                <field-map field-name=\"username\"/></entity-find-one>\n            <if condition=\"userAccount == null\"><return error=\"true\" message=\"User ${username} not found\"/></if>\n\n            <!-- special case for email OTP with UserAccount.emailAddress -->\n            <if condition=\"'UserAccountEmail'.equals(factorId)\">\n                <entity-find entity-name=\"moqui.security.UserAuthcFactor\" list=\"userAuthcFactorList\">\n                    <date-filter/><econdition field-name=\"userId\" from=\"userAccount.userId\"/>\n                    <econdition field-name=\"fromFactorId\" operator=\"is-null\"/>\n                </entity-find>\n                <if condition=\"userAuthcFactorList\">\n                    <return error=\"true\" message=\"User has other authentication factors configured, cannot use account email address\"/></if>\n\n                <service-call name=\"org.moqui.impl.UserServices.send#AuthcCodeUserAccountEmail\"\n                        in-map=\"[username:username]\" out-map=\"context\"/>\n                <return/>\n            </if>\n\n            <entity-find-one entity-name=\"moqui.security.UserAuthcFactor\" value-field=\"userAuthcFactor\">\n                <field-map field-name=\"factorId\"/></entity-find-one>\n            <if condition=\"userAuthcFactor == null\"><return error=\"true\" message=\"Authentication factor ${factorId} not found\"/></if>\n            <if condition=\"userAuthcFactor.userId != userAccount.userId\">\n                <return error=\"true\" message=\"Authentication Factor ${factorId} is not valid for ${userAccount.username}\"/></if>\n\n            <if condition=\"userAuthcFactor.factorTypeEnumId == 'UafEmail'\"><then>\n                <service-call name=\"org.moqui.impl.UserServices.send#AuthcCodeEmail\" in-map=\"[factorId:factorId]\" out-map=\"context\"/>\n            </then><else-if condition=\"userAuthcFactor.factorTypeEnumId == 'UafSms'\">\n                <service-call name=\"org.moqui.impl.UserServices.send#AuthcCodeSms\" in-map=\"[factorId:factorId]\" out-map=\"context\"/>\n            </else-if><else>\n                <return error=\"true\" message=\"Send code not supported for authc factor type ${userAuthcFactor.factorTypeEnumId}\"/>\n            </else></if>\n        </actions>\n    </service>\n    <service verb=\"send\" noun=\"AuthcCodeEmail\">\n        <description>\n            For public access (outside an admin app) this should be called from send#AuthcCode which does validation,\n                including verifying factor is owned by authc username in session.\n            Service to send an email with a single use code in it for verifying an email.\n        </description>\n        <in-parameters><parameter name=\"factorId\" required=\"true\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"singleUseFactorId\"/>\n            <parameter name=\"code\"/>\n        </out-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.security.UserAuthcFactor\" value-field=\"userAuthcFactor\">\n                <field-map field-name=\"factorId\"/></entity-find-one>\n            <set field=\"userId\" from=\"userAuthcFactor.userId\"/>\n            <set field=\"emailAddress\" from=\"userAuthcFactor.factorOption\"/>\n\n            <entity-find-one entity-name=\"moqui.security.UserAccount\" value-field=\"userAccount\">\n                <field-map field-name=\"userId\"/></entity-find-one>\n\n            <!-- create a new single use authc factor code and get it's factorId -->\n            <service-call name=\"org.moqui.impl.UserServices.create#SingleUseAuthcFactor\"\n                    in-map=\"[userId:userId, fromFactorId:factorId]\" out-map=\"createSuFactorOut\"/>\n            <set field=\"singleUseFactorId\" from=\"createSuFactorOut.factorId\"/>\n\n            <!-- send an email with the single use code -->\n            <service-call name=\"org.moqui.impl.EmailServices.send#EmailTemplate\" async=\"true\">\n                <field-map field-name=\"emailTemplateId\" value=\"SINGLE_USE_CODE\"/>\n                <field-map field-name=\"toAddresses\" from=\"emailAddress\"/>\n                <field-map field-name=\"bodyParameters\" from=\"[code:createSuFactorOut.code,\n                        thruDateString:ec.l10n.format(createSuFactorOut.thruDate, null)]\"/>\n            </service-call>\n            <message>Authentication code sent to ${emailAddress}</message>\n\n            <!-- if the email just sent to is not the main email address for the user send the main email address a notification email that an email with a code was sent -->\n            <if condition=\"userAccount.emailAddress &amp;&amp; userAccount.emailAddress != emailAddress\">\n                <service-call name=\"org.moqui.impl.EmailServices.send#EmailTemplate\" async=\"true\">\n                    <field-map field-name=\"emailTemplateId\" value=\"ADDED_EMAIL_AUTHC_FACTOR\"/>\n                    <field-map field-name=\"toAddresses\" from=\"userAccount.emailAddress\"/>\n                    <field-map field-name=\"bodyParameters\" from=\"[userEmail:emailAddress,\n                            thruDateString:ec.l10n.format(createSuFactorOut.thruDate, null)]\"/>\n                </service-call>\n            </if>\n        </actions>\n    </service>\n    <service verb=\"send\" noun=\"AuthcCodeUserAccountEmail\">\n        <in-parameters><parameter name=\"username\" required=\"true\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"singleUseFactorId\"/>\n            <parameter name=\"code\"/>\n        </out-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.security.UserAccount\" value-field=\"userAccount\">\n                <field-map field-name=\"username\"/></entity-find-one>\n            <set field=\"userId\" from=\"userAccount.userId\"/>\n            <set field=\"emailAddress\" from=\"userAccount.emailAddress\"/>\n\n            <!-- create a new single use authc factor code and get it's factorId -->\n            <service-call name=\"org.moqui.impl.UserServices.create#SingleUseAuthcFactor\"\n                    in-map=\"[userId:userId, fromFactorId:'UserAccountEmail']\" out-map=\"createSuFactorOut\"/>\n            <set field=\"singleUseFactorId\" from=\"createSuFactorOut.factorId\"/>\n\n            <!-- send an email with the single use code -->\n            <service-call name=\"org.moqui.impl.EmailServices.send#EmailTemplate\" async=\"true\">\n                <field-map field-name=\"emailTemplateId\" value=\"SINGLE_USE_CODE\"/>\n                <field-map field-name=\"toAddresses\" from=\"emailAddress\"/>\n                <field-map field-name=\"bodyParameters\" from=\"[code:createSuFactorOut.code,\n                        thruDateString:ec.l10n.format(createSuFactorOut.thruDate, null)]\"/>\n            </service-call>\n            <message>Authentication code sent to ${emailAddress}</message>\n        </actions>\n    </service>\n    <service verb=\"send\" noun=\"AuthcCodeSms\">\n        <description>\n            For public access (outside an admin app) this should be called from send#AuthcCode which does validation,\n            including verifying factor is owned by authc username in session.\n            Service to send a SMS message with a single use code in it.\n        </description>\n        <in-parameters><parameter name=\"factorId\" required=\"true\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"singleUseFactorId\"/>\n            <parameter name=\"code\"/>\n        </out-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.security.UserAuthcFactor\" value-field=\"userAuthcFactor\">\n                <field-map field-name=\"factorId\"/></entity-find-one>\n            <set field=\"userId\" from=\"userAuthcFactor.userId\"/>\n            <set field=\"contactNumber\" from=\"userAuthcFactor.factorOption\"/>\n\n            <!-- create a new single use authc factor code and get it's factorId -->\n            <service-call name=\"org.moqui.impl.UserServices.create#SingleUseAuthcFactor\"\n                    in-map=\"[userId:userId, fromFactorId:factorId]\" out-map=\"createSuFactorOut\"/>\n            <set field=\"singleUseFactorId\" from=\"createSuFactorOut.factorId\"/>\n\n            <!-- send a SMS message with the single use code -->\n            <service-call name=\"org.moqui.SmsServices.send#SmsMessage\">\n                <field-map field-name=\"contactNumber\"/>\n                <!-- NOTE: uses LocalizedMessage with original=UserAuthcOtpMessage, defined in CommonL10nData.xml -->\n                <field-map field-name=\"message\" from=\"ec.resource.expand('UserAuthcOtpMessage', null, [code:createSuFactorOut.code])\"/>\n            </service-call>\n            <message public=\"true\" type=\"success\">Authentication code sent to ${contactNumber}</message>\n\n            <!-- TODO consider always sending email to account address to let them know a code was sent, need variation in email text for SMS\n            <entity-find-one entity-name=\"moqui.security.UserAccount\" value-field=\"userAccount\">\n                <field-map field-name=\"userId\"/></entity-find-one>\n            <if condition=\"userAccount.emailAddress\">\n                <service-call name=\"org.moqui.impl.EmailServices.send#EmailTemplate\" async=\"true\">\n                    <field-map field-name=\"emailTemplateId\" value=\"ADDED_EMAIL_AUTHC_FACTOR\"/>\n                    <field-map field-name=\"toAddresses\" from=\"userAccount.emailAddress\"/>\n                    <field-map field-name=\"bodyParameters\" from=\"[userEmail:emailAddress,\n                            thruDateString:ec.l10n.format(createSuFactorOut.thruDate, null)]\"/>\n                </service-call>\n            </if>\n            -->\n        </actions>\n    </service>\n\n    <service verb=\"create\" noun=\"UserAuthcFactorEmail\">\n        <description>Creates a UserAuthcFactor entry for email</description>\n        <in-parameters>\n            <parameter name=\"userId\" required=\"true\"><description>User to enable Factor Method</description></parameter>\n            <parameter name=\"fromDate\" type=\"Timestamp\" default=\"ec.user.nowTimestamp\"/>\n            <parameter name=\"thruDate\" type=\"Timestamp\"/>\n            <parameter name=\"factorTypeEnumId\" default-value=\"UafEmail\"/>\n            <parameter name=\"factorOption\" required=\"true\"><text-email/></parameter>\n            <parameter name=\"needsValidation\" default-value=\"Y\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"factorId\"/>\n            <!-- no longer generates and sends code: <parameter name=\"singleUseFactorId\"/> <parameter name=\"code\"/> -->\n        </out-parameters>\n        <actions>\n            <entity-find-count entity-name=\"moqui.security.UserAuthcFactor\" count-field=\"userAuthcFactorCount\">\n                <date-filter/>\n                <econdition field-name=\"userId\"/>\n                <econdition field-name=\"factorOption\"/>\n            </entity-find-count>\n            <if condition=\"userAuthcFactorCount == 0\"><then>\n                <!-- If there are no UserAuthcFactor entries for this user with the same email, create the UserAuthcFactor. -->\n                <service-call name=\"create#moqui.security.UserAuthcFactor\" in-map=\"context\" out-map=\"context\"/>\n                <!-- don't send code email on create, do it manually if needed from UI:\n                <service-call name=\"org.moqui.impl.UserServices.send#AuthcCodeEmail\" in-map=\"[factorId:factorId]\" out-map=\"context\"/> -->\n            </then><else>\n                <!-- There is an UserAuthcFactor for this user that has the same email. Send error message to the user.-->\n                <return error=\"true\" message=\"Entry already exists for ${factorOption}\"/>\n            </else></if>\n        </actions>\n    </service>\n    <service verb=\"create\" noun=\"UserAuthcFactorSms\">\n        <description>Creates a UserAuthcFactor entry for SMS</description>\n        <in-parameters>\n            <parameter name=\"userId\" required=\"true\"><description>User to enable Factor Method</description></parameter>\n            <parameter name=\"fromDate\" type=\"Timestamp\" default=\"ec.user.nowTimestamp\"/>\n            <parameter name=\"thruDate\" type=\"Timestamp\"/>\n            <parameter name=\"factorTypeEnumId\" default-value=\"UafSms\"/>\n            <parameter name=\"factorOption\" required=\"true\"><matches regexp=\"^\\+?\\d[-\\. \\d]*\\d\\d$\" message=\"Please enter a valid phone number\"/></parameter>\n            <parameter name=\"needsValidation\" default-value=\"Y\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"factorId\"/>\n        </out-parameters>\n        <actions>\n            <entity-find-count entity-name=\"moqui.security.UserAuthcFactor\" count-field=\"userAuthcFactorCount\">\n                <date-filter/>\n                <econdition field-name=\"userId\"/>\n                <econdition field-name=\"factorOption\"/>\n            </entity-find-count>\n            <if condition=\"userAuthcFactorCount == 0\"><then>\n                <service-call name=\"create#moqui.security.UserAuthcFactor\" in-map=\"context\" out-map=\"context\"/>\n            </then><else>\n                <return error=\"true\" message=\"Entry already exists for ${factorOption}\"/>\n            </else></if>\n        </actions>\n    </service>\n\n    <service verb=\"create\" noun=\"UserAuthcFactorTotp\">\n        <description>Create an Authenticator App Factor.</description>\n        <in-parameters>\n            <parameter name=\"userId\" required=\"true\"/>\n            <parameter name=\"thruDate\" type=\"Timestamp\" default=\"ec.user.nowTimestamp + 365\"/>\n            <parameter name=\"needsValidation\" default-value=\"Y\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"factorId\"/>\n            <parameter name=\"factorOption\"/>\n        </out-parameters>\n        <actions>\n            <script>\n                import dev.samstevens.totp.secret.DefaultSecretGenerator\n                DefaultSecretGenerator gen = new DefaultSecretGenerator()\n            </script>\n            <set field=\"factorTypeEnumId\" value=\"UafTotp\"/>\n            <set field=\"fromDate\" from=\"ec.user.nowTimestamp\"/>\n            <set field=\"factorOption\" from=\"gen.generate()\"/>\n            <service-call name=\"create#moqui.security.UserAuthcFactor\" in-map=\"context\" out-map=\"context\"/>\n        </actions>\n    </service>\n    <service verb=\"setup\" noun=\"UserAuthcFactorTotp\">\n        <in-parameters>\n            <parameter name=\"factorId\"/>\n            <parameter name=\"userId\" required=\"true\"/>\n            <parameter name=\"thruDate\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"qrLabel\"/>\n            <parameter name=\"qrIssuer\"/>\n            <parameter name=\"qrSecret\"/>\n            <parameter name=\"qrUri\"/>\n            <parameter name=\"dataUri\"/>\n            <parameter name=\"needsValidation\"/>\n        </out-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.security.UserAccount\" value-field=\"userAccount\">\n                <field-map field-name=\"userId\" from=\"userId\"/></entity-find-one>\n\n            <if condition=\"!factorId\">\n                <then>\n                    <!-- If this service is called and no factorId is specified it will create a Totp UserAuthcFactor entry -->\n                    <service-call name=\"org.moqui.impl.UserServices.create#UserAuthcFactorTotp\"\n                                  in-map=\"[userId:userId, thruDate:thruDate]\" out-map=\"userFactor\"/>\n                </then><else>\n                    <!-- If this service is called and a factorId is specified, get the userFactor from the database -->\n                    <entity-find-one entity-name=\"moqui.security.UserAuthcFactor\" value-field=\"userFactor\">\n                        <field-map field-name=\"factorId\"/>\n                        <field-map field-name=\"userId\"/>\n                    </entity-find-one>\n                </else>\n            </if>\n            <set field=\"factorOption\" from=\"userFactor.factorOption\"/>\n\n            <script>\n                import dev.samstevens.totp.qr.*\n                import dev.samstevens.totp.code.HashingAlgorithm\n                import dev.samstevens.totp.util.Utils\n                QrData data = new QrData.Builder().label(userAccount.username).secret(factorOption).issuer(ec.web.getHostName(false))\n                    .algorithm(HashingAlgorithm.SHA1).digits(6).period(30).build()\n                QrGenerator generator = new ZxingPngQrGenerator()\n            </script>\n            <set field=\"qrLabel\" from=\"data.label\"/><set field=\"qrIssuer\" from=\"data.issuer\"/><set field=\"qrSecret\" from=\"data.secret\"/>\n            <set field=\"dataUri\" from=\"Utils.getDataUriForImage(generator.generate(data), generator.getImageMimeType())\"/>\n            <set field=\"needsValidation\" from=\"userFactor.needsValidation\"/>\n        </actions>\n    </service>\n\n    <service verb=\"invalidate\" noun=\"UserAuthcFactorEntry\">\n        <description>Invalidate a factorId and any UserAuthcFactor entries that are dependant on that factorId.</description>\n        <in-parameters>\n            <parameter name=\"factorId\" required=\"true\"/>\n            <parameter name=\"userId\" required=\"true\"/>\n            <parameter name=\"fromFactorId\"/>\n        </in-parameters>\n        <actions>\n            <entity-find entity-name=\"moqui.security.UserAuthcFactor\" list=\"userAuthcFactorList\">\n                <date-filter/><econdition field-name=\"userId\"/></entity-find>\n\n            <iterate list=\"userAuthcFactorList\" entry=\"userAuthcFactor\">\n                <if condition=\"userAuthcFactor.fromFactorId == factorId || userAuthcFactor.factorId == factorId\">\n                    <service-call name=\"update#moqui.security.UserAuthcFactor\" in-map=\"[factorId:userAuthcFactor.factorId, thruDate:ec.user.nowTimestamp]\"/>\n                </if>\n            </iterate>\n        </actions>\n    </service>\n</services>\n"
  },
  {
    "path": "framework/service/org/moqui/impl/WikiServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a \nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n    <service verb=\"get\" noun=\"WikiPageInfoById\">\n        <in-parameters><parameter name=\"wikiPageId\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"wikiSpaceId\"/>\n            <parameter name=\"pagePath\"/>\n            <parameter name=\"wikiSpace\" type=\"org.moqui.entity.EntityValue\"/>\n            <parameter name=\"pageReference\" type=\"org.moqui.resource.ResourceReference\"/>\n            <parameter name=\"attachmentList\" type=\"List\"><parameter name=\"attachmentInfo\" type=\"Map\">\n                <parameter name=\"filename\"/><parameter name=\"contentType\"/><parameter name=\"lastModified\" type=\"Long\"/>\n            </parameter></parameter>\n            <parameter name=\"pageLocation\"/>\n            <parameter name=\"pageName\"/>\n            <parameter name=\"parentPath\"/>\n            <parameter name=\"breadcrumbMapList\" type=\"List\"><parameter name=\"breadcrumbMap\">\n                <parameter name=\"pageName\"/><parameter name=\"pagePath\"/></parameter></parameter>\n        </out-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.resource.wiki.WikiPage\" value-field=\"wikiPage\"/>\n            <if condition=\"wikiPage == null\"><return message=\"Page not found with ID ${wikiPageId}\"/></if>\n            <set field=\"wikiSpaceId\" from=\"wikiPage.wikiSpaceId\"/>\n            <set field=\"pagePath\" from=\"wikiPage.pagePath\"/>\n            <service-call name=\"org.moqui.impl.WikiServices.get#WikiPageInfo\" in-map=\"context\" out-map=\"context\"/>\n        </actions>\n    </service>\n    <service verb=\"get\" noun=\"PublishedWikiPageText\">\n        <description>Get the published version of a wiki page by its space and path. If there is no published version behaves as if no page was found.</description>\n        <in-parameters>\n            <parameter name=\"wikiSpaceId\" required=\"true\"/>\n            <parameter name=\"pagePath\"/><!-- not required because for root page will be empty -->\n            <parameter name=\"pagePathList\" type=\"List\">\n                <description>Alternative to pagePath when caller has a list of path elements (instead of forward slash separated string)</description>\n                <parameter name=\"pathElement\" type=\"String\"/>\n            </parameter>\n            <parameter name=\"versionName\"><description>Meant for testing, get this version instead of published.\n                To eliminate exposure of non-published versions explicitly set this to null.</description></parameter>\n            <parameter name=\"getPageText\" type=\"Boolean\" default=\"true\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"wikiPageId\"/>\n            <parameter name=\"publishedVersionName\"/>\n            <parameter name=\"versionName\"/>\n            <parameter name=\"pageReference\" type=\"org.moqui.resource.ResourceReference\"/>\n            <parameter name=\"pageLocation\"/>\n            <parameter name=\"pageText\"/>\n        </out-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.resource.wiki.WikiSpace\" value-field=\"wikiSpace\" cache=\"true\">\n                <field-map field-name=\"wikiSpaceId\"/></entity-find-one>\n            <if condition=\"wikiSpace == null\"><return message=\"No space found with ID ${wikiSpaceId}\"/></if>\n\n            <if condition=\"pagePathList &amp;&amp; !pagePath\"><set field=\"pagePath\" from=\"pagePathList.join('/')\"/></if>\n            <entity-find entity-name=\"moqui.resource.wiki.WikiPage\" list=\"wikiPageList\" cache=\"true\">\n                <econdition field-name=\"wikiSpaceId\"/><econdition field-name=\"pagePath\" from=\"pagePath ?: null\"/></entity-find>\n            <set field=\"wikiPage\" from=\"wikiPageList ? wikiPageList[0] : null\"/>\n\n            <!-- if no wikiPage found check WikiPageAlias -->\n            <if condition=\"wikiPage == null\">\n                <entity-find-one entity-name=\"moqui.resource.wiki.WikiPageAlias\" value-field=\"wikiPageAlias\" cache=\"true\">\n                    <field-map field-name=\"wikiSpaceId\"/><field-map field-name=\"aliasPath\" from=\"pagePath ?: null\"/></entity-find-one>\n                <if condition=\"wikiPageAlias?.wikiPageId\">\n                    <entity-find-one entity-name=\"moqui.resource.wiki.WikiPage\" value-field=\"wikiPage\">\n                        <field-map field-name=\"wikiPageId\" from=\"wikiPageAlias.wikiPageId\"/></entity-find-one>\n                </if>\n            </if>\n\n            <if condition=\"wikiPage == null\"><return message=\"No page found at path ${pagePath} in space ${wikiSpaceId}\"/></if>\n\n            <set field=\"publishedVersionName\" from=\"wikiPage.publishedVersionName\"/>\n            <set field=\"versionName\" from=\"versionName ?: publishedVersionName\"/>\n            <!-- if no version specified and no publishedVersionName the act like the page doesn't exist -->\n            <if condition=\"!versionName\"><return message=\"No page version found at path ${pagePath} in space ${wikiSpaceId}\"/></if>\n\n            <set field=\"rootPageRef\" from=\"ec.resource.getLocationReference(wikiSpace.rootPageLocation)\"/>\n            <set field=\"pageReference\" from=\"rootPageRef.findChildFile(wikiPage.pagePath)\"/>\n            <set field=\"pageLocation\" from=\"pageReference.location\"/>\n\n            <set field=\"wikiPageId\" from=\"wikiPage.wikiPageId\"/>\n            <if condition=\"getPageText\"><set field=\"pageText\" from=\"pageReference.getText(versionName)\"/></if>\n        </actions>\n    </service>\n    <service verb=\"set\" noun=\"PublishedVersion\">\n        <in-parameters>\n            <parameter name=\"wikiPageId\" required=\"true\"/>\n            <parameter name=\"publishedVersionName\"/>\n        </in-parameters>\n        <actions><service-call name=\"update#moqui.resource.wiki.WikiPage\" in-map=\"context\"/></actions>\n    </service>\n\n    <service verb=\"get\" noun=\"WikiPageId\">\n        <in-parameters>\n            <parameter name=\"wikiSpaceId\" required=\"true\"/>\n            <parameter name=\"pagePath\"/><!-- not required because for root page will be empty -->\n            <parameter name=\"createIfMissing\" type=\"Boolean\" default=\"false\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"wikiPageId\"/>\n            <parameter name=\"wikiPage\" type=\"org.moqui.entity.EntityValue\"/>\n            <parameter name=\"createdRecord\" type=\"Boolean\"/>\n        </out-parameters>\n        <actions>\n            <entity-find entity-name=\"moqui.resource.wiki.WikiPage\" list=\"wikiPageList\" cache=\"true\">\n                <econdition field-name=\"wikiSpaceId\"/><econdition field-name=\"pagePath\" from=\"pagePath ?: null\"/></entity-find>\n            <set field=\"wikiPage\" from=\"wikiPageList ? wikiPageList[0] : null\"/>\n\n            <!-- if no wikiPage found check WikiPageAlias -->\n            <if condition=\"wikiPage == null\">\n                <entity-find-one entity-name=\"moqui.resource.wiki.WikiPageAlias\" value-field=\"wikiPageAlias\" cache=\"true\">\n                    <field-map field-name=\"wikiSpaceId\"/><field-map field-name=\"aliasPath\" from=\"pagePath ?: null\"/></entity-find-one>\n                <if condition=\"wikiPageAlias?.wikiPageId\">\n                    <entity-find-one entity-name=\"moqui.resource.wiki.WikiPage\" value-field=\"wikiPage\">\n                        <field-map field-name=\"wikiPageId\" from=\"wikiPageAlias.wikiPageId\"/></entity-find-one>\n                </if>\n            </if>\n\n            <!-- <log message=\"===== get#WikiPageId createIfMissing=${createIfMissing}, wikiSpaceId=${wikiSpaceId}, pagePath=${pagePath}, wikiPage: ${wikiPage}\"/> -->\n            <if condition=\"wikiPage == null &amp;&amp; createIfMissing\"><then>\n                <!-- no WikiPage record, create one for this page -->\n                <service-call name=\"create#moqui.resource.wiki.WikiPage\" out-map=\"context\"\n                        in-map=\"[wikiSpaceId:wikiSpaceId, pagePath:(pagePath ?: null), createdByUserId:ec.user.userId]\"/>\n                <entity-find-one entity-name=\"moqui.resource.wiki.WikiPage\" value-field=\"wikiPage\" cache=\"false\"/>\n                <set field=\"createdRecord\" from=\"true\"/>\n            </then><else>\n                <set field=\"createdRecord\" from=\"false\"/>\n            </else></if>\n            <set field=\"wikiPageId\" from=\"wikiPage?.wikiPageId\"/>\n        </actions>\n    </service>\n    <service verb=\"get\" noun=\"WikiPageReference\">\n        <in-parameters>\n            <parameter name=\"wikiSpaceId\"/>\n            <parameter name=\"pagePath\"/>\n            <parameter name=\"wikiPageId\"/>\n            <parameter name=\"extraPathNameList\" type=\"List\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"wikiSpaceId\"/>\n            <parameter name=\"pagePath\"/>\n            <parameter name=\"wikiPageId\"/>\n            <parameter name=\"wikiSpace\" type=\"org.moqui.entity.EntityValue\"/>\n            <parameter name=\"wikiPage\" type=\"org.moqui.entity.EntityValue\"/>\n            <parameter name=\"rootPageLocation\"/>\n            <parameter name=\"rootPageRef\" type=\"org.moqui.resource.ResourceReference\"/>\n            <parameter name=\"spaceRootName\"/>\n            <parameter name=\"pageReference\" type=\"org.moqui.resource.ResourceReference\"/>\n        </out-parameters>\n        <actions>\n            <if condition=\"wikiPageId\">\n                <entity-find-one entity-name=\"moqui.resource.wiki.WikiPage\" value-field=\"wikiPage\" cache=\"true\"/>\n                <set field=\"wikiSpaceId\" from=\"wikiPage.wikiSpaceId\"/>\n                <set field=\"pagePath\" from=\"wikiPage.pagePath\"/>\n            </if>\n\n            <if condition=\"extraPathNameList\">\n                <set field=\"wikiSpaceId\" from=\"wikiSpaceId ?: extraPathNameList[0]\"/>\n                <if condition=\"!pagePath &amp;&amp; extraPathNameList.size() > 1\">\n                    <iterate list=\"extraPathNameList[1..extraPathNameList.size()-1]\" entry=\"pathName\">\n                        <if condition=\"pagePath\"><set field=\"pagePath\" from=\"pagePath + '/'\"/></if>\n                        <set field=\"pagePath\" from=\"(pagePath?:'') + pathName\"/>\n                    </iterate>\n                </if>\n            </if>\n\n            <if condition=\"!wikiSpaceId\"><return error=\"true\" message=\"Cannot get wiki page info without wikiSpaceId parameter or URL path element\"/></if>\n            <entity-find-one entity-name=\"moqui.resource.wiki.WikiSpace\" value-field=\"wikiSpace\" cache=\"true\"/>\n            <if condition=\"wikiSpace == null\"><return error=\"true\" message=\"Wiki Space with ID ${wikiSpaceId} not found\"/></if>\n            <if condition=\"!wikiSpace.rootPageLocation\"><return error=\"true\" message=\"Wiki Space with ID ${wikiSpaceId} has no root page location\"/></if>\n            <set field=\"rootPageLocation\" from=\"wikiSpace.rootPageLocation\"/>\n            <set field=\"spaceRootName\" from=\"rootPageLocation.substring(rootPageLocation.lastIndexOf('/')+1)\"/>\n            <if condition=\"spaceRootName.contains('.')\"><set field=\"spaceRootName\" from=\"spaceRootName.substring(0, spaceRootName.lastIndexOf('.'))\"/></if>\n\n            <!-- NOTE: WikiPage record may not exist, but don't create one on get/view only -->\n            <if condition=\"wikiPage == null\"><service-call name=\"org.moqui.impl.WikiServices.get#WikiPageId\" in-map=\"context\" out-map=\"context\"/></if>\n\n            <!-- get the ResourceReferences -->\n            <set field=\"rootPageRef\" from=\"ec.resource.getLocationReference(wikiSpace.rootPageLocation)\"/>\n            <set field=\"pageReference\" from=\"rootPageRef.findChildFile(pagePath)\"/>\n        </actions>\n    </service>\n    <service verb=\"get\" noun=\"WikiPageInfo\">\n        <implements service=\"org.moqui.impl.WikiServices.get#WikiPageReference\"/>\n        <in-parameters>\n            <!-- see other in-parameters from get#WikiPageReference above -->\n            <parameter name=\"historySeqId\"/>\n            <parameter name=\"versionName\"/>\n        </in-parameters>\n        <out-parameters>\n            <!-- see other out-parameters from get#WikiPageReference above -->\n            <parameter name=\"wikiType\"/>\n            <parameter name=\"versionName\"/>\n            <parameter name=\"currentVersionName\"/>\n            <parameter name=\"publishedVersionName\"/>\n            <parameter name=\"attachmentList\" type=\"List\"><parameter name=\"attachmentInfo\" type=\"Map\">\n                <parameter name=\"filename\"/><parameter name=\"contentType\"/><parameter name=\"lastModified\" type=\"Long\"/>\n            </parameter></parameter>\n            <parameter name=\"pageLocation\"/>\n            <parameter name=\"pageName\"/>\n            <parameter name=\"parentPath\"/>\n            <parameter name=\"breadcrumbNameList\" type=\"List\"><parameter name=\"pageName\"/></parameter>\n            <parameter name=\"breadcrumbMapList\" type=\"List\"><parameter name=\"breadcrumbMap\">\n                <parameter name=\"pageName\"/><parameter name=\"pagePath\"/></parameter></parameter>\n        </out-parameters>\n        <actions>\n            <service-call name=\"org.moqui.impl.WikiServices.get#WikiPageReference\" in-map=\"context\" out-map=\"context\"/>\n\n            <!-- check restrictView for space and page -->\n            <if condition=\"wikiSpace.restrictView == 'Y'\">\n                <entity-find entity-name=\"moqui.resource.wiki.WikiSpaceUser\" list=\"wsuList\">\n                    <econdition-object field=\"[wikiSpaceId:wikiSpaceId, userId:ec.user.userId, allowView:'Y']\"/></entity-find>\n                <if condition=\"!wsuList\"><return error=\"true\" message=\"Wiki Space [${wikiSpaceId}] has restricted view and user ${ec.user.username} [${ec.user.userId}] is not allowed.\"/></if>\n            </if>\n            <if condition=\"wikiPage?.restrictView == 'Y'\">\n                <entity-find entity-name=\"moqui.resource.wiki.WikiPageUser\" list=\"wpuList\">\n                    <econdition-object field=\"[wikiPageId:wikiPageId, userId:ec.user.userId, allowView:'Y']\"/></entity-find>\n                <if condition=\"!wpuList\"><return error=\"true\" message=\"Wiki Page [${wikiSpaceId}/${pagePath}] has restricted view and user ${ec.user.username} [${ec.user.userId}] is not allowed.\"/></if>\n            </if>\n\n            <set field=\"pageLocation\" from=\"pageReference?.location\"/>\n            <set field=\"pageName\" from=\"pageReference?.fileName\"/>\n            <if condition=\"pageName?.contains('.')\">\n                <set field=\"wikiType\" from=\"pageName.substring(pageName.lastIndexOf('.')+1)\"/>\n                <set field=\"pageName\" from=\"pageName.substring(0, pageName.lastIndexOf('.'))\"/>\n            </if>\n\n            <set field=\"pathForParentPath\" from=\"pageReference.getActualChildPath() ?: pagePath\"/>\n            <set field=\"parentPath\" from=\"pathForParentPath?.contains('/') ? pathForParentPath.substring(0, pathForParentPath.lastIndexOf('/')) : ''\"/>\n\n            <!-- now have the real parentPath and pageName, make the full/normalized pagePath -->\n            <if condition=\"parentPath || (pageName != wikiSpaceId &amp;&amp; pageName != spaceRootName)\">\n                <set field=\"pagePath\" from=\"(parentPath ? parentPath+'/' : '') + (pageName?:'')\"/></if>\n\n            <!-- version information -->\n            <set field=\"currentVersionName\" from=\"pageReference?.currentVersion?.versionName\"/>\n            <if condition=\"!versionName\">\n                <if condition=\"historySeqId &amp;&amp; wikiPageId\"><then>\n                    <entity-find-one entity-name=\"moqui.resource.wiki.WikiPageHistory\" value-field=\"wikiPageHistory\"/>\n                    <set field=\"versionName\" from=\"wikiPageHistory?.versionName\"/>\n                </then><else>\n                    <set field=\"versionName\" from=\"currentVersionName\"/>\n                </else></if>\n            </if>\n            <set field=\"publishedVersionName\" from=\"wikiPage?.publishedVersionName\"/>\n\n            <script><![CDATA[\n                breadcrumbNameList = []\n                breadcrumbMapList = []\n                List<String> parentPathList = parentPath.split('/')\n                int listIndex = 0\n                for (String parentPathName in parentPathList) {\n                    if (!parentPathName) continue\n                    String curPath = \"\"\n                    String curPathEncoded  = \"\"\n                    for (int i = 0; i <= listIndex; i++) {\n                        if (curPath) curPath += \"/\"\n                        curPath += parentPathList[i]\n                        if (curPathEncoded) curPathEncoded += \"/\"\n                        curPathEncoded += urlEncodeIfNeeded(parentPathList[i])\n                    }\n                    breadcrumbNameList.add(parentPathName)\n                    breadcrumbMapList.add([pageName:parentPathName, pagePath:curPath, encodedPagePath:curPathEncoded])\n                    listIndex++\n                }\n            ]]></script>\n\n            <set field=\"pageAttachmentsDirectoryRef\" from=\"pageReference?.getChild('_attachments')\"/>\n            <set field=\"pageAttachmentRefList\" from=\"pageAttachmentsDirectoryRef?.getDirectoryEntries()\"/>\n            <set field=\"attachmentList\" from=\"[]\"/>\n            <iterate list=\"pageAttachmentRefList\" entry=\"pageAttachmentRef\"><script>\n                if (pageAttachmentRef.isFile())\n                    attachmentList.add([filename:pageAttachmentRef.getFileName(), contentType:pageAttachmentRef.getContentType(),\n                            lastModified:pageAttachmentRef.getLastModified(), resourceReference:pageAttachmentRef])\n            </script></iterate>\n\n            <!-- <log level=\"warn\" message=\"========= wikiSpaceId=${wikiSpaceId}, pagePath: ${pagePath}, wikiSpace: ${wikiSpace}, rootPageRef: ${rootPageRef}, pageReference: ${pageReference}, pageLocation: ${pageLocation}\"/> -->\n        </actions>\n    </service>\n    <service verb=\"get\" noun=\"WikiPageChildren\">\n        <implements service=\"org.moqui.impl.WikiServices.get#WikiPageReference\"/>\n        <out-parameters>\n            <parameter name=\"childPageInfoList\" type=\"List\"><parameter name=\"childPageInfo\" type=\"Map\"/></parameter>\n        </out-parameters>\n        <actions>\n            <service-call name=\"org.moqui.impl.WikiServices.get#WikiPageReference\" in-map=\"context\" out-map=\"context\"/>\n            <if condition=\"pageReference == null\"><return/></if>\n\n            <set field=\"childPageRefList\" from=\"pageReference.getChildren()\"/>\n            <set field=\"childPageInfoList\" from=\"[]\"/>\n            <iterate list=\"childPageRefList\" entry=\"childPageRef\">\n                <set field=\"childFilename\" from=\"childPageRef.getFileName()\"/>\n                <set field=\"childDotIdx\" from=\"childFilename.lastIndexOf('.')\"/>\n                <set field=\"childPageName\" from=\"childDotIdx &gt; 0 ? childFilename.substring(0, childDotIdx) : childFilename\"/>\n                <set field=\"childPagePath\" from=\"(pagePath ? pagePath + '/' : '') + childPageName\"/>\n                <!-- <log level=\"warn\" message=\"wikiSpaceId ${wikiSpaceId} childPagePath '${childPagePath}' childFilename '${childFilename}' pagePath '${pagePath}'\"/> -->\n\n                <set field=\"sequenceNum\" from=\"50\"/>\n                <service-call name=\"org.moqui.impl.WikiServices.get#WikiPageId\" out-map=\"pageIdOut\" out-map-add-to-existing=\"false\"\n                        in-map=\"[wikiSpaceId:wikiSpaceId, pagePath:childPagePath]\"/>\n                <if condition=\"pageIdOut.wikiPage != null\">\n                    <set field=\"sequenceNum\" from=\"pageIdOut.wikiPage.sequenceNum ?: 50\"/></if>\n\n                <script>childPageInfoList.add([filename:childFilename, pagePath:childPagePath, pageName:childPageName, sequenceNum:sequenceNum])</script>\n            </iterate>\n\n            <order-map-list list=\"childPageInfoList\"><order-by field-name=\"sequenceNum\"/><order-by field-name=\"childPageName\"/></order-map-list>\n        </actions>\n    </service>\n    <service verb=\"get\" noun=\"WikiPageAttachment\">\n        <in-parameters>\n            <parameter name=\"wikiSpaceId\"><description>Optional if pagePath is a wikiPageId or the first segment is a wikiSpaceId</description></parameter>\n            <parameter name=\"pagePath\"><description>Not required, is empty for the space root page. Can be a wikiPageId or the page path within the space.</description></parameter>\n            <parameter name=\"filename\"><description>If not specified last segment of pagePath will be used</description></parameter>\n        </in-parameters>\n        <out-parameters><parameter name=\"attachmentReference\" type=\"org.moqui.resource.ResourceReference\"/></out-parameters>\n        <actions>\n            <set field=\"pathList\" from=\"pagePath ? pagePath.split('/') as List : []\"/>\n            <!-- <log level=\"warn\" message=\"begin wikiSpaceId ${wikiSpaceId} pagePath ${pagePath} filename ${filename} pathList ${pathList}\"/> -->\n            <if condition=\"!filename\">\n                <if condition=\"pathList\"><then>\n                    <set field=\"filename\" from=\"pathList.remove(pathList.size()-1)\"/>\n                    <set field=\"pagePath\" from=\"pathList.join('/')\"/>\n                </then><else>\n                    <return error=\"true\" message=\"No filename specified and no path with filename\"/>\n                </else></if>\n            </if>\n\n            <set field=\"pathFirst\" from=\"pathList ? pathList[0] : null\"/>\n            <!-- <log level=\"warn\" message=\"middle wikiSpaceId ${wikiSpaceId} pagePath ${pagePath} filename ${filename} pathFirst ${pathFirst} pathList ${pathList}\"/> -->\n            <if condition=\"pathFirst\">\n                <!-- see if first part of pagePath is a wikiSpaceId, if so set it and remove from pathList -->\n                <entity-find-one entity-name=\"moqui.resource.wiki.WikiSpace\" value-field=\"wikiSpace\" cache=\"true\">\n                    <field-map field-name=\"wikiSpaceId\" from=\"pathFirst\"/></entity-find-one>\n                <if condition=\"wikiSpace != null\">\n                    <set field=\"wikiSpaceId\" from=\"pathList.remove(0)\"/>\n                    <set field=\"pagePath\" from=\"pathList.join('/')\"/>\n                    <set field=\"pathFirst\" from=\"pathList ? pathList[0] : null\"/>\n                </if>\n            </if>\n            <if condition=\"pathFirst\">\n                <!-- see if first part of pagePath is a wikiPageId, if so use its pagePath -->\n                <entity-find-one entity-name=\"moqui.resource.wiki.WikiPage\" value-field=\"wikiPage\" cache=\"true\">\n                    <field-map field-name=\"wikiPageId\" from=\"pathFirst\"/></entity-find-one>\n                <if condition=\"wikiPage != null\">\n                    <set field=\"wikiSpaceId\" from=\"wikiPage.wikiSpaceId\"/>\n                    <set field=\"pagePath\" from=\"wikiPage.pagePath\"/>\n                </if>\n            </if>\n            <!-- <log level=\"warn\" message=\"after wikiSpaceId ${wikiSpaceId} pagePath ${pagePath} filename ${filename}\"/> -->\n\n            <entity-find-one entity-name=\"moqui.resource.wiki.WikiSpace\" value-field=\"wikiSpace\" cache=\"true\"/>\n            <if condition=\"wikiSpace == null\"><return error=\"true\" message=\"Wiki Space with ID ${wikiSpaceId} not found\"/></if>\n            <if condition=\"!wikiSpace.rootPageLocation\"><return error=\"true\" message=\"Wiki Space with ID ${wikiSpaceId} has no root page location\"/></if>\n\n            <set field=\"rootPageRef\" from=\"ec.resource.getLocationReference(wikiSpace.rootPageLocation)\"/>\n            <set field=\"pageReference\" from=\"rootPageRef.findChildFile(pagePath)\"/>\n            <set field=\"pageAttachmentsDirectoryRef\" from=\"pageReference?.getChild('_attachments')\"/>\n            <set field=\"attachmentReference\" from=\"pageAttachmentsDirectoryRef?.getChild(filename)\"/>\n        </actions>\n    </service>\n    <service verb=\"upload\" noun=\"WikiPageAttachment\">\n        <in-parameters>\n            <parameter name=\"wikiSpaceId\"><description>Optional if pagePath is a wikiPageId or the first segment is a wikiSpaceId</description></parameter>\n            <parameter name=\"pagePath\"><description>Not required, is empty for the space root page. Can be a wikiPageId or the page path within the space.</description></parameter>\n            <parameter name=\"attachmentFile\" type=\"org.apache.commons.fileupload2.core.FileItem\"/>\n        </in-parameters>\n        <out-parameters><parameter name=\"attachmentReference\" type=\"org.moqui.resource.ResourceReference\"/></out-parameters>\n        <actions>\n            <if condition=\"attachmentFile == null\"><return message=\"No attachment uploaded\"/></if>\n            <set field=\"filename\" from=\"attachmentFile.getName()\"/>\n            <service-call name=\"org.moqui.impl.WikiServices.get#WikiPageAttachment\" in-map=\"context\" out-map=\"context\"/>\n            <script><![CDATA[\n                org.moqui.context.ExecutionContext ec = context.ec\n                org.apache.commons.fileupload2.core.FileItem attachmentFile = context.attachmentFile\n                ec.logger.info(\"Uploading file ${filename} for page path ${pagePath} in space ${wikiSpaceId}\")\n\n                InputStream fileStream = attachmentFile.getInputStream()\n                attachmentReference.putStream(fileStream)\n                fileStream.close()\n            ]]></script>\n        </actions>\n    </service>\n    <service verb=\"delete\" noun=\"WikiPageAttachment\">\n        <in-parameters>\n            <parameter name=\"wikiSpaceId\"><description>Optional if pagePath is a wikiPageId or the first segment is a wikiSpaceId</description></parameter>\n            <parameter name=\"pagePath\"><description>Not required, is empty for the space root page. Can be a wikiPageId or the page path within the space.</description></parameter>\n            <parameter name=\"filename\"><description>If not specified last segment of pagePath will be used</description></parameter>\n        </in-parameters>\n        <actions>\n            <service-call name=\"org.moqui.impl.WikiServices.get#WikiPageAttachment\" in-map=\"context\" out-map=\"context\"/>\n            <script><![CDATA[\n                ec.logger.info(\"Deleting file ${filename} for page path ${pagePath} in space ${wikiSpaceId} location ${attachmentReference.location}\")\n                attachmentReference.delete()\n            ]]></script>\n        </actions>\n    </service>\n\n    <service verb=\"update\" noun=\"WikiPage\">\n        <in-parameters>\n            <parameter name=\"wikiSpaceId\" required=\"true\"/>\n            <parameter name=\"wikiPageId\"><description>Optional, existing pages normally looked up by pagePath, use to refer to a specific existing page</description></parameter>\n            <parameter name=\"pagePath\"><description>Defaults to parentPath/pageName (both may be empty, resulting in empty pagePath).\n                To update a pageName of an existing page this must be specified along with the new pageName.</description></parameter>\n            <parameter name=\"parentPath\"/>\n            <parameter name=\"pageName\" required=\"true\">\n                <description>This is required for better usability. If pageName == wikiSpaceId is treated as the root page.</description>\n                <matches regexp=\"[\\w\\.\\-,':()!\\? ]*\" message=\"Invalid page name (letters, digits, [.,'-_:()!? ] only)\"/>\n            </parameter>\n            <parameter name=\"wikiType\" default-value=\"md\"/>\n            <parameter name=\"pageText\" allow-html=\"any\"><description>If WikiSpace.allowAnyHtml = Y will be stored as-is, otherwise filtered like parameter.allow-html=safe.</description></parameter>\n            <parameter name=\"sequenceNum\" type=\"Integer\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"wikiPageId\"/>\n            <parameter name=\"pagePath\"/>\n            <parameter name=\"encodedPagePath\"/>\n        </out-parameters>\n        <actions>\n            <!-- <log level=\"warn\" message=\"update#WikiPage wikiSpaceId=${wikiSpaceId} pageName=${pageName} parentPath=[${parentPath}]\"/> -->\n            <if condition=\"pageName == wikiSpaceId\"><set field=\"pageName\" from=\"null\"/></if>\n            <if condition=\"parentPath == wikiSpaceId\"><set field=\"parentPath\" from=\"null\"/></if>\n\n            <entity-find-one entity-name=\"moqui.resource.wiki.WikiSpace\" value-field=\"wikiSpace\" cache=\"true\"/>\n            <if condition=\"wikiSpace == null\"><return error=\"true\" message=\"Wiki Space with ID ${wikiSpaceId} not found\"/></if>\n            <if condition=\"!wikiSpace.rootPageLocation\"><return error=\"true\" message=\"Wiki Space with ID ${wikiSpaceId} has no root page location\"/></if>\n            <set field=\"rootPageLocation\" from=\"wikiSpace.rootPageLocation\"/>\n            <set field=\"spaceRootName\" from=\"rootPageLocation.substring(rootPageLocation.lastIndexOf('/')+1)\"/>\n            <if condition=\"spaceRootName.contains('.')\"><set field=\"spaceRootName\" from=\"spaceRootName.substring(0, spaceRootName.lastIndexOf('.'))\"/></if>\n            <if condition=\"parentPath == spaceRootName\"><set field=\"parentPath\" from=\"null\"/></if>\n\n            <if condition=\"wikiPageId\"><then>\n                <entity-find-one entity-name=\"moqui.resource.wiki.WikiPage\" value-field=\"wikiPage\" cache=\"true\"/>\n                <set field=\"wikiSpaceId\" from=\"wikiPage.wikiSpaceId\"/>\n                <set field=\"pagePath\" from=\"wikiPage.pagePath\"/>\n                <!-- on lookup by wikiPageId if no parentPath is specified keep it the same -->\n                <if condition=\"!parentPath\"><set field=\"parentPath\" from=\"pagePath.contains('/') ? pagePath.substring(0, pagePath.lastIndexOf('/')) : null\"/></if>\n            </then><else>\n                <if condition=\"!pagePath\"><set field=\"pagePath\" from=\"(parentPath ? parentPath+'/' : '') + (pageName?:'')\"/></if>\n                <service-call name=\"org.moqui.impl.WikiServices.get#WikiPageId\" in-map=\"context + [createIfMissing:true]\" out-map=\"context\"/>\n            </else></if>\n\n            <if condition=\"wikiPage == null\"><return error=\"true\" message=\"Wiki Page [${wikiSpaceId}/${pagePath}] does not exist and could not be created\"/></if>\n\n            <!-- check restrictUpdate for space and page -->\n            <if condition=\"wikiSpace.restrictUpdate == 'Y'\">\n                <entity-find entity-name=\"moqui.resource.wiki.WikiSpaceUser\" list=\"wsuList\">\n                    <econdition-object field=\"[wikiSpaceId:wikiSpaceId, userId:ec.user.userId, allowUpdate:'Y']\"/></entity-find>\n                <if condition=\"!wsuList\">\n                    <return error=\"true\" message=\"Wiki Space [${wikiSpaceId}] has restricted update and user ${ec.user.username} [${ec.user.userId}] is not allowed.\"/></if>\n            </if>\n            <if condition=\"wikiPage.restrictUpdate == 'Y'\">\n                <entity-find entity-name=\"moqui.resource.wiki.WikiPageUser\" list=\"wpuList\">\n                    <econdition-object field=\"[wikiPageId:wikiPageId, userId:ec.user.userId, allowUpdate:'Y']\"/>\n                </entity-find>\n                <if condition=\"!wpuList\">\n                    <return error=\"true\" message=\"Wiki Page [${wikiSpaceId}/${pagePath}] has restricted update and user ${ec.user.username} [${ec.user.userId}] is not allowed.\"/></if>\n            </if>\n\n            <if condition=\"sequenceNum != null &amp;&amp; sequenceNum != wikiPage.sequenceNum\">\n                <service-call name=\"update#moqui.resource.wiki.WikiPage\" in-map=\"[wikiPageId:wikiPageId, sequenceNum:sequenceNum]\"/>\n            </if>\n\n            <set field=\"rootPageRef\" from=\"ec.resource.getLocationReference(wikiSpace.rootPageLocation)\"/>\n            <set field=\"pageReference\" from=\"rootPageRef.findChildFile(pagePath)\"/>\n\n            <if condition=\"!pageReference.exists\">\n                <set field=\"fullPagePath\" value=\"${pagePath}.${wikiType}\"/>\n                <set field=\"pageReference\" from=\"rootPageRef.findChildFile(fullPagePath)\"/>\n            </if>\n            <!-- <log message=\"pagePath=${pagePath}, pageReference=${pageReference}\"/> -->\n\n            <!-- unless WikiSpace.allowAnyHtml = Y filter the HTML -->\n            <if condition=\"wikiSpace.allowAnyHtml != 'Y' &amp;&amp; wikiType == 'html'\">\n                <script>pageText = org.jsoup.Jsoup.clean(pageText, \"\", org.jsoup.safety.Safelist.relaxed(), org.moqui.impl.service.ParameterInfo.outputSettings)</script>\n            </if>\n\n            <!-- do the update, then the move if applicable -->\n            <set field=\"updatedPage\" from=\"false\"/>\n            <set field=\"versionName\" from=\"null\"/>\n            <script>\n                if (pageText != pageReference.getText()) {\n                    pageReference.putText(pageText)\n                    if (pageReference.supportsVersion()) versionName = pageReference.getCurrentVersion()?.versionName\n                    updatedPage = true\n                }\n            </script>\n\n            <set field=\"wikiPageHistoryMap\" from=\"[wikiPageId:wikiPageId, userId:ec.user.userId, changeDateTime:ec.user.nowTimestamp, versionName:versionName]\"/>\n\n            <!-- move the page if applicable -->\n            <set field=\"origParentPath\" from=\"pagePath.contains('/') ? pagePath.substring(0, pagePath.lastIndexOf('/')) : ''\"/>\n            <set field=\"origPageName\" from=\"pagePath.contains('/') ? pagePath.substring(pagePath.lastIndexOf('/')+1) : pagePath\"/>\n            <!-- TODO: support change of wikiType too -->\n            <if condition=\"pageName &amp;&amp; (origParentPath != parentPath || origPageName != pageName)\">\n                <log message=\"update#WikiPage path ${pagePath} pageName=${pageName} origPageName=${origPageName} parentPath=${parentPath} origParentPath=${origParentPath}\"/>\n                <!-- move the page file -->\n                <set field=\"rootPageDirRef\" from=\"rootPageRef.findMatchingDirectory()\"/>\n                <set field=\"newPageLocation\" value=\"${rootPageDirRef.location}${parentPath ? '/' + parentPath : ''}/${pageName}.${wikiType}\"/>\n                <log message=\"update#WikiPage move page ${pageReference.location} to ${newPageLocation}\"/>\n                <script>pageReference.move(newPageLocation)</script>\n                <!-- move the page's corresponding directory -->\n                <set field=\"pageDirReference\" from=\"pageReference.findMatchingDirectory()\"/>\n                <set field=\"newPageDirLocation\" value=\"${rootPageDirRef.location}${parentPath ? '/' + parentPath : ''}/${pageName}\"/>\n                <log message=\"update#WikiPage move directory ${pageDirReference.location} to ${newPageDirLocation}\"/>\n                <script>pageDirReference.move(newPageDirLocation)</script>\n\n                <!-- save the old path in the WikiPageHistory (before the pagePath is set to the new path) -->\n                <set field=\"wikiPageHistoryMap.oldPagePath\" from=\"pagePath\"/>\n\n                <!-- set the new pagePath (it is returned so the user is taken there) -->\n                <set field=\"pagePath\" value=\"${parentPath ? parentPath+'/' : ''}${pageName?:''}\"/>\n                \n                <!-- save the new pagePath on the WikiPage record -->\n                <if condition=\"pagePath != wikiPage.pagePath\">\n                    <set field=\"wikiPageForUpdate\" from=\"wikiPage.cloneValue()\"/>\n                    <set field=\"wikiPageForUpdate.pagePath\" from=\"pagePath\"/>\n                    <entity-update value-field=\"wikiPageForUpdate\"/>\n                    <set field=\"updatedPage\" from=\"true\"/>\n                </if>\n            </if>\n\n            <if condition=\"updatedPage\"><service-call name=\"create#moqui.resource.wiki.WikiPageHistory\" in-map=\"wikiPageHistoryMap\"/></if>\n\n            <script>\n                List pathElementList = pagePath.split('/') as List\n                StringBuffer encodedPagePathSb = new StringBuffer()\n                for (String pathElement in pathElementList) {\n                    if (encodedPagePathSb.length() > 0) encodedPagePathSb.append(\"/\")\n                    encodedPagePathSb.append(java.net.URLEncoder.encode(pathElement, \"UTF-8\"))\n                }\n                encodedPagePath = encodedPagePathSb.toString()\n            </script>\n        </actions>\n    </service>\n\n    <service verb=\"create\" noun=\"WikiSpace\">\n        <in-parameters>\n            <auto-parameters include=\"nonpk\"/>\n            <parameter name=\"wikiSpaceId\" required=\"true\">\n                <matches regexp=\"[A-Za-z]\\w*\" message=\"ID must start with a letter and contain only letters, digits, or underscore\"/>\n                <text-length min=\"3\" max=\"8\"/>\n            </parameter>\n            <parameter name=\"rootPageDirectory\" default-value=\"dbresource://WikiSpace\"/>\n            <parameter name=\"wikiType\" default-value=\"md\"/>\n        </in-parameters>\n        <out-parameters><parameter name=\"wikiSpaceId\" required=\"true\"/></out-parameters>\n        <actions>\n            <if condition=\"!rootPageLocation\">\n                <set field=\"rootPageLocation\" value=\"${rootPageDirectory}/${wikiSpaceId}.${wikiType}\"/></if>\n\n            <!-- create the record -->\n            <service-call name=\"create#moqui.resource.wiki.WikiSpace\" in-map=\"context\" out-map=\"context\"/>\n\n            <!-- create a root page if no file exists at the given location -->\n            <set field=\"rootPageReference\" from=\"ec.resource.getLocationReference(rootPageLocation)\"/>\n            <if condition=\"!rootPageReference.exists\">\n                <script>rootPageReference.putText(\"\\nAutomatic root page for space ${description?:wikiSpaceId}\\n\\n\")</script></if>\n        </actions>\n    </service>\n    <service verb=\"clone\" noun=\"WikiSpace\" transaction-timeout=\"1800\">\n        <in-parameters>\n            <parameter name=\"baseWikiSpaceId\" required=\"true\"/>\n            <parameter name=\"wikiSpaceId\" required=\"true\">\n                <matches regexp=\"[A-Za-z]\\w*\" message=\"ID must start with a letter and contain only letters, digits, or underscore\"/>\n                <text-length min=\"3\" max=\"8\"/>\n            </parameter>\n            <parameter name=\"rootPageDirectory\" default-value=\"dbresource://WikiSpace\"/>\n            <parameter name=\"rootPageLocation\"/>\n            <parameter name=\"description\"/>\n\n            <parameter name=\"copyAttachments\" type=\"Boolean\" default=\"true\"/>\n            <parameter name=\"publishNew\" type=\"Boolean\" default=\"true\"/>\n        </in-parameters>\n        <out-parameters><parameter name=\"wikiSpaceId\"/></out-parameters>\n        <actions>\n            <entity-find-one entity-name=\"moqui.resource.wiki.WikiSpace\" value-field=\"checkWikiSpace\" cache=\"false\">\n                <field-map field-name=\"wikiSpaceId\" from=\"wikiSpaceId\"/></entity-find-one>\n            <if condition=\"checkWikiSpace != null\"><return error=\"true\" message=\"Wiki Space with ID ${wikiSpaceId} already exists\"/></if>\n\n            <entity-find-one entity-name=\"moqui.resource.wiki.WikiSpace\" value-field=\"baseWikiSpace\" cache=\"false\">\n                <field-map field-name=\"wikiSpaceId\" from=\"baseWikiSpaceId\"/></entity-find-one>\n            <if condition=\"baseWikiSpace == null\"><return error=\"true\" message=\"Base Wiki Space with ID ${wikiSpaceId} not found\"/></if>\n\n            <!-- get info for base space root page -->\n            <service-call name=\"org.moqui.impl.WikiServices.get#WikiPageInfo\" out-map=\"baseRootPageInfo\"\n                    in-map=\"[wikiSpaceId:baseWikiSpaceId, pagePath:null]\"/>\n            <if condition=\"!rootPageLocation\">\n                <set field=\"rootPageLocation\" value=\"${rootPageDirectory}/${wikiSpaceId}.${baseRootPageInfo.wikiType}\"/></if>\n            <!-- create root page if doesn't exist, otherwise later ops will fail -->\n            <set field=\"rootPageReference\" from=\"ec.resource.getLocationReference(rootPageLocation)\"/>\n            <if condition=\"!rootPageReference.exists\">\n                <script>rootPageReference.putText(baseRootPageInfo.pageReference.getText())</script></if>\n\n            <!-- create new WikiSpace record -->\n            <set field=\"wikiSpace\" from=\"baseWikiSpace.cloneValue()\"/>\n            <set field=\"wikiSpace.wikiSpaceId\" from=\"wikiSpaceId\"/>\n            <set field=\"wikiSpace.rootPageLocation\" from=\"rootPageLocation\"/>\n            <if condition=\"description\"><set field=\"wikiSpace.description\" from=\"description\"/></if>\n            <entity-create value-field=\"wikiSpace\"/>\n\n            <!-- copy WikiSpaceUser records -->\n            <entity-find entity-name=\"moqui.resource.wiki.WikiSpaceUser\" list=\"baseWikiSpaceUserList\">\n                <econdition field-name=\"wikiSpaceId\" from=\"baseWikiSpaceId\"/></entity-find>\n            <iterate list=\"baseWikiSpaceUserList\" entry=\"baseWikiSpaceUser\">\n                <set field=\"wikiSpaceUser\" from=\"baseWikiSpaceUser.cloneValue()\"/>\n                <set field=\"wikiSpaceUser.wikiSpaceId\" from=\"wikiSpaceId\"/>\n                <entity-create value-field=\"wikiSpaceUser\"/>\n            </iterate>\n\n            <!-- copy the root page -->\n            <service-call name=\"org.moqui.impl.WikiServices.clone#WikiPage\" out-map=\"cloneRootOut\"\n                    in-map=\"[baseWikiSpaceId:baseWikiSpaceId, wikiSpaceId:wikiSpaceId, pagePath:null,\n                            copyAttachments:copyAttachments, publishNew:publishNew]\"/>\n\n            <!-- iterate through all pages in space, copy to new location and add WikiPage/etc records -->\n            <set field=\"baseRootPageRef\" from=\"ec.resource.getLocationReference(baseWikiSpace.rootPageLocation)\"/>\n            <set field=\"baseRootPageDirRef\" from=\"baseRootPageRef.findMatchingDirectory()\"/>\n            <!-- walk the entire tree of pages under the space root and add them to the flat list and the tree of pages -->\n            <set field=\"allChildFileFlatList\" from=\"new ArrayList()\"/>\n            <script>baseRootPageDirRef.walkChildTree(allChildFileFlatList, null)</script>\n            <iterate list=\"allChildFileFlatList\" entry=\"pageInfo\">\n                <service-call name=\"org.moqui.impl.WikiServices.clone#WikiPage\"\n                        in-map=\"[baseWikiSpaceId:baseWikiSpaceId, wikiSpaceId:wikiSpaceId, pagePath:pageInfo.path,\n                            copyAttachments:copyAttachments, publishNew:publishNew]\"/>\n            </iterate>\n        </actions>\n    </service>\n    <service verb=\"clone\" noun=\"WikiPage\">\n        <in-parameters>\n            <parameter name=\"baseWikiSpaceId\" required=\"true\"><description>Source/base wiki space</description></parameter>\n            <parameter name=\"wikiSpaceId\" required=\"true\"><description>Destination/target wiki space</description></parameter>\n            <parameter name=\"pagePath\"><description>Source page path</description></parameter>\n            <parameter name=\"parentPath\"><description>Path of parent page in target space (wikiSpaceId), if not specified use the same pagePath as the source</description></parameter>\n\n            <parameter name=\"copyAttachments\" type=\"Boolean\" default=\"true\"/>\n            <parameter name=\"publishNew\" type=\"Boolean\" default=\"true\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"basePageInfo\" type=\"Map\"/>\n            <parameter name=\"wikiPageId\"/>\n            <parameter name=\"pagePath\"/>\n        </out-parameters>\n        <actions>\n            <log message=\"Cloning WikiPage ${pagePath} from space ${baseWikiSpaceId} to space ${wikiSpaceId} with parentPath ${parentPath}\"/>\n\n            <service-call name=\"org.moqui.impl.WikiServices.get#WikiPageInfo\" out-map=\"basePageInfo\"\n                    in-map=\"[wikiSpaceId:baseWikiSpaceId, pagePath:pagePath]\"/>\n            <!-- <log level=\"warn\" message=\"basePageInfo: ${basePageInfo}\"/> -->\n\n            <!-- copy page resource text, create new WikiPage record -->\n            <!-- NOTE don't do this for root page, content of page handled in clone#WikiSpace -->\n            <if condition=\"pagePath\"><then>\n                <!-- determine parentPath, make sure exists in target space -->\n                <if condition=\"parentPath == wikiSpaceId\"><then>\n                    <set field=\"parentPath\" from=\"null\"/>\n                </then><else>\n                    <set field=\"parentPath\" from=\"parentPath ?: basePageInfo.parentPath\"/>\n                    <service-call name=\"org.moqui.impl.WikiServices.get#WikiPageInfo\" out-map=\"parentPageInfo\"\n                            in-map=\"[wikiSpaceId:wikiSpaceId, pagePath:parentPath]\"/>\n                    <if condition=\"parentPageInfo.pageReference == null || !parentPageInfo.pageReference.exists\">\n                        <message>Parent page not found at ${parentPath}, copying under Root Page</message>\n                        <set field=\"parentPath\" from=\"null\"/>\n                    </if>\n                </else></if>\n\n                <!-- make sure page doesn't already exist -->\n                <service-call name=\"org.moqui.impl.WikiServices.get#WikiPageInfo\" out-map=\"existingPageInfo\"\n                        in-map=\"[wikiSpaceId:wikiSpaceId, pagePath:((parentPath ? parentPath+'/' : '') + (basePageInfo.pageName?:''))]\"/>\n                <if condition=\"existingPageInfo.pageReference?.exists\">\n                    <return type=\"danger\" message=\"Page already exists in space ${wikiSpaceId} at ${parentPath?:''}/${basePageInfo.pageName}\"/></if>\n\n\n                <!-- TODO FUTURE this gets the latest text for the page; consider getting published version based on parameter, if there is a published version for this page -->\n                <service-call name=\"org.moqui.impl.WikiServices.update#WikiPage\" out-map=\"newPageOut\"\n                        in-map=\"[wikiSpaceId:wikiSpaceId, pageName:basePageInfo.pageName,\n                            parentPath:parentPath, wikiType:basePageInfo.wikiType,\n                            pageText:basePageInfo.pageReference.getText()]\"/>\n\n                <service-call name=\"org.moqui.impl.WikiServices.get#WikiPageInfo\" out-map=\"newPageInfo\"\n                        in-map=\"[wikiSpaceId:wikiSpaceId, pagePath:newPageOut.pagePath]\"/>\n\n                <!-- <log level=\"warn\" message=\"newPageOut: ${newPageOut}\"/> -->\n            </then><else>\n                <!-- for root page just get ID from page info, also creates WikiPage record if missing -->\n                <service-call name=\"org.moqui.impl.WikiServices.get#WikiPageInfo\" out-map=\"newPageInfo\"\n                        in-map=\"[wikiSpaceId:baseWikiSpaceId, pagePath:pagePath]\"/>\n            </else></if>\n            <!-- <log level=\"warn\" message=\"newPageInfo: ${newPageInfo}\"/> -->\n            <set field=\"wikiPageId\" from=\"newPageInfo.wikiPageId\"/>\n\n            <if condition=\"publishNew &amp;&amp; wikiPageId &amp;&amp; newPageInfo?.currentVersionName\">\n                <service-call name=\"update#moqui.resource.wiki.WikiPage\"\n                        in-map=\"[wikiPageId:wikiPageId, publishedVersionName:newPageInfo.currentVersionName]\"/>\n            </if>\n\n            <!-- copy attachments -->\n            <if condition=\"copyAttachments\">\n                <iterate list=\"basePageInfo.attachmentList\" entry=\"attachmentInfo\">\n                    <service-call name=\"org.moqui.impl.WikiServices.get#WikiPageAttachment\" out-map=\"baseAttachOut\"\n                            in-map=\"[wikiSpaceId:baseWikiSpaceId, pagePath:basePageInfo.pagePath, filename:attachmentInfo.filename]\"/>\n                    <service-call name=\"org.moqui.impl.WikiServices.get#WikiPageAttachment\" out-map=\"attachOut\"\n                            in-map=\"[wikiSpaceId:wikiSpaceId, pagePath:basePageInfo.pagePath, filename:attachmentInfo.filename]\"/>\n                    <log message=\"Copying attachment from ${baseAttachOut?.attachmentReference?.location} to ${attachOut?.attachmentReference?.location}\"/>\n                    <script><![CDATA[\n                        if (baseAttachOut.attachmentReference != null && attachOut.attachmentReference != null) {\n                            InputStream fileStream = baseAttachOut.attachmentReference.openStream()\n                            attachOut.attachmentReference.putStream(fileStream)\n                            fileStream.close()\n                        }\n                    ]]></script>\n                </iterate>\n            </if>\n\n            <if condition=\"basePageInfo.wikiPageId &amp;&amp; wikiPageId\">\n                <!-- copy WikiPageUser records -->\n                <entity-find entity-name=\"moqui.resource.wiki.WikiPageUser\" list=\"baseWikiPageUserList\">\n                    <econdition field-name=\"wikiPageId\" from=\"basePageInfo.wikiPageId\"/></entity-find>\n                <iterate list=\"baseWikiPageUserList\" entry=\"baseWikiPageUser\">\n                    <set field=\"wikiPageUser\" from=\"baseWikiPageUser.cloneValue()\"/>\n                    <set field=\"wikiPageUser.wikiPageId\" from=\"wikiPageId\"/>\n                    <entity-create value-field=\"wikiPageUser\"/>\n                </iterate>\n\n                <!-- copy WikiPageAlias records, only if alias doesn't exist -->\n                <entity-find entity-name=\"moqui.resource.wiki.WikiPageAlias\" list=\"baseWikiPageAliasList\">\n                    <econdition field-name=\"wikiSpaceId\" from=\"baseWikiSpaceId\"/>\n                    <econdition field-name=\"wikiPageId\" from=\"basePageInfo.wikiPageId\"/>\n                </entity-find>\n                <iterate list=\"baseWikiPageAliasList\" entry=\"baseWikiPageAlias\">\n                    <entity-find-one entity-name=\"moqui.resource.wiki.WikiPageAlias\" value-field=\"existingAlias\">\n                        <field-map field-name=\"wikiSpaceId\"/>\n                        <field-map field-name=\"aliasPath\" from=\"baseWikiPageAlias.aliasPath\"/>\n                    </entity-find-one>\n                    <if condition=\"existingAlias == null\"><then>\n                        <set field=\"wikiPageAlias\" from=\"baseWikiPageAlias.cloneValue()\"/>\n                        <set field=\"wikiPageAlias.wikiSpaceId\" from=\"wikiSpaceId\"/>\n                        <set field=\"wikiPageAlias.wikiPageId\" from=\"wikiPageId\"/>\n                        <entity-create value-field=\"wikiPageAlias\"/>\n                    </then><else>\n                        <message type=\"warning\">Not copying wiki page alias ${baseWikiPageAlias.aliasPath}, already exists in space ${wikiSpaceId}</message>\n                    </else></if>\n                </iterate>\n            </if>\n\n            <!-- TODO FUTURE copy history, optional based on parameter; complex because underlying ResourceReference impl needs to support on its side -->\n\n            <!-- set out parameters -->\n            <set field=\"pagePath\" from=\"newPageInfo.pagePath\"/>\n            <set field=\"wikiPageId\" from=\"newPageInfo.wikiPageId\"/>\n        </actions>\n    </service>\n    <service verb=\"delete\" noun=\"WikiPage\">\n        <in-parameters>\n            <parameter name=\"wikiSpaceId\" required=\"true\"/>\n            <parameter name=\"pagePath\" required=\"true\"/>\n            <parameter name=\"deleteAttachments\" type=\"Boolean\" default=\"false\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"wikiSpaceId\"/>\n            <parameter name=\"pagePath\"><description>Out pagePath is path of parent or empty</description></parameter>\n        </out-parameters>\n        <actions>\n            <service-call name=\"org.moqui.impl.WikiServices.get#WikiPageInfo\" out-map=\"pageInfo\"\n                    in-map=\"[wikiSpaceId:wikiSpaceId, pagePath:pagePath]\"/>\n            <set field=\"pageReference\" from=\"pageInfo.pageReference\"/>\n            <set field=\"matchingDirRef\" from=\"pageReference.findMatchingDirectory()\"/>\n            <set field=\"wikiPageId\" from=\"pageInfo.wikiPageId\"/>\n            <entity-find-one entity-name=\"moqui.resource.wiki.WikiPage\" value-field=\"wikiPage\" for-update=\"true\"/>\n\n            <!-- check for WikiBlog references, if any don't delete -->\n            <if condition=\"wikiPageId\">\n                <entity-find entity-name=\"moqui.resource.wiki.WikiBlog\" list=\"wikiBlogList\">\n                    <econdition field-name=\"wikiPageId\"/></entity-find>\n                <if condition=\"wikiBlogList\"><return type=\"danger\" message=\"Page at ${pagePath} has Wiki Blog references from ${wikiBlogList*.wikiBlogId}, cannot delete\"/></if>\n            </if>\n\n            <!-- check for child pages -->\n            <!-- FUTURE: option to delete child pages as well -->\n            <set field=\"childPageList\" from=\"pageInfo.pageReference.getChildren()\"/>\n            <if condition=\"childPageList\"><return type=\"danger\" message=\"Page at ${pagePath} has child pages, cannot delete\"/></if>\n\n            <!-- if has attachments delete if deleteAttachments else return -->\n            <if condition=\"pageInfo.attachmentList\">\n                <if condition=\"deleteAttachments\"><then>\n                    <!-- delete attachments -->\n                    <iterate list=\"pageInfo.attachmentList\" entry=\"attachmentInfo\">\n                        <script>attachmentInfo.resourceReference.delete()</script>\n                    </iterate>\n                    <!-- delete attachments directory -->\n                    <script>matchingDirRef.getChild('_attachments').delete()</script>\n                </then><else>\n                    <return type=\"danger\" message=\"Page at ${pagePath} has attachments and delete attachments is not enabled\"/>\n                </else></if>\n            </if>\n\n            <if condition=\"wikiPageId\">\n                <!-- delete dependent records -->\n                <entity-delete-by-condition entity-name=\"moqui.resource.wiki.WikiPageAlias\">\n                    <econdition field-name=\"wikiPageId\"/></entity-delete-by-condition>\n                <entity-delete-by-condition entity-name=\"moqui.resource.wiki.WikiPageCategoryMember\">\n                    <econdition field-name=\"wikiPageId\"/></entity-delete-by-condition>\n                <entity-delete-by-condition entity-name=\"moqui.resource.wiki.WikiPageHistory\">\n                    <econdition field-name=\"wikiPageId\"/></entity-delete-by-condition>\n                <entity-delete-by-condition entity-name=\"moqui.resource.wiki.WikiPageUser\">\n                    <econdition field-name=\"wikiPageId\"/></entity-delete-by-condition>\n                <!-- delete WikiPage record -->\n                <entity-delete value-field=\"wikiPage\"/>\n            </if>\n\n            <!-- lastly delete the page file and related directory -->\n            <if condition=\"matchingDirRef.exists\">\n                <!-- NOTE: may want to do more to delete remaining directory entries, etc -->\n                <script>\n                    try { matchingDirRef.delete() }\n                    catch (Throwable t) { ec.message.addMessage('Could not delete matching directory ' + matchingDirRef.location + ': ' + t.toString(), 'danger') }\n                </script>\n            </if>\n            <!-- NOTE: if main page delete fails allow exception to bubble up and rollback other changes -->\n            <script>pageReference.delete()</script>\n\n            <!-- set the out page path to the parent -->\n            <set field=\"pagePath\" from=\"pagePath?.contains('/') ? pagePath.substring(0, pagePath.lastIndexOf('/')) : ''\"/>\n        </actions>\n    </service>\n\n    <service verb=\"get\" noun=\"WikiSpacePages\">\n        <in-parameters>\n            <parameter name=\"wikiSpaceId\" required=\"true\"/>\n            <parameter name=\"currentPagePath\">\n                <description>If specified all pages starting with this path will be excluded</description></parameter>\n            <parameter name=\"wikiPageCategoryId\">\n                <description>If specified then flat list contains only items with this category assigned.</description></parameter>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"allChildFileFlatList\" type=\"List\"><parameter name=\"childInfo\" type=\"Map\">\n                <parameter name=\"path\"/><parameter name=\"name\"/><parameter name=\"location\"/></parameter></parameter>\n            <parameter name=\"rootChildResourceList\" type=\"List\"><parameter name=\"childInfo\" type=\"Map\">\n                <parameter name=\"path\"/><parameter name=\"name\"/><parameter name=\"location\"/></parameter></parameter>\n        </out-parameters>\n        <actions>\n            <entity-find-one entity-name=\"WikiSpace\" value-field=\"wikiSpace\" cache=\"true\"/>\n            <set field=\"rootPageRef\" from=\"ec.resource.getLocationReference(wikiSpace.rootPageLocation)\"/>\n            <set field=\"rootPageDirRef\" from=\"rootPageRef.findMatchingDirectory()\"/>\n            <!-- walk the entire tree of pages under the space root and add them to the flat list and the tree of pages -->\n            <set field=\"allChildFileFlatList\" from=\"new ArrayList()\"/>\n            <script>allChildFileFlatList.add([path:'', name:'Root Page', location:rootPageRef.getLocation()])</script>\n            <set field=\"rootChildResourceList\" from=\"new ArrayList()\"/>\n            <script>rootPageDirRef.walkChildTree(allChildFileFlatList, rootChildResourceList)</script>\n\n            <if condition=\"currentPagePath\">\n                <script><![CDATA[\n                    // don't use this, want to remove any that start with currentPagePath, not just equal it: filterMapList(allChildFileFlatList, [path:pagePath], true)\n                    int allChildFileFlatSize = allChildFileFlatList.size()\n                    for (int i = 0; i < allChildFileFlatSize; ) {\n                        Map allChildFileFlat = (Map) allChildFileFlatList.get(i)\n                        if (allChildFileFlat.path.startsWith(currentPagePath)) { allChildFileFlatList.remove(i); allChildFileFlatSize-- }\n                        else { i++ }\n                    }\n                ]]></script>\n                <script>filterMapList(rootChildResourceList, [path:currentPagePath], true)</script>\n            </if>\n\n            <!-- if category is specified then remove all children which don't have this category -->\n            <if condition=\"wikiPageCategoryId\">\n                <script><![CDATA[\n                    int allChildFileFlatSize = allChildFileFlatList.size()\n                    for (int i = 0; i < allChildFileFlatSize;) {\n                        Map allChildFileFlat = (Map) allChildFileFlatList.get(i)\n                        if (allChildFileFlat) {\n                            resultMap = ec.service.sync().name('org.moqui.impl.WikiServices.get#WikiPageId')\n                                .parameters([wikiSpaceId:wikiSpaceId, pagePath:allChildFileFlat.path, createIfMissing: false]).call()\n                            def wikiPageId = resultMap.wikiPageId\n                            if (wikiPageId && ec.entity.find(\"moqui.resource.wiki.WikiPageCategoryMember\").conditionDate(null, null, null)\n                                    .condition([wikiPageId:wikiPageId, wikiPageCategoryId:wikiPageCategoryId]).one() != null) {\n                                i++\n                            } else {\n                                allChildFileFlatList.remove(i); allChildFileFlatSize--\n                            }\n                        }\n                    }\n                ]]></script>\n            </if>\n\n            <!-- <iterate list=\"allChildFileFlatList\" entry=\"allChildFileFlat\">\n                <log level=\"warn\" message=\"============= allChildFileFlat=${allChildFileFlat}\"/>\n            </iterate>\n            <log level=\"warn\" message=\"============= rootChildResourceList=${rootChildResourceList}\"/> -->\n        </actions>\n    </service>\n\n    <!-- org.moqui.impl.WikiServices.index#WikiSpacePages moved to org.moqui.search.SearchServices.index#WikiSpacePages -->\n    <service verb=\"get\" noun=\"WikiPageManualDocumentData\" authenticate=\"anonymous-view\" no-remember-parameters=\"true\">\n        <implements service=\"org.moqui.EntityServices.add#ManualDocumentData\"/>\n        <actions>\n            <!-- primaryEntityValue is a WikiPage entity value, fields will be in the root document Map -->\n            <service-call name=\"org.moqui.impl.WikiServices.get#WikiPageInfo\" out-map=\"wikiPageInfo\"\n                    in-map=\"[wikiSpaceId:document.wikiSpaceId, pagePath:document.pagePath]\"/>\n            <!-- TODO: add attachments too (for indexing)? -->\n            <set field=\"document.content\" from=\"ec.resource.getLocationText(wikiPageInfo.pageLocation, false)\"/>\n        </actions>\n    </service>\n\n    <service verb=\"get\" noun=\"UserWikiSpaces\">\n        <in-parameters><parameter name=\"userId\"/></in-parameters>\n        <out-parameters>\n            <parameter name=\"wikiSpaceAndUserList\" type=\"List\"><parameter name=\"wikiSpaceAndUser\" type=\"Map\"/></parameter>\n        </out-parameters>\n        <actions>\n            <entity-find entity-name=\"moqui.resource.wiki.WikiSpaceAndUser\" list=\"wikiSpaceAndUserList\">\n                <econditions combine=\"or\">\n                    <econdition field-name=\"restrictView\" value=\"N\"/>\n                    <econdition field-name=\"restrictView\" from=\"null\"/>\n                    <econditions>\n                        <econdition field-name=\"userId\"/>\n                        <econdition field-name=\"restrictView\" value=\"Y\"/>\n                        <econditions combine=\"or\">\n                            <econdition field-name=\"allowAdmin\" value=\"Y\"/>\n                            <econdition field-name=\"allowView\" value=\"Y\"/>\n                        </econditions>\n                    </econditions>\n                </econditions>\n            </entity-find>\n        </actions>\n    </service>\n    <service verb=\"get\" noun=\"UserSpaceWikiPages\">\n        <in-parameters>\n            <parameter name=\"userId\"/>\n            <parameter name=\"wikiSpaceId\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"wikiPageAndUserList\" type=\"List\"><parameter name=\"wikiPageAndUser\" type=\"Map\"/></parameter>\n        </out-parameters>\n        <actions>\n            <entity-find entity-name=\"moqui.resource.wiki.WikiPageAndUser\" list=\"wikiPageAndUserList\">\n                <econdition field-name=\"wikiSpaceId\"/>\n                <econditions combine=\"or\">\n                    <econdition field-name=\"restrictView\" value=\"N\"/>\n                    <econdition field-name=\"restrictView\" from=\"null\"/>\n                    <econditions>\n                        <econdition field-name=\"userId\"/>\n                        <econdition field-name=\"restrictView\" value=\"Y\"/>\n                        <econdition field-name=\"allowView\" value=\"Y\"/>\n                    </econditions>\n                </econditions>\n            </entity-find>\n        </actions>\n    </service>\n    <service verb=\"get\" noun=\"UserSpaceWikiPageSimpleList\">\n        <in-parameters>\n            <parameter name=\"userId\"/>\n            <parameter name=\"wikiSpaceId\"/>\n        </in-parameters>\n        <out-parameters><parameter name=\"resultList\" type=\"List\"><parameter name=\"result\" type=\"Map\"/></parameter></out-parameters>\n        <actions>\n            <service-call name=\"org.moqui.impl.WikiServices.get#UserSpaceWikiPages\" in-map=\"context\" out-map=\"context\"/>\n            <script>\n                resultList = []\n                for (def wikiPageAndUser in wikiPageAndUserList)\n                    resultList.add([wikiPageId:wikiPageAndUser.wikiPageId, pageLabel:\"${wikiPageAndUser.wikiSpaceId}/${wikiPageAndUser.pagePath}\"])\n            </script>\n        </actions>\n    </service>\n\n    <service verb=\"create\" noun=\"WikiBlog\">\n        <in-parameters>\n            <auto-parameters entity-name=\"moqui.resource.wiki.WikiBlog\" include=\"nonpk\">\n                <exclude field-name=\"smallImageLocation\"/></auto-parameters>\n            <parameter name=\"wikiSpaceId\" required=\"true\"/>\n            <parameter name=\"title\" required=\"true\">\n                <matches regexp=\"[\\w\\.\\-,':()!\\? ]*\" message=\"Invalid title (letters, digits, [.,'-_:()!? ] only)\"/></parameter>\n            <parameter name=\"summary\" allow-html=\"safe\"/>\n            <parameter name=\"publishDate\" type=\"Timestamp\" default=\"ec.user.nowTimestamp\"/>\n            <parameter name=\"smallImage\" type=\"org.apache.commons.fileupload2.core.FileItem\"/>\n            <parameter name=\"blogText\" allow-html=\"any\"/><!-- allow any HTML here, is checked if needed in update#WikiPage -->\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"wikiBlogId\"/>\n        </out-parameters>\n        <actions>\n            <if condition=\"!blogText &amp;&amp; !wikiPageId\">\n                <return error=\"true\" message=\"Blog text or Wiki Page required to create a blog article\"/></if>\n            <if condition=\"blogText\">\n                <service-call name=\"org.moqui.impl.WikiServices.update#WikiPage\" out-map=\"context\"\n                        in-map=\"[wikiSpaceId:wikiSpaceId, wikiPageId:wikiPageId, pageName:title, parentPath:wikiSpaceId, wikiType:'html', pageText:blogText]\"/>\n            </if>\n            <service-call name=\"create#moqui.resource.wiki.WikiBlog\" in-map=\"context\" out-map=\"context\"/>\n            <if condition=\"smallImage &amp;&amp; smallImage.size > 0\">\n                <set field=\"filename\" from=\"smallImage.getName()\"/>\n                <set field=\"contentRoot\" from=\"ec.user.getPreference('mantle.content.root') ?: 'dbresource://mantle/content'\"/>\n                <set field=\"contentLocation\" value=\"${contentRoot}/WikiBlog/${wikiBlogId}/smallImage/${filename}\"/>\n                <set field=\"ref\" from=\"ec.resource.getLocationReference(contentLocation)\"/>\n                <script><![CDATA[fileStream = smallImage.getInputStream()\n                try { ref.putStream(fileStream) } finally { fileStream.close() }]]></script>\n                <service-call name=\"update#moqui.resource.wiki.WikiBlog\" in-map=\"[wikiBlogId:wikiBlogId, smallImageLocation:contentLocation]\"/>\n            </if>\n        </actions>\n    </service>\n    <service verb=\"update\" noun=\"WikiBlog\">\n        <in-parameters>\n            <auto-parameters entity-name=\"moqui.resource.wiki.WikiBlog\" include=\"nonpk\">\n                <exclude field-name=\"smallImageLocation\"/></auto-parameters>\n            <parameter name=\"wikiBlogId\" required=\"true\"/>\n            <parameter name=\"title\">\n                <matches regexp=\"[\\w\\.\\-,':()!\\? ]*\" message=\"Invalid title (letters, digits, [.,'-_:()!? ] only)\"/></parameter>\n            <parameter name=\"summary\" allow-html=\"safe\"/>\n            <parameter name=\"smallImage\" type=\"org.apache.commons.fileupload2.core.FileItem\"/>\n            <parameter name=\"blogText\" allow-html=\"any\"/>\n        </in-parameters>\n        <actions>\n            <if condition=\"blogText\">\n                <service-call name=\"org.moqui.impl.WikiServices.update#WikiPage\" out-map=\"context\"\n                        in-map=\"[wikiSpaceId:wikiSpaceId, wikiPageId:wikiPageId, pageName:title, parentPath:wikiSpaceId, wikiType:'html', pageText:blogText]\"/>\n            </if>\n            <service-call name=\"update#moqui.resource.wiki.WikiBlog\" in-map=\"context\"/>\n            <if condition=\"smallImage &amp;&amp; smallImage.size > 0\">\n                <set field=\"filename\" from=\"smallImage.getName()\"/>\n                <set field=\"contentRoot\" from=\"ec.user.getPreference('mantle.content.root') ?: 'dbresource://mantle/content'\"/>\n                <set field=\"contentLocation\" value=\"${contentRoot}/WikiBlog/${wikiBlogId}/smallImage/${filename}\"/>\n                <set field=\"ref\" from=\"ec.resource.getLocationReference(contentLocation)\"/>\n                <script><![CDATA[fileStream = smallImage.getInputStream()\n                try { ref.putStream(fileStream) } finally { fileStream.close() }]]></script>\n                <service-call name=\"update#moqui.resource.wiki.WikiBlog\" in-map=\"[wikiBlogId:wikiBlogId, smallImageLocation:contentLocation]\"/>\n            </if>\n        </actions>\n    </service>\n</services>\n"
  },
  {
    "path": "framework/service/org/moqui/search/ElasticSearchServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n    <!-- for backward compatibility with the service location in the old moqui-elasticsearch component; here because ServiceJob DB records may reference it still -->\n    <service-include verb=\"delete\" noun=\"Documents\" location=\"classpath://service/org/moqui/search/SearchServices.xml\"/>\n</services>\n"
  },
  {
    "path": "framework/service/org/moqui/search/SearchServices.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<services xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/service-definition-3.xsd\">\n\n    <service verb=\"index\" noun=\"DataDocuments\" authenticate=\"false\" no-remember-parameters=\"true\" transaction-timeout=\"3600\">\n        <implements service=\"org.moqui.EntityServices.receive#DataFeed\"/>\n        <in-parameters>\n            <parameter name=\"dataFeedId\" required=\"false\"/>\n            <parameter name=\"feedStamp\" type=\"Timestamp\" required=\"false\"/>\n            <!-- deprecated: <parameter name=\"getOriginalDocuments\" type=\"Boolean\" default=\"false\"/> -->\n            <parameter name=\"verifyIndexes\" type=\"Boolean\" default=\"true\"/>\n            <parameter name=\"clusterName\" default-value=\"default\"/>\n        </in-parameters>\n        <out-parameters>\n            <!-- deprecated: <parameter name=\"documentVersionList\" type=\"List\" required=\"true\"/> -->\n            <!-- deprecated: <parameter name=\"originalDocumentList\" type=\"List\" required=\"false\"/> -->\n        </out-parameters>\n        <actions>\n            <set field=\"elasticClient\" from=\"ec.factory.elastic.getClient(clusterName)\"/>\n            <if condition=\"elasticClient == null\"><return type=\"danger\" message=\"No Elastic Client found for cluster name ${clusterName}, not indexing documents\"/></if>\n\n            <if condition=\"verifyIndexes\"><script>elasticClient.verifyDataDocumentIndexes(documentList)</script></if>\n            <script>elasticClient.bulkIndexDataDocument(documentList)</script>\n        </actions>\n    </service>\n    <service verb=\"put\" noun=\"DataDocumentMappings\">\n        <in-parameters>\n            <parameter name=\"indexName\" required=\"true\"/>\n            <parameter name=\"clusterName\" default-value=\"default\"/>\n        </in-parameters>\n        <actions>\n            <set field=\"elasticClient\" from=\"ec.factory.elastic.getClient(clusterName)\"/>\n            <if condition=\"elasticClient == null\"><return type=\"danger\" message=\"No Elastic Client found for cluster name ${clusterName}, not indexing documents\"/></if>\n\n            <script>elasticClient.putDataDocumentMappings(indexName)</script>\n        </actions>\n    </service>\n    <service verb=\"index\" noun=\"DataFeedDocuments\" authenticate=\"false\" transaction-timeout=\"3600\">\n        <description>Index all documents associated with the feed within the date range. Recommend calling through the IndexDataFeedDocuments service job.</description>\n        <in-parameters>\n            <parameter name=\"dataFeedId\" required=\"true\"/>\n            <parameter name=\"dataDocumentId\"/>\n            <parameter name=\"fromUpdateStamp\" type=\"Timestamp\"/>\n            <parameter name=\"thruUpdateStamp\" type=\"Timestamp\"/>\n            <parameter name=\"batchSize\" type=\"Integer\" default=\"1000\"/>\n            <parameter name=\"clusterName\" default-value=\"default\"/>\n        </in-parameters>\n        <out-parameters><parameter name=\"documentsIndexed\" type=\"Integer\"/></out-parameters>\n        <actions>\n            <set field=\"startTime\" from=\"System.currentTimeMillis()\"/>\n            <entity-find-one entity-name=\"moqui.entity.feed.DataFeed\" value-field=\"df\" cache=\"true\"/>\n            <if condition=\"df == null\"><return error=\"true\" message=\"No DataFeed found for ID ${dataFeedId}\"/></if>\n\n            <entity-find entity-name=\"moqui.entity.feed.DataFeedDocument\" list=\"dfDocList\" cache=\"true\">\n                <econdition field-name=\"dataFeedId\"/>\n                <econdition field-name=\"dataDocumentId\" ignore-if-empty=\"true\"/>\n            </entity-find>\n\n            <if condition=\"!dfDocList\"><return error=\"true\" message=\"No DataDocuments found for DataFeed ID ${dataFeedId}${dataDocumentId ? ' and DataDocument ID ' + dataDocumentId : ''}\"/></if>\n\n            <set field=\"documentsIndexed\" from=\"0\"/>\n            <script><![CDATA[\n                import org.moqui.context.ExecutionContext\n                import java.util.concurrent.Future\n\n                ExecutionContext ec = context.ec\n                def elasticClient = ec.factory.elastic.getClient(clusterName)\n                if (elasticClient == null) {\n                    ec.message.addMessage(\"No Elastic Client found for cluster name ${clusterName}, not indexing documents\", \"danger\")\n                    return\n                }\n                String feedReceiveServiceName = df.feedReceiveServiceName ?: 'org.moqui.search.SearchServices.index#DataDocuments'\n\n                for (Map dfDoc in dfDocList) {\n                    // make sure the index exists\n                    Map dataDocument = ec.entity.fastFindOne(\"moqui.entity.document.DataDocument\", true, false, dfDoc.dataDocumentId)\n                    if (dataDocument?.indexName) elasticClient.checkCreateDataDocumentIndexes((String) dataDocument.indexName)\n\n                    int docListSize = ec.entity.entityDataDocument.feedDataDocuments(dfDoc.dataDocumentId, null,\n                            fromUpdateStamp, thruUpdatedStamp, feedReceiveServiceName, batchSize)\n                    documentsIndexed += docListSize\n                }\n            ]]></script>\n\n            <message>Indexed ${documentsIndexed} documents for feed ${dataFeedId} in ${System.currentTimeMillis() - startTime}ms</message>\n        </actions>\n    </service>\n    <service verb=\"index\" noun=\"WikiSpacePages\">\n        <in-parameters>\n            <parameter name=\"wikiSpaceId\"/>\n            <parameter name=\"dataDocumentId\" required=\"true\"/>\n            <parameter name=\"clusterName\" default-value=\"default\"/>\n        </in-parameters>\n        <actions>\n            <service-call name=\"org.moqui.impl.WikiServices.get#WikiSpacePages\" in-map=\"[wikiSpaceId:wikiSpaceId]\" out-map=\"context\"/>\n\n            <set field=\"recordsCreated\" from=\"0\"/>\n            <set field=\"documentList\" from=\"[]\"/>\n            <iterate list=\"allChildFileFlatList\" entry=\"allChildFileFlat\">\n                <service-call name=\"org.moqui.impl.WikiServices.get#WikiPageId\" out-map=\"getWpiResult\"\n                        in-map=\"[wikiSpaceId:wikiSpaceId, pagePath:allChildFileFlat.path, createIfMissing:true]\"/>\n                <if condition=\"getWpiResult.createdRecord\"><set field=\"recordsCreated\" from=\"recordsCreated + 1\"/></if>\n\n                <script>documentList.addAll(ec.entity.getDataDocuments(dataDocumentId,\n                        ec.entity.conditionFactory.makeCondition([wikiPageId:getWpiResult.wikiPageId]), null, null))</script>\n            </iterate>\n            <script>ec.service.sync().name(\"org.moqui.search.SearchServices.index#DataDocuments\").parameter(\"documentList\", documentList).parameter(\"clusterName\", clusterName).call()</script>\n\n            <message>Found and indexed ${allChildFileFlatList.size()} pages in Wiki Space ${wikiSpaceId}, created DB records for ${recordsCreated}.</message>\n        </actions>\n    </service>\n\n    <service verb=\"search\" noun=\"DataDocuments\">\n        <description>\n            The queryString format is the ElasticSearch supported one, based on the Lucene query strings which are documented here:\n\n            https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html\n\n            Sort options are described here:\n\n            https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-body.html#request-body-search-sort\n        </description>\n        <in-parameters>\n            <parameter name=\"indexName\" required=\"true\"/>\n            <parameter name=\"documentType\">\n                <description>The ElasticSearch document type. For DataDocument based docs this is the dataDocumentId.</description></parameter>\n            <parameter name=\"queryString\" required=\"true\"/>\n            <parameter name=\"nestedQueryMap\" type=\"Map\"><description>For explicit field constraints and such; key is path and\n                may be null for a general query; value is a query JSON String (parsed to Map) or a query Map object</description></parameter>\n            <parameter name=\"orderByFields\" type=\"List\"><parameter name=\"orderByField\"/></parameter>\n            <parameter name=\"highlightFields\" type=\"List\"><parameter name=\"highlightField\"/></parameter>\n            <parameter name=\"pageIndex\" type=\"Integer\" default=\"0\"/>\n            <parameter name=\"pageSize\" type=\"Integer\" default=\"20\"/>\n            <parameter name=\"pageNoLimit\" type=\"Boolean\" default=\"false\"/>\n            <parameter name=\"flattenDocument\" type=\"Boolean\" default=\"true\"/>\n            <parameter name=\"clusterName\" default-value=\"default\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"documentList\" type=\"List\"><parameter name=\"document\" type=\"Map\"/></parameter>\n            <parameter name=\"documentListCount\" type=\"Integer\">\n                <description>The total count of hits, not just the limited number returned.</description></parameter>\n            <parameter name=\"documentListPageIndex\" type=\"Integer\"/>\n            <parameter name=\"documentListPageSize\" type=\"Integer\"/>\n            <parameter name=\"documentListPageMaxIndex\" type=\"Integer\"/>\n            <parameter name=\"documentListPageRangeLow\" type=\"Integer\"/>\n            <parameter name=\"documentListPageRangeHigh\" type=\"Integer\"/>\n        </out-parameters>\n        <actions><script><![CDATA[\n            /* useful docs for query API: https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-body.html */\n\n            import org.moqui.context.ExecutionContext\n            import org.moqui.impl.context.ElasticFacadeImpl\n\n            import java.math.RoundingMode\n\n            ExecutionContext ec = context.ec\n            def elasticClient = ec.factory.elastic.getClient((String) clusterName)\n            if (elasticClient == null) {\n                ec.message.addMessage(\"No Elastic Client found for cluster name ${clusterName}, not running search\", \"danger\")\n                return\n            }\n\n            int fromOffset = pageNoLimit ? 0 : pageIndex * pageSize\n            // TODO FUTURE: for this type of search ES limits size to 10k (default for index.max_result_window, can be changed per index), must use a scroll search to do more\n            int sizeLimit = pageNoLimit ? 10000 : pageSize\n\n            documentList = []\n            boolean hasHighlights = highlightFields != null && highlightFields.size() > 0\n\n            // make sure index exists\n            if (!elasticClient.indexExists((String) indexName)) {\n                ec.loggerFacade.warn(\"Tried to search with indexName ${indexName} that does not exist, returning empty list\")\n                documentListCount = 0\n                documentListPageIndex = pageIndex\n                documentListPageSize = pageSize\n                documentListPageMaxIndex = 0\n                documentListPageRangeLow = 0\n                documentListPageRangeHigh = 0\n                return\n            }\n\n            List mustList = []\n            Map queryMap = [bool: [must: mustList]]\n            Map searchMap = [query: queryMap, from: fromOffset, size: sizeLimit, track_total_hits: true]\n\n            // query string: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html\n            mustList.add([query_string: [query: queryString, lenient: true, time_zone: TimeZone.default.getID()]])\n\n            if (nestedQueryMap) {\n                // nested: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-nested-query.html\n                // wrapper: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-wrapper-query.html\n                // exists: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-exists-query.html\n                for (Map.Entry nestedEntry in nestedQueryMap.entrySet()) {\n                    // NOTE: could be filter() instead of must()...\n                    String nestedPath = nestedEntry.key as String\n                    def nestedQuery = (nestedEntry.value instanceof CharSequence) ? ElasticFacadeImpl.jsonToObject(nestedEntry.value.toString()) : nestedEntry.value\n                    if (nestedPath) {\n                        mustList.add([nested: [path: nestedPath, query: nestedQuery, score_mode: \"avg\"]])\n                        // ec.logger.warn(\"${nestedPath}: ${nestedQuery}\")\n                    } else {\n                        mustList.add(nestedQuery)\n                    }\n                }\n            }\n            // ec.logger.warn(\"queryMap : ${groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(queryMap))}\")\n\n            if (hasHighlights) {\n                Map hlFieldMap = [:]\n                for (String hlField in highlightFields) hlFieldMap.put(hlField, [:])\n                searchMap.put(\"highlight\", [fields: hlFieldMap])\n            }\n            if (orderByFields) {\n                List sortList = []\n                for (String orderByField in orderByFields) {\n                    orderByField = orderByField.trim()\n                    boolean ascending = true\n                    if (orderByField.charAt(0) == (char) '-') {\n                        ascending = false\n                        orderByField = orderByField.substring(1)\n                    } else if (orderByField.charAt(0) == (char) '+') {\n                        ascending = true\n                        orderByField = orderByField.substring(1)\n                    }\n                    // ec.logger.warn(\"========= adding [${orderByField}], ${ascending}; from ${orderByFields}\")\n                    sortList.add(ascending ? orderByField : [(orderByField): \"desc\"])\n                }\n                searchMap.put(\"sort\", sortList)\n            }\n\n            // if documentType use instead of indexName to get only specific indexes (split on comma, ddIdToEsIndex, join with comma)\n            String index = indexName\n            if (documentType) index = ((String) documentType).split(\",\").collect({ ElasticFacadeImpl.ddIdToEsIndex(it) }).join(\",\")\n\n            // validate the query\n            // TODO: consider pulling error message from ElasticSearch, but they are pretty ugly\n            Map validateRespMap = elasticClient.validateQuery(index, queryMap, true)\n            if (validateRespMap != null) {\n                ec.message.addMessage(\"Invalid search: ${queryString}\", \"danger\")\n                documentListCount = 0\n                return\n            }\n\n            // do the search\n            Map resultMap = elasticClient.search(index, searchMap)\n            Map hitsMap = (Map) resultMap.hits\n            List<Map> hitsList = (List<Map>) hitsMap.hits\n\n            for (Map hit in hitsList) {\n                Map document = (Map) hit._source\n                if (flattenDocument) document = flattenNestedMap(document)\n\n                // As of ES 2.0 _index, _type, _id aren't included in the document\n                String _index = (String) hit._index\n                document._index = _index\n                document._id = hit._id\n                // how to get timestamp? doesn't seem to be in API: document._timestamp = hit.get?\n                document._version = hit._version\n                // as of ES 7.0 _type defaults to _doc and may go away entirely in 8.0, so default to _index with underscore to camel case conversion\n                String _type = (String) hit._type\n                if (!_type || \"_doc\".equals(_type)) {\n                    document._type = ElasticFacadeImpl.esIndexToDdId(_index)\n                } else {\n                    document._type = _type\n                }\n\n                if (hasHighlights) {\n                    document.put(\"highlights\", hit.highlight)\n                    // ec.logger.warn(\"Highlight Fields: \" + hit.highlight)\n                }\n\n                documentList.add(document)\n            }\n\n            // get the total search count\n            documentListCount = hitsMap.total.value\n\n            // calculate the pagination values\n            documentListPageIndex = pageIndex\n            documentListPageSize = pageSize\n            documentListPageMaxIndex = ((documentListCount as BigDecimal) - 1.0).divide(documentListPageSize as BigDecimal, 0, java.math.RoundingMode.DOWN) as int\n            documentListPageRangeLow = documentListPageIndex * documentListPageSize + 1\n            documentListPageRangeHigh = (documentListPageIndex * documentListPageSize) + documentListPageSize\n            if (documentListPageRangeHigh > documentListCount) documentListPageRangeHigh = documentListCount\n        ]]></script></actions>\n    </service>\n\n    <!-- not used, removed for now:\n    <service verb=\"search\" noun=\"CountBySource\">\n        <in-parameters>\n            <parameter name=\"indexName\" required=\"true\"/>\n            <parameter name=\"documentTypeList\" type=\"List\"><parameter name=\"documentType\"/></parameter>\n            <parameter name=\"maxResults\" type=\"Integer\" default=\"1000\"/>\n            <parameter name=\"sourceJson\"/>\n            <parameter name=\"sourceMap\" type=\"Map\"/>\n            <parameter name=\"clusterName\" default-value=\"default\"/>\n        </in-parameters>\n        <out-parameters><parameter name=\"searchResponse\" type=\"Object\"/></out-parameters>\n        <actions><message>No search implementation installed, not searching DataDocuments by source (see the moqui-elasticsearch component)</message></actions>\n    </service>\n    -->\n\n    <service verb=\"delete\" noun=\"DataDocument\" authenticate=\"anonymous-all\">\n        <implements service=\"org.moqui.EntityServices.receive#DataFeedDelete\"/>\n        <in-parameters>\n            <parameter name=\"indexName\"/>\n            <parameter name=\"dataDocumentId\" required=\"false\"><description>For DataFeed compatibility supports dataDocumentId that\n                if specified is converted to valid ElasticSearch index name instead of using indexName parameter</description></parameter>\n            <parameter name=\"documentId\" required=\"true\"/>\n            <parameter name=\"clusterName\" default-value=\"default\"/>\n        </in-parameters>\n        <actions><script><![CDATA[\n            import org.moqui.context.ExecutionContext\n            import org.moqui.impl.context.ElasticFacadeImpl\n            ExecutionContext ec = context.ec\n\n            // if documentType use instead of indexName to get only specific indexes (split on comma, ddIdToEsIndex, join with comma)\n            String index = indexName\n            if (dataDocumentId) index = ((String) dataDocumentId).split(\",\").collect({ ElasticFacadeImpl.ddIdToEsIndex(it) }).join(\",\")\n\n            def elasticClient = ec.factory.elastic.getClient((String) clusterName)\n            if (elasticClient == null) {\n                ec.message.addMessage(\"No Elastic Client found for cluster name ${clusterName}, not deleting document ${documentId}\", \"danger\")\n                return\n            }\n            elasticClient.delete(index, (String) documentId)\n        ]]></script></actions>\n    </service>\n\n    <service verb=\"delete\" noun=\"Documents\" authenticate=\"anonymous-all\" transaction-timeout=\"1800\">\n        <!-- authenticate=anonymous-all as this is called in a service job, should be exposed through only trusted UI/etc -->\n        <in-parameters>\n            <parameter name=\"indexName\" required=\"true\"/>\n            <!-- no longer used: <parameter name=\"documentType\"/> -->\n            <parameter name=\"timestampField\" default-value=\"@timestamp\"/>\n            <parameter name=\"daysToKeep\" type=\"Integer\"/>\n            <parameter name=\"queryString\"/>\n            <parameter name=\"clusterName\" default-value=\"default\"/>\n        </in-parameters>\n        <out-parameters>\n            <parameter name=\"deletedCount\" type=\"Long\"/>\n        </out-parameters>\n        <actions><script><![CDATA[\n            import org.moqui.context.ExecutionContext\n            ExecutionContext ec = context.ec\n\n            // ec.logger.warn(\"delete#DataDocuments documents from index ${indexName} with days to keep ${daysToKeep} and query ${queryString}\")\n            if (daysToKeep == null && !queryString) {\n                ec.message.addError(\"To delete documents must specify daysToKeep, queryString, or both\")\n                return\n            }\n\n            def elasticClient = ec.factory.elastic.getClient((String) clusterName)\n\n            Long thruMillis = null\n            if (daysToKeep != null) {\n                Calendar basisCal = ec.user.getCalendarSafe()\n                basisCal.add(Calendar.DAY_OF_YEAR, (int) -daysToKeep)\n                thruMillis = basisCal.getTimeInMillis()\n            }\n\n            List mustList = []\n            Map queryMap = [bool:[must:mustList]]\n            if (queryString) mustList.add([query_string:[query:queryString, lenient:true, time_zone:TimeZone.default.getID()]])\n            if (thruMillis) mustList.add([range:[(timestampField):[lte:thruMillis]]])\n\n            if (mustList.size() == 0) {\n                ec.message.addError(\"To delete documents must specify daysToKeep, queryString, or both\")\n                return\n            }\n\n            deletedCount = elasticClient.deleteByQuery((String) indexName, queryMap)\n\n            String resultMsg = \"Deleted ${deletedCount} documents from index ${indexName} with days to keep ${daysToKeep} and query ${queryString}\".toString()\n            ec.logger.info(resultMsg)\n            ec.message.addMessage(resultMsg)\n        ]]></script></actions>\n    </service>\n\n    <service verb=\"delete\" noun=\"ElasticIndex\">\n        <in-parameters>\n            <parameter name=\"indexName\" required=\"true\"/>\n            <parameter name=\"clusterName\" default-value=\"default\"/>\n        </in-parameters>\n        <actions><script><![CDATA[\n            import org.moqui.context.ExecutionContext\n            ExecutionContext ec = context.ec\n\n            def elasticClient = ec.factory.elastic.getClient((String) clusterName)\n            elasticClient.deleteIndex((String) indexName)\n        ]]></script></actions>\n    </service>\n</services>\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/actions/XmlAction.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.actions;\n\nimport freemarker.core.Environment;\nimport groovy.lang.Script;\nimport org.codehaus.groovy.runtime.DefaultGroovyMethods;\nimport org.codehaus.groovy.runtime.InvokerHelper;\nimport org.moqui.BaseArtifactException;\nimport org.moqui.impl.context.ExecutionContextFactoryImpl;\nimport org.moqui.impl.context.ExecutionContextImpl;\nimport org.moqui.util.MNode;\nimport org.moqui.util.StringUtilities;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.StringWriter;\nimport java.io.Writer;\nimport java.util.HashMap;\nimport java.util.Map;\n\npublic class XmlAction {\n    private static final Logger logger = LoggerFactory.getLogger(XmlAction.class);\n    private static final boolean isDebugEnabled = logger.isDebugEnabled();\n\n    protected final ExecutionContextFactoryImpl ecfi;\n    private final MNode xmlNode;\n    protected final String location;\n    /** The Groovy class compiled from the script transformed from the XML actions text using the FTL template. */\n    private Class groovyClassInternal = null;\n\n    public XmlAction(ExecutionContextFactoryImpl ecfi, MNode xmlNode, String location) {\n        this.ecfi = ecfi;\n        this.xmlNode = xmlNode;\n        this.location = location;\n    }\n\n    public XmlAction(ExecutionContextFactoryImpl ecfi, String xmlText, String location) {\n        this.ecfi = ecfi;\n        this.location = location;\n        if (xmlText != null && !xmlText.isEmpty()) {\n            xmlNode = MNode.parseText(location, xmlText);\n        } else {\n            xmlNode = MNode.parseText(location, ecfi.resourceFacade.getLocationText(location, false));\n        }\n    }\n\n    /** Run the XML actions in the current context of the ExecutionContext */\n    public Object run(ExecutionContextImpl eci) {\n        Class curClass = getGroovyClass();\n        if (curClass == null) throw new IllegalStateException(\"No Groovy class in place for XML actions, look earlier in log for the error in init\");\n        if (isDebugEnabled) logger.debug(\"Running groovy script: \\n\" + writeGroovyWithLines() + \"\\n\");\n\n        Script script = InvokerHelper.createScript(curClass, eci.contextBindingInternal);\n        try {\n            return script.run();\n        } catch (Throwable t) {\n            // NOTE: not logging full stack trace, only needed when lots of threads are running to pin down error (always logged later)\n            String tString = t.toString();\n            if (!tString.contains(\"org.eclipse.jetty.io.EofException\"))\n                logger.error(\"Error running groovy script (\" + t.toString() + \"): \\n\" + writeGroovyWithLines() + \"\\n\");\n            throw t;\n        }\n    }\n\n    public boolean checkCondition(ExecutionContextImpl eci) {\n        Object result = run(eci);\n        if (result == null) return false;\n        return DefaultGroovyMethods.asType(run(eci), Boolean.class);\n    }\n\n    // used in tools screens, must be public\n    public String writeGroovyWithLines() {\n        String groovyString = getGroovyString();\n        StringBuilder groovyWithLines = new StringBuilder();\n        int lineNo = 1;\n        for (String line : groovyString.split(\"\\n\")) groovyWithLines.append(lineNo++).append(\" : \").append(line).append(\"\\n\");\n        return groovyWithLines.toString();\n    }\n\n    public Class getGroovyClass() {\n        if (groovyClassInternal != null) return groovyClassInternal;\n        return makeGroovyClass();\n    }\n    protected synchronized Class makeGroovyClass() {\n        if (groovyClassInternal != null) return groovyClassInternal;\n        String curGroovy = getGroovyString();\n        // if (logger.isTraceEnabled()) logger.trace(\"Xml Action [${location}] groovyString: ${curGroovy}\")\n        try {\n            groovyClassInternal = ecfi.compileGroovy(curGroovy, StringUtilities.cleanStringForJavaName(location));\n        } catch (Throwable t) {\n            groovyClassInternal = null;\n            logger.error(\"Error parsing groovy String at [\" + location + \"]:\\n\" + writeGroovyWithLines() + \"\\n\");\n            throw t;\n        }\n        return groovyClassInternal;\n    }\n\n    public String getGroovyString() {\n        // transform XML to groovy\n        String groovyString;\n        try {\n            Map<String, Object> root = new HashMap<>(1);\n            root.put(\"xmlActionsRoot\", xmlNode);\n\n            Writer outWriter = new StringWriter();\n            Environment env = ecfi.resourceFacade.getXmlActionsScriptRunner().getXmlActionsTemplate().createProcessingEnvironment(root, outWriter);\n            env.process();\n\n            groovyString = outWriter.toString();\n        } catch (Exception e) {\n            logger.error(\"Error reading XML actions from [\" + location + \"], text: \" + xmlNode.toString());\n            throw new BaseArtifactException(\"Error reading XML actions from [\" + location + \"]\", e);\n        }\n\n        if (logger.isTraceEnabled()) logger.trace(\"XML actions at [\" + location + \"] produced groovy script:\\n\" + groovyString + \"\\nFrom xmlNode:\" + xmlNode.toString());\n\n        return groovyString;\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/ArtifactExecutionFacadeImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context\n\nimport groovy.transform.CompileStatic\nimport org.moqui.entity.EntityException\nimport org.moqui.impl.entity.EntityConditionFactoryImpl\nimport org.moqui.impl.entity.condition.EntityConditionImplBase\n\nimport java.sql.Timestamp\n\nimport org.moqui.BaseException\nimport org.moqui.context.ArtifactAuthorizationException\nimport org.moqui.context.ArtifactExecutionFacade\nimport org.moqui.context.ArtifactExecutionInfo\nimport org.moqui.context.ArtifactTarpitException\nimport org.moqui.entity.EntityCondition\nimport org.moqui.entity.EntityCondition.ComparisonOperator\nimport org.moqui.entity.EntityCondition.JoinOperator\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.context.ArtifactExecutionInfoImpl.ArtifactAuthzCheck\nimport org.moqui.impl.entity.EntityDefinition\nimport org.moqui.impl.entity.EntityFacadeImpl\nimport org.moqui.util.MNode\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n@CompileStatic\nclass ArtifactExecutionFacadeImpl implements ArtifactExecutionFacade {\n    protected final static Logger logger = LoggerFactory.getLogger(ArtifactExecutionFacadeImpl.class)\n\n    protected ExecutionContextImpl eci\n    private ArrayDeque<ArtifactExecutionInfoImpl> artifactExecutionInfoStack = new ArrayDeque<ArtifactExecutionInfoImpl>(10)\n    private ArrayList<ArtifactExecutionInfoImpl> artifactExecutionInfoHistory = new ArrayList<ArtifactExecutionInfoImpl>(50)\n    private ArrayList<ArtifactExecutionInfo> aeiStackCache = (ArrayList<ArtifactExecutionInfo>) null\n\n    // this is used by ScreenUrlInfo.isPermitted() which is called a lot, but that is transient so put here to have one per EC instance\n    protected Map<String, Boolean> screenPermittedCache = null\n\n    protected boolean authzDisabled = false\n    protected boolean tarpitDisabled = false\n    protected boolean entityEcaDisabled = false\n    protected boolean entityAuditLogDisabled = false\n    protected boolean entityFkCreateDisabled = false\n    protected boolean entityDataFeedDisabled = false\n\n    ArtifactExecutionFacadeImpl(ExecutionContextImpl eci) {\n        this.eci = eci\n    }\n\n    Map<String, Boolean> getScreenPermittedCache() {\n        if (screenPermittedCache == null) screenPermittedCache = new HashMap<>()\n        return screenPermittedCache\n    }\n\n    @Override\n    ArtifactExecutionInfo peek() { return this.artifactExecutionInfoStack.peekFirst() }\n\n    @Override\n    ArtifactExecutionInfo push(String name, ArtifactExecutionInfo.ArtifactType typeEnum, ArtifactExecutionInfo.AuthzAction actionEnum, boolean requiresAuthz) {\n        ArtifactExecutionInfoImpl aeii = new ArtifactExecutionInfoImpl(name, typeEnum, actionEnum, \"\")\n        pushInternal(aeii, requiresAuthz, true)\n        return aeii\n    }\n    @Override\n    void push(ArtifactExecutionInfo aei, boolean requiresAuthz) {\n        ArtifactExecutionInfoImpl aeii = (ArtifactExecutionInfoImpl) aei\n        pushInternal(aeii, requiresAuthz, true)\n    }\n    void pushInternal(ArtifactExecutionInfoImpl aeii, boolean requiresAuthz, boolean countTarpit) {\n        ArtifactExecutionInfoImpl lastAeii = (ArtifactExecutionInfoImpl) artifactExecutionInfoStack.peekFirst()\n\n        // always do this regardless of the authz checks, etc; keep a history of artifacts run\n        if (lastAeii != null) { lastAeii.addChild(aeii); aeii.setParent(lastAeii) }\n        else artifactExecutionInfoHistory.add(aeii)\n\n        // if (\"AT_XML_SCREEN\" == aeii.typeEnumId) logger.warn(\"TOREMOVE artifact push ${username} - ${aeii}\")\n\n        if (!isPermitted(aeii, lastAeii, requiresAuthz, countTarpit, true, null)) {\n            Deque<ArtifactExecutionInfo> curStack = getStack()\n            StringBuilder warning = new StringBuilder()\n            warning.append(\"User ${eci.user.username ?: eci.user.userId ?: '[No User]'} is not authorized for ${aeii.getActionDescription()} on ${aeii.getTypeDescription()} ${aeii.getName()}\")\n\n            ArtifactAuthorizationException e = new ArtifactAuthorizationException(warning.toString(), aeii, curStack)\n            // end users see this message in vuet mode so better not to add all of this to the main message:\n            warning.append(\"\\nCurrent artifact info: ${aeii.toString()}\\n\")\n            warning.append(\"Current artifact stack:\")\n            for (ArtifactExecutionInfo warnAei in curStack) warning.append(\"\\n\").append(warnAei.toString())\n            logger.warn(\"Artifact authorization failed: \" + warning.toString())\n            throw e\n        }\n\n        // set the moquiTxId for all that make it onto the stack\n        aeii.setMoquiTxId(eci.transactionFacade.getTxStackInfo().moquiTxId)\n\n        // NOTE: if needed the isPermitted method will set additional info in aeii\n        this.artifactExecutionInfoStack.addFirst(aeii)\n        this.aeiStackCache = (ArrayList<ArtifactExecutionInfo>) null\n    }\n\n\n    @Override\n    ArtifactExecutionInfo pop(ArtifactExecutionInfo aei) {\n        try {\n            ArtifactExecutionInfoImpl lastAeii = (ArtifactExecutionInfoImpl) artifactExecutionInfoStack.removeFirst()\n            this.aeiStackCache = (ArrayList<ArtifactExecutionInfo>) null\n\n            // removed this for performance reasons, generally just checking the name is adequate\n            // || aei.typeEnumId != lastAeii.typeEnumId || aei.actionEnumId != lastAeii.actionEnumId\n            if (aei != null && !lastAeii.nameInternal.equals(aei.getName())) {\n                String popMessage = \"Popped artifact (${aei.name}:${aei.getTypeDescription()}:${aei.getActionDescription()}) did not match top of stack (${lastAeii.name}:${lastAeii.getTypeDescription()}:${lastAeii.getActionDescription()}:${lastAeii.actionDetail})\"\n                logger.warn(popMessage, new BaseException(\"Pop Error Location\"))\n                //throw new IllegalArgumentException(popMessage)\n            }\n            // set end time\n            lastAeii.setEndTime()\n            // count artifact hit (now done here instead of by each caller)\n            // NOTE DEJ 20191229 removed condition where only artifacts requiring authz are counted: && lastAeii.internalAuthzWasRequired\n            if (lastAeii.trackArtifactHit && lastAeii.isAccess)\n                eci.ecfi.countArtifactHit(lastAeii.internalTypeEnum, lastAeii.actionDetail, lastAeii.nameInternal,\n                        lastAeii.parameters, lastAeii.startTimeMillis, lastAeii.getRunningTimeMillisDouble(), lastAeii.outputSize)\n            return lastAeii\n        } catch(NoSuchElementException e) {\n            logger.warn(\"Tried to pop from an empty ArtifactExecutionInfo stack\", e)\n            return null\n        }\n    }\n\n    @Override\n    Deque<ArtifactExecutionInfo> getStack() {\n        return new ArrayDeque<ArtifactExecutionInfo>(this.artifactExecutionInfoStack)\n    }\n    @Override\n    ArrayList<ArtifactExecutionInfo> getStackArray() {\n        if (aeiStackCache != null) return aeiStackCache\n        aeiStackCache = new ArrayList<ArtifactExecutionInfo>(this.artifactExecutionInfoStack)\n        return aeiStackCache\n    }\n    String getStackNameString() {\n        StringBuilder sb = new StringBuilder()\n        Iterator i = this.artifactExecutionInfoStack.iterator()\n        while (i.hasNext()) {\n            ArtifactExecutionInfo aei = (ArtifactExecutionInfo) i.next()\n            sb.append(aei.name)\n            if (i.hasNext()) sb.append(', ')\n        }\n        return sb.toString()\n    }\n    @Override\n    List<ArtifactExecutionInfo> getHistory() {\n        List<ArtifactExecutionInfo> newHistList = new ArrayList<>()\n        newHistList.addAll(this.artifactExecutionInfoHistory)\n        return newHistList\n    }\n\n    String printHistory() {\n        StringWriter sw = new StringWriter()\n        for (ArtifactExecutionInfo aei in artifactExecutionInfoHistory) aei.print(sw, 0, true)\n        return sw.toString()\n    }\n\n    ArtifactExecutionInfoImpl.ArtifactTypeStats getArtifactTypeStats() {\n        return ArtifactExecutionInfoImpl.getArtifactTypeStats(artifactExecutionInfoHistory)\n    }\n\n    void logProfilingDetail() {\n        if (!logger.isInfoEnabled()) return\n\n        StringWriter sw = new StringWriter()\n        sw.append(\"========= Hot Spots by Own Time =========\\n\")\n        sw.append(\"[{time}:{timeMin}:{timeAvg}:{timeMax}][{count}] {type} {action} {actionDetail} {name}\\n\")\n        List<Map<String, Object>> ownHotSpotList = ArtifactExecutionInfoImpl.hotSpotByTime(artifactExecutionInfoHistory, true, \"-time\")\n        ArtifactExecutionInfoImpl.printHotSpotList(sw, ownHotSpotList)\n        logger.info(sw.toString())\n\n        sw = new StringWriter()\n        sw.append(\"========= Hot Spots by Total Time =========\\n\")\n        sw.append(\"[{time}:{timeMin}:{timeAvg}:{timeMax}][{count}] {type} {action} {actionDetail} {name}\\n\")\n        List<Map<String, Object>> totalHotSpotList = ArtifactExecutionInfoImpl.hotSpotByTime(artifactExecutionInfoHistory, false, \"-time\")\n        ArtifactExecutionInfoImpl.printHotSpotList(sw, totalHotSpotList)\n        logger.info(sw.toString())\n\n        /* leave this out by default, sometimes interesting, but big\n        sw = new StringWriter()\n        sw.append(\"========= Consolidated Artifact List =========\\n\")\n        sw.append(\"[{time}:{thisTime}:{childrenTime}][{count}] {type} {action} {actionDetail} {name}\\n\")\n        List<Map> consolidatedList = ArtifactExecutionInfoImpl.consolidateArtifactInfo(artifactExecutionInfoHistory)\n        ArtifactExecutionInfoImpl.printArtifactInfoList(sw, consolidatedList, 0)\n        logger.info(sw.toString())\n        */\n    }\n\n\n    void setAnonymousAuthorizedAll() {\n        ArtifactExecutionInfoImpl aeii = artifactExecutionInfoStack.peekFirst()\n        aeii.authorizationInheritable = true\n        aeii.authorizedUserId = eci.getUser().getUserId() ?: \"_NA_\"\n        if (aeii.authorizedAuthzType != ArtifactExecutionInfo.AUTHZT_ALWAYS) aeii.authorizedAuthzType = ArtifactExecutionInfo.AUTHZT_ALLOW\n        aeii.internalAuthorizedActionEnum = ArtifactExecutionInfo.AUTHZA_ALL\n    }\n\n    void setAnonymousAuthorizedView() {\n        ArtifactExecutionInfoImpl aeii = artifactExecutionInfoStack.peekFirst()\n        aeii.authorizationInheritable = true\n        aeii.authorizedUserId = eci.getUser().getUserId() ?: \"_NA_\"\n        if (aeii.authorizedAuthzType != ArtifactExecutionInfo.AUTHZT_ALWAYS) aeii.authorizedAuthzType = ArtifactExecutionInfo.AUTHZT_ALLOW\n        if (aeii.authorizedActionEnum != ArtifactExecutionInfo.AUTHZA_ALL) aeii.authorizedActionEnum = ArtifactExecutionInfo.AUTHZA_VIEW\n    }\n\n    boolean disableAuthz() { boolean alreadyDisabled = authzDisabled; authzDisabled = true; return alreadyDisabled }\n    void enableAuthz() { authzDisabled = false }\n    boolean getAuthzDisabled() { return authzDisabled }\n\n    boolean disableTarpit() { boolean alreadyDisabled = tarpitDisabled; tarpitDisabled = true; return alreadyDisabled }\n    void enableTarpit() { tarpitDisabled = false }\n    // boolean getTarpitDisabled() { return tarpitDisabled }\n\n    boolean disableEntityEca() { boolean alreadyDisabled = entityEcaDisabled; entityEcaDisabled = true; return alreadyDisabled }\n    void enableEntityEca() { entityEcaDisabled = false }\n    boolean entityEcaDisabled() { return entityEcaDisabled }\n\n    boolean disableEntityAuditLog() { boolean alreadyDisabled = entityAuditLogDisabled; entityAuditLogDisabled = true; return alreadyDisabled }\n    void enableEntityAuditLog() { entityAuditLogDisabled = false }\n    boolean entityAuditLogDisabled() { return entityAuditLogDisabled }\n\n    boolean disableEntityFkCreate() { boolean alreadyDisabled = entityFkCreateDisabled; entityFkCreateDisabled = true; return alreadyDisabled }\n    void enableEntityFkCreate() { entityFkCreateDisabled = false }\n    boolean entityFkCreateDisabled() { return entityFkCreateDisabled }\n\n    boolean disableEntityDataFeed() { boolean alreadyDisabled = entityDataFeedDisabled; entityDataFeedDisabled = true; return alreadyDisabled }\n    void enableEntityDataFeed() { entityDataFeedDisabled = false }\n    boolean entityDataFeedDisabled() { return entityDataFeedDisabled }\n\n    /** Checks to see if username is permitted to access given resource.\n     *\n     * @param resourceAccess Formatted as: \"${typeEnumId}:${actionEnumId}:${name}\"\n     * @param nowTimestamp\n     * @param eci\n     */\n    static boolean isPermitted(String resourceAccess, ExecutionContextImpl eci) {\n        int firstColon = resourceAccess.indexOf(\":\")\n        int secondColon = resourceAccess.indexOf(\":\", firstColon + 1)\n        if (firstColon == -1 || secondColon == -1) throw new ArtifactAuthorizationException(\"Resource access string does not have two colons (':'), must be formatted like: \\\"\\${typeEnumId}:\\${actionEnumId}:\\${name}\\\"\", null, null)\n\n        ArtifactExecutionInfo.ArtifactType typeEnum = ArtifactExecutionInfo.ArtifactType.valueOf(resourceAccess.substring(0, firstColon))\n        ArtifactExecutionInfo.AuthzAction actionEnum = ArtifactExecutionInfo.AuthzAction.valueOf(resourceAccess.substring(firstColon + 1, secondColon))\n        String name = resourceAccess.substring(secondColon + 1)\n\n        return eci.artifactExecutionFacade.isPermitted(new ArtifactExecutionInfoImpl(name, typeEnum, actionEnum, \"\"),\n                null, true, true, false, null)\n    }\n\n    boolean isPermitted(ArtifactExecutionInfoImpl aeii, ArtifactExecutionInfoImpl lastAeii, boolean requiresAuthz, boolean countTarpit,\n                        boolean isAccess, ArrayDeque<ArtifactExecutionInfoImpl> currentStack) {\n        ArtifactExecutionInfo.ArtifactType artifactTypeEnum = aeii.internalTypeEnum\n        boolean isEntity = ArtifactExecutionInfo.AT_ENTITY.is(artifactTypeEnum)\n        // right off record whether authz is required and is access\n        aeii.setAuthzReqdAndIsAccess(requiresAuthz, isAccess)\n\n        // never do this for entities when disableAuthz, as we might use any below and would cause infinite recursion\n        // for performance reasons if this is an entity and no authz required don't bother looking at tarpit, checking for deny/etc\n        if ((!requiresAuthz || this.authzDisabled) && isEntity) {\n            if (lastAeii != null && lastAeii.authorizationInheritable) aeii.copyAuthorizedInfo(lastAeii)\n            return true\n        }\n\n        // if (\"AT_XML_SCREEN\" == aeii.typeEnumId) logger.warn(\"TOREMOVE artifact isPermitted after authzDisabled ${aeii}\")\n\n        ExecutionContextFactoryImpl ecfi = eci.ecfi\n        UserFacadeImpl ufi = eci.userFacade\n\n        if (!isEntity && countTarpit && !tarpitDisabled && Boolean.TRUE.is((Boolean) ecfi.artifactTypeTarpitEnabled.get(artifactTypeEnum)) &&\n                (requiresAuthz || (!ArtifactExecutionInfo.AT_XML_SCREEN.is(artifactTypeEnum) && !ArtifactExecutionInfo.AT_REST_PATH.is(artifactTypeEnum)))) {\n            checkTarpit(aeii)\n        }\n\n        // if last was an always allow, then don't bother checking for deny/etc - this is a common case\n        if (lastAeii != null && lastAeii.internalAuthorizationInheritable &&\n                ArtifactExecutionInfo.AUTHZT_ALWAYS.is(lastAeii.internalAuthorizedAuthzType) &&\n                (ArtifactExecutionInfo.AUTHZA_ALL.is(lastAeii.internalAuthorizedActionEnum) || aeii.internalActionEnum.is(lastAeii.internalAuthorizedActionEnum))) {\n            // NOTE: used to also check userId.equals(lastAeii.internalAuthorizedUserId), but rare if ever that could even happen\n            aeii.copyAuthorizedInfo(lastAeii)\n            // if (\"AT_XML_SCREEN\" == aeii.typeEnumId && aeii.getName().contains(\"FOO\"))\n            //     logger.warn(\"TOREMOVE artifact isPermitted already authorized for user ${userId} - ${aeii}\")\n            return true\n        }\n\n        // tarpit enabled already checked, if authz not enabled return true immediately\n        // NOTE: do this after the check above as authz is normally enabled so this doesn't normally save is any time\n        if (!Boolean.TRUE.is((Boolean) ecfi.artifactTypeAuthzEnabled.get(artifactTypeEnum))) {\n            if (lastAeii != null) aeii.copyAuthorizedInfo(lastAeii)\n            return true\n        }\n\n        // search entire list for deny and allow authz, then check for allow with no deny after\n        ArtifactAuthzCheck denyAacv = (ArtifactAuthzCheck) null\n        ArtifactAuthzCheck allowAacv = (ArtifactAuthzCheck) null\n\n        // see if there is a UserAccount for the username, and if so get its userId as a more permanent identifier\n        String userId = ufi.getUserId()\n        if (userId == null) userId = \"\"\n\n        // don't check authz for these queries, would cause infinite recursion\n        boolean alreadyDisabled = disableAuthz()\n        try {\n            // don't make a big condition for the DB to filter the list, or EntityList.filterByCondition from bigger\n            //     cached list, both are slower than manual iterate and check fields explicitly\n            ArrayList<ArtifactAuthzCheck> aacvList = new ArrayList<>()\n            ArrayList<ArtifactAuthzCheck> origAacvList = ufi.getArtifactAuthzCheckList()\n            int origAacvListSize = origAacvList.size()\n            for (int i = 0; i < origAacvListSize; i++) {\n                ArtifactAuthzCheck aacv = (ArtifactAuthzCheck) origAacvList.get(i)\n                if (artifactTypeEnum.is(aacv.artifactType) &&\n                        (ArtifactExecutionInfo.AUTHZA_ALL.is(aacv.authzAction) || aeii.internalActionEnum.is(aacv.authzAction)) &&\n                        (aacv.nameIsPattern || aeii.nameInternal.equals(aacv.artifactName))) {\n                    aacvList.add(aacv)\n                }\n            }\n\n            // if ((ArtifactExecutionInfo.AT_XML_SCREEN.is(artifactTypeEnum) || ArtifactExecutionInfo.AT_XML_SCREEN_TRANS.is(artifactTypeEnum)) && aeii.getName().contains(\"recordChange\"))\n            //     logger.warn(\"TOREMOVE for aeii [${aeii}] artifact isPermitted\\naacvList: ${aacvList}\\norigAacvList: ${origAacvList.join(\"\\n\")}\")\n\n            int aacvListSize = aacvList.size()\n            for (int i = 0; i < aacvListSize; i++) {\n                ArtifactAuthzCheck aacv = (ArtifactAuthzCheck) aacvList.get(i)\n\n                // check the name\n                if (aacv.nameIsPattern && !aeii.getName().matches(aacv.artifactName)) continue\n                // check the filterMap\n                if (aacv.filterMap != null && aeii.parameters != null) {\n                    Map<String, Object> filterMapObj = (Map<String, Object>) eci.getResource().expression(aacv.filterMap, null)\n                    boolean allMatches = true\n                    for (Map.Entry<String, Object> filterEntry in filterMapObj.entrySet()) {\n                        if (filterEntry.getValue() != aeii.parameters.get(filterEntry.getKey())) allMatches = false\n                    }\n                    if (!allMatches) continue\n                }\n\n                ArtifactExecutionInfo.AuthzType authzType = aacv.authzType\n                String authzServiceName = aacv.authzServiceName\n                if (authzServiceName != null && authzServiceName.length() > 0) {\n                    Map result = eci.getService().sync().name(authzServiceName)\n                            .parameters([userId:userId, authzActionEnumId:aeii.getActionEnum().name(),\n                            artifactTypeEnumId:artifactTypeEnum.name(), artifactName:aeii.getName()]).call()\n                    if (result?.authzTypeEnumId) authzType = ArtifactExecutionInfo.AuthzType.valueOf((String) result.authzTypeEnumId)\n                }\n\n                // if (\"AT_XML_SCREEN\" == aeii.typeEnumId && aeii.getName().contains(\"FOO\"))\n                //     logger.warn(\"TOREMOVE found authz record for aeii [${aeii}]: ${aacv}\")\n                if (ArtifactExecutionInfo.AUTHZT_DENY.is(authzType)) {\n                    // we already know last was not always allow (checked above), so keep going in loop just in case\n                    // we find an always allow in the query\n                    denyAacv = aacv\n                } else if (ArtifactExecutionInfo.AUTHZT_ALWAYS.is(authzType)) {\n                    aeii.copyAacvInfo(aacv, userId, true)\n                    // if (\"AT_XML_SCREEN\" == aeii.typeEnumId)\n                    //     logger.warn(\"TOREMOVE artifact isPermitted found always allow for user ${userId} - ${aeii}\")\n                    return true\n                } else if (denyAacv == null && ArtifactExecutionInfo.AUTHZT_ALLOW.is(authzType)) {\n                    // see if there are any denies in AEIs on lower on the stack\n                    boolean ancestorDeny = false\n                    for (ArtifactExecutionInfoImpl ancestorAeii in (currentStack ?: artifactExecutionInfoStack))\n                        if (ArtifactExecutionInfo.AUTHZT_DENY.is(ancestorAeii.getAuthorizedAuthzType())) ancestorDeny = true\n\n                    if (!ancestorDeny) allowAacv = aacv\n                }\n            }\n        } finally {\n            if (!alreadyDisabled) enableAuthz()\n        }\n\n        if (denyAacv != null) {\n            // record that this was an explicit deny (for push or exception in case something catches and handles it)\n            aeii.copyAacvInfo(denyAacv, userId, false)\n\n            if (!requiresAuthz || this.authzDisabled) {\n                // if no authz required, just return true even though it was a failure\n                // if (\"AT_XML_SCREEN\" == aeii.typeEnumId && aeii.getName().contains(\"FOO\"))\n                //     logger.warn(\"TOREMOVE artifact isPermitted (in deny) doesn't require authz or authzDisabled for user ${userId} - ${aeii}\")\n                return true\n            } else {\n                StringBuilder warning = new StringBuilder()\n                warning.append(\"User [${userId}] is not authorized for ${aeii.getTypeDescription()} [${aeii.getName()}] because of a deny record [type:${artifactTypeEnum.name()},action:${aeii.getActionEnum().name()}], here is the current artifact stack:\")\n                for (warnAei in this.stack) warning.append(\"\\n\").append(warnAei.toString())\n                logger.warn(warning.toString())\n\n                eci.getService().sync().name(\"create\", \"moqui.security.ArtifactAuthzFailure\").parameters(\n                        [artifactName:aeii.getName(), artifactTypeEnumId:artifactTypeEnum.name(),\n                        authzActionEnumId:aeii.getActionEnum().name(), userId:userId,\n                        failureDate:new Timestamp(System.currentTimeMillis()), isDeny:\"Y\"]).disableAuthz().call()\n\n                return false\n            }\n        } else if (allowAacv != null) {\n            aeii.copyAacvInfo(allowAacv, userId, true)\n            // if (\"AT_XML_SCREEN\" == aeii.typeEnumId && aeii.getName().contains(\"FOO\"))\n            //     logger.warn(\"TOREMOVE artifact isPermitted allow with no deny for user ${userId} - ${aeii}\")\n            return true\n        } else {\n            // no perms found for this, only allow if the current AEI has inheritable auth and same user, and (ALL action or same action)\n\n            // NOTE: this condition allows any user to be authenticated and allow inheritance if the last artifact was\n            //       logged in anonymously (ie userId=\"_NA_\"); consider alternate approaches; an alternate approach is\n            //       in place when no user is logged in, but when one is this is the only solution so far\n            if (lastAeii != null && lastAeii.internalAuthorizationInheritable &&\n                    (\"_NA_\".equals(lastAeii.internalAuthorizedUserId) || lastAeii.internalAuthorizedUserId == userId) &&\n                    (ArtifactExecutionInfo.AUTHZA_ALL.is(lastAeii.internalAuthorizedActionEnum) || aeii.internalActionEnum.is(lastAeii.internalAuthorizedActionEnum)) &&\n                    !ArtifactExecutionInfo.AUTHZT_DENY.is(lastAeii.internalAuthorizedAuthzType)) {\n                aeii.copyAuthorizedInfo(lastAeii)\n                // if (\"AT_XML_SCREEN\" == aeii.typeEnumId)\n                //     logger.warn(\"TOREMOVE artifact isPermitted inheritable and same user and ALL or same action for user ${userId} - ${aeii}\")\n                return true\n            }\n        }\n\n        if (!requiresAuthz || this.authzDisabled) {\n            // if no authz required, just push it even though it was a failure\n            if (lastAeii != null && lastAeii.internalAuthorizationInheritable) aeii.copyAuthorizedInfo(lastAeii)\n            // if (\"AT_XML_SCREEN\" == aeii.typeEnumId)\n            //     logger.warn(\"TOREMOVE artifact isPermitted doesn't require authz or authzDisabled for user ${userId} - ${aeii}\")\n            return true\n        } else {\n            // if we got here no authz found, so not granted (denied)\n            aeii.setAuthorizationWasGranted(false)\n\n            if (logger.isDebugEnabled()) {\n                StringBuilder warning = new StringBuilder()\n                warning.append(\"User [${userId}] is not authorized for ${aeii.getTypeDescription()} [${aeii.getName()}] because of no allow record [type:${artifactTypeEnum.name()},action:${aeii.getActionEnum().name()}]\\nlastAeii=[${lastAeii}]\\nHere is the artifact stack:\")\n                for (warnAei in this.stack) warning.append(\"\\n\").append(warnAei)\n                logger.debug(warning.toString())\n            }\n\n            if (isAccess) {\n                alreadyDisabled = disableAuthz()\n                try {\n                    // NOTE: this is called sync because failures should be rare and not as performance sensitive, and\n                    //    because this is still in a disableAuthz block (if async a service would have to be written for that)\n                    eci.service.sync().name(\"create\", \"moqui.security.ArtifactAuthzFailure\").parameters(\n                            [artifactName:aeii.getName(), artifactTypeEnumId:artifactTypeEnum.name(),\n                             authzActionEnumId:aeii.getActionEnum().name(), userId:userId,\n                             failureDate:new Timestamp(System.currentTimeMillis()), isDeny:\"N\"]).call()\n                } finally {\n                    if (!alreadyDisabled) enableAuthz()\n                }\n            }\n\n            return false\n        }\n\n        // if (\"AT_XML_SCREEN\" == aeii.typeEnumId) logger.warn(\"TOREMOVE artifact isPermitted got to end for user ${userId} - ${aeii}\")\n        // return true\n    }\n\n    protected void checkTarpit(ArtifactExecutionInfoImpl aeii) {\n        // logger.warn(\"Count tarpit ${aeii.toBasicString()}\", new BaseException(\"loc\"))\n\n        ExecutionContextFactoryImpl ecfi = eci.ecfi\n        UserFacadeImpl ufi = eci.userFacade\n        ArtifactExecutionInfo.ArtifactType artifactTypeEnum = aeii.internalTypeEnum\n\n        ArrayList<Map<String, Object>> artifactTarpitCheckList = ufi.getArtifactTarpitCheckList(artifactTypeEnum)\n        if (artifactTarpitCheckList == null || artifactTarpitCheckList.size() == 0) return\n\n        boolean alreadyDisabled = disableAuthz()\n        try {\n            // record and check velocity limit (tarpit)\n            boolean recordHitTime = false\n            long lockForSeconds = 0L\n            long checkTime = System.currentTimeMillis()\n            // if (artifactTypeEnumId == \"AT_XML_SCREEN\")\n            //     logger.warn(\"TOREMOVE about to check tarpit [${tarpitKey}], userGroupIdSet=${userGroupIdSet}, artifactTarpitList=${artifactTarpitList}\")\n\n            // see if there is a UserAccount for the username, and if so get its userId as a more permanent identifier\n            String userId = ufi.getUserId()\n            if (userId == null) userId = \"\"\n\n            String tarpitKey = userId + '@' + artifactTypeEnum.name() + ':' + aeii.getName()\n            ArrayList<Long> hitTimeList = (ArrayList<Long>) null\n            int artifactTarpitCheckListSize = artifactTarpitCheckList.size()\n            for (int i = 0; i < artifactTarpitCheckListSize; i++) {\n                Map<String, Object> artifactTarpit = (Map<String, Object>) artifactTarpitCheckList.get(i)\n                if (('Y'.equals(artifactTarpit.nameIsPattern) &&\n                        aeii.nameInternal.matches((String) artifactTarpit.artifactName)) ||\n                        aeii.nameInternal.equals(artifactTarpit.artifactName)) {\n                    recordHitTime = true\n                    if (hitTimeList == null) hitTimeList = (ArrayList<Long>) eci.tarpitHitCache.get(tarpitKey)\n                    long maxHitsDuration = artifactTarpit.maxHitsDuration as long\n                    // count hits in this duration; start with 1 to count the current hit\n                    long hitsInDuration = 1L\n                    if (hitTimeList != null && hitTimeList.size() > 0) {\n                        // copy the list to avoid a ConcurrentModificationException\n                        // NOTE: a better approach to concurrency that won't ever miss hits would be better\n                        ArrayList<Long> hitTimeListCopy = new ArrayList<Long>(hitTimeList)\n                        for (int htlInd = 0; htlInd < hitTimeListCopy.size(); htlInd++) {\n                            Long hitTime = (Long) hitTimeListCopy.get(htlInd)\n                            if (hitTime != null && ((hitTime - checkTime) < maxHitsDuration)) hitsInDuration++\n                        }\n                    }\n                    // logger.warn(\"TOREMOVE artifact [${tarpitKey}], now has ${hitsInDuration} hits in ${maxHitsDuration} seconds\")\n                    if (hitsInDuration > (artifactTarpit.maxHitsCount as long) && (artifactTarpit.tarpitDuration as long) > lockForSeconds) {\n                        lockForSeconds = artifactTarpit.tarpitDuration as long\n                        logger.warn(\"User [${userId}] exceeded ${artifactTarpit.maxHitsCount} in ${maxHitsDuration} seconds for artifact [${tarpitKey}], locking for ${lockForSeconds} seconds\")\n                    }\n                }\n            }\n            if (recordHitTime) {\n                if (hitTimeList == null) { hitTimeList = new ArrayList<Long>(); eci.tarpitHitCache.put(tarpitKey, hitTimeList) }\n                hitTimeList.add(System.currentTimeMillis())\n                // logger.warn(\"TOREMOVE recorded hit time for [${tarpitKey}], now has ${hitTimeList.size()} hits\")\n\n                // check the ArtifactTarpitLock for the current artifact attempt before seeing if there is a new lock to create\n                // NOTE: this only runs if we are recording a hit time for an artifact, so no performance impact otherwise\n                EntityFacadeImpl efi = ecfi.entityFacade\n                EntityList tarpitLockList = efi.find('moqui.security.ArtifactTarpitLock')\n                        .condition([userId:userId, artifactName:aeii.getName(), artifactTypeEnumId:artifactTypeEnum.name()] as Map<String, Object>)\n                        .useCache(true).list()\n                        .filterByCondition(efi.getConditionFactory().makeCondition('releaseDateTime', ComparisonOperator.GREATER_THAN, ufi.getNowTimestamp()), true)\n                if (tarpitLockList.size() > 0) {\n                    Timestamp releaseDateTime = tarpitLockList.get(0).getTimestamp('releaseDateTime')\n                    int retryAfterSeconds = ((releaseDateTime.getTime() - System.currentTimeMillis())/1000).intValue()\n                    throw new ArtifactTarpitException(\"User ${userId} has accessed ${aeii.getTypeDescription()} ${aeii.getName()} too many times and may not again until ${eci.l10nFacade.format(releaseDateTime, 'yyyy-MM-dd HH:mm:ss')} (retry after ${retryAfterSeconds} seconds)\".toString(), retryAfterSeconds)\n                }\n            }\n            // record the tarpit lock\n            if (lockForSeconds > 0L) {\n                eci.getService().sync().name('create', 'moqui.security.ArtifactTarpitLock').parameters(\n                        [userId:userId, artifactName:aeii.getName(), artifactTypeEnumId:artifactTypeEnum.name(),\n                         releaseDateTime:(new Timestamp(checkTime + ((lockForSeconds as BigDecimal) * 1000).intValue()))]).call()\n                eci.tarpitHitCache.remove(tarpitKey)\n            }\n        } finally {\n            if (!alreadyDisabled) enableAuthz()\n        }\n    }\n\n    static class AuthzFilterInfo {\n        String entityFilterSetId\n        EntityValue entityFilterSet\n        EntityValue entityFilter\n        Map<String, ArrayList<MNode>> memberFieldAliases\n        AuthzFilterInfo(EntityValue entityFilterSet, EntityValue entityFilter, Map<String, ArrayList<MNode>> memberFieldAliases) {\n            this.entityFilterSet = entityFilterSet\n            entityFilterSetId = (String) entityFilterSet?.getNoCheckSimple(\"entityFilterSetId\")\n            this.entityFilter = entityFilter\n            this.memberFieldAliases = memberFieldAliases\n        }\n    }\n    ArrayList<AuthzFilterInfo> getFindFiltersForUser(String findEntityName) {\n        EntityDefinition findEd = eci.entityFacade.getEntityDefinition(findEntityName)\n        return getFindFiltersForUser(findEd, null)\n    }\n    ArrayList<AuthzFilterInfo> getFindFiltersForUser(EntityDefinition findEd, Set<String> entityAliasUsedSet) {\n        // do nothing if authz disabled\n        if (authzDisabled) return null\n\n        // NOTE: look for filters in all unique aacv in stack? shouldn't be needed, most recent auth is the valid one\n        ArtifactExecutionInfoImpl lastAeii = (ArtifactExecutionInfoImpl) artifactExecutionInfoStack.peekFirst()\n        ArtifactAuthzCheck aacv = lastAeii.internalAacv\n        if (aacv == null) return null\n\n        String findEntityName = findEd.getFullEntityName()\n        // skip all Moqui Framework entities;  note that this skips moqui.example too...\n        if (findEntityName.startsWith(\"moqui.\")) return null\n\n        // find applicable EntityFilter records\n        EntityList artifactAuthzFilterList = eci.entityFacade.find(\"moqui.security.ArtifactAuthzFilter\")\n                .condition(\"artifactAuthzId\", aacv.artifactAuthzId).disableAuthz().useCache(true).list()\n\n        if (artifactAuthzFilterList == null) return null\n        int authzFilterSize = artifactAuthzFilterList.size()\n        if (authzFilterSize == 0) return null\n\n        ArrayList<AuthzFilterInfo> authzFilterInfoList = (ArrayList<AuthzFilterInfo>) null\n        for (int i = 0; i < authzFilterSize; i++) {\n            EntityValue artifactAuthzFilter = (EntityValue) artifactAuthzFilterList.get(i)\n            String entityFilterSetId = (String) artifactAuthzFilter.getNoCheckSimple(\"entityFilterSetId\")\n            String authzApplyCond = (String) artifactAuthzFilter.getNoCheckSimple(\"applyCond\")\n\n            EntityValue entityFilterSet = eci.entityFacade.find(\"moqui.security.EntityFilterSet\")\n                    .condition(\"entityFilterSetId\", entityFilterSetId).disableAuthz().useCache(true).one()\n            String setApplyCond = (String) entityFilterSet.getNoCheckSimple(\"applyCond\")\n\n            boolean hasAuthzCond = authzApplyCond != null && !authzApplyCond.isEmpty()\n            boolean hasSetCond = setApplyCond != null && !setApplyCond.isEmpty()\n            if (hasAuthzCond || hasSetCond) {\n                // for evaluating apply conditions add user context to ec.context\n                // this might be more efficient outside the loop, or perhaps even expect it to be in place outside this method\n                //     (fine for filterFindForUser(), cumbersome for other uses of this method)\n                eci.contextStack.push(eci.userFacade.context)\n                try {\n                    if (hasAuthzCond && !eci.resourceFacade.condition(authzApplyCond, null)) continue\n                    if (hasSetCond && !eci.resourceFacade.condition(setApplyCond, null)) continue\n                } finally {\n                    eci.contextStack.pop()\n                }\n            }\n\n            // NOTE: at this level the results could be cached, but worth it? EntityFilter entity list cached already,\n            //     some processing for view-entity but mostly only if entityAliasUsedSet, and could only cache if !entityAliasUsedSet\n            EntityList entityFilterList = eci.entityFacade.find(\"moqui.security.EntityFilter\")\n                    .condition(\"entityFilterSetId\", entityFilterSetId).disableAuthz().useCache(true).list()\n\n            if (entityFilterList == null) continue\n            int entFilterSize = entityFilterList.size()\n            if (entFilterSize == 0) continue\n\n            for (int j = 0; j < entFilterSize; j++) {\n                EntityValue entityFilter = entityFilterList.get(j)\n                String filterEntityName = (String) entityFilter.getNoCheckSimple(\"entityName\")\n                if (filterEntityName == null) continue\n\n                // see if there if any filter entities match the current entity or if it is a view then a member entity\n                Map<String, ArrayList<MNode>> memberFieldAliases = (Map<String, ArrayList<MNode>>) null\n                if (!filterEntityName.equals(findEd.getFullEntityName())) {\n                    if (findEd.isViewEntity) {\n                        memberFieldAliases = findEd.getMemberFieldAliases(filterEntityName)\n                        if (memberFieldAliases == null) continue\n                    } else {\n                        continue\n                    }\n                }\n\n                if (memberFieldAliases != null && entityAliasUsedSet != null) {\n                    // trim memberFieldAliases by entity aliases actually used\n                    Map<String, ArrayList<MNode>> newFieldAliases = (Map<String, ArrayList<MNode>>) null\n\n                    for (Map.Entry<String, ArrayList<MNode>> aliasesEntry in memberFieldAliases.entrySet()) {\n                        ArrayList<MNode> aliasList = aliasesEntry.getValue()\n                        if (aliasList == null) continue // should never happen, buy yeah\n                        ArrayList<MNode> newAliasList = (ArrayList<MNode>) null\n\n                        int aliasListSize = aliasList.size()\n                        for (int ali = 0; ali < aliasListSize; ali++) {\n                            MNode aliasNode = (MNode) aliasList.get(ali)\n                            String entityAlias = aliasNode.attribute(\"entity-alias\")\n                            if (entityAliasUsedSet.contains(entityAlias)) {\n                                // is used, copy over\n                                if (newAliasList == null) {\n                                    newAliasList = new ArrayList<>()\n                                    if (newFieldAliases == null) newFieldAliases = new LinkedHashMap<>()\n                                    newFieldAliases.put(aliasesEntry.getKey(), newAliasList)\n                                }\n                                newAliasList.add(aliasNode)\n                            }\n                        }\n                    }\n\n                    // if nothing added then nothing to filter on for this entity\n                    if (newFieldAliases == (Map<String, ArrayList<MNode>>) null) continue\n                    memberFieldAliases = newFieldAliases\n                }\n\n                // if we got to this point we found a matching filter\n                if (authzFilterInfoList == (ArrayList<AuthzFilterInfo>) null) authzFilterInfoList = new ArrayList<>()\n                authzFilterInfoList.add(new AuthzFilterInfo(entityFilterSet, entityFilter, memberFieldAliases))\n            }\n        }\n\n        return authzFilterInfoList\n    }\n\n    ArrayList<EntityConditionImplBase> filterFindForUser(EntityDefinition findEd, Set<String> entityAliasUsedSet) {\n        ArrayList<AuthzFilterInfo> authzFilterInfoList = getFindFiltersForUser(findEd, entityAliasUsedSet)\n        if (authzFilterInfoList == null) return null\n        int authzFilterInfoListSize = authzFilterInfoList.size()\n        if (authzFilterInfoListSize == 0) return null\n\n        // for evaluating filter Maps add user context to ec.context\n        eci.contextStack.push(eci.userFacade.context)\n\n        ArrayList<EntityConditionImplBase> condList = (ArrayList<EntityConditionImplBase>) null\n        try {\n            for (int i = 0; i < authzFilterInfoListSize; i++) {\n                AuthzFilterInfo authzFilterInfo = (AuthzFilterInfo) authzFilterInfoList.get(i)\n                EntityValue entityFilter = authzFilterInfo.entityFilter\n                Map<String, ArrayList<MNode>> memberFieldAliases = authzFilterInfo.memberFieldAliases\n\n                // NOTE: this expression eval must be done for the current context, with eci.userFacade.context added\n                Object filterMapObjEval = eci.resourceFacade.expression((String) entityFilter.getNoCheckSimple('filterMap'), null)\n                Map<String, Object> filterMapObj\n                if (filterMapObjEval instanceof Map) {\n                    filterMapObj = filterMapObjEval as Map<String, Object>\n                } else {\n                    logger.error(\"EntityFiler filterMap did not evaluate to a Map<String, Object>: ${entityFilter.getString('filterMap')}\")\n                    continue\n                }\n                // logger.warn(\"===== ${findEd.getFullEntityName()} filterMapObj: ${filterMapObj}\")\n\n                EntityConditionFactoryImpl conditionFactory = eci.entityFacade.conditionFactoryImpl\n                String efComparisonEnumId = (String) entityFilter.getNoCheckSimple('comparisonEnumId')\n                ComparisonOperator compOp = efComparisonEnumId != null && efComparisonEnumId.length() > 0 ?\n                        conditionFactory.comparisonOperatorFromEnumId(efComparisonEnumId) : null\n                JoinOperator joinOp = \"Y\".equals(entityFilter.getNoCheckSimple('joinOr')) ? EntityCondition.OR : EntityCondition.AND\n\n                // use makeCondition(Map) instead of breaking down here\n                try {\n                    EntityConditionImplBase entCond = conditionFactory.makeCondition(filterMapObj, compOp, joinOp, findEd, memberFieldAliases, true)\n                    if (entCond == (EntityConditionImplBase) null) continue\n\n                    // add the condition to the list to return\n                    if (condList == (ArrayList<EntityConditionImplBase>) null) condList = new ArrayList<>()\n                    condList.add(entCond)\n\n                    // logger.info(\"Query on ${findEntityName} added authz filter conditions: ${entCond}\")\n                    // logger.info(\"Query on ${findEntityName} find: ${efb.toString()}\")\n                } catch (Exception e) {\n                    String entityFilterId = (String) entityFilter.getNoCheckSimple(\"entityFilterId\")\n                    logger.error(\"Error adding authz entity filter ${entityFilterId} condition: ${e.toString()}\")\n                    if (!\"Y\".equals(authzFilterInfo.entityFilterSet.getNoCheckSimple(\"allowMissingAlias\")))\n                        throw new ArtifactAuthorizationException(\"Could not apply authorized data filter so not doing query, required field alias missing\", e)\n                }\n            }\n        } finally {\n            eci.contextStack.pop()\n        }\n\n        // if (condList) logger.warn(\"Filters for ${findEd.getFullEntityName()}: ${condList}\")\n        return condList\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/ArtifactExecutionInfoImpl.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context;\n\nimport org.moqui.context.ArtifactExecutionInfo;\nimport org.moqui.impl.entity.EntityValueBase;\nimport org.moqui.util.CollectionUtilities;\nimport org.moqui.util.StringUtilities;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.IOException;\nimport java.io.StringWriter;\nimport java.io.Writer;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.util.*;\n\npublic class ArtifactExecutionInfoImpl implements ArtifactExecutionInfo {\n    protected final static Logger logger = LoggerFactory.getLogger(ArtifactExecutionInfoImpl.class);\n\n    // NOTE: these need to be in a Map instead of the DB because Enumeration records may not yet be loaded\n    private final static Map<ArtifactType, String> artifactTypeDescriptionMap = new EnumMap<>(ArtifactType.class);\n    private final static Map<AuthzAction, String> artifactActionDescriptionMap = new EnumMap<>(AuthzAction.class);\n    static {\n        artifactTypeDescriptionMap.put(AT_XML_SCREEN, \"Screen\"); artifactTypeDescriptionMap.put(AT_XML_SCREEN_TRANS, \"Transition\");\n        artifactTypeDescriptionMap.put(AT_XML_SCREEN_CONTENT, \"Screen Content\");\n        artifactTypeDescriptionMap.put(AT_SERVICE, \"Service\"); artifactTypeDescriptionMap.put(AT_ENTITY, \"Entity\");\n        artifactTypeDescriptionMap.put(AT_REST_PATH, \"REST Path\"); artifactTypeDescriptionMap.put(AT_OTHER, \"Other\");\n\n        artifactActionDescriptionMap.put(AUTHZA_VIEW, \"View\"); artifactActionDescriptionMap.put(AUTHZA_CREATE, \"Create\");\n        artifactActionDescriptionMap.put(AUTHZA_UPDATE, \"Update\"); artifactActionDescriptionMap.put(AUTHZA_DELETE, \"Delete\");\n        artifactActionDescriptionMap.put(AUTHZA_ALL, \"All\");\n    }\n\n    public final String nameInternal;\n    public final ArtifactType internalTypeEnum;\n    public final AuthzAction internalActionEnum;\n    public final String actionDetail;\n    protected Map<String, Object> parameters = null;\n    public String internalAuthorizedUserId = null;\n    public AuthzType internalAuthorizedAuthzType = null;\n    public AuthzAction internalAuthorizedActionEnum = null;\n    public boolean internalAuthorizationInheritable = false;\n    public boolean internalAuthzWasRequired = false;\n    public boolean isAccess = false;\n    public boolean trackArtifactHit = true;\n    private boolean internalAuthzWasGranted = false;\n    public ArtifactAuthzCheck internalAacv = null;\n    public Long moquiTxId = null;\n\n    //protected Exception createdLocation = null\n    private ArtifactExecutionInfoImpl parentAeii = (ArtifactExecutionInfoImpl) null;\n    public final long startTimeMillis;\n    public final long startTimeNanos;\n    private long endTimeNanos = 0;\n    public Long outputSize = null;\n    private ArrayList<ArtifactExecutionInfoImpl> childList = (ArrayList<ArtifactExecutionInfoImpl>) null;\n    private long childrenRunningTime = 0;\n\n    public ArtifactExecutionInfoImpl(String name, ArtifactType typeEnum, AuthzAction actionEnum, String detail) {\n        nameInternal = name;\n        internalTypeEnum = typeEnum;\n        internalActionEnum = actionEnum != null ? actionEnum : AUTHZA_ALL;\n        actionDetail = detail;\n        //createdLocation = new Exception(\"Create AEII location for ${name}, type ${typeEnumId}, action ${actionEnumId}\")\n        startTimeMillis = System.currentTimeMillis();\n        startTimeNanos = System.nanoTime();\n    }\n\n    public ArtifactExecutionInfoImpl setParameters(Map<String, Object> parameters) { this.parameters = parameters; return this; }\n\n    @Override\n    public String getName() { return nameInternal; }\n\n    @Override\n    public ArtifactType getTypeEnum() { return internalTypeEnum; }\n    @Override\n    public String getTypeDescription() {\n        String desc = artifactTypeDescriptionMap.get(internalTypeEnum);\n        return desc != null ? desc : internalTypeEnum.name();\n    }\n\n    @Override\n    public AuthzAction getActionEnum() { return internalActionEnum; }\n    @Override\n    public String getActionDescription() {\n        String desc = artifactActionDescriptionMap.get(internalActionEnum);\n        return desc != null ? desc : internalActionEnum.name();\n    }\n\n    @Override\n    public String getAuthorizedUserId() { return internalAuthorizedUserId; }\n    void setAuthorizedUserId(String authorizedUserId) { this.internalAuthorizedUserId = authorizedUserId; }\n\n    @Override\n    public AuthzType getAuthorizedAuthzType() { return internalAuthorizedAuthzType; }\n    void setAuthorizedAuthzType(AuthzType authorizedAuthzType) { this.internalAuthorizedAuthzType = authorizedAuthzType; }\n\n    @Override\n    public AuthzAction getAuthorizedActionEnum() { return internalAuthorizedActionEnum; }\n    void setAuthorizedActionEnum(AuthzAction authorizedActionEnum) { this.internalAuthorizedActionEnum = authorizedActionEnum; }\n\n    @Override\n    public boolean isAuthorizationInheritable() { return internalAuthorizationInheritable; }\n    void setAuthorizationInheritable(boolean isAuthorizationInheritable) { this.internalAuthorizationInheritable = isAuthorizationInheritable; }\n\n    @Override\n    public boolean getAuthorizationWasRequired() { return internalAuthzWasRequired; }\n    public ArtifactExecutionInfoImpl setAuthzReqdAndIsAccess(boolean authzReqd, boolean isAccess) {\n        internalAuthzWasRequired = authzReqd;\n        this.isAccess = isAccess;\n        return this;\n    }\n    public ArtifactExecutionInfoImpl setTrackArtifactHit(boolean tah) { trackArtifactHit = tah; return this; }\n    @Override\n    public boolean getAuthorizationWasGranted() { return internalAuthzWasGranted; }\n    void setAuthorizationWasGranted(boolean value) { internalAuthzWasGranted = value ? Boolean.TRUE : Boolean.FALSE; }\n\n    public Long getMoquiTxId() { return moquiTxId; }\n    void setMoquiTxId(Long txId) { moquiTxId = txId; }\n\n    ArtifactAuthzCheck getAacv() { return internalAacv; }\n\n    public void copyAacvInfo(ArtifactAuthzCheck aacv, String userId, boolean wasGranted) {\n        internalAacv = aacv;\n        internalAuthorizedUserId = userId;\n        internalAuthorizedAuthzType = aacv.authzType;\n        internalAuthorizedActionEnum = aacv.authzAction;\n        internalAuthorizationInheritable = aacv.inheritAuthz;\n        internalAuthzWasGranted = wasGranted;\n    }\n\n    public void copyAuthorizedInfo(ArtifactExecutionInfoImpl aeii) {\n        internalAacv = aeii.internalAacv;\n        internalAuthorizedUserId = aeii.internalAuthorizedUserId;\n        internalAuthorizedAuthzType = aeii.internalAuthorizedAuthzType;\n        internalAuthorizedActionEnum = aeii.internalAuthorizedActionEnum;\n        internalAuthorizationInheritable = aeii.internalAuthorizationInheritable;\n        // NOTE: don't copy internalAuthzWasRequired, always set in isPermitted()\n        internalAuthzWasGranted = aeii.internalAuthzWasGranted;\n    }\n\n    void setEndTime() { this.endTimeNanos = System.nanoTime(); }\n    @Override\n    public long getRunningTime() { return endTimeNanos != 0 ? endTimeNanos - startTimeNanos : 0; }\n    public double getRunningTimeMillisDouble() { return (endTimeNanos != 0 ? endTimeNanos - startTimeNanos : 0) / 1000000.0; }\n    public long getRunningTimeMillisLong() { return Math.round((endTimeNanos != 0 ? endTimeNanos - startTimeNanos : 0) / 1000000.0); }\n    private void calcChildTime(boolean recurse) {\n        childrenRunningTime = 0;\n        if (childList != null) for (ArtifactExecutionInfoImpl aeii: childList) {\n            childrenRunningTime += aeii.getRunningTime();\n            if (recurse) aeii.calcChildTime(true);\n        }\n    }\n    @Override\n    public long getThisRunningTime() { return getRunningTime() - getChildrenRunningTime(); }\n    @Override\n    public long getChildrenRunningTime() {\n        if (childrenRunningTime == 0) calcChildTime(false);\n        return childrenRunningTime;\n    }\n\n    public BigDecimal getRunningTimeMillis() { return new BigDecimal(getRunningTime()).movePointLeft(6).setScale(2, RoundingMode.HALF_UP); }\n    public BigDecimal getThisRunningTimeMillis() { return new BigDecimal(getThisRunningTime()).movePointLeft(6).setScale(2, RoundingMode.HALF_UP); }\n    public BigDecimal getChildrenRunningTimeMillis() { return new BigDecimal(getChildrenRunningTime()).movePointLeft(6).setScale(2, RoundingMode.HALF_UP); }\n\n    void setParent(ArtifactExecutionInfoImpl parentAeii) { this.parentAeii = parentAeii; }\n    @Override\n    public ArtifactExecutionInfo getParent() { return parentAeii; }\n    @Override\n    public BigDecimal getPercentOfParentTime() { return parentAeii != null && endTimeNanos != 0 ?\n        new BigDecimal((getRunningTime() / parentAeii.getRunningTime()) * 100).setScale(2, RoundingMode.HALF_UP) : BigDecimal.ZERO; }\n\n\n    void addChild(ArtifactExecutionInfoImpl aeii) {\n        if (childList == null) childList = new ArrayList<>();\n        childList.add(aeii);\n    }\n    @Override\n    public List<ArtifactExecutionInfo> getChildList() {\n        List<ArtifactExecutionInfo> newChildList = new ArrayList<>();\n        newChildList.addAll(childList);\n        return newChildList;\n    }\n\n    public void print(Writer writer, int level, boolean children) {\n        try {\n            for (int i = 0; i < (level * 2); i++) writer.append(\" \");\n            writer.append(\"[\").append(parentAeii != null ? StringUtilities.paddedString(getPercentOfParentTime().toPlainString(), 5, false) : \"     \").append(\"%]\");\n            writer.append(\"[\").append(StringUtilities.paddedString(getRunningTimeMillis().toPlainString(), 5, false)).append(\"]\");\n            writer.append(\"[\").append(StringUtilities.paddedString(getThisRunningTimeMillis().toPlainString(), 3, false)).append(\"]\");\n            writer.append(\"[\").append(childList != null ? StringUtilities.paddedString(getChildrenRunningTimeMillis().toPlainString(), 3, false) : \"   \").append(\"] \");\n            writer.append(StringUtilities.paddedString(getTypeDescription(), 10, true)).append(\" \");\n            writer.append(StringUtilities.paddedString(getActionDescription(), 7, true)).append(\" \");\n            writer.append(StringUtilities.paddedString(actionDetail, 5, true)).append(\" \");\n            writer.append(nameInternal).append(\"\\n\");\n\n            if (children && childList != null)\n                for (ArtifactExecutionInfoImpl aeii: childList) aeii.print(writer, level + 1, true);\n        } catch (IOException e) {\n            e.printStackTrace();\n        }\n    }\n\n    private String getKeyString() { return nameInternal + \":\" + internalTypeEnum.name() + \":\" + internalActionEnum.name() + \":\" + actionDetail; }\n    private String getKeyStringNoName() { return internalTypeEnum.name() + \":\" + internalActionEnum.name() + \":\" + actionDetail; }\n\n    public static class ArtifactTypeStats {\n\n        public int screenCount = 0, screenTransCount = 0, screenContentCount = 0, restPathCount = 0,\n                serviceViewCount = 0, serviceOtherCount = 0,\n                entityFindOneCount = 0, entityFindListCount = 0, entityFindIteratorCount = 0, entityFindCountCount = 0,\n                entityCreateCount = 0, entityUpdateCount = 0, entityDeleteCount = 0;\n        public long screenTime = 0, screenTransTime = 0, screenContentTime = 0, restPathTime = 0,\n                serviceViewTime = 0, serviceOtherTime = 0,\n                entityFindOneTime = 0, entityFindListTime = 0, entityFindIteratorTime = 0, entityFindCountTime = 0,\n                entityCreateTime = 0, entityUpdateTime = 0, entityDeleteTime = 0;\n        public void add(ArtifactTypeStats that) {\n            if (that == null) return;\n\n            screenCount += that.screenCount; screenTransCount += that.screenTransCount;\n            screenContentCount += that.screenContentCount; restPathCount += that.restPathCount;\n            serviceViewCount += that.serviceViewCount; serviceOtherCount += that.serviceOtherCount;\n            entityFindOneCount += that.entityFindOneCount; entityFindListCount += that.entityFindListCount;\n            entityFindIteratorCount += that.entityFindIteratorCount; entityFindCountCount += that.entityFindCountCount;\n            entityCreateCount += that.entityCreateCount; entityUpdateCount += that.entityUpdateCount;\n            entityDeleteCount += that.entityDeleteCount;\n\n            screenTime += that.screenTime; screenTransTime += that.screenTransTime;\n            screenContentTime += that.screenContentTime; restPathTime += that.restPathTime;\n            serviceViewTime += that.serviceViewTime; serviceOtherTime += that.serviceOtherTime;\n            entityFindOneTime += that.entityFindOneTime; entityFindListTime += that.entityFindListTime;\n            entityFindIteratorTime += that.entityFindIteratorTime; entityFindCountTime += that.entityFindCountTime;\n            entityCreateTime += that.entityCreateTime; entityUpdateTime += that.entityUpdateTime;\n            entityDeleteTime += that.entityDeleteTime;\n        }\n        public ArtifactTypeStats cloneStats(ArtifactTypeStats that) {\n            ArtifactTypeStats newStats = new ArtifactTypeStats();\n            newStats.add(that);\n            return newStats;\n        }\n    }\n    static ArtifactTypeStats getArtifactTypeStats(ArrayList<ArtifactExecutionInfoImpl> aeiiList) {\n        ArtifactTypeStats stats = new ArtifactTypeStats();\n        addArtifactTypeStats(aeiiList, stats);\n        return stats;\n    }\n    static void addArtifactTypeStats(ArrayList<ArtifactExecutionInfoImpl> aeiiList, ArtifactTypeStats stats) {\n        if (aeiiList == null) return;\n        int aeiiListSize = aeiiList.size();\n        for (int i = 0; i < aeiiListSize; i++) {\n            ArtifactExecutionInfoImpl aeii = aeiiList.get(i);\n            // tight loop, use switch instead of if on these enums for much better performance; run fast for use in on the fly accumulators\n            switch (aeii.internalTypeEnum) {\n                case AT_ENTITY:\n                    switch (aeii.internalActionEnum) {\n                        case AUTHZA_VIEW:\n                            if (aeii.actionDetail != null && !aeii.actionDetail.isEmpty()) {\n                                char first = aeii.actionDetail.charAt(0);\n                                switch (first) {\n                                    case 'o': // one\n                                    case 'r': // refresh\n                                        stats.entityFindOneCount++;\n                                        stats.entityFindOneTime += aeii.getRunningTime();\n                                        break;\n                                    case 'l': // list\n                                        stats.entityFindListCount++;\n                                        stats.entityFindListTime += aeii.getRunningTime();\n                                        break;\n                                    case 'i': // iterator\n                                        stats.entityFindIteratorCount++;\n                                        stats.entityFindIteratorTime += aeii.getRunningTime();\n                                        break;\n                                    case 'c': // count\n                                        stats.entityFindCountCount++;\n                                        stats.entityFindCountTime += aeii.getRunningTime();\n                                        break;\n                                }\n                            } else {\n                                logger.warn(\"entity view with no detail \" + aeii.toBasicString());\n                            }\n                            break;\n                        case AUTHZA_CREATE:\n                            stats.entityCreateCount++;\n                            stats.entityCreateTime += aeii.getRunningTime();\n                            break;\n                        case AUTHZA_UPDATE:\n                            stats.entityUpdateCount++;\n                            stats.entityUpdateTime += aeii.getRunningTime();\n                            break;\n                        case AUTHZA_DELETE:\n                            stats.entityDeleteCount++;\n                            stats.entityDeleteTime += aeii.getRunningTime();\n                            break;\n                    }\n                    break;\n                case AT_SERVICE:\n                    if (aeii.internalActionEnum == AUTHZA_VIEW) {\n                        stats.serviceViewCount++;\n                        stats.serviceViewTime += aeii.getRunningTime();\n                    } else {\n                        stats.serviceOtherCount++;\n                        stats.serviceOtherTime += aeii.getRunningTime();\n                    }\n                    break;\n                case AT_XML_SCREEN:\n                    stats.screenCount++;\n                    stats.screenTime += aeii.getRunningTime();\n                    break;\n                case AT_XML_SCREEN_TRANS:\n                    stats.screenTransCount++;\n                    stats.screenTransTime += aeii.getRunningTime();\n                    break;\n                case AT_XML_SCREEN_CONTENT:\n                    stats.screenContentCount++;\n                    stats.screenContentTime += aeii.getRunningTime();\n                    break;\n                case AT_REST_PATH:\n                    stats.restPathCount++;\n                    stats.restPathTime += aeii.getRunningTime();\n                    break;\n            }\n\n            // this aeii is done, how about children?\n            addArtifactTypeStats(aeii.childList, stats);\n        }\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    static List<Map<String, Object>> hotSpotByTime(List<ArtifactExecutionInfoImpl> aeiiList, boolean ownTime, String orderBy) {\n        Map<String, Map<String, Object>> timeByArtifact = new LinkedHashMap<>();\n        for (ArtifactExecutionInfoImpl aeii: aeiiList) aeii.addToMapByTime(timeByArtifact, ownTime);\n        List<Map<String, Object>> hotSpotList = new LinkedList<>();\n        hotSpotList.addAll(timeByArtifact.values());\n\n        // in some cases we get REALLY long times before the system is warmed, knock those out\n        for (Map<String, Object> val: hotSpotList) {\n            int knockOutCount = 0;\n            List<BigDecimal> newTimes = new LinkedList<>();\n            BigDecimal timeAvg = (BigDecimal) val.get(\"timeAvg\");\n            for (BigDecimal time: (List<BigDecimal>) val.get(\"times\")) {\n                // this ain\"t no standard deviation, but consider 3 times average to be abnormal\n                if (time.floatValue() > (timeAvg.floatValue() * 3)) {\n                    knockOutCount++;\n                } else {\n                    newTimes.add(time);\n                }\n            }\n            if (knockOutCount > 0 && newTimes.size() > 0) {\n                // calc new average, add knockOutCount times to fill in gaps, calc new time total\n                BigDecimal newTotal = BigDecimal.ZERO;\n                BigDecimal newMax = BigDecimal.ZERO;\n                for (BigDecimal time: newTimes) { newTotal = newTotal.add(time); if (time.compareTo(newMax) > 0) newMax = time; }\n                BigDecimal newAvg = newTotal.divide(new BigDecimal(newTimes.size()), 2, RoundingMode.HALF_UP);\n                // long newTimeAvg = newAvg.setScale(0, RoundingMode.HALF_UP)\n                newTotal = newTotal.add(newAvg.multiply(new BigDecimal(knockOutCount)));\n                val.put(\"time\", newTotal);\n                val.put(\"timeMax\", newMax);\n                val.put(\"timeAvg\", newAvg);\n            }\n        }\n\n        List<String> obList = new LinkedList<>();\n        if (orderBy != null && orderBy.length() > 0) obList.add(orderBy); else obList.add(\"-time\");\n        CollectionUtilities.orderMapList(hotSpotList, obList);\n        return hotSpotList;\n    }\n    @SuppressWarnings(\"unchecked\")\n    private void addToMapByTime(Map<String, Map<String, Object>> timeByArtifact, boolean ownTime) {\n        String key = getKeyString();\n        Map<String, Object> val = timeByArtifact.get(key);\n        BigDecimal curTime = ownTime ? getThisRunningTimeMillis() : getRunningTimeMillis();\n        if (val == null) {\n            Map<String, Object> newMap = new LinkedHashMap<>();\n            List<BigDecimal> timesList = new LinkedList<>();\n            timesList.add(curTime);\n            newMap.put(\"times\", timesList); newMap.put(\"time\", curTime); newMap.put(\"timeMin\", curTime);\n            newMap.put(\"timeMax\", curTime); newMap.put(\"timeAvg\", curTime); newMap.put(\"count\", BigDecimal.ONE);\n            newMap.put(\"name\", nameInternal); newMap.put(\"actionDetail\", actionDetail);\n            newMap.put(\"type\", getTypeDescription()); newMap.put(\"action\", getActionDescription());\n            timeByArtifact.put(key, newMap);\n        } else {\n            val = timeByArtifact.get(key);\n            BigDecimal newCount = ((BigDecimal) val.get(\"count\")).add(BigDecimal.ONE);\n            val.put(\"count\", newCount);\n            if (newCount.intValue() == 2 && ((List<BigDecimal>) val.get(\"times\")).get(0).compareTo(curTime.multiply(new BigDecimal(3))) > 0) {\n                // if the first is much higher than the 2nd, use the 2nd for both\n                List<BigDecimal> timesList = new LinkedList<>();\n                timesList.add(curTime); timesList.add(curTime);\n                val.put(\"times\", timesList);\n                val.put(\"time\", curTime.add(curTime)); val.put(\"timeMin\", curTime);\n                val.put(\"timeMax\", curTime); val.put(\"timeAvg\", curTime);\n            } else {\n                ((List<BigDecimal>) val.get(\"times\")).add(curTime);\n                val.put(\"time\", ((BigDecimal) val.get(\"time\")).add(curTime));\n                val.put(\"timeMin\", ((BigDecimal) val.get(\"timeMin\")).compareTo(curTime) > 0 ? curTime : (BigDecimal) val.get(\"timeMin\"));\n                val.put(\"timeMax\", ((BigDecimal) val.get(\"timeMax\")).compareTo(curTime) > 0 ? (BigDecimal) val.get(\"timeMax\") : curTime);\n                val.put(\"timeAvg\", ((BigDecimal) val.get(\"time\")).divide((BigDecimal) val.get(\"count\"), 2, RoundingMode.HALF_UP));\n            }\n        }\n        if (childList != null) for (ArtifactExecutionInfoImpl aeii: childList) aeii.addToMapByTime(timeByArtifact, ownTime);\n    }\n    static void printHotSpotList(Writer writer, List<Map> infoList) throws IOException {\n        // \"[${time}:${timeMin}:${timeAvg}:${timeMax}][${count}] ${type} ${action} ${actionDetail} ${name}\"\n        for (Map info: infoList) {\n            writer.append(\"[\").append(StringUtilities.paddedString(((BigDecimal) info.get(\"time\")).toPlainString(), 8, false)).append(\":\");\n            writer.append(StringUtilities.paddedString(((BigDecimal) info.get(\"timeMin\")).toPlainString(), 7, false)).append(\":\");\n            writer.append(StringUtilities.paddedString(((BigDecimal) info.get(\"timeAvg\")).toPlainString(), 7, false)).append(\":\");\n            writer.append(StringUtilities.paddedString(((BigDecimal) info.get(\"timeMax\")).toPlainString(), 7, false)).append(\"]\");\n            writer.append(\"[\").append(StringUtilities.paddedString(((BigDecimal) info.get(\"count\")).toPlainString(), 4, false)).append(\"] \");\n            writer.append(StringUtilities.paddedString((String) info.get(\"type\"), 10, true)).append(\" \");\n            writer.append(StringUtilities.paddedString((String) info.get(\"action\"), 7, true)).append(\" \");\n            writer.append(StringUtilities.paddedString((String) info.get(\"actionDetail\"), 5, true)).append(\" \");\n            writer.append((String) info.get(\"name\")).append(\"\\n\");\n        }\n    }\n\n\n    static List<Map> consolidateArtifactInfo(List<ArtifactExecutionInfoImpl> aeiiList) {\n        List<Map> topLevelList = new LinkedList<>();\n        Map<String, Map<String, Object>> flatMap = new LinkedHashMap<>();\n        for (ArtifactExecutionInfoImpl aeii: aeiiList) aeii.consolidateArtifactInfo(topLevelList, flatMap, null);\n        return topLevelList;\n    }\n    @SuppressWarnings(\"unchecked\")\n    private void consolidateArtifactInfo(List<Map> topLevelList, Map<String, Map<String, Object>> flatMap, Map parentArtifactMap) {\n        String key = getKeyString();\n        Map<String, Object> artifactMap = flatMap.get(key);\n        if (artifactMap == null) {\n            artifactMap = new LinkedHashMap<>();\n            artifactMap.put(\"time\", getRunningTimeMillis()); artifactMap.put(\"thisTime\", getThisRunningTimeMillis());\n            artifactMap.put(\"childrenTime\", getChildrenRunningTimeMillis()); artifactMap.put(\"count\", BigDecimal.ONE);\n            artifactMap.put(\"name\", nameInternal); artifactMap.put(\"actionDetail\", actionDetail);\n            artifactMap.put(\"childInfoList\", new LinkedList()); artifactMap.put(\"key\", key);\n            artifactMap.put(\"type\", getTypeDescription()); artifactMap.put(\"action\", getActionDescription());\n            flatMap.put(key, artifactMap);\n            if (parentArtifactMap != null) {\n                ((List) parentArtifactMap.get(\"childInfoList\")).add(artifactMap);\n            } else {\n                topLevelList.add(artifactMap);\n            }\n        } else {\n            artifactMap.put(\"count\", ((BigDecimal) artifactMap.get(\"count\")).add(BigDecimal.ONE));\n            artifactMap.put(\"time\", ((BigDecimal) artifactMap.get(\"time\")).add(getRunningTimeMillis()));\n            artifactMap.put(\"thisTime\", ((BigDecimal) artifactMap.get(\"thisTime\")).add(getThisRunningTimeMillis()));\n            artifactMap.put(\"childrenTime\", ((BigDecimal) artifactMap.get(\"childrenTime\")).add(getChildrenRunningTimeMillis()));\n            if (parentArtifactMap != null) {\n                // is the current artifact in the current parent\"s child list? if not add it (a given artifact may be under multiple parents, normal)\n                boolean foundMap = false;\n                for (Map candidate: (List<Map>) parentArtifactMap.get(\"childInfoList\")) if (key.equals(candidate.get(\"key\"))) { foundMap = true; break; }\n                if (!foundMap) ((List) parentArtifactMap.get(\"childInfoList\")).add(artifactMap);\n            }\n        }\n\n        if (childList != null) for (ArtifactExecutionInfoImpl aeii: childList) aeii.consolidateArtifactInfo(topLevelList, flatMap, artifactMap);\n    }\n    public static String printArtifactInfoList(List<Map> infoList) throws IOException {\n        StringWriter sw = new StringWriter();\n        printArtifactInfoList(sw, infoList, 0);\n        return sw.toString();\n    }\n    @SuppressWarnings(\"unchecked\")\n    public static void printArtifactInfoList(Writer writer, List<Map> infoList, int level) throws IOException {\n        // \"[${time}:${thisTime}:${childrenTime}][${count}] ${type} ${action} ${actionDetail} ${name}\"\n        for (Map info: infoList) {\n            for (int i = 0; i < level; i++) writer.append(\"|\").append(\" \");\n            writer.append(\"[\").append(StringUtilities.paddedString(((BigDecimal) info.get(\"time\")).toPlainString(), 8, false)).append(\":\");\n            writer.append(StringUtilities.paddedString(((BigDecimal) info.get(\"thisTime\")).toPlainString(), 6, false)).append(\":\");\n            writer.append(StringUtilities.paddedString(((BigDecimal) info.get(\"childrenTime\")).toPlainString(), 6, false)).append(\"]\");\n            writer.append(\"[\").append(StringUtilities.paddedString(((BigDecimal) info.get(\"count\")).toPlainString(), 4, false)).append(\"] \");\n            writer.append(StringUtilities.paddedString((String) info.get(\"type\"), 10, true)).append(\" \");\n            writer.append(StringUtilities.paddedString((String) info.get(\"action\"), 7, true)).append(\" \");\n            writer.append(StringUtilities.paddedString((String) info.get(\"actionDetail\"), 5, true)).append(\" \");\n            writer.append((String) info.get(\"name\")).append(\"\\n\");\n            // if we get past level 25 just give up, probably a loop in the tree\n            if (level < 25) {\n                printArtifactInfoList(writer, (List<Map>) info.get(\"childInfoList\"), level + 1);\n            } else {\n                for (int i = 0; i < level; i++) writer.append(\"|\").append(\" \");\n                writer.append(\"Reached depth limit, not printing children (may be a cycle in the 'tree')\\n\");\n            }\n        }\n    }\n\n    @Override public String toString() {\n        return \"[name:'\" + nameInternal + \"', type:'\" + internalTypeEnum + \"', action:'\" + internalActionEnum +\n                \"', required: \" + internalAuthzWasRequired + \", granted:\" + internalAuthzWasGranted +\n                \", user:'\" + internalAuthorizedUserId + \"', authz:'\" + internalAuthorizedAuthzType +\n                \"', authAction:'\" + internalAuthorizedActionEnum + \"', inheritable:\" + internalAuthorizationInheritable +\n                \", runningTime:\" + getRunningTime() + \"', txId:\" + moquiTxId + \"]\";\n    }\n    @Override public String toBasicString() {\n        StringBuilder builder = new StringBuilder().append(internalTypeEnum.toString()).append(':').append(nameInternal)\n                .append(\" (\").append(internalActionEnum.toString());\n        if (actionDetail != null && !actionDetail.isEmpty()) builder.append(':').append(actionDetail);\n        builder.append(\") \").append(System.currentTimeMillis() - startTimeMillis).append(\"ms\");\n        if (moquiTxId != null) builder.append(\" TX \").append(moquiTxId);\n        return builder.toString();\n    }\n\n\n    public static class ArtifactAuthzCheck {\n        public String userGroupId, artifactAuthzId, authzServiceName;\n        public String artifactGroupId, artifactName, filterMap;\n        public ArtifactType artifactType;\n        public AuthzAction authzAction;\n        public AuthzType authzType;\n        public boolean nameIsPattern, inheritAuthz;\n        public ArtifactAuthzCheck(EntityValueBase aacvEvb) {\n            Map<String, Object> aacvMap = aacvEvb.getValueMap();\n            userGroupId = (String) aacvMap.get(\"userGroupId\");\n            artifactAuthzId = (String) aacvMap.get(\"artifactAuthzId\");\n            authzServiceName = (String) aacvMap.get(\"authzServiceName\");\n\n            artifactGroupId = (String) aacvMap.get(\"artifactGroupId\");\n            artifactName = (String) aacvMap.get(\"artifactName\");\n            filterMap = (String) aacvMap.get(\"filterMap\");\n\n            String artifactTypeEnumId = (String) aacvMap.get(\"artifactTypeEnumId\");\n            artifactType = artifactTypeEnumId != null ? ArtifactType.valueOf(artifactTypeEnumId) : null;\n            String authzActionEnumId = (String) aacvMap.get(\"authzActionEnumId\");\n            authzAction = authzActionEnumId != null ? AuthzAction.valueOf(authzActionEnumId) : null;\n            String authzTypeEnumId = (String) aacvMap.get(\"authzTypeEnumId\");\n            authzType = authzTypeEnumId != null ? AuthzType.valueOf(authzTypeEnumId) : null;\n\n            nameIsPattern = \"Y\".equals(aacvMap.get(\"nameIsPattern\"));\n            inheritAuthz = \"Y\".equals(aacvMap.get(\"inheritAuthz\"));\n        }\n        @Override public String toString() {\n            return \"[userGroupId:\" + userGroupId + \", artifactAuthzId:\" + artifactAuthzId + \", artifactGroupId:\" + artifactGroupId + \", artifactName:\" + artifactName + \", artifactType:\" + artifactType + \", authzAction:\" + authzAction + \", authzType:\" + authzType + \", nameIsPattern:\" + nameIsPattern + \", inheritAuthz:\" + inheritAuthz + \"]\";\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/CacheFacadeImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context\n\nimport groovy.transform.CompileStatic\nimport org.moqui.jcache.MCache\nimport org.moqui.jcache.MCacheConfiguration\nimport org.moqui.jcache.MCacheManager\nimport org.moqui.impl.tools.MCacheToolFactory\nimport org.moqui.jcache.MEntry\nimport org.moqui.jcache.MStats\nimport org.moqui.util.CollectionUtilities\nimport org.moqui.util.MNode\nimport org.moqui.util.ObjectUtilities\n\nimport javax.cache.Cache\nimport javax.cache.CacheManager\nimport javax.cache.configuration.Configuration\nimport javax.cache.configuration.Factory\nimport javax.cache.configuration.MutableConfiguration\nimport javax.cache.expiry.AccessedExpiryPolicy\nimport javax.cache.expiry.CreatedExpiryPolicy\nimport javax.cache.expiry.Duration\nimport javax.cache.expiry.EternalExpiryPolicy\nimport javax.cache.expiry.ExpiryPolicy\nimport java.sql.Timestamp\nimport java.util.concurrent.ConcurrentHashMap\nimport java.util.concurrent.ConcurrentMap\n\nimport org.moqui.context.CacheFacade\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.util.concurrent.TimeUnit\n\n@CompileStatic\npublic class CacheFacadeImpl implements CacheFacade {\n    protected final static Logger logger = LoggerFactory.getLogger(CacheFacadeImpl.class)\n\n    protected final ExecutionContextFactoryImpl ecfi\n\n    protected CacheManager localCacheManagerInternal = (CacheManager) null\n    protected CacheManager distCacheManagerInternal = (CacheManager) null\n\n    final ConcurrentMap<String, Cache> localCacheMap = new ConcurrentHashMap<>()\n\n    CacheFacadeImpl(ExecutionContextFactoryImpl ecfi) {\n        this.ecfi = ecfi\n\n        MNode cacheListNode = ecfi.getConfXmlRoot().first(\"cache-list\")\n        String localCacheFactoryName = cacheListNode.attribute(\"local-factory\") ?: MCacheToolFactory.TOOL_NAME\n        localCacheManagerInternal = ecfi.getTool(localCacheFactoryName, CacheManager.class)\n    }\n\n    CacheManager getDistCacheManager() {\n        if (distCacheManagerInternal == null) {\n            MNode cacheListNode = ecfi.getConfXmlRoot().first(\"cache-list\")\n            String distCacheFactoryName = cacheListNode.attribute(\"distributed-factory\") ?: MCacheToolFactory.TOOL_NAME\n            distCacheManagerInternal = ecfi.getTool(distCacheFactoryName, CacheManager.class)\n        }\n        return distCacheManagerInternal\n    }\n\n    void destroy() {\n        if (localCacheManagerInternal != null) {\n            for (String cacheName in localCacheManagerInternal.getCacheNames())\n                localCacheManagerInternal.destroyCache(cacheName)\n        }\n        localCacheMap.clear()\n        if (distCacheManagerInternal != null) {\n            for (String cacheName in distCacheManagerInternal.getCacheNames())\n                distCacheManagerInternal.destroyCache(cacheName)\n        }\n    }\n\n    @Override\n    void clearAllCaches() { for (Cache cache in localCacheMap.values()) cache.clear() }\n\n    @Override\n    void clearCachesByPrefix(String prefix) {\n        for (Map.Entry<String, Cache> entry in localCacheMap.entrySet()) {\n            String tempName = entry.key\n            int separatorIndex = tempName.indexOf(\"__\")\n            if (separatorIndex > 0) tempName = tempName.substring(separatorIndex + 2)\n            if (!tempName.startsWith(prefix)) continue\n\n            entry.value.clear()\n        }\n    }\n\n    @Override\n    Cache getCache(String cacheName) { return getCacheInternal(cacheName, \"local\") }\n    @Override\n    <K, V> Cache<K, V> getCache(String cacheName, Class<K> keyType, Class<V> valueType) {\n        return getCacheInternal(cacheName, \"local\")\n    }\n\n    @Override\n    MCache getLocalCache(String cacheName) {\n        return getCacheInternal(cacheName, \"local\").unwrap(MCache.class)\n    }\n    @Override\n    Cache getDistributedCache(String cacheName) {\n        return getCacheInternal(cacheName, \"distributed\")\n    }\n\n    Cache getCacheInternal(String cacheName, String defaultCacheType) {\n        Cache theCache = localCacheMap.get(cacheName)\n        if (theCache == null) {\n            localCacheMap.putIfAbsent(cacheName, initCache(cacheName, defaultCacheType))\n            theCache = localCacheMap.get(cacheName)\n        }\n        return theCache\n    }\n\n    @Override\n    void registerCache(Cache cache) {\n        String cacheName = cache.getName()\n        localCacheMap.putIfAbsent(cacheName, cache)\n    }\n\n    @Override\n    boolean cacheExists(String cacheName) { return localCacheMap.containsKey(cacheName) }\n    @Override\n    Set<String> getCacheNames() { return localCacheMap.keySet() }\n\n    List<Map<String, Object>> getAllCachesInfo(String orderByField, String filterRegexp) {\n        boolean hasFilterRegexp = filterRegexp != null && filterRegexp.length() > 0\n        List<Map<String, Object>> ci = new LinkedList()\n        for (String cn in localCacheMap.keySet()) {\n            if (hasFilterRegexp && !cn.matches(\"(?i).*\" + filterRegexp + \".*\")) continue\n            Cache co = getCache(cn)\n            /* TODO: somehow support external cache stats like Hazelcast, through some sort of Moqui interface or maybe the JMX bean?\n               NOTE: this isn't all that important because we don't have a good use case for distributed caches\n            if (co instanceof ICache) {\n                ICache ico = co.unwrap(ICache.class)\n                CacheStatistics cs = ico.getLocalCacheStatistics()\n                CacheConfig conf = co.getConfiguration(CacheConfig.class)\n                EvictionConfig evConf = conf.getEvictionConfig()\n                ExpiryPolicy expPol = conf.getExpiryPolicyFactory()?.create()\n                Long expireIdle = expPol.expiryForAccess?.durationAmount ?: 0\n                Long expireLive = expPol.expiryForCreation?.durationAmount ?: 0\n                ci.add([name:co.getName(), expireTimeIdle:expireIdle,\n                        expireTimeLive:expireLive, maxElements:evConf.getSize(),\n                        evictionStrategy:evConf.getEvictionPolicy().name(), size:ico.size(),\n                        getCount:cs.getCacheGets(), putCount:cs.getCachePuts(),\n                        hitCount:cs.getCacheHits(), missCountTotal:cs.getCacheMisses(),\n                        evictionCount:cs.getCacheEvictions(), removeCount:cs.getCacheRemovals(),\n                        expireCount:0] as Map<String, Object>)\n            } else\n            */\n            if (co instanceof MCache) {\n                MCache mc = co.unwrap(MCache.class)\n                MStats stats = mc.getMStats()\n                Long expireIdle = mc.getAccessDuration()?.durationAmount ?: 0\n                Long expireLive = mc.getCreationDuration()?.durationAmount ?: 0\n                ci.add([name:co.getName(), expireTimeIdle:expireIdle,\n                        expireTimeLive:expireLive, maxElements:mc.getMaxEntries(),\n                        evictionStrategy:\"LRU\", size:mc.size(),\n                        getCount:stats.getCacheGets(), putCount:stats.getCachePuts(),\n                        hitCount:stats.getCacheHits(), missCountTotal:stats.getCacheMisses(),\n                        evictionCount:stats.getCacheEvictions(), removeCount:stats.getCacheRemovals(),\n                        expireCount:stats.getCacheExpires()] as Map<String, Object>)\n            } else {\n                logger.warn(\"Cannot get detailed info for cache ${cn} which is of type ${co.class.name}\")\n            }\n        }\n        if (orderByField) CollectionUtilities.orderMapList(ci, [orderByField])\n        return ci\n    }\n\n    protected MNode getCacheNode(String cacheName) {\n        MNode cacheListNode = ecfi.getConfXmlRoot().first(\"cache-list\")\n        MNode cacheElement = cacheListNode.first({ MNode it -> it.name == \"cache\" && it.attribute(\"name\") == cacheName })\n        // nothing found? try starts with, ie allow the cache configuration to be a prefix\n        if (cacheElement == null) cacheElement = cacheListNode\n                .first({ MNode it -> it.name == \"cache\" && cacheName.startsWith(it.attribute(\"name\")) })\n        return cacheElement\n    }\n\n    protected synchronized Cache initCache(String cacheName, String defaultCacheType) {\n        if (localCacheMap.containsKey(cacheName)) return localCacheMap.get(cacheName)\n\n        if (!defaultCacheType) defaultCacheType = \"local\"\n\n        Cache newCache\n        MNode cacheNode = getCacheNode(cacheName)\n        if (cacheNode != null) {\n            String keyTypeName = cacheNode.attribute(\"key-type\") ?: \"String\"\n            String valueTypeName = cacheNode.attribute(\"value-type\") ?: \"Object\"\n            Class keyType = ObjectUtilities.getClass(keyTypeName)\n            Class valueType = ObjectUtilities.getClass(valueTypeName)\n\n            Factory<ExpiryPolicy> expiryPolicyFactory\n            if (cacheNode.attribute(\"expire-time-idle\") && cacheNode.attribute(\"expire-time-idle\") != \"0\") {\n                expiryPolicyFactory = AccessedExpiryPolicy.factoryOf(\n                        new Duration(TimeUnit.SECONDS, Long.parseLong(cacheNode.attribute(\"expire-time-idle\"))))\n            } else if (cacheNode.attribute(\"expire-time-live\") && cacheNode.attribute(\"expire-time-live\") != \"0\") {\n                expiryPolicyFactory = CreatedExpiryPolicy.factoryOf(\n                        new Duration(TimeUnit.SECONDS, Long.parseLong(cacheNode.attribute(\"expire-time-live\"))))\n            } else {\n                expiryPolicyFactory = EternalExpiryPolicy.factoryOf()\n            }\n\n            String cacheType = cacheNode.attribute(\"type\") ?: defaultCacheType\n            CacheManager cacheManager\n            if (\"local\".equals(cacheType)) {\n                cacheManager = localCacheManagerInternal\n            } else if (\"distributed\".equals(cacheType)) {\n                cacheManager = getDistCacheManager()\n            } else {\n                throw new IllegalArgumentException(\"Cache type ${cacheType} not supported\")\n            }\n\n            Configuration config\n            if (cacheManager instanceof MCacheManager) {\n                // use MCache\n                MCacheConfiguration mConf = new MCacheConfiguration()\n                mConf.setTypes(keyType, valueType)\n                mConf.setStoreByValue(false).setStatisticsEnabled(true)\n                mConf.setExpiryPolicyFactory(expiryPolicyFactory)\n\n                String maxElementsStr = cacheNode.attribute(\"max-elements\")\n                if (maxElementsStr && maxElementsStr != \"0\") {\n                    int maxElements = Integer.parseInt(maxElementsStr)\n                    mConf.setMaxEntries(maxElements)\n                }\n\n                config = (Configuration) mConf\n            /* TODO: somehow support external cache configuration like Hazelcast, through some sort of Moqui interface, maybe pass cacheNode to Cache factory?\n               NOTE: this isn't all that important because we don't have a good use case for distributed caches, and they can be configured directly through hazelcast.xml or other Hazelcast conf\n            } else if (cacheManager instanceof AbstractHazelcastCacheManager) {\n                // use Hazelcast\n                CacheConfig cacheConfig = new CacheConfig()\n                cacheConfig.setTypes(keyType, valueType)\n                cacheConfig.setStoreByValue(true).setStatisticsEnabled(true)\n                cacheConfig.setExpiryPolicyFactory(expiryPolicyFactory)\n\n                // from here down the settings are specific to Hazelcast (not supported in javax.cache)\n                cacheConfig.setName(fullCacheName)\n                cacheConfig.setInMemoryFormat(InMemoryFormat.OBJECT)\n\n                String maxElementsStr = cacheNode.attribute(\"max-elements\")\n                if (maxElementsStr && maxElementsStr != \"0\") {\n                    int maxElements = Integer.parseInt(maxElementsStr)\n                    EvictionPolicy ep = cacheNode.attribute(\"eviction-strategy\") == \"least-recently-used\" ? EvictionPolicy.LRU : EvictionPolicy.LFU\n                    EvictionConfig evictionConfig = new EvictionConfig(maxElements, EvictionConfig.MaxSizePolicy.ENTRY_COUNT, ep)\n                    cacheConfig.setEvictionConfig(evictionConfig)\n                }\n\n                config = (Configuration) cacheConfig\n            */\n            } else {\n                logger.info(\"Initializing cache ${cacheName} which has a CacheManager of type ${cacheManager.class.name} and extended configuration not supported, using simple MutableConfigutation\")\n                MutableConfiguration mutConfig = new MutableConfiguration()\n                mutConfig.setTypes(keyType, valueType)\n                mutConfig.setStoreByValue(\"distributed\".equals(cacheType)).setStatisticsEnabled(true)\n                mutConfig.setExpiryPolicyFactory(expiryPolicyFactory)\n\n                config = (Configuration) mutConfig\n            }\n\n            newCache = cacheManager.createCache(cacheName, config)\n        } else {\n            CacheManager cacheManager\n            boolean storeByValue\n            if (\"local\".equals(defaultCacheType)) {\n                cacheManager = localCacheManagerInternal\n                storeByValue = false\n            } else if (\"distributed\".equals(defaultCacheType)) {\n                cacheManager = getDistCacheManager()\n                storeByValue = true\n            } else {\n                throw new IllegalArgumentException(\"Default cache type ${defaultCacheType} not supported\")\n            }\n\n            logger.info(\"Creating default ${defaultCacheType} cache ${cacheName}, storeByValue=${storeByValue}\")\n            MutableConfiguration mutConfig = new MutableConfiguration()\n            mutConfig.setStoreByValue(storeByValue).setStatisticsEnabled(true)\n            // any defaults we want here? better to use underlying defaults and conf file settings only\n            newCache = cacheManager.createCache(cacheName, mutConfig)\n        }\n\n        // NOTE: put in localCacheMap done in caller (getCache)\n        return newCache\n    }\n\n    List<Map> makeElementInfoList(String cacheName, String orderByField) {\n        Cache cache = getCache(cacheName)\n        if (cache instanceof MCache) {\n            MCache mCache = cache.unwrap(MCache.class)\n            List<Map> elementInfoList = new ArrayList<>();\n            for (Cache.Entry ce in mCache.getEntryList()) {\n                MEntry entry = ce.unwrap(MEntry.class)\n                Map<String, Object> im = new HashMap<String, Object>([key:entry.key as String,\n                        value:entry.value as String, hitCount:entry.getAccessCount(),\n                        creationTime:new Timestamp(entry.getCreatedTime())])\n                if (entry.getLastUpdatedTime()) im.lastUpdateTime = new Timestamp(entry.getLastUpdatedTime())\n                if (entry.getLastAccessTime()) im.lastAccessTime = new Timestamp(entry.getLastAccessTime())\n                elementInfoList.add(im)\n            }\n            if (orderByField) CollectionUtilities.orderMapList(elementInfoList, [orderByField])\n            return elementInfoList\n        } else {\n            return new ArrayList<Map>()\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/ContextJavaUtil.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context;\n\nimport com.fasterxml.jackson.annotation.JsonInclude;\nimport com.fasterxml.jackson.core.JsonGenerator;\nimport com.fasterxml.jackson.core.JsonProcessingException;\nimport com.fasterxml.jackson.databind.DeserializationFeature;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.fasterxml.jackson.databind.SerializationFeature;\nimport com.fasterxml.jackson.databind.SerializerProvider;\nimport com.fasterxml.jackson.databind.module.SimpleModule;\nimport com.fasterxml.jackson.databind.ser.std.StdSerializer;\nimport groovy.lang.GString;\nimport org.codehaus.groovy.runtime.StringGroovyMethods;\nimport org.jetbrains.annotations.NotNull;\nimport org.moqui.context.ArtifactExecutionInfo;\nimport org.moqui.entity.EntityFind;\nimport org.moqui.entity.EntityList;\nimport org.moqui.entity.EntityValue;\nimport org.moqui.impl.entity.EntityValueBase;\nimport org.moqui.impl.screen.ScreenRenderImpl;\nimport org.moqui.resource.ResourceReference;\nimport org.moqui.util.ContextStack;\nimport org.moqui.util.LiteStringMap;\nimport org.moqui.util.ObjectUtilities;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport jakarta.transaction.Synchronization;\nimport jakarta.transaction.Transaction;\nimport javax.transaction.xa.XAResource;\nimport java.io.IOException;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.sql.*;\nimport java.time.ZoneOffset;\nimport java.time.format.DateTimeFormatter;\nimport java.util.*;\nimport java.util.concurrent.*;\nimport java.util.concurrent.atomic.AtomicInteger;\nimport java.util.concurrent.atomic.AtomicLong;\n\npublic class ContextJavaUtil {\n    protected final static Logger logger = LoggerFactory.getLogger(ContextJavaUtil.class);\n    private static final long checkSlowThreshold = 50;\n    protected static final double userImpactMinMillis = 1000;\n\n    /** the Groovy JsonBuilder doesn't handle various Moqui objects very well, ends up trying to access all\n     * properties and results in infinite recursion, so need to unwrap and exclude some */\n    public static Map<String, Object> unwrapMap(Map<String, Object> sourceMap) {\n        Map<String, Object> targetMap = new HashMap<>();\n        for (Map.Entry<String, Object> entry: sourceMap.entrySet()) {\n            String key = entry.getKey();\n            Object value = entry.getValue();\n            if (value == null) continue;\n            // logger.warn(\"======== actionsResult - ${entry.key} (${entry.value?.getClass()?.getName()}): ${entry.value}\")\n            Object unwrapped = unwrap(key, value);\n            if (unwrapped != null) targetMap.put(key, unwrapped);\n        }\n        return targetMap;\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    public static Object unwrap(String key, Object value) {\n        if (value == null) return null;\n        if (value instanceof CharSequence || value instanceof Number || value instanceof java.util.Date) {\n            return value;\n        } else if (value instanceof EntityFind || value instanceof ExecutionContextImpl ||\n                value instanceof ScreenRenderImpl || value instanceof ContextStack) {\n            // intentionally skip, commonly left in context by entity-find XML action\n            return null;\n        } else if (value instanceof EntityValue) {\n            EntityValue ev = (EntityValue) value;\n            return ev.getPlainValueMap(0);\n        } else if (value instanceof EntityList) {\n            EntityList el = (EntityList) value;\n            ArrayList<Map> newList = new ArrayList<>();\n            int elSize = el.size();\n            for (int i = 0; i < elSize; i++) {\n                EntityValue ev = el.get(i);\n                newList.add(ev.getPlainValueMap(0));\n            }\n            return newList;\n        } else if (value instanceof Collection) {\n            Collection valCol = (Collection) value;\n            ArrayList<Object> newList = new ArrayList<>(valCol.size());\n            for (Object entry: valCol) newList.add(unwrap(key, entry));\n            return newList;\n        } else if (value instanceof Map) {\n            Map<Object, Object> valMap = (Map) value;\n            Map<Object, Object> newMap = new HashMap<>(valMap.size());\n            for (Map.Entry entry: valMap.entrySet()) newMap.put(entry.getKey(), unwrap(key, entry.getValue()));\n            return newMap;\n        } else {\n            logger.info(\"In screen actions skipping value from actions block that is not supported; key=\" + key + \", type=\" + value.getClass().getName() + \", value=\" + value);\n            return null;\n        }\n    }\n\n    public static class ArtifactStatsInfo {\n        private ArtifactExecutionInfo.ArtifactType artifactTypeEnum;\n        private String artifactSubType;\n        private String artifactName;\n        public ArtifactBinInfo curHitBin = null;\n        private long hitCount = 0L; // slowHitCount = 0L;\n        private double totalTimeMillis = 0, totalSquaredTime = 0;\n\n        ArtifactStatsInfo(ArtifactExecutionInfo.ArtifactType artifactTypeEnum, String artifactSubType, String artifactName) {\n            this.artifactTypeEnum = artifactTypeEnum;\n            this.artifactSubType = artifactSubType;\n            this.artifactName = artifactName;\n        }\n        double getAverage() { return hitCount > 0 ? totalTimeMillis / hitCount : 0; }\n        double getStdDev() {\n            if (hitCount < 2) return 0;\n            return Math.sqrt(Math.abs(totalSquaredTime - ((totalTimeMillis*totalTimeMillis) / hitCount)) / (hitCount - 1L));\n        }\n        public boolean countHit(long startTime, double runningTime) {\n            hitCount++;\n            boolean isSlow = isHitSlow(runningTime);\n            // if (isSlow) slowHitCount++;\n            // do something funny with these so we get a better avg and std dev, leave out the first result (count 2nd\n            //     twice) if first hit is more than 2x the second because the first hit is almost always MUCH slower\n            if (hitCount == 2L && totalTimeMillis > (runningTime * 3)) {\n                totalTimeMillis = runningTime * 2;\n                totalSquaredTime = runningTime * runningTime * 2;\n            } else {\n                totalTimeMillis += runningTime;\n                totalSquaredTime += runningTime * runningTime;\n            }\n\n            if (curHitBin == null) curHitBin = new ArtifactBinInfo(this, startTime);\n            curHitBin.countHit(runningTime, isSlow);\n\n            return isSlow;\n        }\n        boolean isHitSlow(double runningTime) {\n            if (hitCount < checkSlowThreshold) return false;\n            // calc new average and standard deviation\n            double average = hitCount > 0 ? totalTimeMillis / hitCount : 0;\n            double stdDev = Math.sqrt(Math.abs(totalSquaredTime - ((totalTimeMillis*totalTimeMillis) / hitCount)) / (hitCount - 1L));\n\n            // if runningTime is more than 2.6 std devs from the avg, count it and possibly log it\n            // using 2.6 standard deviations because 2 would give us around 5% of hits (normal distro), shooting for more like 1%\n            double slowTime = average + (stdDev * 2.6);\n            if (slowTime != 0 && runningTime > slowTime) {\n                if (runningTime > userImpactMinMillis) logger.warn(\"Slow hit to \" + artifactTypeEnum + \":\" + artifactSubType +\n                        \":\" + artifactName + \" running time \" + runningTime + \" is greater than average \" + average +\n                        \" plus 2.6 standard deviations \" + stdDev);\n                return true;\n            } else {\n                return false;\n            }\n        }\n    }\n\n    public static class ArtifactBinInfo {\n        private final ArtifactStatsInfo statsInfo;\n        public final long startTime;\n\n        private long hitCount = 0L, slowHitCount = 0L;\n        private double totalTimeMillis = 0, totalSquaredTime = 0, minTimeMillis = Long.MAX_VALUE, maxTimeMillis = 0;\n\n        ArtifactBinInfo(ArtifactStatsInfo statsInfo, long startTime) {\n            this.statsInfo = statsInfo;\n            this.startTime = startTime;\n        }\n\n        void countHit(double runningTime, boolean isSlow) {\n            hitCount++;\n            if (isSlow) slowHitCount++;\n\n            if (hitCount == 2L && totalTimeMillis > (runningTime * 3)) {\n                totalTimeMillis = runningTime * 2;\n                totalSquaredTime = runningTime * runningTime * 2;\n            } else {\n                totalTimeMillis += runningTime;\n                totalSquaredTime += runningTime * runningTime;\n            }\n\n            if (runningTime < minTimeMillis) minTimeMillis = runningTime;\n            if (runningTime > maxTimeMillis) maxTimeMillis = runningTime;\n        }\n\n        EntityValue makeAhbValue(ExecutionContextFactoryImpl ecfi, Timestamp binEndDateTime) {\n            EntityValueBase ahb = (EntityValueBase) ecfi.entityFacade.makeValue(\"moqui.server.ArtifactHitBin\");\n            ahb.put(\"artifactType\", statsInfo.artifactTypeEnum.name());\n            ahb.put(\"artifactSubType\", statsInfo.artifactSubType);\n            ahb.put(\"artifactName\", statsInfo.artifactName);\n            ahb.put(\"binStartDateTime\", new Timestamp(startTime));\n            ahb.put(\"binEndDateTime\", binEndDateTime);\n            ahb.put(\"hitCount\", hitCount);\n            // NOTE: use 6 digit precision for nanos in millisecond unit\n            ahb.put(\"totalTimeMillis\", new BigDecimal(totalTimeMillis).setScale(6, RoundingMode.HALF_UP));\n            ahb.put(\"totalSquaredTime\", new BigDecimal(totalSquaredTime).setScale(6, RoundingMode.HALF_UP));\n            ahb.put(\"minTimeMillis\", new BigDecimal(minTimeMillis).setScale(6, RoundingMode.HALF_UP));\n            ahb.put(\"maxTimeMillis\", new BigDecimal(maxTimeMillis).setScale(6, RoundingMode.HALF_UP));\n            ahb.put(\"slowHitCount\", slowHitCount);\n            ahb.put(\"serverIpAddress\", ecfi.localhostAddress != null ? ecfi.localhostAddress.getHostAddress() : \"127.0.0.1\");\n            ahb.put(\"serverHostName\", ecfi.localhostAddress != null ? ecfi.localhostAddress.getHostName() : \"localhost\");\n            return ahb;\n\n        }\n    }\n\n    public static class ArtifactHitInfo {\n        String visitId, userId;\n        boolean isSlowHit;\n        ArtifactExecutionInfo.ArtifactType artifactTypeEnum;\n        String artifactSubType, artifactName;\n        long startTime;\n        double runningTimeMillis;\n        Map<String, Object> parameters;\n        Long outputSize;\n        String errorMessage = null;\n        String requestUrl = null, referrerUrl = null;\n\n        ArtifactHitInfo(ExecutionContextImpl eci, boolean isSlowHit, ArtifactExecutionInfo.ArtifactType artifactTypeEnum,\n                        String artifactSubType, String artifactName, long startTime, double runningTimeMillis,\n                        Map<String, Object> parameters, Long outputSize) {\n            visitId = eci.userFacade.getVisitId();\n            userId = eci.userFacade.getUserId();\n            this.isSlowHit = isSlowHit;\n            this.artifactTypeEnum = artifactTypeEnum;\n            this.artifactSubType = artifactSubType;\n            this.artifactName = artifactName;\n            this.startTime = startTime;\n            this.runningTimeMillis = runningTimeMillis;\n            this.parameters = parameters;\n            this.outputSize = outputSize;\n            if (eci.getMessage().hasError()) {\n                StringBuilder errorMessage = new StringBuilder();\n                for (String curErr: eci.getMessage().getErrors()) errorMessage.append(curErr).append(\";\");\n                if (errorMessage.length() > 255) errorMessage.delete(255, errorMessage.length());\n                this.errorMessage = errorMessage.toString();\n            }\n            WebFacadeImpl wfi = eci.getWebImpl();\n            if (wfi != null) {\n                String fullUrl = wfi.getRequestUrl();\n                requestUrl = (fullUrl.length() > 255) ? fullUrl.substring(0, 255) : fullUrl;\n                referrerUrl = wfi.getRequest().getHeader(\"Referer\");\n            }\n        }\n        EntityValue makeAhiValue(ExecutionContextFactoryImpl ecfi) {\n            EntityValueBase ahp = (EntityValueBase) ecfi.entityFacade.makeValue(\"moqui.server.ArtifactHit\");\n            ahp.put(\"visitId\", visitId);\n            ahp.put(\"userId\", userId);\n            ahp.put(\"isSlowHit\", isSlowHit ? 'Y' : 'N');\n            ahp.put(\"artifactType\", artifactTypeEnum.name());\n            ahp.put(\"artifactSubType\", artifactSubType);\n            ahp.put(\"artifactName\", artifactName);\n            ahp.put(\"startDateTime\", new Timestamp(startTime));\n            ahp.put(\"runningTimeMillis\", new BigDecimal(runningTimeMillis).setScale(6, RoundingMode.HALF_UP));\n\n            if (parameters != null && parameters.size() > 0) {\n                StringBuilder ps = new StringBuilder();\n                for (Map.Entry<String, Object> pme: parameters.entrySet()) {\n                    Object value = pme.getValue();\n                    if (value == null || ObjectUtilities.isEmpty(value) || value instanceof Map || value instanceof Collection) continue;\n                    String key = pme.getKey();\n                    if (key != null && key.contains(\"password\")) continue;\n                    if (ps.length() > 0) ps.append(\", \");\n                    String valString = value.toString();\n                    if (valString.length() > 80) valString = valString.substring(0, 80);\n                    ps.append(key).append(\"=\").append(valString);\n                }\n                // is text-long, could be up to 4000, probably don't want that much for data size\n                if (ps.length() > 1000) ps.delete(1000, ps.length());\n                ahp.put(\"parameterString\", ps.toString());\n            }\n            if (outputSize != null) ahp.put(\"outputSize\", outputSize);\n            if (errorMessage != null) {\n                ahp.put(\"wasError\", \"Y\");\n                ahp.put(\"errorMessage\", errorMessage);\n            } else {\n                ahp.put(\"wasError\", \"N\");\n            }\n            if (requestUrl != null && requestUrl.length() > 0) ahp.put(\"requestUrl\", requestUrl);\n            if (referrerUrl != null && referrerUrl.length() > 0) ahp.put(\"referrerUrl\", referrerUrl);\n\n            ahp.put(\"serverIpAddress\", ecfi.localhostAddress != null ? ecfi.localhostAddress.getHostAddress() : \"127.0.0.1\");\n            ahp.put(\"serverHostName\", ecfi.localhostAddress != null ? ecfi.localhostAddress.getHostName() : \"localhost\");\n\n            return ahp;\n        }\n    }\n\n    static class RollbackInfo {\n        public String causeMessage;\n        /** A rollback is often done because of another error, this represents that error. */\n        public Throwable causeThrowable;\n        /** This is for a stack trace for where the rollback was actually called to help track it down more easily. */\n        public Exception rollbackLocation;\n\n        public RollbackInfo(String causeMessage, Throwable causeThrowable, Exception rollbackLocation) {\n            this.causeMessage = causeMessage;\n            this.causeThrowable = causeThrowable;\n            this.rollbackLocation = rollbackLocation;\n        }\n    }\n\n    static final AtomicLong moquiTxIdLast = new AtomicLong(0L);\n    static class TxStackInfo {\n        private TransactionFacadeImpl transactionFacade;\n        public final long moquiTxId = moquiTxIdLast.incrementAndGet();\n        public Exception transactionBegin = null;\n        public Long transactionBeginStartTime = null;\n        public int transactionTimeout = 60;\n        public RollbackInfo rollbackOnlyInfo = null;\n\n        public Transaction suspendedTx = null;\n        public Exception suspendedTxLocation = null;\n\n        Map<String, XAResource> activeXaResourceMap = new HashMap<>();\n        Map<String, Synchronization> activeSynchronizationMap = new HashMap<>();\n        Map<String, ConnectionWrapper> txConByGroup = new HashMap<>();\n        public TransactionCache txCache = null;\n        ArrayList<EntityRecordLock> recordLockList = new ArrayList<>();\n\n        public Map<String, XAResource> getActiveXaResourceMap() { return activeXaResourceMap; }\n        public Map<String, Synchronization> getActiveSynchronizationMap() { return activeSynchronizationMap; }\n        public Map<String, ConnectionWrapper> getTxConByGroup() { return txConByGroup; }\n\n        public TxStackInfo(TransactionFacadeImpl tfi) { transactionFacade = tfi; }\n\n        public void clearCurrent() {\n            rollbackOnlyInfo = null;\n            transactionBegin = null;\n            transactionBeginStartTime = null;\n            transactionTimeout = 60;\n            activeXaResourceMap.clear();\n            activeSynchronizationMap.clear();\n            txCache = null;\n            // this should already be done, but make sure\n            closeTxConnections();\n\n            // lock track: remove all EntityRecordLock in recordLockList from TransactionFacadeImpl.recordLockByEntityPk\n            int recordLockListSize = recordLockList.size();\n            // if (recordLockListSize > 0) logger.warn(\"TOREMOVE TxStackInfo EntityRecordLock clearing \" + recordLockListSize + \" locks\");\n            for (int i = 0; i < recordLockListSize; i++) {\n                EntityRecordLock erl = recordLockList.get(i);\n                erl.clear(transactionFacade.recordLockByEntityPk);\n            }\n            recordLockList.clear();\n        }\n\n        public void closeTxConnections() {\n            for (ConnectionWrapper con: txConByGroup.values()) {\n                try {\n                    if (con != null && !con.isClosed()) con.closeInternal();\n                } catch (Throwable t) {\n                    logger.error(\"Error closing connection for group \" + con.getGroupName(), t);\n                }\n            }\n            txConByGroup.clear();\n        }\n    }\n    public static class EntityRecordLock {\n        // TODO enum for operation? create, update, delete, find-for-update\n        String entityName, pkString, entityPlusPk, threadName;\n        String mutateEntityName, mutatePkString;\n        ArrayList<ArtifactExecutionInfo> artifactStack;\n        long lockTime = -1, txBeginTime = -1, moquiTxId = -1;\n        public EntityRecordLock(String entityName, String pkString, ArrayList<ArtifactExecutionInfo> artifactStack) {\n            this.entityName = entityName;\n            this.pkString = pkString;\n            // NOTE: used primary as a key, for efficiency don't use separator between entityName and pkString\n            entityPlusPk = entityName.concat(pkString);\n            threadName = Thread.currentThread().getName();\n            this.artifactStack = artifactStack;\n            lockTime = System.currentTimeMillis();\n        }\n\n        EntityRecordLock mutator(String mutateEntityName, String mutatePkString) {\n            this.mutateEntityName = mutateEntityName;\n            this.mutatePkString = mutatePkString;\n            return this;\n        }\n\n        void register(ConcurrentHashMap<String, ArrayList<EntityRecordLock>> recordLockByEntityPk, TxStackInfo txStackInfo) {\n            if (txStackInfo != null) {\n                moquiTxId = txStackInfo.moquiTxId;\n                txBeginTime = txStackInfo.transactionBeginStartTime != null ? txStackInfo.transactionBeginStartTime : -1;\n            }\n\n            ArrayList<EntityRecordLock> curErlList = recordLockByEntityPk.computeIfAbsent(entityPlusPk, k -> new ArrayList<>());\n            synchronized (curErlList) {\n                // is this another lock in the same transaction?\n                if (curErlList.size() > 0) {\n                    for (int i = 0; i < curErlList.size(); i++) {\n                        EntityRecordLock otherErl = curErlList.get(i);\n                        if (otherErl.moquiTxId == moquiTxId) {\n                            // found a match, just return and do nothing\n                            return;\n                        }\n                    }\n                }\n\n                // check for existing locks in this.recordLockByEntityPk, log warning if others found\n                if (curErlList.size() > 0) {\n                    StringBuilder msgBuilder = new StringBuilder().append(\"Potential lock conflict entity \").append(entityName)\n                            .append(\" pk \").append(pkString).append(\" thread \").append(threadName)\n                            .append(\" TX \").append(moquiTxId).append(\" began \").append(new Timestamp(txBeginTime));\n                    if (mutateEntityName != null) msgBuilder.append(\" from mutate of entity \").append(mutateEntityName).append(\" pk \").append(mutatePkString);\n                    msgBuilder.append(\" at: \");\n                    if (artifactStack != null) for (int mi = 0; mi < artifactStack.size(); mi++) {\n                        msgBuilder.append(\"\\n\").append(StringGroovyMethods.padLeft((CharSequence) Integer.toString(mi), 2, \"0\"))\n                                .append(\": \").append(artifactStack.get(mi).toBasicString());\n                    }\n                    for (int i = 0; i < curErlList.size(); i++) {\n                        EntityRecordLock otherErl = curErlList.get(i);\n                        msgBuilder.append(\"\\n== OTHER LOCK \").append(i).append(\" thread \").append(otherErl.threadName)\n                                .append(\" TX \").append(otherErl.moquiTxId).append(\" began \").append(new Timestamp(otherErl.txBeginTime)).append(\" at: \");\n                        if (otherErl.artifactStack != null) for (int mi = 0; mi < otherErl.artifactStack.size(); mi++) {\n                            msgBuilder.append(\"\\n\").append(StringGroovyMethods.padLeft((CharSequence) Integer.toString(mi), 2, \"0\"))\n                                    .append(\": \").append(otherErl.artifactStack.get(mi).toBasicString());\n                        }\n                    }\n                    logger.warn(msgBuilder.toString());\n                }\n\n                // add new lock to this.recordLockByEntityPk, and TxStackInfo.recordLockList\n                if (txStackInfo != null) {\n                    curErlList.add(this);\n                    txStackInfo.recordLockList.add(this);\n                } else {\n                    logger.warn(\"In EntityRecordLock register no TxStackInfo so not registering lock because won't be able to clear for entity \" + entityName + \" pk \" + pkString + \" thread \" + threadName);\n                }\n            }\n        }\n        void clear(ConcurrentHashMap<String, ArrayList<EntityRecordLock>> recordLockByEntityPk) {\n            ArrayList<EntityRecordLock> curErlList = recordLockByEntityPk.get(entityPlusPk);\n            if (curErlList == null) {\n                logger.warn(\"In EntityRecordLock clear no locks found for \" + entityPlusPk);\n                return;\n            }\n            synchronized (curErlList) {\n                boolean haveRemoved = false;\n                for (int i = 0; i < curErlList.size(); i++) {\n                    EntityRecordLock otherErl = curErlList.get(i);\n                    if (moquiTxId == otherErl.moquiTxId) {\n                        curErlList.remove(i);\n                        haveRemoved = true;\n                    }\n                }\n                if (!haveRemoved) logger.warn(\"In EntityRecordLock clear no locks found for \" + entityPlusPk);\n            }\n        }\n    }\n\n    /** A simple delegating wrapper for java.sql.Connection.\n     *\n     * The close() method does nothing, only closed when closeInternal() called by TransactionFacade on commit,\n     * rollback, or destroy (when transactions are also cleaned up as a last resort).\n     *\n     * Connections are attached to 2 things: entity group and transaction.\n     */\n    public static class ConnectionWrapper implements Connection {\n        protected Connection con;\n        TransactionFacadeImpl tfi;\n        String groupName;\n\n        public ConnectionWrapper(Connection con, TransactionFacadeImpl tfi, String groupName) {\n            this.con = con;\n            this.tfi = tfi;\n            this.groupName = groupName;\n        }\n\n        public String getGroupName() { return groupName; }\n\n        public void closeInternal() throws SQLException {\n            con.close();\n        }\n\n        @Override public Statement createStatement() throws SQLException { return con.createStatement(); }\n        @Override public PreparedStatement prepareStatement(String sql) throws SQLException { return con.prepareStatement(sql); }\n        @Override public CallableStatement prepareCall(String sql) throws SQLException { return con.prepareCall(sql); }\n        @Override public String nativeSQL(String sql) throws SQLException { return con.nativeSQL(sql); }\n        @Override public void setAutoCommit(boolean autoCommit) throws SQLException { con.setAutoCommit(autoCommit); }\n        @Override public boolean getAutoCommit() throws SQLException { return con.getAutoCommit(); }\n        @Override public void commit() throws SQLException { con.commit(); }\n        @Override public void rollback() throws SQLException { con.rollback(); }\n\n        @Override\n        public void close() throws SQLException {\n            // do nothing! see closeInternal\n        }\n\n        @Override public boolean isClosed() throws SQLException { return con.isClosed(); }\n        @Override public DatabaseMetaData getMetaData() throws SQLException { return con.getMetaData(); }\n        @Override public void setReadOnly(boolean readOnly) throws SQLException { con.setReadOnly(readOnly); }\n        @Override public boolean isReadOnly() throws SQLException { return con.isReadOnly(); }\n        @Override public void setCatalog(String catalog) throws SQLException { con.setCatalog(catalog); }\n        @Override public String getCatalog() throws SQLException { return con.getCatalog(); }\n        @Override public void setTransactionIsolation(int level) throws SQLException { con.setTransactionIsolation(level); }\n        @Override public int getTransactionIsolation() throws SQLException { return con.getTransactionIsolation(); }\n        @Override public SQLWarning getWarnings() throws SQLException { return con.getWarnings(); }\n        @Override public void clearWarnings() throws SQLException { con.clearWarnings(); }\n\n        @Override public Statement createStatement(int resultSetType, int resultSetConcurrency) throws SQLException {\n            return con.createStatement(resultSetType, resultSetConcurrency); }\n        @Override public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency) throws SQLException {\n            return con.prepareStatement(sql, resultSetType, resultSetConcurrency); }\n        @Override public CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency) throws SQLException {\n            return con.prepareCall(sql, resultSetType, resultSetConcurrency); }\n\n        @Override public Map<String, Class<?>> getTypeMap() throws SQLException { return con.getTypeMap(); }\n        @Override public void setTypeMap(Map<String, Class<?>> map) throws SQLException { con.setTypeMap(map); }\n        @Override public void setHoldability(int holdability) throws SQLException { con.setHoldability(holdability); }\n        @Override public int getHoldability() throws SQLException { return con.getHoldability(); }\n        @Override public Savepoint setSavepoint() throws SQLException { return con.setSavepoint(); }\n        @Override public Savepoint setSavepoint(String name) throws SQLException { return con.setSavepoint(name); }\n        @Override public void rollback(Savepoint savepoint) throws SQLException { con.rollback(savepoint); }\n        @Override public void releaseSavepoint(Savepoint savepoint) throws SQLException { con.releaseSavepoint(savepoint); }\n\n        @Override public Statement createStatement(int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException {\n            return con.createStatement(resultSetType, resultSetConcurrency, resultSetHoldability); }\n        @Override public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException {\n            return con.prepareStatement(sql, resultSetType, resultSetConcurrency, resultSetHoldability); }\n        @Override public CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException {\n            return con.prepareCall(sql, resultSetType, resultSetConcurrency, resultSetHoldability); }\n        @Override public PreparedStatement prepareStatement(String sql, int autoGeneratedKeys) throws SQLException {\n            return con.prepareStatement(sql, autoGeneratedKeys); }\n        @Override public PreparedStatement prepareStatement(String sql, int[] columnIndexes) throws SQLException {\n            return con.prepareStatement(sql, columnIndexes); }\n        @Override public PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException {\n            return con.prepareStatement(sql, columnNames); }\n\n        @Override public Clob createClob() throws SQLException { return con.createClob(); }\n        @Override public Blob createBlob() throws SQLException { return con.createBlob(); }\n        @Override public NClob createNClob() throws SQLException { return con.createNClob(); }\n        @Override public SQLXML createSQLXML() throws SQLException { return con.createSQLXML(); }\n        @Override public boolean isValid(int timeout) throws SQLException { return con.isValid(timeout); }\n        @Override public void setClientInfo(String name, String value) throws SQLClientInfoException { con.setClientInfo(name, value); }\n        @Override public void setClientInfo(Properties properties) throws SQLClientInfoException { con.setClientInfo(properties); }\n        @Override public String getClientInfo(String name) throws SQLException { return con.getClientInfo(name); }\n        @Override public Properties getClientInfo() throws SQLException { return con.getClientInfo(); }\n        @Override public Array createArrayOf(String typeName, Object[] elements) throws SQLException {\n            return con.createArrayOf(typeName, elements); }\n        @Override public Struct createStruct(String typeName, Object[] attributes) throws SQLException {\n            return con.createStruct(typeName, attributes); }\n\n        @Override public void setSchema(String schema) throws SQLException { con.setSchema(schema); }\n        @Override public String getSchema() throws SQLException { return con.getSchema(); }\n\n        @Override public void abort(Executor executor) throws SQLException { con.abort(executor); }\n        @Override public void setNetworkTimeout(Executor executor, int milliseconds) throws SQLException {\n            con.setNetworkTimeout(executor, milliseconds); }\n        @Override public int getNetworkTimeout() throws SQLException { return con.getNetworkTimeout(); }\n\n        @Override public <T> T unwrap(Class<T> iface) throws SQLException { return con.unwrap(iface); }\n        @Override public boolean isWrapperFor(Class<?> iface) throws SQLException { return con.isWrapperFor(iface); }\n\n        // Object overrides\n        @Override public int hashCode() { return con.hashCode(); }\n        @Override public boolean equals(Object obj) { return obj instanceof Connection && con.equals(obj); }\n        @Override public String toString() {\n            return \"Group: \" + groupName + \", Con: \" + con.toString();\n        }\n        /* these don't work, don't think we need them anyway:\n        protected Object clone() throws CloneNotSupportedException {\n            return new ConnectionWrapper((Connection) con.clone(), tfi, groupName) }\n        protected void finalize() throws Throwable { con.finalize() }\n        */\n    }\n\n\n    public final static ObjectMapper jacksonMapper = new ObjectMapper()\n            .setDefaultPropertyInclusion(JsonInclude.Value.construct(\n                        JsonInclude.Include.ALWAYS,\n                        JsonInclude.Include.ALWAYS))\n            .enable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS).enable(SerializationFeature.INDENT_OUTPUT)\n            .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)\n            .configure(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN, true);\n    static {\n        // Jackson custom serializers, etc\n        SimpleModule module = new SimpleModule();\n        module.addSerializer(GString.class, new ContextJavaUtil.GStringJsonSerializer());\n        module.addSerializer(LiteStringMap.class, new ContextJavaUtil.LiteStringMapJsonSerializer());\n        module.addSerializer(ResourceReference.class, new ContextJavaUtil.ResourceReferenceJsonSerializer());\n        jacksonMapper.registerModule(module);\n    }\n    static class GStringJsonSerializer extends StdSerializer<GString> {\n        GStringJsonSerializer() { super(GString.class); }\n        @Override public void serialize(GString value, JsonGenerator gen, SerializerProvider serializers)\n                throws IOException, JsonProcessingException { if (value != null) gen.writeString(value.toString()); }\n    }\n    static class TimestampNoNegativeJsonSerializer extends StdSerializer<Timestamp> {\n        TimestampNoNegativeJsonSerializer() { super(Timestamp.class); }\n        @Override public void serialize(Timestamp value, JsonGenerator gen, SerializerProvider serializers)\n                throws IOException, JsonProcessingException {\n            if (value != null) {\n                long time = value.getTime();\n                if (time < 0) {\n                    String isoUtc = value.toInstant().atZone(ZoneOffset.UTC.normalized()).format(DateTimeFormatter.ISO_INSTANT);\n                    gen.writeString(isoUtc);\n                    // logger.warn(\"Negative Timestamp \" + time + \": \" + isoUtc);\n                } else {\n                    gen.writeNumber(time);\n                }\n            }\n        }\n    }\n    static class LiteStringMapJsonSerializer extends StdSerializer<LiteStringMap> {\n        LiteStringMapJsonSerializer() { super(LiteStringMap.class); }\n        @Override public void serialize(LiteStringMap lsm, JsonGenerator gen, SerializerProvider serializers)\n                throws IOException, JsonProcessingException {\n            gen.writeStartObject();\n            if (lsm != null) {\n                int size = lsm.size();\n                for (int i = 0; i < size; i++) {\n                    String key = lsm.getKey(i);\n                    Object value = lsm.getValue(i);\n                    // sparse maps could have null keys at certain indexes\n                    if (key == null) continue;\n                    gen.writeObjectField(key, value);\n                }\n            }\n            gen.writeEndObject();\n        }\n    }\n    static class ResourceReferenceJsonSerializer extends StdSerializer<ResourceReference> {\n        ResourceReferenceJsonSerializer() { super(ResourceReference.class); }\n        @Override public void serialize(ResourceReference resourceRef, JsonGenerator gen, SerializerProvider serializers)\n                throws IOException, JsonProcessingException {\n            if (resourceRef == null) {\n                gen.writeNull();\n                return;\n            }\n            gen.writeStartObject();\n            gen.writeObjectField(\"location\", resourceRef.getLocation());\n            gen.writeObjectField(\"isDirectory\", resourceRef.isDirectory());\n            gen.writeObjectField(\"lastModified\", resourceRef.getLastModified());\n            ResourceReference.Version currentVersion = resourceRef.getCurrentVersion();\n            if (currentVersion != null) gen.writeObjectField(\"currentVersionName\", currentVersion.getVersionName());\n            gen.writeEndObject();\n        }\n    }\n\n    // NOTE: using unbound LinkedBlockingQueue, so max pool size in ThreadPoolExecutor has no effect\n    public static class WorkerThreadFactory implements ThreadFactory {\n        private final ThreadGroup workerGroup = new ThreadGroup(\"MoquiWorkers\");\n        private final AtomicInteger threadNumber = new AtomicInteger(1);\n        public Thread newThread(Runnable r) { return new Thread(workerGroup, r, \"MoquiWorker-\" + threadNumber.getAndIncrement()); }\n    }\n    public static class JobThreadFactory implements ThreadFactory {\n        private final ThreadGroup workerGroup = new ThreadGroup(\"MoquiJobs\");\n        private final AtomicInteger threadNumber = new AtomicInteger(1);\n        public Thread newThread(Runnable r) { return new Thread(workerGroup, r, \"MoquiJob-\" + threadNumber.getAndIncrement()); }\n    }\n    public static class WorkerThreadPoolExecutor extends ThreadPoolExecutor {\n        private ExecutionContextFactoryImpl ecfi;\n        public WorkerThreadPoolExecutor(ExecutionContextFactoryImpl ecfi, int coreSize, int maxSize, long aliveTime,\n                                        TimeUnit timeUnit, BlockingQueue<Runnable> blockingQueue, ThreadFactory threadFactory) {\n            super(coreSize, maxSize, aliveTime, timeUnit, blockingQueue, threadFactory);\n            this.ecfi = ecfi;\n        }\n\n        @Override protected void afterExecute(Runnable runnable, Throwable throwable) {\n            ExecutionContextImpl activeEc = ecfi.activeContext.get();\n            if (activeEc != null) {\n                logger.warn(\"In WorkerThreadPoolExecutor.afterExecute() there is still an ExecutionContext for runnable \" + runnable.getClass().getName() + \" in thread (\" + Thread.currentThread().threadId() + \":\" + Thread.currentThread().getName() + \"), destroying\");\n                try {\n                    activeEc.destroy();\n                } catch (Throwable t) {\n                    logger.error(\"Error destroying ExecutionContext in WorkerThreadPoolExecutor.afterExecute()\", t);\n                }\n            } else {\n                if (ecfi.transactionFacade.isTransactionInPlace()) {\n                    logger.error(\"In WorkerThreadPoolExecutor a transaction is in place for thread \" + Thread.currentThread().getName() + \", trying to commit\");\n                    try {\n                        ecfi.transactionFacade.destroyAllInThread();\n                    } catch (Exception e) {\n                        logger.error(\"WorkerThreadPoolExecutor commit in place transaction failed in thread \" + Thread.currentThread().getName(), e);\n                    }\n                }\n            }\n\n            super.afterExecute(runnable, throwable);\n        }\n    }\n\n    static class ScheduledThreadFactory implements ThreadFactory {\n        private final ThreadGroup workerGroup = new ThreadGroup(\"MoquiScheduled\");\n        private final AtomicInteger threadNumber = new AtomicInteger(1);\n        public Thread newThread(Runnable r) { return new Thread(workerGroup, r, \"MoquiScheduled-\" + threadNumber.getAndIncrement()); }\n    }\n    public static class CustomScheduledTask<V> implements RunnableScheduledFuture<V> {\n        public final Runnable runnable;\n        public final Callable<V> callable;\n        public final RunnableScheduledFuture<V> future;\n\n        public CustomScheduledTask(Runnable runnable, RunnableScheduledFuture<V> future) {\n            this.runnable = runnable;\n            this.callable = null;\n            this.future = future;\n        }\n        public CustomScheduledTask(Callable<V> callable, RunnableScheduledFuture<V> future) {\n            this.runnable = null;\n            this.callable = callable;\n            this.future = future;\n        }\n\n        @Override public boolean isPeriodic() { return future.isPeriodic(); }\n        @Override public long getDelay(@NotNull TimeUnit timeUnit) { return future.getDelay(timeUnit); }\n        @Override public int compareTo(@NotNull Delayed delayed) { return future.compareTo(delayed); }\n\n        @Override public void run() {\n            try {\n                // logger.info(\"Running scheduled task \" + toString());\n                future.run();\n            } catch (Throwable t) {\n                logger.error(\"CustomScheduledTask uncaught Throwable in run(), catching and suppressing so task does not get unscheduled\", t);\n            }\n        }\n        @Override public boolean cancel(boolean b) { return future.cancel(b); }\n        @Override public boolean isCancelled() { return future.isCancelled(); }\n        @Override public boolean isDone() { return future.isDone(); }\n\n        @Override public V get() throws InterruptedException, ExecutionException { return future.get(); }\n        @Override public V get(long l, @NotNull TimeUnit timeUnit) throws InterruptedException, ExecutionException, TimeoutException {\n            return future.get(l, timeUnit);\n        }\n\n        @Override public String toString() {\n            return \"CustomScheduledTask \" + (runnable != null ? runnable.getClass().getName() : (callable != null ? callable.getClass().getName() : \"[no Runnable or Callable!]\"));\n        }\n    }\n    public static class CustomScheduledExecutor extends ScheduledThreadPoolExecutor {\n        public CustomScheduledExecutor(int coreThreads) {\n            super(coreThreads, new ScheduledThreadFactory());\n        }\n        public CustomScheduledExecutor(int coreThreads, ThreadFactory threadFactory) {\n            super(coreThreads, threadFactory);\n        }\n        protected <V> RunnableScheduledFuture<V> decorateTask(Runnable r, RunnableScheduledFuture<V> task) {\n            return new CustomScheduledTask<V>(r, task);\n        }\n        protected <V> RunnableScheduledFuture<V> decorateTask(Callable<V> c, RunnableScheduledFuture<V> task) {\n            return new CustomScheduledTask<V>(c, task);\n        }\n    }\n    static class ScheduledRunnableInfo {\n        public final Runnable command;\n        public final long period;\n        // NOTE: tracking initial ScheduledFuture is useless as it gets replaced with each run: public final ScheduledFuture scheduledFuture;\n        ScheduledRunnableInfo(Runnable command, long period) {\n            this.command = command; this.period = period;\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/ElasticFacadeImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context\n\nimport com.fasterxml.jackson.annotation.JsonInclude\nimport com.fasterxml.jackson.core.JsonGenerator\nimport com.fasterxml.jackson.databind.DeserializationFeature\nimport com.fasterxml.jackson.databind.JsonNode\nimport com.fasterxml.jackson.databind.ObjectMapper\nimport com.fasterxml.jackson.databind.module.SimpleModule\nimport groovy.json.JsonOutput\nimport groovy.transform.CompileStatic\n\nimport org.moqui.BaseException\nimport org.moqui.context.ElasticFacade\nimport org.moqui.entity.EntityException\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.entity.EntityDataDocument\nimport org.moqui.impl.entity.EntityDefinition\nimport org.moqui.impl.entity.EntityJavaUtil\nimport org.moqui.impl.entity.FieldInfo\nimport org.moqui.impl.util.ElasticSearchLogger\nimport org.moqui.util.LiteStringMap\nimport org.moqui.util.MNode\nimport org.moqui.util.RestClient\nimport org.moqui.util.RestClient.Method\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.sql.Timestamp\nimport java.util.concurrent.Future\n\n@CompileStatic\nclass ElasticFacadeImpl implements ElasticFacade {\n    private final static Logger logger = LoggerFactory.getLogger(ElasticFacadeImpl.class)\n    private final static Set<String> docSkipKeys = new HashSet<>(Arrays.asList(\"_index\", \"_type\", \"_id\", \"_timestamp\"))\n\n    // Max HTTP Response Size for Search - this may need to be configurable, set very high for now (appears that Jetty only grows the buffer as needed for response content)\n    public static int MAX_RESPONSE_SIZE_SEARCH = 100 * 1024 * 1024\n    // Request Timeout, another thing that could be configurable but can be specified via API, set to 50 to give plenty of time for TX/etc cleanup\n    public static int DEFAULT_REQUEST_TIMEOUT = 50\n    public static int SMALL_OP_REQUEST_TIMEOUT = 5\n\n    public final static ObjectMapper jacksonMapper = new ObjectMapper()\n            .setSerializationInclusion(JsonInclude.Include.ALWAYS)\n            .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)\n            .configure(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN, true)\n    static {\n        // Jackson custom serializers, etc\n        SimpleModule module = new SimpleModule()\n        module.addSerializer(GString.class, new ContextJavaUtil.GStringJsonSerializer())\n        module.addSerializer(LiteStringMap.class, new ContextJavaUtil.LiteStringMapJsonSerializer())\n        // NOTE: using custom serializer for Timestamps because ElasticSearch 7+ does NOT allow negative longs for epoch_millis format... sigh\n        //     .enable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)\n        module.addSerializer(Timestamp.class, new ContextJavaUtil.TimestampNoNegativeJsonSerializer())\n        jacksonMapper.registerModule(module)\n    }\n\n    public final ExecutionContextFactoryImpl ecfi\n    private final Map<String, ElasticClientImpl> clientByClusterName = new LinkedHashMap<>()\n    private ElasticSearchLogger esLogger = null\n\n    ElasticFacadeImpl(ExecutionContextFactoryImpl ecfi) {\n        this.ecfi = ecfi\n        init()\n    }\n    void init() {\n        MNode elasticFacadeNode = ecfi.getConfXmlRoot().first(\"elastic-facade\")\n        ArrayList<MNode> clusterNodeList = elasticFacadeNode.children(\"cluster\")\n        for (MNode clusterNode in clusterNodeList) {\n            clusterNode.setSystemExpandAttributes(true)\n\n            String clusterName = clusterNode.attribute(\"name\")\n            String clusterUrl = clusterNode.attribute(\"url\")\n            logger.info(\"Initializing ElasticFacade Client for cluster ${clusterName} at ${clusterUrl}\")\n\n            if (clientByClusterName.containsKey(clusterName)) {\n                logger.warn(\"ElasticFacade Client for cluster ${clusterName} already initialized, skipping\")\n                continue\n            }\n            if (!clusterUrl) {\n                logger.warn(\"ElasticFacade Client for cluster ${clusterName} has no url, skipping\")\n                continue\n            }\n\n            try {\n                ElasticClientImpl elci = new ElasticClientImpl(clusterNode, ecfi)\n                clientByClusterName.put(clusterName, elci)\n            } catch (Throwable t) {\n                Throwable cause = t.getCause()\n                if (cause != null && cause.message.contains(\"refused\")) {\n                    logger.error(\"Error initializing ElasticClient for cluster ${clusterName}: ${cause.toString()}\")\n                } else {\n                    logger.error(\"Error initializing ElasticClient for cluster ${clusterName}\", t)\n                }\n            }\n        }\n\n        // init ElasticSearchLogger\n        if (esLogger == null || !esLogger.isInitialized()) {\n            ElasticClientImpl loggerEci = clientByClusterName.get(\"logger\") ?: clientByClusterName.get(\"default\")\n            if (loggerEci != null) {\n                logger.info(\"Initializing ElasticSearchLogger with cluster ${loggerEci.getClusterName()}\")\n                esLogger = new ElasticSearchLogger(loggerEci, ecfi)\n            } else {\n                logger.warn(\"No Elastic Client found with name 'logger' or 'default', not initializing ElasticSearchLogger\")\n            }\n        } else {\n            logger.warn(\"ElasticSearchLogger in place and initialized, not initializing ElasticSearchLogger\")\n        }\n\n        // Index DataFeed with indexOnStartEmpty=Y\n        try {\n            ElasticClientImpl defaultEci = clientByClusterName.get(\"default\")\n            if (defaultEci != null) {\n                EntityList dataFeedList = ecfi.entityFacade.find(\"moqui.entity.feed.DataFeed\")\n                        .condition(\"indexOnStartEmpty\", \"Y\").disableAuthz().list()\n                for (EntityValue dataFeed in dataFeedList) {\n                    EntityList dfddList = ecfi.entityFacade.find(\"moqui.entity.feed.DataFeedDocumentDetail\")\n                            .condition(\"dataFeedId\", dataFeed.dataFeedId).disableAuthz().list()\n                    Set<String> indexNames = new HashSet<String>()\n                    for (int i = 0; i < dfddList.size(); i++) {\n                        EntityValue dfdd = (EntityValue) dfddList.get(i)\n                        indexNames.add(dfdd.getString(\"indexName\"))\n                    }\n                    boolean foundNotExists = false\n                    for (String indexName in indexNames) if (!defaultEci.indexExists(indexName)) foundNotExists = true\n                    if (foundNotExists) {\n                        // NOTE: called with localOnly(true) to avoid issues during startup if a distributed executor service is configured\n                        String jobRunId = ecfi.service.job(\"IndexDataFeedDocuments\").parameter(\"dataFeedId\", dataFeed.dataFeedId).localOnly(true).run()\n                        logger.info(\"Found index does not exist for DataFeed ${dataFeed.dataFeedId}, started job ${jobRunId} to index\")\n                    }\n                }\n            }\n        } catch (Throwable t) {\n            logger.error(\"Error checking or indexing for all DataFeed with indexOnStartEmpty=Y\", t)\n        }\n    }\n\n    void destroy() {\n        if (esLogger != null) esLogger.destroy()\n        for (ElasticClientImpl eci in clientByClusterName.values()) eci.destroy()\n    }\n\n    @Override ElasticClient getDefault() { return clientByClusterName.get(\"default\") }\n    @Override ElasticClient getClient(String clusterName) { return clientByClusterName.get(clusterName) }\n    @Override List<ElasticClient> getClientList() { return new ArrayList<ElasticClient>(clientByClusterName.values()) }\n\n    static class ElasticClientImpl implements ElasticClient {\n        private final ExecutionContextFactoryImpl ecfi\n        private final MNode clusterNode\n        private final String clusterName, clusterUser, clusterPassword, indexPrefix\n        private final String clusterUrl, clusterProtocol, clusterHost\n        private final int clusterPort\n        private RestClient.PooledRequestFactory requestFactory\n        private Map serverInfo = (Map) null\n        private String esVersion = (String) null\n        private boolean esVersionUnder7 = false\n        private boolean isOpenSearch = true\n\n        ElasticClientImpl(MNode clusterNode, ExecutionContextFactoryImpl ecfi) {\n            this.ecfi = ecfi\n            this.clusterNode = clusterNode\n\n            this.clusterName = clusterNode.attribute(\"name\")\n            this.clusterUser = clusterNode.attribute(\"user\")\n            this.clusterPassword = clusterNode.attribute(\"password\")\n            this.indexPrefix = clusterNode.attribute(\"index-prefix\")\n            String urlTemp = clusterNode.attribute(\"url\")\n            if (urlTemp.endsWith(\"/\")) urlTemp = urlTemp.substring(0, urlTemp.length() - 1)\n            this.clusterUrl = urlTemp\n            URI uri = new URI(urlTemp)\n            clusterProtocol = uri.getScheme() ?: \"http\"\n            clusterHost = uri.getHost()\n            int portTemp = uri.getPort()\n            clusterPort = portTemp > 0 ? portTemp : 9200\n\n            String poolMaxStr = clusterNode.attribute(\"pool-max\")\n            String queueSizeStr = clusterNode.attribute(\"queue-size\")\n\n            requestFactory = new RestClient.PooledRequestFactory(\"ES_\" + clusterName)\n            if (poolMaxStr) requestFactory.poolSize(Integer.parseInt(poolMaxStr))\n            if (queueSizeStr) requestFactory.queueSize(Integer.parseInt(queueSizeStr))\n            requestFactory.init()\n\n            // try connecting and get server info\n            int retries = ((clusterHost == 'localhost' || clusterHost == '127.0.0.1') && !\"true\".equals(System.getProperty(\"moqui.elasticsearch.started\"))) ? 1 : 20\n            for (int i = 1; i <= retries; i++) {\n                try {\n                    serverInfo = getServerInfo()\n                } catch (Throwable t) {\n                    if (i == retries) {\n                        requestFactory.destroy()\n                        throw t\n                        // logger.error(\"Final error connecting to ElasticSearch cluster ${clusterName} at ${clusterProtocol}://${clusterHost}:${clusterPort}, try ${i} of ${retries}: ${t.toString()}\", t)\n                    } else {\n                        logger.warn(\"Error connecting to ElasticSearch cluster ${clusterName} at ${clusterProtocol}://${clusterHost}:${clusterPort}, try ${i} of ${retries}: ${t.toString()}\")\n                        Thread.sleep(1000)\n                    }\n                }\n                if (serverInfo != null) {\n                    // [name:dejc-m1p.local, cluster_name:opensearch, cluster_uuid:aoMc3T7ES9yCC6yzi-_Ghg, version:[distribution:opensearch, number:1.3.1, build_type:tar, build_hash:c4c0672877bf0f787ca857c7c37b775967f93d81, build_date:2022-03-29T18:34:46.566802Z, build_snapshot:false, lucene_version:8.10.1, minimum_wire_compatibility_version:6.8.0, minimum_index_compatibility_version:6.0.0-beta1], tagline:The OpenSearch Project: https://opensearch.org/]\n                    Map versionMap = ((Map) serverInfo.version)\n                    String distro = versionMap?.distribution ?: \"elasticsearch\"\n                    isOpenSearch = \"opensearch\".equals(distro)\n                    esVersion = versionMap?.number\n                    esVersionUnder7 = !isOpenSearch && esVersion?.charAt(0) < ((char) '7')\n                    logger.info(\"Connected to ElasticSearch cluster ${clusterName} at ${clusterProtocol}://${clusterHost}:${clusterPort} distribution ${distro} version ${esVersion}, ES earlier than 7.0? ${esVersionUnder7}\\n${serverInfo}\")\n                    break\n                }\n            }\n        }\n\n        @Override String getClusterName() { return clusterName }\n        @Override String getClusterLocation() { return clusterProtocol + \"://\" + clusterHost + \":\" + clusterPort }\n        boolean isEsVersionUnder7() { return esVersionUnder7 }\n\n        void destroy() { requestFactory.destroy() }\n\n        @Override\n        Map getServerInfo() {\n            RestClient.RestResponse response = makeRestClient(Method.GET, null, null, null).call()\n            checkResponse(response, \"Server info\", null)\n            return (Map) jsonToObject(response.text())\n        }\n\n        @Override\n        boolean indexExists(final String index) {\n            if (index == null || index.isEmpty()) throw new IllegalArgumentException(\"Index name required\")\n            RestClient.RestResponse response = makeRestClient(Method.HEAD, index, null, null).call()\n            return response.statusCode == 200\n        }\n        @Override\n        boolean aliasExists(final String origAlias) {\n            String alias = prefixIndexName(origAlias)\n            if (alias == null) throw new IllegalArgumentException(\"Alias required\")\n            RestClient.RestResponse response = makeRestClient(Method.HEAD, null, \"_alias/\" + alias, null).call()\n            return response.statusCode == 200\n        }\n\n        @Override\n        void createIndex(String index, Map docMapping, String origAlias) { createIndex(index, null, docMapping, origAlias, null) }\n        void createIndex(String index, String docType, Map docMapping, String origAlias) { createIndex(index, docType, docMapping, origAlias, null) }\n        void createIndex(String index, String docType, Map docMapping, String origAlias, Map settings) {\n            if (index == null || index.isEmpty()) throw new IllegalArgumentException(\"Index name required\")\n            RestClient restClient = makeRestClient(Method.PUT, index, null, null)\n            if (docMapping || origAlias) {\n                Map requestMap = new HashMap()\n                if (docMapping) {\n                    if (esVersionUnder7) requestMap.put(\"mappings\", [(docType?:'_doc'):docMapping])\n                    else requestMap.put(\"mappings\", docMapping)\n                }\n                if (settings != null && settings.size() > 0) {\n                    requestMap.put('settings', settings)\n                }\n                if (origAlias != null && !origAlias.isEmpty()) {\n                    String alias = prefixIndexName(origAlias)\n                    requestMap.put(\"aliases\", [(alias):[:]])\n                }\n                restClient.text(objectToJson(requestMap))\n            }\n            // NOTE: this is for ES 7.0+ only, before that mapping needed to be named\n            RestClient.RestResponse response = restClient.call()\n            checkResponse(response, \"Create index\", index)\n        }\n        @Override\n        void putMapping(String index, Map docMapping) { putMapping(index, null, docMapping) }\n        void putMapping(String index, String docType, Map docMapping) {\n            if (!docMapping) throw new IllegalArgumentException(\"Mapping may not be empty for put mapping\")\n            // NOTE: this is for ES 7.0+ only, before that mapping needed to be named in the path\n            String path = esVersionUnder7 ? \"_mapping/\" + (docType?:'_doc') : \"_mapping\"\n            RestClient restClient = makeRestClient(Method.PUT, index, path, null)\n            restClient.text(objectToJson(docMapping))\n            RestClient.RestResponse response = restClient.call()\n            checkResponse(response, \"Put mapping\", index)\n        }\n        @Override\n        void deleteIndex(String index) {\n            RestClient restClient = makeRestClient(Method.DELETE, index, null, null)\n            RestClient.RestResponse response = restClient.call()\n            checkResponse(response, \"Delete index\", index)\n        }\n\n        @Override\n        void index(String index, String _id, Map document) {\n            if (index == null || index.isEmpty()) throw new IllegalArgumentException(\"In index document the index name may not be empty\")\n            if (_id == null || _id.isEmpty()) throw new IllegalArgumentException(\"In index document the _id may not be empty\")\n            RestClient.RestResponse response = makeRestClient(Method.PUT, index, \"_doc/\" + _id, null, SMALL_OP_REQUEST_TIMEOUT)\n                    .text(objectToJson(document)).call()\n            checkResponse(response, \"Index document ${_id}\", index)\n        }\n\n        @Override\n        void update(String index, String _id, Map documentFragment) {\n            if (index == null || index.isEmpty()) throw new IllegalArgumentException(\"In update document the index name may not be empty\")\n            if (_id == null || _id.isEmpty()) throw new IllegalArgumentException(\"In update document the _id may not be empty\")\n            RestClient.RestResponse response = makeRestClient(Method.POST, index, \"_update/\" + _id, null, SMALL_OP_REQUEST_TIMEOUT)\n                    .text(objectToJson([doc:documentFragment])).call()\n            checkResponse(response, \"Update document ${_id}\", index)\n        }\n\n        @Override\n        void delete(String index, String _id) {\n            if (index == null || index.isEmpty()) throw new IllegalArgumentException(\"In delete document the index name may not be empty\")\n            if (_id == null || _id.isEmpty()) throw new IllegalArgumentException(\"In delete document the _id may not be empty\")\n            RestClient.RestResponse response = makeRestClient(Method.DELETE, index, \"_doc/\" + _id, null, SMALL_OP_REQUEST_TIMEOUT).call()\n            if (response.statusCode == 404) {\n                logger.warn(\"In delete document not found in index ${index} with ID ${_id}\")\n            } else {\n                checkResponse(response, \"Delete document ${_id}\", index)\n            }\n        }\n\n        @Override\n        Integer deleteByQuery(String index, Map queryMap) {\n            if (index == null || index.isEmpty()) throw new IllegalArgumentException(\"In delete by query the index name may not be empty\")\n            RestClient.RestResponse response = makeRestClient(Method.POST, index, \"_delete_by_query\", null)\n                    .text(objectToJson([query:queryMap])).call()\n            checkResponse(response, \"Delete by query\", index)\n            Map responseMap = (Map) jsonToObject(response.text())\n            return responseMap.deleted as Integer\n        }\n\n        @Override\n        void bulk(String index, List<Map> actionSourceList) {\n            if (actionSourceList == null || actionSourceList.size() == 0) return\n\n            RestClient.RestResponse response = bulkResponse(index, actionSourceList, false)\n            checkResponse(response, \"Bulk operations\", index)\n        }\n        RestClient.RestResponse bulkResponse(String index, List<Map> actionSourceList, boolean refresh) {\n            // NOTE: don't use logger in this method, with ElasticSearchLogger in place results in infinite log feedback\n            if (actionSourceList == null || actionSourceList.size() == 0) return null\n\n            StringWriter bodyWriter = new StringWriter(actionSourceList.size() * 100)\n            for (Map entry in actionSourceList) {\n                // look for _index fields in each Map, if found prefix\n                if (entry.size() == 1) {\n                    Map actionMap = (Map) entry.values().first()\n                    Object _indexVal = actionMap.get(\"_index\")\n                    if (_indexVal != null && _indexVal instanceof String) actionMap.put(\"_index\", prefixIndexName((String) _indexVal))\n                }\n                // System.out.println(\"bulk entry ${entry}\")\n                // now done mucking around with the data, write it\n                jacksonMapper.writeValue(bodyWriter, entry)\n                bodyWriter.append((char) '\\n')\n            }\n            RestClient restClient = makeRestClient(Method.POST, index, \"_bulk\", [refresh:(refresh ? \"true\" : \"wait_for\")])\n                    .contentType(\"application/x-ndjson\")\n            restClient.timeout(600)\n            restClient.text(bodyWriter.toString())\n            // System.out.println(\"Bulk:\\n${bodyWriter.toString()}\")\n\n            RestClient.RestResponse response = restClient.call()\n            // System.out.println(\"Bulk Response: ${response.statusCode} ${response.reasonPhrase}\\n${response.text()}\")\n            return response\n        }\n\n        @Override\n        void bulkIndex(String index, String idField, List<Map> documentList) { bulkIndex(index, null, idField, documentList, false) }\n        void bulkIndex(String index, String docType, String idField, List<Map> documentList, boolean refresh) {\n            List<Map> actionSourceList = new ArrayList<>(documentList.size() * 2)\n            boolean hasId = idField != null && !idField.isEmpty()\n            int loopIdx = 0\n            for (Map document in documentList) {\n                Map indexMap = new LinkedHashMap()\n                indexMap.put(\"_index\", index)\n                if (hasId) {\n                    Object idValue = document.get(idField)\n                    if (idValue != null) {\n                        indexMap.put(\"_id\", idValue)\n                    } else {\n                        logger.warn(\"Bulk Index to ${index} found null value for ${idField} in doc ${loopIdx}\")\n                    }\n                }\n                if (esVersionUnder7) indexMap.put(\"_type\", docType ?: \"_doc\")\n                Map actionMap = [index:indexMap]\n                actionSourceList.add(actionMap)\n                actionSourceList.add(document)\n                loopIdx++\n            }\n\n            RestClient.RestResponse response = bulkResponse(index, actionSourceList, refresh)\n            checkResponse(response, \"Bulk operations\", index)\n            checkBulkResponseErrors(response, \"Bulk index\", index)\n        }\n\n        @Override\n        Map get(final String index, String _id) {\n            if (index == null || index.isEmpty()) throw new IllegalArgumentException(\"In get document the index name may not be empty\")\n            if (_id == null || _id.isEmpty()) throw new IllegalArgumentException(\"In get document the _id may not be empty\")\n            String path = \"_doc/\" + _id\n            if (esVersionUnder7) {\n                // need actual doc type, this is a hack that will only work with old moqui-elasticsearch DataDocument based index name, otherwise need another parameter so API changes\n                // NOTE: this is for partial backwards compatibility for specific scenarios, remove after moqui-elasticsearch deprecate\n                path = esIndexToDdId(index) + \"/\" + _id\n            }\n            RestClient.RestResponse response = makeRestClient(Method.GET, index, path, null, SMALL_OP_REQUEST_TIMEOUT).call()\n            if (response.statusCode == 404) {\n                return null\n            } else {\n                checkResponse(response, \"Get document ${_id}\", index)\n                return (Map) jsonToObject(response.text())\n            }\n        }\n        @Override\n        Map getSource(String index, String _id) { return (Map) get(index, _id)?._source }\n\n        @Override\n        List<Map> get(String index, List<String> _idList) {\n            if (_idList == null || _idList.size() == 0) return []\n            if (index == null || index.isEmpty()) throw new IllegalArgumentException(\"In get documents the index name may not be empty\")\n            RestClient.RestResponse response = makeRestClient(Method.GET, index, \"_mget\", null)\n                    .text(objectToJson([ids:_idList])).call()\n            checkResponse(response, \"Get document multi\", index)\n            Map bodyMap = (Map) jsonToObject(response.text())\n            return (List) bodyMap.docs\n        }\n\n        @Override\n        Map search(String index, Map searchMap) {\n            // logger.warn(\"Search ${index}\\n${objectToJson(searchMap)}\")\n            RestClient.RestResponse response = makeRestClient(Method.GET, index, \"_search\", null).maxResponseSize(MAX_RESPONSE_SIZE_SEARCH)\n                    .text(objectToJson(searchMap)).call()\n            // System.out.println(\"Search Response: ${response.statusCode} ${response.reasonPhrase}\\n${response.text()}\")\n            checkResponse(response, \"Search\", index)\n            Map resultMap = (Map) jsonToObject(response.text())\n            // go through each hit (in resultMap.hits.hits) and replace _index value from ES\n            List<Map> hitsList = (List<Map>) ((Map) resultMap.hits).hits\n            for (Map hit in hitsList) {\n                Object _indexVal = hit.get(\"_index\")\n                if (_indexVal != null && _indexVal instanceof String) hit.put(\"_index\", unprefixIndexName((String) _indexVal))\n                // logger.warn(\"search hit ${hit}\")\n            }\n            // now done mucking around with the data, return it\n            return resultMap\n        }\n        @Override\n        List<Map> searchHits(String origIndex, Map searchMap) {\n            Map resultMap = search(origIndex, searchMap)\n            return (List) ((Map) resultMap.hits).hits\n        }\n        @Override\n        Map validateQuery(String index, Map queryMap, boolean explain) {\n            String queryJson = objectToJson([query:queryMap])\n            RestClient.RestResponse response = makeRestClient(Method.GET, index, \"_validate/query\", explain ? [explain:'true'] : null, SMALL_OP_REQUEST_TIMEOUT)\n                    .text(queryJson).call()\n            checkResponse(response, \"Validate Query\", index)\n            String responseText = response.text()\n            Map responseMap = (Map) jsonToObject(responseText)\n            // System.out.println(\"Validate Query Response: ${response.statusCode} ${response.reasonPhrase} Value? ${responseMap.get(\"valid\") as boolean}\\n${response.text()}\")\n            // return null if valid\n            if (responseMap.get(\"valid\")) return null\n            logger.warn(\"Invalid ElasticSearch query\\n${JsonOutput.prettyPrint(queryJson)}\\nResponse: ${JsonOutput.prettyPrint(responseText)}\")\n            return responseMap\n        }\n\n        @Override\n        long count(String index, Map countMap) {\n            Map resultMap = countResponse(index, countMap)\n            Number count = (Number) resultMap.count\n            return count != null ? count.longValue() : 0\n        }\n        @Override\n        Map countResponse(String index, Map countMap) {\n            if (countMap == null || countMap.isEmpty()) countMap = [query:[match_all:[:]]]\n            // System.out.println(\"Count Request index ${index} ${countMap}\")\n            RestClient.RestResponse response = makeRestClient(Method.GET, index, \"_count\", null)\n                    .text(objectToJson(countMap)).call()\n            // System.out.println(\"Count Response: ${response.statusCode} ${response.reasonPhrase}\\n${response.text()}\")\n            checkResponse(response, \"Count\", index)\n            Map resultMap = (Map) jsonToObject(response.text())\n            return resultMap\n        }\n\n        @Override\n        String getPitId(String index, String keepAlive) {\n            if (keepAlive == null) keepAlive = \"60s\"\n            RestClient.RestResponse response\n            if (isOpenSearch) {\n                // see: https://opensearch.org/docs/latest/opensearch/point-in-time-api#create-a-pit\n                // requires 3.4.0 or later\n                response = makeRestClient(Method.POST, index, \"_search/point_in_time\", [keep_alive:keepAlive]).call()\n            } else {\n                // see: https://www.elastic.co/guide/en/elasticsearch/reference/7.10/paginate-search-results.html#scroll-search-results\n                // whatever the docs say:\n                // - it doesn't work with the keep_alive parameter at all \"contains unrecognized parameter: [keep_alive]\"\n                // - does not work with no body \"request body is required\"\n                // - and it doesn't work without the doc type _doc before _pit in the path \"mapping type name [_pit] can't start with '_' unless it is called [_doc]\"\n                // in other words, the docs are completely wrong for ES 7.10.2\n                // response = makeRestClient(Method.POST, index, \"_pit\", [keep_alive:keepAlive]).call()\n                response = makeRestClient(Method.POST, index, \"_doc/_pit\", null).text(objectToJson([keep_alive:keepAlive])).call()\n            }\n            // System.out.println(\"Get PIT Response: ${response.statusCode} ${response.reasonPhrase}\\n${response.text()}\")\n            checkResponse(response, \"PIT\", index)\n            Map resultMap = (Map) jsonToObject(response.text())\n            return isOpenSearch ? resultMap?.pit_id : resultMap?.id\n        }\n        @Override\n        void deletePit(String pitId) {\n            RestClient.RestResponse response\n            if (isOpenSearch) {\n                // see: https://opensearch.org/docs/latest/opensearch/point-in-time-api#delete-pits\n                // requires 3.4.0 or later\n                response = makeRestClient(Method.DELETE, null, \"_search/point_in_time\", null)\n                        .text(objectToJson([pit_id:[pitId]])).call()\n            } else {\n                // see: https://www.elastic.co/guide/en/elasticsearch/reference/7.10/paginate-search-results.html#scroll-search-results\n                response = makeRestClient(Method.DELETE, null, \"_pit\", null).text(objectToJson([id:pitId])).call()\n            }\n            // System.out.println(\"Delete PIT Response: ${response.statusCode} ${response.reasonPhrase}\\n${response.text()}\")\n            checkResponse(response, \"PIT\", null)\n        }\n\n        @Override\n        RestClient.RestResponse call(Method method, String index, String path, Map<String, String> parameters, Object bodyJsonObject) {\n            RestClient restClient = makeRestClient(method, index, path, parameters).text(objectToJson(bodyJsonObject))\n            return restClient.call()\n        }\n\n        @Override\n        Future<RestClient.RestResponse> callFuture(Method method, String index, String path, Map<String, String> parameters, Object bodyJsonObject) {\n            RestClient restClient = makeRestClient(method, index, path, parameters).text(objectToJson(bodyJsonObject))\n            return restClient.callFuture()\n        }\n\n        @Override\n        RestClient makeRestClient(Method method, String index, String path, Map<String, String> parameters) {\n            return makeRestClient(method, index, path, parameters, null)\n        }\n        RestClient makeRestClient(Method method, String index, String path, Map<String, String> parameters, Integer timeout) {\n            // NOTE: don't use logger in this method, with ElasticSearchLogger in place results in infinite log feedback\n            String serverIndex = prefixIndexName(index)\n            // System.out.println(\"=== ES call index ${serverIndex} path ${path} parameters ${parameters}\")\n            RestClient restClient = new RestClient().withRequestFactory(requestFactory).method(method)\n                    .contentType(\"application/json\").timeout(timeout != null ? timeout : DEFAULT_REQUEST_TIMEOUT)\n            restClient.uri().protocol(clusterProtocol).host(clusterHost).port(clusterPort)\n                    .path(serverIndex).path(path).parameters(parameters).build()\n            // see https://www.elastic.co/guide/en/elasticsearch/reference/7.4/http-clients.html\n            if (clusterUser != null && !clusterUser.isEmpty()) restClient.basicAuth(clusterUser, clusterPassword)\n            return restClient\n        }\n\n        @Override\n        void checkCreateDataDocumentIndexes(String indexName) {\n            // if the index alias exists call it good\n            if (indexExists(indexName)) return\n\n            EntityList ddList = ecfi.entityFacade.find(\"moqui.entity.document.DataDocument\").condition(\"indexName\", indexName).list()\n            for (EntityValue dd in ddList) storeIndexAndMapping(indexName, dd)\n        }\n        @Override\n        void checkCreateDataDocumentIndex(String dataDocumentId) {\n            String idxName = ddIdToEsIndex(dataDocumentId)\n            if (indexExists(idxName)) return\n\n            EntityValue dd = ecfi.entityFacade.find(\"moqui.entity.document.DataDocument\").condition(\"dataDocumentId\", dataDocumentId).one()\n            storeIndexAndMapping((String) dd.indexName, dd)\n        }\n        @Override\n        void putDataDocumentMappings(String indexName) {\n            EntityList ddList = ecfi.entityFacade.find(\"moqui.entity.document.DataDocument\").condition(\"indexName\", indexName).list()\n            for (EntityValue dd in ddList) storeIndexAndMapping(indexName, dd)\n        }\n        synchronized protected void storeIndexAndMapping(String indexName, EntityValue dd) {\n            String dataDocumentId = (String) dd.getNoCheckSimple(\"dataDocumentId\")\n            String manualMappingServiceName = (String) dd.getNoCheckSimple(\"manualMappingServiceName\")\n            String esIndexName = ddIdToEsIndex(dataDocumentId)\n\n            // logger.warn(\"========== Checking index ${esIndexName} with alias ${indexName} , hasIndex=${hasIndex}\")\n            boolean hasIndex = indexExists(esIndexName)\n            Map docMapping = makeElasticSearchMapping(dataDocumentId, ecfi)\n            Map settings = null\n\n            if (manualMappingServiceName) {\n                def serviceResult = ecfi.service.sync().name(manualMappingServiceName).parameter('mapping', docMapping).call()\n                docMapping = (Map) serviceResult.mapping\n                settings = (Map) serviceResult.settings\n            }\n\n            if (hasIndex) {\n                logger.info(\"Updating ElasticSearch index ${esIndexName} for ${dataDocumentId} document mapping\")\n                putMapping(esIndexName, dataDocumentId, docMapping)\n            } else {\n                logger.info(\"Creating ElasticSearch index ${esIndexName} for ${dataDocumentId} with alias ${indexName} and adding document mapping\")\n                createIndex(esIndexName, dataDocumentId, docMapping, indexName, settings)\n                // logger.warn(\"========== Added mapping for ${dataDocumentId} to index ${esIndexName}:\\n${docMapping}\")\n            }\n        }\n\n        @Override\n        void verifyDataDocumentIndexes(List<Map> documentList) {\n            Set<String> indexNames = new HashSet()\n            Set<String> dataDocumentIds = new HashSet()\n            for (Map document in documentList) {\n                indexNames.add((String) document.get(\"_index\"))\n                dataDocumentIds.add((String) document.get(\"_type\"))\n            }\n            for (String indexName in indexNames) checkCreateDataDocumentIndexes(indexName)\n            for (String dataDocumentId in dataDocumentIds) checkCreateDataDocumentIndex(dataDocumentId)\n        }\n\n        @Override\n        void bulkIndexDataDocument(List<Map> documentList) {\n            int docsPerBulk = 1000\n            int docListSize = documentList.size()\n\n            String _index = null\n            String _type = null\n            String _id = null\n            String esIndexName = null\n\n            ArrayList<Map> actionSourceList = new ArrayList<Map>(docsPerBulk * 2)\n            int curBulkDocs = 0\n            int batchCount = 0\n            for (Map document in documentList) {\n                // logger.warn(\"====== Indexing document: ${document}\")\n\n                _index = document._index\n                _type = document._type\n                _id = document._id\n                // String _timestamp = document._timestamp\n                // As of ES 2.0 _index, _type, _id, and _timestamp shouldn't be in document to be indexed\n                // clone document before removing fields so they are present for other code using the same data\n                document = new LiteStringMap(document, docSkipKeys)\n                // no longer needed with docSkipKeys: document.remove('_index'); document.remove('_type'); document.remove('_id'); document.remove('_timestamp')\n\n                // as of ES 6.0, and required for 7 series, one index per doc type so one per dataDocumentId, cleaned up to be valid ES index name (all lower case, etc)\n                esIndexName = ddIdToEsIndex(_type)\n\n                // before indexing convert types needed for ES\n                // hopefully not needed with Jackson settings, but if so: ElasticSearchUtil.convertTypesForEs(document)\n\n                // add the document to the bulk index\n                if (esVersionUnder7) {\n                    actionSourceList.add([index:[_index:esIndexName, _type:_type, _id:_id]])\n                } else {\n                    actionSourceList.add([index:[_index:esIndexName, _id:_id]])\n                }\n                actionSourceList.add(document)\n\n                curBulkDocs++\n\n                if (curBulkDocs >= docsPerBulk) {\n                    // logger.info(\"Bulk index batch ${batchCount}, cur docs ${curBulkDocs} of ${docListSize}, last index ${esIndexName} (for index ${_index} type ${_type})\")\n                    // logger.warn(\"last document: ${document}\")\n                    RestClient.RestResponse response = bulkResponse(null, actionSourceList, false)\n                    if (response.statusCode < 200 || response.statusCode >= 300) {\n                        checkResponse(response, \"Bulk index\", null)\n                        curBulkDocs = 0\n                        actionSourceList = null\n                        break\n                    }\n\n                    /* don't support getting versions any more, generally waste of resources:\n                    BulkItemResponse[] itemResponses = bulkResponse.getItems()\n                    int itemResponsesSize = itemResponses.length\n                    for (int i = 0; i < itemResponsesSize; i++) documentVersionList.add(itemResponses[i].getVersion())\n                     */\n\n                    // reset for the next set\n                    curBulkDocs = 0\n                    actionSourceList = new ArrayList<Map>(docsPerBulk * 2)\n                    batchCount++\n                }\n            }\n            if (curBulkDocs > 0) {\n                // logger.info(\"Bulk index last, cur docs ${curBulkDocs} of ${docListSize}, last index ${esIndexName} (for index ${_index} type ${_type})\")\n                RestClient.RestResponse response = bulkResponse(null, actionSourceList, false)\n                checkResponse(response, \"Bulk index\", null)\n\n                /* don't support getting versions any more, generally waste of resources:\n                BulkItemResponse[] itemResponses = bulkResponse.getItems()\n                int itemResponsesSize = itemResponses.length\n                for (int i = 0; i < itemResponsesSize; i++) documentVersionList.add(itemResponses[i].getVersion())\n                 */\n            }\n        }\n\n        @Override String objectToJson(Object jsonObject) { return ElasticFacadeImpl.objectToJson(jsonObject) }\n        @Override Object jsonToObject(String jsonString) { return ElasticFacadeImpl.jsonToObject(jsonString) }\n\n        String prefixIndexName(String index) {\n            if (index == null) return null\n            index = index.trim()\n            if (index.isEmpty()) return null\n            // handle comma separated index names\n            return index.split(\",\").collect({\n                it = it.trim()\n                return indexPrefix != null && !it.startsWith(indexPrefix) ? indexPrefix.concat(it) : it\n            }).join(\",\")\n            // return indexPrefix != null && !index.startsWith(indexPrefix) ? indexPrefix.concat(index) : index\n        }\n        String unprefixIndexName(String index) {\n            if (index == null) return null\n            index = index.trim()\n            if (index.isEmpty()) return null\n            // handle comma separated index names\n            return index.split(\",\").collect({\n                it = it.trim()\n                return indexPrefix != null && it.startsWith(indexPrefix) ? it.substring(indexPrefix.length()) : it\n            }).join(\",\")\n            // return indexPrefix != null && index.startsWith(indexPrefix) ? index.substring(indexPrefix.length()) : index\n        }\n    }\n\n    // ============== Utility Methods ==============\n\n    static void checkResponse(RestClient.RestResponse response, String operation, String index) {\n        if (response.statusCode >= 200 && response.statusCode < 300) return\n\n        String msg = \"${operation}${index ? ' on index ' + index : ''} failed with code ${response.statusCode}: ${response.reasonPhrase}\"\n        String responseText = response.text()\n        boolean logRequestBody = true\n        try {\n            Map responseMap = (Map) jsonToObject(response.text())\n            Map errorMap = (Map) responseMap.error\n            if (errorMap) {\n                msg = msg + ' - ' + errorMap.reason + ' (line ' + errorMap.line + ' col ' + errorMap.col + ')'\n                // maybe not, just always do it: logRequestBody = errorMap.type == 'parsing_exception'\n            }\n        } catch (Throwable t) {\n            logger.error(\"Error parsing ElasticSearch response: ${t.toString()}\")\n        }\n\n        String requestUri = response.getClient().getUriString()\n        String requestBody = response.getClient().getBodyText()\n        if (requestBody != null && requestBody.length() > 2000) requestBody = requestBody.substring(0, 2000)\n        logger.error(\"ElasticSearch ${msg}${responseText ? '\\nResponse: ' + responseText : ''}${requestUri ? '\\nURI: ' + requestUri : ''}${requestBody ? '\\nRequest: ' + requestBody : ''}\")\n\n        throw new BaseException(msg)\n    }\n\n    static void checkBulkResponseErrors(RestClient.RestResponse response, String operation, String index) {\n        if (response == null) return\n        try {\n            Map responseMap = (Map) jsonToObject(response.text())\n            if (responseMap.errors == true) {\n                List<Map> items = (List<Map>) responseMap.items\n                List<String> errorMessages = []\n                for (Map item in items) {\n                    Map itemMap = (Map) item.values().first()\n                    if (itemMap.error) {\n                        String errorMsg = \"Doc ${itemMap._id ?: 'unknown'}: ${itemMap.error}\"\n                        errorMessages.add(errorMsg)\n                        if (errorMessages.size() >= 10) break\n                    }\n                }\n                String msg = \"${operation}${index ? ' on index ' + index : ''} had ${items?.size() ?: 0} items with errors\"\n                if (errorMessages) msg += \":\\n  \" + errorMessages.join(\"\\n  \")\n                logger.error(\"ElasticSearch ${msg}\")\n                throw new BaseException(msg)\n            }\n        } catch (BaseException be) {\n            throw be\n        } catch (Throwable t) {\n            logger.error(\"Error checking bulk response for errors: ${t.toString()}\")\n        }\n    }\n\n    static String objectToJson(Object jsonObject) {\n        if (jsonObject instanceof String) return (String) jsonObject\n        return jacksonMapper.writeValueAsString(jsonObject)\n    }\n    static Object jsonToObject(String jsonString) {\n        try {\n            JsonNode jsonNode = jacksonMapper.readTree(jsonString)\n            if (jsonNode.isObject()) {\n                return jacksonMapper.treeToValue(jsonNode, Map.class)\n            } else if (jsonNode.isArray()) {\n                return jacksonMapper.treeToValue(jsonNode, List.class)\n            } else {\n                throw new BaseException(\"JSON text root is not an Object or Array\")\n            }\n        } catch (Throwable t) {\n            throw new BaseException(\"Error parsing JSON: \" + t.toString(), t)\n        }\n    }\n\n    /* with Jackson configuration for serialization should not need this:\n    static void convertTypesForEs(Map theMap) {\n        // initially just Timestamp to Long using Timestamp.getTime() to handle ES time zone issues with Timestamp objects\n        for (Map.Entry entry in theMap.entrySet()) {\n            Object valObj = entry.getValue()\n            if (valObj instanceof Timestamp) {\n                entry.setValue(((Timestamp) valObj).getTime())\n            } else if (valObj instanceof java.sql.Date) {\n                entry.setValue(valObj.toString())\n            } else if (valObj instanceof BigDecimal) {\n                entry.setValue(((BigDecimal) valObj).doubleValue())\n            } else if (valObj instanceof GString) {\n                entry.setValue(valObj.toString())\n            } else if (valObj instanceof Map) {\n                convertTypesForEs((Map) valObj)\n            } else if (valObj instanceof Collection) {\n                for (Object colObj in ((Collection) valObj)) {\n                    if (colObj instanceof Map) {\n                        convertTypesForEs((Map) colObj)\n                    } else {\n                        // if first in list isn't a Map don't expect others to be\n                        break\n                    }\n                }\n            }\n        }\n    }\n    */\n\n    static String ddIdToEsIndex(String dataDocumentId) {\n        if (dataDocumentId.contains(\"_\")) return dataDocumentId.toLowerCase()\n        return EntityJavaUtil.camelCaseToUnderscored(dataDocumentId).toLowerCase()\n    }\n    static String esIndexToDdId(String index) {\n        return EntityJavaUtil.underscoredToCamelCase(index, true)\n    }\n\n    static final Map<String, String> esTypeMap = [id:'keyword', 'id-long':'keyword', date:'date', time:'text',\n            'date-time':'date', 'number-integer':'long', 'number-decimal':'double', 'number-float':'double',\n            'currency-amount':'double', 'currency-precise':'double', 'text-indicator':'keyword', 'text-short':'text',\n            'text-medium':'text', 'text-intermediate':'text', 'text-long':'text', 'text-very-long':'text', 'binary-very-long':'binary']\n\n    static Map makeElasticSearchMapping(String dataDocumentId, ExecutionContextFactoryImpl ecfi) {\n        EntityValue dataDocument = ecfi.entityFacade.find(\"moqui.entity.document.DataDocument\")\n                .condition(\"dataDocumentId\", dataDocumentId).useCache(true).one()\n        if (dataDocument == null) throw new EntityException(\"No DataDocument found with ID [${dataDocumentId}]\")\n        EntityList dataDocumentFieldList = dataDocument.findRelated(\"moqui.entity.document.DataDocumentField\", null, null, true, false)\n        EntityList dataDocumentRelAliasList = dataDocument.findRelated(\"moqui.entity.document.DataDocumentRelAlias\", null, null, true, false)\n\n        Map<String, String> relationshipAliasMap = [:]\n        for (EntityValue dataDocumentRelAlias in dataDocumentRelAliasList)\n            relationshipAliasMap.put((String) dataDocumentRelAlias.relationshipName, (String) dataDocumentRelAlias.documentAlias)\n\n        String primaryEntityName = dataDocument.primaryEntityName\n        // String primaryEntityAlias = relationshipAliasMap.get(primaryEntityName) ?: primaryEntityName\n        EntityDefinition primaryEd = ecfi.entityFacade.getEntityDefinition(primaryEntityName)\n\n        Map<String, Object> rootProperties = [_entity:[type:'keyword']] as Map<String, Object>\n        Map<String, Object> mappingMap = [properties:rootProperties] as Map<String, Object>\n\n        List<String> remainingPkFields = new ArrayList(primaryEd.getPkFieldNames())\n        for (EntityValue dataDocumentField in dataDocumentFieldList) {\n            String fieldPath = (String) dataDocumentField.fieldPath\n            ArrayList<String> fieldPathElementList = EntityDataDocument.fieldPathToList(fieldPath)\n            if (fieldPathElementList.size() == 1) {\n                // is a field on the primary entity, put it there\n                String fieldName = ((String) dataDocumentField.fieldNameAlias) ?: fieldPath\n                String mappingType = (String) dataDocumentField.fieldType\n                String sortable = (String) dataDocumentField.sortable\n                if (fieldPath.startsWith(\"(\")) {\n                    rootProperties.put(fieldName, makePropertyMap(null, mappingType ?: 'double', sortable))\n                } else {\n                    FieldInfo fieldInfo = primaryEd.getFieldInfo(fieldPath)\n                    if (fieldInfo == null) throw new EntityException(\"Could not find field [${fieldPath}] for entity [${primaryEd.getFullEntityName()}] in DataDocument [${dataDocumentId}]\")\n                    rootProperties.put(fieldName, makePropertyMap(fieldInfo.type, mappingType, sortable))\n                    if (remainingPkFields.contains(fieldPath)) remainingPkFields.remove(fieldPath)\n                }\n\n                continue\n            }\n\n            Map<String, Object> currentProperties = rootProperties\n            EntityDefinition currentEd = primaryEd\n            int fieldPathElementListSize = fieldPathElementList.size()\n            for (int i = 0; i < fieldPathElementListSize; i++) {\n                String fieldPathElement = (String) fieldPathElementList.get(i)\n                if (i < (fieldPathElementListSize - 1)) {\n                    EntityJavaUtil.RelationshipInfo relInfo = currentEd.getRelationshipInfo(fieldPathElement)\n                    if (relInfo == null) throw new EntityException(\"Could not find relationship [${fieldPathElement}] for entity [${currentEd.getFullEntityName()}] in DataDocument [${dataDocumentId}]\")\n                    currentEd = relInfo.relatedEd\n                    if (currentEd == null) throw new EntityException(\"Could not find entity [${relInfo.relatedEntityName}] in DataDocument [${dataDocumentId}]\")\n\n                    // only put type many in sub-objects, same as DataDocument generation\n                    if (!relInfo.isTypeOne) {\n                        String objectName = relationshipAliasMap.get(fieldPathElement) ?: fieldPathElement\n                        Map<String, Object> subObject = (Map<String, Object>) currentProperties.get(objectName)\n                        Map<String, Object> subProperties\n                        if (subObject == null) {\n                            subProperties = new HashMap<>()\n                            // using type:'nested' with include_in_root:true seems to support nested queries and currently works with query string full path field names too\n                            // NOTE: keep an eye on this and if it breaks for our primary use case which is query strings with full path field names then remove type:'nested' and include_in_root\n                            subObject = [properties:subProperties, type:'nested', include_in_root:true] as Map<String, Object>\n                            currentProperties.put(objectName, subObject)\n                        } else {\n                            subProperties = (Map<String, Object>) subObject.get(\"properties\")\n                        }\n                        currentProperties = subProperties\n                    }\n                } else {\n                    String fieldName = (String) dataDocumentField.fieldNameAlias ?: fieldPathElement\n                    String mappingType = (String) dataDocumentField.fieldType\n                    String sortable = (String) dataDocumentField.sortable\n                    if (fieldPathElement.startsWith(\"(\")) {\n                        currentProperties.put(fieldName, makePropertyMap(null, mappingType ?: 'double', sortable))\n                    } else {\n                        FieldInfo fieldInfo = currentEd.getFieldInfo(fieldPathElement)\n                        if (fieldInfo == null) throw new EntityException(\"Could not find field [${fieldPathElement}] for entity [${currentEd.getFullEntityName()}] in DataDocument [${dataDocumentId}]\")\n                        currentProperties.put(fieldName, makePropertyMap(fieldInfo.type, mappingType, sortable))\n                    }\n                }\n            }\n        }\n\n        // now get all the PK fields not aliased explicitly\n        for (String remainingPkName in remainingPkFields) {\n            FieldInfo fieldInfo = primaryEd.getFieldInfo(remainingPkName)\n            String mappingType = esTypeMap.get(fieldInfo.type) ?: 'keyword'\n            Map propertyMap = makePropertyMap(null, mappingType, null)\n            // don't use not_analyzed in more recent ES: if (fieldInfo.type.startsWith(\"id\")) propertyMap.index = 'not_analyzed'\n            rootProperties.put(remainingPkName, propertyMap)\n        }\n\n        if (logger.isTraceEnabled()) logger.trace(\"Generated ElasticSearch mapping for ${dataDocumentId}: \\n${JsonOutput.prettyPrint(JsonOutput.toJson(mappingMap))}\")\n\n        return mappingMap\n    }\n    static Map makePropertyMap(String fieldType, String mappingType, String sortable) {\n        if (!mappingType) mappingType = esTypeMap.get(fieldType) ?: 'text'\n        Map<String, Object> propertyMap = new LinkedHashMap<>()\n        propertyMap.put(\"type\", mappingType)\n        if (\"Y\".equals(sortable) && \"text\".equals(mappingType)) propertyMap.put(\"fields\", [keyword: [type: \"keyword\"]])\n        if (\"date-time\".equals(fieldType)) propertyMap.format = \"date_time||epoch_millis||date_time_no_millis||yyyy-MM-dd HH:mm:ss.SSS||yyyy-MM-dd HH:mm:ss.S||yyyy-MM-dd\"\n        else if (\"date\".equals(fieldType)) propertyMap.format = \"date||strict_date_optional_time||epoch_millis\"\n        // if (fieldType.startsWith(\"id\")) propertyMap.index = 'not_analyzed'\n        return propertyMap\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/ExecutionContextFactoryImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context\n\nimport groovy.json.JsonSlurper\nimport groovy.transform.CompileStatic\nimport org.apache.logging.log4j.LogManager\nimport org.apache.logging.log4j.core.LoggerContext\nimport org.apache.shiro.SecurityUtils\nimport org.apache.shiro.authc.credential.CredentialsMatcher\nimport org.apache.shiro.authc.credential.HashedCredentialsMatcher\nimport org.apache.shiro.crypto.hash.SimpleHash\nimport org.apache.shiro.env.BasicIniEnvironment\nimport org.apache.shiro.mgt.SecurityManager\nimport org.codehaus.groovy.control.CompilationUnit\nimport org.codehaus.groovy.control.CompilerConfiguration\nimport org.codehaus.groovy.tools.GroovyClass\nimport org.moqui.BaseException\nimport org.moqui.Moqui\nimport org.moqui.context.*\nimport org.moqui.context.ArtifactExecutionInfo.ArtifactType\nimport org.moqui.entity.EntityDataLoader\nimport org.moqui.entity.EntityFacade\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityValue\nimport org.moqui.util.CollectionUtilities\nimport org.moqui.util.MClassLoader\nimport org.moqui.impl.actions.XmlAction\nimport org.moqui.resource.UrlResourceReference\nimport org.moqui.impl.context.ContextJavaUtil.ArtifactBinInfo\nimport org.moqui.impl.context.ContextJavaUtil.ArtifactStatsInfo\nimport org.moqui.impl.context.ContextJavaUtil.ArtifactHitInfo\nimport org.moqui.impl.context.ContextJavaUtil.CustomScheduledExecutor\nimport org.moqui.impl.context.ContextJavaUtil.ScheduledRunnableInfo\nimport org.moqui.impl.entity.EntityFacadeImpl\nimport org.moqui.impl.screen.ScreenFacadeImpl\nimport org.moqui.impl.service.ServiceFacadeImpl\nimport org.moqui.impl.webapp.NotificationWebSocketListener\nimport org.moqui.screen.ScreenFacade\nimport org.moqui.service.ServiceFacade\nimport org.moqui.util.MNode\nimport org.moqui.resource.ResourceReference\nimport org.moqui.util.ObjectUtilities\nimport org.moqui.util.SimpleTopic\nimport org.moqui.util.StringUtilities\nimport org.moqui.util.SystemBinding\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport jakarta.servlet.ServletContext\nimport jakarta.servlet.http.HttpServletRequest\nimport jakarta.servlet.http.HttpServletResponse\n\nimport javax.annotation.Nonnull\nimport jakarta.websocket.server.ServerContainer\nimport java.lang.management.ManagementFactory\nimport java.math.RoundingMode\nimport java.sql.Timestamp\nimport java.util.concurrent.BlockingQueue\nimport java.util.concurrent.ConcurrentLinkedQueue\nimport java.util.concurrent.LinkedBlockingQueue\nimport java.util.concurrent.ScheduledFuture\nimport java.util.concurrent.ScheduledThreadPoolExecutor\nimport java.util.concurrent.ThreadPoolExecutor\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.atomic.AtomicBoolean\nimport java.util.jar.JarFile\nimport java.util.zip.ZipEntry\nimport java.util.zip.ZipInputStream\n\n@CompileStatic\nclass ExecutionContextFactoryImpl implements ExecutionContextFactory {\n    protected final static Logger logger = LoggerFactory.getLogger(ExecutionContextFactoryImpl.class)\n    protected final static boolean isTraceEnabled = logger.isTraceEnabled()\n    \n    private AtomicBoolean destroyed = new AtomicBoolean(false)\n\n    public final long initStartTime\n    public final String initStartHex\n    protected String runtimePath\n    @SuppressWarnings(\"GrFinalVariableAccess\") protected final String runtimeConfPath\n    @SuppressWarnings(\"GrFinalVariableAccess\") protected final MNode confXmlRoot\n    protected MNode serverStatsNode\n    protected String moquiVersion = \"\"\n    protected Map versionMap = null\n    protected InetAddress localhostAddress = null\n\n    protected MClassLoader moquiClassLoader\n    protected GroovyClassLoader groovyClassLoader\n    protected CompilerConfiguration groovyCompilerConf\n    // NOTE: this is experimental, don't set to true! still issues with unique class names, etc\n    // also issue with how to support recompile of actions on change, could just use for expressions but that only helps so much\n    // maybe some way to load from disk only if timestamp newer for XmlActions and GroovyScriptRunner\n    // this could be driven by setting in Moqui Conf XML file\n    // also need to clean out runtime/script-classes in gradle cleanAll\n    protected boolean groovyCompileCacheToDisk = false\n\n    protected LinkedHashMap<String, ComponentInfo> componentInfoMap = new LinkedHashMap<>()\n    public final ThreadLocal<ExecutionContextImpl> activeContext = new ThreadLocal<>()\n    public final Map<Long, ExecutionContextImpl> activeContextMap = new HashMap<>()\n    protected final LinkedHashMap<String, ToolFactory> toolFactoryMap = new LinkedHashMap<>()\n\n    protected final Map<String, WebappInfo> webappInfoMap = new HashMap<>()\n    protected final List<NotificationMessageListener> registeredNotificationMessageListeners = []\n\n    protected final Map<String, ArtifactStatsInfo> artifactStatsInfoByType = new HashMap<>()\n    public final Map<ArtifactType, Boolean> artifactTypeAuthzEnabled = new EnumMap<ArtifactType, Boolean>(ArtifactType.class)\n    public final Map<ArtifactType, Boolean> artifactTypeTarpitEnabled = new EnumMap<ArtifactType, Boolean>(ArtifactType.class)\n\n    protected String skipStatsCond\n    protected long hitBinLengthMillis = 900000 // 15 minute default\n    private final EnumMap<ArtifactType, Boolean> artifactPersistHitByTypeEnum = new EnumMap<ArtifactType, Boolean>(ArtifactType.class)\n    private final EnumMap<ArtifactType, Boolean> artifactPersistBinByTypeEnum = new EnumMap<ArtifactType, Boolean>(ArtifactType.class)\n    final ConcurrentLinkedQueue<ArtifactHitInfo> deferredHitInfoQueue = new ConcurrentLinkedQueue<ArtifactHitInfo>()\n\n    /** The SecurityManager for Apache Shiro */\n    protected SecurityManager internalSecurityManager\n    /** The ServletContext, if Moqui was initialized in a webapp (generally through MoquiContextListener) */\n    protected ServletContext internalServletContext = null\n    /** The WebSocket ServerContainer, if found in 'jakarta.websocket.server.ServerContainer' ServletContext attribute */\n    protected ServerContainer internalServerContainer = null\n\n    /** Notification Message Topic (for distributed notifications) */\n    private SimpleTopic<NotificationMessageImpl> notificationMessageTopic = null\n    private NotificationWebSocketListener notificationWebSocketListener = new NotificationWebSocketListener()\n\n    protected ArrayList<LogEventSubscriber> logEventSubscribers = new ArrayList<>()\n\n    // ======== Permanent Delegated Facades ========\n    @SuppressWarnings(\"GrFinalVariableAccess\") public final CacheFacadeImpl cacheFacade\n    @SuppressWarnings(\"GrFinalVariableAccess\") public final LoggerFacadeImpl loggerFacade\n    @SuppressWarnings(\"GrFinalVariableAccess\") public final ResourceFacadeImpl resourceFacade\n    @SuppressWarnings(\"GrFinalVariableAccess\") public final TransactionFacadeImpl transactionFacade\n    @SuppressWarnings(\"GrFinalVariableAccess\") public final EntityFacadeImpl entityFacade\n    @SuppressWarnings(\"GrFinalVariableAccess\") public final ElasticFacadeImpl elasticFacade\n    @SuppressWarnings(\"GrFinalVariableAccess\") public final ServiceFacadeImpl serviceFacade\n    @SuppressWarnings(\"GrFinalVariableAccess\") public final ScreenFacadeImpl screenFacade\n\n    /** The main worker pool for services, running async closures and runnables, etc */\n    @SuppressWarnings(\"GrFinalVariableAccess\") public final ThreadPoolExecutor workerPool\n    /** An executor for the scheduled job runner */\n    @SuppressWarnings(\"GrFinalVariableAccess\") public final CustomScheduledExecutor scheduledExecutor\n    public final ArrayList<ScheduledRunnableInfo> scheduledRunnableList = new ArrayList<>()\n\n    /**\n     * This constructor gets runtime directory and conf file location from a properties file on the classpath so that\n     * it can initialize on its own. This is the constructor to be used by the ServiceLoader in the Moqui.java file,\n     * or by init methods in a servlet or context filter or OSGi component or Spring component or whatever.\n     */\n    ExecutionContextFactoryImpl() {\n        initStartTime = System.currentTimeMillis()\n        // 1609900441000 (decimal 13 chars) = 176D58B3DA8 (hex 11 chars) take 7 means leave off 4 hex chars which is 65536ms which is ~1 minute (ie server start time round floor to ~1 min)\n        initStartHex = Long.toHexString(initStartTime).take(7)\n\n        // get the MoquiInit.properties file\n        Properties moquiInitProperties = new Properties()\n        URL initProps = this.class.getClassLoader().getResource(\"MoquiInit.properties\")\n        if (initProps != null) { InputStream is = initProps.openStream(); moquiInitProperties.load(is); is.close() }\n\n        // if there is a system property use that, otherwise from the properties file\n        runtimePath = System.getProperty(\"moqui.runtime\")\n        if (!runtimePath) {\n            runtimePath = moquiInitProperties.getProperty(\"moqui.runtime\")\n            // if there was no system property set one, make sure at least something is always set for conf files/etc\n            if (runtimePath) System.setProperty(\"moqui.runtime\", runtimePath)\n        }\n        if (!runtimePath) throw new IllegalArgumentException(\"No moqui.runtime property found in MoquiInit.properties or in a system property (with: -Dmoqui.runtime=... on the command line)\")\n        if (runtimePath.endsWith(\"/\")) runtimePath = runtimePath.substring(0, runtimePath.length()-1)\n\n        // check the runtime directory via File\n        File runtimeFile = new File(runtimePath)\n        if (runtimeFile.exists()) { runtimePath = runtimeFile.getCanonicalPath() }\n        else { throw new IllegalArgumentException(\"The moqui.runtime path [${runtimePath}] was not found.\") }\n\n        // get the moqui configuration file path\n        String confPartialPath = System.getProperty(\"moqui.conf\")\n        if (!confPartialPath) confPartialPath = moquiInitProperties.getProperty(\"moqui.conf\")\n        if (!confPartialPath) throw new IllegalArgumentException(\"No moqui.conf property found in MoquiInit.properties or in a system property (with: -Dmoqui.conf=... on the command line)\")\n\n        String confFullPath\n        if (confPartialPath.startsWith(\"/\")) {\n            confFullPath = confPartialPath\n        } else {\n            confFullPath = runtimePath + \"/\" + confPartialPath\n        }\n        // setup the confFile\n        File confFile = new File(confFullPath)\n        if (confFile.exists()) {\n            runtimeConfPath = confFullPath\n        } else {\n            runtimeConfPath = null\n            throw new IllegalArgumentException(\"The moqui.conf path [${confFullPath}] was not found.\")\n        }\n\n        // sleep here to attach profiler before init: sleep(30000)\n\n        // initialize all configuration, get various conf files merged and load components\n        MNode runtimeConfXmlRoot = MNode.parse(confFile)\n        MNode baseConfigNode = initBaseConfig(runtimeConfXmlRoot)\n        // init components before initConfig() so component configuration files can be incorporated\n        initComponents(baseConfigNode)\n        // init the configuration (merge from component and runtime conf files)\n        confXmlRoot = initConfig(baseConfigNode, runtimeConfXmlRoot)\n\n        reconfigureLog4j()\n        workerPool = makeWorkerPool()\n        scheduledExecutor = makeScheduledExecutor()\n\n        preFacadeInit()\n\n        // this init order is important as some facades will use others\n        cacheFacade = new CacheFacadeImpl(this)\n        logger.info(\"Cache Facade initialized\")\n        loggerFacade = new LoggerFacadeImpl(this)\n        // logger.info(\"Logger Facade initialized\")\n        resourceFacade = new ResourceFacadeImpl(this)\n        logger.info(\"Resource Facade initialized\")\n\n        transactionFacade = new TransactionFacadeImpl(this)\n        logger.info(\"Transaction Facade initialized\")\n        entityFacade = new EntityFacadeImpl(this)\n        logger.info(\"Entity Facade initialized\")\n        serviceFacade = new ServiceFacadeImpl(this)\n        logger.info(\"Service Facade initialized\")\n        screenFacade = new ScreenFacadeImpl(this)\n        logger.info(\"Screen Facade initialized\")\n\n        postFacadeInit()\n\n        // NOTE: ElasticFacade init after postFacadeInit() so finds embedded from moqui-elasticsearch if present, can move up once moqui-elasticsearch deprecated\n        elasticFacade = new ElasticFacadeImpl(this)\n        logger.info(\"Elastic Facade initialized\")\n\n        logger.info(\"Execution Context Factory initialized in ${(System.currentTimeMillis() - initStartTime)/1000} seconds\")\n    }\n\n    /** This constructor takes the runtime directory path and conf file path directly. */\n    ExecutionContextFactoryImpl(String runtimePathParm, String confPathParm) {\n        initStartTime = System.currentTimeMillis()\n        // 1609900441000 (decimal 13 chars) = 176D58B3DA8 (hex 11 chars) take 7 means leave off 4 hex chars which is 65536ms which is ~1 minute (ie server start time round floor to ~1 min)\n        initStartHex = Long.toHexString(initStartTime).take(7)\n\n        // setup the runtimeFile\n        File runtimeFile = new File(runtimePathParm)\n        if (!runtimeFile.exists()) throw new IllegalArgumentException(\"The moqui.runtime path [${runtimePathParm}] was not found.\")\n\n        // setup the confFile\n        if (runtimePathParm.endsWith('/')) runtimePathParm = runtimePathParm.substring(0, runtimePathParm.length()-1)\n        if (confPathParm.startsWith('/')) confPathParm = confPathParm.substring(1)\n        String confFullPath = runtimePathParm + '/' + confPathParm\n        File confFile = new File(confFullPath)\n        if (!confFile.exists()) throw new IllegalArgumentException(\"The moqui.conf path [${confFullPath}] was not found.\")\n\n        runtimePath = runtimePathParm\n        runtimeConfPath = confFullPath\n\n        // initialize all configuration, get various conf files merged and load components\n        MNode runtimeConfXmlRoot = MNode.parse(confFile)\n        MNode baseConfigNode = initBaseConfig(runtimeConfXmlRoot)\n        // init components before initConfig() so component configuration files can be incorporated\n        initComponents(baseConfigNode)\n        // init the configuration (merge from component and runtime conf files)\n        confXmlRoot = initConfig(baseConfigNode, runtimeConfXmlRoot)\n\n        reconfigureLog4j()\n        workerPool = makeWorkerPool()\n        scheduledExecutor = makeScheduledExecutor()\n\n        preFacadeInit()\n\n        // this init order is important as some facades will use others\n        cacheFacade = new CacheFacadeImpl(this)\n        logger.info(\"Cache Facade initialized\")\n        loggerFacade = new LoggerFacadeImpl(this)\n        // logger.info(\"LoggerFacadeImpl initialized\")\n        resourceFacade = new ResourceFacadeImpl(this)\n        logger.info(\"Resource Facade initialized\")\n\n        transactionFacade = new TransactionFacadeImpl(this)\n        logger.info(\"Transaction Facade initialized\")\n        entityFacade = new EntityFacadeImpl(this)\n        logger.info(\"Entity Facade initialized\")\n        serviceFacade = new ServiceFacadeImpl(this)\n        logger.info(\"Service Facade initialized\")\n        screenFacade = new ScreenFacadeImpl(this)\n        logger.info(\"Screen Facade initialized\")\n\n        postFacadeInit()\n\n        // NOTE: ElasticFacade init after postFacadeInit() so finds embedded from moqui-elasticsearch if present, can move up once moqui-elasticsearch deprecated\n        elasticFacade = new ElasticFacadeImpl(this)\n        logger.info(\"Elastic Facade initialized\")\n\n        logger.info(\"Execution Context Factory initialized in ${(System.currentTimeMillis() - initStartTime)/1000} seconds\")\n    }\n\n    protected void reconfigureLog4j() {\n        URL log4j2Url = this.class.getClassLoader().getResource(\"log4j2.xml\")\n        if (log4j2Url == null) {\n            logger.warn(\"No log4j2.xml file found on the classpath, no reconfiguring Log4J\")\n            return\n        }\n        final LoggerContext ctx = (LoggerContext) LogManager.getContext(true)\n        ctx.setConfigLocation(log4j2Url.toURI())\n    }\n\n    protected MNode initBaseConfig(MNode runtimeConfXmlRoot) {\n        String version = this.class.getPackage().getImplementationVersion()\n        if (version != null) moquiVersion = version\n        /*\n        Enumeration<URL> resources = getClass().getClassLoader().getResources(\"META-INF/MANIFEST.MF\")\n        while (resources.hasMoreElements()) {\n            try {\n                Manifest manifest = new Manifest(resources.nextElement().openStream())\n                Attributes attributes = manifest.getMainAttributes()\n                String implTitle = attributes.getValue(\"Implementation-Title\")\n                String implVendor = attributes.getValue(\"Implementation-Vendor\")\n                if (\"Moqui Framework\".equals(implTitle) && \"Moqui Ecosystem\".equals(implVendor)) {\n                    moquiVersion = attributes.getValue(\"Implementation-Version\")\n                    break\n                }\n            } catch (IOException e) {\n                logger.info(\"Error reading manifest files\", e)\n            }\n        }\n        */\n        System.setProperty(\"moqui.version\", moquiVersion)\n\n        // don't set the moqui.runtime and moqui.conf system properties as before, causes conflict with multiple moqui instances in one JVM\n        // NOTE: moqui.runtime is set in MoquiStart and in MoquiContextListener (if there is an embedded runtime directory)\n        // System.setProperty(\"moqui.runtime\", runtimePath)\n        // System.setProperty(\"moqui.conf\", runtimeConfPath)\n\n        logger.info(\"Initializing Moqui Framework version ${moquiVersion ?: 'Unknown'}\\n - runtime directory: ${this.runtimePath}\\n - runtime config:    ${this.runtimeConfPath}\")\n        logger.info(\"Running on Java ${System.getProperty(\"java.version\")} VM ${System.getProperty(\"java.vm.version\")} Runtime ${System.getProperty(\"java.runtime.version\")}\")\n\n        URL defaultConfUrl = this.class.getClassLoader().getResource(\"MoquiDefaultConf.xml\")\n        if (defaultConfUrl == null) throw new IllegalArgumentException(\"Could not find MoquiDefaultConf.xml file on the classpath\")\n        MNode newConfigXmlRoot = MNode.parse(defaultConfUrl.toString(), defaultConfUrl.newInputStream())\n\n        // just merge the component configuration, needed before component init is done\n        mergeConfigComponentNodes(newConfigXmlRoot, runtimeConfXmlRoot)\n\n        return newConfigXmlRoot\n    }\n    protected void initComponents(MNode baseConfigNode) {\n        File versionJsonFile = new File(runtimePath + \"/version.json\")\n        if (versionJsonFile.exists()) {\n            try {\n                versionMap = (Map) new JsonSlurper().parse(versionJsonFile)\n            } catch (Exception e) {\n                logger.warn(\"Error parsion runtime/version.json\", e)\n            }\n        }\n\n        // init components referred to in component-list.component and component-dir elements in the conf file\n        for (MNode childNode in baseConfigNode.first(\"component-list\").children) {\n            if (\"component\".equals(childNode.name)) {\n                addComponent(new ComponentInfo(null, childNode, this))\n            } else if (\"component-dir\".equals(childNode.name)) {\n                addComponentDir(childNode.attribute(\"location\"))\n            }\n        }\n        checkSortDependentComponents()\n    }\n    protected MNode initConfig(MNode baseConfigNode, MNode runtimeConfXmlRoot) {\n        // merge any config files in components\n        for (ComponentInfo ci in componentInfoMap.values()) {\n            ResourceReference compXmlRr = ci.componentRr.getChild(\"MoquiConf.xml\")\n            if (compXmlRr.getExists()) {\n                logger.info(\"Merging MoquiConf.xml file from component ${ci.name}\")\n                MNode compXmlNode = MNode.parse(compXmlRr)\n                mergeConfigNodes(baseConfigNode, compXmlNode)\n            }\n        }\n\n        // merge the runtime conf file into the default one to override any settings (they both have the same root node, go from there)\n        logger.info(\"Merging runtime configuration at ${runtimeConfPath}\")\n        mergeConfigNodes(baseConfigNode, runtimeConfXmlRoot)\n\n        // set default System properties now that all is merged\n        for (MNode defPropNode in baseConfigNode.children(\"default-property\")) {\n            String propName = defPropNode.attribute(\"name\")\n            String isSecretAttr = defPropNode.attribute(\"is-secret\")\n            boolean isSecret = !\"false\".equals(isSecretAttr) &&\n                    (\"true\".equals(isSecretAttr) || propName.contains(\"pass\") || propName.contains(\"pw\") || propName.contains(\"key\"))\n            if (System.getProperty(propName)) {\n                if (isSecret) {\n                    logger.info(\"Found secret property ${propName}, not setting from env var or default\")\n                } else {\n                    logger.info(\"Found property ${propName} with value [${System.getProperty(propName)}], not setting from env var or default\")\n                }\n            } else if (System.getenv(propName)) {\n                // make env vars available as Java System properties\n                System.setProperty(propName, System.getenv(propName))\n                if (isSecret) {\n                    logger.info(\"Setting secret property ${propName} from env var\")\n                } else {\n                    logger.info(\"Setting property ${propName} from env var with value [${System.getProperty(propName)}]\")\n                }\n            } else {\n                String valueAttr = defPropNode.attribute(\"value\")\n                if (valueAttr != null && !valueAttr.isEmpty()) {\n                    System.setProperty(propName, SystemBinding.expand(valueAttr))\n                    if (isSecret) {\n                        logger.info(\"Setting secret property ${propName} from default\")\n                    } else {\n                        logger.info(\"Setting property ${propName} from default with value [${System.getProperty(propName)}]\")\n                    }\n                }\n            }\n        }\n\n        // if there are default_locale or default_time_zone Java props or system env vars set defaults\n        String localeStr = SystemBinding.getPropOrEnv(\"default_locale\")\n        if (localeStr) {\n            try {\n                int usIdx = localeStr.indexOf(\"_\")\n                Locale.setDefault(usIdx < 0 ? new Locale(localeStr) :\n                        new Locale(localeStr.substring(0, usIdx), localeStr.substring(usIdx+1).toUpperCase()))\n            } catch (Throwable t) {\n                logger.error(\"Error setting default locale to ${localeStr}: ${t.toString()}\")\n            }\n        }\n        String tzStr = SystemBinding.getPropOrEnv(\"default_time_zone\")\n        if (tzStr) {\n            try {\n                logger.info(\"Found default_time_zone ${tzStr}: ${TimeZone.getTimeZone(tzStr)}\")\n                TimeZone.setDefault(TimeZone.getTimeZone(tzStr))\n            } catch (Throwable t) {\n                logger.error(\"Error setting default time zone to ${tzStr}: ${t.toString()}\")\n            }\n        }\n        logger.info(\"Default locale ${Locale.getDefault()}, time zone ${TimeZone.getDefault()}\")\n\n        return baseConfigNode\n    }\n\n    private ThreadPoolExecutor makeWorkerPool() {\n        MNode toolsNode = confXmlRoot.first('tools')\n\n        int workerQueueSize = (toolsNode.attribute(\"worker-queue\") ?: \"65536\") as int\n        BlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<>(workerQueueSize)\n\n        int coreSize = (toolsNode.attribute(\"worker-pool-core\") ?: \"16\") as int\n        int maxSize = (toolsNode.attribute(\"worker-pool-max\") ?: \"32\") as int\n        int availableProcessorsSize = Runtime.getRuntime().availableProcessors() * 3\n        if (availableProcessorsSize > maxSize) {\n            logger.info(\"Setting worker pool size to ${availableProcessorsSize} based on available processors * 3\")\n            maxSize = availableProcessorsSize\n        }\n        long aliveTime = (toolsNode.attribute(\"worker-pool-alive\") ?: \"60\") as long\n\n        logger.info(\"Initializing worker ThreadPoolExecutor: queue limit ${workerQueueSize}, pool-core ${coreSize}, pool-max ${maxSize}, pool-alive ${aliveTime}s\")\n        return new ContextJavaUtil.WorkerThreadPoolExecutor(this, coreSize, maxSize, aliveTime, TimeUnit.SECONDS,\n                workQueue, new ContextJavaUtil.WorkerThreadFactory())\n    }\n    boolean waitWorkerPoolEmpty(int retryLimit) {\n        ThreadPoolExecutor jobWorkerPool = serviceFacade.jobWorkerPool\n        int count = 0\n        while (count < retryLimit && (workerPool.getQueue().size() > 0 || workerPool.getActiveCount() > 0 ||\n                jobWorkerPool.getQueue().size() > 0 || jobWorkerPool.getActiveCount() > 0)) {\n            if (count % 10 == 0) logger.warn(\"Wait for workerPool and jobWorkerPool empty: worker queue size ${workerPool.getQueue().size()} active ${workerPool.getActiveCount()} max threads ${workerPool.getMaximumPoolSize()}; service job queue size ${jobWorkerPool.getQueue().size()} active ${jobWorkerPool.getActiveCount()}\")\n            Thread.sleep(100)\n            count++\n        }\n        int afterSize = workerPool.getQueue().size() + workerPool.getActiveCount()\n        int jobAfterSize = jobWorkerPool.getQueue().size() + jobWorkerPool.getActiveCount()\n        if (afterSize > 0 || jobAfterSize > 0) logger.warn(\"After ${retryLimit} 100ms waits worker pool size is ${afterSize} and service job pool size is ${jobAfterSize}\")\n        return afterSize == 0 && jobAfterSize == 0\n    }\n\n    private CustomScheduledExecutor makeScheduledExecutor() {\n        // TODO: make the scheduled thread pool core and max sizes configurable? so far only used for a small number of scheduled Runnables\n        CustomScheduledExecutor executor = new CustomScheduledExecutor(2)\n        executor.setMaximumPoolSize(8)\n        return executor\n    }\n    void scheduleAtFixedRate(Runnable command, long initialDelaySeconds, long periodSeconds) {\n        // NOTE: actually returns an inaccessible class: ScheduledThreadPoolExecutor$ScheduledFutureTask\n        ScheduledFuture scheduledFuture = this.scheduledExecutor.scheduleAtFixedRate(command, initialDelaySeconds, periodSeconds, TimeUnit.SECONDS)\n        this.scheduledRunnableList.add(new ScheduledRunnableInfo(command, periodSeconds))\n    }\n    void scheduledReInit() {\n        for (ScheduledRunnableInfo runnableInfo in this.scheduledRunnableList) {\n            String commandClass = runnableInfo.command.class.name\n            logger.warn(\"Removing scheduled runnable ${commandClass}\")\n            BlockingQueue<Runnable> queue = this.scheduledExecutor.getQueue()\n            for (Runnable qr in queue) {\n                if (qr instanceof ContextJavaUtil.CustomScheduledTask) {\n                    ContextJavaUtil.CustomScheduledTask task = (ContextJavaUtil.CustomScheduledTask) qr\n                    if (task.runnable != null && task.runnable.class.name == commandClass) {\n                        logger.warn(\"Removing scheduled runnable ${commandClass} - found matching task, removing\")\n                        boolean removed = scheduledExecutor.remove(task)\n                        logger.warn(\"Removed scheduled runnable ${commandClass}, was present? ${removed}\")\n                    }\n                }\n            }\n\n            logger.warn(\"Adding scheduled runnable ${commandClass} period ${runnableInfo.period}s\")\n            this.scheduledExecutor.scheduleAtFixedRate(runnableInfo.command, 0, runnableInfo.period, TimeUnit.SECONDS)\n        }\n    }\n\n    private void preFacadeInit() {\n        // save the current configuration in a file for debugging/reference\n        File confSaveFile = new File(runtimePath + \"/log/MoquiActualConf.xml\")\n        try {\n            if (confSaveFile.exists()) confSaveFile.delete()\n            if (!confSaveFile.parentFile.exists()) confSaveFile.parentFile.mkdirs()\n            FileWriter fw = new FileWriter(confSaveFile)\n            fw.write(confXmlRoot.toString())\n            fw.close()\n        } catch (Exception e) {\n            logger.warn(\"Could not save ${confSaveFile.absolutePath} file: ${e.toString()}\")\n        }\n\n        // get localhost address for ongoing use\n        try {\n            localhostAddress = InetAddress.getLocalHost()\n        } catch (UnknownHostException e) {\n            logger.warn(\"Could not get localhost address\", new BaseException(\"Could not get localhost address\", e))\n        }\n\n        // init ClassLoader early so that classpath:// resources and framework interface impls will work\n        initClassLoader()\n\n        // do these after initComponents as that may override configuration\n        serverStatsNode = confXmlRoot.first('server-stats')\n        skipStatsCond = serverStatsNode.attribute(\"stats-skip-condition\")\n        String binLengthAttr = serverStatsNode.attribute(\"bin-length-seconds\")\n        if (binLengthAttr != null && !binLengthAttr.isEmpty()) hitBinLengthMillis = (binLengthAttr as long)*1000\n        // populate ArtifactType configurations\n        for (ArtifactType at in ArtifactType.values()) {\n            MNode artifactStats = getArtifactStatsNode(at.name(), null)\n            if (artifactStats == null) {\n                artifactPersistHitByTypeEnum.put(at, Boolean.FALSE)\n                artifactPersistBinByTypeEnum.put(at, Boolean.FALSE)\n            } else {\n                artifactPersistHitByTypeEnum.put(at, \"true\".equals(artifactStats.attribute(\"persist-hit\")))\n                artifactPersistBinByTypeEnum.put(at, \"true\".equals(artifactStats.attribute(\"persist-bin\")))\n            }\n            MNode aeNode = getArtifactExecutionNode(at.name())\n            if (aeNode == null) {\n                artifactTypeAuthzEnabled.put(at, true)\n                artifactTypeTarpitEnabled.put(at, true)\n            } else {\n                artifactTypeAuthzEnabled.put(at, !\"false\".equals(aeNode.attribute(\"authz-enabled\")))\n                artifactTypeTarpitEnabled.put(at, !\"false\".equals(aeNode.attribute(\"tarpit-enabled\")))\n            }\n        }\n\n        // register notificationWebSocketListener\n        registerNotificationMessageListener(notificationWebSocketListener)\n\n        // Load ToolFactory implementations from tools.tool-factory elements, run preFacadeInit() methods\n        ArrayList<Map<String, String>> toolFactoryAttrsList = new ArrayList<>()\n        for (MNode toolFactoryNode in confXmlRoot.first(\"tools\").children(\"tool-factory\")) {\n            if (toolFactoryNode.attribute(\"disabled\") == \"true\") {\n                logger.info(\"Not loading disabled ToolFactory with class: ${toolFactoryNode.attribute(\"class\")}\")\n                continue\n            }\n            toolFactoryAttrsList.add(toolFactoryNode.getAttributes())\n        }\n        CollectionUtilities.orderMapList(toolFactoryAttrsList as List<Map>, [\"init-priority\", \"class\"])\n        for (Map<String, String> toolFactoryAttrs in toolFactoryAttrsList) {\n            String tfClass = toolFactoryAttrs.get(\"class\")\n            logger.info(\"Loading ToolFactory with class: ${tfClass}\")\n            try {\n                ToolFactory tf = (ToolFactory) Thread.currentThread().getContextClassLoader().loadClass(tfClass).newInstance()\n                tf.preFacadeInit(this)\n                toolFactoryMap.put(tf.getName(), tf)\n            } catch (Throwable t) {\n                logger.error(\"Error loading ToolFactory with class ${tfClass}\", t)\n            }\n        }\n    }\n\n    private void postFacadeInit() {\n        entityFacade.postFacadeInit()\n        serviceFacade.postFacadeInit()\n\n        // Warm cache on start if configured to do so\n        if (confXmlRoot.first(\"cache-list\").attribute(\"warm-on-start\") != \"false\") warmCache()\n\n        // Run init() in ToolFactory implementations from tools.tool-factory elements\n        Iterator<Map.Entry<String, ToolFactory>> tfIterator = toolFactoryMap.entrySet().iterator()\n        while (tfIterator.hasNext()) {\n            Map.Entry<String, ToolFactory> tfEntry = tfIterator.next()\n            ToolFactory tf = tfEntry.getValue()\n            logger.info(\"Initializing ToolFactory: ${tf.getName()}\")\n            try {\n                tf.init(this)\n            } catch (Throwable t) {\n                logger.error(\"Error initializing ToolFactory ${tf.getName()}\", t)\n                tfIterator.remove()\n            }\n        }\n\n        // Notification Message Topic\n        String notificationTopicFactory = confXmlRoot.first(\"tools\").attribute(\"notification-topic-factory\")\n        if (notificationTopicFactory) {\n            try {\n                notificationMessageTopic = (SimpleTopic<NotificationMessageImpl>) getTool(notificationTopicFactory, SimpleTopic.class)\n            } catch (Throwable t) {\n                logger.error(\"Error initializing notification-topic-factory ${notificationTopicFactory}\", t)\n            }\n        }\n\n        // schedule DeferredHitInfoFlush (every 5 seconds, after 10 second init delay)\n        DeferredHitInfoFlush dhif = new DeferredHitInfoFlush(this)\n        this.scheduleAtFixedRate(dhif, 10, 5)\n\n        // all config loaded, save memory by clearing the parsed MNode cache, especially for production mode\n        MNode.clearParsedNodeCache()\n        // bunch of junk in memory, trigger gc (to happen soon, when JVM decides, not immediate)\n        System.gc()\n    }\n\n    void warmCache() {\n        this.entityFacade.warmCache()\n        this.serviceFacade.warmCache()\n        this.screenFacade.warmCache()\n    }\n\n    /** Setup the cached ClassLoader, this should init in the main thread so we can set it properly */\n    private void initClassLoader() {\n        long startTime = System.currentTimeMillis()\n        MClassLoader.addCommonClass(\"org.moqui.entity.EntityValue\", EntityValue.class)\n        MClassLoader.addCommonClass(\"EntityValue\", EntityValue.class)\n        MClassLoader.addCommonClass(\"org.moqui.entity.EntityList\", EntityList.class)\n        MClassLoader.addCommonClass(\"EntityList\", EntityList.class)\n\n        logger.info(\"Initializing MClassLoader context ${Thread.currentThread().getContextClassLoader()?.class?.name} cur class ${this.class.classLoader?.class?.name} system ${System.classLoader?.class?.name}\")\n        ClassLoader pcl = (Thread.currentThread().getContextClassLoader() ?: this.class.classLoader) ?: System.classLoader\n        moquiClassLoader = new MClassLoader(pcl)\n        logger.info(\"Initialized MClassLoader with parent ${pcl.class.name}\")\n        // NOTE: initialized here but NOT used as currentThread ClassLoader\n        groovyClassLoader = new GroovyClassLoader(moquiClassLoader)\n\n        File scriptClassesDir = new File(runtimePath + \"/script-classes\")\n        scriptClassesDir.mkdirs()\n        if (groovyCompileCacheToDisk) moquiClassLoader.addClassesDirectory(scriptClassesDir)\n        groovyCompilerConf = new CompilerConfiguration()\n        groovyCompilerConf.setTargetDirectory(scriptClassesDir)\n\n        // add runtime/classes jar files to the class loader\n        File runtimeClassesFile = new File(runtimePath + \"/classes\")\n        if (runtimeClassesFile.exists()) {\n            moquiClassLoader.addClassesDirectory(runtimeClassesFile)\n        }\n        // add runtime/lib jar files to the class loader\n        File runtimeLibFile = new File(runtimePath + \"/lib\")\n        if (runtimeLibFile.exists()) for (File jarFile: runtimeLibFile.listFiles()) {\n            if (jarFile.getName().endsWith(\".jar\")) {\n                moquiClassLoader.addJarFile(new JarFile(jarFile), jarFile.toURI().toURL())\n                logger.info(\"Added JAR from runtime/lib: ${jarFile.getName()}\")\n            }\n        }\n\n        // add <component>/classes and <component>/lib jar files to the class loader now that component locations loaded\n        for (ComponentInfo ci in componentInfoMap.values()) {\n            ResourceReference classesRr = ci.componentRr.getChild(\"classes\")\n            if (classesRr.exists && classesRr.supportsDirectory() && classesRr.isDirectory()) {\n                moquiClassLoader.addClassesDirectory(new File(classesRr.getUrl().getPath()))\n            }\n\n            ResourceReference libRr = ci.componentRr.getChild(\"lib\")\n            if (libRr.exists && libRr.supportsDirectory() && libRr.isDirectory()) {\n                Set<String> jarsLoaded = new LinkedHashSet<>()\n                for (ResourceReference jarRr: libRr.getDirectoryEntries()) {\n                    if (jarRr.fileName.endsWith(\".jar\")) {\n                        try {\n                            moquiClassLoader.addJarFile(new JarFile(new File(jarRr.getUrl().getPath())), jarRr.getUrl())\n                            jarsLoaded.add(jarRr.getFileName())\n                        } catch (Exception e) {\n                            logger.error(\"Could not load JAR from component ${ci.name}: ${jarRr.getLocation()}: ${e.toString()}\")\n                        }\n                    }\n                }\n                logger.info(\"Added JARs from component ${ci.name}: ${jarsLoaded}\")\n            }\n        }\n\n        // clear not found info just in case anything was falsely added\n        moquiClassLoader.clearNotFoundInfo()\n        // set as context classloader\n        Thread.currentThread().setContextClassLoader(moquiClassLoader)\n\n        logger.info(\"Initialized ClassLoaders in ${System.currentTimeMillis() - startTime}ms\")\n    }\n\n    @Override boolean checkEmptyDb() {\n        /* NOTE: Called from Moqui.dynamicInit() after ECFI init (which is also called from MoquiContextListener.contextInitialized()) */\n        MNode toolsNode = confXmlRoot.first(\"tools\")\n        toolsNode.setSystemExpandAttributes(true)\n\n        boolean needsRestartEcfi = false\n        boolean emptyDbLoadRan = false\n\n        // if empty-db-load has a value and is not 'none' then load those\n        String emptyDbLoad = toolsNode.attribute(\"empty-db-load\")\n        if (emptyDbLoad && emptyDbLoad != 'none') {\n            long enumCount = getEntity().find(\"moqui.basic.Enumeration\").disableAuthz().count()\n            if (enumCount == 0) {\n                logger.info(\"Found ${enumCount} Enumeration records, loading empty-db-load data types (${emptyDbLoad})\")\n\n                ExecutionContext ec = getExecutionContext()\n                try {\n                    ec.getArtifactExecution().disableAuthz()\n                    ec.getArtifactExecution().push(\"loadDataEmptyDb\", ArtifactExecutionInfo.AT_OTHER, ArtifactExecutionInfo.AUTHZA_ALL, false)\n                    ec.getArtifactExecution().setAnonymousAuthorizedAll()\n                    ec.getUser().loginAnonymousIfNoUser()\n\n                    EntityDataLoader edl = ec.getEntity().makeDataLoader()\n                    if (emptyDbLoad != 'all') edl.dataTypes(new HashSet(emptyDbLoad.split(\",\") as List))\n\n                    try {\n                        long startTime = System.currentTimeMillis()\n                        long records = edl.load()\n\n                        logger.info(\"Loaded [${records}] records (with types from empty-db-load: ${emptyDbLoad}) in ${(System.currentTimeMillis() - startTime)/1000} seconds.\")\n                    } catch (Throwable t) {\n                        logger.error(\"Error loading empty DB data (with types: ${emptyDbLoad})\", t)\n                    }\n\n                } finally {\n                    ec.destroy()\n                }\n\n                needsRestartEcfi = true\n                emptyDbLoadRan = true\n            } else {\n                logger.info(\"Found ${enumCount} Enumeration records, NOT loading empty-db-load data types (${emptyDbLoad})\")\n            }\n        }\n\n        // if on-start-load-types has a value and is not 'none' then load those\n        String onStartLoadTypes = toolsNode.attribute(\"on-start-load-types\")\n        String onStartLoadComponents = toolsNode.attribute(\"on-start-load-components\")\n        if (!emptyDbLoadRan && onStartLoadTypes && onStartLoadTypes != 'none') {\n            logger.info(\"Loading on-start-load-types data types [${onStartLoadTypes}] and components [${onStartLoadComponents ?: 'all'}]\")\n\n            ExecutionContext ec = getExecutionContext()\n            try {\n                ec.getArtifactExecution().disableAuthz()\n                ec.getArtifactExecution().push(\"loadDataOnStart\", ArtifactExecutionInfo.AT_OTHER, ArtifactExecutionInfo.AUTHZA_ALL, false)\n                ec.getArtifactExecution().setAnonymousAuthorizedAll()\n                ec.getUser().loginAnonymousIfNoUser()\n\n                EntityDataLoader edl = ec.getEntity().makeDataLoader()\n                if (onStartLoadTypes != 'all') edl.dataTypes(new HashSet(onStartLoadTypes.split(\",\") as List))\n                if (onStartLoadComponents && onStartLoadComponents != 'all') edl.componentNameList(onStartLoadComponents.split(\",\") as List)\n\n                try {\n                    long startTime = System.currentTimeMillis()\n                    long records = edl.load()\n\n                    logger.info(\"Loaded [${records}] records (with types from on-start-load-types: [${onStartLoadTypes}] components: [${onStartLoadComponents ?: 'all'}]) in ${(System.currentTimeMillis() - startTime)/1000} seconds.\")\n                } catch (Throwable t) {\n                    logger.error(\"Error loading on-start DB data (with types: [${onStartLoadTypes}] components: [${onStartLoadComponents ?: 'all'}])\", t)\n                }\n\n            } finally {\n                ec.destroy()\n            }\n\n            needsRestartEcfi = true\n        }\n\n        // if this instance_purpose is test load type 'test' data\n        if (\"test\".equals(System.getProperty(\"instance_purpose\"))) {\n            logger.warn(\"Loading 'test' type data (because instance_purpose=test)\")\n            ExecutionContext ec = getExecutionContext()\n            try {\n                ec.getArtifactExecution().disableAuthz()\n                ec.getArtifactExecution().push(\"loadDataTest\", ArtifactExecutionInfo.AT_OTHER, ArtifactExecutionInfo.AUTHZA_ALL, false)\n                ec.getArtifactExecution().setAnonymousAuthorizedAll()\n                ec.getUser().loginAnonymousIfNoUser()\n\n                EntityDataLoader edl = ec.getEntity().makeDataLoader()\n                edl.dataTypes(new HashSet(['test']))\n\n                try {\n                    long startTime = System.currentTimeMillis()\n                    long records = edl.load()\n\n                    logger.info(\"Loaded [${records}] records (with type test) in ${(System.currentTimeMillis() - startTime)/1000} seconds.\")\n                } catch (Throwable t) {\n                    logger.error(\"Error loading empty DB data (with type test)\", t)\n                }\n\n            } finally {\n                ec.destroy()\n            }\n        }\n\n        return needsRestartEcfi\n    }\n\n    @Override void destroy() {\n        if (destroyed.getAndSet(true)) {\n            logger.warn(\"Not destroying ExecutionContextFactory, already destroyed (or destroying)\")\n            return\n        }\n\n        // persist any remaining bins in artifactHitBinByType\n        Timestamp currentTimestamp = new Timestamp(System.currentTimeMillis())\n        List<ArtifactStatsInfo> asiList = new ArrayList<>(artifactStatsInfoByType.values())\n        artifactStatsInfoByType.clear()\n        ArtifactExecutionFacadeImpl aefi = getEci().artifactExecutionFacade\n        boolean enableAuthz = !aefi.disableAuthz()\n        try {\n            for (ArtifactStatsInfo asi in asiList) {\n                if (asi.curHitBin == null) continue\n                EntityValue ahb = asi.curHitBin.makeAhbValue(this, currentTimestamp)\n                ahb.setSequencedIdPrimary().create()\n            }\n        } finally { if (enableAuthz) aefi.enableAuthz() }\n        logger.info(\"ArtifactHitBins stored\")\n\n        // shutdown scheduled executor and worker pools\n        try {\n            logger.info(\"Shutting scheduled executor\")\n            scheduledExecutor.shutdown()\n            logger.info(\"Shutting down worker pool\")\n            workerPool.shutdown()\n\n            scheduledExecutor.awaitTermination(30, TimeUnit.SECONDS)\n            if (scheduledExecutor.isTerminated()) logger.info(\"Scheduled executor shut down and terminated\")\n            else logger.warn(\"Scheduled executor NOT YET terminated, waited 30 seconds\")\n\n            workerPool.awaitTermination(30, TimeUnit.SECONDS)\n            if (workerPool.isTerminated()) logger.info(\"Worker pool shut down and terminated\")\n            else logger.warn(\"Worker pool NOT YET terminated, waited 30 seconds\")\n        } catch (Throwable t) { logger.error(\"Error in workerPool/scheduledExecutor shutdown\", t) }\n\n        // stop NotificationMessageListeners\n        for (NotificationMessageListener nml in registeredNotificationMessageListeners) nml.destroy()\n\n        // Run destroy() in ToolFactory implementations from tools.tool-factory elements, in reverse order\n        ArrayList<ToolFactory> toolFactoryList = new ArrayList<>(toolFactoryMap.values())\n        Collections.reverse(toolFactoryList)\n        for (ToolFactory tf in toolFactoryList) {\n            logger.info(\"Destroying ToolFactory: ${tf.getName()}\")\n            // NOTE: also calling System.out.println because log4j gets often gets closed before this completes\n            // System.out.println(\"Destroying ToolFactory: ${tf.getName()}\")\n            try {\n                tf.destroy()\n            } catch (Throwable t) {\n                logger.error(\"Error destroying ToolFactory ${tf.getName()}\", t)\n            }\n        }\n\n        /* use to watch destroy issues:\n        if (activeContextMap.size() > 2) {\n            Set<Long> threadIds = activeContextMap.keySet()\n            ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean()\n            for (Long threadId in threadIds) {\n                ThreadInfo threadInfo = threadMXBean.getThreadInfo(threadId)\n                if (threadInfo == null) continue\n                logger.warn(\"Active execution context in thread ${threadInfo.threadId}:${threadInfo.getThreadName()} state ${threadInfo.getThreadState()} blocked ${threadInfo.getBlockedCount()} lock ${threadInfo.getLockInfo()}\")\n            }\n            for (ThreadInfo threadInfo in threadMXBean.dumpAllThreads(true, true)) {\n                System.out.println()\n                System.out.println(threadInfo.toString())\n                // for (StackTraceElement ste in threadInfo.stackTrace) System.out.println(\"    ste \" + ste.toString())\n            }\n        }\n        */\n\n        // this destroy order is important as some use others so must be destroyed first\n        if (this.serviceFacade != null) this.serviceFacade.destroy()\n        if (this.elasticFacade != null) this.elasticFacade.destroy()\n        if (this.entityFacade != null) this.entityFacade.destroy()\n        if (this.transactionFacade != null) this.transactionFacade.destroy()\n        if (this.cacheFacade != null) this.cacheFacade.destroy()\n        logger.info(\"Facades destroyed\")\n        System.out.println(\"Facades destroyed\")\n\n        for (ToolFactory tf in toolFactoryList) {\n            try {\n                tf.postFacadeDestroy()\n            } catch (Throwable t) {\n                logger.error(\"Error in post-facade destroy of ToolFactory ${tf.getName()}\", t)\n            }\n        }\n\n        activeContext.remove()\n\n        // use System.out directly for this as logger may already be stopped\n        System.out.println(\"Moqui ExecutionContextFactory Destroyed\")\n    }\n    @Override boolean isDestroyed() { return destroyed }\n\n    /** Trigger ECF destroy and re-init in another thread, after short wait */\n    void triggerDynamicReInit() {\n        Thread.start(\"EcfiReInit\", {\n            sleep(2000) // wait 2 seconds\n            Moqui.dynamicReInit(ExecutionContextFactoryImpl.class, internalServletContext)\n        })\n    }\n\n    @Override @Nonnull String getRuntimePath() { return runtimePath }\n    @Override @Nonnull String getMoquiVersion() { return moquiVersion }\n    Map getVersionMap() { return versionMap }\n    MNode getConfXmlRoot() { return confXmlRoot }\n    MNode getServerStatsNode() { return serverStatsNode }\n    MNode getArtifactExecutionNode(String artifactTypeEnumId) {\n        return confXmlRoot.first(\"artifact-execution-facade\")\n                .first({ MNode it -> it.name == \"artifact-execution\" && it.attribute(\"type\") == artifactTypeEnumId })\n    }\n\n    InetAddress getLocalhostAddress() { return localhostAddress }\n\n    @Override void registerNotificationMessageListener(@Nonnull NotificationMessageListener nml) {\n        nml.init(this)\n        registeredNotificationMessageListeners.add(nml)\n    }\n    @Override void registerLogEventSubscriber(@Nonnull LogEventSubscriber subscriber) { logEventSubscribers.add(subscriber) }\n    @Override List<LogEventSubscriber> getLogEventSubscribers() { return Collections.unmodifiableList(logEventSubscribers) }\n\n    /** Called by NotificationMessageImpl.send(), send to topic (possibly distributed) */\n    void sendNotificationMessageToTopic(NotificationMessageImpl nmi) {\n        if (notificationMessageTopic != null) {\n            // send it to the topic, this will call notifyNotificationMessageListeners(nmi)\n            notificationMessageTopic.publish(nmi)\n            // logger.warn(\"Sent nmi to distributed topic, topic=${nmi.topic}\")\n        } else {\n            // run it locally\n            notifyNotificationMessageListeners(nmi)\n        }\n    }\n    /** This is called when message received from topic (possibly distributed) */\n    void notifyNotificationMessageListeners(NotificationMessageImpl nmi) {\n        // process notifications in the worker thread pool\n        ExecutionContextImpl.ThreadPoolRunnable runnable = new ExecutionContextImpl.ThreadPoolRunnable(this, {\n            int nmlSize = registeredNotificationMessageListeners.size()\n            for (int i = 0; i < nmlSize; i++) {\n                NotificationMessageListener nml = (NotificationMessageListener) registeredNotificationMessageListeners.get(i)\n                nml.onMessage(nmi)\n            }\n        })\n        workerPool.execute(runnable)\n    }\n    NotificationWebSocketListener getNotificationWebSocketListener() { return notificationWebSocketListener }\n\n    SecurityManager getSecurityManager() {\n        if (internalSecurityManager != null) { return internalSecurityManager }\n        // init Apache Shiro; NOTE: init must be done here so that ecfi will be fully initialized and in the static context\n        BasicIniEnvironment env = new BasicIniEnvironment(\"classpath:shiro.ini\");\n        internalSecurityManager = env.getSecurityManager()\n        // NOTE: setting this statically just in case something uses it, but for Moqui we'll be getting the SecurityManager from the ecfi\n        SecurityUtils.setSecurityManager(internalSecurityManager)\n        return internalSecurityManager\n    }\n    CredentialsMatcher getCredentialsMatcher(String hashType, boolean isBase64) {\n        HashedCredentialsMatcher hcm = new HashedCredentialsMatcher()\n        if (hashType) {\n            hcm.setHashAlgorithmName(hashType)\n        } else {\n            hcm.setHashAlgorithmName(getPasswordHashType())\n        }\n        // in Shiro this defaults to true, which is the default unless UserAccount.passwordBase64 = 'Y'\n        hcm.setStoredCredentialsHexEncoded(!isBase64)\n        return hcm\n    }\n    // NOTE: may not be used\n    static String getRandomSalt() { return StringUtilities.getRandomString(8) }\n    String getPasswordHashType() {\n        MNode passwordNode = confXmlRoot.first(\"user-facade\").first(\"password\")\n        return passwordNode.attribute(\"encrypt-hash-type\") ?: \"SHA-256\"\n    }\n    // NOTE: used in UserServices.xml\n    String getSimpleHash(String source, String salt) { return getSimpleHash(source, salt, getPasswordHashType(), false) }\n    String getSimpleHash(String source, String salt, String hashType, boolean isBase64) {\n        SimpleHash simple = new SimpleHash(hashType ?: getPasswordHashType(), source, salt ?: '')\n        return isBase64 ? simple.toBase64() : simple.toHex()\n    }\n\n    String getLoginKeyHashType() {\n        MNode loginKeyNode = confXmlRoot.first(\"user-facade\").first(\"login-key\")\n        return loginKeyNode.attribute(\"encrypt-hash-type\") ?: \"SHA-256\"\n    }\n    float getLoginKeyExpireHours() {\n        MNode loginKeyNode = confXmlRoot.first(\"user-facade\").first(\"login-key\")\n        return (loginKeyNode.attribute(\"expire-hours\") ?: \"144\") as float\n    }\n\n    // ====================================================\n    // ========== Main Interface Implementations ==========\n    // ====================================================\n\n    @Override @Nonnull ExecutionContext getExecutionContext() { return getEci() }\n    ExecutionContextImpl getEci() {\n        // the ExecutionContextImpl cast here looks funny, but avoids Groovy using a slow castToType call\n        ExecutionContextImpl ec = (ExecutionContextImpl) activeContext.get()\n        if (ec != null) return ec\n\n        Thread currentThread = Thread.currentThread()\n        if (logger.traceEnabled) logger.trace(\"Creating new ExecutionContext in thread [${currentThread.id}:${currentThread.name}]\")\n        if (!currentThread.getContextClassLoader().is(moquiClassLoader)) currentThread.setContextClassLoader(moquiClassLoader)\n        ec = new ExecutionContextImpl(this, currentThread)\n        this.activeContext.set(ec)\n        this.activeContextMap.put(currentThread.id, ec)\n        return ec\n    }\n\n    void destroyActiveExecutionContext() {\n        ExecutionContext ec = this.activeContext.get()\n        if (ec != null) {\n            ec.destroy()\n            this.activeContext.remove()\n            this.activeContextMap.remove(Thread.currentThread().id)\n        }\n    }\n\n    /** Using an EC in multiple threads is dangerous as much of the ECI is not designed to be thread safe. */\n    void useExecutionContextInThread(ExecutionContextImpl eci) {\n        ExecutionContextImpl curEc = activeContext.get()\n        if (curEc != null) curEc.destroy()\n        activeContext.set(eci)\n    }\n\n    @Override\n    <V> ToolFactory<V> getToolFactory(@Nonnull String toolName) {\n        ToolFactory<V> toolFactory = (ToolFactory<V>) toolFactoryMap.get(toolName)\n        return toolFactory\n    }\n    @Override\n    <V> V getTool(@Nonnull String toolName, Class<V> instanceClass, Object... parameters) {\n        ToolFactory<V> toolFactory = (ToolFactory<V>) toolFactoryMap.get(toolName)\n        if (toolFactory == null) throw new IllegalArgumentException(\"No ToolFactory found with name ${toolName}\")\n        return toolFactory.getInstance(parameters)\n    }\n\n    @Override @Nonnull LinkedHashMap<String, String> getComponentBaseLocations() {\n        LinkedHashMap<String, String> compLocMap = new LinkedHashMap<String, String>()\n        for (ComponentInfo componentInfo in componentInfoMap.values()) compLocMap.put(componentInfo.name, componentInfo.location)\n        return compLocMap\n    }\n\n    @Override @Nonnull L10nFacade getL10n() { getEci().l10nFacade }\n    @Override @Nonnull ResourceFacade getResource() { resourceFacade }\n    @Override @Nonnull LoggerFacade getLogger() { loggerFacade }\n    @Override @Nonnull CacheFacade getCache() { cacheFacade }\n    @Override @Nonnull TransactionFacade getTransaction() { transactionFacade }\n    @Override @Nonnull EntityFacade getEntity() { entityFacade }\n    @Override @Nonnull ElasticFacade getElastic() { elasticFacade }\n    @Override @Nonnull ServiceFacade getService() { serviceFacade }\n    @Override @Nonnull ScreenFacade getScreen() { screenFacade }\n\n    @Override @Nonnull ClassLoader getClassLoader() { moquiClassLoader }\n    @Override @Nonnull GroovyClassLoader getGroovyClassLoader() { groovyClassLoader }\n\n    synchronized Class compileGroovy(String script, String className) {\n        boolean hasClassName = className != null && !className.isEmpty()\n        if (groovyCompileCacheToDisk && hasClassName) {\n            // if the className already exists just return it\n            try {\n                Class existingClass = groovyClassLoader.loadClass(className)\n                if (existingClass != null) return existingClass\n            } catch (ClassNotFoundException e) { /* ignore */ }\n\n            CompilationUnit compileUnit = new CompilationUnit(groovyCompilerConf, null, groovyClassLoader)\n            compileUnit.addSource(className, script)\n            compileUnit.compile() // just through Phases.CLASS_GENERATION?\n\n            List compiledClasses = compileUnit.getClasses()\n            if (compiledClasses.size() > 1) logger.warn(\"WARNING: compiled groovy class ${className} got ${compiledClasses.size()} classes\")\n            Class returnClass = null\n            for (Object compiledClass in compiledClasses) {\n                GroovyClass groovyClass = (GroovyClass) compiledClass\n                String compiledName = groovyClass.getName()\n                byte[] compiledBytes = groovyClass.getBytes()\n                // NOTE: this is the same step we'd use when getting bytes from disk\n                Class curClass = null\n                try { curClass = groovyClassLoader.loadClass(compiledName) } catch (ClassNotFoundException e) { /* ignore */ }\n                if (curClass == null) curClass = groovyClassLoader.defineClass(compiledName, compiledBytes)\n                if (compiledName.equals(className)) {\n                    returnClass = curClass\n                } else {\n                    logger.warn(\"Got compiled groovy class with name ${compiledName} not same as original class name ${className}\")\n                }\n            }\n\n            if (returnClass == null) logger.error(\"No errors in groovy compilation but got null Class for ${className}\")\n            return returnClass\n        } else {\n            // the simple approach, groovy compiles internally and don't save to disk/etc\n            return hasClassName ? groovyClassLoader.parseClass(script, className) : groovyClassLoader.parseClass(script)\n        }\n    }\n\n    @Override @Nonnull ServletContext getServletContext() { internalServletContext }\n    @Override @Nonnull ServerContainer getServerContainer() { internalServerContainer }\n    @Override void initServletContext(ServletContext sc) {\n        internalServletContext = sc\n        internalServerContainer = (ServerContainer) sc.getAttribute(\"jakarta.websocket.server.ServerContainer\")\n    }\n\n\n    Map<String, Object> getStatusMap() { return getStatusMap(false) }\n    Map<String, Object> getStatusMap(boolean includeSensitive) {\n        def memoryMXBean = ManagementFactory.getMemoryMXBean()\n        def heapMemoryUsage = memoryMXBean.getHeapMemoryUsage()\n        def nonHeapMemoryUsage = memoryMXBean.getNonHeapMemoryUsage()\n\n        def runtimeFile = new File(runtimePath)\n\n        def osMXBean = ManagementFactory.getOperatingSystemMXBean()\n        def runtimeMXBean = ManagementFactory.getRuntimeMXBean()\n        def uptimeHours = runtimeMXBean.getUptime() / (1000*60*60)\n        def startTimestamp = new Timestamp(runtimeMXBean.getStartTime())\n\n        def gcMXBeans = ManagementFactory.getGarbageCollectorMXBeans()\n        def gcCount = 0\n        def gcTime = 0\n        for (gcMXBean in gcMXBeans) {\n            gcCount += gcMXBean.getCollectionCount()\n            gcTime += gcMXBean.getCollectionTime()\n        }\n        def jitMXBean = ManagementFactory.getCompilationMXBean()\n        def classMXBean = ManagementFactory.getClassLoadingMXBean()\n\n        def threadMXBean = ManagementFactory.getThreadMXBean()\n\n        BigDecimal loadAvg = new BigDecimal(osMXBean.getSystemLoadAverage()).setScale(2, RoundingMode.HALF_UP)\n        int processors = osMXBean.getAvailableProcessors()\n        BigDecimal loadPercent = ((loadAvg / processors) * 100.0).setScale(2, RoundingMode.HALF_UP)\n\n        long heapUsed = heapMemoryUsage.getUsed()\n        long heapMax = heapMemoryUsage.getMax()\n        BigDecimal heapPercent = ((heapUsed / heapMax) * 100.0).setScale(2, RoundingMode.HALF_UP)\n\n        long diskFreeSpace = runtimeFile.getFreeSpace()\n        long diskTotalSpace = runtimeFile.getTotalSpace()\n        BigDecimal diskPercent = (((diskTotalSpace - diskFreeSpace) / diskTotalSpace) * 100.0).setScale(2, RoundingMode.HALF_UP)\n\n        HttpServletRequest request = getEci().getWeb()?.getRequest()\n        Map<String, Object> statusMap = [\n            // because security: MoquiFramework:moquiVersion,\n            Utilization: [LoadPercent:loadPercent, HeapPercent:heapPercent, DiskPercent:diskPercent],\n            Web: [ LocalAddr:request?.getLocalAddr(), LocalPort:request?.getLocalPort(), LocalName:request?.getLocalName(),\n                    ServerName:request?.getServerName(), ServerPort:request?.getServerPort() ],\n            Heap: [ Used:(heapUsed/(1024*1024)).setScale(3, RoundingMode.HALF_UP),\n                    Committed:(heapMemoryUsage.getCommitted()/(1024*1024)).setScale(3, RoundingMode.HALF_UP),\n                    Max:(heapMax/(1024*1024)).setScale(3, RoundingMode.HALF_UP) ],\n            NonHeap: [ Used:(nonHeapMemoryUsage.getUsed()/(1024*1024)).setScale(3, RoundingMode.HALF_UP),\n                    Committed:(nonHeapMemoryUsage.getCommitted()/(1024*1024)).setScale(3, RoundingMode.HALF_UP) ],\n            Disk: [ Free:(diskFreeSpace/(1024*1024)).setScale(3, RoundingMode.HALF_UP),\n                    Usable:(runtimeFile.getUsableSpace()/(1024*1024)).setScale(3, RoundingMode.HALF_UP),\n                    Total:(diskTotalSpace/(1024*1024)).setScale(3, RoundingMode.HALF_UP) ],\n            // trimmed because security: System: [ Load:loadAvg, Processors:processors, CPU:osMXBean.getArch(), OsName:osMXBean.getName(), OsVersion:osMXBean.getVersion() ],\n            System: [ Load:loadAvg, Processors:processors ],\n            // trimmed because security: JavaRuntime: [ SpecVersion:runtimeMXBean.getSpecVersion(), VmVendor:runtimeMXBean.getVmVendor(), VmVersion:runtimeMXBean.getVmVersion(), Start:startTimestamp, UptimeHours:uptimeHours ],\n            JavaRuntime: [ Start:startTimestamp, UptimeHours:uptimeHours ],\n            JavaStats: [ GcCount:gcCount, GcTimeSeconds:gcTime/1000, JIT:jitMXBean.getName(), CompileTimeSeconds:jitMXBean.getTotalCompilationTime()/1000,\n                    ClassesLoaded:classMXBean.getLoadedClassCount(), ClassesTotalLoaded:classMXBean.getTotalLoadedClassCount(),\n                    ClassesUnloaded:classMXBean.getUnloadedClassCount(), ThreadCount:threadMXBean.getThreadCount(),\n                    PeakThreadCount:threadMXBean.getPeakThreadCount() ] as Map<String, Object>\n            // because security: DataSources: entityFacade.getDataSourcesInfo()\n        ] as Map<String, Object>\n        if (includeSensitive) {\n            statusMap.MoquiFramework = moquiVersion\n            statusMap.System = [Load:loadAvg, Processors:processors, CPU:osMXBean.getArch(), OsName:osMXBean.getName(), OsVersion:osMXBean.getVersion()]\n            statusMap.JavaRuntime = [SpecVersion:runtimeMXBean.getSpecVersion(), VmVendor:runtimeMXBean.getVmVendor(), VmVersion:runtimeMXBean.getVmVersion(), Start:startTimestamp, UptimeHours:uptimeHours]\n            statusMap.DataSources = entityFacade.getDataSourcesInfo()\n        }\n        return statusMap\n    }\n\n    // ==========================================\n    // ========== Component Management ==========\n    // ==========================================\n\n    // called in System dashboard\n    List<Map<String, Object>> getComponentInfoList() {\n        List<Map<String, Object>> infoList = new ArrayList<>(componentInfoMap.size())\n        for (ComponentInfo ci in componentInfoMap.values())\n            infoList.add([name:ci.name, location:ci.location, version:ci.version, versionMap:ci.versionMap, dependsOnNames:ci.dependsOnNames] as Map<String, Object>)\n        return infoList\n    }\n\n    protected void checkSortDependentComponents() {\n        // we have an issue here where not all dependencies are declared, most are implied by component load order\n        // because of this not doing a full topological sort, just a single pass with dependencies inserted as needed\n\n        ArrayList<String> sortedNames = new ArrayList<>()\n        for (ComponentInfo componentInfo in componentInfoMap.values()) {\n            // for each dependsOn make sure component is valid, add to the list if not already there\n            // given a close starting sort order this should get us to a pretty good list\n            for (String dependsOnName in componentInfo.getRecursiveDependencies())\n                if (!sortedNames.contains(dependsOnName)) sortedNames.add(dependsOnName)\n\n            if (!sortedNames.contains(componentInfo.name)) sortedNames.add(componentInfo.name)\n        }\n\n        logger.info(\"Components after depends-on sort: ${sortedNames}\")\n\n        // see if all dependencies are met\n        List<String> messages = []\n        for (int i = 0; i < sortedNames.size(); i++) {\n            String name = sortedNames.get(i)\n            ComponentInfo componentInfo = componentInfoMap.get(name)\n            for (String dependsOnName in componentInfo.dependsOnNames) {\n                int dependsOnIndex = sortedNames.indexOf(dependsOnName)\n                if (dependsOnIndex > i)\n                    messages.add(\"Broken dependency order after initial pass: [${dependsOnName}] is after [${name}]\".toString())\n            }\n        }\n\n        if (messages) {\n            StringBuilder sb = new StringBuilder()\n            for (String message in messages) {\n                logger.error(message)\n                sb.append(message).append(\" \")\n            }\n            throw new IllegalArgumentException(sb.toString())\n        }\n\n        // now create a new Map and replace the original\n        LinkedHashMap<String, ComponentInfo> newMap = new LinkedHashMap<String, ComponentInfo>()\n        for (String sortedName in sortedNames) newMap.put(sortedName, componentInfoMap.get(sortedName))\n        componentInfoMap = newMap\n    }\n\n    protected void addComponent(ComponentInfo componentInfo) {\n        if (componentInfoMap.containsKey(componentInfo.name))\n            logger.warn(\"Overriding component [${componentInfo.name}] at [${componentInfoMap.get(componentInfo.name).location}] with location [${componentInfo.location}] because another component of the same name was initialized\")\n        // components registered later override those registered earlier by replacing the Map entry\n        componentInfoMap.put(componentInfo.name, componentInfo)\n        logger.info(\"Added component ${componentInfo.name.padRight(18)} at ${componentInfo.location}\")\n    }\n\n    protected void addComponentDir(String location) {\n        ResourceReference componentRr = getResourceReference(location)\n        // if directory doesn't exist skip it, runtime doesn't always have an component directory\n        if (componentRr.getExists() && componentRr.isDirectory()) {\n            // see if there is a components.xml file, if so load according to it instead of all sub-directories\n            ResourceReference cxmlRr = getResourceReference(location + \"/components.xml\")\n\n            if (cxmlRr.getExists()) {\n                MNode componentList = MNode.parse(cxmlRr)\n                for (MNode childNode in componentList.children) {\n                    if (childNode.name == 'component') {\n                        ComponentInfo componentInfo = new ComponentInfo(location, childNode, this)\n                        addComponent(componentInfo)\n                    } else if (childNode.name == 'component-dir') {\n                        String locAttr = childNode.attribute(\"location\")\n                        addComponentDir(location + \"/\" + locAttr)\n                    }\n                }\n            } else {\n                // get all files in the directory\n                TreeMap<String, ResourceReference> componentDirEntries = new TreeMap<String, ResourceReference>()\n                for (ResourceReference componentSubRr in componentRr.getDirectoryEntries()) {\n                    // if it's a directory and doesn't start with a \".\" then add it as a component dir\n                    String subRrName = componentSubRr.getFileName()\n                    if ((!componentSubRr.isDirectory() && !subRrName.endsWith(\".zip\")) || subRrName.startsWith(\".\")) continue\n                    componentDirEntries.put(componentSubRr.getFileName(), componentSubRr)\n                }\n                for (Map.Entry<String, ResourceReference> componentDirEntry in componentDirEntries.entrySet()) {\n                    String compName = componentDirEntry.value.getFileName()\n                    // skip zip files that already have a matching directory\n                    if (compName.endsWith(\".zip\")) {\n                        String compNameNoZip = stripVersionFromName(compName.substring(0, compName.length() - 4))\n                        if (componentDirEntries.containsKey(compNameNoZip)) continue\n                    }\n                    ComponentInfo componentInfo = new ComponentInfo(componentDirEntry.value.location, this)\n                    this.addComponent(componentInfo)\n                }\n            }\n        }\n    }\n\n    protected static String stripVersionFromName(String name) {\n        int lastDash = name.lastIndexOf(\"-\")\n        if (lastDash > 0 && lastDash < name.length() - 2 && Character.isDigit(name.charAt(lastDash + 1))) {\n            return name.substring(0, lastDash)\n        } else {\n            return name\n        }\n    }\n    protected static ResourceReference getResourceReference(String location) {\n        // NOTE: somehow support other resource location types?\n        // the ResourceFacade inits after components are loaded (so it is aware of initial components), so we can't get ResourceReferences from it\n        return new UrlResourceReference().init(location)\n    }\n\n    static class ComponentInfo {\n        protected final static Logger logger = LoggerFactory.getLogger(ComponentInfo.class)\n        ExecutionContextFactoryImpl ecfi\n        String name, location, version\n        Map versionMap = null\n        ResourceReference componentRr\n        Set<String> dependsOnNames = new LinkedHashSet<String>()\n        ComponentInfo(String baseLocation, MNode componentNode, ExecutionContextFactoryImpl ecfi) {\n            this.ecfi = ecfi\n            String curLoc = null\n            if (baseLocation) curLoc = baseLocation + \"/\" + componentNode.attribute(\"location\")\n            init(curLoc, componentNode)\n        }\n        ComponentInfo(String location, ExecutionContextFactoryImpl ecfi) {\n            this.ecfi = ecfi\n            init(location, null)\n        }\n        protected void init(String specLoc, MNode origNode) {\n            location = specLoc ?: origNode?.attribute(\"location\")\n            if (!location) throw new IllegalArgumentException(\"Cannot init component with no location (not specified or found in component.@location)\")\n\n            // support component zip files, expand now and replace name and location\n            if (location.endsWith(\".zip\")) {\n                ResourceReference zipRr = getResourceReference(location)\n                if (!zipRr.supportsExists()) throw new IllegalArgumentException(\"Could component location ${location} does not support exists, cannot use as a component location\")\n                // make sure corresponding directory does not exist\n                String locNoZip = stripVersionFromName(location.substring(0, location.length() - 4))\n                ResourceReference noZipRr = getResourceReference(locNoZip)\n                if (zipRr.getExists() && !noZipRr.getExists()) {\n                    // NOTE: could use getPath() instead of toExternalForm().substring(5) for file specific URLs, will work on Windows?\n                    String zipPath = zipRr.getUrl().toExternalForm().substring(5)\n                    File zipFile = new File(zipPath)\n                    String targetDirLocation = zipFile.getParent()\n                    logger.info(\"Expanding component archive ${zipRr.getFileName()} to ${targetDirLocation}\")\n\n                    ZipInputStream zipIn = new ZipInputStream(zipRr.openStream())\n                    try {\n                        ZipEntry entry = zipIn.getNextEntry()\n                        // iterates over entries in the zip file\n                        while (entry != null) {\n                            ResourceReference entryRr = getResourceReference(targetDirLocation + '/' + entry.getName())\n                            String filePath = entryRr.getUrl().toExternalForm().substring(5)\n                            if (entry.isDirectory()) {\n                                File dir = new File(filePath)\n                                dir.mkdir()\n                            } else {\n                                OutputStream os = new FileOutputStream(filePath)\n                                ObjectUtilities.copyStream(zipIn, os)\n                            }\n                            zipIn.closeEntry()\n                            entry = zipIn.getNextEntry()\n                        }\n                    } finally {\n                        zipIn.close()\n                    }\n                }\n\n                // assumes zip contains a single directory named the same as the component name (without version)\n                location = locNoZip\n            }\n\n            // clean up the location\n            if (location.endsWith('/')) location = location.substring(0, location.length()-1)\n            int lastSlashIndex = location.lastIndexOf('/')\n            if (lastSlashIndex < 0) {\n                // if this happens the component directory is directly under the runtime directory, so prefix loc with that\n                location = ecfi.runtimePath + '/' + location\n                lastSlashIndex = location.lastIndexOf('/')\n            }\n            // set the default component name, version\n            name = location.substring(lastSlashIndex+1)\n            version = \"unknown\"\n\n            // make sure directory exists\n            componentRr = getResourceReference(location)\n            if (!componentRr.supportsExists()) throw new IllegalArgumentException(\"Could component location ${location} does not support exists, cannot use as a component location\")\n            if (!componentRr.getExists()) throw new IllegalArgumentException(\"Could not find component directory at: ${location}\")\n            if (!componentRr.isDirectory()) throw new IllegalArgumentException(\"Component location is not a directory: ${location}\")\n\n            // see if there is a component.xml file, if so use that as the componentNode instead of origNode\n            ResourceReference compXmlRr = componentRr.getChild(\"component.xml\")\n            MNode componentNode = compXmlRr.exists ? MNode.parse(compXmlRr) : origNode\n            if (componentNode != null) {\n                String nameAttr = componentNode.attribute(\"name\")\n                if (nameAttr) name = nameAttr\n                String versionAttr = componentNode.attribute(\"version\")\n                if (versionAttr) version = SystemBinding.expand(versionAttr)\n                if (componentNode.hasChild(\"depends-on\")) for (MNode dependsOnNode in componentNode.children(\"depends-on\"))\n                    dependsOnNames.add(dependsOnNode.attribute(\"name\"))\n            }\n\n            ResourceReference versionJsonRr = componentRr.getChild(\"version.json\")\n            if (versionJsonRr.exists) {\n                try {\n                    versionMap = (Map) new JsonSlurper().parseText(versionJsonRr.getText())\n                } catch (Exception e) {\n                    logger.warn(\"Error parsing ${versionJsonRr.location}\", e)\n                }\n            }\n        }\n\n        List<String> getRecursiveDependencies() {\n            List<String> dependsOnList = []\n            for (String dependsOnName in dependsOnNames) {\n                ComponentInfo depCompInfo = ecfi.componentInfoMap.get(dependsOnName)\n                if (depCompInfo == null) throw new IllegalArgumentException(\"Component ${name} depends on component ${dependsOnName} which is not initialized; try running 'gradle getDepends'\")\n                List<String> childDepList = depCompInfo.getRecursiveDependencies()\n                for (String childDep in childDepList) if (!dependsOnList.contains(childDep)) dependsOnList.add(childDep)\n                if (!dependsOnList.contains(dependsOnName)) dependsOnList.add(dependsOnName)\n            }\n            return dependsOnList\n        }\n    }\n\n    /*\n    @Deprecated\n    void initComponent(String location) {\n        ComponentInfo componentInfo = new ComponentInfo(location, this)\n        // check dependencies\n        if (componentInfo.dependsOnNames) for (String dependsOnName in componentInfo.dependsOnNames) {\n            if (!componentInfoMap.containsKey(dependsOnName))\n                throw new IllegalArgumentException(\"Component [${componentInfo.name}] depends on component [${dependsOnName}] which is not initialized\")\n        }\n        addComponent(componentInfo)\n    }\n    void destroyComponent(String componentName) throws BaseException { componentInfoMap.remove(componentName) }\n    */\n\n\n    // ==========================================\n    // ========== Server Stat Tracking ==========\n    // ==========================================\n\n    protected MNode getArtifactStatsNode(String artifactType, String artifactSubType) {\n        // find artifact-stats node by type AND sub-type, if not found find by just the type\n        MNode artifactStats = null\n        if (artifactSubType != null)\n            artifactStats = confXmlRoot.first(\"server-stats\").first({ MNode it -> it.name == \"artifact-stats\" &&\n                it.attribute(\"type\") == artifactType && it.attribute(\"sub-type\") == artifactSubType })\n        if (artifactStats == null)\n            artifactStats = confXmlRoot.first(\"server-stats\")\n                    .first({ MNode it -> it.name == \"artifact-stats\" && it.attribute('type') == artifactType })\n        return artifactStats\n    }\n\n    protected final Set<String> entitiesToSkipHitCount = new HashSet([\n            'moqui.server.ArtifactHit', 'create#moqui.server.ArtifactHit',\n            'moqui.server.ArtifactHitBin', 'create#moqui.server.ArtifactHitBin',\n            'moqui.entity.SequenceValueItem', 'moqui.security.UserAccount',\n            'moqui.entity.document.DataDocument', 'moqui.entity.document.DataDocumentField',\n            'moqui.entity.document.DataDocumentCondition', 'moqui.entity.feed.DataFeedAndDocument',\n            'moqui.entity.view.DbViewEntity', 'moqui.entity.view.DbViewEntityMember',\n            'moqui.entity.view.DbViewEntityKeyMap', 'moqui.entity.view.DbViewEntityAlias'])\n\n    void countArtifactHit(ArtifactType artifactTypeEnum, String artifactSubType, String artifactName,\n              Map<String, Object> parameters, long startTime, double runningTimeMillis, Long outputSize) {\n        boolean isEntity = ArtifactExecutionInfo.AT_ENTITY.is(artifactTypeEnum) || (artifactSubType != null && artifactSubType.startsWith('entity'))\n        // don't count the ones this calls\n        if (isEntity && entitiesToSkipHitCount.contains(artifactName)) return\n        // for screen, transition, screen-content check skip stats expression\n        if (!isEntity && (ArtifactExecutionInfo.AT_XML_SCREEN.is(artifactTypeEnum) ||\n                ArtifactExecutionInfo.AT_XML_SCREEN_CONTENT.is(artifactTypeEnum) ||\n                ArtifactExecutionInfo.AT_XML_SCREEN_TRANS.is(artifactTypeEnum)) && eci.getSkipStats()) return\n\n        boolean isSlowHit = false\n        if (Boolean.TRUE.is((Boolean) artifactPersistBinByTypeEnum.get(artifactTypeEnum))) {\n            // NOTE: not adding artifactTypeEnum.name() to key, artifact names should be unique\n            String binKey = artifactName\n            // TODO: may be more cases where we don't need to append artifactTypeEnum, ie based on artifactName\n            if (artifactSubType != null && !ArtifactExecutionInfo.AT_SERVICE.is(artifactTypeEnum)) binKey = binKey.concat(artifactSubType)\n            ArtifactStatsInfo statsInfo = (ArtifactStatsInfo) artifactStatsInfoByType.get(binKey)\n            if (statsInfo == null) {\n                // consider seeding this from the DB using ArtifactHitReport to get all past data, or maybe not to better handle different servers/etc over time, etc\n                statsInfo = new ArtifactStatsInfo(artifactTypeEnum, artifactSubType, artifactName)\n                artifactStatsInfoByType.put(binKey, statsInfo)\n            }\n\n            // has the current bin expired since the last hit record?\n            if (statsInfo.curHitBin != null) {\n                long binStartTime = statsInfo.curHitBin.startTime\n                if (startTime > (binStartTime + hitBinLengthMillis)) {\n                    if (isTraceEnabled) logger.trace(\"Advancing ArtifactHitBin [${artifactTypeEnum.name()}.${artifactSubType}:${artifactName}] current hit start [${new Timestamp(startTime)}], bin start [${new Timestamp(binStartTime)}] bin length ${hitBinLengthMillis/1000} seconds\")\n                    advanceArtifactHitBin(getEci(), statsInfo, startTime, hitBinLengthMillis)\n                }\n            }\n\n            // handle stats since start\n            isSlowHit = statsInfo.countHit(startTime, runningTimeMillis)\n        }\n        // NOTE: never save individual hits for entity artifact hits, way too heavy and also avoids self-reference\n        //     (could also be done by checking for ArtifactHit/etc of course)\n        // Always save slow hits above userImpactMinMillis regardless of settings\n        if (!isEntity && ((isSlowHit && runningTimeMillis > ContextJavaUtil.userImpactMinMillis) ||\n                Boolean.TRUE.is((Boolean) artifactPersistHitByTypeEnum.get(artifactTypeEnum)))) {\n            ExecutionContextImpl eci = getEci()\n            ArtifactHitInfo ahi = new ArtifactHitInfo(eci, isSlowHit, artifactTypeEnum, artifactSubType, artifactName,\n                    startTime, runningTimeMillis, parameters, outputSize)\n            deferredHitInfoQueue.add(ahi)\n        }\n    }\n\n    static class DeferredHitInfoFlush implements Runnable {\n        protected final static Logger logger = LoggerFactory.getLogger(DeferredHitInfoFlush.class)\n        // max creates per chunk, one transaction per chunk (unless error)\n        final static int maxCreates = 1000\n        final ExecutionContextFactoryImpl ecfi\n        DeferredHitInfoFlush(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi }\n        @Override synchronized void run() {\n            ExecutionContextImpl eci = ecfi.getEci()\n            eci.artifactExecutionFacade.disableAuthz()\n            try {\n                try {\n                    ConcurrentLinkedQueue<ArtifactHitInfo> queue = ecfi.deferredHitInfoQueue\n                    // split into maxCreates chunks, repeat based on initial size (may be added to while running)\n                    int remainingCreates = queue.size()\n                    // if (remainingCreates > maxCreates) logger.warn(\"Deferred ArtifactHit create queue size ${remainingCreates} is greater than max creates per chunk ${maxCreates}\")\n                    // logger.info(\"Flushing ArtifactHit queue, size \" + queue.size())\n                    while (remainingCreates > 0) {\n                        flushQueue(queue)\n                        remainingCreates -= maxCreates\n                        // logger.info(\"Flush ArtifactHit queue pass complete, queue size ${queue.size()} remainingCreates ${remainingCreates}\")\n                    }\n                } catch (Throwable t) {\n                    logger.error(\"Error saving ArtifactHits\", t)\n                }\n            } finally {\n                // no need, we're destroying the eci: if (!authzDisabled) eci.artifactExecution.enableAuthz()\n                eci.destroy()\n            }\n        }\n\n        void flushQueue(ConcurrentLinkedQueue<ArtifactHitInfo> queue) {\n            ExecutionContextFactoryImpl localEcfi = ecfi\n            ArrayList<ArtifactHitInfo> createList = new ArrayList<>(maxCreates)\n            int createCount = 0\n            while (createCount < maxCreates) {\n                ArtifactHitInfo ahi = queue.poll()\n                if (ahi == null) break\n                createCount++\n                createList.add(ahi)\n            }\n            int retryCount = 5\n            while (retryCount > 0) {\n                try {\n                    int createListSize = createList.size()\n                    if (createListSize == 0) break\n                    long startTime = System.currentTimeMillis()\n                    ecfi.transactionFacade.runUseOrBegin(60, \"Error saving ArtifactHits\", {\n                        List<EntityValue> evList = new ArrayList<>(createListSize)\n                        for (int i = 0; i < createListSize; i++) {\n                            ArtifactHitInfo ahi = (ArtifactHitInfo) createList.get(i)\n                            EntityValue ahValue = ahi.makeAhiValue(localEcfi)\n                            ahValue.setSequencedIdPrimary()\n                            evList.add(ahValue)\n                            // old approach, create call per record, too slow when ArtifactHitBin in the logging group for ElasticFacade\n                            // try { ahValue.create() } catch (Throwable t) { createList.remove(i); throw t }\n                        }\n                        // new approach, use new EntityFacade.createBulk() method\n                        localEcfi.entityFacade.createBulk(evList)\n                    })\n                    if (isTraceEnabled) logger.trace(\"Created ${createListSize} ArtifactHit records in ${System.currentTimeMillis() - startTime}ms\")\n                    break\n                } catch (Throwable t) {\n                    logger.error(\"Error saving ArtifactHits, retrying (${retryCount})\", t)\n                    retryCount--\n                }\n            }\n        }\n    }\n\n    protected synchronized void advanceArtifactHitBin(ExecutionContextImpl eci, ArtifactStatsInfo statsInfo,\n            long startTime, long hitBinLengthMillis) {\n        ArtifactBinInfo abi = statsInfo.curHitBin\n        if (abi == null) {\n            statsInfo.curHitBin = new ArtifactBinInfo(statsInfo, startTime)\n            return\n        }\n\n        // check the time again and return just in case something got in while waiting with the same type\n        long binStartTime = abi.startTime\n        if (startTime < (binStartTime + hitBinLengthMillis)) return\n\n        // otherwise, persist the old and create a new one\n        EntityValue ahb = abi.makeAhbValue(this, new Timestamp(binStartTime + hitBinLengthMillis))\n        eci.runInWorkerThread({\n            ArtifactExecutionFacadeImpl aefi = getEci().artifactExecutionFacade\n            boolean enableAuthz = !aefi.disableAuthz()\n            try { ahb.setSequencedIdPrimary().create() }\n            finally { if (enableAuthz) aefi.enableAuthz() }\n        })\n\n        statsInfo.curHitBin = new ArtifactBinInfo(statsInfo, startTime)\n    }\n\n    // ========================================================\n    // ========== Configuration File Merging Methods ==========\n    // ========================================================\n\n    protected static void mergeConfigNodes(MNode baseNode, MNode overrideNode) {\n        baseNode.mergeChildrenByKey(overrideNode, \"default-property\", \"name\", null)\n        baseNode.mergeChildWithChildKey(overrideNode, \"tools\", \"tool-factory\", \"class\", null)\n        baseNode.mergeChildWithChildKey(overrideNode, \"cache-list\", \"cache\", \"name\", null)\n\n        if (overrideNode.hasChild(\"server-stats\")) {\n            // the artifact-stats nodes have 2 keys: type, sub-type; can't use the normal method\n            MNode ssNode = baseNode.first(\"server-stats\")\n            MNode overrideSsNode = overrideNode.first(\"server-stats\")\n            // override attributes for this node\n            ssNode.attributes.putAll(overrideSsNode.attributes)\n            for (MNode childOverrideNode in overrideSsNode.children(\"artifact-stats\")) {\n                String type = childOverrideNode.attribute(\"type\")\n                String subType = childOverrideNode.attribute(\"sub-type\")\n                MNode childBaseNode = ssNode.first({ MNode it -> it.name == \"artifact-stats\" && it.attribute(\"type\") == type &&\n                        (it.attribute(\"sub-type\") == subType || (!it.attribute(\"sub-type\") && !subType)) })\n                if (childBaseNode) {\n                    // merge the node attributes\n                    childBaseNode.attributes.putAll(childOverrideNode.attributes)\n                } else {\n                    // no matching child base node, so add a new one\n                    ssNode.append(childOverrideNode)\n                }\n            }\n        }\n\n        baseNode.mergeChildWithChildKey(overrideNode, \"webapp-list\", \"webapp\", \"name\",\n                { MNode childBaseNode, MNode childOverrideNode -> mergeWebappChildNodes(childBaseNode, childOverrideNode) })\n\n        baseNode.mergeChildWithChildKey(overrideNode, \"artifact-execution-facade\", \"artifact-execution\", \"type\", null)\n\n        if (overrideNode.hasChild(\"user-facade\")) {\n            MNode ufBaseNode = baseNode.first(\"user-facade\")\n            MNode ufOverrideNode = overrideNode.first(\"user-facade\")\n            ufBaseNode.mergeSingleChild(ufOverrideNode, \"password\")\n            ufBaseNode.mergeSingleChild(ufOverrideNode, \"login-key\")\n            ufBaseNode.mergeSingleChild(ufOverrideNode, \"login\")\n        }\n\n        if (overrideNode.hasChild(\"transaction-facade\")) {\n            MNode tfBaseNode = baseNode.first(\"transaction-facade\")\n            MNode tfOverrideNode = overrideNode.first(\"transaction-facade\")\n            tfBaseNode.attributes.putAll(tfOverrideNode.attributes)\n            tfBaseNode.mergeSingleChild(tfOverrideNode, \"server-jndi\")\n            tfBaseNode.mergeSingleChild(tfOverrideNode, \"transaction-jndi\")\n            tfBaseNode.mergeSingleChild(tfOverrideNode, \"transaction-internal\")\n        }\n\n        if (overrideNode.hasChild(\"resource-facade\")) {\n            baseNode.mergeChildWithChildKey(overrideNode, \"resource-facade\", \"resource-reference\", \"scheme\", null)\n            baseNode.mergeChildWithChildKey(overrideNode, \"resource-facade\", \"template-renderer\", \"extension\", null)\n            baseNode.mergeChildWithChildKey(overrideNode, \"resource-facade\", \"script-runner\", \"extension\", null)\n        }\n\n        if (overrideNode.hasChild(\"screen-facade\")) {\n            baseNode.mergeChildWithChildKey(overrideNode, \"screen-facade\", \"screen-text-output\", \"type\", null)\n            baseNode.mergeChildWithChildKey(overrideNode, \"screen-facade\", \"screen-output\", \"type\", null)\n            baseNode.mergeChildWithChildKey(overrideNode, \"screen-facade\", \"screen\", \"location\", {\n                MNode childBaseNode, MNode childOverrideNode -> childBaseNode.mergeChildrenByKey(childOverrideNode, \"subscreens-item\", \"name\", null) })\n        }\n\n        if (overrideNode.hasChild(\"service-facade\")) {\n            MNode sfBaseNode = baseNode.first(\"service-facade\")\n            MNode sfOverrideNode = overrideNode.first(\"service-facade\")\n            sfBaseNode.mergeNodeWithChildKey(sfOverrideNode, \"service-location\", \"name\", null)\n            sfBaseNode.mergeChildrenByKey(sfOverrideNode, \"service-type\", \"name\", null)\n            sfBaseNode.mergeChildrenByKey(sfOverrideNode, \"service-file\", \"location\", null)\n            sfBaseNode.mergeChildrenByKey(sfOverrideNode, \"startup-service\", \"name\", null)\n\n            // handle thread-pool\n            MNode tpOverrideNode = sfOverrideNode.first(\"thread-pool\")\n            if (tpOverrideNode) {\n                MNode tpBaseNode = sfBaseNode.first(\"thread-pool\")\n                if (tpBaseNode) {\n                    tpBaseNode.mergeNodeWithChildKey(tpOverrideNode, \"run-from-pool\", \"name\", null)\n                } else {\n                    sfBaseNode.append(tpOverrideNode)\n                }\n            }\n\n            // handle jms-service, just copy all over\n            for (MNode jsOverrideNode in sfOverrideNode.children(\"jms-service\")) {\n                sfBaseNode.append(jsOverrideNode)\n            }\n        }\n\n        if (overrideNode.hasChild(\"elastic-facade\")) {\n            MNode efBaseNode = baseNode.first(\"elastic-facade\")\n            MNode efOverrideNode = overrideNode.first(\"elastic-facade\")\n            efBaseNode.mergeChildrenByKey(efOverrideNode, \"cluster\", \"name\", null)\n        }\n\n        if (overrideNode.hasChild(\"entity-facade\")) {\n            MNode efBaseNode = baseNode.first(\"entity-facade\")\n            MNode efOverrideNode = overrideNode.first(\"entity-facade\")\n            efBaseNode.mergeNodeWithChildKey(efOverrideNode, \"datasource\", \"group-name\", { MNode childBaseNode, MNode childOverrideNode ->\n                // handle the jndi-jdbc and inline-jdbc nodes: if either exist in override have it totally remove both from base, then copy over\n                if (childOverrideNode.hasChild(\"jndi-jdbc\") || childOverrideNode.hasChild(\"inline-jdbc\")) {\n                    childBaseNode.remove(\"jndi-jdbc\")\n                    childBaseNode.remove(\"inline-jdbc\")\n\n                    if (childOverrideNode.hasChild(\"inline-jdbc\")) {\n                        childBaseNode.append(childOverrideNode.first(\"inline-jdbc\"))\n                    } else if (childOverrideNode.hasChild(\"jndi-jdbc\")) {\n                        childBaseNode.append(childOverrideNode.first(\"jndi-jdbc\"))\n                    }\n                }\n            })\n            efBaseNode.mergeSingleChild(efOverrideNode, \"server-jndi\")\n            // for load-entity and load-data just copy over override nodes\n            for (MNode copyNode in efOverrideNode.children(\"load-entity\")) efBaseNode.append(copyNode)\n            for (MNode copyNode in efOverrideNode.children(\"load-data\")) efBaseNode.append(copyNode)\n        }\n\n        if (overrideNode.hasChild(\"database-list\")) {\n            baseNode.mergeChildWithChildKey(overrideNode, \"database-list\", \"dictionary-type\", \"type\", null)\n            // handle database-list -> database, database -> database-type@type\n            baseNode.mergeChildWithChildKey(overrideNode, \"database-list\", \"database\", \"name\",\n                    { MNode childBaseNode, MNode childOverrideNode -> childBaseNode.mergeNodeWithChildKey(childOverrideNode, \"database-type\", \"type\", null) })\n        }\n\n        baseNode.mergeChildWithChildKey(overrideNode, \"repository-list\", \"repository\", \"name\", {\n            MNode childBaseNode, MNode childOverrideNode -> childBaseNode.mergeChildrenByKey(childOverrideNode, \"init-param\", \"name\", null) })\n\n        // NOTE: don't merge component-list node, done separately (for runtime config only, and before component config merges)\n    }\n\n    protected static void mergeConfigComponentNodes(MNode baseNode, MNode overrideNode) {\n        if (overrideNode.hasChild(\"component-list\")) {\n            if (!baseNode.hasChild(\"component-list\")) baseNode.append(\"component-list\", null)\n            MNode baseComponentNode = baseNode.first(\"component-list\")\n            for (MNode copyNode in overrideNode.first(\"component-list\").children) baseComponentNode.append(copyNode)\n        }\n    }\n\n    protected static void mergeWebappChildNodes(MNode baseNode, MNode overrideNode) {\n        baseNode.mergeChildrenByKey(overrideNode, \"root-screen\", \"host\", null)\n        baseNode.mergeChildrenByKey(overrideNode, \"error-screen\", \"error\", null)\n        // handle webapp -> first-hit-in-visit[1], after-request[1], before-request[1], after-login[1], before-logout[1]\n        mergeWebappActions(baseNode, overrideNode, \"first-hit-in-visit\")\n        mergeWebappActions(baseNode, overrideNode, \"after-request\")\n        mergeWebappActions(baseNode, overrideNode, \"before-request\")\n        mergeWebappActions(baseNode, overrideNode, \"after-login\")\n        mergeWebappActions(baseNode, overrideNode, \"before-logout\")\n        mergeWebappActions(baseNode, overrideNode, \"after-startup\")\n        mergeWebappActions(baseNode, overrideNode, \"before-shutdown\")\n\n        baseNode.mergeChildrenByKey(overrideNode, \"filter\", \"name\", { MNode childBaseNode, MNode childOverrideNode ->\n            childBaseNode.mergeChildrenByKey(childOverrideNode, \"init-param\", \"name\", null)\n            for (MNode upNode in overrideNode.children(\"url-pattern\")) childBaseNode.append(upNode.deepCopy(null))\n            for (MNode upNode in overrideNode.children(\"dispatcher\")) childBaseNode.append(upNode.deepCopy(null))\n        })\n        baseNode.mergeChildrenByKey(overrideNode, \"listener\", \"class\", null)\n        baseNode.mergeChildrenByKey(overrideNode, \"servlet\", \"name\", { MNode childBaseNode, MNode childOverrideNode ->\n            childBaseNode.mergeChildrenByKey(childOverrideNode, \"init-param\", \"name\", null)\n            for (MNode upNode in overrideNode.children(\"url-pattern\")) childBaseNode.append(upNode.deepCopy(null))\n        })\n        baseNode.mergeSingleChild(overrideNode, \"session-config\")\n\n        baseNode.mergeChildrenByKey(overrideNode, \"endpoint\", \"path\", null)\n\n        baseNode.mergeChildrenByKeys(overrideNode, \"response-header\", null, \"type\", \"name\")\n    }\n\n    protected static void mergeWebappActions(MNode baseWebappNode, MNode overrideWebappNode, String childNodeName) {\n        List<MNode> overrideActionNodes = overrideWebappNode.first(childNodeName)?.first(\"actions\")?.children\n        if (overrideActionNodes) {\n            MNode childNode = baseWebappNode.first(childNodeName)\n            if (childNode == null) childNode = baseWebappNode.append(childNodeName, null)\n            MNode actionsNode = childNode.first(\"actions\")\n            if (actionsNode == null) actionsNode = childNode.append(\"actions\", null)\n\n            for (MNode overrideActionNode in overrideActionNodes) actionsNode.append(overrideActionNode)\n        }\n    }\n\n    MNode getWebappNode(String webappName) { return confXmlRoot.first(\"webapp-list\")\n            .first({ MNode it -> it.name == \"webapp\" && it.attribute(\"name\") == webappName }) }\n\n    WebappInfo getWebappInfo(String webappName) {\n        WebappInfo wi = webappInfoMap.get(webappName)\n        if (wi != null) return wi\n        return makeWebappInfo(webappName)\n    }\n    protected synchronized WebappInfo makeWebappInfo(String webappName) {\n        if (webappName == null || webappName.isEmpty()) return null\n        WebappInfo wi = new WebappInfo(webappName, this)\n        webappInfoMap.put(webappName, wi)\n        return wi\n    }\n\n    static class WebappInfo {\n        protected final static Logger logger = LoggerFactory.getLogger(WebappInfo.class)\n        String webappName\n        MNode webappNode\n        XmlAction firstHitInVisitActions = null\n        XmlAction beforeRequestActions = null\n        XmlAction afterRequestActions = null\n        XmlAction afterLoginActions = null\n        XmlAction beforeLogoutActions = null\n        XmlAction afterStartupActions = null\n        XmlAction beforeShutdownActions = null\n        ArrayList<MNode> responseHeaderList\n        Set<String> allowOriginSet = new HashSet<>()\n\n        Integer sessionTimeoutSeconds = null\n        String httpPort, httpHost, httpsPort, httpsHost\n        boolean httpsEnabled\n        boolean requireSessionToken\n        String clientIpHeader\n\n        WebappInfo(String webappName, ExecutionContextFactoryImpl ecfi) {\n            this.webappName = webappName\n            webappNode = ecfi.confXmlRoot.first(\"webapp-list\").first({ MNode it -> it.name == \"webapp\" && it.attribute(\"name\") == webappName })\n            if (webappNode == null) throw new BaseException(\"Could not find webapp element for name ${webappName}\")\n\n            webappNode.setSystemExpandAttributes(true)\n            httpPort = webappNode.attribute(\"http-port\") ?: null\n            httpHost = webappNode.attribute(\"http-host\") ?: null\n            httpsPort = webappNode.attribute(\"https-port\") ?: null\n            httpsHost = webappNode.attribute(\"https-host\") ?: httpHost ?: null\n            httpsEnabled = \"true\".equals(webappNode.attribute(\"https-enabled\"))\n            requireSessionToken = !\"false\".equals(webappNode.attribute(\"require-session-token\"))\n            clientIpHeader = webappNode.attribute(\"client-ip-header\")\n\n            String allowOrigins = webappNode.attribute(\"allow-origins\")\n            if (allowOrigins) for (String origin in allowOrigins.split(\",\")) allowOriginSet.add(origin.trim().toLowerCase())\n\n            logger.info(\"Initializing webapp ${webappName} http://${httpHost}:${httpPort} https://${httpsHost}:${httpsPort} https enabled? ${httpsEnabled}\")\n\n            // prep actions\n            if (webappNode.hasChild(\"first-hit-in-visit\"))\n                firstHitInVisitActions = new XmlAction(ecfi, webappNode.first(\"first-hit-in-visit\").first(\"actions\"),\n                        \"webapp_${webappName}.first_hit_in_visit.actions\")\n\n            if (webappNode.hasChild(\"before-request\"))\n                beforeRequestActions = new XmlAction(ecfi, webappNode.first(\"before-request\").first(\"actions\"),\n                        \"webapp_${webappName}.before_request.actions\")\n            if (webappNode.hasChild(\"after-request\"))\n                afterRequestActions = new XmlAction(ecfi, webappNode.first(\"after-request\").first(\"actions\"),\n                        \"webapp_${webappName}.after_request.actions\")\n\n            if (webappNode.hasChild(\"after-login\"))\n                afterLoginActions = new XmlAction(ecfi, webappNode.first(\"after-login\").first(\"actions\"),\n                        \"webapp_${webappName}.after_login.actions\")\n            if (webappNode.hasChild(\"before-logout\"))\n                beforeLogoutActions = new XmlAction(ecfi, webappNode.first(\"before-logout\").first(\"actions\"),\n                        \"webapp_${webappName}.before_logout.actions\")\n\n            if (webappNode.hasChild(\"after-startup\"))\n                afterStartupActions = new XmlAction(ecfi, webappNode.first(\"after-startup\").first(\"actions\"),\n                        \"webapp_${webappName}.after_startup.actions\")\n            if (webappNode.hasChild(\"before-shutdown\"))\n                beforeShutdownActions = new XmlAction(ecfi, webappNode.first(\"before-shutdown\").first(\"actions\"),\n                        \"webapp_${webappName}.before_shutdown.actions\")\n\n            responseHeaderList = webappNode.children(\"response-header\")\n\n            MNode sessionConfigNode = webappNode.first(\"session-config\")\n            if (sessionConfigNode != null && sessionConfigNode.attribute(\"timeout\")) {\n                sessionTimeoutSeconds = (sessionConfigNode.attribute(\"timeout\") as int) * 60\n            }\n        }\n\n        MNode getErrorScreenNode(String error) {\n            return webappNode.first({ MNode it -> it.name == \"error-screen\" && it.attribute(\"error\") == error })\n        }\n\n        void addHeaders(String type, HttpServletResponse response) {\n            if (type == null || response == null) return\n            int responseHeaderListSize = responseHeaderList.size()\n            for (int i = 0; i < responseHeaderListSize; i++) {\n                MNode responseHeader = (MNode) responseHeaderList.get(i)\n                if (!type.equals(responseHeader.attribute(\"type\"))) continue\n                String headerValue = responseHeader.attribute(\"value\")\n                if (headerValue == null || headerValue.isEmpty()) continue\n                if (\"true\".equals(responseHeader.attribute(\"add\"))) {\n                    response.addHeader(responseHeader.attribute(\"name\"), headerValue)\n                } else {\n                    response.setHeader(responseHeader.attribute(\"name\"), headerValue)\n                }\n                // logger.warn(\"Added header ${responseHeader.attribute(\"name\")} value ${headerValue} type ${type}\")\n            }\n        }\n    }\n\n    @Override String toString() { return \"ExecutionContextFactory \" + moquiVersion }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/ExecutionContextImpl.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context;\n\nimport groovy.lang.Closure;\n\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\n\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.Future;\n\nimport javax.annotation.Nonnull;\nimport javax.annotation.Nullable;\nimport javax.cache.Cache;\n\nimport org.moqui.context.*;\nimport org.moqui.entity.EntityFacade;\nimport org.moqui.entity.EntityFind;\nimport org.moqui.entity.EntityList;\nimport org.moqui.entity.EntityValue;\nimport org.moqui.impl.entity.EntityFacadeImpl;\nimport org.moqui.impl.screen.ScreenFacadeImpl;\nimport org.moqui.impl.service.ServiceFacadeImpl;\nimport org.moqui.screen.ScreenFacade;\nimport org.moqui.service.ServiceFacade;\nimport org.moqui.util.ContextBinding;\nimport org.moqui.util.ContextStack;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.slf4j.MDC;\n\npublic class ExecutionContextImpl implements ExecutionContext {\n    private static final Logger loggerDirect = LoggerFactory.getLogger(ExecutionContextFactoryImpl.class);\n\n    public final ExecutionContextFactoryImpl ecfi;\n    public final ContextStack contextStack = new ContextStack();\n    public final ContextBinding contextBindingInternal = new ContextBinding(contextStack);\n\n    private EntityFacadeImpl activeEntityFacade;\n\n    private WebFacade webFacade = (WebFacade) null;\n    private WebFacadeImpl webFacadeImpl = (WebFacadeImpl) null;\n\n    public final UserFacadeImpl userFacade;\n    public final MessageFacadeImpl messageFacade;\n    public final ArtifactExecutionFacadeImpl artifactExecutionFacade;\n    public final L10nFacadeImpl l10nFacade;\n\n    // local references to ECFI fields\n    public final CacheFacadeImpl cacheFacade;\n    public final LoggerFacadeImpl loggerFacade;\n    public final ResourceFacadeImpl resourceFacade;\n    public final ScreenFacadeImpl screenFacade;\n    public final ServiceFacadeImpl serviceFacade;\n    public final TransactionFacadeImpl transactionFacade;\n\n    private Boolean skipStats = null;\n    private Cache<String, String> l10nMessageCache;\n    private Cache<String, ArrayList> tarpitHitCache;\n\n    public String forThreadName;\n    public long forThreadId;\n    // public final Exception createLoc;\n\n    public ExecutionContextImpl(ExecutionContextFactoryImpl ecfi, Thread forThread) {\n        this.ecfi = ecfi;\n        // NOTE: no WebFacade init here, wait for call in to do that\n        // put reference to this in the context root\n        contextStack.put(\"ec\", this);\n        forThreadName = forThread.getName();\n        forThreadId = forThread.threadId();\n        // createLoc = new BaseException(\"ec create\");\n\n        activeEntityFacade = ecfi.entityFacade;\n        userFacade = new UserFacadeImpl(this);\n        messageFacade = new MessageFacadeImpl();\n        artifactExecutionFacade = new ArtifactExecutionFacadeImpl(this);\n        l10nFacade = new L10nFacadeImpl(this);\n\n        cacheFacade = ecfi.cacheFacade;\n        loggerFacade = ecfi.loggerFacade;\n        resourceFacade = ecfi.resourceFacade;\n        screenFacade = ecfi.screenFacade;\n        serviceFacade = ecfi.serviceFacade;\n        transactionFacade = ecfi.transactionFacade;\n\n        if (cacheFacade == null) throw new IllegalStateException(\"cacheFacade was null\");\n        if (loggerFacade == null) throw new IllegalStateException(\"loggerFacade was null\");\n        if (resourceFacade == null) throw new IllegalStateException(\"resourceFacade was null\");\n        if (screenFacade == null) throw new IllegalStateException(\"screenFacade was null\");\n        if (serviceFacade == null) throw new IllegalStateException(\"serviceFacade was null\");\n        if (transactionFacade == null) throw new IllegalStateException(\"transactionFacade was null\");\n\n        initCaches();\n\n        if (loggerDirect.isTraceEnabled()) loggerDirect.trace(\"ExecutionContextImpl initialized\");\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private void initCaches() {\n        tarpitHitCache = cacheFacade.getCache(\"artifact.tarpit.hits\");\n        l10nMessageCache = cacheFacade.getCache(\"l10n.message\");\n    }\n    Cache<String, String> getL10nMessageCache() { return l10nMessageCache; }\n    public Cache<String, ArrayList> getTarpitHitCache() { return tarpitHitCache; }\n\n    @Override public @Nonnull ExecutionContextFactory getFactory() { return ecfi; }\n\n    @Override public @Nonnull ContextStack getContext() { return contextStack; }\n    @Override public @Nonnull Map<String, Object> getContextRoot() { return contextStack.getRootMap(); }\n    @Override public @Nonnull ContextBinding getContextBinding() { return contextBindingInternal; }\n\n    @Override\n    public <V> V getTool(@Nonnull String toolName, Class<V> instanceClass, Object... parameters) {\n        return ecfi.getTool(toolName, instanceClass, parameters);\n    }\n\n    @Override public @Nullable WebFacade getWeb() { return webFacade; }\n    public @Nullable WebFacadeImpl getWebImpl() { return webFacadeImpl; }\n\n    @Override public @Nonnull UserFacade getUser() { return userFacade; }\n    @Override public @Nonnull MessageFacade getMessage() { return messageFacade; }\n    @Override public @Nonnull ArtifactExecutionFacade getArtifactExecution() { return artifactExecutionFacade; }\n    @Override public @Nonnull L10nFacade getL10n() { return l10nFacade; }\n    @Override public @Nonnull ResourceFacade getResource() { return resourceFacade; }\n    @Override public @Nonnull LoggerFacade getLogger() { return loggerFacade; }\n    @Override public @Nonnull CacheFacade getCache() { return cacheFacade; }\n    @Override public @Nonnull TransactionFacade getTransaction() { return transactionFacade; }\n\n    @Override public @Nonnull EntityFacade getEntity() { return activeEntityFacade; }\n    public @Nonnull EntityFacadeImpl getEntityFacade() { return activeEntityFacade; }\n\n    @Override public @Nonnull ElasticFacade getElastic() { return ecfi.elasticFacade; }\n    @Override public @Nonnull ServiceFacade getService() { return serviceFacade; }\n    @Override public @Nonnull ScreenFacade getScreen() { return screenFacade; }\n\n    @Override public @Nonnull NotificationMessage makeNotificationMessage() { return new NotificationMessageImpl(ecfi); }\n\n    @Override\n    public @Nonnull List<NotificationMessage> getNotificationMessages(@Nullable String topic) {\n        String userId = userFacade.getUserId();\n        if (userId == null || userId.isEmpty()) return new ArrayList<>();\n\n        List<NotificationMessage> nmList = new ArrayList<>();\n        boolean alreadyDisabled = artifactExecutionFacade.disableAuthz();\n        try {\n            EntityFind nmbuFind = activeEntityFacade.find(\"moqui.security.user.NotificationMessageByUser\").condition(\"userId\", userId);\n            if (topic != null && !topic.isEmpty()) nmbuFind.condition(\"topic\", topic);\n            EntityList nmbuList = nmbuFind.list();\n            for (EntityValue nmbu : nmbuList) {\n                NotificationMessageImpl nmi = new NotificationMessageImpl(ecfi);\n                nmi.populateFromValue(nmbu);\n                nmList.add(nmi);\n            }\n        } finally {\n            if (!alreadyDisabled) artifactExecutionFacade.enableAuthz();\n        }\n\n        return nmList;\n    }\n\n    @Override\n    public void initWebFacade(@Nonnull String webappMoquiName, @Nonnull HttpServletRequest request, @Nonnull HttpServletResponse response) {\n        WebFacadeImpl wfi = new WebFacadeImpl(webappMoquiName, request, response, this);\n        webFacade = wfi;\n        webFacadeImpl = wfi;\n\n        // now that we have the webFacade in place we can do init UserFacade\n        userFacade.initFromHttpRequest(request, response);\n        // for convenience (and more consistent code in screen actions, services, etc) add all requestParameters to the context\n        contextStack.putAll(webFacadeImpl.getRequestParameters());\n        // this is the beginning of a request, so trigger before-request actions\n        wfi.runBeforeRequestActions();\n\n        String userId = userFacade.getUserId();\n        if (userId != null && !userId.isEmpty()) MDC.put(\"moqui_userId\", userId);\n        String visitorId = userFacade.getVisitorId();\n        if (visitorId != null && !visitorId.isEmpty()) MDC.put(\"moqui_visitorId\", visitorId);\n\n        if (loggerDirect.isTraceEnabled()) loggerDirect.trace(\"ExecutionContextImpl WebFacade initialized\");\n    }\n\n    /** Meant to be used to set a test stub that implements the WebFacade interface */\n    public void setWebFacade(WebFacade wf) {\n        webFacade = wf;\n        if (wf instanceof WebFacadeImpl) webFacadeImpl = (WebFacadeImpl) wf;\n        contextStack.putAll(webFacade.getRequestParameters());\n    }\n\n    public boolean getSkipStats() {\n        if (skipStats != null) return skipStats;\n        String skipStatsCond = ecfi.skipStatsCond;\n        Map<String, Object> skipParms = new HashMap<>();\n        if (webFacade != null) skipParms.put(\"pathInfo\", webFacade.getPathInfo());\n        skipStats = (skipStatsCond != null && !skipStatsCond.isEmpty()) && ecfi.resourceFacade.condition(skipStatsCond, null, skipParms);\n        return skipStats;\n    }\n\n    @Override\n    public Future runAsync(@Nonnull Closure closure) {\n        ThreadPoolRunnable runnable = new ThreadPoolRunnable(this, closure);\n        return ecfi.workerPool.submit(runnable);\n    }\n    /** Uses the ECFI constructor for ThreadPoolRunnable so does NOT use the current ECI in the separate thread */\n    public Future runInWorkerThread(@Nonnull Closure closure) {\n        ThreadPoolRunnable runnable = new ThreadPoolRunnable(ecfi, closure);\n        return ecfi.workerPool.submit(runnable);\n    }\n\n    @Override\n    public void destroy() {\n        // if webFacade exists this is the end of a request, so trigger after-request actions\n        if (webFacadeImpl != null) webFacadeImpl.runAfterRequestActions();\n\n        // make sure there are no transactions open, if any commit them all now\n        ecfi.transactionFacade.destroyAllInThread();\n        // clean up resources, like JCR session\n        ecfi.resourceFacade.destroyAllInThread();\n        // clear out the ECFI's reference to this as well\n        ecfi.activeContext.remove();\n        ecfi.activeContextMap.remove(Thread.currentThread().threadId());\n\n        MDC.remove(\"moqui_userId\");\n        MDC.remove(\"moqui_visitorId\");\n\n        if (loggerDirect.isTraceEnabled()) loggerDirect.trace(\"ExecutionContextImpl destroyed\");\n    }\n\n    @Override public String toString() { return \"ExecutionContext\"; }\n\n    public static class ThreadPoolRunnable implements Runnable {\n        private ExecutionContextImpl threadEci;\n        private ExecutionContextFactoryImpl ecfi;\n        private Closure closure;\n        /** With this constructor (passing ECI) the ECI is used in the separate thread */\n        public ThreadPoolRunnable(ExecutionContextImpl eci, Closure closure) {\n            threadEci = eci;\n            ecfi = eci.ecfi;\n            this.closure = closure;\n        }\n\n        /** With this constructor (passing ECFI) a new ECI is created for the separate thread */\n        public ThreadPoolRunnable(ExecutionContextFactoryImpl ecfi, Closure closure) {\n            this.ecfi = ecfi;\n            threadEci = null;\n            this.closure = closure;\n        }\n\n        @Override\n        public void run() {\n            if (threadEci != null) {\n                // ecfi.useExecutionContextInThread(threadEci);\n                ExecutionContextImpl eci = ecfi.getEci();\n                String threadUsername = threadEci.userFacade.getUsername();\n                if (threadUsername != null && !threadUsername.isEmpty())\n                    eci.userFacade.internalLoginUser(threadUsername, false);\n                if (threadEci.artifactExecutionFacade.authzDisabled)\n                    eci.artifactExecutionFacade.disableAuthz();\n            }\n            try {\n                closure.call();\n            } catch (Throwable t) {\n                loggerDirect.error(\"Error in EC worker Runnable\", t);\n            } finally {\n                // now using separate ECI in thread so always destroy, ie don't do: if (threadEci == null)\n                ecfi.destroyActiveExecutionContext();\n            }\n        }\n\n        public ExecutionContextFactoryImpl getEcfi() { return ecfi; }\n        public void setEcfi(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi; }\n        public Closure getClosure() { return closure; }\n        public void setClosure(Closure closure) { this.closure = closure; }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/L10nFacadeImpl.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context;\n\nimport org.moqui.BaseArtifactException;\nimport org.moqui.context.L10nFacade;\nimport org.moqui.entity.EntityCondition;\nimport org.moqui.entity.EntityValue;\nimport org.moqui.entity.EntityFind;\n\nimport groovy.json.JsonOutput;\n\nimport jakarta.xml.bind.DatatypeConverter;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.text.DecimalFormat;\nimport java.text.DecimalFormatSymbols;\nimport java.text.NumberFormat;\nimport java.sql.Date;\nimport java.sql.Time;\nimport java.sql.Timestamp;\nimport java.util.*;\n\nimport org.apache.commons.validator.routines.BigDecimalValidator;\nimport org.apache.commons.validator.routines.CalendarValidator;\n\nimport org.moqui.util.ObjectUtilities;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\npublic class L10nFacadeImpl implements L10nFacade {\n    protected final static Logger logger = LoggerFactory.getLogger(L10nFacadeImpl.class);\n\n    final static BigDecimalValidator bigDecimalValidator = new BigDecimalValidator(false);\n    final static CalendarValidator calendarValidator = new CalendarValidator();\n\n    protected final ExecutionContextImpl eci;\n\n    public L10nFacadeImpl(ExecutionContextImpl eci) { this.eci = eci; }\n\n    protected Locale getLocale() { return eci.userFacade.getLocale(); }\n    protected TimeZone getTimeZone() { return eci.userFacade.getTimeZone(); }\n\n    @Override\n    public String localize(String original) { return localize(original, getLocale()); }\n    @Override\n    public String localize(String original, Locale locale) {\n        if (original == null) return \"\";\n        int originalLength = original.length();\n        if (originalLength == 0) return \"\";\n        if (originalLength > 255) {\n            throw new BaseArtifactException(\"Original String cannot be more than 255 characters long, passed in string was \" + originalLength + \" characters long\");\n        }\n\n        if (locale == null) locale = getLocale();\n        String localeString = locale.toString();\n\n        String cacheKey = original.concat(\"::\").concat(localeString);\n        String lmsg = eci.getL10nMessageCache().get(cacheKey);\n        if (lmsg != null) return lmsg;\n\n        String defaultValue = original;\n        int localeUnderscoreIndex = localeString.indexOf('_');\n\n        EntityFind find = eci.getEntity().find(\"moqui.basic.LocalizedMessage\")\n                .condition(\"original\", original).condition(\"locale\", localeString).useCache(true);\n        EntityValue localizedMessage = find.one();\n        if (localizedMessage == null && localeUnderscoreIndex > 0)\n            localizedMessage = find.condition(\"locale\", localeString.substring(0, localeUnderscoreIndex)).one();\n        if (localizedMessage == null)\n            localizedMessage = find.condition(\"locale\", \"default\").one();\n\n        // if original has a hash and we still don't have a localizedMessage then use what precedes the hash and try again\n        if (localizedMessage == null) {\n            int indexOfCloseCurly = original.lastIndexOf('}');\n            int indexOfHash = original.lastIndexOf(\"##\");\n            if (indexOfHash > 0 && indexOfHash > indexOfCloseCurly) {\n                defaultValue = original.substring(0, indexOfHash);\n                EntityFind findHash = eci.getEntity().find(\"moqui.basic.LocalizedMessage\")\n                        .condition(\"original\", defaultValue).condition(\"locale\", localeString).useCache(true);\n                localizedMessage = findHash.one();\n                if (localizedMessage == null && localeUnderscoreIndex > 0)\n                    localizedMessage = findHash.condition(\"locale\", localeString.substring(0, localeUnderscoreIndex)).one();\n                if (localizedMessage == null)\n                    localizedMessage = findHash.condition(\"locale\", \"default\").one();\n            }\n        }\n\n        String result = localizedMessage != null ? localizedMessage.getString(\"localized\") : defaultValue;\n        eci.getL10nMessageCache().put(cacheKey, result);\n        return result;\n    }\n\n    @Override\n    public String formatCurrencyNoSymbol(Object amount, String uomId) { return formatCurrency(amount, uomId, null, getLocale(), true); }\n    @Override\n    public String formatCurrency(Object amount, String uomId) { return formatCurrency(amount, uomId, null, getLocale(), false); }\n    @Override\n    public String formatCurrency(Object amount, String uomId, Integer fractionDigits) { return formatCurrency(amount, uomId, fractionDigits, getLocale(), false); }\n    @Override\n    public String formatCurrency(Object amount, String uomId, Integer fractionDigits, Locale locale) { return formatCurrency(amount, uomId, fractionDigits, locale, false); }\n    public String formatCurrency(Object amount, String uomId, Integer fractionDigits, Locale locale, boolean hideSymbol) {\n        if (amount == null) return \"\";\n        if (amount instanceof CharSequence) {\n            if (((CharSequence) amount).length() == 0) {\n                return \"\";\n            } else {\n                amount = parseNumber((String) amount, null);\n            }\n        }\n\n        if (locale == null) locale = getLocale();\n        NumberFormat nf = NumberFormat.getCurrencyInstance(locale);\n        String currencySymbol = null;\n        if (hideSymbol)\n            currencySymbol = \"\";\n        EntityValue uom = null;\n        if (uomId != null && uomId.length() > 0) {\n            List<EntityValue> uomList = eci.getEntity().find(\"moqui.basic.Uom\").condition(\"uomId\", uomId)\n                    .condition(\"uomTypeEnumId\", \"UT_CURRENCY_MEASURE\").disableAuthz().list();\n            if (uomList.size() > 0) {\n                uom = uomList.get(0);\n                String symbol = uom.getString(\"symbol\");\n                if (currencySymbol == null && symbol != null)\n                    currencySymbol = symbol;\n                Object fractionDigitsField = uom.get(\"fractionDigits\");\n                if (fractionDigits == null && fractionDigitsField != null) {\n                    if (fractionDigitsField instanceof Integer)\n                        fractionDigits = (Integer)fractionDigitsField;\n                    else if (fractionDigitsField instanceof Long)\n                        fractionDigits = ((Long)fractionDigitsField).intValue();\n                }\n            }\n        }\n\n        Currency currency = null;\n        if (uomId != null && uomId.length() > 0) {\n            try {\n                currency = Currency.getInstance(uomId);\n                if (currencySymbol == null)\n                    currencySymbol = currency.getSymbol();\n                if (fractionDigits == null)\n                    fractionDigits = currency.getDefaultFractionDigits();\n            } catch (Exception e) {\n                if (logger.isTraceEnabled()) logger.trace(\"Ignoring IllegalArgumentException for Currency parse: \" + e.toString());\n            }\n        }\n        if (currencySymbol == null)\n            currencySymbol = \"\";\n\n        if (fractionDigits == null)\n            fractionDigits = 2;\n        nf.setMaximumFractionDigits(fractionDigits);\n        nf.setMinimumFractionDigits(fractionDigits);\n        DecimalFormatSymbols dfSymbols = new DecimalFormatSymbols(locale);\n        dfSymbols.setCurrencySymbol(currencySymbol);\n        ((DecimalFormat)nf).setDecimalFormatSymbols(dfSymbols);\n\n        return nf.format(amount);\n    }\n\n    @Override\n    public BigDecimal roundCurrency(BigDecimal amount, String uomId) { return roundCurrency(amount, uomId, false); }\n    @Override\n    public BigDecimal roundCurrency(BigDecimal amount, String uomId, boolean precise) { return roundCurrency(amount, uomId, false, RoundingMode.HALF_UP); }\n    @Override\n    public BigDecimal roundCurrency(BigDecimal amount, String uomId, boolean precise, int roundingMethod) {\n        return roundCurrency(amount, uomId, precise, RoundingMode.valueOf(roundingMethod));\n    }\n    @Override\n    public BigDecimal roundCurrency(BigDecimal amount, String uomId, boolean precise, RoundingMode mode) {\n        if (amount == null)\n            return null;\n        List<EntityValue> uomList = eci.getEntity().find(\"moqui.basic.Uom\").condition(\"uomId\", uomId).condition(\"uomTypeEnumId\", \"UT_CURRENCY_MEASURE\").list();\n        Integer fractionDigits = null;\n        if (uomList.size() > 0) {\n            Object fractionDigitsField = uomList.get(0).get(\"fractionDigits\");\n            if (fractionDigitsField != null) {\n                if (fractionDigitsField instanceof Integer)\n                    fractionDigits = (Integer)fractionDigitsField;\n                else if (fractionDigitsField instanceof Long)\n                    fractionDigits = ((Long)fractionDigitsField).intValue();\n            }\n        }\n        if (fractionDigits == null) {\n            Currency currency = Currency.getInstance(uomId);\n            fractionDigits = currency.getDefaultFractionDigits();\n        }\n        if (fractionDigits == null) {\n            fractionDigits = 2;\n        }\n        if (precise) fractionDigits++;\n        eci.getLogger().info(\"Rounding to \" + fractionDigits + \" digits.\");\n        return amount.setScale(fractionDigits, mode);\n    }\n\n    @Override\n    public Time parseTime(String input, String format) {\n        Locale curLocale = getLocale();\n        TimeZone curTz = getTimeZone();\n        if (format == null || format.isEmpty()) format = \"HH:mm:ss.SSS\";\n        Calendar cal = calendarValidator.validate(input, format, curLocale, curTz);\n        if (cal == null) cal = calendarValidator.validate(input, \"HH:mm:ss\", curLocale, curTz);\n        if (cal == null) cal = calendarValidator.validate(input, \"HH:mm\", curLocale, curTz);\n        if (cal == null) cal = calendarValidator.validate(input, \"h:mm a\", curLocale, curTz);\n        if (cal == null) cal = calendarValidator.validate(input, \"h:mm:ss a\", curLocale, curTz);\n        // also try the full ISO-8601, times may come in that way (even if funny with a date of 1970-01-01)\n        if (cal == null) cal = calendarValidator.validate(input, \"yyyy-MM-dd'T'HH:mm:ssZ\", curLocale, curTz);\n        if (cal != null) {\n            Time time = new Time(cal.getTimeInMillis());\n            // logger.warn(\"============== parseTime input=${input} cal=${cal} long=${cal.getTimeInMillis()} time=${time} time long=${time.getTime()} util date=${new java.util.Date(cal.getTimeInMillis())} timestamp=${new java.sql.Timestamp(cal.getTimeInMillis())}\")\n            return time;\n        }\n\n        // try interpreting the String as a long\n        try {\n            Long lng = Long.valueOf(input);\n            return new Time(lng);\n        } catch (NumberFormatException e) {\n            if (logger.isTraceEnabled()) logger.trace(\"Ignoring NumberFormatException for Time parse: \" + e.toString());\n        }\n\n        return null;\n    }\n    public String formatTime(Time input, String format, Locale locale, TimeZone tz) {\n        if (locale == null) locale = getLocale();\n        if (tz == null) tz = getTimeZone();\n        if (format == null || format.isEmpty()) format = \"HH:mm:ss\";\n        String timeStr = calendarValidator.format(input, format, locale, tz);\n        // logger.warn(\"============= formatTime input=${input} timeStr=${timeStr} long=${input.getTime()}\")\n        return timeStr;\n    }\n\n    @Override\n    public java.sql.Date parseDate(String input, String format) {\n        if (format == null || format.isEmpty()) format = \"yyyy-MM-dd\";\n        Locale curLocale = getLocale();\n\n        // NOTE DEJ 20150317 Date parsing in terms of time zone causes funny issues because the time part of the long\n        //   since epoch representation is lost going to/from the DB, especially since the time portion is set to 0 and\n        //   with time zone conversion when the system date is in an earlier time zone than the user date it pushes the\n        //   Date to the previous day; what seems like the best solution is to parse and save the Date in the\n        //   system/default time zone, and format it that way as well.\n        // The BIG dilemma is there is no way to represent a Date (yyyy-MM-dd) in an object that does not use the long\n        //   since epoch but rather is an absolute year, month, and day... which is really what we want.\n        /*\n        TimeZone curTz = getTimeZone()\n        Calendar cal = calendarValidator.validate(input, format, curLocale, curTz)\n        if (cal == null) cal = calendarValidator.validate(input, \"MM/dd/yyyy\", curLocale, curTz)\n        // also try the full ISO-8601, dates may come in that way\n        if (cal == null) cal = calendarValidator.validate(input, \"yyyy-MM-dd'T'HH:mm:ssZ\", curLocale, curTz)\n        */\n\n        Calendar cal = calendarValidator.validate(input, format, curLocale);\n        if (cal == null) cal = calendarValidator.validate(input, \"MM/dd/yyyy\", curLocale);\n        // also try the full ISO-8601, dates may come in that way\n        if (cal == null) cal = calendarValidator.validate(input, \"yyyy-MM-dd'T'HH:mm:ssZ\", curLocale);\n        if (cal != null) {\n            java.sql.Date date = new java.sql.Date(cal.getTimeInMillis());\n            // logger.warn(\"============== parseDate input=${input} cal=${cal} long=${cal.getTimeInMillis()} date=${date} date long=${date.getTime()} util date=${new java.util.Date(cal.getTimeInMillis())} timestamp=${new java.sql.Timestamp(cal.getTimeInMillis())}\")\n            return date;\n        }\n\n        // try interpreting the String as a long\n        try {\n            long lng = Long.parseLong(input);\n            return new java.sql.Date(lng);\n        } catch (NumberFormatException e) {\n            if (logger.isTraceEnabled()) logger.trace(\"Ignoring NumberFormatException for Date parse: \" + e.toString());\n        }\n\n        return null;\n    }\n    public String formatDate(java.util.Date input, String format, Locale locale, TimeZone tz) {\n        if (locale == null) locale = getLocale();\n        // if (tz == null) tz = getTimeZone();\n        if (format == null || format.isEmpty()) format = \"yyyy-MM-dd\";\n        // See comment in parseDate for why we are ignoring the time zone\n        // String dateStr = calendarValidator.format(input, format, getLocale(), getTimeZone())\n        String dateStr = calendarValidator.format(input, format, locale);\n        // logger.warn(\"============= formatDate input=${input} dateStr=${dateStr} long=${input.getTime()}\")\n        return dateStr;\n    }\n\n    static final ArrayList<String> timestampFormats;\n    static {\n        timestampFormats = new ArrayList<>();\n        timestampFormats.add(\"yyyy-MM-dd HH:mm\"); timestampFormats.add(\"yyyy-MM-dd HH:mm:ss.SSS\");\n        timestampFormats.add(\"yyyy-MM-dd'T'HH:mm:ss\"); timestampFormats.add(\"yyyy-MM-dd'T'HH:mm:ssZ\");\n        timestampFormats.add(\"yyyy-MM-dd'T'HH:mm:ss.SSSZ\");\n        timestampFormats.add(\"yyyy-MM-dd HH:mm:ss\"); timestampFormats.add(\"yyyy-MM-dd\");\n        timestampFormats.add(\"yyyy-MM-dd HH:mm:ss.SSS z\");\n    }\n\n    @Override\n    public Timestamp parseTimestamp(String input, String format) {\n        if (input == null || input.isEmpty()) return null;\n        return parseTimestamp(input, format, null, null);\n    }\n    @Override\n    public Timestamp parseTimestamp(final String input, final String format, final Locale locale, final TimeZone timeZone) {\n        if (input == null || input.isEmpty()) return null;\n        Locale curLocale = locale != null ? locale : getLocale();\n        TimeZone curTz = timeZone != null ? timeZone : getTimeZone();\n        Calendar cal = null;\n        if (format != null && !format.isEmpty()) cal = calendarValidator.validate(input, format, curLocale, curTz);\n\n        // long values are pretty common, so if there are no special characters try that first (fast to check)\n        if (cal == null) {\n            int nonDigits = ObjectUtilities.countChars(input, false, true, true);\n            if (nonDigits == 0 || (nonDigits == 1 && input.startsWith(\"-\"))) {\n                try {\n                    long lng = Long.parseLong(input);\n                    return new Timestamp(lng);\n                } catch (NumberFormatException e) {\n                    if (logger.isTraceEnabled()) logger.trace(\"Ignoring NumberFormatException for Timestamp parse: \" + e.toString());\n                }\n            }\n        }\n\n        // try a bunch of other format strings\n        if (cal == null) {\n            int timestampFormatsSize = timestampFormats.size();\n            for (int i = 0; cal == null && i < timestampFormatsSize; i++) {\n                String tf = timestampFormats.get(i);\n                cal = calendarValidator.validate(input, tf, curLocale, curTz);\n            }\n        }\n\n        // logger.warn(\"=========== input=${input}, cal=${cal}, long=${cal?.getTimeInMillis()}, locale=${curLocale}, timeZone=${curTz}, System=${System.currentTimeMillis()}\")\n        if (cal != null) return new Timestamp(cal.getTimeInMillis());\n\n        try {\n            // NOTE: do this AFTER the long parse because long numbers are interpreted really weird by this\n            // ISO 8601 parsing using JAXB DatatypeConverter.parseDateTime(); on Java 7 can use \"X\" instead of \"Z\" in format string, but not in Java 6\n            cal = DatatypeConverter.parseDateTime(input);\n            if (cal != null) return new Timestamp(cal.getTimeInMillis());\n        } catch (Exception e) {\n            if (logger.isTraceEnabled()) logger.trace(\"Ignoring Exception for DatatypeConverter Timestamp parse: \" + e.toString());\n        }\n\n        return null;\n    }\n    public static String formatTimestamp(java.util.Date input, String format, Locale locale, TimeZone tz) {\n        if (format == null || format.isEmpty()) format = \"yyyy-MM-dd HH:mm\";\n        return calendarValidator.format(input, format, locale, tz);\n    }\n\n    @Override public Calendar parseDateTime(String input, String format) {\n        return calendarValidator.validate(input, format, getLocale(), getTimeZone()); }\n    @Override public String formatDateTime(Calendar input, String format, Locale locale, TimeZone tz) {\n        if (locale == null) locale = getLocale();\n        if (tz == null) tz = getTimeZone();\n        return calendarValidator.format(input, format, locale, tz);\n    }\n\n    @Override public BigDecimal parseNumber(String input, String format) {\n        return bigDecimalValidator.validate(input, format, getLocale()); }\n    @Override public String formatNumber(Number input, String format, Locale locale) {\n        if (locale == null) locale = getLocale();\n        if (format == null || format.isEmpty()) {\n            // BigDecimalValidator defaults to 3 decimal digits, if no format specified we don't want to truncate so small, use better defaults\n            NumberFormat nf = locale != null ? NumberFormat.getNumberInstance(locale) : NumberFormat.getNumberInstance();\n            nf.setMinimumFractionDigits(0);\n            nf.setMaximumFractionDigits(12);\n            nf.setMinimumIntegerDigits(1);\n            nf.setGroupingUsed(true);\n            return nf.format(input);\n        } else {\n            return bigDecimalValidator.format(input, format, locale);\n        }\n    }\n\n    @Override\n    public String format(Object value, String format) {\n        return this.format(value, format, getLocale(), getTimeZone());\n    }\n    @Override\n    public String format(Object value, String format, Locale locale, TimeZone tz) {\n        if (value == null) return \"\";\n        if (locale == null) locale = getLocale();\n        if (tz == null) tz = getTimeZone();\n        Class<?> valueClass = value.getClass();\n        if (valueClass == String.class) return (String) value;\n        if (valueClass == Timestamp.class) return formatTimestamp((Timestamp) value, format, locale, tz);\n        if (valueClass == java.util.Date.class) return formatTimestamp((java.util.Date) value, format, locale, tz);\n        if (valueClass == java.sql.Date.class) return formatDate((Date) value, format, locale, tz);\n        if (valueClass == Time.class) return formatTime((Time) value, format, locale, tz);\n        // this one needs to be instanceof to include the many sub-classes of Number\n        if (value instanceof Number) return formatNumber((Number) value, format, locale);\n        // Calendar is an abstract class, so must use instanceof here as well\n        if (value instanceof Calendar) return formatDateTime((Calendar) value, format, locale, tz);\n        // support formatting of Map and Collection using JSON\n        if (value instanceof Map || value instanceof Collection) {\n            String json = JsonOutput.toJson(value);\n            return (json.length() > 128) ? JsonOutput.prettyPrint(json) : json;\n        }\n        return value.toString();\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/LoggerFacadeImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context\n\nimport org.moqui.context.LoggerFacade\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n\nclass LoggerFacadeImpl implements LoggerFacade {\n    protected final static Logger logger = LoggerFactory.getLogger(LoggerFacadeImpl.class)\n\n    protected final ExecutionContextFactoryImpl ecfi\n\n    LoggerFacadeImpl(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi }\n\n    void log(String levelStr, String message, Throwable thrown) {\n        int level\n        switch (levelStr) {\n            case \"trace\": level = TRACE_INT; break\n            case \"debug\": level = DEBUG_INT; break\n            case \"info\": level = INFO_INT; break\n            case \"warn\": level = WARN_INT; break\n            case \"error\": level = ERROR_INT; break\n            case \"off\": // do nothing\n            default: return\n        }\n        log(level, message, thrown)\n    }\n\n    @Override\n    void log(int level, String message, Throwable thrown) {\n        switch (level) {\n            case TRACE_INT: logger.trace(message, thrown); break\n            case DEBUG_INT: logger.debug(message, thrown); break\n            case INFO_INT: logger.info(message, thrown); break\n            case WARN_INT: logger.warn(message, thrown); break\n            case ERROR_INT: logger.error(message, thrown); break\n            case FATAL_INT: logger.error(message, thrown); break\n            case ALL_INT: logger.warn(message, thrown); break\n            case OFF_INT: break // do nothing\n        }\n    }\n\n    void trace(String message) { log(TRACE_INT, message, null) }\n    void debug(String message) { log(DEBUG_INT, message, null) }\n    void info(String message) { log(INFO_INT, message, null) }\n    void warn(String message) { log(WARN_INT, message, null) }\n    void error(String message) { log(ERROR_INT, message, null) }\n\n    void trace(String message, Throwable thrown) { log(TRACE_INT, message, thrown) }\n    void debug(String message, Throwable thrown) { log(DEBUG_INT, message, thrown) }\n    void info(String message, Throwable thrown) { log(INFO_INT, message, thrown) }\n    void warn(String message, Throwable thrown) { log(WARN_INT, message, thrown) }\n    void error(String message, Throwable thrown) { log(ERROR_INT, message, thrown) }\n\n    @Override\n    boolean logEnabled(int level) {\n        switch (level) {\n            case TRACE_INT: return logger.isTraceEnabled()\n            case DEBUG_INT: return logger.isDebugEnabled()\n            case INFO_INT: return logger.isInfoEnabled()\n            case WARN_INT: return logger.isWarnEnabled()\n            case ERROR_INT:\n            case FATAL_INT: return logger.isErrorEnabled()\n            case ALL_INT: return logger.isWarnEnabled()\n            case OFF_INT: return false\n            default: return false\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/MessageFacadeImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context\n\nimport groovy.transform.CompileStatic\nimport org.moqui.context.MessageFacade\nimport org.moqui.context.MessageFacade.MessageInfo\nimport org.moqui.context.NotificationMessage.NotificationType\nimport org.moqui.context.ValidationError\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n@CompileStatic\nclass MessageFacadeImpl implements MessageFacade {\n    protected final static Logger logger = LoggerFactory.getLogger(MessageFacadeImpl.class)\n\n    private final static List<String> emptyStringList = Collections.unmodifiableList(new ArrayList<String>())\n    private final static List<ValidationError> emptyValidationErrorList = Collections.unmodifiableList(new ArrayList<ValidationError>())\n    private final static List<MessageInfo> emptyMessageInfoList = Collections.unmodifiableList(new ArrayList<MessageInfo>())\n\n    private ArrayList<MessageInfo> messageList = (ArrayList<MessageInfo>) null\n    private ArrayList<MessageInfo> publicMessageList = (ArrayList<MessageInfo>) null\n    private ArrayList<String> errorList = (ArrayList<String>) null\n    private ArrayList<ValidationError> validationErrorList = (ArrayList<ValidationError>) null\n    private boolean hasErrors = false\n\n    private LinkedList<SavedErrors> savedErrorsStack = (LinkedList<SavedErrors>) null\n\n    MessageFacadeImpl() { }\n\n    @Override\n    List<String> getMessages() {\n        if (messageList == null) return emptyStringList\n        ArrayList<String> strList = new ArrayList<>(messageList.size())\n        for (int i = 0; i < messageList.size(); i++) strList.add(((MessageInfo) messageList.get(i)).getMessage())\n        return strList\n    }\n    @Override\n    List<MessageInfo> getMessageInfos() {\n        if (messageList == null) return emptyMessageInfoList\n        return Collections.unmodifiableList(messageList)\n    }\n    @Override\n    String getMessagesString() {\n        if (messageList == null) return \"\"\n        StringBuilder messageBuilder = new StringBuilder()\n        for (MessageInfo message in messageList) messageBuilder.append(message.getMessage()).append(\"\\n\")\n        return messageBuilder.toString()\n    }\n    @Override void addMessage(String message) { addMessage(message, info) }\n    @Override void addMessage(String message, NotificationType type) { addMessage(message, type?.toString()) }\n    @Override\n    void addMessage(String message, String type) {\n        if (message == null || message.isEmpty()) return\n        if (messageList == null) messageList = new ArrayList<>()\n        MessageInfo mi = new MessageInfo(message, type)\n        messageList.add(mi)\n        logger.info(mi.toString())\n    }\n\n    @Override void addPublic(String message, NotificationType type) { addPublic(message, type?.toString()) }\n    @Override\n    void addPublic(String message, String type) {\n        if (message == null || message.isEmpty()) return\n        if (publicMessageList == null) publicMessageList = new ArrayList<>()\n        if (messageList == null) messageList = new ArrayList<>()\n        MessageInfo mi = new MessageInfo(message, type)\n        publicMessageList.add(mi)\n        messageList.add(mi)\n        logger.info(mi.toString())\n    }\n\n    @Override\n    List<String> getPublicMessages() {\n        if (publicMessageList == null) return emptyStringList\n        ArrayList<String> strList = new ArrayList<>(publicMessageList.size())\n        for (int i = 0; i < publicMessageList.size(); i++) strList.add(((MessageInfo) publicMessageList.get(i)).getMessage())\n        return strList\n    }\n    @Override\n    List<MessageInfo> getPublicMessageInfos() {\n        if (publicMessageList == null) return emptyMessageInfoList\n        return Collections.unmodifiableList(publicMessageList)\n    }\n\n    @Override\n    List<String> getErrors() {\n        if (errorList == null) return emptyStringList\n        return Collections.unmodifiableList(errorList)\n    }\n    @Override\n    void addError(String error) {\n        if (error == null || error.isEmpty()) return\n        if (errorList == null) errorList = new ArrayList<>()\n        errorList.add(error)\n        logger.error(error)\n        hasErrors = true\n    }\n\n    @Override\n    List<ValidationError> getValidationErrors() {\n        if (validationErrorList == null) return emptyValidationErrorList\n        return Collections.unmodifiableList(validationErrorList)\n    }\n    @Override\n    void addValidationError(String form, String field, String serviceName, String message, Throwable nested) {\n        if (message == null || message.isEmpty()) return\n        if (validationErrorList == null) validationErrorList = new ArrayList<>()\n        ValidationError ve = new ValidationError(form, field, serviceName, message, nested)\n        validationErrorList.add(ve)\n        logger.error(ve.getMap().toString())\n        hasErrors = true\n    }\n    @Override void addError(ValidationError error) {\n        if (error == null) return\n        if (validationErrorList == null) validationErrorList = new ArrayList<>()\n        validationErrorList.add(error)\n        logger.error(error.getMap().toString())\n        hasErrors = true\n    }\n\n    @Override boolean hasError() { return hasErrors }\n    @Override\n    String getErrorsString() {\n        StringBuilder errorBuilder = new StringBuilder()\n        if (errorList != null) for (String errorMessage in errorList) errorBuilder.append(errorMessage).append(\"\\n\")\n        if (validationErrorList != null) for (ValidationError validationError in validationErrorList) {\n            errorBuilder.append(validationError.toStringPretty()).append(\"\\n\")\n        }\n        return errorBuilder.toString()\n    }\n\n    @Override\n    void clearAll() {\n        clearErrors()\n        if (messageList != null) messageList.clear()\n        if (publicMessageList != null) publicMessageList.clear()\n    }\n    @Override\n    void clearErrors() {\n        if (messageList == null) messageList = new ArrayList<>()\n        if (errorList != null) {\n            for (int i = 0; i < errorList.size(); i++) {\n                String errMsg = (String) errorList.get(i)\n                messageList.add(new MessageInfo(errMsg, NotificationType.danger))\n            }\n            errorList.clear()\n        }\n        if (validationErrorList != null) {\n            for (int i = 0; i < validationErrorList.size(); i++) {\n                ValidationError error = (ValidationError) validationErrorList.get(i)\n                messageList.add(new MessageInfo(error.toStringPretty(), NotificationType.danger))\n            }\n            validationErrorList.clear()\n        }\n        hasErrors = false\n    }\n\n    void moveErrorsToDangerMessages() {\n        if (errorList != null) {\n            for (String errMsg : errorList) addMessage(errMsg, danger)\n            errorList.clear()\n        }\n        if (validationErrorList != null) {\n            for (ValidationError ve : validationErrorList) addMessage(ve.toStringPretty(), danger)\n            validationErrorList.clear()\n        }\n        hasErrors = false\n    }\n\n    @Override\n    void copyMessages(MessageFacade mf) {\n        if (mf.getMessageInfos()) {\n            if (messageList == null) messageList = new ArrayList<>()\n            messageList.addAll(mf.getMessageInfos())\n        }\n        if (mf.getErrors()) {\n            if (errorList == null) errorList = new ArrayList<>()\n            errorList.addAll(mf.getErrors())\n            hasErrors = true\n        }\n        if (mf.getValidationErrors()) {\n            if (validationErrorList == null) validationErrorList = new ArrayList<>()\n            validationErrorList.addAll(mf.getValidationErrors())\n            hasErrors = true\n        }\n        if (mf.getPublicMessageInfos()) {\n            if (publicMessageList == null) publicMessageList = new ArrayList<>()\n            publicMessageList.addAll(mf.getPublicMessageInfos())\n        }\n    }\n\n    @Override\n    void pushErrors() {\n        if (savedErrorsStack == null) savedErrorsStack = new LinkedList<SavedErrors>()\n        savedErrorsStack.addFirst(new SavedErrors(errorList, validationErrorList))\n        errorList = null\n        validationErrorList = null\n        hasErrors = false\n    }\n    @Override\n    void popErrors() {\n        if (savedErrorsStack == null || savedErrorsStack.size() == 0) return\n        SavedErrors se = savedErrorsStack.removeFirst()\n        if (se.errorList != null && se.errorList.size() > 0) {\n            if (errorList == null) errorList = new ArrayList<>()\n            errorList.addAll(se.errorList)\n            hasErrors = true\n        }\n        if (se.validationErrorList != null && se.validationErrorList.size() > 0) {\n            if (validationErrorList == null) validationErrorList = new ArrayList<>()\n            validationErrorList.addAll(se.validationErrorList)\n            hasErrors = true\n        }\n    }\n\n    static class SavedErrors {\n        List<String> errorList\n        List<ValidationError> validationErrorList\n        SavedErrors(List<String> errorList, List<ValidationError> validationErrorList) {\n            this.errorList = errorList\n            this.validationErrorList = validationErrorList\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/NotificationMessageImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context\n\nimport groovy.json.JsonOutput\nimport groovy.json.JsonSlurper\nimport groovy.transform.CompileStatic\nimport org.moqui.BaseArtifactException\nimport org.moqui.Moqui\nimport org.moqui.context.ExecutionContext\nimport org.moqui.context.NotificationMessage\nimport org.moqui.entity.EntityFacade\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityValue\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.sql.Timestamp\n\n@CompileStatic\nclass NotificationMessageImpl implements NotificationMessage, Externalizable {\n    private final static Logger logger = LoggerFactory.getLogger(NotificationMessageImpl.class)\n\n    private Set<String> userIdSet = new HashSet()\n    private String userGroupId = (String) null\n    private String topic = (String) null\n    private String subTopic = (String) null\n    private transient EntityValue notificationTopic = (EntityValue) null\n    private String messageJson = (String) null\n    private transient Map<String, Object> messageMap = (Map<String, Object>) null\n    private String notificationMessageId = (String) null\n    private Timestamp sentDate = (Timestamp) null\n\n    private String titleTemplate = (String) null\n    private String linkTemplate = (String) null\n    private String titleText = (String) null\n    private String linkText = (String) null\n\n    private NotificationType type = (NotificationType) null\n    private Boolean showAlert = (Boolean) null\n    private Boolean alertNoAutoHide = (Boolean) null\n    private Boolean persistOnSend = (Boolean) null\n    private String emailTemplateId = (String) null\n    private Boolean emailMessageSave = (Boolean) null\n\n    private Map<String, String> emailMessageIdByUserId = (Map<String, String>) null\n\n    private transient ExecutionContextFactoryImpl ecfiTransient = (ExecutionContextFactoryImpl) null\n\n    /** Default constructor for deserialization */\n    NotificationMessageImpl() { }\n    NotificationMessageImpl(ExecutionContextFactoryImpl ecfi) { ecfiTransient = ecfi }\n\n    ExecutionContextFactoryImpl getEcfi() {\n        if (ecfiTransient == null) ecfiTransient = (ExecutionContextFactoryImpl) Moqui.getExecutionContextFactory()\n        return ecfiTransient\n    }\n    EntityValue getNotificationTopic() {\n        if (notificationTopic == null && topic != null && !topic.isEmpty())\n            notificationTopic = ecfi.entityFacade.fastFindOne(\"moqui.security.user.NotificationTopic\", true, true, topic)\n        return notificationTopic\n    }\n\n    @Override NotificationMessage userId(String userId) { userIdSet.add(userId); return this }\n    @Override NotificationMessage userIds(Set<String> userIds) { userIdSet.addAll(userIds); return this }\n    @Override Set<String> getUserIds() { userIdSet }\n\n    @Override NotificationMessage userGroupId(String userGroupId) { this.userGroupId = userGroupId; return this }\n    @Override String getUserGroupId() { userGroupId }\n\n    @Override Set<String> getNotifyUserIds() {\n        Set<String> notifyUserIds = new HashSet<>()\n        Set<String> checkedUserIds = new HashSet<>()\n        EntityFacade ef = ecfi.entityFacade\n\n        for (String userId in userIdSet) {\n            checkedUserIds.add(userId)\n            if (checkUserNotify(userId, ef)) notifyUserIds.add(userId)\n        }\n\n        // notify by group, skipping users already notified\n        if (userGroupId) {\n            ef.find(\"moqui.security.UserGroupMember\")\n                    .conditionDate(\"fromDate\", \"thruDate\", new Timestamp(System.currentTimeMillis()))\n                    .condition(\"userGroupId\", userGroupId).disableAuthz().iterator().withCloseable ({eli ->\n                EntityValue nextValue\n                while ((nextValue = (EntityValue) eli.next()) != null) {\n                    String userId = (String) nextValue.userId\n                    if (checkedUserIds.contains(userId)) continue\n                    checkedUserIds.add(userId)\n                    if (checkUserNotify(userId, ef)) notifyUserIds.add(userId)\n                }\n            })\n        }\n\n        // add all users subscribed to all messages on the topic\n        EntityList allNotificationUsers = ef.find(\"moqui.security.user.NotificationTopicUser\")\n                .condition(\"topic\", topic).condition(\"allNotifications\", \"Y\").useCache(true).disableAuthz().list()\n        int allNotificationUsersSize = allNotificationUsers.size()\n        for (int i = 0; i < allNotificationUsersSize; i++) {\n            EntityValue allNotificationUser = (EntityValue) allNotificationUsers.get(i)\n            notifyUserIds.add((String) allNotificationUser.userId)\n        }\n\n        // check each user to see if account terminated (UserAccount.terminateDate != null && < now)\n        long nowTime = System.currentTimeMillis()\n        EntityList notifyUserAccountList = ef.find(\"moqui.security.UserAccount\")\n                .condition(\"userId\", \"in\", notifyUserIds)\n                .selectField(\"userId\").selectField(\"terminateDate\").disableAuthz().list()\n        int notifyUaSize = notifyUserAccountList.size()\n        for (int i = 0; i < notifyUaSize; i++) {\n            EntityValue userAccount = (EntityValue) notifyUserAccountList.get(i)\n            Timestamp terminateDate = (Timestamp) userAccount.getNoCheckSimple(\"terminateDate\")\n            if (terminateDate != (Timestamp) null && nowTime > terminateDate.getTime()) notifyUserIds.remove(userAccount.get(\"userId\"))\n        }\n\n        return notifyUserIds\n    }\n    private boolean checkUserNotify(String userId, EntityFacade ef) {\n        EntityValue notTopicUser = ef.find(\"moqui.security.user.NotificationTopicUser\")\n                .condition(\"topic\", topic).condition(\"userId\", userId).useCache(true).disableAuthz().one()\n        boolean notifyUser = true\n        if (notTopicUser != null && notTopicUser.receiveNotifications) {\n            notifyUser = notTopicUser.receiveNotifications == 'Y'\n        } else {\n            EntityValue localNotTopic = getNotificationTopic()\n            if (localNotTopic != null && localNotTopic.receiveNotifications)\n                notifyUser = localNotTopic.receiveNotifications == 'Y'\n        }\n        return notifyUser\n    }\n\n    @Override NotificationMessage topic(String topic) { this.topic = topic; notificationTopic = null; return this }\n    @Override String getTopic() { topic }\n\n    @Override String getSubTopic() { subTopic }\n    @Override NotificationMessage subTopic(String st) { subTopic = st; return this }\n\n    @Override NotificationMessage message(String messageJson) { this.messageJson = messageJson; messageMap = null; return this }\n    @Override NotificationMessage message(Map message) {\n        this.messageMap = Collections.unmodifiableMap(message) as Map<String, Object>\n        messageJson = null\n        return this\n    }\n    @Override String getMessageJson() {\n        if (messageJson == null && messageMap != null) {\n            try {\n                messageJson = JsonOutput.toJson(messageMap)\n            } catch (Exception e) {\n                logger.warn(\"Error writing JSON for Notification ${topic} message: ${e.toString()}\\n${messageMap}\")\n            }\n        }\n        return messageJson\n    }\n    @Override Map<String, Object> getMessageMap() {\n        if (messageMap == null && messageJson != null)\n            messageMap = Collections.unmodifiableMap((Map<String, Object>) new JsonSlurper().parseText(messageJson))\n        return messageMap\n    }\n\n    @Override NotificationMessage title(String title) { titleTemplate = title; return this }\n    @Override String getTitle() {\n        if (titleText == null) {\n            if (titleTemplate != null && !titleTemplate.isEmpty())\n                titleText = ecfi.resource.expand(titleTemplate, \"\", getMessageMap(), true)\n            if (titleText == null || titleText.isEmpty()) {\n                EntityValue localNotTopic = getNotificationTopic()\n                if (localNotTopic != null) {\n                    if (type == danger && localNotTopic.errorTitleTemplate) {\n                        titleText = ecfi.resource.expand((String) localNotTopic.errorTitleTemplate, \"\", getMessageMap(), true)\n                    } else if (localNotTopic.titleTemplate) {\n                        titleText = ecfi.resource.expand((String) localNotTopic.titleTemplate, \"\", getMessageMap(), true)\n                    }\n                }\n            }\n        }\n        return titleText\n    }\n\n    @Override NotificationMessage link(String link) { linkTemplate = link; return this }\n    @Override String getLink() {\n        if (linkText == null) {\n            if (linkTemplate) {\n                linkText = ecfi.resource.expand(linkTemplate, \"\", getMessageMap(), true)\n            } else {\n                EntityValue localNotTopic = getNotificationTopic()\n                if (localNotTopic != null && localNotTopic.linkTemplate)\n                    linkText = ecfi.resource.expand((String) localNotTopic.linkTemplate, \"\", getMessageMap(), true)\n            }\n        }\n        return linkText\n    }\n\n    @Override NotificationMessage type(NotificationType type) { this.type = type; return this }\n    @Override NotificationMessage type(String type) { this.type = NotificationType.valueOf(type); return this }\n    @Override String getType() {\n        if (type != null) {\n            return type.name()\n        } else {\n            EntityValue localNotTopic = getNotificationTopic()\n            if (localNotTopic != null && localNotTopic.typeString) {\n                return localNotTopic.typeString\n            } else {\n                return info.name()\n            }\n        }\n    }\n\n    @Override NotificationMessage showAlert(boolean show) { showAlert = show; return this }\n    @Override boolean isShowAlert() {\n        if (showAlert != null) {\n            return showAlert.booleanValue()\n        } else {\n            EntityValue localNotTopic = getNotificationTopic()\n            if (localNotTopic != null && localNotTopic.showAlert) {\n                return localNotTopic.showAlert == 'Y'\n            } else {\n                return false\n            }\n        }\n    }\n\n    @Override NotificationMessage alertNoAutoHide(boolean noAutoHide) { alertNoAutoHide = noAutoHide; return this }\n    @Override boolean isAlertNoAutoHide() {\n        if (alertNoAutoHide != null) {\n            return alertNoAutoHide.booleanValue()\n        } else {\n            EntityValue localNotTopic = getNotificationTopic()\n            if (localNotTopic != null && localNotTopic.alertNoAutoHide) {\n                return localNotTopic.alertNoAutoHide == 'Y'\n            } else {\n                return false\n            }\n        }\n    }\n\n    @Override NotificationMessage emailTemplateId(String id) {\n        emailTemplateId = id\n        if (emailTemplateId != null && emailTemplateId.isEmpty()) emailTemplateId = null\n        return this\n    }\n    @Override String getEmailTemplateId() {\n        if (emailTemplateId != null) {\n            return emailTemplateId\n        } else {\n            EntityValue localNotTopic = getNotificationTopic()\n            if (localNotTopic != null && localNotTopic.emailTemplateId) {\n                return localNotTopic.emailTemplateId\n            } else {\n                return null\n            }\n        }\n    }\n    @Override NotificationMessage emailMessageSave(Boolean save) { emailMessageSave = save; return this }\n    @Override boolean isEmailMessageSave() {\n        if (emailMessageSave != null) {\n            return emailMessageSave.booleanValue()\n        } else {\n            EntityValue localNotTopic = getNotificationTopic()\n            if (localNotTopic != null && localNotTopic.emailMessageSave) {\n                return localNotTopic.emailMessageSave == 'Y'\n            } else {\n                return false\n            }\n        }\n    }\n\n    @Override Map<String, String> getEmailMessageIdByUserId() { return emailMessageIdByUserId }\n\n    @Override NotificationMessage persistOnSend(Boolean persist) { persistOnSend = persist; return this }\n    @Override boolean isPersistOnSend() {\n        if (persistOnSend != null) {\n            return persistOnSend.booleanValue()\n        } else {\n            EntityValue localNotTopic = getNotificationTopic()\n            if (localNotTopic != null && localNotTopic.persistOnSend) {\n                return localNotTopic.persistOnSend == 'Y'\n            } else {\n                return false\n            }\n        }\n    }\n\n    @Override NotificationMessage send(boolean persist) {\n        persistOnSend = persist\n        return send()\n    }\n    @Override NotificationMessage send() {\n        // persist if is persistOnSend\n        if (isPersistOnSend()) {\n            sentDate = new Timestamp(System.currentTimeMillis())\n            TransactionFacadeImpl tfi = ecfi.transactionFacade\n\n            // run in separate transaction so that it is saved immediately, NotificationMessage listeners running async are\n            //     outside of this transaction and may use these records (like markSent() before the current tx is complete)\n            boolean suspendedTransaction = false\n            try {\n                if (tfi.isTransactionInPlace()) suspendedTransaction = tfi.suspend()\n                boolean beganTransaction = tfi.begin(null)\n                try {\n                    Map createResult = ecfi.service.sync().name(\"create\", \"moqui.security.user.NotificationMessage\")\n                            .parameters([topic:this.topic, subTopic:this.subTopic, userGroupId:this.userGroupId, sentDate:this.sentDate,\n                                    messageJson:this.getMessageJson(), titleText:this.getTitle(), linkText:this.getLink(),\n                                    typeString:this.getType(), showAlert:(this.showAlert ? 'Y' : 'N')])\n                            .disableAuthz().call()\n                    // if it's null we got an error so return from closure\n                    if (createResult == null) return\n\n                    this.setNotificationMessageId((String) createResult.notificationMessageId)\n                    for (String userId in this.getNotifyUserIds())\n                        ecfi.service.sync().name(\"create\", \"moqui.security.user.NotificationMessageUser\")\n                                .parameters([notificationMessageId:createResult.notificationMessageId, userId:userId])\n                                .disableAuthz().call()\n                } catch (Throwable t) {\n                    tfi.rollback(beganTransaction, \"Error saving NotificationMessage\", t)\n                    throw t\n                } finally {\n                    tfi.commit(beganTransaction)\n                }\n            } finally {\n                if (suspendedTransaction) tfi.resume()\n            }\n\n            /* old approach, cleaner and simpler but blows up under Groovy 2.5.13 and later\n             *  java.lang.VerifyError: Bad type on operand stack\n             *  Exception Details:\n             *  Location: org/moqui/impl/context/NotificationMessageImpl$_send_closure1.doCall(Ljava/lang/Object;)Ljava/lang/Object; @223: ifnonnull\n             *  Reason: Type integer (current frame, stack[5]) is not assignable to reference type\n\n            // a little trick so that this is available in the closure\n            NotificationMessageImpl nmi = this\n            // run in runRequireNew so that it is saved immediately, NotificationMessage listeners running async are\n            //     outside of this transaction and may use these records (like markSent() before the current tx is complete)\n            ecfi.transactionFacade.runRequireNew(null, \"Error saving NotificationMessage\", {\n                Map createResult = ecfi.service.sync().name(\"create\", \"moqui.security.user.NotificationMessage\")\n                        .parameters([topic:nmi.topic, userGroupId:nmi.userGroupId, sentDate:nmi.sentDate,\n                                     messageJson:nmi.getMessageJson(), titleText:nmi.getTitle(), linkText:nmi.getLink(),\n                                     typeString:nmi.getType(), showAlert:(nmi.showAlert ? 'Y' : 'N')])\n                        .disableAuthz().call()\n                // if it's null we got an error so return from closure\n                if (createResult == null) return\n\n                nmi.setNotificationMessageId((String) createResult.notificationMessageId)\n                for (String userId in nmi.getNotifyUserIds())\n                    ecfi.service.sync().name(\"create\", \"moqui.security.user.NotificationMessageUser\")\n                            .parameters([notificationMessageId:createResult.notificationMessageId, userId:userId])\n                            .disableAuthz().call()\n            })\n             */\n        }\n\n        // now send it to the topic\n        ecfi.sendNotificationMessageToTopic(this)\n\n        // send emails if emailTemplateId\n        String localEmailTemplateId = getEmailTemplateId()\n        if (localEmailTemplateId != null && !localEmailTemplateId.isEmpty()) {\n            Map<String, Object> wrappedMessageMap = getWrappedMessageMap()\n            EntityValue notificationTopic = getNotificationTopic()\n\n            Set<String> curNotifyUserIds = getNotifyUserIds()\n            EntityList notificationTopicUsers = ecfi.entityFacade.find(\"moqui.security.user.NotificationTopicUser\")\n                    .condition(\"topic\", topic).condition(\"userId\", \"in\", curNotifyUserIds).disableAuthz().list()\n\n            for (String userId in curNotifyUserIds) {\n                EntityValue notificationUser = (EntityValue) notificationTopicUsers.findByAnd(\"userId\", userId)\n\n                if (\"N\".equals(notificationUser?.emailNotifications)) continue\n                if (!(\"Y\".equals(notificationUser?.emailNotifications) || \"Y\".equals(notificationTopic?.emailNotifications))) continue\n\n                EntityValue userAccount = ecfi.entityFacade.find(\"moqui.security.UserAccount\")\n                        .condition(\"userId\", userId).disableAuthz().one()\n                String emailAddress = userAccount?.emailAddress\n                if (emailAddress) {\n                    // FUTURE: if there is an option to create EmailMessage record also configure emailTypeEnumId (maybe if emailTypeEnumId is set create EmailMessage)\n                    Map<String, Object> sendOut = ecfi.serviceFacade.sync().name(\"org.moqui.impl.EmailServices.send#EmailTemplate\")\n                            .parameters([emailTemplateId:localEmailTemplateId, toAddresses:emailAddress,\n                                    bodyParameters:wrappedMessageMap, toUserId:userId, createEmailMessage:isEmailMessageSave()]).call()\n                    String emailMessageId = (String) sendOut.emailMessageId\n                    if (emailMessageId) {\n                        if (emailMessageIdByUserId == null) emailMessageIdByUserId = new HashMap<String, String>()\n                        emailMessageIdByUserId.put(userId, emailMessageId)\n                        String notificationMessageId = getNotificationMessageId()\n                        if (notificationMessageId) {\n                            // use store to update if was created above or create if not\n                            ecfi.service.sync().name(\"store\", \"moqui.security.user.NotificationMessageUser\")\n                                    .parameters([notificationMessageId:notificationMessageId, userId:userId,\n                                            emailMessageId:emailMessageId, sentDate:new Timestamp(System.currentTimeMillis())])\n                                    .disableAuthz().call()\n                        }\n                    }\n                }\n            }\n        }\n\n        return this\n    }\n\n    @Override String getNotificationMessageId() { return notificationMessageId }\n    void setNotificationMessageId(String id) { notificationMessageId = id }\n\n    @Override NotificationMessage markSent(String userId) {\n        // if no notificationMessageId there is nothing to do, this isn't persisted as far as we know\n        if (!notificationMessageId) return this\n        if (!userId) throw new BaseArtifactException(\"Must specify userId to mark notification message sent\")\n\n        ExecutionContextImpl eci = ecfi.getEci()\n        boolean alreadyDisabled = eci.getArtifactExecution().disableAuthz()\n        try {\n            ecfi.entityFacade.makeValue(\"moqui.security.user.NotificationMessageUser\")\n                    .set(\"userId\", userId).set(\"notificationMessageId\", notificationMessageId)\n                    .set(\"sentDate\", new Timestamp(System.currentTimeMillis())).update()\n        } catch (Throwable t) {\n            logger.error(\"Error marking notification message ${notificationMessageId} sent\", t)\n        } finally {\n            if (!alreadyDisabled) eci.getArtifactExecution().enableAuthz()\n        }\n\n        return this\n    }\n    @Override NotificationMessage markViewed(String userId) {\n        // if no notificationMessageId there is nothing to do, this isn't persisted as far as we know\n        if (!notificationMessageId) return this\n        if (!userId) throw new BaseArtifactException(\"Must specify userId to mark notification message received\")\n\n        markViewed(notificationMessageId, userId, ecfi.getEci())\n        return this\n    }\n    static Timestamp markViewed(String notificationMessageId, String userId, ExecutionContext ec) {\n        boolean alreadyDisabled = ec.getArtifactExecution().disableAuthz()\n        try {\n            Timestamp recStamp = new Timestamp(System.currentTimeMillis())\n            ec.factory.entity.makeValue(\"moqui.security.user.NotificationMessageUser\")\n                    .set(\"userId\", userId).set(\"notificationMessageId\", notificationMessageId)\n                    .set(\"viewedDate\", recStamp).update()\n            return recStamp\n        } catch (Throwable t) {\n            logger.error(\"Error marking notification message ${notificationMessageId} sent\", t)\n            return null\n        } finally {\n            if (!alreadyDisabled) ec.getArtifactExecution().enableAuthz()\n        }\n    }\n\n    @Override Map<String, Object> getWrappedMessageMap() {\n        EntityValue localNotTopic = getNotificationTopic()\n        return [topic:topic, subTopic:subTopic, sentDate:sentDate, notificationMessageId:notificationMessageId, topicDescription:localNotTopic?.description,\n                message:getMessageMap(), title:getTitle(), link:getLink(), type:getType(), persistOnSend:isPersistOnSend(),\n                showAlert:isShowAlert(), alertNoAutoHide:isAlertNoAutoHide()]\n    }\n    @Override String getWrappedMessageJson() {\n        Map<String, Object> wrappedMap = getWrappedMessageMap()\n        try {\n            return JsonOutput.toJson(wrappedMap)\n        } catch (Exception e) {\n            logger.warn(\"Error writing JSON for Notification ${topic} message: ${e.toString()}\\n${wrappedMap}\")\n            return null\n        }\n    }\n\n    void populateFromValue(EntityValue nmbu) {\n        this.notificationMessageId = nmbu.notificationMessageId\n        this.topic = nmbu.topic\n        this.subTopic = nmbu.subTopic\n        this.sentDate = nmbu.getTimestamp(\"sentDate\")\n        this.userGroupId = nmbu.userGroupId\n        this.messageJson = nmbu.messageJson\n        this.titleText = nmbu.titleText\n        this.linkText = nmbu.linkText\n        if (nmbu.typeString) this.type = NotificationType.valueOf((String) nmbu.typeString)\n        this.showAlert = nmbu.showAlert == 'Y'\n        this.alertNoAutoHide = nmbu.alertNoAutoHide == 'Y'\n\n        EntityList nmuList = nmbu.findRelated(\"moqui.security.user.NotificationMessageUser\",\n                [notificationMessageId:notificationMessageId] as Map<String, Object>, null, false, false)\n        for (EntityValue nmu in nmuList) userIdSet.add((String) nmu.userId)\n    }\n\n    @Override void writeExternal(ObjectOutput out) throws IOException {\n        // NOTE: lots of writeObject because values are nullable\n        out.writeObject(userIdSet)\n        out.writeObject(userGroupId)\n        out.writeUTF(topic)\n        out.writeObject(subTopic)\n        out.writeUTF(getMessageJson())\n        out.writeObject(notificationMessageId)\n        out.writeObject(sentDate)\n        out.writeObject(getTitle())\n        out.writeObject(getLink())\n        out.writeObject(type)\n        out.writeObject(showAlert)\n        out.writeObject(alertNoAutoHide)\n        out.writeObject(persistOnSend)\n    }\n    @Override void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException {\n        userIdSet = (Set<String>) objectInput.readObject()\n        userGroupId = (String) objectInput.readObject()\n        topic = objectInput.readUTF()\n        subTopic = objectInput.readObject()\n        messageJson = objectInput.readUTF()\n        notificationMessageId = (String) objectInput.readObject()\n        sentDate = (Timestamp) objectInput.readObject()\n        titleText = (String) objectInput.readObject()\n        linkText = (String) objectInput.readObject()\n        type = (NotificationType) objectInput.readObject()\n        showAlert = (Boolean) objectInput.readObject()\n        alertNoAutoHide = (Boolean) objectInput.readObject()\n        persistOnSend = (Boolean) objectInput.readObject()\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/ResourceFacadeImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context\n\nimport groovy.transform.CompileStatic\nimport org.codehaus.groovy.runtime.InvokerHelper\nimport org.moqui.BaseArtifactException\nimport org.moqui.context.*\nimport org.moqui.impl.context.reference.BaseResourceReference\nimport org.moqui.impl.context.renderer.FtlTemplateRenderer\nimport org.moqui.impl.context.renderer.NoTemplateRenderer\nimport org.moqui.impl.context.runner.JavaxScriptRunner\nimport org.moqui.impl.context.runner.XmlActionsScriptRunner\nimport org.moqui.impl.entity.EntityValueBase\nimport org.moqui.jcache.MCache\nimport org.moqui.util.ContextBinding\nimport org.moqui.util.ContextStack\nimport org.moqui.util.MNode\nimport org.moqui.resource.ResourceReference\nimport org.moqui.util.ObjectUtilities\nimport org.moqui.util.StringUtilities\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport jakarta.activation.DataSource\nimport jakarta.mail.util.ByteArrayDataSource\n\nimport javax.cache.Cache\nimport javax.jcr.Repository\nimport javax.jcr.RepositoryFactory\nimport javax.jcr.Session\nimport javax.jcr.SimpleCredentials\n\nimport javax.script.ScriptEngine\nimport javax.script.ScriptEngineManager\nimport javax.xml.transform.Source\nimport javax.xml.transform.Transformer\nimport javax.xml.transform.TransformerFactory\nimport javax.xml.transform.URIResolver\nimport javax.xml.transform.sax.SAXResult\nimport javax.xml.transform.stream.StreamSource\nimport java.lang.reflect.Method\n\n@CompileStatic\nclass ResourceFacadeImpl implements ResourceFacade {\n    protected final static Logger logger = LoggerFactory.getLogger(ResourceFacadeImpl.class)\n\n    protected final ExecutionContextFactoryImpl ecfi\n\n    final FtlTemplateRenderer ftlTemplateRenderer\n    final XmlActionsScriptRunner xmlActionsScriptRunner\n\n    // the groovy Script object is not thread safe, so have one per thread per expression; can be reused as thread is reused\n    protected final ThreadLocal<Map<String, Script>> threadScriptByExpression = new ThreadLocal<>()\n    protected final Map<String, Class> scriptGroovyExpressionCache = new HashMap<>()\n\n    protected final Cache<String, String> textLocationCache\n    protected final Cache<String, ResourceReference> resourceReferenceByLocation\n\n    protected final Map<String, Class> resourceReferenceClasses = new HashMap<>()\n    protected final Map<String, TemplateRenderer> templateRenderers = new HashMap<>()\n    protected final ArrayList<String> templateRendererExtensions = new ArrayList<>()\n    protected final ArrayList<Integer> templateRendererExtensionsDots = new ArrayList<>()\n    protected final Map<String, ScriptRunner> scriptRunners = new HashMap<>()\n    protected final ScriptEngineManager scriptEngineManager = new ScriptEngineManager()\n    protected final ToolFactory<org.xml.sax.ContentHandler> xslFoHandlerFactory\n\n    protected final Map<String, Repository> contentRepositories = new HashMap<>()\n    protected final ThreadLocal<Map<String, Session>> contentSessions = new ThreadLocal<Map<String, Session>>()\n\n    ResourceFacadeImpl(ExecutionContextFactoryImpl ecfi) {\n        this.ecfi = ecfi\n\n        ftlTemplateRenderer = new FtlTemplateRenderer()\n        ftlTemplateRenderer.init(ecfi)\n\n        xmlActionsScriptRunner = new XmlActionsScriptRunner()\n        xmlActionsScriptRunner.init(ecfi)\n\n        textLocationCache = ecfi.cacheFacade.getCache(\"resource.text.location\", String.class, String.class)\n        // a plain HashMap is faster and just fine here: scriptGroovyExpressionCache = ecfi.cacheFacade.getCache(\"resource.groovy.expression\")\n        resourceReferenceByLocation = ecfi.cacheFacade.getCache(\"resource.reference.location\", String.class, ResourceReference.class)\n\n        MNode resourceFacadeNode = ecfi.confXmlRoot.first(\"resource-facade\")\n\n        // Setup resource reference classes\n        for (MNode rrNode in resourceFacadeNode.children(\"resource-reference\")) {\n            try {\n                Class rrClass = Thread.currentThread().getContextClassLoader().loadClass(rrNode.attribute(\"class\"))\n                resourceReferenceClasses.put(rrNode.attribute(\"scheme\"), rrClass)\n            } catch (ClassNotFoundException e) {\n                logger.info(\"Class [${rrNode.attribute(\"class\")}] not found (${e.toString()})\")\n            }\n        }\n\n        // Setup template renderers\n        for (MNode templateRendererNode in resourceFacadeNode.children(\"template-renderer\")) {\n            TemplateRenderer tr = (TemplateRenderer) Thread.currentThread().getContextClassLoader()\n                    .loadClass(templateRendererNode.attribute(\"class\")).newInstance()\n            templateRenderers.put(templateRendererNode.attribute(\"extension\"), tr.init(ecfi))\n        }\n        for (String ext in templateRenderers.keySet()) {\n            templateRendererExtensions.add(ext)\n            templateRendererExtensionsDots.add(ObjectUtilities.countChars(ext, (char) '.'))\n        }\n\n        // Setup script runners\n        for (MNode scriptRunnerNode in resourceFacadeNode.children(\"script-runner\")) {\n            if (scriptRunnerNode.attribute(\"class\")) {\n                ScriptRunner sr = (ScriptRunner) Thread.currentThread().getContextClassLoader()\n                        .loadClass(scriptRunnerNode.attribute(\"class\")).newInstance()\n                scriptRunners.put(scriptRunnerNode.attribute(\"extension\"), sr.init(ecfi))\n            } else if (scriptRunnerNode.attribute(\"engine\")) {\n                ScriptRunner sr = new JavaxScriptRunner(scriptRunnerNode.attribute(\"engine\")).init(ecfi)\n                scriptRunners.put(scriptRunnerNode.attribute(\"extension\"), sr)\n            } else {\n                logger.error(\"Configured script-runner for extension [${scriptRunnerNode.attribute(\"extension\")}] must have either a class or engine attribute and has neither.\")\n            }\n        }\n\n        // Get XSL-FO Handler Factory\n        if (resourceFacadeNode.attribute(\"xsl-fo-handler-factory\")) {\n            xslFoHandlerFactory = ecfi.getToolFactory(resourceFacadeNode.attribute(\"xsl-fo-handler-factory\"))\n            if (xslFoHandlerFactory != null) {\n                logger.info(\"Using xsl-fo-handler-factory ${resourceFacadeNode.attribute(\"xsl-fo-handler-factory\")} (${xslFoHandlerFactory.class.name})\")\n            } else {\n                logger.warn(\"Could not find xsl-fo-handler-factory with name ${resourceFacadeNode.attribute(\"xsl-fo-handler-factory\")}\")\n            }\n        } else {\n            xslFoHandlerFactory = null\n        }\n\n        // Setup content repositories\n        for (MNode repositoryNode in ecfi.confXmlRoot.first(\"repository-list\").children(\"repository\")) {\n            String repoName = repositoryNode.attribute(\"name\")\n            Repository repo = null\n            Map parameters = new HashMap()\n            for (MNode paramNode in repositoryNode.children(\"init-param\"))\n                parameters.put(paramNode.attribute(\"name\"), paramNode.attribute(\"value\"))\n\n            try {\n                for (RepositoryFactory factory : ServiceLoader.load(RepositoryFactory.class)) {\n                    repo = factory.getRepository(parameters)\n                    // factory accepted parameters\n                    if (repo != null) break\n                }\n                if (repo != null) {\n                    contentRepositories.put(repoName, repo)\n                    logger.info(\"Added JCR Repository ${repoName} of type ${repo.class.name} for workspace ${repositoryNode.attribute(\"workspace\")} using parameters: ${parameters}\")\n                } else {\n                    logger.error(\"Could not find JCR RepositoryFactory for repository ${repoName} using parameters: ${parameters}\")\n                }\n            } catch (Exception e) {\n                logger.error(\"Error getting JCR Repository ${repositoryNode.attribute(\"name\")}: ${e.toString()}\")\n            }\n        }\n    }\n\n    void destroyAllInThread() {\n        Map<String, Session> sessionMap = contentSessions.get()\n        if (sessionMap) for (Session openSession in sessionMap.values()) openSession.logout()\n        contentSessions.remove()\n    }\n\n    ExecutionContextFactoryImpl getEcfi() { ecfi }\n    Map<String, TemplateRenderer> getTemplateRenderers() { Collections.unmodifiableMap(templateRenderers) }\n    TreeSet<String> getTemplateRendererExtensionSet() { new TreeSet(templateRendererExtensions) }\n\n    Repository getContentRepository(String name) { contentRepositories.get(name) }\n\n    /** Get the active JCR Session for the context/thread, making sure it is live, and make one if needed. */\n    Session getContentRepositorySession(String name) {\n        Map<String, Session> sessionMap = contentSessions.get()\n        if (sessionMap == null) {\n            sessionMap = new HashMap()\n            contentSessions.set(sessionMap)\n        }\n        Session newSession = sessionMap.get(name)\n        if (newSession != null) {\n            if (newSession.isLive()) {\n                return newSession\n            } else {\n                sessionMap.remove(name)\n                // newSession = null\n            }\n        }\n\n        Repository rep = contentRepositories[name]\n        if (!rep) return null\n        MNode repositoryNode = ecfi.confXmlRoot.first(\"repository-list\")\n                .first({ MNode it -> it.name == \"repository\" && it.attribute(\"name\") == name })\n        SimpleCredentials credentials = new SimpleCredentials(repositoryNode.attribute(\"username\") ?: \"anonymous\",\n                (repositoryNode.attribute(\"password\") ?: \"\").toCharArray())\n        if (repositoryNode.attribute(\"workspace\")) {\n            newSession = rep.login(credentials, repositoryNode.attribute(\"workspace\"))\n        } else {\n            newSession = rep.login(credentials)\n        }\n\n        if (newSession != null) sessionMap.put(name, newSession)\n        return newSession\n    }\n\n    @Override ResourceReference getLocationReference(String location) {\n        if (location == null) return null\n        return internalGetReference(getLocationScheme(location), location)\n    }\n    static String getLocationScheme(String location) {\n        String scheme = \"file\"\n        // Q: how to get the scheme for windows? the Java URI class doesn't like spaces, the if we look for the first \":\"\n        //    it may be a drive letter instead of a scheme/protocol\n        // A: ignore colon if only one character before it\n        if (location.indexOf(\":\") > 1) {\n            String prefix = location.substring(0, location.indexOf(\":\"))\n            if (!prefix.contains(\"/\") && prefix.length() > 2) scheme = prefix\n        }\n        return scheme\n    }\n    @Override ResourceReference getUriReference(URI uri) {\n        if (uri == null) return null\n        // we care about 2 parts: scheme, path (use full scheme-specific part)\n        String scheme = uri.getScheme() ?: \"file\"\n        String ssPart = uri.getSchemeSpecificPart()\n        return internalGetReference(scheme, scheme + \":\" + ssPart)\n    }\n    private ResourceReference internalGetReference(String scheme, String location) {\n        // version ignored for this call, just strip it\n        int hashIdx = location.indexOf(\"#\")\n        if (hashIdx > 0) location = location.substring(0, hashIdx)\n\n        ResourceReference cachedRr = resourceReferenceByLocation.get(location)\n        if (cachedRr != null) return cachedRr\n\n        Class rrClass = resourceReferenceClasses.get(scheme)\n        if (rrClass == null) throw new BaseArtifactException(\"Prefix (${scheme}) not supported for location ${location}\")\n\n        ResourceReference rr = (ResourceReference) rrClass.newInstance()\n        if (rr instanceof BaseResourceReference) {\n            ((BaseResourceReference) rr).init(location, ecfi)\n        } else {\n            rr.init(location)\n        }\n        resourceReferenceByLocation.put(location, rr)\n        return rr\n    }\n\n    @Override InputStream getLocationStream(String location) {\n        if (location == null) return null\n\n        int hashIdx = location.indexOf(\"#\")\n        String versionName = null\n        if (hashIdx > 0) {\n            if ((hashIdx+1) < location.length()) versionName = location.substring(hashIdx+1)\n            location = location.substring(0, hashIdx)\n        }\n\n        ResourceReference rr = getLocationReference(location)\n        if (rr == null) return null\n        return rr.openStream(versionName)\n    }\n\n    @Override String getLocationText(String location, boolean cache) {\n        if (location == null) return \"\"\n\n        int hashIdx = location.indexOf(\"#\")\n        String versionName = (hashIdx > 0 && (hashIdx+1) < location.length()) ? location.substring(hashIdx+1) : null\n\n        ResourceReference textRr = getLocationReference(location)\n        if (textRr == null) {\n            logger.info(\"Cound not get resource reference for location [${location}], returning empty location text String\")\n            return \"\"\n        }\n        // don't cache when getting by version\n        if (versionName != null) cache = false\n        if (cache) {\n            String cachedText\n            if (textLocationCache instanceof MCache) {\n                MCache<String, String> mCache = (MCache) textLocationCache\n                // if we have a rr and last modified is newer than the cache entry then throw it out (expire when cached entry\n                //     updated time is older/less than rr.lastModified)\n                cachedText = (String) mCache.get(location, textRr.getLastModified())\n            } else {\n                // TODO: doesn't support on the fly reloading without cache expire/clear!\n                cachedText = (String) textLocationCache.get(location)\n            }\n            if (cachedText != null) return cachedText\n        }\n        InputStream locStream = textRr.openStream(versionName)\n        if (locStream == null) logger.info(\"Cannot get text, no resource found at location [${location}]\")\n        String text = ObjectUtilities.getStreamText(locStream)\n        if (cache) textLocationCache.put(location, text)\n        // logger.warn(\"==== getLocationText at ${location} version ${versionName} text ${text.length() > 100 ? text.substring(0, 100) : text}\")\n        return text\n    }\n\n    @Override DataSource getLocationDataSource(String location) {\n        int hashIdx = location.indexOf(\"#\")\n        String versionName = null\n        if (hashIdx > 0) {\n            if ((hashIdx+1) < location.length()) versionName = location.substring(hashIdx+1)\n            location = location.substring(0, hashIdx)\n        }\n\n        ResourceReference fileResourceRef = getLocationReference(location)\n        TemplateRenderer tr = getTemplateRendererByLocation(fileResourceRef.location)\n\n        String fileName = fileResourceRef.fileName\n        // strip template extension(s) to avoid problems with trying to find content types based on them\n        String fileContentType = getContentType(tr != null ? tr.stripTemplateExtension(fileName) : fileName)\n\n        boolean isBinary = ResourceReference.isBinaryContentType(fileContentType)\n\n        if (isBinary) {\n            return new ByteArrayDataSource(fileResourceRef.openStream(versionName), fileContentType)\n        } else {\n            // not a binary object (hopefully), get the text and pass it over\n            if (tr != null) {\n                // NOTE: version ignored here\n                StringWriter sw = new StringWriter()\n                tr.render(fileResourceRef.location, sw)\n                return new ByteArrayDataSource(sw.toString(), fileContentType)\n            } else {\n                // no renderer found, just grab the text (cached) and throw it to the writer\n                String textLoc = fileResourceRef.location\n                if (versionName != null && !versionName.isEmpty()) textLoc = textLoc.concat(\"#\").concat(versionName)\n                String text = getLocationText(textLoc, true)\n                return new ByteArrayDataSource(text, fileContentType)\n            }\n        }\n    }\n\n    @Override void template(String location, Writer writer) { template(location, writer, null) }\n    @Override void template(String location, Writer writer, String defaultExtension) {\n        // NOTE: let version fall through to tr.render() and getLocationText()\n        TemplateRenderer tr = getTemplateRendererByLocation(location)\n        if ((tr == null || tr instanceof NoTemplateRenderer) && defaultExtension != null && !defaultExtension.isEmpty())\n            tr = getTemplateRendererByLocation(defaultExtension)\n        // logger.info(\"location ${location} defaultExtension ${defaultExtension} tr ${tr?.class?.name}\")\n\n        if (tr != null) {\n            tr.render(location, writer)\n        } else {\n            // no renderer found, just grab the text and throw it to the writer\n            String text = getLocationText(location, true)\n            if (text) writer.write(text)\n        }\n    }\n    @Override String template(String location, String defaultExtension) {\n        StringWriter sw = new StringWriter()\n        template(location, sw, defaultExtension)\n        return sw.toString()\n    }\n\n    static final Set<String> binaryExtensions = new HashSet<>([\"png\", \"jpg\", \"jpeg\", \"gif\", \"pdf\", \"doc\", \"docx\", \"xsl\", \"xslx\"])\n    TemplateRenderer getTemplateRendererByLocation(String location) {\n        int hashIdx = location.indexOf(\"#\")\n        if (hashIdx > 0) location = location.substring(0, hashIdx)\n\n        // match against extension for template renderer, with as many dots that match as possible (most specific match)\n        int lastSlashIndex = location.lastIndexOf(\"/\")\n        int dotIndex = location.indexOf(\".\", lastSlashIndex)\n        String fullExt = location.substring(dotIndex + 1)\n        TemplateRenderer tr = (TemplateRenderer) templateRenderers.get(fullExt)\n        if (tr != null || templateRenderers.containsKey(fullExt)) return tr\n\n        int lastDotIndex = location.lastIndexOf(\".\", lastSlashIndex)\n        String lastExt = location.substring(lastDotIndex+ 1)\n        if (binaryExtensions.contains(lastExt)) {\n            templateRenderers.put(fullExt, null)\n            return null\n        }\n\n        int mostDots = -1\n        int templateRendererExtensionsSize = templateRendererExtensions.size()\n        for (int i = 0; i < templateRendererExtensionsSize; i++) {\n            String ext = (String) templateRendererExtensions.get(i)\n            if (location.endsWith(ext)) {\n                int dots = templateRendererExtensionsDots.get(i).intValue()\n                if (dots > mostDots) {\n                    mostDots = dots\n                    tr = (TemplateRenderer) templateRenderers.get(ext)\n                }\n            }\n        }\n        // if there is no template renderer for extension remember that\n        if (tr == null) {\n            // logger.warn(\"No renderer found for ${location}, exts: ${templateRendererExtensions}\\ntemplateRenderers: ${templateRenderers}\")\n            templateRenderers.put(fullExt, null)\n        }\n        return tr\n    }\n\n    @Override Object script(String location, String method) {\n        int hashIdx = location.indexOf(\"#\")\n        if (hashIdx > 0) location = location.substring(0, hashIdx)\n        // NOTE: version ignored here\n\n        ExecutionContextImpl ec = ecfi.getEci()\n        String extension = location.substring(location.lastIndexOf(\".\"))\n        ScriptRunner sr = scriptRunners.get(extension)\n\n        if (sr != null) {\n            return sr.run(location, method, ec)\n        } else {\n            // see if the extension is known\n            ScriptEngine engine = scriptEngineManager.getEngineByExtension(extension)\n            if (engine == null) throw new BaseArtifactException(\"Cannot run script [${location}], unknown extension (not in Moqui Conf file, and unkown to Java ScriptEngineManager).\")\n            return JavaxScriptRunner.bindAndRun(location, ec, engine, ecfi.cacheFacade.getCache(\"resource.script${extension}.location\"))\n        }\n    }\n    @Override Object script(String location, String method, Map additionalContext) {\n        ExecutionContextImpl ec = ecfi.getEci()\n        ContextStack cs = ec.contextStack\n        boolean doPushPop = additionalContext != null && additionalContext.size() > 0\n        try {\n            if (doPushPop) {\n                if (additionalContext instanceof EntityValueBase) cs.push(((EntityValueBase) additionalContext).getValueMap())\n                else cs.push(additionalContext)\n                // do another push so writes to the context don't modify the passed in Map\n                cs.push()\n            }\n            return script(location, method)\n        } finally {\n            if (doPushPop) { cs.pop(); cs.pop() }\n        }\n    }\n\n    Object setInContext(String field, String from, String value, String defaultValue, String type, String setIfEmpty) {\n        def tempValue = getValueFromContext(from, value, defaultValue, type)\n        ecfi.getEci().contextStack.put(\"_tempValue\", tempValue)\n        if (tempValue || setIfEmpty) expression(\"${field} = _tempValue\", \"\")\n\n        return tempValue\n    }\n    Object getValueFromContext(String from, String value, String defaultValue, String type) {\n        def tempValue = from ? expression(from, \"\") : expand(value, \"\", null, false)\n        if (!tempValue && defaultValue) tempValue = expand(defaultValue, \"\", null, false)\n        if (type) tempValue = ObjectUtilities.basicConvert(tempValue, type)\n        return tempValue\n    }\n\n    @Override boolean condition(String expression, String debugLocation) {\n        return conditionInternal(expression, debugLocation, ecfi.getEci())\n    }\n    protected boolean conditionInternal(String expression, String debugLocation, ExecutionContextImpl ec) {\n        if (expression == null || expression.isEmpty()) return false\n        try {\n            Script script = getGroovyScript(expression, ec)\n            Object result = script.run()\n            script.setBinding(null)\n            return result as boolean\n        } catch (Exception e) {\n            throw new BaseArtifactException(\"Error in condition [${expression}] from [${debugLocation}]\", e)\n        }\n    }\n    @Override boolean condition(String expression, String debugLocation, Map additionalContext) {\n        ExecutionContextImpl ec = ecfi.getEci()\n        ContextStack cs = ec.contextStack\n        boolean doPushPop = additionalContext != null && additionalContext.size() > 0\n        try {\n            if (doPushPop) {\n                if (additionalContext instanceof EntityValueBase) cs.push(((EntityValueBase) additionalContext).getValueMap())\n                else cs.push(additionalContext)\n                // do another push so writes to the context don't modify the passed in Map\n                cs.push()\n            }\n            return conditionInternal(expression, debugLocation, ec)\n        } finally {\n            if (doPushPop) { cs.pop(); cs.pop() }\n        }\n    }\n\n    @Override Object expression(String expression, String debugLocation) {\n        return expressionInternal(expression, debugLocation, ecfi.getEci()) }\n    protected Object expressionInternal(String expression, String debugLocation, ExecutionContextImpl ec) {\n        if (expression == null || expression.isEmpty()) return null\n        try {\n            Script script = getGroovyScript(expression, ec)\n            Object result = script.run()\n            script.setBinding(null)\n            return result\n        } catch (Exception e) {\n            throw new BaseArtifactException(\"Error in field expression [${expression}] from [${debugLocation}]\", e)\n        }\n    }\n    @Override Object expression(String expr, String debugLocation, Map additionalContext) {\n        ExecutionContextImpl ec = ecfi.getEci()\n        ContextStack cs = ec.contextStack\n        boolean doPushPop = additionalContext != null && additionalContext.size() > 0\n        try {\n            if (doPushPop) {\n                if (additionalContext instanceof EntityValueBase) cs.push(((EntityValueBase) additionalContext).getValueMap())\n                else cs.push(additionalContext)\n                // do another push so writes to the context don't modify the passed in Map\n                // TODO: is this really necessary? is very memory inefficient; these expressions are meant to evaluate to a value, not generally to set anything\n                cs.push()\n            }\n            return expressionInternal(expr, debugLocation, ec)\n        } finally {\n            if (doPushPop) { cs.pop(); cs.pop() }\n        }\n    }\n\n\n    @Override String expandNoL10n(String inputString, String debugLocation) { return expand(inputString, debugLocation, null, false) }\n    @Override String expand(String inputString, String debugLocation) { return expand(inputString, debugLocation, null, true) }\n    @Override String expand(String inputString, String debugLocation, Map additionalContext) {\n        return expand(inputString, debugLocation, additionalContext, true) }\n    @Override String expand(String inputString, String debugLocation, Map additionalContext, boolean localize) {\n        if (inputString == null) return \"\"\n        int inputStringLength = inputString.length()\n        if (inputStringLength == 0) return \"\"\n\n        ExecutionContextImpl eci = (ExecutionContextImpl) null\n        // localize string before expanding\n        if (localize && inputStringLength < 256) {\n            eci = ecfi.getEci()\n            inputString = eci.l10nFacade.localize(inputString)\n        }\n        // if no $ or $ is the last character then it's a plain String, just return it\n        int lastDollarSignIdx = inputString.lastIndexOf('$')\n        if (lastDollarSignIdx == -1 || lastDollarSignIdx == (inputString.length()-1)) return inputString\n\n        if (eci == null) eci = ecfi.getEci()\n        boolean doPushPop = additionalContext != null && additionalContext.size() > 0\n        ContextStack cs = (ContextStack) null\n        if (doPushPop) cs = eci.contextStack\n        try {\n            if (doPushPop) {\n                if (additionalContext instanceof EntityValueBase) { cs.push(((EntityValueBase) additionalContext).getValueMap()) }\n                else { cs.push(additionalContext) }\n                // do another push so writes to the context don't modify the passed in Map\n                cs.push()\n            }\n\n            String expression = '\"\"\"' + inputString + '\"\"\"'\n            try {\n                Script script = getGroovyScript(expression, eci)\n                if (script == null) return \"\"\n                Object result = script.run()\n                script.setBinding(null)\n                return result as String\n            } catch (Exception e) {\n                throw new BaseArtifactException(\"Error in string expression [${expression}] from ${debugLocation}\", e)\n            }\n        } finally {\n            if (doPushPop) { cs.pop(); cs.pop() }\n        }\n    }\n\n    Script getGroovyScript(String expression, ExecutionContextImpl eci) {\n        ContextBinding curBinding = eci.contextBindingInternal\n\n        Map<String, Script> curScriptByExpr = (Map<String, Script>) threadScriptByExpression.get()\n        if (curScriptByExpr == null) {\n            curScriptByExpr = new HashMap<String, Script>()\n            threadScriptByExpression.set(curScriptByExpr)\n        }\n\n        Script script = (Script) curScriptByExpr.get(expression)\n        if (script == null) {\n            script = InvokerHelper.createScript(getGroovyClass(expression), curBinding)\n            curScriptByExpr.put(expression, script)\n        } else {\n            script.setBinding(curBinding)\n        }\n\n        return script\n    }\n    Class getGroovyClass(String expression) {\n        if (expression == null || expression.isEmpty()) return null\n        Class groovyClass = (Class) scriptGroovyExpressionCache.get(expression)\n        if (groovyClass == null) {\n            groovyClass = ecfi.compileGroovy(expression, StringUtilities.getExpressionClassName(expression))\n            scriptGroovyExpressionCache.put(expression, groovyClass)\n            // logger.warn(\"class ${groovyClass.getName()} parsed expression ${expression}\")\n        }\n        return groovyClass\n    }\n\n    @Override String getContentType(String filename) { return ResourceReference.getContentType(filename) }\n\n    @Override\n    Integer xslFoTransform(StreamSource xslFoSrc, StreamSource xsltSrc, OutputStream out, String contentType) {\n        if (xslFoHandlerFactory == null) throw new BaseArtifactException(\"No XSL-FO Handler ToolFactory found (from resource-facade.@xsl-fo-handler-factory)\")\n\n        TransformerFactory factory = TransformerFactory.newInstance()\n        factory.setURIResolver(new LocalResolver(ecfi, factory.getURIResolver()))\n\n        Transformer transformer = xsltSrc == null ? factory.newTransformer() : factory.newTransformer(xsltSrc)\n        transformer.setURIResolver(new LocalResolver(ecfi, transformer.getURIResolver()))\n\n        final org.xml.sax.ContentHandler contentHandler = xslFoHandlerFactory.getInstance(out, contentType)\n\n        // There's a ThreadLocal memory leak in XALANJ, reported in 2005 but still not fixed in 2016\n        // The memory it prevent GC depend on the fo file size and the thread pool size. So use a separate thread to workaround.\n        // https://issues.apache.org/jira/browse/XALANJ-2195\n        BaseArtifactException transformException = null\n        ExecutionContextImpl.ThreadPoolRunnable runnable = new ExecutionContextImpl.ThreadPoolRunnable(ecfi.getEci(), {\n            try { transformer.transform(xslFoSrc, new SAXResult(contentHandler)) }\n            catch (Throwable t) { transformException = new BaseArtifactException(\"Error transforming XSL-FO to ${contentType}\", t) }\n        })\n        Thread transThread = new Thread(runnable)\n        transThread.start()\n        transThread.join()\n        if (transformException != null) throw transformException\n\n        try {\n            Method pcMethod = xslFoHandlerFactory.class.getMethod(\"getPageCount\", org.xml.sax.ContentHandler.class)\n            return pcMethod.invoke(xslFoHandlerFactory, contentHandler) as Integer\n        } catch (NoSuchMethodException e) {\n            if (logger.isDebugEnabled()) logger.debug(\"xsl-fo transform factory has no getPageCount method, returning null for page count\", e)\n            return null\n        }\n    }\n\n    @CompileStatic\n    static class LocalResolver implements URIResolver {\n        protected ExecutionContextFactoryImpl ecfi\n        protected URIResolver defaultResolver\n\n        LocalResolver(ExecutionContextFactoryImpl ecfi, URIResolver defaultResolver) {\n            this.ecfi = ecfi\n            this.defaultResolver = defaultResolver\n        }\n\n        Source resolve(String href, String base) {\n            // try plain href\n            ResourceReference rr = ecfi.resourceFacade.getLocationReference(href)\n\n            // if href has no colon try base + href\n            if (rr == null && href.indexOf(':') < 0) rr = ecfi.resourceFacade.getLocationReference(base + href)\n\n            if (rr != null) {\n                URL url = rr.getUrl()\n                InputStream is = rr.openStream()\n                if (is != null) {\n                    if (url != null) {\n                        return new StreamSource(is, url.toExternalForm())\n                    } else {\n                        return new StreamSource(is)\n                    }\n                }\n            }\n\n            return defaultResolver.resolve(href, base)\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/TransactionCache.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context\n\nimport groovy.transform.CompileStatic\nimport org.moqui.entity.EntityCondition\nimport org.moqui.entity.EntityException\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.entity.EntityDefinition\nimport org.moqui.impl.entity.EntityFacadeImpl\nimport org.moqui.impl.entity.EntityFindBase\nimport org.moqui.impl.entity.EntityJavaUtil\nimport org.moqui.impl.entity.EntityListImpl\nimport org.moqui.impl.entity.EntityValueBase\nimport org.moqui.impl.entity.EntityJavaUtil.EntityWriteInfo\nimport org.moqui.impl.entity.EntityJavaUtil.FindAugmentInfo\nimport org.moqui.impl.entity.EntityJavaUtil.WriteMode\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport jakarta.transaction.Synchronization\nimport java.sql.Connection\nimport javax.transaction.xa.XAException\n\n/** This is a per-transaction cache that basically pretends to be the database for the scope of the transaction.\n * Test your code well when using this as it doesn't support everything.\n *\n * See notes on limitations in the JavaDoc for ServiceCallSync.useTransactionCache()\n *\n */\n@CompileStatic\nclass TransactionCache implements Synchronization {\n    protected final static Logger logger = LoggerFactory.getLogger(TransactionCache.class)\n\n    protected ExecutionContextFactoryImpl ecfi\n    private boolean readOnly\n\n    private Map<Map, EntityValueBase> readOneCache = new HashMap<>()\n    private Set<Map> knownLocked = new HashSet<>()\n    private Map<String, Map<EntityCondition, EntityListImpl>> readListCache = [:]\n\n    private Map<Map, EntityWriteInfo> firstWriteInfoMap = new HashMap<Map, EntityWriteInfo>()\n    private Map<Map, EntityWriteInfo> lastWriteInfoMap = new HashMap<Map, EntityWriteInfo>()\n    private ArrayList<EntityWriteInfo> writeInfoList = new ArrayList<EntityWriteInfo>(50)\n    private LinkedHashMap<String, LinkedHashMap<Map, EntityValueBase>> createByEntityRef = new LinkedHashMap<>()\n\n    TransactionCache(ExecutionContextFactoryImpl ecfi, boolean readOnly) {\n        this.ecfi = ecfi\n        this.readOnly = readOnly\n    }\n\n    boolean isReadOnly() { return readOnly }\n    void makeReadOnly() {\n        if (readOnly) return\n        flushCache(false)\n        readOnly = true\n    }\n    void makeWriteThrough() { readOnly = false }\n\n    LinkedHashMap<Map, EntityValueBase> getCreateByEntityMap(String entityName) {\n        LinkedHashMap<Map, EntityValueBase> createMap = createByEntityRef.get(entityName)\n        if (createMap == null) {\n            createMap = new LinkedHashMap<>()\n            createByEntityRef.put(entityName, createMap)\n        }\n        return createMap\n    }\n\n    static Map<String, Object> makeKey(EntityValueBase evb) {\n        if (evb == null) return null\n        Map<String, Object> key = evb.getPrimaryKeys()\n        if (!key) return null\n        key.put(\"_entityName\", evb.resolveEntityName())\n        return key\n    }\n    static Map makeKeyFind(EntityFindBase efb) {\n        // NOTE: this should never come in null (EntityFindBase.one() => oneGet() => this is only call path)\n        if (efb == null) return null\n        Map key = efb.getSimpleMapPrimaryKeys()\n        if (!key) return null\n        key.put(\"_entityName\", efb.getEntityDef().getFullEntityName())\n        return key\n    }\n    void addWriteInfo(Map<String, Object> key, EntityWriteInfo newEwi) {\n        writeInfoList.add(newEwi)\n        if (!firstWriteInfoMap.containsKey(key)) firstWriteInfoMap.put(key, newEwi)\n        lastWriteInfoMap.put(key, newEwi)\n    }\n\n    /** Returns true if create handled, false if not; if false caller should handle the operation */\n    boolean create(EntityValueBase evb) {\n        Map<String, Object> key = makeKey(evb)\n        if (key == null) return false\n\n        if (!readOnly) {\n            // if create info already exists blow up\n            EntityWriteInfo currentEwi = lastWriteInfoMap.get(key)\n            if (readOneCache.get(key) != null)\n                throw new EntityException(\"Tried to create a value that already exists in database, entity ${evb.resolveEntityName()}, PK ${evb.getPrimaryKeys()}\")\n            if (currentEwi != null && currentEwi.writeMode != WriteMode.DELETE)\n                throw new EntityException(\"Tried to create a value that already exists in write cache, entity ${evb.resolveEntityName()}, PK ${evb.getPrimaryKeys()}\")\n\n            EntityWriteInfo newEwi = new EntityWriteInfo(evb, WriteMode.CREATE)\n            addWriteInfo(key, newEwi)\n            if (currentEwi == null || currentEwi.writeMode != WriteMode.DELETE) {\n                getCreateByEntityMap(evb.resolveEntityName()).put(evb.getPrimaryKeys(), evb)\n            }\n        }\n\n        // add to readCache after so we don't think it already exists\n        readOneCache.put(key, evb)\n        // add to any matching list cache entries\n        Map<EntityCondition, EntityListImpl> entityListCache = readListCache.get(evb.resolveEntityName())\n        if (entityListCache != null) {\n            for (Map.Entry<EntityCondition, EntityListImpl> entry in entityListCache.entrySet()) {\n                if (entry.getKey().mapMatches(evb)) entry.getValue().add(evb)\n            }\n        }\n\n        // consider created records locked to avoid forUpdate queries\n        knownLocked.add(key)\n\n        return !readOnly\n    }\n    boolean update(EntityValueBase evb) {\n        Map<String, Object> key = makeKey(evb)\n        if (key == null) return false\n\n        if (!readOnly) {\n            // with writeInfoList as plain list approach no need to look for existing create or update, just add to the list\n            if (!evb.getIsFromDb()) {\n                EntityValueBase cacheEvb = readOneCache.get(key)\n                if (cacheEvb != null) {\n                    cacheEvb.setFields(evb, true, null, false)\n                    evb = cacheEvb\n                } else {\n                    EntityValueBase dbEvb = (EntityValueBase) evb.cloneValue()\n                    dbEvb.refresh()\n                    dbEvb.setFields(evb, true, null, false)\n                    logger.warn(\"====== tx cache update not from db\\nevb: ${evb}\\ndbEvb: ${dbEvb}\")\n                    evb = dbEvb\n                }\n            }\n\n            EntityWriteInfo newEwi = new EntityWriteInfo(evb, WriteMode.UPDATE)\n            addWriteInfo(key, newEwi)\n        }\n\n        // add to readCache\n        if (evb.getIsFromDb()) {\n            readOneCache.put(key, evb)\n        } else {\n            // not from DB, may have partial values so find existing and put all from valueMap\n            EntityValueBase existingEv = readOneCache.get(key)\n            if (existingEv != null) {\n                existingEv.putAll(evb)\n            } else {\n                // NOTE: should put a not from DB value if not read only? if read only definitely no\n                if (!readOnly) readOneCache.put(key, evb)\n            }\n        }\n\n        // NOTE: issue here if the evb is partial, not full from DB/cache, and doesn't have field value that would match; solve higher up by getting full value?\n        // update any matching list cache entries, add to list cache if not there (though generally should be, depending on the condition)\n        Map<EntityCondition, EntityListImpl> entityListCache = readListCache.get(evb.resolveEntityName())\n        if (entityListCache != null) {\n            for (Map.Entry<EntityCondition, EntityListImpl> entry in entityListCache.entrySet()) {\n                if (entry.getKey().mapMatches(evb)) {\n                    // find an existing entry and update it\n                    boolean foundEntry = false\n                    EntityListImpl eli = entry.getValue()\n                    int eliSize = eli.size()\n                    for (int i = 0; i < eliSize; i++) {\n                        EntityValueBase existingEv = (EntityValueBase) eli.get(i)\n                        if (evb.primaryKeyMatches(existingEv)) {\n                            existingEv.putAll(evb)\n                            foundEntry = true\n                        }\n                    }\n                    // if no existing entry found add this\n                    if (!foundEntry) entry.getValue().add(evb)\n                }\n            }\n        }\n\n        knownLocked.add(key)\n\n        return !readOnly\n    }\n    boolean delete(EntityValueBase evb) {\n        Map<String, Object> key = makeKey(evb)\n        if (key == null) return false\n        // logger.warn(\"txc delete ${key}\")\n\n        if (!readOnly) {\n            EntityWriteInfo currentEwi = firstWriteInfoMap.get(key)\n            if (currentEwi != null && currentEwi.writeMode == WriteMode.CREATE) {\n                // if was created in TX cache but never written to DB just clear all changes\n                firstWriteInfoMap.remove(key)\n                lastWriteInfoMap.remove(key)\n                for (int i = 0; i < writeInfoList.size(); ) {\n                    EntityWriteInfo ewi = (EntityWriteInfo) writeInfoList.get(i)\n                    if (key.equals(makeKey(ewi.evb))) { writeInfoList.remove(i) }\n                    else { i++ }\n                }\n                getCreateByEntityMap(evb.resolveEntityName()).remove(evb.getPrimaryKeys())\n            } else {\n                EntityWriteInfo newEwi = new EntityWriteInfo(evb, WriteMode.DELETE)\n                addWriteInfo(key, newEwi)\n            }\n        }\n\n        // remove from readCache if needed\n        readOneCache.remove(key)\n        // remove any matching list cache entries\n        Map<EntityCondition, EntityListImpl> entityListCache = readListCache.get(evb.resolveEntityName())\n        if (entityListCache != null) {\n            for (Map.Entry<EntityCondition, EntityListImpl> entry in entityListCache.entrySet()) {\n                if (entry.getKey().mapMatches(evb)) {\n                    Iterator existingEvIter = entry.getValue().iterator()\n                    while (existingEvIter.hasNext()) {\n                        EntityValue existingEv = (EntityValue) existingEvIter.next()\n                        if (evb.getPrimaryKeys() == existingEv.getPrimaryKeys()) existingEvIter.remove()\n                    }\n                }\n            }\n        }\n\n        knownLocked.add(key)\n\n        return !readOnly\n    }\n    boolean refresh(EntityValueBase evb) {\n        Map<String, Object> key = makeKey(evb)\n        if (key == null) return false\n        EntityValueBase curEvb = readOneCache.get(key)\n        if (curEvb != null) {\n            ArrayList<String> nonPkFieldList = evb.getEntityDefinition().getNonPkFieldNames()\n            int nonPkSize = nonPkFieldList.size()\n            for (int j = 0; j < nonPkSize; j++) {\n                String fieldName = nonPkFieldList.get(j)\n                evb.getValueMap().put(fieldName, curEvb.getValueMap().get(fieldName))\n            }\n            evb.setSyncedWithDb()\n            return true\n        } else {\n            return false\n        }\n    }\n\n    boolean isTxCreate(EntityValueBase evb) {\n        if (readOnly || writeInfoList.size() == 0) return false\n        Map<String, Object> key = makeKey(evb)\n        if (key == null) return false\n        return isTxCreate(key)\n    }\n    protected boolean isTxCreate(Map key) {\n        if (readOnly || writeInfoList.size() == 0) return false\n        EntityWriteInfo currentEwi = firstWriteInfoMap.get(key)\n        if (currentEwi == null) return false\n        return currentEwi.writeMode == WriteMode.CREATE\n    }\n\n    boolean isKnownLocked(EntityValueBase evb) {\n        if (readOnly || knownLocked.size() == 0) return false\n        Map<String, Object> key = makeKey(evb)\n        if (key == null) return false\n        return knownLocked.contains(key)\n    }\n    EntityValueBase oneGet(EntityFindBase efb) {\n        // NOTE: do nothing here on forUpdate, handled by caller\n        Map<String, Object> key = makeKeyFind(efb)\n        if (key == null) return null\n\n        if (!readOnly) {\n            // if this has been deleted return a DeletedEntityValue instance so caller knows it was deleted and doesn't look in the DB for another record\n            EntityWriteInfo currentEwi = (EntityWriteInfo) lastWriteInfoMap.get(key)\n            if (currentEwi != null && currentEwi.writeMode == WriteMode.DELETE)\n                return new EntityValueBase.DeletedEntityValue(efb.getEntityDef(), ecfi.entityFacade)\n        }\n\n        // cloneValue() so that updates aren't in the read cache until an update is done\n        EntityValueBase evb = (EntityValueBase) readOneCache.get(key)?.cloneValue()\n        return evb\n    }\n    void onePut(EntityValueBase evb, boolean forUpdate) {\n        Map<String, Object> key = makeKey(evb)\n        if (key == null) return\n        EntityWriteInfo currentEwi = (EntityWriteInfo) lastWriteInfoMap.get(key)\n        // if this has been deleted we don't want to add it, but in general if we have a ewi then it's already in the\n        //     cache and we don't want to update from this (generally from DB and may be older than value already there)\n        // clone the value before putting it into the cache so that the caller can't change it later with an update call\n        if (currentEwi == null || currentEwi.writeMode != WriteMode.DELETE) readOneCache.put(key, (EntityValueBase) evb.cloneValue())\n\n        // if (evb.getEntityDefinition().getEntityName() == \"Asset\") logger.warn(\"=========== onePut of Asset ${evb.get('assetId')}\", new Exception(\"Location\"))\n\n        if (forUpdate) knownLocked.add(key)\n    }\n\n    EntityListImpl listGet(EntityDefinition ed, EntityCondition whereCondition, List<String> orderByExpanded) {\n        Map<EntityCondition, EntityListImpl> entityListCache = readListCache.get(ed.getFullEntityName())\n        // always clone this so that filters/sorts/etc by callers won't change this\n        EntityListImpl cacheList = entityListCache != null ? entityListCache.get(whereCondition)?.deepCloneList() : null\n\n        // if we are searching by a field that is a PK on a related entity to the one being searched it can only exist\n        //     in the read cache so find here and don't bother with a DB query\n        if (cacheList == null) {\n            // if the condition depends on a record that was created in this tx cache, then build the list from here\n            //     instead of letting it drop to the DB, finding nothing, then being expanded from the txCache\n            Map<String, Object> condMap = new LinkedHashMap<>()\n            if (whereCondition != null && whereCondition.populateMap(condMap)) {\n                boolean foundCreatedDependent = false\n\n                for (EntityJavaUtil.RelationshipInfo relInfo in ed.getRelationshipsInfo(false)) {\n                    if (relInfo.type != \"one\") continue\n                    // would be nice to skip this, but related-entity-name may not be full entity name\n                    EntityDefinition relEd = relInfo.relatedEd\n                    String relEntityName = relEd.getFullEntityName()\n                    // first see if there is a create Map for this, then do the more expensive operation of getting the\n                    //     expanded key Map and the related entity's PK Map\n                    Map relCreateMap = getCreateByEntityMap(relEntityName)\n                    if (relCreateMap) {\n                        Map relKeyMap = relInfo.keyMap\n                        Map relPk = [:]\n                        boolean foundAllPks = true\n                        for (Map.Entry<String, String> entry in relKeyMap.entrySet()) {\n                            Object relValue = condMap.get(entry.getKey())\n                            if (relValue) relPk.put(entry.getValue(), relValue)\n                            else foundAllPks = false\n                        }\n                        // if (ed.getFullEntityName().contains(\"OrderItem\")) logger.warn(\"==== listGet ${relEntityName} foundAllPks=${foundAllPks} relPk=${relPk} relCreateMap=${relCreateMap}\")\n                        if (!foundAllPks) continue\n                        if (relCreateMap.containsKey(relPk)) {\n                            foundCreatedDependent = true\n                            break\n                        }\n                    }\n                }\n                if (foundCreatedDependent) {\n                    EntityListImpl createdValueList = new EntityListImpl(ecfi.entityFacade)\n                    Map createMap = createByEntityRef.get(ed.getFullEntityName())\n                    if (createMap != null) {\n                        for (Object createEvbObj in createMap.values()) {\n                            if (createEvbObj instanceof EntityValueBase) {\n                                EntityValueBase createEvb = (EntityValueBase) createEvbObj\n                                if (whereCondition.mapMatches(createEvb)) createdValueList.add(createEvb)\n                            }\n                        }\n                    }\n\n                    if (createdValueList.size() > 0) {\n                        listPut(ed, whereCondition, createdValueList)\n                        cacheList = createdValueList.deepCloneList()\n                    }\n                }\n            }\n        }\n\n        if (cacheList && orderByExpanded) cacheList.orderByFields(orderByExpanded)\n        return cacheList\n    }\n    Map<EntityCondition, EntityListImpl> getEntityListCache(String entityName) {\n        Map<EntityCondition, EntityListImpl> entityListCache = readListCache.get(entityName)\n        if (entityListCache == null) {\n            entityListCache = [:]\n            readListCache.put(entityName, entityListCache)\n        }\n        return entityListCache\n    }\n    void listPut(EntityDefinition ed, EntityCondition whereCondition, EntityListImpl eli) {\n        if (eli.isFromCache()) return\n        Map<EntityCondition, EntityListImpl> entityListCache = getEntityListCache(ed.getFullEntityName())\n        // don't need to do much else here; list will already have values created/updated/deleted in this TX Cache\n        entityListCache.put(whereCondition, (EntityListImpl) eli.cloneList())\n    }\n\n    // NOTE: no need to filter EntityList or EntityListIterator, they do it internally by calling this method\n    WriteMode checkUpdateValue(EntityValueBase evb, FindAugmentInfo fai) {\n        Map<String, Object> key = makeKey(evb)\n        if (key == null) return null\n        EntityWriteInfo firstEwi = (EntityWriteInfo) firstWriteInfoMap.get(key)\n        EntityWriteInfo currentEwi = (EntityWriteInfo) lastWriteInfoMap.get(key)\n        if (currentEwi == null) {\n            // add to readCache for future reference\n            onePut(evb, false)\n            return null\n        }\n        if (WriteMode.CREATE.is(firstEwi.writeMode)) {\n            throw new EntityException(\"Found value from database that matches a value created in the write-through transaction cache, throwing error now instead of waiting to fail on commit\")\n        }\n        if (WriteMode.UPDATE.is(currentEwi.writeMode)) {\n            if (fai != null && ((fai.econd != null && !fai.econd.mapMatches(currentEwi.evb)) || fai.foundUpdated.contains(currentEwi.evb.getPrimaryKeys()))) {\n                // current value no longer matches, tell ELII to skip it (same as DELETE)\n                return WriteMode.DELETE\n            }\n            evb.setFields(currentEwi.evb, true, null, false)\n            // add to readCache\n            onePut(evb, false)\n        }\n        return currentEwi.writeMode\n    }\n    FindAugmentInfo getFindAugmentInfo(String entityName, EntityCondition econd) {\n        ArrayList<EntityValueBase> valueList = new ArrayList<>()\n\n        // also get values that have been updated so that they should now be included in the list\n        Set<Map> foundUpdated = new HashSet<>()\n        if (econd != null) {\n            int writeInfoListSize = writeInfoList.size()\n            // go through backwards to get the most recent only\n            for (int i = (writeInfoListSize - 1); i >= 0 ; i--) {\n                EntityWriteInfo ewi = (EntityWriteInfo) writeInfoList.get(i)\n                if (WriteMode.UPDATE.is(ewi.writeMode) && entityName.equals(ewi.evb.resolveEntityName()) && econd.mapMatches(ewi.evb)) {\n                    Map<String, Object> pkMap = ewi.evb.getPrimaryKeys()\n                    if (!foundUpdated.contains(pkMap)) {\n                        foundUpdated.add(pkMap)\n                        valueList.add(ewi.evb)\n                    }\n                }\n            }\n        }\n\n        Map<Map, EntityValueBase> createMap = getCreateByEntityMap(entityName)\n        if (createMap.size() > 0) for (EntityValueBase evb in createMap.values()) {\n            if (econd.mapMatches(evb) && (foundUpdated.size() == 0 || !foundUpdated.contains(evb.getPrimaryKeys())))\n                valueList.add(evb)\n        }\n        // if (entityName.contains(\"OrderPart\")) logger.warn(\"OP tx cache list: ${valueList}\")\n        return new FindAugmentInfo(valueList, foundUpdated, econd)\n    }\n\n    void flushCache(boolean clearRead) {\n        Map<String, Connection> connectionByGroup = new HashMap<>()\n        try {\n            int writeInfoListSize = writeInfoList.size()\n            if (writeInfoListSize > 0) {\n                // logger.error(\"Tx cache flush at\", new BaseException(\"txc flush\"))\n                EntityFacadeImpl efi = ecfi.entityFacade\n\n                long startTime = System.currentTimeMillis()\n                int createCount = 0\n                int updateCount = 0\n                int deleteCount = 0\n                // for (EntityWriteInfo ewi in writeInfoList) logger.warn(\"===== TX Cache value to ${ewi.writeMode} ${ewi.evb.resolveEntityName()}: \\n${ewi.evb}\")\n                if (readOnly && writeInfoListSize > 0) logger.warn(\"Read only TX cache has ${writeInfoListSize} values to write\")\n                for (int i = 0; i < writeInfoListSize; i++) {\n                    EntityWriteInfo ewi = (EntityWriteInfo) writeInfoList.get(i)\n                    String groupName = ewi.evb.getEntityDefinition().getEntityGroupName()\n                    Connection con = connectionByGroup.get(groupName)\n                    if (con == null) {\n                        con = efi.getConnection(groupName)\n                        connectionByGroup.put(groupName, con)\n                    }\n\n                    if (ewi.writeMode.is(WriteMode.CREATE)) {\n                        ewi.evb.basicCreate(con)\n                        createCount++\n                    } else if (ewi.writeMode.is(WriteMode.DELETE)) {\n                        ewi.evb.deleteExtended(con)\n                        deleteCount++\n                    } else {\n                        ewi.evb.basicUpdate(con)\n                        updateCount++\n                    }\n                }\n                if (logger.isDebugEnabled()) logger.debug(\"Flushed TransactionCache in ${System.currentTimeMillis() - startTime}ms: ${createCount} creates, ${updateCount} updates, ${deleteCount} deletes, ${readOneCache.size()} read entries, ${readListCache.size()} entities with list cache\")\n            }\n\n            writeInfoList.clear()\n            firstWriteInfoMap.clear()\n            lastWriteInfoMap.clear()\n            createByEntityRef.clear()\n            if (clearRead) {\n                readOneCache.clear()\n                readListCache.clear()\n                // set to readOnly to avoid any other write through\n                readOnly = true\n            }\n        } catch (Throwable t) {\n            logger.error(\"Error writing values from TransactionCache: ${t.toString()}\", t)\n            throw new XAException(\"Error writing values from TransactionCache: + ${t.toString()}\")\n        } finally {\n            // now close connections\n            for (Connection con in connectionByGroup.values()) con.close()\n        }\n    }\n\n    @Override void beforeCompletion() { flushCache(true) }\n    @Override void afterCompletion(int i) { }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/TransactionFacadeImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context\n\nimport groovy.transform.CompileStatic\nimport org.moqui.BaseException\nimport org.moqui.context.TransactionException\nimport org.moqui.context.TransactionFacade\nimport org.moqui.context.TransactionInternal\nimport org.moqui.impl.context.ContextJavaUtil.ConnectionWrapper\nimport org.moqui.impl.context.ContextJavaUtil.EntityRecordLock\nimport org.moqui.impl.context.ContextJavaUtil.RollbackInfo\nimport org.moqui.impl.context.ContextJavaUtil.TxStackInfo\nimport org.moqui.util.MNode\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport javax.naming.Context\nimport javax.naming.InitialContext\nimport javax.naming.NamingException\nimport javax.sql.XAConnection\nimport jakarta.transaction.*\nimport javax.transaction.xa.XAException\nimport javax.transaction.xa.XAResource\nimport java.sql.*\nimport java.util.concurrent.ConcurrentHashMap\n\n@CompileStatic\nclass TransactionFacadeImpl implements TransactionFacade {\n    protected final static Logger logger = LoggerFactory.getLogger(TransactionFacadeImpl.class)\n    protected final static boolean isTraceEnabled = logger.isTraceEnabled()\n\n    protected final ExecutionContextFactoryImpl ecfi\n\n    protected TransactionInternal transactionInternal = null\n\n    protected UserTransaction ut\n    protected TransactionManager tm\n\n    protected boolean useTransactionCache = true\n    protected boolean useConnectionStash = true\n    protected boolean useLockTrack = false\n    protected boolean useStatementTimeout = false\n\n    private ThreadLocal<TxStackInfo> txStackInfoCurThread = new ThreadLocal<TxStackInfo>()\n    private ThreadLocal<LinkedList<TxStackInfo>> txStackInfoListThread = new ThreadLocal<LinkedList<TxStackInfo>>()\n\n    protected final ConcurrentHashMap<String, ArrayList<EntityRecordLock>> recordLockByEntityPk = new ConcurrentHashMap<>()\n\n    TransactionFacadeImpl(ExecutionContextFactoryImpl ecfi) {\n        this.ecfi = ecfi\n\n        MNode transactionFacadeNode = ecfi.getConfXmlRoot().first(\"transaction-facade\")\n        transactionFacadeNode.setSystemExpandAttributes(true)\n        useLockTrack = \"true\".equals(transactionFacadeNode.attribute(\"use-lock-track\"))\n        useStatementTimeout = \"true\".equals(transactionFacadeNode.attribute(\"use-statement-timeout\"))\n\n        if (transactionFacadeNode.hasChild(\"transaction-jndi\")) {\n            this.populateTransactionObjectsJndi()\n        } else if (transactionFacadeNode.hasChild(\"transaction-internal\")) {\n            // initialize internal\n            MNode transactionInternalNode = transactionFacadeNode.first(\"transaction-internal\")\n            String tiClassName = transactionInternalNode.attribute(\"class\")\n            transactionInternal = (TransactionInternal) Thread.currentThread().getContextClassLoader()\n                    .loadClass(tiClassName).newInstance()\n            transactionInternal.init(ecfi)\n\n            ut = transactionInternal.getUserTransaction()\n            tm = transactionInternal.getTransactionManager()\n\n            logger.info(\"Internal transaction manager initialized: UserTransaction class ${ut?.class?.name}, TransactionManager class ${tm?.class?.name}\")\n        } else {\n            throw new IllegalArgumentException(\"No transaction-jndi or transaction-internal elements found in Moqui Conf XML file\")\n        }\n\n        if (transactionFacadeNode.attribute(\"use-transaction-cache\") == \"false\") useTransactionCache = false\n        if (transactionFacadeNode.attribute(\"use-connection-stash\") == \"false\") useConnectionStash = false\n    }\n\n    void destroy() {\n        // set to null first to avoid additional operations\n        this.tm = null\n        this.ut = null\n\n        // destroy internal if applicable; nothing for JNDI\n        if (transactionInternal != null) transactionInternal.destroy()\n\n        txStackInfoCurThread.remove()\n        txStackInfoListThread.remove()\n    }\n\n    /** This is called to make sure all transactions, etc are closed for the thread.\n     * It commits any active transactions, clears out internal data for the thread, etc.\n     */\n    void destroyAllInThread() {\n        if (isTransactionInPlace()) {\n            logger.warn(\"Thread ending with a transaction in place. Trying to commit.\")\n            commit()\n        }\n\n        LinkedList<TxStackInfo> txStackInfoList = txStackInfoListThread.get()\n        if (txStackInfoList) {\n            int numSuspended = 0\n            for (TxStackInfo txStackInfo in txStackInfoList) {\n                Transaction tx = txStackInfo.suspendedTx\n                if (tx != null) {\n                    resume()\n                    commit()\n                    numSuspended++\n                }\n            }\n            if (numSuspended > 0) logger.warn(\"Cleaned up [\" + numSuspended + \"] suspended transactions.\")\n        }\n\n        txStackInfoCurThread.remove()\n        txStackInfoListThread.remove()\n    }\n\n    boolean getUseLockTrack() { return useLockTrack }\n    boolean getUseStatementTimeout() { return useStatementTimeout }\n\n    TransactionInternal getTransactionInternal() { return transactionInternal }\n    TransactionManager getTransactionManager() { return tm }\n    UserTransaction getUserTransaction() { return ut }\n    Long getCurrentTransactionStartTime() {\n        TxStackInfo txStackInfo = getTxStackInfo()\n        Long time = txStackInfo != null ? (Long) txStackInfo.transactionBeginStartTime : (Long) null\n        if (time == null && isTraceEnabled) logger.trace(\"No transaction begin start time, transaction in place? [${this.isTransactionInPlace()}]\", new BaseException(\"Empty transactionBeginStackList location\"))\n        return time\n    }\n\n    protected LinkedList<TxStackInfo> getTxStackInfoList() {\n        LinkedList<TxStackInfo> list = (LinkedList<TxStackInfo>) txStackInfoListThread.get()\n        if (list == null) {\n            list = new LinkedList<TxStackInfo>()\n            txStackInfoListThread.set(list)\n            TxStackInfo txStackInfo = new TxStackInfo(this)\n            list.add(txStackInfo)\n            txStackInfoCurThread.set(txStackInfo)\n        }\n        return list\n    }\n    protected TxStackInfo getTxStackInfo() {\n        TxStackInfo txStackInfo = (TxStackInfo) txStackInfoCurThread.get()\n        if (txStackInfo == null) {\n            LinkedList<TxStackInfo> list = getTxStackInfoList()\n            txStackInfo = list.getFirst()\n        }\n        return txStackInfo\n    }\n    protected void pushTxStackInfo(Transaction tx, Exception txLocation) {\n        TxStackInfo txStackInfo = new TxStackInfo(this)\n        txStackInfo.suspendedTx = tx\n        txStackInfo.suspendedTxLocation = txLocation\n        getTxStackInfoList().addFirst(txStackInfo)\n        txStackInfoCurThread.set(txStackInfo)\n    }\n    protected void popTxStackInfo() {\n        LinkedList<TxStackInfo> list = getTxStackInfoList()\n        list.removeFirst()\n        txStackInfoCurThread.set(list.getFirst())\n    }\n\n\n    @Override\n    Object runUseOrBegin(Integer timeout, String rollbackMessage, Closure closure) {\n        if (rollbackMessage == null) rollbackMessage = \"\"\n        boolean beganTransaction = begin(timeout)\n        try {\n            return closure.call()\n        } catch (Throwable t) {\n            rollback(beganTransaction, rollbackMessage, t)\n            throw t\n        } finally {\n            commit(beganTransaction)\n        }\n    }\n    @Override\n    Object runRequireNew(Integer timeout, String rollbackMessage, Closure closure) {\n        return runRequireNew(timeout, rollbackMessage, true, true, closure)\n    }\n    protected final static boolean requireNewThread = true\n    Object runRequireNew(Integer timeout, String rollbackMessage, boolean beginTx, boolean threadReuseEci, Closure closure) {\n        Object result = null\n        if (requireNewThread) {\n            // if there is a timeout for this thread wait 10x the timeout (so multiple seconds by 10k instead of 1k)\n            long threadWait = timeout != null ? timeout * 10000 : 60000\n\n            Thread txThread = null\n            ExecutionContextImpl eci = ecfi.getEci()\n            Throwable threadThrown = null\n\n            try {\n                txThread = Thread.start('RequireNewTx', {\n                    if (threadReuseEci) ecfi.useExecutionContextInThread(eci)\n                    try {\n                        if (beginTx) {\n                            result = runUseOrBegin(timeout, rollbackMessage, closure)\n                        } else {\n                            result = closure.call()\n                        }\n                    } catch (Throwable t) {\n                        threadThrown = t\n                    }\n                })\n            } finally {\n                if (txThread != null) {\n                    txThread.join(threadWait)\n                    if (txThread.state != Thread.State.TERMINATED) {\n                        // TODO: do more than this?\n                        logger.warn(\"New transaction thread not terminated, in state ${txThread.state}\")\n                    }\n                }\n            }\n            if (threadThrown != null) throw threadThrown\n        } else {\n            boolean suspendedTransaction = false\n            try {\n                if (isTransactionInPlace()) suspendedTransaction = suspend()\n                if (beginTx) {\n                    result = runUseOrBegin(timeout, rollbackMessage, closure)\n                } else {\n                    result = closure.call()\n                }\n            } finally {\n                if (suspendedTransaction) resume()\n            }\n        }\n        return result\n    }\n\n    @Override\n    XAResource getActiveXaResource(String resourceName) {\n        return getTxStackInfo().getActiveXaResourceMap().get(resourceName)\n    }\n    @Override\n    void putAndEnlistActiveXaResource(String resourceName, XAResource xar) {\n        enlistResource(xar)\n        getTxStackInfo().getActiveXaResourceMap().put(resourceName, xar)\n    }\n\n    @Override\n    Synchronization getActiveSynchronization(String syncName) {\n        return getTxStackInfo().getActiveSynchronizationMap().get(syncName)\n    }\n    @Override\n    void putAndEnlistActiveSynchronization(String syncName, Synchronization sync) {\n        registerSynchronization(sync)\n        getTxStackInfo().getActiveSynchronizationMap().put(syncName, sync)\n    }\n\n\n    @Override\n    int getStatus() {\n        if (ut == null) return Status.STATUS_NO_TRANSACTION\n        try {\n            return ut.getStatus()\n        } catch (SystemException e) {\n            throw new TransactionException(\"System error, could not get transaction status\", e)\n        }\n    }\n\n    @Override\n    String getStatusString() {\n        int statusInt = getStatus()\n        /*\n         * javax.transaction.Status\n         * STATUS_ACTIVE           0\n         * STATUS_MARKED_ROLLBACK  1\n         * STATUS_PREPARED         2\n         * STATUS_COMMITTED        3\n         * STATUS_ROLLEDBACK       4\n         * STATUS_UNKNOWN          5\n         * STATUS_NO_TRANSACTION   6\n         * STATUS_PREPARING        7\n         * STATUS_COMMITTING       8\n         * STATUS_ROLLING_BACK     9\n         */\n        switch (statusInt) {\n            case Status.STATUS_ACTIVE:\n                return \"Active (${statusInt})\"\n            case Status.STATUS_COMMITTED:\n                return \"Committed (${statusInt})\"\n            case Status.STATUS_COMMITTING:\n                return \"Committing (${statusInt})\"\n            case Status.STATUS_MARKED_ROLLBACK:\n                return \"Marked Rollback-Only (${statusInt})\"\n            case Status.STATUS_NO_TRANSACTION:\n                return \"No Transaction (${statusInt})\"\n            case Status.STATUS_PREPARED:\n                return \"Prepared (${statusInt})\"\n            case Status.STATUS_PREPARING:\n                return \"Preparing (${statusInt})\"\n            case Status.STATUS_ROLLEDBACK:\n                return \"Rolledback (${statusInt})\"\n            case Status.STATUS_ROLLING_BACK:\n                return \"Rolling Back (${statusInt})\"\n            case Status.STATUS_UNKNOWN:\n                return \"Status Unknown (${statusInt})\"\n            default:\n                return \"Not a valid status code (${statusInt})\"\n        }\n    }\n\n    @Override\n    boolean isTransactionInPlace() { getStatus() != Status.STATUS_NO_TRANSACTION }\n\n    boolean isTransactionActive() { getStatus() == Status.STATUS_ACTIVE }\n    boolean isTransactionOperable() {\n        int curStatus = getStatus()\n        return curStatus == Status.STATUS_ACTIVE || curStatus == Status.STATUS_NO_TRANSACTION\n    }\n\n    int getTransactionTimeout() { return getTxStackInfo().transactionTimeout }\n    long getTxTimeoutRemainingMillis() {\n        TxStackInfo txStackInfo = getTxStackInfo()\n        long txTimeoutMs = txStackInfo.transactionTimeout * 1000L\n        long txSinceBeginMs = txStackInfo.transactionBeginStartTime != null ? System.currentTimeMillis() - txStackInfo.transactionBeginStartTime : 0L\n        return txSinceBeginMs > 0 ? txTimeoutMs - txSinceBeginMs : txTimeoutMs\n    }\n\n    @Override\n    boolean begin(Integer timeout) {\n        if (ut == null) throw new IllegalStateException(\"No transaction manager in place\")\n        int currentStatus = ut.getStatus()\n        // logger.warn(\"================ begin TX, currentStatus=${currentStatus}\", new BaseException(\"beginning transaction at\"))\n\n        if (currentStatus == Status.STATUS_ACTIVE) {\n            // don't begin, and return false so caller knows we didn't\n            return false\n        } else if (currentStatus == Status.STATUS_MARKED_ROLLBACK) {\n            TxStackInfo txStackInfo = getTxStackInfo()\n            if (txStackInfo.transactionBegin != null) {\n                logger.warn(\"Current transaction marked for rollback, so no transaction begun. This stack trace shows where the transaction began: \", txStackInfo.transactionBegin)\n            } else {\n                logger.warn(\"Current transaction marked for rollback, so no transaction begun (NOTE: No stack trace to show where transaction began).\")\n            }\n            if (txStackInfo.rollbackOnlyInfo != null) {\n                logger.warn(\"Current transaction marked for rollback, not beginning a new transaction. The rollback-only was set here: \", txStackInfo.rollbackOnlyInfo.rollbackLocation)\n                throw new TransactionException((String) \"Current transaction marked for rollback, so no transaction begun. The rollback was originally caused by: \" + txStackInfo.rollbackOnlyInfo.causeMessage, txStackInfo.rollbackOnlyInfo.causeThrowable)\n            } else {\n                return false\n            }\n        }\n\n        try {\n            // NOTE: Since JTA 1.1 setTransactionTimeout() is local to the thread, so this doesn't need to be synchronized.\n            if (timeout != null) ut.setTransactionTimeout(timeout)\n            ut.begin()\n\n            TxStackInfo txStackInfo = getTxStackInfo()\n            txStackInfo.transactionBegin = new Exception(\"Tx Begin Placeholder\")\n            txStackInfo.transactionBeginStartTime = System.currentTimeMillis()\n            if (timeout != null) txStackInfo.transactionTimeout = timeout\n            // logger.warn(\"================ begin TX, getActiveSynchronizationStack()=${getActiveSynchronizationStack()}\")\n\n            if (txStackInfo.txCache != null) logger.warn(\"Begin TX, tx cache is not null!\")\n            /* FUTURE: this is an interesting possibility, always use tx cache in read only mode, but currently causes issues (needs more work with cache clear, etc)\n            if (useTransactionCache) {\n                txStackInfo.txCache = new TransactionCache(ecfi, true)\n                registerSynchronization(txStackInfo.txCache)\n            }\n            */\n\n            return true\n        } catch (NotSupportedException e) {\n            throw new TransactionException(\"Could not begin transaction (could be a nesting problem)\", e)\n        } catch (SystemException e) {\n            throw new TransactionException(\"Could not begin transaction\", e)\n        } finally {\n            // make sure the timeout always gets reset to the default\n            if (timeout != null) ut.setTransactionTimeout(0)\n        }\n    }\n\n    @Override\n    void commit(boolean beganTransaction) { if (beganTransaction) this.commit() }\n\n    @Override\n    void commit() {\n        if (ut == null) throw new IllegalStateException(\"No transaction manager in place\")\n        TxStackInfo txStackInfo = getTxStackInfo()\n        try {\n            int status = ut.getStatus()\n            // logger.warn(\"================ commit TX, currentStatus=${status}\")\n\n            txStackInfo.closeTxConnections()\n            if (status == Status.STATUS_MARKED_ROLLBACK) {\n                if (txStackInfo.rollbackOnlyInfo != null) {\n                    logger.warn(\"Tried to commit transaction but marked rollback only, doing rollback instead; rollback-only was set here:\", txStackInfo.rollbackOnlyInfo.rollbackLocation)\n                } else {\n                    logger.warn(\"Tried to commit transaction but marked rollback only, doing rollback instead; no rollback-only info, current location:\", new BaseException(\"Rollback instead of commit location\"))\n                }\n                ut.rollback()\n            } else if (status != Status.STATUS_NO_TRANSACTION && status != Status.STATUS_COMMITTING &&\n                    status != Status.STATUS_COMMITTED && status != Status.STATUS_ROLLING_BACK &&\n                    status != Status.STATUS_ROLLEDBACK) {\n                ut.commit()\n            } else {\n                if (status != Status.STATUS_NO_TRANSACTION)\n                    logger.warn((String) \"Not committing transaction because status is \" + getStatusString(), new Exception(\"Bad TX status location\"))\n            }\n        } catch (RollbackException e) {\n            if (txStackInfo.rollbackOnlyInfo != null) {\n                logger.warn(\"Could not commit transaction, was marked rollback-only. The rollback-only was set here: \", txStackInfo.rollbackOnlyInfo.rollbackLocation)\n                throw new TransactionException(\"Could not commit transaction, was marked rollback-only. The rollback was originally caused by: \" + txStackInfo.rollbackOnlyInfo.causeMessage, txStackInfo.rollbackOnlyInfo.causeThrowable)\n            } else {\n                throw new TransactionException(\"Could not commit transaction, was rolled back instead (and we don't have a rollback-only cause)\", e)\n            }\n        } catch (IllegalStateException e) {\n            throw new TransactionException(\"Could not commit transaction\", e)\n        } catch (HeuristicMixedException e) {\n            throw new TransactionException(\"Could not commit transaction\", e)\n        } catch (HeuristicRollbackException e) {\n            throw new TransactionException(\"Could not commit transaction\", e)\n        } catch (SystemException e) {\n            throw new TransactionException(\"Could not commit transaction\", e)\n        } finally {\n            // there shouldn't be a TX around now, but if there is the commit may have failed so rollback to clean things up\n            if (ut != null) {\n                int status = ut.getStatus()\n                if (status != Status.STATUS_NO_TRANSACTION && status != Status.STATUS_COMMITTING &&\n                        status != Status.STATUS_COMMITTED && status != Status.STATUS_ROLLING_BACK &&\n                        status != Status.STATUS_ROLLEDBACK) {\n                    rollback(\"Commit failed, rolling back to clean up\", null)\n                }\n            }\n\n            txStackInfo.clearCurrent()\n        }\n    }\n\n    @Override\n    void rollback(boolean beganTransaction, String causeMessage, Throwable causeThrowable) {\n        if (beganTransaction) {\n            this.rollback(causeMessage, causeThrowable)\n        } else {\n            this.setRollbackOnly(causeMessage, causeThrowable)\n        }\n    }\n\n    @Override\n    void rollback(String causeMessage, Throwable causeThrowable) {\n        if (ut == null) throw new IllegalStateException(\"No transaction manager in place\")\n        TxStackInfo txStackInfo = getTxStackInfo()\n        try {\n            txStackInfo.closeTxConnections()\n\n            // logger.warn(\"================ rollback TX, currentStatus=${getStatus()}\")\n            if (getStatus() == Status.STATUS_NO_TRANSACTION) {\n                logger.warn(\"Transaction not rolled back, status is STATUS_NO_TRANSACTION\")\n                return\n            }\n\n            if (causeThrowable != null) {\n                String causeString = causeThrowable.toString()\n                if (causeString.contains(\"org.eclipse.jetty.io.EofException\")) {\n                    logger.warn(\"Transaction rollback. The rollback was originally caused by: ${causeMessage}\\n${causeString}\")\n                } else {\n                    logger.warn(\"Transaction rollback. The rollback was originally caused by: ${causeMessage}\", causeThrowable)\n                    logger.warn(\"Transaction rollback for [${causeMessage}]. Here is the current location: \", new BaseException(\"Rollback location\"))\n                }\n            } else {\n                logger.warn(\"Transaction rollback for [${causeMessage}]. Here is the current location: \", new BaseException(\"Rollback location\"))\n            }\n\n            ut.rollback()\n        } catch (IllegalStateException e) {\n            throw new TransactionException(\"Could not rollback transaction\", e)\n        } catch (SystemException e) {\n            throw new TransactionException(\"Could not rollback transaction\", e)\n        } finally {\n            // NOTE: should this really be in finally? maybe we only want to do this if there is a successful rollback\n            // to avoid removing things that should still be there, or maybe here in finally it will match up the adds\n            // and removes better\n            txStackInfo.clearCurrent()\n        }\n    }\n\n    @Override\n    void setRollbackOnly(String causeMessage, Throwable causeThrowable) {\n        if (ut == null) throw new IllegalStateException(\"No transaction manager in place\")\n        try {\n            int status = getStatus()\n            if (status != Status.STATUS_NO_TRANSACTION) {\n                if (status != Status.STATUS_MARKED_ROLLBACK) {\n                    Exception rbLocation = new BaseException(\"Set rollback only location\")\n\n                    if (causeThrowable != null) {\n                        String causeString = causeThrowable.toString()\n                        if (causeString.contains(\"org.eclipse.jetty.io.EofException\")) {\n                            logger.warn(\"Transaction set rollback only. The rollback was originally caused by: ${causeMessage}\\n${causeString}\")\n                        } else {\n                            logger.warn(\"Transaction set rollback only. The rollback was originally caused by: ${causeMessage}\", causeThrowable)\n                            logger.warn(\"Transaction set rollback only for [${causeMessage}]. Here is the current location: \", rbLocation)\n                        }\n                    } else {\n                        logger.warn(\"Transaction rollback for [${causeMessage}]. Here is the current location: \", rbLocation)\n                    }\n\n                    ut.setRollbackOnly()\n                    // do this after setRollbackOnly so it only tracks it if rollback-only was actually set\n                    getTxStackInfo().rollbackOnlyInfo = new RollbackInfo(causeMessage, causeThrowable, rbLocation)\n                }\n            } else {\n                logger.warn(\"Rollback only not set on current transaction, status is STATUS_NO_TRANSACTION\")\n            }\n        } catch (IllegalStateException e) {\n            throw new TransactionException(\"Could not set rollback only on current transaction\", e)\n        } catch (SystemException e) {\n            throw new TransactionException(\"Could not set rollback only on current transaction\", e)\n        }\n    }\n\n    @Override\n    boolean suspend() {\n        if (ut == null) throw new IllegalStateException(\"No transaction manager in place\")\n        try {\n            if (getStatus() == Status.STATUS_NO_TRANSACTION) {\n                logger.warn(\"No transaction in place so not suspending\")\n                return false\n            }\n\n            // close connections before suspend, let the pool reuse them\n            TxStackInfo txStackInfo = getTxStackInfo()\n            txStackInfo.closeTxConnections()\n\n            Transaction tx = tm.suspend()\n            // only do these after successful suspend\n            pushTxStackInfo(tx, new Exception(\"Transaction Suspend Location\"))\n\n            return true\n        } catch (SystemException e) {\n            throw new TransactionException(\"Could not suspend transaction\", e)\n        }\n    }\n\n    @Override\n    void resume() {\n        if (ut == null) throw new IllegalStateException(\"No transaction manager in place\")\n        if (isTransactionInPlace()) {\n            logger.warn(\"Resume with transaction in place, trying commit to close\")\n            commit()\n        }\n\n        try {\n            TxStackInfo txStackInfo = getTxStackInfo()\n            if (txStackInfo.suspendedTx != null) {\n                tm.resume(txStackInfo.suspendedTx)\n                // only do this after successful resume\n                popTxStackInfo()\n            } else {\n                logger.warn(\"No transaction suspended, so not resuming\")\n            }\n        } catch (InvalidTransactionException e) {\n            throw new TransactionException(\"Could not resume transaction\", e)\n        } catch (SystemException e) {\n            throw new TransactionException(\"Could not resume transaction\", e)\n        }\n    }\n\n    @Override\n    Connection enlistConnection(XAConnection con) {\n        if (con == null) return null\n        try {\n            XAResource resource = con.getXAResource()\n            this.enlistResource(resource)\n            return con.getConnection()\n        } catch (SQLException e) {\n            throw new TransactionException(\"Could not enlist connection in transaction\", e)\n        }\n    }\n\n    @Override\n    void enlistResource(XAResource resource) {\n        if (resource == null) return\n        if (getStatus() != Status.STATUS_ACTIVE) {\n            logger.warn(\"Not enlisting XAResource: transaction not ACTIVE\", new Exception(\"Warning Location\"))\n            return\n        }\n        try {\n            Transaction tx = tm.getTransaction()\n            if (tx != null) {\n                 tx.enlistResource(resource)\n            } else {\n                logger.warn(\"Not enlisting XAResource: transaction was null\", new Exception(\"Warning Location\"))\n            }\n        } catch (RollbackException e) {\n            throw new TransactionException(\"Could not enlist XAResource in transaction\", e)\n        } catch (SystemException e) {\n            // This is deprecated, hopefully errors are adequate without, but leaving here for future reference\n            // if (e instanceof ExtendedSystemException) {\n            //     for (Throwable se in e.errors) logger.error(\"Extended Atomikos error: ${se.toString()}\", se)\n            // }\n            throw new TransactionException(\"Could not enlist XAResource in transaction\", e)\n        }\n    }\n\n    @Override\n    void registerSynchronization(Synchronization sync) {\n        if (sync == null) return\n        if (getStatus() != Status.STATUS_ACTIVE) {\n            logger.warn(\"Not registering Synchronization: transaction not ACTIVE\", new Exception(\"Warning Location\"))\n            return\n        }\n        try {\n            Transaction tx = tm.getTransaction()\n            if (tx != null) {\n                 tx.registerSynchronization(sync)\n            } else {\n                logger.warn(\"Not registering Synchronization: transaction was null\", new Exception(\"Warning Location\"))\n            }\n        } catch (RollbackException e) {\n            throw new TransactionException(\"Could not register Synchronization in transaction\", e)\n        } catch (SystemException e) {\n            throw new TransactionException(\"Could not register Synchronization in transaction\", e)\n        }\n    }\n\n    @Override\n    void initTransactionCache(boolean readOnly) {\n        if (!useTransactionCache) return\n        TxStackInfo txStackInfo = getTxStackInfo()\n        if (txStackInfo.txCache == null) {\n            if (isTraceEnabled) {\n                StringBuilder infoString = new StringBuilder()\n                infoString.append(\"Initializing TX cache at:\")\n                for (infoAei in ecfi.getEci().artifactExecutionFacade.getStack()) infoString.append(infoAei.getName())\n                logger.trace(infoString.toString())\n            // } else if (logger.isInfoEnabled()) {\n            //     logger.info(\"Initializing TX cache in ${ecfi.getEci().getArtifactExecutionImpl().peek()?.getName()}\")\n            }\n\n            if (tm == null || tm.getStatus() != Status.STATUS_ACTIVE) throw new XAException(\"Cannot enlist: no transaction manager or transaction not active\")\n\n            TransactionCache txCache = new TransactionCache(this.ecfi, readOnly)\n            txStackInfo.txCache = txCache\n            registerSynchronization(txCache)\n        } else if (txStackInfo.txCache.isReadOnly()) {\n            if (isTraceEnabled) logger.trace(\"Making TX cache write through in ${ecfi.getEci().artifactExecutionFacade.peek()?.getName()}\")\n            txStackInfo.txCache.makeWriteThrough()\n            // doing on read only init: registerSynchronization(txStackInfo.txCache)\n        }\n    }\n    @Override\n    boolean isTransactionCacheActive() {\n        TxStackInfo txStackInfo = getTxStackInfo()\n        return txStackInfo.txCache != null && !txStackInfo.txCache.isReadOnly()\n    }\n    TransactionCache getTransactionCache() { return getTxStackInfo().txCache }\n    @Override\n    void flushAndDisableTransactionCache() {\n        TxStackInfo txStackInfo = getTxStackInfo()\n        if (txStackInfo.txCache != null) {\n            txStackInfo.txCache.makeReadOnly()\n            // would be safer to flush and remove it completely, but trying just switching to read only mode\n            // txStackInfo.txCache.flushCache(true)\n            // txStackInfo.txCache = null\n        }\n    }\n\n    Connection getTxConnection(String groupName) {\n        if (!useConnectionStash) return null\n\n        String conKey = groupName\n        TxStackInfo txStackInfo = getTxStackInfo()\n        ConnectionWrapper con = (ConnectionWrapper) txStackInfo.txConByGroup.get(conKey)\n        if (con == null) return null\n\n        if (con.isClosed()) {\n            txStackInfo.txConByGroup.remove(conKey)\n            logger.info(\"Stashed connection closed elsewhere for group ${groupName}: ${con.toString()}\")\n            return null\n        }\n        if (!isTransactionActive()) {\n            con.close()\n            txStackInfo.txConByGroup.remove(conKey)\n            logger.info(\"Stashed connection found but transaction is not active (${getStatusString()}) for group ${groupName}: ${con.toString()}\")\n            return null\n        }\n        return con\n    }\n    Connection stashTxConnection(String groupName, Connection con) {\n        if (!useConnectionStash || !isTransactionActive()) return con\n\n        TxStackInfo txStackInfo = getTxStackInfo()\n        // if transactionBeginStartTime is null we didn't begin the transaction, so can't count on commit/rollback through this\n        if (txStackInfo.transactionBeginStartTime == null) return con\n\n        String conKey = groupName\n        ConnectionWrapper existing = (ConnectionWrapper) txStackInfo.txConByGroup.get(conKey)\n        try {\n            if (existing != null && !existing.isClosed()) existing.closeInternal()\n        } catch (Throwable t) {\n            logger.error(\"Error closing previously stashed connection for group ${groupName}: ${existing.toString()}\", t)\n        }\n        ConnectionWrapper newCw = new ConnectionWrapper(con, this, groupName)\n        txStackInfo.txConByGroup.put(conKey, newCw)\n        return newCw\n    }\n\n    /* ================== */\n    /* Lock Track Methods */\n    /* ================== */\n\n    void registerRecordLock(EntityRecordLock erl) {\n        if (!useLockTrack) return\n        erl.register(recordLockByEntityPk, getTxStackInfo())\n    }\n\n\n    // ========== Initialize/Populate Methods ==========\n\n    void populateTransactionObjectsJndi() {\n        MNode transactionJndiNode = this.ecfi.getConfXmlRoot().first(\"transaction-facade\").first(\"transaction-jndi\")\n        String userTxJndiName = transactionJndiNode.attribute(\"user-transaction-jndi-name\")\n        String txMgrJndiName = transactionJndiNode.attribute(\"transaction-manager-jndi-name\")\n\n        MNode serverJndi = this.ecfi.getConfXmlRoot().first(\"transaction-facade\").first(\"server-jndi\")\n\n        try {\n            InitialContext ic\n            if (serverJndi != null) {\n                Hashtable<String, Object> h = new Hashtable<String, Object>()\n                h.put(Context.INITIAL_CONTEXT_FACTORY, serverJndi.attribute(\"initial-context-factory\"))\n                h.put(Context.PROVIDER_URL, serverJndi.attribute(\"context-provider-url\"))\n                if (serverJndi.attribute(\"url-pkg-prefixes\")) h.put(Context.URL_PKG_PREFIXES, serverJndi.attribute(\"url-pkg-prefixes\"))\n                if (serverJndi.attribute(\"security-principal\")) h.put(Context.SECURITY_PRINCIPAL, serverJndi.attribute(\"security-principal\"))\n                if (serverJndi.attribute(\"security-credentials\")) h.put(Context.SECURITY_CREDENTIALS, serverJndi.attribute(\"security-credentials\"))\n                ic = new InitialContext(h)\n            } else {\n                ic = new InitialContext()\n            }\n\n            this.ut = (UserTransaction) ic.lookup(userTxJndiName)\n            this.tm = (TransactionManager) ic.lookup(txMgrJndiName)\n        } catch (NamingException ne) {\n            logger.error(\"Error while finding JNDI Transaction objects [${userTxJndiName}] and [${txMgrJndiName}] from server [${serverJndi ? serverJndi.attribute(\"context-provider-url\") : \"default\"}].\", ne)\n        }\n\n        if (this.ut == null) logger.error(\"Could not find UserTransaction with name [${userTxJndiName}] in JNDI server [${serverJndi ? serverJndi.attribute(\"context-provider-url\") : \"default\"}].\")\n        if (this.tm == null) logger.error(\"Could not find TransactionManager with name [${txMgrJndiName}] in JNDI server [${serverJndi ? serverJndi.attribute(\"context-provider-url\") : \"default\"}].\")\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/TransactionInternalBitronix.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context\n\nimport bitronix.tm.BitronixTransactionManager\nimport bitronix.tm.TransactionManagerServices\nimport bitronix.tm.resource.jdbc.PoolingDataSource\nimport bitronix.tm.utils.ClassLoaderUtils\nimport bitronix.tm.utils.PropertyUtils\nimport groovy.transform.CompileStatic\nimport org.moqui.context.ExecutionContextFactory\nimport org.moqui.context.TransactionInternal\nimport org.moqui.entity.EntityFacade\nimport org.moqui.impl.entity.EntityFacadeImpl\nimport org.moqui.util.MNode\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport javax.sql.DataSource\nimport javax.sql.XADataSource\nimport jakarta.transaction.TransactionManager\nimport jakarta.transaction.UserTransaction\nimport java.sql.Connection\n\n@CompileStatic\nclass TransactionInternalBitronix implements TransactionInternal {\n    protected final static Logger logger = LoggerFactory.getLogger(TransactionInternalBitronix.class)\n\n    protected ExecutionContextFactoryImpl ecfi\n\n    protected BitronixTransactionManager btm\n    protected UserTransaction ut\n    protected TransactionManager tm\n\n    protected List<PoolingDataSource> pdsList = []\n\n    @Override\n    TransactionInternal init(ExecutionContextFactory ecf) {\n        this.ecfi = (ExecutionContextFactoryImpl) ecf\n\n        // NOTE: see the bitronix-default-config.properties file for more config\n\n        btm = TransactionManagerServices.getTransactionManager()\n        this.ut = btm\n        this.tm = btm\n\n        return this\n    }\n\n    @Override\n    TransactionManager getTransactionManager() { return tm }\n\n    @Override\n    UserTransaction getUserTransaction() { return ut }\n\n    @Override\n    DataSource getDataSource(EntityFacade ef, MNode datasourceNode) {\n        // NOTE: this is called during EFI init, so use the passed one and don't try to get from ECFI\n        EntityFacadeImpl efi = (EntityFacadeImpl) ef\n\n        EntityFacadeImpl.DatasourceInfo dsi = new EntityFacadeImpl.DatasourceInfo(efi, datasourceNode)\n\n        PoolingDataSource pds = new PoolingDataSource()\n        pds.setUniqueName(dsi.uniqueName)\n        if (dsi.xaDsClass) {\n            pds.setClassName(dsi.xaDsClass)\n            pds.setDriverProperties(dsi.xaProps)\n\n            Class<?> xaFactoryClass = ClassLoaderUtils.loadClass(dsi.xaDsClass)\n            Object xaFactory = xaFactoryClass.newInstance()\n            if (!(xaFactory instanceof XADataSource))\n                throw new IllegalArgumentException(\"xa-ds-class \" + xaFactory.getClass().getName() + \" does not implement XADataSource\")\n            XADataSource xaDataSource = (XADataSource) xaFactory\n\n            for (Map.Entry<Object, Object> entry : dsi.xaProps.entrySet()) {\n                String name = (String) entry.getKey()\n                Object value = entry.getValue()\n\n                try {\n                    PropertyUtils.setProperty(xaDataSource, name, value)\n                } catch (Exception e) {\n                    logger.warn(\"Error setting ${dsi.uniqueName} property ${name}, ignoring: ${e.toString()}\")\n                }\n            }\n            pds.setXaDataSource(xaDataSource)\n        } else {\n            pds.setClassName(\"bitronix.tm.resource.jdbc.lrc.LrcXADataSource\")\n            pds.getDriverProperties().setProperty(\"driverClassName\", dsi.jdbcDriver)\n            pds.getDriverProperties().setProperty(\"url\", dsi.jdbcUri)\n            pds.getDriverProperties().setProperty(\"user\", dsi.jdbcUsername)\n            pds.getDriverProperties().setProperty(\"password\", dsi.jdbcPassword)\n        }\n\n        String txIsolationLevel = dsi.inlineJdbc.attribute(\"isolation-level\") ?\n                dsi.inlineJdbc.attribute(\"isolation-level\") : dsi.database.attribute(\"default-isolation-level\")\n        int isolationInt = efi.getTxIsolationFromString(txIsolationLevel)\n        if (txIsolationLevel && isolationInt != -1) {\n            switch (isolationInt) {\n                case Connection.TRANSACTION_SERIALIZABLE: pds.setIsolationLevel(\"SERIALIZABLE\"); break\n                case Connection.TRANSACTION_REPEATABLE_READ: pds.setIsolationLevel(\"REPEATABLE_READ\"); break\n                case Connection.TRANSACTION_READ_UNCOMMITTED: pds.setIsolationLevel(\"READ_UNCOMMITTED\"); break\n                case Connection.TRANSACTION_READ_COMMITTED: pds.setIsolationLevel(\"READ_COMMITTED\"); break\n                case Connection.TRANSACTION_NONE: pds.setIsolationLevel(\"NONE\"); break\n            }\n        }\n\n        // no need for this, just sets min and max sizes: ads.setPoolSize\n        pds.setMinPoolSize((dsi.inlineJdbc.attribute(\"pool-minsize\") ?: \"5\") as int)\n        pds.setMaxPoolSize((dsi.inlineJdbc.attribute(\"pool-maxsize\") ?: \"50\") as int)\n\n        if (dsi.inlineJdbc.attribute(\"pool-time-idle\")) pds.setMaxIdleTime(dsi.inlineJdbc.attribute(\"pool-time-idle\") as int)\n        // if (dsi.inlineJdbc.\"@pool-time-reap\") ads.setReapTimeout(dsi.inlineJdbc.\"@pool-time-reap\" as int)\n        // if (dsi.inlineJdbc.\"@pool-time-maint\") ads.setMaintenanceInterval(dsi.inlineJdbc.\"@pool-time-maint\" as int)\n        if (dsi.inlineJdbc.attribute(\"pool-time-wait\")) pds.setAcquisitionTimeout(dsi.inlineJdbc.attribute(\"pool-time-wait\") as int)\n        pds.setAllowLocalTransactions(true) // allow mixing XA and non-XA transactions\n        pds.setAutomaticEnlistingEnabled(true) // automatically enlist/delist this resource in the tx\n        pds.setShareTransactionConnections(true) // share connections within a transaction\n        pds.setDeferConnectionRelease(true) // only one transaction per DB connection (can be false if supported by DB)\n        // pds.setShareTransactionConnections(false) // don't share connections in the ACCESSIBLE, needed?\n        // pds.setIgnoreRecoveryFailures(false) // something to consider for XA recovery errors, quarantines by default\n\n        pds.setEnableJdbc4ConnectionTest(true) // use faster jdbc4 connection test\n        // default is 0, disabled PreparedStatement cache (cache size per Connection)\n        // NOTE: make this configurable? value too high or low?\n        pds.setPreparedStatementCacheSize(100)\n\n        // use-tm-join defaults to true, so does Bitronix so just set to false if false\n        if (dsi.database.attribute(\"use-tm-join\") == \"false\") pds.setUseTmJoin(false)\n\n        if (dsi.inlineJdbc.attribute(\"pool-test-query\")) {\n            pds.setTestQuery(dsi.inlineJdbc.attribute(\"pool-test-query\"))\n        } else if (dsi.database.attribute(\"default-test-query\")) {\n            pds.setTestQuery(dsi.database.attribute(\"default-test-query\"))\n        }\n\n        logger.info(\"Initializing DataSource ${dsi.uniqueName} (${dsi.database.attribute('name')}) with properties: ${dsi.dsDetails}\")\n\n        // init the DataSource\n        pds.init()\n        logger.info(\"Init DataSource ${dsi.uniqueName} (${dsi.database.attribute('name')}) isolation ${pds.getIsolationLevel()} (${isolationInt}), max pool ${pds.getMaxPoolSize()}\")\n\n        pdsList.add(pds)\n\n        return pds\n    }\n\n    @Override\n    void destroy() {\n        logger.info(\"Shutting down Bitronix\")\n        // close the DataSources\n        for (PoolingDataSource pds in pdsList) pds.close()\n        // shutdown Bitronix\n        btm.shutdown()\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/UserFacadeImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context\n\nimport groovy.transform.CompileStatic\nimport org.apache.shiro.authc.AuthenticationToken\nimport org.apache.shiro.authc.ExpiredCredentialsException\nimport org.moqui.context.PasswordChangeRequiredException\n\nimport jakarta.servlet.http.Cookie\nimport jakarta.servlet.http.HttpServletRequest\nimport jakarta.servlet.http.HttpServletResponse\nimport jakarta.servlet.http.HttpSession\n\nimport jakarta.websocket.server.HandshakeRequest\nimport java.sql.Timestamp\n\nimport org.apache.shiro.authc.AuthenticationException\nimport org.apache.shiro.authc.UsernamePasswordToken\nimport org.apache.shiro.subject.Subject\nimport org.apache.shiro.subject.support.DefaultSubjectContext\nimport org.apache.shiro.web.subject.WebSubjectContext\nimport org.apache.shiro.web.subject.support.DefaultWebSubjectContext\nimport org.apache.shiro.web.session.HttpServletSession\n\nimport org.moqui.context.ArtifactExecutionInfo\nimport org.moqui.context.AuthenticationRequiredException\nimport org.moqui.context.SecondFactorRequiredException\nimport org.moqui.context.UserFacade\nimport org.moqui.entity.EntityCondition\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.context.ArtifactExecutionInfoImpl.ArtifactAuthzCheck\nimport org.moqui.impl.entity.EntityValueBase\nimport org.moqui.impl.screen.ScreenUrlInfo\nimport org.moqui.impl.util.MoquiShiroRealm\nimport org.moqui.util.MNode\nimport org.moqui.util.StringUtilities\nimport org.moqui.util.WebUtilities\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n@CompileStatic\nclass UserFacadeImpl implements UserFacade {\n    protected final static Logger logger = LoggerFactory.getLogger(UserFacadeImpl.class)\n    protected final static Set<String> allUserGroupIdOnly = new HashSet([\"ALL_USERS\"])\n\n    protected ExecutionContextImpl eci\n    protected Timestamp effectiveTime = (Timestamp) null\n\n    protected UserInfo currentInfo\n    protected Deque<UserInfo> userInfoStack = new LinkedList<UserInfo>()\n\n    // there may be non-web visits, so keep a copy of the visitId here\n    protected String visitId = (String) null\n    protected EntityValue visitInternal = (EntityValue) null\n    protected String visitorIdInternal = (String) null\n    protected String clientIpInternal = (String) null\n\n    // we mostly want this for the Locale default, and may be useful for other things\n    protected HttpServletRequest request = (HttpServletRequest) null\n    protected HttpServletResponse response = (HttpServletResponse) null\n    // NOTE: a better practice is to always get from the request, but for WebSocket handshakes we don't have a request\n    protected HttpSession session = (HttpSession) null\n\n    UserFacadeImpl(ExecutionContextImpl eci) {\n        this.eci = eci\n        pushUser(null)\n    }\n\n    Subject makeEmptySubject() {\n        if (session != null) {\n            WebSubjectContext wsc = new DefaultWebSubjectContext()\n            if (request != null) wsc.setServletRequest(request)\n            if (response != null) wsc.setServletResponse(response)\n            wsc.setSession(new HttpServletSession(session, request?.getServerName()))\n            return eci.ecfi.getSecurityManager().createSubject(wsc)\n        } else {\n            return eci.ecfi.getSecurityManager().createSubject(new DefaultSubjectContext())\n        }\n    }\n\n    void initFromHttpRequest(HttpServletRequest request, HttpServletResponse response) {\n        this.request = request\n        this.response = response\n        this.session = request.getSession()\n\n        // get client IP address, handle proxy upstream address if added in a header\n        clientIpInternal = getClientIp(request, null, eci.ecfi)\n\n        String preUsername = getUsername()\n        Subject webSubject = makeEmptySubject()\n        if (webSubject.isAuthenticated()) {\n            String sesUsername = (String) webSubject.getPrincipal()\n            if (preUsername != null && !preUsername.isEmpty()) {\n                if (!preUsername.equals(sesUsername)) {\n                    logger.warn(\"Found user ${sesUsername} in session but UserFacade has user ${preUsername}, popping user\")\n                    popUser()\n                }\n            }\n\n            // user found in session so no login needed, but make sure hasLoggedOut != \"Y\"\n            EntityValue userAccount = (EntityValue) null\n            if (sesUsername != null && !sesUsername.isEmpty()) {\n                EntityCondition usernameCond = eci.entityFacade.getConditionFactory()\n                        .makeCondition(\"username\", EntityCondition.ComparisonOperator.EQUALS, username).ignoreCase()\n                userAccount = eci.getEntity().find(\"moqui.security.UserAccount\")\n                        .condition(usernameCond).useCache(false).disableAuthz().one()\n            }\n\n            if (userAccount != null && \"Y\".equals(userAccount.getNoCheckSimple(\"hasLoggedOut\"))) {\n                // logout user through Shiro, invalidate session, continue\n                logger.info(\"User ${sesUsername} is authenticated in session but hasLoggedOut elsewhere, logging out\")\n                webSubject.logout()\n                // Shiro invalidates session, but make sure just in case\n                HttpSession oldSession = request.getSession(false)\n                if (oldSession != null) oldSession.invalidate()\n                this.session = request.getSession()\n            } else {\n\n                // effectively login the user for framework (already logged in for session through Shiro)\n                pushUserSubject(webSubject)\n                if (logger.traceEnabled) logger.trace(\"For new request found user [${getUsername()}] in the session\")\n            }\n        } else {\n            if (logger.traceEnabled) logger.trace(\"For new request NO user authenticated in the session\")\n            if (preUsername != null && !preUsername.isEmpty()) {\n                logger.warn(\"Found NO user in session but UserFacade has user ${preUsername}, popping user\")\n                popUser()\n            }\n        }\n\n        this.visitId = session.getAttribute(\"moqui.visitId\")\n\n        // check for HTTP Basic Authorization for Authentication purposes\n        // NOTE: do this even if there is another user logged in, will go on stack\n        Map secureParameters = eci.webImpl != null ? eci.webImpl.getSecureRequestParameters() :\n                WebUtilities.simplifyRequestParameters(request, true)\n        String authzHeader = request.getHeader(\"Authorization\")\n        if (authzHeader != null && authzHeader.length() > 6 && authzHeader.startsWith(\"Basic \")) {\n            String basicAuthEncoded = authzHeader.substring(6).trim()\n            String basicAuthAsString = new String(basicAuthEncoded.decodeBase64())\n            int indexOfColon = basicAuthAsString.indexOf(\":\")\n            if (indexOfColon > 0) {\n                String username = basicAuthAsString.substring(0, indexOfColon)\n                String password = basicAuthAsString.substring(indexOfColon + 1)\n                this.loginUser(username, password)\n            } else {\n                logger.warn(\"For HTTP Basic Authorization got bad credentials string. Base64 encoded is [${basicAuthEncoded}] and after decoding is [${basicAuthAsString}].\")\n            }\n        }\n        if (currentInfo.username == null && (request.getHeader(\"api_key\") || request.getHeader(\"login_key\"))) {\n            String loginKey = request.getHeader(\"api_key\") ?: request.getHeader(\"login_key\")\n            loginKey = loginKey.trim()\n            if (loginKey != null && !loginKey.isEmpty() && !\"null\".equals(loginKey) && !\"undefined\".equals(loginKey))\n                this.loginUserKey(loginKey)\n        }\n        if (currentInfo.username == null && (secureParameters.api_key || secureParameters.login_key)) {\n            String loginKey = secureParameters.api_key ?: secureParameters.login_key\n            loginKey = loginKey.trim()\n            if (loginKey != null && !loginKey.isEmpty() && !\"null\".equals(loginKey) && !\"undefined\".equals(loginKey))\n                this.loginUserKey(loginKey)\n        }\n        if (currentInfo.username == null && secureParameters.authUsername) {\n            // try the Moqui-specific parameters for instant login\n            // if we have credentials coming in anywhere other than URL parameters, try logging in\n            String authUsername = secureParameters.authUsername\n            String authPassword = secureParameters.authPassword\n            this.loginUser(authUsername, authPassword)\n        }\n        if (eci.messageFacade.hasError()) request.setAttribute(\"moqui.login.error\", \"true\")\n\n        // NOTE: only tracking Visitor and Visit if there is a WebFacadeImpl in place\n        if (eci.webImpl != null && !this.visitId && !eci.getSkipStats()) {\n            MNode serverStatsNode = eci.ecfi.getServerStatsNode()\n            ScreenUrlInfo sui = ScreenUrlInfo.getScreenUrlInfo(eci.screenFacade, request)\n            // before doing anything with the visit, etc make sure exists\n            sui.checkExists()\n            boolean isJustContent = sui.fileResourceRef != null\n\n            // handle visitorId and cookie\n            String cookieVisitorId = (String) null\n            if (!isJustContent && !\"false\".equals(serverStatsNode.attribute('visitor-enabled'))) {\n                Cookie[] cookies = request.getCookies()\n                if (cookies != null) {\n                    for (int i = 0; i < cookies.length; i++) {\n                        if (cookies[i].getName().equals(\"moqui.visitor\")) {\n                            cookieVisitorId = cookies[i].getValue()\n                            break\n                        }\n                    }\n                }\n                if (cookieVisitorId) {\n                    // make sure the Visitor record actually exists, if not act like we got no moqui.visitor cookie\n                    EntityValue visitor = eci.entity.find(\"moqui.server.Visitor\").condition(\"visitorId\", cookieVisitorId).disableAuthz().one()\n                    if (visitor == null) {\n                        logger.info(\"Got invalid visitorId [${cookieVisitorId}] in moqui.visitor cookie in session [${session.id}], throwing away and making a new one\")\n                        cookieVisitorId = null\n                    }\n                }\n                if (!cookieVisitorId) {\n                    // NOTE: disable authz for this call, don't normally want to allow create of Visitor, but this is a special case\n                    Map cvResult = eci.service.sync().name(\"create\", \"moqui.server.Visitor\")\n                            .parameter(\"createdDate\", getNowTimestamp()).disableAuthz().call()\n                    cookieVisitorId = (String) cvResult?.visitorId\n                    if (logger.traceEnabled) logger.trace(\"Created new Visitor with ID [${cookieVisitorId}] in session [${session.id}]\")\n                }\n                if (cookieVisitorId) {\n                    // whether it existed or not, add it again to keep it fresh; stale cookies get thrown away\n                    Cookie visitorCookie = new Cookie(\"moqui.visitor\", cookieVisitorId)\n                    visitorCookie.setMaxAge(60 * 60 * 24 * 365)\n                    visitorCookie.setPath(\"/\")\n                    visitorCookie.setHttpOnly(true)\n                    if (request.isSecure()) visitorCookie.setSecure(true)\n                    response.addCookie(visitorCookie)\n\n                    session.setAttribute(\"moqui.visitorId\", cookieVisitorId)\n                }\n            }\n            visitorIdInternal = cookieVisitorId\n\n            if (!isJustContent && !\"false\".equals(serverStatsNode.attribute('visit-enabled'))) {\n                // create and persist Visit\n                String contextPath = session.getServletContext().getContextPath()\n                String webappId = contextPath.length() > 1 ? contextPath.substring(1) : \"ROOT\"\n                String fullUrl = eci.webImpl.requestUrl\n                fullUrl = (fullUrl.length() > 255) ? fullUrl.substring(0, 255) : fullUrl.toString()\n                String curUserAgent = request.getHeader(\"User-Agent\") ?: \"\"\n                if (curUserAgent != null && curUserAgent.length() > 255) curUserAgent = curUserAgent.substring(0, 255)\n                Map<String, Object> parameters = new HashMap<String, Object>([sessionId:session.id, webappName:webappId,\n                        fromDate:new Timestamp(session.getCreationTime()),\n                        initialLocale:getLocale().toString(), initialRequest:fullUrl,\n                        initialReferrer:request.getHeader(\"Referer\")?:\"\",\n                        initialUserAgent:curUserAgent,\n                        clientHostName:request.getRemoteHost(), clientUser:request.getRemoteUser()])\n\n                InetAddress address = eci.ecfi.getLocalhostAddress()\n                parameters.serverIpAddress = address?.getHostAddress() ?: \"127.0.0.1\"\n                parameters.serverHostName = address?.getHostName() ?: \"localhost\"\n                parameters.clientIpAddress = clientIpInternal\n                if (cookieVisitorId) parameters.visitorId = cookieVisitorId\n\n                // NOTE: disable authz for this call, don't normally want to allow create of Visit, but this is special case\n                Map visitResult = eci.service.sync().name(\"create\", \"moqui.server.Visit\").parameters(parameters)\n                        .disableAuthz().call()\n                // put visitId in session as \"moqui.visitId\"\n                if (visitResult) {\n                    session.setAttribute(\"moqui.visitId\", visitResult.visitId)\n                    this.visitId = visitResult.visitId\n                    if (logger.traceEnabled) logger.trace(\"Created new Visit with ID [${this.visitId}] in session [${session.id}]\")\n                }\n            }\n        }\n    }\n    void initFromHandshakeRequest(HandshakeRequest request) {\n        try {\n            this.session = (HttpSession) request.getHttpSession()\n        } catch (Throwable t) {\n            // Jetty 12 EE 11 bug https://github.com/jetty/jetty.project/issues/11809\n            logger.trace(\"Failed to get HttpSession from WebSocket HandshakeRequest\", t)\n        }\n\n        // get client IP address, handle proxy original address if exists\n        clientIpInternal = getClientIp(null, request, eci.ecfi)\n\n        // WebSocket handshake request is the HTTP upgrade request so this will be the original session\n        // login user from value in session\n        Subject webSubject = makeEmptySubject()\n        if (webSubject.isAuthenticated()) {\n            // effectively login the user\n            pushUserSubject(webSubject)\n            if (logger.traceEnabled) logger.trace(\"For new request found user [${username}] in the session\")\n        } else {\n            if (logger.traceEnabled) logger.trace(\"For new request NO user authenticated in the session\")\n        }\n\n        Map<String, List<String>> headers = request.getHeaders()\n        Map<String, List<String>> parameters = request.getParameterMap()\n        String authzHeader = headers.get(\"Authorization\") ? headers.get(\"Authorization\").get(0) : null\n        if (authzHeader != null && authzHeader.length() > 6 && authzHeader.substring(0, 6).equals(\"Basic \")) {\n            String basicAuthEncoded = authzHeader.substring(6).trim()\n            String basicAuthAsString = new String(basicAuthEncoded.decodeBase64())\n            if (basicAuthAsString.indexOf(\":\") > 0) {\n                String username = basicAuthAsString.substring(0, basicAuthAsString.indexOf(\":\"))\n                String password = basicAuthAsString.substring(basicAuthAsString.indexOf(\":\") + 1)\n                this.loginUser(username, password)\n            } else {\n                logger.warn(\"For HTTP Basic Authorization got bad credentials string. Base64 encoded is [${basicAuthEncoded}] and after decoding is [${basicAuthAsString}].\")\n            }\n        }\n        if (currentInfo.username == null && (headers.api_key || headers.login_key)) {\n            String loginKey = headers.api_key ? headers.api_key.get(0) : (headers.login_key ? headers.login_key.get(0) : null)\n            loginKey = loginKey.trim()\n            if (loginKey != null && !loginKey.isEmpty() && !\"null\".equals(loginKey) && !\"undefined\".equals(loginKey))\n                this.loginUserKey(loginKey)\n        }\n        if (currentInfo.username == null && (parameters.api_key || parameters.login_key)) {\n            String loginKey = parameters.api_key ? parameters.api_key.get(0) : (parameters.login_key ? parameters.login_key.get(0) : null)\n            loginKey = loginKey.trim()\n            logger.warn(\"loginKey2 ${loginKey}\")\n            if (loginKey != null && !loginKey.isEmpty() && !\"null\".equals(loginKey) && !\"undefined\".equals(loginKey))\n                this.loginUserKey(loginKey)\n        }\n        if (currentInfo.username == null && parameters.authUsername) {\n            // try the Moqui-specific parameters for instant login\n            // if we have credentials coming in anywhere other than URL parameters, try logging in\n            String authUsername = parameters.authUsername.get(0)\n            String authPassword = parameters.authPassword ? parameters.authPassword.get(0) : null\n            this.loginUser(authUsername, authPassword)\n        }\n    }\n    void initFromHttpSession(HttpSession session) {\n        this.session = session\n        Subject webSubject = makeEmptySubject()\n        if (webSubject.isAuthenticated()) {\n            // effectively login the user\n            pushUserSubject(webSubject)\n            if (logger.traceEnabled) logger.trace(\"For new request found user [${username}] in the session\")\n        } else {\n            if (logger.traceEnabled) logger.trace(\"For new request NO user authenticated in the session\")\n        }\n    }\n\n\n    @Override Locale getLocale() { return currentInfo.localeCache }\n    @Override void setLocale(Locale locale) {\n        if (currentInfo.userAccount != null) {\n            eci.transaction.runUseOrBegin(null, \"Error saving locale\", {\n                boolean alreadyDisabled = eci.getArtifactExecution().disableAuthz()\n                try {\n                    EntityValue userAccountClone = currentInfo.userAccount.cloneValue()\n                    userAccountClone.set(\"locale\", locale.toString())\n                    userAccountClone.update()\n                } finally { if (!alreadyDisabled) eci.getArtifactExecution().enableAuthz() }\n            })\n        }\n        currentInfo.localeCache = locale\n    }\n\n    @Override TimeZone getTimeZone() { return currentInfo.tzCache }\n    Calendar getCalendarSafe() {\n        return Calendar.getInstance(currentInfo.tzCache != null ? currentInfo.tzCache : TimeZone.getDefault(),\n                currentInfo.localeCache != null ? currentInfo.localeCache :\n                        (request != null ? request.getLocale() : Locale.getDefault()))\n    }\n    @Override void setTimeZone(TimeZone tz) {\n        if (currentInfo.userAccount != null) {\n            eci.transaction.runUseOrBegin(null, \"Error saving timeZone\", {\n                boolean alreadyDisabled = eci.getArtifactExecution().disableAuthz()\n                try {\n                    EntityValue userAccountClone = currentInfo.userAccount.cloneValue()\n                    userAccountClone.set(\"timeZone\", tz.getID())\n                    userAccountClone.update()\n                } finally { if (!alreadyDisabled) eci.getArtifactExecution().enableAuthz() }\n            })\n        }\n        currentInfo.tzCache = tz\n    }\n\n    @Override String getCurrencyUomId() { return currentInfo.currencyUomId }\n    @Override void setCurrencyUomId(String uomId) {\n        if (currentInfo.userAccount != null) {\n            eci.transaction.runUseOrBegin(null, \"Error saving currencyUomId\", {\n                boolean alreadyDisabled = eci.getArtifactExecution().disableAuthz()\n                try {\n                    EntityValue userAccountClone = currentInfo.userAccount.cloneValue()\n                    userAccountClone.set(\"currencyUomId\", uomId)\n                    userAccountClone.update()\n                } finally { if (!alreadyDisabled) eci.getArtifactExecution().enableAuthz() }\n            })\n        }\n        currentInfo.currencyUomId = uomId\n    }\n\n    @Override String getPreference(String preferenceKey) {\n        String userId = getUserId()\n        return getPreference(preferenceKey, userId)\n    }\n    String getPreference(String preferenceKey, String userId) {\n        if (preferenceKey == null || preferenceKey.isEmpty()) return null\n\n        // look in system properties for preferenceKey or key with '.' replaced by '_'; overrides DB values\n        String sysPropVal = System.getProperty(preferenceKey)\n        if (sysPropVal == null || sysPropVal.isEmpty()) {\n            String underscoreKey = preferenceKey.replace('.' as char, '_' as char)\n            sysPropVal = System.getProperty(underscoreKey)\n        }\n        if (sysPropVal != null && !sysPropVal.isEmpty()) return sysPropVal\n\n        EntityValue up = userId != null ? eci.entityFacade.fastFindOne(\"moqui.security.UserPreference\", true, true, userId, preferenceKey) : null\n        if (up == null) {\n            // try UserGroupPreference\n            EntityList ugpList = eci.getEntity().find(\"moqui.security.UserGroupPreference\")\n                    .condition(\"userGroupId\", EntityCondition.IN, getUserGroupIdSet(userId))\n                    .condition(\"preferenceKey\", preferenceKey)\n                    .orderBy(\"groupPriority\").orderBy(\"-userGroupId\")\n                    .useCache(true).disableAuthz().list()\n            if (ugpList != null && ugpList.size() > 0) up = ugpList.get(0)\n        }\n        return up?.preferenceValue\n    }\n\n    @Override Map<String, String> getPreferences(String keyRegexp) {\n        String userId = getUserId()\n        boolean hasKeyFilter = keyRegexp != null && !keyRegexp.isEmpty()\n\n        Map<String, String> prefMap = new HashMap<>()\n        // start with UserGroupPreference, put UserPreference values over top to override\n        // NOTE: sort in reverse order from normal query so that later values in list overwrite earlier values\n        EntityList ugpList = eci.getEntity().find(\"moqui.security.UserGroupPreference\")\n                .condition(\"userGroupId\", EntityCondition.IN, getUserGroupIdSet(userId))\n                .orderBy(\"-groupPriority\").orderBy(\"userGroupId\")\n                .disableAuthz().list()\n        int ugpListSize = ugpList.size()\n        for (int i = 0; i < ugpListSize; i++) {\n            EntityValue ugp = (EntityValue) ugpList.get(i)\n            String prefKey = (String) ugp.getNoCheckSimple(\"preferenceKey\")\n            if (hasKeyFilter && !prefKey.matches(keyRegexp)) continue\n            String prefValue = (String) ugp.getNoCheckSimple(\"preferenceValue\")\n            if (prefValue != null && !prefValue.isEmpty()) prefMap.put(prefKey, prefValue)\n        }\n\n        if (userId != null) {\n            EntityList uprefList = eci.getEntity().find(\"moqui.security.UserPreference\")\n                    .condition(\"userId\", userId).disableAuthz().list()\n            int uprefListSize = uprefList.size()\n            for (int i = 0; i < uprefListSize; i++) {\n                EntityValue upref = (EntityValue) uprefList.get(i)\n                String prefKey = (String) upref.getNoCheckSimple(\"preferenceKey\")\n                if (hasKeyFilter && !prefKey.matches(keyRegexp)) continue\n                String prefValue = (String) upref.getNoCheckSimple(\"preferenceValue\")\n                if (prefValue != null && !prefValue.isEmpty()) prefMap.put(prefKey, prefValue)\n            }\n        }\n\n        return prefMap\n    }\n\n    @Override void setPreference(String preferenceKey, String preferenceValue) {\n        String userId = getUserId()\n        if (!userId) throw new IllegalStateException(\"Cannot set preference with key ${preferenceKey}, no user logged in.\")\n        boolean alreadyDisabled = eci.getArtifactExecution().disableAuthz()\n        boolean beganTransaction = eci.transaction.begin(null)\n        try {\n            eci.getEntity().makeValue(\"moqui.security.UserPreference\").set(\"userId\", getUserId())\n                    .set(\"preferenceKey\", preferenceKey).set(\"preferenceValue\", preferenceValue).createOrUpdate()\n        } catch (Throwable t) {\n            eci.transaction.rollback(beganTransaction, \"Error saving UserPreference\", t)\n        } finally {\n            if (eci.transaction.isTransactionInPlace()) eci.transaction.commit(beganTransaction)\n            if (!alreadyDisabled) eci.getArtifactExecution().enableAuthz()\n        }\n    }\n\n    @Override Map<String, Object> getContext() { return currentInfo.getUserContext() }\n\n    @Override Timestamp getNowTimestamp() {\n        // NOTE: review Timestamp and nowTimestamp use, have things use this by default (except audit/etc where actual date/time is needed\n        return ((Object) this.effectiveTime != null) ? this.effectiveTime : new Timestamp(System.currentTimeMillis())\n    }\n\n    @Override Calendar getNowCalendar() {\n        Calendar nowCal = getCalendarSafe()\n        nowCal.setTimeInMillis(getNowTimestamp().getTime())\n        return nowCal\n    }\n\n    @Override ArrayList<Timestamp> getPeriodRange(String period, String poffset) { return getPeriodRange(period, poffset, null) }\n    @Override ArrayList<Timestamp> getPeriodRange(String period, String poffset, String pdate) {\n        int offset = (poffset ?: \"0\") as int\n        java.sql.Date sqlDate = (pdate != null && !pdate.isEmpty()) ? eci.l10nFacade.parseDate(pdate, null) : null\n        return getPeriodRange(period, offset, sqlDate)\n    }\n    @Override ArrayList<Timestamp> getPeriodRange(String period, int offset, java.sql.Date sqlDate) {\n        period = (period ?: \"day\").toLowerCase()\n        boolean perIsNumber = Character.isDigit(period.charAt(0))\n\n        Calendar basisCal = getCalendarSafe()\n        if (sqlDate != null) basisCal.setTimeInMillis(sqlDate.getTime())\n        basisCal.set(Calendar.HOUR_OF_DAY, 0); basisCal.set(Calendar.MINUTE, 0)\n        basisCal.set(Calendar.SECOND, 0); basisCal.set(Calendar.MILLISECOND, 0)\n        // this doesn't seem to work to set the time to midnight: basisCal.setTime(new java.sql.Date(nowTimestamp.time))\n        Calendar fromCal = (Calendar) basisCal.clone()\n        Calendar thruCal\n        if (perIsNumber && period.endsWith(\"d\")) {\n            int days = Integer.parseInt(period.substring(0, period.length() - 1))\n            if (offset < 0) {\n                fromCal.add(Calendar.DAY_OF_YEAR, offset * days)\n                thruCal = (Calendar) basisCal.clone()\n                // also include today (or anchor date in pdate)\n                thruCal.add(Calendar.DAY_OF_YEAR, 1)\n            } else {\n                // fromCal already set to basisCal, just set thruCal\n                thruCal = (Calendar) basisCal.clone()\n                thruCal.add(Calendar.DAY_OF_YEAR, (offset + 1) * days)\n            }\n        } else if (perIsNumber && period.endsWith(\"r\")) {\n            int days = Integer.parseInt(period.substring(0, period.length() - 1))\n            if (offset < 0) offset = -offset\n            fromCal.add(Calendar.DAY_OF_YEAR, -offset * days)\n            thruCal = (Calendar) basisCal.clone()\n            thruCal.add(Calendar.DAY_OF_YEAR, offset * days)\n        } else if (period == \"week\") {\n            fromCal.set(Calendar.DAY_OF_WEEK, fromCal.getFirstDayOfWeek())\n            fromCal.add(Calendar.WEEK_OF_YEAR, offset)\n            thruCal = (Calendar) fromCal.clone()\n            thruCal.add(Calendar.WEEK_OF_YEAR, 1)\n        } else if (period == \"weeks\") {\n            if (offset < 0) {\n                // from end of month of basis date go back offset months (add negative offset to from after copying for thru)\n                fromCal.set(Calendar.DAY_OF_WEEK, fromCal.getFirstDayOfWeek())\n                thruCal = (Calendar) fromCal.clone()\n                thruCal.add(Calendar.WEEK_OF_YEAR, 1)\n                fromCal.add(Calendar.WEEK_OF_YEAR, offset + 1)\n            } else {\n                // from beginning of month of basis date go forward offset months (add offset to thru)\n                fromCal.set(Calendar.DAY_OF_WEEK, fromCal.getFirstDayOfWeek())\n                thruCal = (Calendar) fromCal.clone()\n                thruCal.add(Calendar.WEEK_OF_YEAR, offset == 0 ? 1 : offset)\n            }\n        } else if (period == \"month\") {\n            fromCal.set(Calendar.DAY_OF_MONTH, fromCal.getActualMinimum(Calendar.DAY_OF_MONTH))\n            fromCal.add(Calendar.MONTH, offset)\n            thruCal = (Calendar) fromCal.clone()\n            thruCal.add(Calendar.MONTH, 1)\n        } else if (period == \"months\") {\n            if (offset < 0) {\n                // from end of month of basis date go back offset months (add negative offset to from after copying for thru)\n                fromCal.set(Calendar.DAY_OF_MONTH, fromCal.getActualMinimum(Calendar.DAY_OF_MONTH))\n                thruCal = (Calendar) fromCal.clone()\n                thruCal.add(Calendar.MONTH, 1)\n                fromCal.add(Calendar.MONTH, offset + 1)\n            } else {\n                // from beginning of month of basis date go forward offset months (add offset to thru)\n                fromCal.set(Calendar.DAY_OF_MONTH, fromCal.getActualMinimum(Calendar.DAY_OF_MONTH))\n                thruCal = (Calendar) fromCal.clone()\n                thruCal.add(Calendar.MONTH, offset == 0 ? 1 : offset)\n            }\n        } else if (period == \"quarter\") {\n            fromCal.set(Calendar.DAY_OF_MONTH, fromCal.getActualMinimum(Calendar.DAY_OF_MONTH))\n            int quarterNumber = (fromCal.get(Calendar.MONTH) / 3) as int\n            fromCal.set(Calendar.MONTH, (quarterNumber * 3))\n            fromCal.add(Calendar.MONTH, (offset * 3))\n            thruCal = (Calendar) fromCal.clone()\n            thruCal.add(Calendar.MONTH, 3)\n        } else if (period == \"year\") {\n            fromCal.set(Calendar.DAY_OF_YEAR, fromCal.getActualMinimum(Calendar.DAY_OF_YEAR))\n            fromCal.add(Calendar.YEAR, offset)\n            thruCal = (Calendar) fromCal.clone()\n            thruCal.add(Calendar.YEAR, 1)\n        } else {\n            // default to day\n            fromCal.add(Calendar.DAY_OF_YEAR, offset)\n            thruCal = (Calendar) fromCal.clone()\n            thruCal.add(Calendar.DAY_OF_YEAR, 1)\n        }\n\n        ArrayList<Timestamp> rangeList = new ArrayList<>(2)\n        rangeList.add(new Timestamp(fromCal.getTimeInMillis()))\n        rangeList.add(new Timestamp(thruCal.getTimeInMillis()))\n        // logger.warn(\"fromCal first ${fromCal.getFirstDayOfWeek()} TZ ${fromCal.getTimeZone().getDisplayName()} basisCal first ${basisCal.getFirstDayOfWeek()} TZ ${basisCal.getTimeZone().getDisplayName()} range ${rangeList} default TZ ${TimeZone.getDefault().getDisplayName()}\")\n\n        return rangeList\n    }\n\n    @Override String getPeriodDescription(String period, String poffset, String pdate) {\n        ArrayList<Timestamp> rangeList = getPeriodRange(period, poffset, pdate)\n        StringBuilder desc = new StringBuilder()\n        if (poffset == \"0\") desc.append(eci.getL10n().localize(\"This\"))\n        else if (poffset == \"-1\") desc.append(eci.getL10n().localize(\"Last\"))\n        else if (poffset == \"1\") desc.append(eci.getL10n().localize(\"Next\"))\n        else desc.append(poffset)\n        desc.append(' ')\n\n        if (period == \"day\") desc.append(eci.getL10n().localize(\"Day\"))\n        else if (period == \"7d\") desc.append('7 ').append(eci.getL10n().localize(\"Days\"))\n        else if (period == \"30d\") desc.append('30 ').append(eci.getL10n().localize(\"Days\"))\n        else if (period == \"week\") desc.append(eci.getL10n().localize(\"Week\"))\n        else if (period == \"weeks\") desc.append(eci.getL10n().localize(\"Weeks\"))\n        else if (period == \"month\") desc.append(eci.getL10n().localize(\"Month\"))\n        else if (period == \"months\") desc.append(eci.getL10n().localize(\"Months\"))\n        else if (period == \"quarter\") desc.append(eci.getL10n().localize(\"Quarter\"))\n        else if (period == \"year\") desc.append(eci.getL10n().localize(\"Year\"))\n        else if (period == \"7r\") desc.append(\"+/-7d\")\n        else if (period == \"30r\") desc.append(\"+/-30d\")\n\n        if (pdate) desc.append(\" \").append(eci.getL10n().localize(\"from##period\")).append(\" \").append(pdate)\n\n        desc.append(\" (\").append(eci.l10n.format(rangeList[0], 'yyyy-MM-dd')).append(' ')\n                .append(eci.getL10n().localize(\"to##period\")).append(' ')\n                .append(eci.l10n.format(rangeList[1] - 1, 'yyyy-MM-dd')).append(')')\n\n        return desc.toString()\n    }\n\n    @Override ArrayList<Timestamp> getPeriodRange(String baseName, Map<String, Object> inputFieldsMap) {\n        if (inputFieldsMap.get(baseName + \"_period\")) {\n            return getPeriodRange((String) inputFieldsMap.get(baseName + \"_period\"),\n                    (String) inputFieldsMap.get(baseName + \"_poffset\"), (String) inputFieldsMap.get(baseName + \"_pdate\"))\n        } else {\n            ArrayList<Timestamp> rangeList = new ArrayList<>(2)\n            rangeList.add(null); rangeList.add(null)\n\n            Object fromValue = inputFieldsMap.get(baseName + \"_from\")\n            if (fromValue && fromValue instanceof CharSequence) {\n                if (fromValue.length() < 12)\n                    rangeList.set(0, eci.l10nFacade.parseTimestamp(fromValue.toString() + \" 00:00:00.000\", \"yyyy-MM-dd HH:mm:ss.SSS\"))\n                else\n                    rangeList.set(0, eci.l10nFacade.parseTimestamp(fromValue.toString(), null))\n            } else if (fromValue instanceof Timestamp) {\n                rangeList.set(0, (Timestamp) fromValue)\n            }\n            Object thruValue = inputFieldsMap.get(baseName + \"_thru\")\n            if (thruValue && thruValue instanceof CharSequence) {\n                if (thruValue.length() < 12)\n                    rangeList.set(1, eci.l10nFacade.parseTimestamp(thruValue.toString() + \" 23:59:59.999\", \"yyyy-MM-dd HH:mm:ss.SSS\"))\n                else\n                    rangeList.set(1, eci.l10nFacade.parseTimestamp(thruValue.toString(), null))\n            } else if (thruValue instanceof Timestamp) {\n                rangeList.set(1, (Timestamp) thruValue)\n            }\n\n            return rangeList\n        }\n    }\n\n    @Override void setEffectiveTime(Timestamp effectiveTime) { this.effectiveTime = effectiveTime }\n\n    @Override boolean loginUser(String username, String password) {\n        if (username == null || username.isEmpty()) {\n            eci.messageFacade.addError(eci.l10n.localize(\"No username specified\"))\n            return false\n        }\n        if (password == null || password.isEmpty()) {\n            eci.messageFacade.addError(eci.l10n.localize(\"No password specified\"))\n            return false\n        }\n\n        // if there is a web session invalidate it so there is a new session for the login (prevent Session Fixation attacks)\n        if (eci.getWebImpl() != null) eci.getWebImpl().makeNewSession()\n\n        UsernamePasswordToken token = new UsernamePasswordToken(username, password, true)\n        return internalLoginToken(username, token)\n    }\n\n    /** For internal framework use only, does a login without authc. */\n    boolean internalLoginUser(String username) { return internalLoginUser(username, true) }\n    boolean internalLoginUser(String username, boolean saveHistory) {\n        if (username == null || username.isEmpty()) {\n            eci.message.addError(eci.l10n.localize(\"No username specified\"))\n            return false\n        }\n\n        UsernamePasswordToken token = new MoquiShiroRealm.ForceLoginToken(username, true, saveHistory)\n        return internalLoginToken(username, token)\n    }\n    boolean internalLoginToken(String username, AuthenticationToken token) {\n        if (eci.web != null) {\n            // this ensures that after correctly logging in, a previously attempted login user's \"Second Factor\" screen isn't displayed\n            eci.web.sessionAttributes.remove(\"moquiPreAuthcUsername\")\n            eci.web.sessionAttributes.remove(\"moquiAuthcFactorRequired\")\n        }\n\n        Subject loginSubject = makeEmptySubject()\n        try {\n            // do the actual login through Shiro\n            loginSubject.login(token)\n\n            // do this first so that the rest will be done as this user\n            // just in case there is already a user authenticated push onto a stack to remember\n            pushUserSubject(loginSubject)\n\n            // after successful login trigger the after-login actions\n            if (eci.getWebImpl() != null) {\n                eci.getWebImpl().runAfterLoginActions()\n                eci.getWebImpl().getRequest().setAttribute(\"moqui.request.authenticated\", \"true\")\n            }\n        } catch (SecondFactorRequiredException ae) {\n            if (eci.web != null) {\n                // This makes the session realize the this user needs to verify login with an authentication factor\n                eci.web.sessionAttributes.put(\"moquiPreAuthcUsername\", username)\n                eci.web.sessionAttributes.put(\"moquiAuthcFactorRequired\", \"true\")\n            }\n            // don't add this particular error, causes problems when this is followed immediately by an attempt to verify a submitted code in the same tx: eci.messageFacade.addError(ae.message)\n            return false\n        } catch (PasswordChangeRequiredException ae) {\n            if (eci.web != null) {\n                eci.web.sessionAttributes.put(\"moquiPreAuthcUsername\", username)\n                eci.web.sessionAttributes.put(\"moquiPasswordChangeRequired\", \"true\")\n            }\n            eci.messageFacade.addError(ae.message)\n            return false\n        } catch (ExpiredCredentialsException ae) {\n            if (eci.web != null) {\n                eci.web.sessionAttributes.put(\"moquiPreAuthcUsername\", username)\n                eci.web.sessionAttributes.put(\"moquiExpiredCredentials\", \"true\")\n            }\n            eci.messageFacade.addError(ae.message)\n            return false\n        } catch (AuthenticationException ae) {\n            // others to consider handling differently (these all inherit from AuthenticationException):\n            //     UnknownAccountException, IncorrectCredentialsException, ExpiredCredentialsException,\n            //     CredentialsException, LockedAccountException, DisabledAccountException, ExcessiveAttemptsException\n            eci.messageFacade.addError(ae.message)\n            return false\n        }\n        return true\n    }\n\n    /** For internal use only, quick login using a Subject already logged in from another thread, etc */\n    boolean internalLoginSubject(Subject loginSubject) {\n        if (loginSubject == null || !loginSubject.getPrincipal() || !loginSubject.isAuthenticated()) return false\n        pushUserSubject(loginSubject)\n        return true\n    }\n\n    void logoutLocal() {\n        // before logout trigger the before-logout actions\n        if (eci.getWebImpl() != null) eci.getWebImpl().runBeforeLogoutActions()\n\n        // pop from user stack, also calls Shiro logout()\n        popUser()\n    }\n\n    @Override void logoutUser() {\n        String userId = getUserId()\n        // if userId set hasLoggedOut\n        if (userId != null && !userId.isEmpty()) {\n            logger.info(\"Setting hasLoggedOut for user ${userId}\")\n            eci.serviceFacade.sync().name(\"update\", \"moqui.security.UserAccount\")\n                    .parameters([userId:userId, hasLoggedOut:\"Y\"]).disableAuthz().call()\n        }\n\n        logoutLocal()\n\n        // if there is a request and session invalidate and get new\n        if (request != null) {\n            HttpSession oldSession = request.getSession(false)\n            if (oldSession != null) oldSession.invalidate()\n            session = request.getSession()\n        }\n    }\n\n    @Override boolean loginUserKey(String loginKey) {\n        if (!loginKey) {\n            eci.message.addError(eci.l10n.localize(\"No login key specified\"))\n            return false\n        }\n\n        // lookup login key, by hashed key\n        String hashedKey = eci.ecfi.getSimpleHash(loginKey, \"\", eci.ecfi.getLoginKeyHashType(), false)\n        EntityValue userLoginKey = eci.getEntity().find(\"moqui.security.UserLoginKey\")\n                .condition(\"loginKey\", hashedKey).disableAuthz().one()\n\n        // see if we found a record for the login key\n        if (userLoginKey == null) {\n            eci.message.addError(eci.l10n.localize(\"Login key not valid\"))\n            return false\n        }\n\n        // check expire date\n        Timestamp nowDate = getNowTimestamp()\n        Timestamp thruDate = userLoginKey.getTimestamp(\"thruDate\")\n        if (thruDate != (Timestamp) null && nowDate > thruDate) {\n            eci.message.addError(eci.l10n.localize(\"Login key expired\"))\n            return false\n        }\n\n        // login user with internalLoginUser()\n        EntityValue userAccount = eci.getEntity().find(\"moqui.security.UserAccount\")\n                .condition(\"userId\", userLoginKey.userId).disableAuthz().one()\n        return internalLoginUser(userAccount.getString(\"username\"))\n    }\n    @Override String getLoginKey() {\n        return getLoginKey(eci.ecfi.getLoginKeyExpireHours())\n    }\n    @Override String getLoginKey(float expireHours) {\n        String userId = getUserId()\n        if (!userId) throw new AuthenticationRequiredException(\"No active user, cannot get login key\")\n\n        // generate login key\n        String loginKey = StringUtilities.getRandomString(40)\n\n        // save hashed in UserLoginKey, calc expire and set from/thru dates\n        String hashedKey = eci.ecfi.getSimpleHash(loginKey, \"\", eci.ecfi.getLoginKeyHashType(), false)\n        Timestamp fromDate = getNowTimestamp()\n        long thruTime = fromDate.getTime() + Math.round(expireHours * 60*60*1000)\n        eci.serviceFacade.sync().name(\"create\", \"moqui.security.UserLoginKey\")\n                .parameters([loginKey:hashedKey, userId:userId, fromDate:fromDate, thruDate:new Timestamp(thruTime)])\n                .disableAuthz().requireNewTransaction(true).call()\n\n        // clean out expired keys\n        eci.entity.find(\"moqui.security.UserLoginKey\").condition(\"userId\", userId)\n                .condition(\"thruDate\", EntityCondition.LESS_THAN, fromDate).disableAuthz().deleteAll()\n\n        return loginKey\n    }\n\n    @Override boolean loginAnonymousIfNoUser() {\n        if (currentInfo.username == null && !currentInfo.loggedInAnonymous) {\n            currentInfo.loggedInAnonymous = true\n            return true\n        } else {\n            return false\n        }\n    }\n    void logoutAnonymousOnly() { currentInfo.loggedInAnonymous = false }\n    boolean getLoggedInAnonymous() { return currentInfo.loggedInAnonymous }\n\n    @Override boolean hasPermission(String userPermissionId) {\n        return hasPermissionById(getUserId(), userPermissionId, getNowTimestamp(), eci) }\n\n    static boolean hasPermission(String username, String userPermissionId, Timestamp whenTimestamp, ExecutionContextImpl eci) {\n        EntityValue ua = eci.entityFacade.fastFindOne(\"moqui.security.UserAccount\", true, true, username)\n        if (ua == null) ua = eci.entityFacade.find(\"moqui.security.UserAccount\").condition(\"username\", username).useCache(true).disableAuthz().one()\n        if (ua == null) return false\n        hasPermissionById((String) ua.userId, userPermissionId, whenTimestamp, eci)\n    }\n    static boolean hasPermissionById(String userId, String userPermissionId, Timestamp whenTimestamp, ExecutionContextImpl eci) {\n        if (!userId) return false\n        if ((Object) whenTimestamp == null) whenTimestamp = new Timestamp(System.currentTimeMillis())\n        return (eci.getEntity().find(\"moqui.security.UserPermissionCheck\")\n                .condition([userId:userId, userPermissionId:userPermissionId] as Map<String, Object>).useCache(true).disableAuthz().list()\n                .filterByDate(\"groupFromDate\", \"groupThruDate\", whenTimestamp)\n                .filterByDate(\"permissionFromDate\", \"permissionThruDate\", whenTimestamp)) as boolean\n    }\n\n    @Override boolean isInGroup(String userGroupId) { return isInGroup(getUserId(), userGroupId, getNowTimestamp(), eci) }\n\n    static boolean isInGroup(String username, String userGroupId, Timestamp whenTimestamp, ExecutionContextImpl eci) {\n        EntityValue ua = eci.entityFacade.fastFindOne(\"moqui.security.UserAccount\", true, true, username)\n        if (ua == null) ua = eci.entityFacade.find(\"moqui.security.UserAccount\").condition(\"username\", username).useCache(true).disableAuthz().one()\n        return isInGroupById((String) ua?.userId, userGroupId, whenTimestamp, eci)\n    }\n    static boolean isInGroupById(String userId, String userGroupId, Timestamp whenTimestamp, ExecutionContextImpl eci) {\n        if (userGroupId == \"ALL_USERS\") return true\n        if (!userId) return false\n        if ((Object) whenTimestamp == null) whenTimestamp = new Timestamp(System.currentTimeMillis())\n        return (eci.getEntity().find(\"moqui.security.UserGroupMember\").condition(\"userId\", userId).condition(\"userGroupId\", userGroupId)\n                .useCache(true).disableAuthz().list().filterByDate(\"fromDate\", \"thruDate\", whenTimestamp)) as boolean\n    }\n\n    @Override Set<String> getUserGroupIdSet() {\n        // first get the groups the user is in (cached), always add the \"ALL_USERS\" group to it\n        if (!currentInfo.userId) return allUserGroupIdOnly\n        if (currentInfo.internalUserGroupIdSet == null) currentInfo.internalUserGroupIdSet = getUserGroupIdSet(currentInfo.userId)\n        return currentInfo.internalUserGroupIdSet\n    }\n\n    Set<String> getUserGroupIdSet(String userId) {\n        Set<String> groupIdSet = new HashSet(allUserGroupIdOnly)\n        if (userId) {\n            // expand the userGroupId Set with UserGroupMember\n            EntityList ugmList = eci.getEntity().find(\"moqui.security.UserGroupMember\").condition(\"userId\", userId)\n                    .useCache(true).disableAuthz().list().filterByDate(null, null, null)\n            for (EntityValue userGroupMember in ugmList) groupIdSet.add((String) userGroupMember.userGroupId)\n        }\n        return groupIdSet\n    }\n\n    ArrayList<Map<String, Object>> getArtifactTarpitCheckList(ArtifactExecutionInfo.ArtifactType artifactTypeEnum) {\n        ArrayList<Map<String, Object>> checkList = (ArrayList<Map<String, Object>>) currentInfo.internalArtifactTarpitCheckListMap.get(artifactTypeEnum)\n        if (checkList == null) {\n            // get the list for each group separately to increase cache hits/efficiency\n            checkList = new ArrayList<>()\n            for (String userGroupId in getUserGroupIdSet()) {\n                EntityList atcvList = eci.getEntity().find(\"moqui.security.ArtifactTarpitCheckView\")\n                        .condition(\"userGroupId\", userGroupId).condition(\"artifactTypeEnumId\", artifactTypeEnum.name())\n                        .useCache(true).disableAuthz().list()\n                int atcvListSize = atcvList.size()\n                for (int i = 0; i < atcvListSize; i++) checkList.add(((EntityValueBase) atcvList.get(i)).getValueMap())\n            }\n            currentInfo.internalArtifactTarpitCheckListMap.put(artifactTypeEnum, checkList)\n        }\n        return checkList\n    }\n\n    ArrayList<ArtifactAuthzCheck> getArtifactAuthzCheckList() {\n        // NOTE: even if there is no user, still consider part of the ALL_USERS group and such: if (usernameStack.size() == 0) return EntityListImpl.EMPTY\n        if (currentInfo.internalArtifactAuthzCheckList == null) {\n            // get the list for each group separately to increase cache hits/efficiency\n            ArrayList<ArtifactAuthzCheck> newList = new ArrayList<>()\n            for (String userGroupId in getUserGroupIdSet()) {\n                EntityList aacvList = eci.getEntity().find(\"moqui.security.ArtifactAuthzCheckView\")\n                        .condition(\"userGroupId\", userGroupId).useCache(true).disableAuthz().list()\n                int aacvListSize = aacvList.size()\n                for (int i = 0; i < aacvListSize; i++) newList.add(new ArtifactAuthzCheck((EntityValueBase) aacvList.get(i)))\n            }\n            currentInfo.internalArtifactAuthzCheckList = newList\n        }\n        return currentInfo.internalArtifactAuthzCheckList\n    }\n\n    @Override String getUserId() { return currentInfo.userId }\n    @Override String getUsername() { return currentInfo.username }\n    @Override EntityValue getUserAccount() { return currentInfo.getUserAccount() }\n\n    @Override String getVisitUserId() { return visitId ? getVisit().userId : null }\n    @Override String getVisitId() { return visitId }\n    @Override EntityValue getVisit() {\n        if (visitInternal != null) return visitInternal\n        if (visitId == null || visitId.isEmpty()) return null\n        visitInternal = eci.entityFacade.fastFindOne(\"moqui.server.Visit\", false, true, visitId)\n        return visitInternal\n    }\n    @Override String getVisitorId() {\n        if (visitorIdInternal != null) return visitorIdInternal\n        EntityValue visitLocal = getVisit()\n        visitorIdInternal = visitLocal != null ? visitLocal.getNoCheckSimple(\"visitorId\") : null\n        return visitorIdInternal\n    }\n    @Override String getClientIp() { return clientIpInternal }\n\n    // ========== UserInfo ==========\n\n    UserInfo pushUserSubject(Subject subject) {\n        UserInfo userInfo = pushUser((String) subject.getPrincipal())\n        userInfo.subject = subject\n        return userInfo\n    }\n    UserInfo pushUser(String username) {\n        if (currentInfo != null && currentInfo.username == username) return currentInfo\n\n        if (currentInfo == null || currentInfo.isPopulated()) {\n            // logger.info(\"Pushing UserInfo for ${username} to stack, was ${currentInfo.username}\")\n            UserInfo userInfo = new UserInfo(this, username)\n            userInfoStack.addFirst(userInfo)\n            currentInfo = userInfo\n            return userInfo\n        } else {\n            currentInfo.setInfo(username)\n            return currentInfo\n        }\n    }\n    Subject getCurrentSubject() { return currentInfo.subject != null && currentInfo.subject.isAuthenticated() ? currentInfo.subject : null }\n    void popUser() {\n        if (currentInfo.subject != null && currentInfo.subject.isAuthenticated()) currentInfo.subject.logout()\n        userInfoStack.removeFirst()\n\n        // always leave at least an empty UserInfo on the stack\n        if (userInfoStack.size() == 0) userInfoStack.addFirst(new UserInfo(this, null))\n\n        UserInfo newCurInfo = userInfoStack.getFirst()\n        // logger.info(\"Popping UserInfo ${currentInfo.username}, new current is ${newCurInfo.username}\")\n\n        // whether previous user on stack or new one, set the currentInfo\n        currentInfo = newCurInfo\n    }\n\n    static String getClientIp(HttpServletRequest httpRequest, HandshakeRequest handshakeRequest, ExecutionContextFactoryImpl ecfi) {\n        // use configured client-ip-header to support more than the unreliable X-Forwarded-For header\n        String webappName = null\n        if (httpRequest != null) {\n            webappName = httpRequest.servletContext.getInitParameter(\"moqui-name\")\n        } else if (handshakeRequest != null) {\n            try {\n                Object hsrSession = handshakeRequest.getHttpSession()\n                if (hsrSession instanceof HttpSession)\n                    webappName = hsrSession.getServletContext().getInitParameter(\"moqui-name\")\n            } catch (Throwable t) {\n                // Jetty 12 EE 11 bug https://github.com/jetty/jetty.project/issues/11809\n                logger.trace(\"Failed to get HttpSession from WebSocket HandshakeRequest for client IP lookup\", t)\n            }\n        }\n\n        String clientIpHeaderValue = null\n        if (webappName != null && !webappName.isEmpty()) {\n            ExecutionContextFactoryImpl.WebappInfo webappInfo = ecfi.getWebappInfo(webappName)\n            String clientIpHeader = webappInfo?.clientIpHeader\n            // get the header value from http or handshake request\n            if (clientIpHeader != null && !clientIpHeader.isEmpty()) {\n                if (httpRequest != null) {\n                    clientIpHeaderValue = httpRequest.getHeader(clientIpHeader)\n                } else if (handshakeRequest != null) {\n                    clientIpHeaderValue = handshakeRequest.getHeaders().get(clientIpHeader)?.first()\n                }\n\n                if (httpRequest != null && (clientIpHeaderValue == null || clientIpHeaderValue.isEmpty())) {\n                    logger.warn(\"No value found in HTTP request Client IP header ${clientIpHeader}, servlet container reports ${httpRequest.getRemoteAddr()}\")\n                }\n            }\n        }\n\n        // get first entry in header value or request's remote addr\n        String clientIp = null\n        if (clientIpHeaderValue != null && !clientIpHeaderValue.isEmpty()) {\n            clientIp = clientIpHeaderValue.split(\",\")[0].trim()\n        } else {\n            if (httpRequest != null) {\n                clientIp = httpRequest.getRemoteAddr()\n                // logger.info(\"httpRequest remote addr clientIp ${clientIp}\")\n            } else if (handshakeRequest != null) {\n                // any other way to get websocket client IP? else { clientIpInternal = request.getRemoteAddr() }\n            }\n        }\n\n        if (clientIp != null) {\n            // some headers, like CloudFront-Viewer-Address, contain a port as well so remove that\n            int cipColonIdx = clientIp.lastIndexOf(':')\n            if (cipColonIdx >= 0) {\n                // for IPv6 addresses with square braces, only strip before colon if colon after closing square brace\n                int closeSqBrIdx = clientIp.indexOf(']')\n                if (closeSqBrIdx == -1 || closeSqBrIdx < cipColonIdx)\n                    clientIp = clientIp.substring(cipColonIdx + 1)\n            }\n\n            // strip IPv6 square braces if present\n            if (clientIp != null && !clientIp.isEmpty()) {\n                if (clientIp.charAt(0) == (char) '[') clientIp = clientIp.substring(1)\n                if (clientIp.charAt(clientIp.length() - 1) == (char) ']')\n                    clientIp = clientIp.substring(0, clientIp.length() - 1)\n            }\n        }\n\n        return clientIp\n    }\n\n    static class UserInfo {\n        final UserFacadeImpl ufi\n        // keep a reference to a UserAccount for performance reasons, avoid repeated cached queries\n        protected EntityValueBase userAccount = (EntityValueBase) null\n        protected String username = (String) null\n        protected String userId = (String) null\n        Set<String> internalUserGroupIdSet = (Set<String>) null\n        // these two are used by ArtifactExecutionFacadeImpl but are maintained here to be cleared when user changes, are based on current user's groups\n        final EnumMap<ArtifactExecutionInfo.ArtifactType, ArrayList<Map<String, Object>>> internalArtifactTarpitCheckListMap =\n                new EnumMap<ArtifactExecutionInfo.ArtifactType, ArrayList<Map<String, Object>>>(ArtifactExecutionInfo.ArtifactType.class)\n        ArrayList<ArtifactAuthzCheck> internalArtifactAuthzCheckList = (ArrayList<ArtifactAuthzCheck>) null\n\n        Locale localeCache = (Locale) null\n        TimeZone tzCache = (TimeZone) null\n        String currencyUomId = (String) null\n\n        /** The Shiro Subject (user) */\n        Subject subject = (Subject) null\n        /** This is set instead of adding _NA_ user as logged in to pass authc tests but not generally behave as if a user is logged in */\n        boolean loggedInAnonymous = false\n\n        protected Map<String, Object> userContext = (Map<String, Object>) null\n\n        UserInfo(UserFacadeImpl ufi, String username) {\n            this.ufi = ufi\n            setInfo(username)\n        }\n\n        boolean isPopulated() { return (username != null && username.length() > 0) || loggedInAnonymous }\n\n        void setInfo(String username) {\n            // this shouldn't happen unless there is a bug in the framework\n            if (isPopulated()) throw new IllegalStateException(\"Cannot set user info, UserInfo already populated\")\n\n            this.username = username\n\n            EntityValueBase ua = (EntityValueBase) null\n            if (username != null && username.length() > 0) {\n                EntityCondition usernameCond = ufi.eci.entityFacade.getConditionFactory()\n                        .makeCondition(\"username\", EntityCondition.ComparisonOperator.EQUALS, username).ignoreCase()\n                ua = (EntityValueBase) ufi.eci.getEntity().find(\"moqui.security.UserAccount\")\n                        .condition(usernameCond).useCache(false).disableAuthz().one()\n            }\n            if (ua != null) {\n                userAccount = ua\n                this.username = ua.username\n                userId = ua.userId\n\n                String localeStr = ua.locale\n                if (localeStr != null && localeStr.length() > 0) {\n                    int usIdx = localeStr.indexOf(\"_\")\n                    localeCache = usIdx < 0 ? new Locale(localeStr) :\n                            new Locale(localeStr.substring(0, usIdx), localeStr.substring(usIdx+1).toUpperCase())\n                } else {\n                    localeCache = ufi.request != null ? ufi.request.getLocale() : Locale.getDefault()\n                }\n\n                String tzStr = ua.timeZone\n                tzCache = tzStr ? TimeZone.getTimeZone(tzStr) : TimeZone.getDefault()\n\n                currencyUomId = userAccount.currencyUomId\n            } else {\n                // set defaults if no user\n                localeCache = ufi.request != null ? ufi.request.getLocale() : Locale.getDefault()\n                tzCache = TimeZone.getDefault()\n            }\n\n            internalUserGroupIdSet = (Set<String>) null\n            internalArtifactTarpitCheckListMap.clear()\n            internalArtifactAuthzCheckList = (ArrayList<ArtifactAuthzCheck>) null\n        }\n\n        String getUsername() { return username }\n        String getUserId() { return userId }\n        EntityValueBase getUserAccount() { return userAccount }\n\n        Map<String, Object> getUserContext() {\n            if (userContext == null) userContext = new HashMap<>()\n            return userContext\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/WebFacadeImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context\n\nimport groovy.transform.CompileStatic\n\nimport com.fasterxml.jackson.core.io.JsonStringEncoder\nimport com.fasterxml.jackson.databind.JsonNode\n\nimport org.apache.commons.fileupload2.core.DiskFileItemFactory\nimport org.apache.commons.fileupload2.core.FileItem\nimport org.apache.commons.fileupload2.core.FileItemFactory\nimport org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletFileUpload\nimport org.apache.commons.io.IOUtils\nimport org.apache.commons.io.output.StringBuilderWriter\nimport org.moqui.context.*\nimport org.moqui.context.MessageFacade.MessageInfo\nimport org.moqui.entity.EntityNotFoundException\nimport org.moqui.entity.EntityValue\nimport org.moqui.entity.EntityValueNotFoundException\nimport org.moqui.impl.context.ExecutionContextFactoryImpl.WebappInfo\nimport org.moqui.impl.screen.ScreenDefinition\nimport org.moqui.impl.screen.ScreenUrlInfo\nimport org.moqui.impl.service.RestApi\nimport org.moqui.impl.service.ServiceJsonRpcDispatcher\nimport org.moqui.impl.util.SimpleSigner\nimport org.moqui.resource.ResourceReference\nimport org.moqui.util.ContextStack\nimport org.moqui.util.MNode\nimport org.moqui.util.ObjectUtilities\nimport org.moqui.util.StringUtilities\nimport org.moqui.util.WebUtilities\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport jakarta.servlet.ServletContext\nimport jakarta.servlet.http.HttpServletRequest\nimport jakarta.servlet.http.HttpServletResponse\nimport jakarta.servlet.http.HttpSession\n\nimport java.nio.charset.StandardCharsets\nimport java.sql.Timestamp\n\nimport javax.crypto.Mac\nimport javax.crypto.spec.SecretKeySpec\n\n/** This class is a facade to easily get information from and about the web context. */\n@CompileStatic\nclass WebFacadeImpl implements WebFacade {\n    protected final static Logger logger = LoggerFactory.getLogger(WebFacadeImpl.class)\n\n    static SimpleSigner qzSigner = new SimpleSigner(\"qz-private-key.pem\")\n    // Not using shared root URL cache because causes issues when requests come to server through different hosts/etc:\n    // protected static final Map<String, String> webappRootUrlByParms = new HashMap()\n\n    protected ExecutionContextImpl eci\n    protected String webappMoquiName\n    protected HttpServletRequest request\n    protected HttpServletResponse response\n    protected String requestBodyText = (String) null\n\n    protected Map<String, Object> savedParameters = (Map<String, Object>) null\n    protected Map<String, Object> multiPartParameters = (Map<String, Object>) null\n    protected Map<String, Object> jsonParameters = (Map<String, Object>) null\n    protected Map<String, Object> declaredPathParameters = (Map<String, Object>) null\n\n    protected ContextStack parameters = (ContextStack) null\n    protected Map<String, Object> requestAttributes = (Map<String, Object>) null\n    protected Map<String, Object> requestParameters = (Map<String, Object>) null\n    protected Map<String, Object> sessionAttributes = (Map<String, Object>) null\n    protected Map<String, Object> applicationAttributes = (Map<String, Object>) null\n\n    protected Map<String, Object> errorParameters = (Map<String, Object>) null\n\n    protected List<MessageInfo> savedMessages = (List<MessageInfo>) null\n    protected List<MessageInfo> savedPublicMessages = (List<MessageInfo>) null\n    protected List<String> savedErrors = (List<String>) null\n    protected List<ValidationError> savedValidationErrors = (List<ValidationError>) null\n\n    WebFacadeImpl(String webappMoquiName, HttpServletRequest request, HttpServletResponse response,\n                  ExecutionContextImpl eci) {\n        this.eci = eci\n        this.webappMoquiName = webappMoquiName\n        this.request = request\n        this.response = response\n\n        MNode webappNode = eci.ecfi.getWebappNode(webappMoquiName)\n        boolean uploadExecutableAllow = \"true\".equals(webappNode.attribute(\"upload-executable-allow\"))\n\n        // NOTE: the Visit is not setup here but rather in the MoquiSessionListener (for init and destroy)\n        // don't set 'ec' in request attributes, not serializable: request.setAttribute(\"ec\", eci)\n\n        // get any parameters saved to the session from the last request, and clear that session attribute if there\n        savedParameters = (Map<String, Object>) request.session.getAttribute(\"moqui.saved.parameters\")\n        if (savedParameters != null) request.session.removeAttribute(\"moqui.saved.parameters\")\n\n        errorParameters = (Map<String, Object>) request.session.getAttribute(\"moqui.error.parameters\")\n        if (errorParameters != null) request.session.removeAttribute(\"moqui.error.parameters\")\n\n        // get any messages saved to the session, and clear them from the session\n        if (session.getAttribute(\"moqui.message.messageInfos\") != null) {\n            savedMessages = (List<MessageInfo>) session.getAttribute(\"moqui.message.messageInfos\")\n            session.removeAttribute(\"moqui.message.messageInfos\")\n        }\n        if (session.getAttribute(\"moqui.message.publicMessageInfos\") != null) {\n            savedPublicMessages = (List<MessageInfo>) session.getAttribute(\"moqui.message.publicMessageInfos\")\n            session.removeAttribute(\"moqui.message.publicMessageInfos\")\n        }\n        if (session.getAttribute(\"moqui.message.errors\") != null) {\n            savedErrors = (List<String>) session.getAttribute(\"moqui.message.errors\")\n            session.removeAttribute(\"moqui.message.errors\")\n        }\n        if (session.getAttribute(\"moqui.message.validationErrors\") != null) {\n            savedValidationErrors = (List<ValidationError>) session.getAttribute(\"moqui.message.validationErrors\")\n            session.removeAttribute(\"moqui.message.validationErrors\")\n        }\n\n        // if there is a JSON document submitted consider those as parameters too\n        String contentType = request.getHeader(\"Content-Type\")\n        if (ResourceReference.isTextContentType(contentType)) {\n            // read the body first to make sure it isn't empty, better support clients that pass a Content-Type but no content (even though they shouldn't)\n            BufferedReader reader = request.getReader()\n            StringBuilderWriter bodyBuilder = new StringBuilderWriter()\n            if (reader != null) IOUtils.copyLarge(reader, bodyBuilder)\n\n            if (bodyBuilder.builder.length() > 0) {\n                String bodyString = bodyBuilder.toString()\n                requestBodyText = bodyString\n                multiPartParameters = new HashMap()\n                multiPartParameters.put(\"_requestBodyText\", bodyString)\n\n                if ((contentType.contains(\"application/json\") || contentType.contains(\"text/json\"))) {\n                    try {\n                        JsonNode jsonNode = ContextJavaUtil.jacksonMapper.readTree(bodyString)\n                        if (jsonNode.isObject()) {\n                            jsonParameters = ContextJavaUtil.jacksonMapper.treeToValue(jsonNode, Map.class)\n                        } else if (jsonNode.isArray()) {\n                            jsonParameters = [_requestBodyJsonList:ContextJavaUtil.jacksonMapper.treeToValue(jsonNode, List.class)] as Map<String, Object>\n                        }\n                    } catch (Throwable t) {\n                        logger.error(\"Error parsing HTTP request body JSON: ${t.toString()}\", t)\n                        jsonParameters = [_requestBodyJsonParseError:t.getMessage()] as Map<String, Object>\n                    }\n                    // logger.warn(\"=========== Got JSON HTTP request body: ${jsonParameters}\")\n                }\n            }\n        } else if (JakartaServletFileUpload.isMultipartContent(request)) {\n            // if this is a multi-part request, get the data for it\n            multiPartParameters = new HashMap()\n            FileItemFactory factory = makeDiskFileItemFactory()\n            JakartaServletFileUpload upload = new JakartaServletFileUpload(factory)\n\n            List<FileItem> items = (List<FileItem>) upload.parseRequest(request)\n            List<FileItem> fileUploadList = []\n            multiPartParameters.put(\"_fileUploadList\", fileUploadList)\n\n            for (FileItem item in items) {\n                if (item.isFormField()) {\n                    addValueToMultipartParameterMap(item.getFieldName(), item.getString(StandardCharsets.UTF_8))\n                } else {\n                    if (!uploadExecutableAllow) {\n                        if (WebUtilities.isExecutable(item)) {\n                            logger.warn(\"Found executable upload file ${item.getName()}\")\n                            throw new WebMediaTypeException(\"Executable file ${item.getName()} upload not allowed\")\n                        }\n                    }\n\n                    // put the FileItem itself in the Map to be used by the application code\n                    addValueToMultipartParameterMap(item.getFieldName(), item)\n                    fileUploadList.add(item)\n\n                    /* Stuff to do with the FileItem:\n                      - get info about the uploaded file\n                        String fieldName = item.getFieldName()\n                        String fileName = item.getName()\n                        String contentType = item.getContentType()\n                        boolean isInMemory = item.isInMemory()\n                        long sizeInBytes = item.getSize()\n\n                      - get the bytes in memory\n                        byte[] data = item.get()\n\n                      - write the data to a File\n                        File uploadedFile = new File(...)\n                        item.write(uploadedFile)\n\n                      - get the bytes in a stream\n                        InputStream uploadedStream = item.getInputStream()\n                        ...\n                        uploadedStream.close()\n                     */\n                }\n            }\n        }\n\n        // create the session token if needed (protection against CSRF/XSRF attacks; see ScreenRenderImpl)\n        String sessionToken = session.getAttribute(\"moqui.session.token\")\n        if (sessionToken == null || sessionToken.length() == 0) {\n            sessionToken = StringUtilities.getRandomString(20)\n            session.setAttribute(\"moqui.session.token\", sessionToken)\n            request.setAttribute(\"moqui.session.token.created\", \"true\")\n            response.setHeader(\"moquiSessionToken\", sessionToken)\n            response.setHeader(\"X-CSRF-Token\", sessionToken)\n        }\n    }\n\n    /** Apache Commons FileUpload does not support string array so when using multiple select and there's a duplicate\n     * fieldName convert value to an array list when fieldName is already in multipart parameters. */\n    private void addValueToMultipartParameterMap(String key, Object value) {\n        // change &nbsp; (\\u00a0) to null, used as a placeholder when empty string doesn't work\n        if (\"\\u00a0\".equals(value)) value = null\n        Object previousValue = multiPartParameters.put(key, value)\n        if (previousValue != null) {\n            List<Object> valueList = new ArrayList<>()\n            multiPartParameters.put(key, valueList)\n            if(previousValue instanceof Collection) {\n                valueList.addAll((Collection) previousValue)\n            } else {\n                valueList.add(previousValue)\n            }\n            valueList.add(value)\n        }\n    }\n\n    @Override String getSessionToken() { return getSession().getAttribute(\"moqui.session.token\") }\n\n    void runFirstHitInVisitActions() {\n        WebappInfo wi = eci.ecfi.getWebappInfo(webappMoquiName)\n        if (wi.firstHitInVisitActions) wi.firstHitInVisitActions.run(eci)\n    }\n    void runBeforeRequestActions() {\n        WebappInfo wi = eci.ecfi.getWebappInfo(webappMoquiName)\n        if (wi.beforeRequestActions) wi.beforeRequestActions.run(eci)\n    }\n    void runAfterRequestActions() {\n        WebappInfo wi = eci.ecfi.getWebappInfo(webappMoquiName)\n        if (wi.afterRequestActions) wi.afterRequestActions.run(eci)\n    }\n    void runAfterLoginActions() {\n        WebappInfo wi = eci.ecfi.getWebappInfo(webappMoquiName)\n        if (wi.afterLoginActions) wi.afterLoginActions.run(eci)\n    }\n    void runBeforeLogoutActions() {\n        WebappInfo wi = eci.ecfi.getWebappInfo(webappMoquiName)\n        if (wi.beforeLogoutActions) wi.beforeLogoutActions.run(eci)\n    }\n\n    void saveScreenHistory(ScreenUrlInfo.UrlInstance urlInstanceOrig) {\n        ScreenUrlInfo sui = urlInstanceOrig.sui\n        ScreenDefinition targetScreen = urlInstanceOrig.sui.targetScreen\n\n        // logger.warn(\"save hist ${urlInstanceOrig.path} standalone ${sui.lastStandalone} ${targetScreen.isStandalone()} transition ${urlInstanceOrig.getTargetTransition()}\")\n        // don't save standalone screens (for sui.lastStandalone int only exclude negative so vapps, etc are saved)\n        if (sui.lastStandalone < 0 || targetScreen.isStandalone()) return\n        // don't save transition requests, just screens\n        if (urlInstanceOrig.getTargetTransition() != null) return\n        // if history=false on the screen don't save\n        if (\"false\".equals(targetScreen.screenNode.attribute(\"history\"))) return\n\n        List<Map> screenHistoryList = (List<Map>) session.getAttribute(\"moqui.screen.history\")\n        if (screenHistoryList == null) {\n            screenHistoryList = Collections.<Map>synchronizedList(new LinkedList<Map>())\n            session.setAttribute(\"moqui.screen.history\", screenHistoryList)\n        }\n\n        ScreenUrlInfo.UrlInstance urlInstance = urlInstanceOrig.cloneUrlInstance()\n        // instead of ignoring page index for history (old approach), retain but exclude in history duplicate search\n        urlInstance.getParameterMap().remove(\"pageIndex\")\n        // logger.warn(\"======= parameters: ${urlInstance.getParameterMap()}\")\n        String urlWithAllParams = urlInstanceOrig.getUrlWithParams()\n        String urlWithParamsNoPageIndex = urlInstance.getUrlWithParams()\n        String urlNoParams = urlInstance.getUrl()\n        // logger.warn(\"======= urlWithParams: ${urlWithParams}\")\n\n        // if is the same as last screen skip it\n        Map firstItem = screenHistoryList.size() > 0 ? screenHistoryList.get(0) : null\n        if (firstItem != null && firstItem.url == urlWithParamsNoPageIndex) return\n\n        String targetMenuName = targetScreen.getDefaultMenuName()\n\n        StringBuilder nameBuilder = new StringBuilder()\n        // append parent screen name\n        ScreenDefinition parentScreen = sui.getParentScreen()\n        if (parentScreen != null) {\n            if (parentScreen.getLocation() != sui.rootSd.getLocation())\n                nameBuilder.append(parentScreen.getDefaultMenuName()).append(' - ')\n        }\n        // append target screen name\n        if (targetMenuName.contains('${')) {\n            nameBuilder.append(eci.getResource().expand(targetMenuName, targetScreen.getLocation()))\n        } else {\n            nameBuilder.append(targetMenuName)\n            // append parameter values\n            Map parameters = urlInstance.getParameterMap()\n            StringBuilder paramBuilder = new StringBuilder()\n            if (parameters) {\n                int pCount = 0\n                Iterator<Map.Entry<String, String>> entryIter = parameters.entrySet().iterator()\n                while (entryIter.hasNext() && pCount < 2) {\n                    Map.Entry<String, String> entry = entryIter.next()\n                    if (entry.key.contains(\"_op\")) continue\n                    if (entry.key.contains(\"_not\")) continue\n                    if (entry.key.contains(\"_ic\")) continue\n                    if (\"moquiSessionToken\".equals(entry.key)) continue\n                    if (entry.value.trim().length() == 0) continue\n\n                    // injection issue with name field: userId=%3Cscript%3Ealert(%27Test%20Crack!%27)%3C/script%3E\n                    String parmValue = entry.value\n                    if (parmValue) parmValue = URLEncoder.encode(parmValue, \"UTF-8\")\n                    paramBuilder.append(parmValue)\n\n                    pCount++\n                    if (entryIter.hasNext() && pCount < 2) paramBuilder.append(', ')\n                }\n            }\n            if (paramBuilder.length() > 0) nameBuilder.append(' (').append(paramBuilder.toString()).append(')')\n        }\n\n        synchronized (screenHistoryList) {\n            // remove existing item(s) from list with same URL\n            Iterator<Map> screenHistoryIter = screenHistoryList.iterator()\n            while (screenHistoryIter.hasNext()) {\n                Map screenHistory = screenHistoryIter.next()\n                if (screenHistory.urlNoPageIndex == urlWithParamsNoPageIndex) screenHistoryIter.remove()\n            }\n            // add to history list\n            screenHistoryList.add(0, [name:nameBuilder.toString(), url:urlWithAllParams, urlNoParams:urlNoParams,\n                    urlNoPageIndex:urlWithParamsNoPageIndex, path:urlInstance.path, pathWithParams:urlInstance.pathWithParams,\n                    image:sui.menuImage, imageType:sui.menuImageType, screenLocation:targetScreen.getLocation()])\n            // trim the list if needed; keep 40, whatever uses it may display less\n            while (screenHistoryList.size() > 40) screenHistoryList.remove(40)\n        }\n    }\n\n    @Override\n    List<Map> getScreenHistory() {\n        List<Map> histList = (List<Map>) session.getAttribute(\"moqui.screen.history\")\n        if (histList == null) histList = Collections.<Map>synchronizedList(new LinkedList<Map>())\n        return histList\n    }\n\n    @Override\n    String getRequestUrl() {\n        StringBuilder requestUrl = new StringBuilder()\n        requestUrl.append(request.getScheme())\n        requestUrl.append(\"://\" + request.getServerName())\n        if (request.getServerPort() != 80 && request.getServerPort() != 443) requestUrl.append(\":\" + request.getServerPort())\n        requestUrl.append(request.getRequestURI())\n        if (request.getQueryString()) requestUrl.append(\"?\" + request.getQueryString())\n        return requestUrl.toString()\n    }\n\n    void addDeclaredPathParameter(String name, String value) {\n        if (declaredPathParameters == null) declaredPathParameters = new HashMap()\n        declaredPathParameters.put(name, value)\n    }\n\n    @Override\n    Map<String, Object> getParameters() {\n        // NOTE: no blocking in these methods because the WebFacadeImpl is created for each thread\n\n        // only create when requested, then keep for additional requests\n        if (parameters != null) return parameters\n\n        // Uses the approach of creating a series of this objects wrapping the other non-Map attributes/etc instead of\n        // copying everything from the various places into a single combined Map; this should be much faster to create\n        // and only slightly slower when running.\n        ContextStack cs = new ContextStack(false)\n        cs.push(getRequestParameters())\n        cs.push(getApplicationAttributes())\n        cs.push(getSessionAttributes())\n        cs.push(getRequestAttributes())\n        // add an extra Map for anything added so won't go in  request attributes (can put there explicitly if desired)\n        cs.push()\n        parameters = cs\n        return parameters\n    }\n\n    @Override HttpServletRequest getRequest() { return request }\n\n    @Override Map<String, Object> getRequestAttributes() {\n        if (requestAttributes != null) return requestAttributes\n        requestAttributes = new WebUtilities.AttributeContainerMap(new WebUtilities.ServletRequestContainer(request))\n        return requestAttributes\n    }\n    @Override Map<String, Object> getRequestParameters() {\n        if (requestParameters != null) return requestParameters\n\n        ContextStack cs = new ContextStack(false)\n        if (savedParameters != null) cs.push(savedParameters)\n        if (multiPartParameters != null) cs.push(multiPartParameters)\n        if (jsonParameters != null) cs.push(jsonParameters)\n        if (declaredPathParameters != null) cs.push(new WebUtilities.CanonicalizeMap(declaredPathParameters))\n\n        // no longer uses CanonicalizeMap, search Map for String[] of size 1 and change to String\n        Map<String, Object> reqParmMap = WebUtilities.simplifyRequestParameters(request, false)\n        if (reqParmMap.size() > 0) cs.push(reqParmMap)\n\n        // NOTE: We decode path parameter ourselves, so use getRequestURI instead of getPathInfo\n        Map<String, Object> pathInfoParameterMap = WebUtilities.getPathInfoParameterMap(request.getRequestURI())\n        if (pathInfoParameterMap != null && pathInfoParameterMap.size() > 0) cs.push(pathInfoParameterMap)\n        // NOTE: the CanonicalizeMap cleans up character encodings, and unwraps lists of values with a single entry\n\n        // do one last push so writes don't modify whatever was at the top of the stack\n        cs.push()\n        requestParameters = cs\n        return requestParameters\n    }\n    @Override Map<String, Object> getSecureRequestParameters() {\n        ContextStack cs = new ContextStack(false)\n        if (savedParameters) cs.push(savedParameters)\n        if (multiPartParameters) cs.push(multiPartParameters)\n        if (jsonParameters) cs.push(jsonParameters)\n\n        Map<String, Object> reqParmMap = WebUtilities.simplifyRequestParameters(request, true)\n        if (reqParmMap.size() > 0) cs.push(reqParmMap)\n\n        return cs\n    }\n\n    @Override String getHostName(boolean withPort) {\n        URL requestUrl = new URL(getRequest().getRequestURL().toString())\n        String hostName = null\n        Integer port = null\n        try {\n            hostName = requestUrl.getHost()\n            port = requestUrl.getPort()\n            // logger.info(\"Got hostName [${hostName}] from getRequestURL [${webFacade.getRequest().getRequestURL()}]\")\n        } catch (Exception e) {\n            /* ignore it, default to getServerName() result */\n            logger.trace(\"Error getting hostName from getRequestURL: \", e)\n        }\n        if (!hostName) hostName = getRequest().getServerName()\n        if (!port || port == -1) port = getRequest().getServerPort()\n        if (!port || port == -1) port = getRequest().isSecure() ? 443 : 80\n\n        return withPort ? hostName + \":\" + port : hostName\n    }\n\n    @Override String getPathInfo() { return getPathInfo(request) }\n    static String getPathInfo(HttpServletRequest request) {\n        ArrayList<String> pathList = getPathInfoList(request)\n        // as per spec if no extra path info return null\n        if (pathList == null) return null\n        int pathListSize = pathList.size()\n        if (pathListSize == 0) return null\n        StringBuilder pathSb = new StringBuilder(255)\n        for (int i = 0; i < pathListSize; i++) {\n            String pathSegment = (String) pathList.get(i)\n            pathSb.append(\"/\").append(pathSegment)\n        }\n        return pathSb.toString()\n    }\n    @Override ArrayList<String> getPathInfoList() { return getPathInfoList(request) }\n    static ArrayList<String> getPathInfoList(HttpServletRequest request) {\n        // generated URL path segments are encoded with URLEncoder, to match use URLDecoder instead of servlet container's decoding\n        // this uses the application/x-www-form-urlencoded MIME format for screen path segments\n        // was: String pathInfo = request.getPathInfo()\n        String reqURI = request.getRequestURI()\n        // exclude servlet path segments\n        String servletPath = request.getServletPath()\n        // subtract 1 to exclude empty string before leading '/' that will always be there\n        int servletPathSize = servletPath.isEmpty() ? 0 : (servletPath.split(\"/\").length - 1)\n\n        // exclude context path segments\n        String contextPath = request.getContextPath()\n        int contextPathSize = contextPath.isEmpty() ? 0 : (contextPath.split(\"/\").length - 1)\n\n        ArrayList<String> pathList = StringUtilities.pathStringToList(reqURI, servletPathSize + contextPathSize)\n        // logger.warn(\"pathInfo ${request.getPathInfo()} servletPath ${servletPath} reqURI ${request.getRequestURI()} pathList ${pathList}\")\n        return pathList\n    }\n\n    @Override String getRequestBodyText() { return requestBodyText }\n    @Override String getResourceDistinctValue() {\n        return eci.ecfi.initStartHex\n    }\n\n    @Override HttpServletResponse getResponse() { return response }\n\n    @Override HttpSession getSession() { return request.getSession() }\n    /** Invalidate the current session (if there is one) and create a new session for the request, copies attributes.\n     * NOTE that this must be called before any response is sent and more generally before screen rendering begins. */\n    HttpSession makeNewSession() {\n        HttpSession oldSession = request.getSession(false)\n        Map<String, Object> oldSessionAttributes = (Map<String, Object>) null\n        if (oldSession != null) {\n            // get old session attributes and put in new HashMap so that are copied right away, because this wraps the session won't work after invalidate\n            oldSessionAttributes = new HashMap<>(new WebUtilities.AttributeContainerMap(new WebUtilities.HttpSessionContainer(oldSession)))\n            oldSession.invalidate()\n        }\n        HttpSession newSession = request.getSession(true)\n        if (oldSessionAttributes != null) for (Map.Entry<String, Object> attrEntry in oldSessionAttributes.entrySet()) {\n            newSession.setAttribute(attrEntry.getKey(), attrEntry.getValue())\n            // logger.warn(\"Copying attr ${attrEntry.getKey()}:${attrEntry.getValue()}\")\n        }\n        // force a new moqui.session.token\n        String sessionToken = StringUtilities.getRandomString(20)\n        newSession.setAttribute(\"moqui.session.token\", sessionToken)\n        request.setAttribute(\"moqui.session.token.created\", \"true\")\n        if (response != null) {\n            response.setHeader(\"moquiSessionToken\", sessionToken)\n            response.setHeader(\"X-CSRF-Token\", sessionToken)\n        }\n        // remake sessionAttributes to use newSession\n        sessionAttributes = new WebUtilities.AttributeContainerMap(new WebUtilities.HttpSessionContainer(newSession))\n\n        // UserFacadeImpl keeps a session reference, update it\n        if (eci.userFacade != null) eci.userFacade.session = newSession\n\n        // done\n        return newSession\n    }\n\n    @Override Map<String, Object> getSessionAttributes() {\n        if (sessionAttributes != null) return sessionAttributes\n        sessionAttributes = new WebUtilities.AttributeContainerMap(new WebUtilities.HttpSessionContainer(getSession()))\n        return sessionAttributes\n    }\n\n    @Override ServletContext getServletContext() { return getSession().getServletContext() }\n    @Override Map<String, Object> getApplicationAttributes() {\n        if (applicationAttributes != null) return applicationAttributes\n        applicationAttributes = new WebUtilities.AttributeContainerMap(new WebUtilities.ServletContextContainer(getServletContext()))\n        return applicationAttributes\n    }\n\n    String getWebappMoquiName() { return webappMoquiName }\n    @Override String getWebappRootUrl(boolean requireFullUrl, Boolean useEncryption) {\n        return getWebappRootUrl(this.webappMoquiName, null, requireFullUrl, useEncryption, eci)\n    }\n\n    static String getWebappRootUrl(String webappName, String servletContextPath, boolean requireFullUrl, Boolean useEncryption, ExecutionContextImpl eci) {\n        WebFacade webFacade = eci.getWeb()\n        HttpServletRequest request = webFacade?.getRequest()\n        boolean requireEncryption = useEncryption == null && request != null ? request.isSecure() : (useEncryption != null ? useEncryption.booleanValue() : false)\n        boolean needFullUrl = requireFullUrl || request == null ||\n                (requireEncryption && !request.isSecure()) || (!requireEncryption && request.isSecure())\n\n        /* Not using shared root URL cache because causes issues when requests come to server through different hosts/etc:\n        String cacheKey = webappName + servletContextPath + needFullUrl.toString() + requireEncryption.toString()\n        String cachedRootUrl = webappRootUrlByParms.get(cacheKey)\n        if (cachedRootUrl != null) return cachedRootUrl\n\n        String urlValue = makeWebappRootUrl(webappName, servletContextPath, eci, webFacade, requireEncryption, needFullUrl)\n        webappRootUrlByParms.put(cacheKey, urlValue)\n        return urlValue\n         */\n\n        // cache the root URLs just within the request, common to generate various URLs in a single request\n        String cacheKey = (String) null\n        if (request != null) {\n            StringBuilder keyBuilder = new StringBuilder(200)\n            keyBuilder.append(webappName).append(servletContextPath)\n            if (needFullUrl) keyBuilder.append(\"T\") else keyBuilder.append(\"F\")\n            if (requireEncryption) keyBuilder.append(\"T\") else keyBuilder.append(\"F\")\n            cacheKey = keyBuilder.toString()\n\n            String cachedRootUrl = request.getAttribute(cacheKey)\n            if (cachedRootUrl != null) return cachedRootUrl\n        }\n\n        String urlValue = makeWebappRootUrl(webappName, servletContextPath, eci, webFacade, requireEncryption, needFullUrl)\n        if (cacheKey != null) request.setAttribute(cacheKey, urlValue)\n        return urlValue\n    }\n    static String makeWebappHost(String webappName, ExecutionContextImpl eci, WebFacade webFacade, boolean requireEncryption) {\n        WebappInfo webappInfo = eci.ecfi.getWebappInfo(webappName)\n        // can't get these settings, hopefully a URL from the root will do\n        if (webappInfo == null) return \"\"\n\n        StringBuilder urlBuilder = new StringBuilder()\n        HttpServletRequest request = webFacade?.getRequest()\n        if (\"https\".equals(request?.getScheme()) || (requireEncryption && webappInfo.httpsEnabled)) {\n            urlBuilder.append(\"https://\")\n            if (webappInfo.httpsHost != null) {\n                urlBuilder.append(webappInfo.httpsHost)\n            } else {\n                if (webFacade != null) {\n                    urlBuilder.append(webFacade.getHostName(false))\n                } else {\n                    // uh-oh, no web context, default to localhost\n                    urlBuilder.append(\"localhost\")\n                }\n            }\n            String httpsPort = webappInfo.httpsPort\n            // try the local port; this won't work when switching from http to https, conf required for that\n            if (httpsPort == null && request != null && request.isSecure()) httpsPort = request.getServerPort() as String\n            if (httpsPort != null && !httpsPort.isEmpty() && !\"443\".equals(httpsPort)) urlBuilder.append(\":\").append(httpsPort)\n        } else {\n            urlBuilder.append(\"http://\")\n            if (webappInfo.httpHost != null) {\n                urlBuilder.append(webappInfo.httpHost)\n            } else {\n                if (webFacade != null) {\n                    urlBuilder.append(webFacade.getHostName(false))\n                } else {\n                    // uh-oh, no web context, default to localhost\n                    urlBuilder.append(\"localhost\")\n                    logger.trace(\"No webapp http-host and no webFacade in place, defaulting to localhost for hostName\")\n                }\n            }\n            String httpPort = webappInfo.httpPort\n            // try the server port; this won't work when switching from https to http, conf required for that\n            if (!httpPort && request != null && !request.isSecure()) httpPort = request.getServerPort() as String\n            if (httpPort != null && !httpPort.isEmpty() && !\"80\".equals(httpPort)) urlBuilder.append(\":\").append(httpPort)\n        }\n        return urlBuilder.toString()\n    }\n    static String makeWebappRootUrl(String webappName, String servletContextPath, ExecutionContextImpl eci, WebFacade webFacade,\n                                    boolean requireEncryption, boolean needFullUrl) {\n        StringBuilder urlBuilder = new StringBuilder()\n        // build base from conf\n        if (needFullUrl) urlBuilder.append(makeWebappHost(webappName, eci, webFacade, requireEncryption))\n        urlBuilder.append(\"/\")\n\n        // add servletContext.contextPath\n        if (!servletContextPath && webFacade)\n            servletContextPath = webFacade.getServletContext().getContextPath()\n        if (servletContextPath) {\n            if (servletContextPath.startsWith(\"/\")) servletContextPath = servletContextPath.substring(1)\n            urlBuilder.append(servletContextPath)\n        }\n\n        // make sure we don't have a trailing slash\n        if (urlBuilder.charAt(urlBuilder.length()-1) == (char) '/') urlBuilder.deleteCharAt(urlBuilder.length()-1)\n\n        String urlValue = urlBuilder.toString()\n        return urlValue\n    }\n\n    String getRequestDetails() {\n        StringBuilder sb = new StringBuilder()\n        sb.append(\"Request: \").append(request.getMethod()).append(\" \").append(request.getRequestURL()).append(\"\\n\")\n        sb.append(\"Scheme: \").append(request.getScheme()).append(\", Secure? \").append(request.isSecure()).append(\"\\n\")\n        sb.append(\"Remote: \").append(request.getRemoteAddr()).append(\" - \").append(request.getRemoteHost()).append(\"\\n\")\n        for (String hn in request.getHeaderNames()) {\n            sb.append(\"Header: \").append(hn).append(\" = \")\n            for (String hv in request.getHeaders(hn)) sb.append(\"[\").append(hv).append(\"] \")\n            sb.append(\"\\n\")\n        }\n        for (String pn in request.getParameterNames()) sb.append(\"Parameter: \").append(pn).append(\" = \").append(request.getParameterValues(pn)).append(\"\\n\")\n        return sb.toString()\n    }\n\n    @Override Map<String, Object> getErrorParameters() { return errorParameters }\n    @Override List<MessageInfo> getSavedMessages() { return savedMessages }\n    @Override List<MessageInfo> getSavedPublicMessages() { return savedPublicMessages }\n    @Override List<String> getSavedErrors() { return savedErrors }\n    @Override List<ValidationError> getSavedValidationErrors() { return savedValidationErrors }\n    @Override List<ValidationError> getFieldValidationErrors(String fieldName) {\n        List<ValidationError> errorList = null\n        if (savedValidationErrors != null && savedValidationErrors.size() > 0) {\n            for (ValidationError ve in savedValidationErrors) if (fieldName == null || fieldName.equals(ve.field)) {\n                if (errorList == null) errorList = new ArrayList<ValidationError>(5)\n                errorList.add(ve)\n            }\n        }\n        List<ValidationError> mfErrorList = eci.messageFacade.getValidationErrors()\n        if (mfErrorList != null && mfErrorList.size() > 0) {\n            for (ValidationError ve in mfErrorList) if (fieldName == null || fieldName.equals(ve.field)) {\n                if (errorList == null) errorList = new ArrayList<ValidationError>(5)\n                errorList.add(ve)\n            }\n        }\n        return errorList\n    }\n\n    @Override\n    void sendJsonResponse(Object responseObj) { sendJsonResponseInternal(responseObj, eci, request, response, requestAttributes) }\n    static void sendJsonResponseInternal(Object responseObj, ExecutionContextImpl eci, HttpServletRequest request,\n                                         HttpServletResponse response, Map<String, Object> requestAttributes) {\n        String jsonStr = null\n        if (responseObj instanceof CharSequence) {\n            jsonStr = responseObj.toString()\n            responseObj = null\n        } else {\n            Map responseMap = responseObj instanceof Map ? (Map) responseObj : null\n\n            if (eci.message.messages) {\n                if (responseObj == null) {\n                    responseObj = [messages:eci.message.getMessagesString()] as Map<String, Object>\n                } else if (responseMap != null && !responseMap.containsKey(\"messages\")) {\n                    responseMap = new HashMap(responseMap)\n                    responseMap.put(\"messages\", eci.message.getMessagesString())\n                    responseObj = responseMap\n                }\n            }\n\n            if (eci.getMessage().hasError()) {\n                // if the responseObj is a Map add all of it's data\n                // only add an errors if it is not a jsonrpc response (JSON RPC has it's own error handling)\n                if (responseMap != null && !responseMap.containsKey(\"errors\") && !responseMap.containsKey(\"jsonrpc\")) {\n                    responseMap = new HashMap(responseMap)\n                    responseMap.put(\"errors\", eci.message.errorsString)\n                    responseObj = responseMap\n                } else if (responseObj != null && !(responseObj instanceof Map)) {\n                    logger.error(\"Error found when sending JSON string, JSON object is not a Map so not adding errors to return: ${eci.message.errorsString}\")\n                }\n                response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)\n            } else {\n                response.setStatus(HttpServletResponse.SC_OK)\n            }\n        }\n\n        // logger.warn(\"========== Sending JSON for object: ${responseObj}\")\n        if (responseObj != null) jsonStr = ContextJavaUtil.jacksonMapper.writeValueAsString(responseObj)\n\n        if (!jsonStr) return\n\n        // logger.warn(\"========== Sending JSON string: ${jsonStr}\")\n        response.setContentType(\"application/json\")\n        // NOTE: String.length not correct for byte length\n        String charset = response.getCharacterEncoding() ?: \"UTF-8\"\n        int length = jsonStr.getBytes(charset).length\n        response.setContentLength(length)\n\n        try {\n            response.writer.write(jsonStr)\n            response.writer.flush()\n            if (logger.isTraceEnabled()) {\n                Long startTime = (Long) requestAttributes.get(\"moquiRequestStartTime\")\n                String timeMsg = \"\"\n                if (startTime) timeMsg = \"in ${(System.currentTimeMillis()-startTime)}ms\"\n                logger.trace(\"Sent JSON response ${length} bytes ${charset} encoding ${timeMsg} for ${request.getMethod()} to ${request.getPathInfo()}\")\n            }\n        } catch (IOException e) {\n            logger.error(\"Error sending JSON string response\", e)\n        }\n    }\n\n    @Override\n    void sendJsonError(int statusCode, String message, Throwable origThrowable) {\n        sendJsonErrorInternal(statusCode, message, origThrowable, response)\n    }\n    static void sendJsonErrorInternal(int statusCode, String message, Throwable origThrowable, HttpServletResponse response) {\n        if ((message == null || message.isEmpty()) && origThrowable != null) message = origThrowable.message\n        // NOTE: uses same field name as sendJsonResponseInternal\n        String jsonStr = ContextJavaUtil.jacksonMapper.writeValueAsString([errorCode:statusCode, errors:message])\n        response.setContentType(\"application/json\")\n        // NOTE: String.length not correct for byte length\n        String charset = response.getCharacterEncoding() ?: \"UTF-8\"\n        int length = jsonStr.getBytes(charset).length\n        response.setContentLength(length)\n        response.setStatus(statusCode)\n        response.writer.write(jsonStr)\n        response.writer.flush()\n    }\n\n\n    @Override\n    void sendTextResponse(String text) {\n        sendTextResponseInternal(text, \"text/plain\", null, eci, request, response, requestAttributes)\n    }\n    @Override\n    void sendTextResponse(String text, String contentType, String filename) {\n        sendTextResponseInternal(text, contentType, filename, eci, request, response, requestAttributes)\n    }\n    static void sendTextResponseInternal(String text, String contentType, String filename, ExecutionContextImpl eci,\n                                         HttpServletRequest request, HttpServletResponse response,\n                                         Map<String, Object> requestAttributes) {\n        if (!contentType) contentType = \"text/plain\"\n        String responseText\n        if (eci.getMessage().hasError()) {\n            responseText = eci.message.errorsString\n            response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR)\n        } else {\n            responseText = text\n            response.setStatus(HttpServletResponse.SC_OK)\n        }\n\n        response.setContentType(contentType)\n        // NOTE: String.length not correct for byte length\n        String charset = response.getCharacterEncoding() ?: \"UTF-8\"\n        int length = responseText != null ? responseText.getBytes(charset).length : 0I\n        response.setContentLength(length)\n\n        if (!filename) {\n            response.setHeader(\"Content-Disposition\", \"inline\")\n        } else {\n            response.setHeader(\"Content-Disposition\", \"attachment; filename=\\\"${filename}\\\"; filename*=utf-8''${StringUtilities.encodeAsciiFilename(filename)}\")\n        }\n\n        try {\n            if (responseText) response.writer.write(responseText)\n            response.writer.flush()\n            if (logger.infoEnabled) {\n                Long startTime = (Long) requestAttributes.get(\"moquiRequestStartTime\")\n                String timeMsg = \"\"\n                if (startTime) timeMsg = \"in [${(System.currentTimeMillis()-startTime)/1000}] seconds\"\n                logger.info(\"Sent text (${contentType}) response of length [${length}] with [${charset}] encoding ${timeMsg} for ${request.getMethod()} request to ${request.getPathInfo()}\")\n            }\n        } catch (IOException e) {\n            logger.error(\"Error sending text response\", e)\n        }\n    }\n\n    @Override void sendResourceResponse(String location) { sendResourceResponseInternal(location, false, eci, response) }\n    @Override void sendResourceResponse(String location, boolean inline) { sendResourceResponseInternal(location, inline, eci, response) }\n    static void sendResourceResponseInternal(String location, boolean inline, ExecutionContextImpl eci, HttpServletResponse response) {\n        ResourceReference rr = eci.resource.getLocationReference(location)\n        if (rr == null || (rr.supportsExists() && !rr.getExists())) {\n            logger.warn(\"Sending not found response, resource not found at: ${location}\")\n            response.sendError(HttpServletResponse.SC_NOT_FOUND, \"Resource not found at ${location}\")\n            return\n        }\n        String contentType = rr.getContentType()\n        if (contentType) response.setContentType(contentType)\n        if (inline) {\n            response.setHeader(\"Content-Disposition\", \"inline\")\n\n            WebappInfo webappInfo = eci.ecfi.getWebappInfo(eci.webImpl?.webappMoquiName)\n            if (webappInfo != null) {\n                webappInfo.addHeaders(\"web-resource-inline\", response)\n            } else {\n                response.setHeader(\"Cache-Control\", \"max-age=86400, must-revalidate, public\")\n            }\n        } else {\n            response.setHeader(\"Content-Disposition\", \"attachment; filename=\\\"${rr.getFileName()}\\\"; filename*=utf-8''${StringUtilities.encodeAsciiFilename(rr.getFileName())}\")\n        }\n        if (contentType == null || contentType.isEmpty() || ResourceReference.isBinaryContentType(contentType)) {\n            InputStream is = rr.openStream()\n            if (is == null) {\n                logger.warn(\"Sending not found response, openStream returned null for location: ${location}\")\n                response.sendError(HttpServletResponse.SC_NOT_FOUND, \"Resource not found at ${location}\")\n                return\n            }\n\n            try {\n                OutputStream os = response.outputStream\n                try {\n                    int totalLen = ObjectUtilities.copyStream(is, os)\n                    logger.info(\"Streamed ${totalLen} bytes from location ${location}\")\n                } finally {\n                    os.close()\n                }\n            } finally {\n                is.close()\n            }\n        } else {\n            String rrText = rr.getText()\n            if (rrText) response.writer.append(rrText)\n            response.writer.flush()\n        }\n    }\n\n    void sendQzSignedResponse(String message) {\n        try {\n            String signature = qzSigner.sign(message)\n            response.setContentType(\"text/plain\")\n            response.getWriter().write(signature)\n        } catch (Exception e) {\n            logger.error(\"Error signing QZ message, sending error response: \" + e.toString())\n            response.sendError(500, e.message)\n        }\n    }\n\n    static Map<Integer, String> errorCodeNames = [401:\"Authentication Required\", 403:\"Access Forbidden\", 404:\"Not Found\",\n            429:\"Too Many Requests\", 500:\"Internal Server Error\"]\n    @Override\n    void sendError(int errorCode, String message, Throwable origThrowable) {\n        sendError(errorCode, message, origThrowable, request, response)\n    }\n\n    static void sendError(int errorCode, String message, Throwable origThrowable, HttpServletRequest request, HttpServletResponse response) {\n        if ((message == null || message.isEmpty()) && origThrowable != null) message = origThrowable.message\n        String errorCodeName = errorCodeNames.get(errorCode) ?: \"\"\n        if (message == null || message.isEmpty()) message = errorCodeName\n\n        String acceptHeader = request.getHeader(\"Accept\")\n        if (acceptHeader == null) acceptHeader = \"\"\n\n        if (acceptHeader.contains(\"text/html\")) {\n            // logger.warn(\"sendError html ${errorCode} ${message}\")\n            response.setStatus(errorCode)\n            response.setContentType(\"text/html\")\n            response.setCharacterEncoding(\"UTF-8\")\n\n            Writer writer = response.getWriter()\n            writer.write('<html><head><meta http-equiv=\"Content-Type\" content=\"text/html;charset=utf-8\"/>')\n            writer.write(\"<title>Error ${errorCode} ${errorCodeName}</title>\")\n            writer.write(\"</head><body>\\n\")\n            writer.write(\"<h2>Error ${errorCode} ${errorCodeName}</h2>\\n\")\n            writer.write(\"<p>Problem accessing ${WebUtilities.encodeHtml(getPathInfo(request))}</p>\\n\")\n            if (message != null && !message.isEmpty()) writer.write(\"<p>Reason: ${WebUtilities.encodeHtml(message)}</p>\\n\")\n            writer.write(\"</body></html>\\n\")\n            writer.flush()\n\n            // NOTE: maybe include throwable info, do we ever want that?\n        } else if (acceptHeader.contains(\"application/json\") || acceptHeader.contains(\"text/json\")) {\n            // logger.warn(\"sendError json ${errorCode} ${message}\")\n            response.setStatus(errorCode)\n            response.setContentType(\"application/json\")\n            response.setCharacterEncoding(\"UTF-8\")\n\n            JsonStringEncoder jsonEncoder = JsonStringEncoder.getInstance()\n\n            Writer writer = response.getWriter()\n            writer.write(\"{'message':'\")\n            writer.write(jsonEncoder.quoteAsString(message))\n            writer.write(\"','errorName':'\")\n            writer.write(errorCodeName)\n            writer.write(\"','error':\")\n            writer.write(Integer.toString(errorCode))\n            writer.write(\",'path':'\")\n            writer.write(jsonEncoder.quoteAsString(getPathInfo(request)))\n            writer.write(\"'}\")\n            writer.flush()\n        } else {\n            // logger.warn(\"sendError default ${errorCode} ${message}\")\n            response.setStatus(errorCode)\n            response.setContentType(\"text/plain\")\n            response.setCharacterEncoding(\"UTF-8\")\n\n            Writer writer = response.getWriter()\n            writer.write(Integer.toString(errorCode))\n            writer.write(\" \")\n            writer.write(message)\n            writer.write(\" \")\n            writer.write(getPathInfo(request))\n            writer.flush()\n        }\n    }\n\n    @Override void handleJsonRpcServiceCall() { new ServiceJsonRpcDispatcher(eci).dispatch() }\n\n    @Override\n    void handleEntityRestCall(List<String> extraPathNameList, boolean masterNameInPath) {\n        ContextStack parmStack = (ContextStack) getParameters()\n\n        // check for parsing error, send a 400 response\n        if (parmStack._requestBodyJsonParseError) {\n            sendJsonError(HttpServletResponse.SC_BAD_REQUEST, (String) parmStack._requestBodyJsonParseError, null)\n            return\n        }\n\n        // make sure a user is logged in, screen/etc that calls will generally be configured to not require auth\n        if (!eci.getUser().getUsername()) {\n            // if there was a login error there will be a MessageFacade error message\n            String errorMessage = eci.message.errorsString\n            if (!errorMessage) errorMessage = \"Authentication required for entity REST operations\"\n            sendJsonError(HttpServletResponse.SC_UNAUTHORIZED, errorMessage, null)\n            return\n        }\n\n        String method = request.getMethod()\n        if (\"post\".equalsIgnoreCase(method)) {\n            String ovdMethod = request.getHeader(\"X-HTTP-Method-Override\")\n            if (ovdMethod != null && !ovdMethod.isEmpty()) method = ovdMethod.toLowerCase()\n        }\n\n        try {\n            // logger.warn(\"====== parameters: ${parmStack.toString()}\")\n            long startTime = System.currentTimeMillis()\n            // if _requestBodyJsonList do multiple calls\n            if (parmStack._requestBodyJsonList) {\n                // TODO: Consider putting all of this in a transaction for non-find operations (currently each is run in\n                // TODO:     a separate transaction); or handle errors per-row instead of blowing up the whole request\n                List responseList = []\n                for (Object bodyListObj in parmStack._requestBodyJsonList) {\n                    if (!(bodyListObj instanceof Map)) {\n                        String errMsg = \"If request body JSON is a list/array it must contain only object/map values, found non-map entry of type ${bodyListObj.getClass().getName()} with value: ${bodyListObj}\"\n                        logger.warn(errMsg)\n                        sendJsonError(HttpServletResponse.SC_BAD_REQUEST, errMsg, null)\n                        return\n                    }\n                    // logger.warn(\"========== REST ${method} ${request.getPathInfo()} ${extraPathNameList}; body list object: ${bodyListObj}\")\n                    parmStack.push()\n                    parmStack.putAll((Map) bodyListObj)\n                    Object responseObj = eci.entityFacade.rest(method, extraPathNameList, parmStack, masterNameInPath)\n                    responseList.add(responseObj ?: [:])\n                    parmStack.pop()\n                }\n                response.addIntHeader('X-Run-Time-ms', (System.currentTimeMillis() - startTime) as int)\n                sendJsonResponse(responseList)\n            } else {\n                Object responseObj = eci.entityFacade.rest(method, extraPathNameList, parmStack, masterNameInPath)\n                response.addIntHeader('X-Run-Time-ms', (System.currentTimeMillis() - startTime) as int)\n\n                if (parmStack.xTotalCount != null) response.addIntHeader('X-Total-Count', parmStack.xTotalCount as int)\n                if (parmStack.xPageIndex != null) response.addIntHeader('X-Page-Index', parmStack.xPageIndex as int)\n                if (parmStack.xPageSize != null) response.addIntHeader('X-Page-Size', parmStack.xPageSize as int)\n                if (parmStack.xPageMaxIndex != null) response.addIntHeader('X-Page-Max-Index', parmStack.xPageMaxIndex as int)\n                if (parmStack.xPageRangeLow != null) response.addIntHeader('X-Page-Range-Low', parmStack.xPageRangeLow as int)\n                if (parmStack.xPageRangeHigh != null) response.addIntHeader('X-Page-Range-High', parmStack.xPageRangeHigh as int)\n\n                // NOTE: This will always respond with 200 OK, consider using 201 Created (for successful POST, create PUT)\n                //     and 204 No Content (for DELETE and other when no content is returned)\n                sendJsonResponse(responseObj)\n            }\n        } catch (ArtifactAuthorizationException e) {\n            // SC_UNAUTHORIZED 401 used when authc/login fails, use SC_FORBIDDEN 403 for authz failures\n            logger.warn(\"REST Access Forbidden (403 no authz): \" + e.message)\n            sendJsonError(HttpServletResponse.SC_FORBIDDEN, null, e)\n        } catch (ArtifactTarpitException e) {\n            logger.warn(\"REST Too Many Requests (429 tarpit): \" + e.message)\n            if (e.getRetryAfterSeconds()) response.addIntHeader(\"Retry-After\", e.getRetryAfterSeconds())\n            // NOTE: there is no constant on HttpServletResponse for 429; see RFC 6585 for details\n            sendJsonError(429, null, e)\n        } catch (EntityNotFoundException e) {\n            logger.warn((String) \"REST Entity Not Found (404): \" + e.message, e)\n            // send 404 Not Found for entities that don't exist (along with records that don't exist)\n            sendJsonError(HttpServletResponse.SC_NOT_FOUND, null, e)\n        } catch (EntityValueNotFoundException e) {\n            logger.warn(\"REST Entity Value Not Found (404): \" + e.message)\n            // record doesn't exist, send 404 Not Found\n            sendJsonError(HttpServletResponse.SC_NOT_FOUND, null, e)\n        } catch (Throwable t) {\n            String errorMessage = t.toString()\n            if (eci.message.hasError()) {\n                String errorsString = eci.message.errorsString\n                logger.error(errorsString, t)\n                errorMessage = errorMessage + ' ' + errorsString\n            }\n            logger.warn((String) \"General error in entity REST: \" + t.toString(), t)\n            sendJsonError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, errorMessage, null)\n        }\n    }\n\n    @Override\n    void handleServiceRestCall(List<String> extraPathNameList) {\n        ContextStack parmStack = (ContextStack) getParameters()\n\n        logger.info(\"Service REST for ${request.getMethod()} to ${request.getPathInfo()} headers ${request.headerNames.collect()} parameters ${getRequestParameters().keySet()}\")\n\n        // check for login, etc error messages\n        if (eci.message.hasError()) {\n            String errorsString = eci.message.errorsString\n            if (\"true\".equals(request.getAttribute(\"moqui.login.error\"))) {\n                logger.warn((String) \"Login error in Service REST API: \" + errorsString)\n                sendJsonError(HttpServletResponse.SC_UNAUTHORIZED, errorsString, null)\n            } else {\n                logger.warn((String) \"General error in Service REST API: \" + errorsString)\n                sendJsonError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, errorsString, null)\n            }\n            return\n        }\n\n        // check for parsing error, send a 400 response\n        if (parmStack._requestBodyJsonParseError) {\n            sendJsonError(HttpServletResponse.SC_BAD_REQUEST, (String) parmStack._requestBodyJsonParseError, null)\n            return\n        }\n\n        try {\n            long startTime = System.currentTimeMillis()\n            // if _requestBodyJsonList do multiple calls\n            if (parmStack._requestBodyJsonList) {\n                // TODO: Consider putting all of this in a transaction for non-find operations (currently each is run in\n                // TODO:     a separate transaction); or handle errors per-row instead of blowing up the whole request\n                List responseList = []\n                for (Object bodyListObj in parmStack._requestBodyJsonList) {\n                    if (!(bodyListObj instanceof Map)) {\n                        String errMsg = \"If request body JSON is a list/array it must contain only object/map values, found non-map entry of type ${bodyListObj.getClass().getName()} with value: ${bodyListObj}\"\n                        logger.warn(errMsg)\n                        sendJsonError(HttpServletResponse.SC_BAD_REQUEST, errMsg, null)\n                        return\n                    }\n                    // logger.warn(\"========== REST ${request.getMethod()} ${request.getPathInfo()} ${extraPathNameList}; body list object: ${bodyListObj}\")\n                    parmStack.push()\n                    parmStack.putAll((Map) bodyListObj)\n                    eci.contextStack.push(parmStack)\n\n                    RestApi.RestResult restResult = eci.serviceFacade.restApi.run(extraPathNameList, eci)\n                    responseList.add(restResult.responseObj ?: [:])\n\n                    eci.contextStack.pop()\n                    parmStack.pop()\n                }\n                response.addIntHeader('X-Run-Time-ms', (System.currentTimeMillis() - startTime) as int)\n\n                if (eci.message.hasError()) {\n                    // if error return that\n                    String errorsString = eci.message.errorsString\n                    logger.warn((String) \"General error in Service REST API: \" + errorsString)\n                    sendJsonError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, errorsString, null)\n                } else {\n                    // otherwise send response\n                    sendJsonResponse(responseList)\n                }\n            } else {\n                eci.contextStack.push(parmStack)\n                RestApi.RestResult restResult = eci.serviceFacade.restApi.run(extraPathNameList, eci)\n                eci.contextStack.pop()\n                response.addIntHeader('X-Run-Time-ms', (System.currentTimeMillis() - startTime) as int)\n                restResult.setHeaders(response)\n\n                if (eci.message.hasError()) {\n                    // if error return that\n                    String errorsString = eci.message.errorsString\n                    logger.warn((String) \"Error message from Service REST API (400): \" + errorsString)\n                    sendJsonError(HttpServletResponse.SC_BAD_REQUEST, errorsString, null)\n                } else {\n                    // NOTE: This will always respond with 200 OK, consider using 201 Created (for successful POST, create PUT)\n                    //     and 204 No Content (for DELETE and other when no content is returned)\n                    sendJsonResponse(restResult.responseObj)\n                }\n            }\n        } catch (AuthenticationRequiredException e) {\n            logger.warn(\"REST Unauthorized (401 no authc): \" + e.message)\n            sendJsonError(HttpServletResponse.SC_UNAUTHORIZED, null, e)\n        } catch (ArtifactAuthorizationException e) {\n            // SC_UNAUTHORIZED 401 used when authc/login fails, use SC_FORBIDDEN 403 for authz failures\n            logger.warn(\"REST Access Forbidden (403 no authz): \" + e.message)\n            sendJsonError(HttpServletResponse.SC_FORBIDDEN, null, e)\n        } catch (ArtifactTarpitException e) {\n            logger.warn(\"REST Too Many Requests (429 tarpit): \" + e.message)\n            if (e.getRetryAfterSeconds()) response.addIntHeader(\"Retry-After\", e.getRetryAfterSeconds())\n            // NOTE: there is no constant on HttpServletResponse for 429; see RFC 6585 for details\n            sendJsonError(429, null, e)\n        } catch (RestApi.ResourceNotFoundException e) {\n            logger.warn((String) \"REST Resource Not Found (404): \" + e.message)\n            // send 404 Not Found for resources/paths that don't exist (along with records that don't exist)\n            sendJsonError(HttpServletResponse.SC_NOT_FOUND, null, e)\n        } catch (RestApi.MethodNotSupportedException e) {\n            logger.warn((String) \"REST Method Not Supported (405): \" + e.message)\n            sendJsonError(HttpServletResponse.SC_METHOD_NOT_ALLOWED, null, e)\n        } catch (EntityValueNotFoundException e) {\n            logger.warn(\"REST Entity Value Not Found (404): \" + e.message)\n            // record doesn't exist, send 404 Not Found\n            sendJsonError(HttpServletResponse.SC_NOT_FOUND, null, e)\n        } catch (Throwable t) {\n            String errorMessage = t.toString()\n            if (eci.message.hasError()) {\n                String errorsString = eci.message.errorsString\n                logger.error(errorsString, t)\n                errorMessage = errorMessage + ' ' + errorsString\n            }\n            logger.warn((String) \"Error thrown in Service REST API (500): \" + t.toString(), t)\n            sendJsonError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, errorMessage, null)\n        }\n    }\n\n    void handleSystemMessage(List<String> extraPathNameList) {\n        int pathSize = extraPathNameList.size()\n        if (pathSize < 2) {\n            response.sendError(HttpServletResponse.SC_BAD_REQUEST, \"No message type or remote system specified\")\n            return\n        }\n        String systemMessageTypeId = (String) extraPathNameList.get(0)\n        String systemMessageRemoteId = (String) extraPathNameList.get(1)\n        String remoteMessageId = pathSize > 2 ? (String) extraPathNameList.get(2) : (String) null\n        String messageText = getRequestBodyText()\n        if (messageText == null || messageText.isEmpty()) {\n            response.sendError(HttpServletResponse.SC_BAD_REQUEST, \"Request body empty\")\n            return\n        }\n\n        try {\n            // make sure systemMessageTypeId and systemMessageRemoteId are valid before the service call\n            EntityValue systemMessageType = eci.entityFacade.find(\"moqui.service.message.SystemMessageType\")\n                    .condition(\"systemMessageTypeId\", systemMessageTypeId).disableAuthz().one()\n            if (systemMessageType == null) {\n                response.sendError(HttpServletResponse.SC_BAD_REQUEST, \"Message type ${systemMessageTypeId} not valid\")\n                return\n            }\n            EntityValue systemMessageRemote = eci.entityFacade.find(\"moqui.service.message.SystemMessageRemote\")\n                    .condition(\"systemMessageRemoteId\", systemMessageRemoteId).disableAuthz().one()\n            if (systemMessageRemote == null) {\n                response.sendError(HttpServletResponse.SC_BAD_REQUEST, \"Remote system ${systemMessageRemoteId} not valid\")\n                return\n            }\n\n            // authc mechanism, what can clients send? custom header or body or anything? may need various options\n            String userId = eci.userFacade.getUserId()\n            String messageAuthEnumId = systemMessageRemote.getNoCheckSimple(\"messageAuthEnumId\")\n            // TODO: consider moving this elsewhere\n            if (!messageAuthEnumId || \"SmatLogin\".equals(messageAuthEnumId)) {\n                // require that user is logged in by this point (handled by UserFacadeImpl init)\n                if (!userId) {\n                    response.sendError(HttpServletResponse.SC_UNAUTHORIZED, \"Receive message for remote system ${systemMessageRemoteId} requires login\")\n                    return\n                }\n                // see if isPermitted for service org.moqui.impl.SystemMessageServices.receive#IncomingSystemMessage\n                ArtifactExecutionInfoImpl aeii = new ArtifactExecutionInfoImpl(\"org.moqui.impl.SystemMessageServices.receive#IncomingSystemMessage\",\n                        ArtifactExecutionInfo.AT_SERVICE, ArtifactExecutionInfo.AUTHZA_ALL, null)\n                try {\n                    eci.artifactExecutionFacade.isPermitted(aeii, null, true, false, true, null)\n                } catch (ArtifactAuthorizationException e) {\n                    logger.warn(\"Authz failutre for system message receive from remote ${systemMessageRemoteId}\", e.toString())\n                    response.sendError(HttpServletResponse.SC_FORBIDDEN, \"Receive message for remote system ${systemMessageRemoteId} not authorized for user with ID ${userId}\")\n                    return\n                }\n            } else if (\"SmatHmacSha256\".equals(messageAuthEnumId)) {\n                // validate HMAC value from authHeaderName HTTP header using sharedSecret and messageText\n                String authHeaderName = (String) systemMessageRemote.authHeaderName\n                String sharedSecret = (String) systemMessageRemote.sharedSecret\n\n                String headerValue = request.getHeader(authHeaderName)\n                if (!headerValue) {\n                    logger.warn(\"System message receive HMAC verify no header ${authHeaderName} value found, for remote ${systemMessageRemoteId}\")\n                    response.sendError(HttpServletResponse.SC_FORBIDDEN, \"No HMAC header ${authHeaderName} found for remote system ${systemMessageRemoteId}\")\n                    return\n                }\n\n                Mac hmac = Mac.getInstance(\"HmacSHA256\")\n                hmac.init(new SecretKeySpec(sharedSecret.getBytes(\"UTF-8\"), \"HmacSHA256\"))\n                // NOTE: if this fails try with \"ISO-8859-1\"\n                String signature = Base64.encoder.encodeToString(hmac.doFinal(messageText.getBytes(\"UTF-8\")))\n\n                if (headerValue != signature) {\n                    logger.warn(\"System message receive HMAC verify header value ${headerValue} calculated ${signature} did not match for remote ${systemMessageRemoteId}\")\n                    response.sendError(HttpServletResponse.SC_FORBIDDEN, \"HMAC verify failed for remote system ${systemMessageRemoteId}\")\n                    return\n                }\n\n                // login anonymous if not logged in\n                eci.userFacade.loginAnonymousIfNoUser()\n            } else if (\"SmatHmacSha256Timestamp\".equals(messageAuthEnumId)) {\n                // validate HMAC value from authHeaderName HTTP header using sharedSecret and messageText\n                String authHeaderName = (String) systemMessageRemote.authHeaderName\n                String sharedSecret = (String) systemMessageRemote.sharedSecret\n\n                String headerValue = request.getHeader(authHeaderName)\n                if (!headerValue) {\n                    logger.warn(\"System message receive HMAC verify no header ${authHeaderName} value found, for remote ${systemMessageRemoteId}\")\n                    response.sendError(HttpServletResponse.SC_FORBIDDEN, \"No HMAC header ${authHeaderName} found for remote system ${systemMessageRemoteId}\")\n                    return\n                }\n\n                // This assumes a header format like\n                // Example-Signature-Header:\n                //t=1492774577,\n                //v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd\n                // We’ve added newlines for clarity, but a realExample-Signature-Header is on a single line.\n                String timestamp = null;\n                String incomingSignature = null;\n                String[] headerValueList = headerValue.split(\",\") // split on comma\n                for (String headerValueItem : headerValueList) {\n                    String key = headerValueItem.split(\"=\")[0].trim()\n                    if (\"t\".equals(key))\n                        timestamp = headerValueItem.split(\"=\")[1].trim()\n                    else if (\"v1\".equals(key))\n                        incomingSignature = headerValueItem.split(\"=\")[1].trim()\n                }\n\n                // This also assumes that the signature is generated from the following concatenated strings:\n                // Timestamp in the header\n                // The character .\n                // The text body of the request\n                String signatureTextToVerify = timestamp + \".\" + messageText\n\n                Mac hmac = Mac.getInstance(\"HmacSHA256\")\n                hmac.init(new SecretKeySpec(sharedSecret.getBytes(StandardCharsets.UTF_8), \"HmacSHA256\"))\n                // NOTE: if this fails try with \"ISO-8859-1\"\n                byte[] hash = hmac.doFinal(signatureTextToVerify.getBytes(StandardCharsets.UTF_8));\n                String signature = \"\"\n                for (byte b : hash) {\n                    // Came from https://github.com/stripe/stripe-java/blob/3686feb8f2067878b7bb4619f931580a3d31bf4f/src/main/java/com/stripe/net/Webhook.java#L187\n                    signature += Integer.toString((b & 0xff) + 0x100, 16).substring(1);\n                }\n\n                if (incomingSignature != signature) {\n                    logger.warn(\"System message receive HMAC verify header value ${incomingSignature} calculated ${signature} did not match for remote ${systemMessageRemoteId}\")\n                    response.sendError(HttpServletResponse.SC_FORBIDDEN, \"HMAC verify failed for remote system ${systemMessageRemoteId}\")\n                    return\n                }\n\n                Timestamp incomingTimestamp = new Timestamp(Long.parseLong(timestamp) * 1000)\n\n                // Add 10 seconds to now timestamp to allow for clock skew (10 seconds = 10000 milliseconds = 10*1000)\n                Timestamp nowTimestamp = new Timestamp(eci.user.nowTimestamp.getTime() + 10000)\n                // If timestamp was not sent in past 5 minutes, reject message (5 minutes = 300000 milliseconds = 5*60*1000)\n                Timestamp beforeTimestamp = new Timestamp(nowTimestamp.getTime() - 300000)\n                if (!incomingTimestamp.before(nowTimestamp) || !incomingTimestamp.after(beforeTimestamp) ){\n                    logger.warn(\"System message receive HMAC invalid incoming timestamp where before timestamp ${beforeTimestamp} < incoming timestamp ${incomingTimestamp} < now timestamp ${nowTimestamp}\" )\n                    response.sendError(HttpServletResponse.SC_FORBIDDEN, \"HMAC timestamp verification failed\")\n                    return\n                }\n\n                // login anonymous if not logged in\n                eci.userFacade.loginAnonymousIfNoUser()\n            } else if (!\"SmatNone\".equals(messageAuthEnumId)) {\n                logger.error(\"Got system message for remote ${systemMessageRemoteId} with unsupported messageAuthEnumId ${messageAuthEnumId}, returning error\")\n                response.sendError(HttpServletResponse.SC_BAD_REQUEST, \"Remote system ${systemMessageRemoteId} auth configuration not valid\")\n                return\n            }\n\n            // NOTE: called with disableAuthz() since we do an authz check before when needed\n            Map<String, Object> result = eci.serviceFacade.sync().name(\"org.moqui.impl.SystemMessageServices.receive#IncomingSystemMessage\")\n                    .parameter(\"systemMessageTypeId\", systemMessageTypeId).parameter(\"systemMessageRemoteId\", systemMessageRemoteId)\n                    .parameter(\"remoteMessageId\", remoteMessageId).parameter(\"messageText\", messageText).disableAuthz().call()\n\n            if (eci.messageFacade.hasError()) {\n                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, eci.messageFacade.getErrorsString())\n                return\n             }\n\n            // technically SC_ACCEPTED (202) is more accurate, OK (200) more common\n            response.setStatus(HttpServletResponse.SC_OK)\n\n            // TODO: consider returning response with systemMessageIdList in JSON or XML based on Accept header\n        } catch (Throwable t) {\n            logger.error(\"Error handling system message type ${systemMessageTypeId} remote ${systemMessageRemoteId} remote msg ${remoteMessageId}\", t)\n            response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, \"Error receiving message: ${t.toString()}\")\n        }\n    }\n\n    /* Session based pass through handling, etc */\n\n    void saveScreenLastInfo(String screenPath, Map parameters) {\n        session.setAttribute(\"moqui.screen.last.path\", screenPath ?: getPathInfo())\n        parameters = parameters ?: new HashMap(getRequestParameters())\n        // logger.warn(\"saveScreenLastInfo parameters: ${parameters}\")\n        // logger.warn(\"saveScreenLastInfo getRequestParameters(): ${getRequestParameters().toString()}\")\n        WebUtilities.testSerialization(\"moqui.screen.last.parameters\", parameters)\n        session.setAttribute(\"moqui.screen.last.parameters\", parameters)\n    }\n\n    String getRemoveScreenLastPath() {\n        String path = session.getAttribute(\"moqui.screen.last.path\")\n        session.removeAttribute(\"moqui.screen.last.path\")\n        return path\n    }\n    Map getSavedParameters() { return (Map) session.getAttribute(\"moqui.saved.parameters\") }\n    void removeScreenLastParameters(boolean moveToSaved) {\n        if (moveToSaved) session.setAttribute(\"moqui.saved.parameters\", session.getAttribute(\"moqui.screen.last.parameters\"))\n        session.removeAttribute(\"moqui.screen.last.parameters\")\n    }\n\n    void saveMessagesToSession() {\n        List<MessageInfo> messageInfos = eci.messageFacade.getMessageInfos()\n        WebUtilities.testSerialization(\"moqui.message.messageInfos\", messageInfos)\n        if (messageInfos != null && messageInfos.size() > 0) session.setAttribute(\"moqui.message.messageInfos\", messageInfos)\n        List<MessageInfo> publicMessageInfos = eci.messageFacade.getPublicMessageInfos()\n        WebUtilities.testSerialization(\"moqui.message.publicMessageInfos\", publicMessageInfos)\n        if (publicMessageInfos != null && publicMessageInfos.size() > 0)\n            session.setAttribute(\"moqui.message.publicMessageInfos\", publicMessageInfos)\n\n        List<String> errors = eci.messageFacade.getErrors()\n        if (errors != null && errors.size() > 0) session.setAttribute(\"moqui.message.errors\", errors)\n        List<ValidationError> validationErrors = eci.messageFacade.validationErrors\n        WebUtilities.testSerialization(\"moqui.message.validationErrors\", validationErrors)\n        if (validationErrors != null && validationErrors.size() > 0)\n            session.setAttribute(\"moqui.message.validationErrors\", validationErrors)\n    }\n\n    /** Save passed parameters Map to a Map in the moqui.saved.parameters session attribute */\n    void saveParametersToSession(Map parameters) {\n        if (parameters == null || parameters.size() == 0) return\n        Map parms = new HashMap()\n        // merge existing moqui.saved.parameters if there are any, valid for current request only as WebFacadeImpl() constructors removes this session attribute if has value\n        Map currentSavedParameters = (Map) request.session.getAttribute(\"moqui.saved.parameters\")\n        if (currentSavedParameters) parms.putAll(currentSavedParameters)\n        parms.putAll(parameters)\n        if (!\"production\".equals(System.getProperty(\"instance_purpose\")))\n            WebUtilities.testSerialization(\"moqui.saved.parameters\", parms)\n        session.setAttribute(\"moqui.saved.parameters\", parms)\n    }\n    /** Save request parameters and attributes to a Map in the moqui.saved.parameters session attribute */\n    void saveRequestParametersToSession() {\n        Map parms = new HashMap()\n        // merge existing moqui.saved.parameters if there are any, valid for current request only as WebFacadeImpl() constructors removes this session attribute if has value\n        Map currentSavedParameters = (Map) request.session.getAttribute(\"moqui.saved.parameters\")\n        if (currentSavedParameters) parms.putAll(currentSavedParameters)\n        if (requestParameters) parms.putAll(requestParameters)\n        // don't include attributes, end up with internal stuff in URL parameters: if (requestAttributes) parms.putAll(requestAttributes)\n        if (!\"production\".equals(System.getProperty(\"instance_purpose\")))\n            WebUtilities.testSerialization(\"moqui.saved.parameters\", parms)\n        session.setAttribute(\"moqui.saved.parameters\", parms)\n    }\n\n    /** Save request parameters and attributes to a Map in the moqui.error.parameters session attribute */\n    void saveErrorParametersToSession() {\n        Map parms = new HashMap()\n        if (requestParameters) parms.putAll(requestParameters)\n        // don't include attributes, end up with internal stuff in URL parameters: if (requestAttributes) parms.putAll(requestAttributes)\n        if (!\"production\".equals(System.getProperty(\"instance_purpose\")))\n            WebUtilities.testSerialization(\"moqui.error.parameters\", parms)\n        session.setAttribute(\"moqui.error.parameters\", parms)\n    }\n\n    static final byte[] trackingPng = [(byte)0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,0x00,0x00,0x00,0x0D,0x49,0x48,0x44,0x52,0x00,\n            0x00,0x00,0x01,0x00,0x00,0x00,0x01,0x08,0x06,0x00,0x00,0x00,0x1F,0x15,(byte)0xC4,(byte)0x89,0x00,0x00,0x00,0x0B,0x49,\n            0x44,0x41,0x54,0x78,(byte)0xDA,0x63,0x60,0x00,0x02,0x00,0x00,0x05,0x00,0x01,(byte)0xE9,(byte)0xFA,(byte)0xDC,(byte)0xD8,\n            0x00,0x00,0x00,0x00,0x49,0x45,0x4E,0x44,(byte)0xAE,0x42,0x60,(byte)0x82]\n    void viewEmailMessage() {\n        // first send the empty image\n        response.setContentType('image/png')\n        response.setHeader(\"Content-Disposition\", \"inline\")\n        OutputStream os = response.outputStream\n        try { os.write(trackingPng) } finally { os.close() }\n        // mark the message viewed\n        try {\n            String emailMessageId = (String) eci.contextStack.get(\"emailMessageId\")\n            if (emailMessageId != null && !emailMessageId.isEmpty()) {\n                int dotIndex = emailMessageId.indexOf(\".\")\n                if (dotIndex > 0) emailMessageId = emailMessageId.substring(0, dotIndex)\n                EntityValue emailMessage = eci.entity.find(\"moqui.basic.email.EmailMessage\").condition(\"emailMessageId\", emailMessageId)\n                        .disableAuthz().one()\n                if (emailMessage == null) {\n                    logger.warn(\"Tried to mark EmailMessage ${emailMessageId} viewed but not found\")\n                } else if (!\"ES_VIEWED\".equals(emailMessage.statusId)) {\n                    eci.service.sync().name(\"update#moqui.basic.email.EmailMessage\").parameter(\"emailMessageId\", emailMessageId)\n                            .parameter(\"statusId\", \"ES_VIEWED\").parameter(\"receivedDate\", eci.user.nowTimestamp).disableAuthz().call()\n                }\n            }\n        } catch (Throwable t) {\n            logger.error(\"Error marking EmailMessage viewed\", t)\n        }\n    }\n\n    protected DiskFileItemFactory makeDiskFileItemFactory() {\n        // NOTE: consider keeping this factory somewhere to be more efficient, if it even makes a difference...\n        File repository = new File(eci.ecfi.runtimePath + \"/tmp\")\n        if (!repository.exists()) repository.mkdir()\n        DiskFileItemFactory factory = DiskFileItemFactory.builder()\n                .setPath(repository.toPath())\n                .setBufferSize(DiskFileItemFactory.DEFAULT_THRESHOLD)\n                .get()\n        // TODO: this was causing files to get deleted before the upload was streamed... need to figure out something else\n        //FileCleaningTracker fileCleaningTracker = FileCleanerCleanup.getFileCleaningTracker(request.getServletContext())\n        //factory.setFileCleaningTracker(fileCleaningTracker)\n        return factory\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/reference/BaseResourceReference.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context.reference;\n\nimport org.moqui.impl.context.ExecutionContextFactoryImpl;\nimport org.moqui.resource.ResourceReference;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.net.URL;\nimport java.util.*;\n\npublic abstract class BaseResourceReference extends ResourceReference {\n    protected static final Logger logger = LoggerFactory.getLogger(BaseResourceReference.class);\n    protected ExecutionContextFactoryImpl ecf = (ExecutionContextFactoryImpl) null;\n\n    public BaseResourceReference() { }\n\n    @Override\n    public ResourceReference init(String location) { return init(location, null); }\n    public abstract ResourceReference init(String location, ExecutionContextFactoryImpl ecf);\n\n    @Override public abstract ResourceReference createNew(String location);\n    @Override public abstract String getLocation();\n    @Override public abstract InputStream openStream();\n    @Override public abstract OutputStream getOutputStream();\n    @Override public abstract String getText();\n\n    @Override public abstract boolean supportsAll();\n    @Override public abstract boolean supportsUrl();\n    @Override public abstract URL getUrl();\n    @Override public abstract boolean supportsDirectory();\n    @Override public abstract boolean isFile();\n    @Override public abstract boolean isDirectory();\n    @Override public abstract List<ResourceReference> getDirectoryEntries();\n\n    @Override public abstract boolean supportsExists();\n    @Override public abstract boolean getExists();\n    @Override public abstract boolean supportsLastModified();\n    @Override public abstract long getLastModified();\n    @Override public abstract boolean supportsSize();\n    @Override public abstract long getSize();\n\n    @Override public abstract boolean supportsWrite();\n    @Override public abstract void putText(String text);\n    @Override public abstract void putStream(InputStream stream);\n    @Override public abstract void move(String newLocation);\n\n    @Override public abstract ResourceReference makeDirectory(String name);\n    @Override public abstract ResourceReference makeFile(String name);\n    @Override public abstract boolean delete();\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/reference/ComponentResourceReference.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context.reference;\n\nimport org.moqui.impl.context.ExecutionContextFactoryImpl;\nimport org.moqui.resource.ResourceReference;\n\nimport java.util.ArrayList;\nimport java.util.List;\n\npublic class ComponentResourceReference extends WrapperResourceReference {\n    private String componentLocation;\n\n    public ComponentResourceReference() { super(); }\n\n    public ResourceReference init(String location, ExecutionContextFactoryImpl ecf) {\n        this.ecf = ecf;\n\n        // if there is a hash (used in resource locations for versions) strip the hash and everything after\n        int hashIdx = location.indexOf(\"#\");\n        if (hashIdx > 0) location = location.substring(0, hashIdx);\n        // remove trailing slash if there is one\n        if (location.endsWith(\"/\")) location = location.substring(0, location.length() - 1);\n        this.componentLocation = location;\n\n        String strippedLocation = ResourceReference.stripLocationPrefix(location);\n\n        // turn this into another URL using the component location\n        StringBuilder baseLocation = new StringBuilder(strippedLocation);\n        // componentName is everything before the first slash\n        String componentName;\n        int firstSlash = baseLocation.indexOf(\"/\");\n        if (firstSlash > 0) {\n            componentName = baseLocation.substring(0, firstSlash);\n            // got the componentName, now remove it from the baseLocation\n            baseLocation.delete(0, firstSlash + 1);\n        } else {\n            componentName = baseLocation.toString();\n            baseLocation.delete(0, baseLocation.length());\n        }\n\n        baseLocation.insert(0, \"/\");\n        baseLocation.insert(0, ecf.getComponentBaseLocations().get(componentName));\n\n        setRr(ecf.getResource().getLocationReference(baseLocation.toString()));\n        return this;\n    }\n\n    @Override\n    public ResourceReference createNew(String location) {\n        ComponentResourceReference resRef = new ComponentResourceReference();\n        resRef.init(location, ecf);\n        return resRef;\n    }\n\n    @Override\n    public String getLocation() { return componentLocation; }\n\n    @Override\n    public List<ResourceReference> getDirectoryEntries() {\n        // a little extra work to keep the directory entries as component-based locations\n        List<ResourceReference> nestedList = this.getRr().getDirectoryEntries();\n        List<ResourceReference> newList = new ArrayList<>(nestedList.size());\n        for (ResourceReference entryRr : nestedList) {\n            String entryLoc = entryRr.getLocation();\n            if (entryLoc.endsWith(\"/\")) entryLoc = entryLoc.substring(0, entryLoc.length() - 1);\n            String newLocation = this.componentLocation + \"/\" + entryLoc.substring(entryLoc.lastIndexOf(\"/\") + 1);\n            newList.add(new ComponentResourceReference().init(newLocation, ecf));\n        }\n\n        return newList;\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/reference/ContentResourceReference.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context.reference\n\nimport groovy.transform.CompileStatic\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.util.ObjectUtilities\n\nimport javax.jcr.NodeIterator\nimport javax.jcr.PathNotFoundException\nimport javax.jcr.Session\nimport javax.jcr.Property\n\nimport org.moqui.resource.ResourceReference\nimport org.moqui.impl.context.ResourceFacadeImpl\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n@CompileStatic\nclass ContentResourceReference extends BaseResourceReference {\n    protected final static Logger logger = LoggerFactory.getLogger(ContentResourceReference.class)\n    public final static String locationPrefix = \"content://\"\n\n    String location\n    String repositoryName\n    String nodePath\n\n    protected javax.jcr.Node theNode = null\n\n    ContentResourceReference() { }\n    \n    @Override ResourceReference init(String location, ExecutionContextFactoryImpl ecf) {\n        this.ecf = ecf\n\n        this.location = location\n        // TODO: change to not rely on URI, or to encode properly\n        URI locationUri = new URI(location)\n        repositoryName = locationUri.host\n        nodePath = locationUri.path\n\n        return this\n    }\n\n    ResourceReference init(String repositoryName, javax.jcr.Node node, ExecutionContextFactoryImpl ecf) {\n        this.ecf = ecf\n\n        this.repositoryName = repositoryName\n        this.nodePath = node.path\n        this.location = \"${locationPrefix}${repositoryName}${nodePath}\"\n        this.theNode = node\n        return this\n    }\n\n    @Override ResourceReference createNew(String location) {\n        ContentResourceReference resRef = new ContentResourceReference();\n        resRef.init(location, ecf);\n        return resRef;\n    }\n    @Override String getLocation() { location }\n\n    @Override InputStream openStream() {\n        javax.jcr.Node node = getNode()\n        if (node == null) return null\n        javax.jcr.Node contentNode = node.getNode(\"jcr:content\")\n        if (contentNode == null) throw new IllegalArgumentException(\"Cannot get stream for content at [${repositoryName}][${nodePath}], has no jcr:content child node\")\n        Property dataProperty = contentNode.getProperty(\"jcr:data\")\n        if (dataProperty == null) throw new IllegalArgumentException(\"Cannot get stream for content at [${repositoryName}][${nodePath}], has no jcr:content.jcr:data property\")\n        return dataProperty.binary.stream\n    }\n\n    @Override OutputStream getOutputStream() {\n        throw new UnsupportedOperationException(\"The getOutputStream method is not supported for JCR, use putStream() instead\")\n    }\n\n    @Override String getText() { return ObjectUtilities.getStreamText(openStream()) }\n\n    @Override boolean supportsAll() { true }\n\n    @Override boolean supportsUrl() { false }\n    @Override URL getUrl() { return null }\n\n    @Override boolean supportsDirectory() { true }\n    @Override boolean isFile() {\n        javax.jcr.Node node = getNode()\n        if (node == null) return false\n        return node.isNodeType(\"nt:file\")\n    }\n    @Override boolean isDirectory() {\n        javax.jcr.Node node = getNode()\n        if (node == null) return false\n        return node.isNodeType(\"nt:folder\")\n    }\n    @Override List<ResourceReference> getDirectoryEntries() {\n        List<ResourceReference> dirEntries = new LinkedList()\n        javax.jcr.Node node = getNode()\n        if (node == null) return dirEntries\n\n        NodeIterator childNodes = node.getNodes()\n        while (childNodes.hasNext()) {\n            javax.jcr.Node childNode = childNodes.nextNode()\n            dirEntries.add(new ContentResourceReference().init(repositoryName, childNode, ecf))\n        }\n        return dirEntries\n    }\n    // TODO: consider overriding findChildFile() to let the JCR impl do the query\n    // ResourceReference findChildFile(String relativePath)\n\n    @Override boolean supportsExists() { true }\n    @Override boolean getExists() {\n        if (theNode != null) return true\n        Session session = ((ResourceFacadeImpl) ecf.resource).getContentRepositorySession(repositoryName)\n        return session.nodeExists(nodePath)\n    }\n\n    @Override boolean supportsLastModified() { true }\n    @Override long getLastModified() {\n        try {\n            return getNode()?.getProperty(\"jcr:lastModified\")?.getDate()?.getTimeInMillis()\n        } catch (PathNotFoundException e) {\n            return System.currentTimeMillis()\n        }\n    }\n\n    @Override boolean supportsSize() { true }\n    @Override long getSize() {\n        try {\n            return getNode()?.getProperty(\"jcr:content/jcr:data\")?.getLength()\n        } catch (PathNotFoundException e) {\n            return 0\n        }\n    }\n\n    @Override boolean supportsWrite() { true }\n\n    @Override void putText(String text) { putObject(text) }\n    @Override void putStream(InputStream stream) { putObject(stream) }\n    protected void putObject(Object obj) {\n        if (obj == null) {\n            logger.warn(\"Data was null, not saving to resource [${getLocation()}]\")\n            return\n        }\n        Session session = ((ResourceFacadeImpl) ecf.resource).getContentRepositorySession(repositoryName)\n        javax.jcr.Node fileNode = getNode()\n        javax.jcr.Node fileContent\n        if (fileNode != null) {\n            fileContent = fileNode.getNode(\"jcr:content\")\n        } else {\n            // first make sure the directory exists that this is in\n            List<String> nodePathList = new ArrayList<>(Arrays.asList(nodePath.split('/')))\n            // if nodePath started with a '/' the first element will be empty\n            if (nodePathList && nodePathList[0] == \"\") nodePathList.remove(0)\n            // remove the filename to just get the directory\n            if (nodePathList) nodePathList.remove(nodePathList.size()-1)\n            javax.jcr.Node folderNode = findDirectoryNode(session, nodePathList, true)\n\n            // now create the node\n            fileNode = folderNode.addNode(fileName, \"nt:file\")\n            fileContent = fileNode.addNode(\"jcr:content\", \"nt:resource\")\n        }\n        fileContent.setProperty(\"jcr:mimeType\", contentType)\n        // fileContent.setProperty(\"jcr:encoding\", ?)\n        Calendar lastModified = Calendar.getInstance(); lastModified.setTimeInMillis(System.currentTimeMillis())\n        fileContent.setProperty(\"jcr:lastModified\", lastModified)\n        if (obj instanceof CharSequence) {\n            fileContent.setProperty(\"jcr:data\", session.valueFactory.createValue(obj.toString()))\n        } else if (obj instanceof InputStream) {\n            fileContent.setProperty(\"jcr:data\", session.valueFactory.createBinary((InputStream) obj))\n        } else if (obj == null) {\n            fileContent.setProperty(\"jcr:data\", session.valueFactory.createValue(\"\"))\n        } else {\n            throw new IllegalArgumentException(\"Cannot save content for obj with type ${obj.class.name}\")\n        }\n\n        session.save()\n    }\n\n    static javax.jcr.Node findDirectoryNode(Session session, List<String> pathList, boolean create) {\n        javax.jcr.Node rootNode = session.getRootNode()\n        javax.jcr.Node folderNode = rootNode\n        if (pathList) {\n            for (String nodePathElement in pathList) {\n                if (folderNode.hasNode(nodePathElement)) {\n                    folderNode = folderNode.getNode(nodePathElement)\n                } else {\n                    if (create) {\n                        folderNode = folderNode.addNode(nodePathElement, \"nt:folder\")\n                    } else {\n                        folderNode = null\n                        break\n                    }\n                }\n            }\n        }\n        return folderNode\n    }\n\n    void move(String newLocation) {\n        if (!newLocation.startsWith(locationPrefix))\n            throw new IllegalArgumentException(\"New location [${newLocation}] is not a content location, not moving resource at ${getLocation()}\")\n\n        Session session = ((ResourceFacadeImpl) ecf.resource).getContentRepositorySession(repositoryName)\n\n        ResourceReference newRr = ecf.resource.getLocationReference(newLocation)\n        if (!newRr instanceof ContentResourceReference)\n            throw new IllegalArgumentException(\"New location [${newLocation}] is not a content location, not moving resource at ${getLocation()}\")\n        ContentResourceReference newCrr = (ContentResourceReference) newRr\n\n        // make sure the target folder exists\n        List<String> nodePathList = new ArrayList<>(Arrays.asList(newCrr.getNodePath().split('/')))\n        if (nodePathList && nodePathList[0] == \"\") nodePathList.remove(0)\n        if (nodePathList) nodePathList.remove(nodePathList.size()-1)\n        findDirectoryNode(session, nodePathList, true)\n\n        session.move(this.getNodePath(), newCrr.getNodePath())\n        session.save()\n\n        this.theNode = null\n    }\n\n    @Override ResourceReference makeDirectory(String name) {\n        Session session = ((ResourceFacadeImpl) ecf.resource).getContentRepositorySession(repositoryName)\n        findDirectoryNode(session, [name], true)\n        return new ContentResourceReference().init(\"${location}/${name}\", ecf)\n    }\n    @Override ResourceReference makeFile(String name) {\n        ContentResourceReference newRef = (ContentResourceReference) new ContentResourceReference().init(\"${location}/${name}\", ecf)\n        newRef.putObject(null)\n        return newRef\n    }\n    @Override boolean delete() {\n        javax.jcr.Node curNode = getNode()\n        if (curNode == null) return false\n\n        Session session = ((ResourceFacadeImpl) ecf.resource).getContentRepositorySession(repositoryName)\n        session.removeItem(nodePath)\n        session.save()\n\n        this.theNode = null\n        return true\n    }\n\n    javax.jcr.Node getNode() {\n        if (theNode != null) return theNode\n        Session session = ((ResourceFacadeImpl) ecf.resource).getContentRepositorySession(repositoryName)\n        return session.nodeExists(nodePath) ? session.getNode(nodePath) : null\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/reference/DbResourceReference.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context.reference\n\nimport groovy.transform.CompileStatic\nimport org.moqui.BaseArtifactException\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.resource.ResourceReference\n// NOTE: IDE says this isn't needed but compiler requires it\nimport org.moqui.resource.ResourceReference.Version\nimport org.moqui.entity.EntityValue\nimport org.moqui.entity.EntityList\nimport org.moqui.util.ObjectUtilities\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport javax.sql.rowset.serial.SerialBlob\nimport java.nio.charset.StandardCharsets\nimport java.sql.Timestamp\n\n@CompileStatic\nclass DbResourceReference extends BaseResourceReference {\n    protected final static Logger logger = LoggerFactory.getLogger(DbResourceReference.class)\n    public final static String locationPrefix = \"dbresource://\"\n\n    String location\n    String resourceId = (String) null\n\n    DbResourceReference() { }\n\n    @Override ResourceReference init(String location, ExecutionContextFactoryImpl ecf) {\n        this.ecf = ecf\n        this.location = location\n        return this\n    }\n\n    ResourceReference init(String location, EntityValue dbResource, ExecutionContextFactoryImpl ecf) {\n        this.ecf = ecf\n        this.location = location\n        resourceId = dbResource.resourceId\n        return this\n    }\n\n    @Override ResourceReference createNew(String location) {\n        DbResourceReference resRef = new DbResourceReference()\n        resRef.init(location, ecf)\n        return resRef\n    }\n    @Override String getLocation() { location }\n\n    String getPath() {\n        if (!location) return \"\"\n        // should have a prefix of \"dbresource://\"\n        return location.substring(locationPrefix.length())\n    }\n\n    @Override InputStream openStream() {\n        EntityValue dbrf = getDbResourceFile()\n        if (dbrf == null) return null\n        return dbrf.getSerialBlob(\"fileData\")?.getBinaryStream()\n    }\n\n    @Override OutputStream getOutputStream() {\n        throw new UnsupportedOperationException(\"The getOutputStream method is not supported for DB resources, use putStream() instead\")\n    }\n\n    @Override String getText() { return ObjectUtilities.getStreamText(openStream()) }\n\n    @Override boolean supportsAll() { true }\n\n    @Override boolean supportsUrl() { false }\n    @Override URL getUrl() { return null }\n\n    @Override boolean supportsDirectory() { true }\n    @Override boolean isFile() { return \"Y\".equals(getDbResource(true)?.isFile) }\n    @Override boolean isDirectory() {\n        if (!getPath()) return true // consider root a directory\n        EntityValue dbr = getDbResource(true)\n        return dbr != null && !\"Y\".equals(dbr.isFile)\n    }\n    @Override List<ResourceReference> getDirectoryEntries() {\n        List<ResourceReference> dirEntries = new LinkedList()\n        EntityValue dbr = getDbResource(true)\n        if (getPath() && dbr == null) return dirEntries\n\n        // allow parentResourceId to be null for the root\n        EntityList childList = ecf.entity.find(\"moqui.resource.DbResource\").condition([parentResourceId:dbr?.resourceId])\n                .orderBy(\"filename\").useCache(true).disableAuthz().list()\n        for (EntityValue child in childList) {\n            String childLoc = getPath() ? \"${location}/${child.filename}\" : \"${location}${child.filename}\"\n            dirEntries.add(new DbResourceReference().init(childLoc, child, ecf))\n        }\n        return dirEntries\n    }\n\n    @Override boolean supportsExists() { true }\n    @Override boolean getExists() { return getDbResource(true) != null }\n\n    @Override boolean supportsLastModified() { true }\n    @Override long getLastModified() {\n        EntityValue dbr = getDbResource(true)\n        if (dbr == null) return 0\n        if (\"Y\".equals(dbr.isFile)) {\n            EntityValue dbrf = ecf.entity.find(\"moqui.resource.DbResourceFile\").condition(\"resourceId\", resourceId)\n                    .selectField(\"lastUpdatedStamp\").useCache(false).disableAuthz().one()\n            if (dbrf != null) return dbrf.getTimestamp(\"lastUpdatedStamp\").getTime()\n        }\n        return dbr.getTimestamp(\"lastUpdatedStamp\").getTime()\n    }\n\n    @Override boolean supportsSize() { true }\n    @Override long getSize() {\n        EntityValue dbrf = getDbResourceFile()\n        if (dbrf == null) return 0\n        return dbrf.getSerialBlob(\"fileData\")?.length() ?: 0\n    }\n\n    @Override boolean supportsWrite() { true }\n    @Override void putText(String text) {\n        // TODO: use diff from last version for text\n        SerialBlob sblob = text ? new SerialBlob(text.getBytes(StandardCharsets.UTF_8)) : null\n        this.putObject(sblob)\n    }\n    @Override void putStream(InputStream stream) {\n        if (stream == null) return\n        ByteArrayOutputStream baos = new ByteArrayOutputStream()\n        ObjectUtilities.copyStream(stream, baos)\n        SerialBlob sblob = new SerialBlob(baos.toByteArray())\n        this.putObject(sblob)\n    }\n    @Override void putBytes(byte[] bytes) {\n        this.putObject(new SerialBlob(bytes))\n    }\n\n    protected void putObject(Object fileObj) {\n        EntityValue dbrf = getDbResourceFile()\n        if (dbrf != null) {\n            makeNextVersion(dbrf, fileObj)\n        } else {\n            // first make sure the directory exists that this is in\n            List<String> filenameList = new ArrayList<>(Arrays.asList(getPath().split(\"/\")))\n            int filenameListSize = filenameList.size()\n            if (filenameListSize == 0) throw new BaseArtifactException(\"Cannot put file at empty location ${getPath()}\")\n            String filename = filenameList.get(filenameList.size()-1)\n            // remove the current filename from the list, and find ID of parent directory for path\n            filenameList.remove(filenameList.size()-1)\n            String parentResourceId = findDirectoryId(filenameList, true)\n\n            if (parentResourceId == null) throw new BaseArtifactException(\"Could not find directory to put new file in at ${filenameList}\")\n\n            // lock the parentResourceId\n            ecf.entity.find(\"moqui.resource.DbResource\").condition(\"resourceId\", parentResourceId)\n                    .selectField(\"lastUpdatedStamp\").forUpdate(true).disableAuthz().one()\n            // do a query by name to see if it exists\n            EntityValue existingValue = ecf.entity.find(\"moqui.resource.DbResource\")\n                    .condition(\"parentResourceId\", parentResourceId).condition(\"filename\", filename)\n                    .useCache(false).disableAuthz().list().getFirst()\n            if (existingValue != null) {\n                resourceId = existingValue.resourceId\n                dbrf = getDbResourceFile()\n                makeNextVersion(dbrf, fileObj)\n            } else {\n                // now write the DbResource and DbResourceFile records\n                Map createDbrResult = ecf.service.sync().name(\"create\", \"moqui.resource.DbResource\")\n                        .parameters([parentResourceId:parentResourceId, filename:filename, isFile:\"Y\"])\n                        .disableAuthz().call()\n                resourceId = createDbrResult.resourceId\n                String versionName = \"01\"\n                ecf.service.sync().name(\"create\", \"moqui.resource.DbResourceFile\")\n                        .parameters([resourceId:resourceId, mimeType:getContentType(), versionName:versionName,\n                                     rootVersionName:versionName, fileData:fileObj])\n                        .disableAuthz().call()\n                ExecutionContextImpl eci = ecf.getEci()\n                // NOTE: no fileData, for non-diff only past versions\n                ecf.service.sync().name(\"create\", \"moqui.resource.DbResourceFileHistory\")\n                        .parameters([resourceId:resourceId, versionDate:eci.userFacade.nowTimestamp,\n                                     userId:eci.userFacade.userId, isDiff:\"N\"])\n                        .disableAuthz().call()\n            }\n        }\n    }\n    protected void makeNextVersion(EntityValue dbrf, Object newFileObj) {\n        String currentVersionName = dbrf.versionName\n        if (currentVersionName != null && !currentVersionName.isEmpty()) {\n            EntityValue currentDbrfHistory = ecf.entityFacade.find(\"moqui.resource.DbResourceFileHistory\")\n                    .condition(\"resourceId\", resourceId).condition(\"versionName\", currentVersionName)\n                    .useCache(false).disableAuthz().one()\n            if (currentDbrfHistory != null) {\n                currentDbrfHistory.set(\"fileData\", dbrf.fileData)\n                currentDbrfHistory.update()\n            }\n        }\n        ExecutionContextImpl eci = ecf.getEci()\n        // NOTE: no fileData, for non-diff only past versions\n        Map createOut = ecf.service.sync().name(\"create\", \"moqui.resource.DbResourceFileHistory\")\n                .parameters([resourceId:resourceId, previousVersionName:currentVersionName,\n                             versionDate:eci.userFacade.nowTimestamp, userId:eci.userFacade.userId,\n                             isDiff:\"N\"])\n                .disableAuthz().call()\n        String newVersionName = createOut.versionName\n        if (!dbrf.rootVersionName) dbrf.rootVersionName = currentVersionName ?: newVersionName\n        dbrf.versionName = newVersionName\n        dbrf.fileData = newFileObj\n        dbrf.update()\n    }\n    String findDirectoryId(List<String> pathList, boolean create) {\n        String finalParentResourceId = null\n        if (pathList) {\n            String parentResourceId = null\n            boolean found = true\n            for (String filename in pathList) {\n                if (filename == null || filename.length() == 0) continue\n\n                EntityValue directoryValue = ecf.entity.find(\"moqui.resource.DbResource\")\n                        .condition(\"parentResourceId\", parentResourceId).condition(\"filename\", filename)\n                        .useCache(true).disableAuthz().list().getFirst()\n                if (directoryValue == null) {\n                    if (create) {\n                        // trying a create so lock the parent, then query again to make sure it doesn't exist\n                        ecf.entity.find(\"moqui.resource.DbResource\").condition(\"resourceId\", parentResourceId)\n                                .selectField(\"lastUpdatedStamp\").forUpdate(true).disableAuthz().one()\n                        directoryValue = ecf.entity.find(\"moqui.resource.DbResource\")\n                                .condition(\"parentResourceId\", parentResourceId).condition(\"filename\", filename)\n                                .useCache(false).disableAuthz().list().getFirst()\n                        if (directoryValue == null) {\n                            Map createResult = ecf.service.sync().name(\"create\", \"moqui.resource.DbResource\")\n                                    .parameters([parentResourceId:parentResourceId, filename:filename, isFile:\"N\"])\n                                    .disableAuthz().call()\n                            parentResourceId = createResult.resourceId\n                            // logger.warn(\"=============== put text to ${location}, created dir ${filename}\")\n                        }\n                        // else fall through, handle below\n                    } else {\n                        found = false\n                        break\n                    }\n                }\n                if (directoryValue != null) {\n                    if (directoryValue.isFile == \"Y\") {\n                        throw new BaseArtifactException(\"Tried to find a directory in a path but found file instead at ${filename} under DbResource ${parentResourceId}\")\n                    } else {\n                        parentResourceId = directoryValue.resourceId\n                        // logger.warn(\"=============== put text to ${location}, found existing dir ${filename}\")\n                    }\n                }\n            }\n            if (found) finalParentResourceId = parentResourceId\n        }\n        return finalParentResourceId\n    }\n\n    @Override void move(String newLocation) {\n        EntityValue dbr = getDbResource(false)\n        // if the current resource doesn't exist, nothing to move\n        if (!dbr) {\n            logger.warn(\"Could not find dbresource at [${getPath()}]\")\n            return\n        }\n        if (!newLocation) throw new BaseArtifactException(\"No location specified, not moving resource at ${getLocation()}\")\n        // ResourceReference newRr = ecf.resource.getLocationReference(newLocation)\n        if (!newLocation.startsWith(locationPrefix))\n            throw new BaseArtifactException(\"Location [${newLocation}] is not a dbresource location, not moving resource at ${getLocation()}\")\n\n        List<String> filenameList = new ArrayList<>(Arrays.asList(newLocation.substring(locationPrefix.length()).split(\"/\")))\n        if (filenameList) {\n            String newFilename = filenameList.get(filenameList.size()-1)\n            filenameList.remove(filenameList.size()-1)\n            String parentResourceId = findDirectoryId(filenameList, true)\n\n            dbr.parentResourceId = parentResourceId\n            dbr.filename = newFilename\n            dbr.update()\n        }\n    }\n\n    @Override ResourceReference makeDirectory(String name) {\n        findDirectoryId([name], true)\n        return new DbResourceReference().init(\"${location}/${name}\", ecf)\n    }\n    @Override ResourceReference makeFile(String name) {\n        DbResourceReference newRef = (DbResourceReference) new DbResourceReference().init(\"${location}/${name}\", ecf)\n        newRef.putObject(null)\n        return newRef\n    }\n    @Override boolean delete() {\n        EntityValue dbr = getDbResource(false)\n        if (dbr == null) return false\n        if (dbr.isFile == \"Y\") {\n            EntityValue dbrf = getDbResourceFile()\n            if (dbrf != null) {\n                // first delete history records\n                dbrf.deleteRelated(\"histories\")\n                // then delete the file\n                dbrf.delete()\n            }\n        }\n        dbr.delete()\n        resourceId = null\n        return true\n    }\n\n    @Override boolean supportsVersion() { return true }\n    @Override Version getVersion(String versionName) {\n        String resourceId = getDbResourceId()\n        if (resourceId == null) return null\n        return makeVersion(ecf.entityFacade.find(\"moqui.resource.DbResourceFileHistory\")\n                .condition(\"resourceId\", resourceId).condition(\"versionName\", versionName)\n                .useCache(false).disableAuthz().one())\n    }\n    @Override Version getCurrentVersion() {\n        EntityValue dbrf = getDbResourceFile()\n        if (dbrf == null) return null\n        return getVersion((String) dbrf.versionName)\n    }\n    @Override Version getRootVersion() {\n        EntityValue dbrf = getDbResourceFile()\n        if (dbrf == null) return null\n        return getVersion((String) dbrf.rootVersionName)\n    }\n    @Override ArrayList<Version> getVersionHistory() {\n        String resourceId = getDbResourceId()\n        if (resourceId == null) return new ArrayList<>()\n        EntityList dbrfHistoryList = ecf.entityFacade.find(\"moqui.resource.DbResourceFileHistory\")\n                .condition(\"resourceId\", resourceId).orderBy(\"-versionDate\")\n                .useCache(false).disableAuthz().list()\n        int dbrfHistorySize = dbrfHistoryList.size()\n        ArrayList<Version> verList = new ArrayList<>(dbrfHistorySize)\n        for (int i = 0; i < dbrfHistorySize; i++) {\n            EntityValue dbrfHistory = dbrfHistoryList.get(i)\n            verList.add(makeVersion(dbrfHistory))\n        }\n        return verList\n    }\n    @Override ArrayList<Version> getNextVersions(String versionName) {\n        String resourceId = getDbResourceId()\n        if (resourceId == null) return new ArrayList<>()\n        EntityList dbrfHistoryList = ecf.entityFacade.find(\"moqui.resource.DbResourceFileHistory\")\n                .condition(\"resourceId\", resourceId).condition(\"previousVersionName\", versionName)\n                .useCache(false).disableAuthz().list()\n        int dbrfHistorySize = dbrfHistoryList.size()\n        ArrayList<Version> verList = new ArrayList<>(dbrfHistorySize)\n        for (int i = 0; i < dbrfHistorySize; i++) {\n            EntityValue dbrfHistory = dbrfHistoryList.get(i)\n            verList.add(makeVersion(dbrfHistory))\n        }\n        return verList\n    }\n    @Override InputStream openStream(String versionName) {\n        if (versionName == null || versionName.isEmpty()) return openStream()\n        EntityValue dbrfHistory = getDbResourceFileHistory(versionName)\n        if (dbrfHistory == null) return null\n        if (\"Y\".equals(dbrfHistory.isDiff)) {\n            // TODO if current version get full text from dbrf otherwise reconstruct from root merging in diffs as needed up to versionName\n            return null\n        } else {\n            SerialBlob fileData = dbrfHistory.getSerialBlob(\"fileData\")\n            if (fileData != null) {\n                return fileData.getBinaryStream()\n            } else {\n                // may be the current version with no fileData value in dbrfHistory\n                EntityValue dbrf = getDbResourceFile()\n                if (dbrf == null || !versionName.equals(dbrf.versionName)) return null\n                fileData = dbrf.getSerialBlob(\"fileData\")\n                if (fileData == null) return null\n                return fileData.getBinaryStream()\n            }\n        }\n    }\n    @Override String getText(String versionName) { return ObjectUtilities.getStreamText(openStream(versionName)) }\n\n    Version makeVersion(EntityValue dbrfHistory) {\n        if (dbrfHistory == null) return null\n        return new Version(this, (String) dbrfHistory.versionName, (String) dbrfHistory.previousVersionName,\n                (String) dbrfHistory.userId, (Timestamp) dbrfHistory.versionDate)\n    }\n    String getDbResourceId() {\n        if (resourceId != null) return resourceId\n\n        List<String> filenameList = new ArrayList<>(Arrays.asList(getPath().split(\"/\")))\n        String lastResourceId = null\n        for (String filename in filenameList) {\n            EntityValue curDbr = ecf.entityFacade.find(\"moqui.resource.DbResource\")\n                    .condition(\"parentResourceId\", lastResourceId)\n                    .condition(\"filename\", filename).useCache(true)\n                    .disableAuthz().one()\n            if (curDbr == null) return null\n            lastResourceId = curDbr.resourceId\n        }\n\n        resourceId = lastResourceId\n        return resourceId\n    }\n\n    EntityValue getDbResource(boolean useCache) {\n        String resourceId = getDbResourceId()\n        if (resourceId == null) return null\n        return ecf.entityFacade.fastFindOne(\"moqui.resource.DbResource\", useCache, true, resourceId)\n    }\n    EntityValue getDbResourceFile() {\n        String resourceId = getDbResourceId()\n        if (resourceId == null) return null\n        // don't cache this, can be big and will be cached below this as text if needed\n        return ecf.entityFacade.fastFindOne(\"moqui.resource.DbResourceFile\", false, true, resourceId)\n    }\n    EntityValue getDbResourceFileHistory(String versionName) {\n        if (versionName == null) return null\n        String resourceId = getDbResourceId()\n        if (resourceId == null) return null\n        // don't cache this, can be big and will be cached below this as text if needed\n        return ecf.entityFacade.fastFindOne(\"moqui.resource.DbResourceFileHistory\", false, true, resourceId, versionName)\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/reference/WrapperResourceReference.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context.reference\n\nimport groovy.transform.CompileStatic\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.resource.ResourceReference\n\n@CompileStatic\nabstract class WrapperResourceReference extends BaseResourceReference {\n    ResourceReference rr = null\n\n    WrapperResourceReference() { }\n\n    @Override\n    ResourceReference init(String location, ExecutionContextFactoryImpl ecf) {\n        this.ecf = ecf\n        return this\n    }\n    ResourceReference init(ResourceReference rr, ExecutionContextFactoryImpl ecf) {\n        this.rr = rr\n        this.ecf = ecf\n        return this\n    }\n\n    @Override abstract ResourceReference createNew(String location);\n\n    String getLocation() { return rr.getLocation() }\n\n    InputStream openStream() { return rr.openStream() }\n    OutputStream getOutputStream() { return rr.getOutputStream() }\n    String getText() { return rr.getText() }\n\n    boolean supportsAll() { return rr.supportsAll() }\n\n    boolean supportsUrl() { return rr.supportsUrl() }\n    URL getUrl() { return rr.getUrl() }\n\n    boolean supportsDirectory() { return rr.supportsDirectory() }\n    boolean isFile() { return rr.isFile() }\n    boolean isDirectory() { return rr.isDirectory() }\n    List<ResourceReference> getDirectoryEntries() { return rr.getDirectoryEntries() }\n\n    boolean supportsExists() { return rr.supportsExists() }\n    boolean getExists() { return rr.getExists()}\n\n    boolean supportsLastModified() { return rr.supportsLastModified() }\n    long getLastModified() { return rr.getLastModified() }\n\n    boolean supportsSize() { return rr.supportsSize() }\n    long getSize() { return rr.getSize() }\n\n    boolean supportsWrite() { return rr.supportsWrite() }\n    void putText(String text) { rr.putText(text) }\n    void putStream(InputStream stream) { rr.putStream(stream) }\n    void move(String newLocation) { rr.move(newLocation) }\n    ResourceReference makeDirectory(String name) { return rr.makeDirectory(name) }\n    ResourceReference makeFile(String name) { return rr.makeFile(name) }\n    boolean delete() { return rr.delete() }\n\n    void destroy() { rr.destroy() }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/renderer/FtlMarkdownTemplateRenderer.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context.renderer\n\nimport freemarker.template.Template\nimport groovy.transform.CompileStatic\nimport org.moqui.BaseArtifactException\nimport org.moqui.context.ExecutionContextFactory\nimport org.moqui.resource.ResourceReference\nimport org.moqui.context.TemplateRenderer\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.jcache.MCache\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport javax.cache.Cache\n\n@CompileStatic\nclass FtlMarkdownTemplateRenderer implements TemplateRenderer {\n    protected final static Logger logger = LoggerFactory.getLogger(FtlMarkdownTemplateRenderer.class)\n\n    protected ExecutionContextFactoryImpl ecfi\n    protected Cache<String, Template> templateFtlLocationCache\n\n    FtlMarkdownTemplateRenderer() { }\n\n    TemplateRenderer init(ExecutionContextFactory ecf) {\n        this.ecfi = (ExecutionContextFactoryImpl) ecf\n        this.templateFtlLocationCache = ecfi.cacheFacade.getCache(\"resource.ftl.location\", String.class, Template.class)\n        return this\n    }\n\n    void render(String location, Writer writer) {\n        boolean hasVersion = location.indexOf(\"#\") > 0\n        Template theTemplate = null\n        if (!hasVersion) {\n            if (templateFtlLocationCache instanceof MCache) {\n                MCache<String, Template> mCache = (MCache) templateFtlLocationCache\n                ResourceReference rr = ecfi.resourceFacade.getLocationReference(location)\n                long lastModified = rr != null ? rr.getLastModified() : 0L\n                theTemplate = mCache.get(location, lastModified)\n            } else {\n                // TODO: doesn't support on the fly reloading without cache expire/clear!\n                theTemplate = templateFtlLocationCache.get(location)\n            }\n        }\n        if (theTemplate == null) theTemplate = makeTemplate(location, hasVersion)\n        if (theTemplate == null) throw new BaseArtifactException(\"Could not find template at ${location}\")\n        theTemplate.createProcessingEnvironment(ecfi.getEci().contextStack, writer).process()\n    }\n\n    protected Template makeTemplate(String location, boolean hasVersion) {\n        if (!hasVersion) {\n            Template theTemplate = (Template) templateFtlLocationCache.get(location)\n            if (theTemplate != null) return theTemplate\n        }\n\n        Template newTemplate\n        try {\n            //ScreenRenderImpl sri = (ScreenRenderImpl) ecfi.getExecutionContext().getContext().get(\"sri\")\n            // how to set base URL? if (sri != null) builder.setBase(sri.getBaseLinkUri())\n            /*\n            Markdown4jProcessor markdown4jProcessor = new Markdown4jProcessor()\n            String mdText = markdown4jProcessor.process(ecfi.resourceFacade.getLocationText(location, false))\n            PegDownProcessor pdp = new PegDownProcessor(MarkdownTemplateRenderer.pegDownOptions)\n            String mdText = pdp.markdownToHtml(ecfi.resourceFacade.getLocationText(location, false))\n            */\n\n            com.vladsch.flexmark.util.ast.Node document = MarkdownTemplateRenderer.PARSER.parse(ecfi.resourceFacade.getLocationText(location, false))\n            String mdText = MarkdownTemplateRenderer.RENDERER.render(document)\n\n            // logger.warn(\"======== .md.ftl post-markdown text: ${mdText}\")\n\n            Reader templateReader = new StringReader(mdText)\n            newTemplate = new Template(location, templateReader, ecfi.resourceFacade.ftlTemplateRenderer.getFtlConfiguration())\n        } catch (Exception e) {\n            throw new BaseArtifactException(\"Error while initializing template at [${location}]\", e)\n        }\n\n        if (!hasVersion && newTemplate != null) templateFtlLocationCache.put(location, newTemplate)\n        return newTemplate\n    }\n\n    String stripTemplateExtension(String fileName) {\n        String stripped = fileName.contains(\".md\") ? fileName.replace(\".md\", \"\") : fileName\n        stripped = stripped.contains(\".markdown\") ? stripped.replace(\".markdown\", \"\") : stripped\n        return stripped.contains(\".ftl\") ? stripped.replace(\".ftl\", \"\") : stripped\n    }\n\n    void destroy() { }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/renderer/FtlTemplateRenderer.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context.renderer;\n\nimport freemarker.core.Environment;\nimport freemarker.core.InvalidReferenceException;\nimport freemarker.ext.beans.BeansWrapper;\nimport freemarker.ext.beans.BeansWrapperBuilder;\nimport freemarker.template.*;\nimport groovy.transform.CompileStatic;\n\nimport org.moqui.BaseArtifactException;\nimport org.moqui.BaseException;\nimport org.moqui.context.ExecutionContextFactory;\nimport org.moqui.resource.ResourceReference;\nimport org.moqui.context.TemplateRenderer;\nimport org.moqui.impl.context.ExecutionContextFactoryImpl;\nimport org.moqui.jcache.MCache;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport javax.cache.Cache;\nimport java.io.*;\nimport java.nio.charset.StandardCharsets;\nimport java.util.Locale;\n\n@CompileStatic\npublic class FtlTemplateRenderer implements TemplateRenderer {\n    public static final Version FTL_VERSION = Configuration.VERSION_2_3_34;\n    private static final Logger logger = LoggerFactory.getLogger(FtlTemplateRenderer.class);\n\n    protected ExecutionContextFactoryImpl ecfi;\n    private Configuration defaultFtlConfiguration;\n    private Cache<String, Template> templateFtlLocationCache;\n\n    public FtlTemplateRenderer() { }\n\n    @SuppressWarnings(\"unchecked\")\n    public TemplateRenderer init(ExecutionContextFactory ecf) {\n        this.ecfi = (ExecutionContextFactoryImpl) ecf;\n        defaultFtlConfiguration = makeFtlConfiguration(ecfi);\n        templateFtlLocationCache = ecfi.cacheFacade.getCache(\"resource.ftl.location\", String.class, Template.class);\n        return this;\n    }\n\n    public void render(String location, Writer writer) {\n        Template theTemplate = getFtlTemplateByLocation(location);\n        try {\n            theTemplate.createProcessingEnvironment(ecfi.getEci().contextStack, writer).process();\n        } catch (Exception e) { throw new BaseArtifactException(\"Error rendering template at \" + location, e); }\n    }\n    public String stripTemplateExtension(String fileName) { return fileName.contains(\".ftl\") ? fileName.replace(\".ftl\", \"\") : fileName; }\n\n    public void destroy() { }\n\n    @SuppressWarnings(\"unchecked\")\n    private Template getFtlTemplateByLocation(final String location) {\n        boolean hasVersion = location.indexOf(\"#\") > 0;\n        Template theTemplate = null;\n        if (!hasVersion) {\n            if (templateFtlLocationCache instanceof MCache) {\n                MCache<String, Template> mCache = (MCache) templateFtlLocationCache;\n                ResourceReference rr = ecfi.resourceFacade.getLocationReference(location);\n                // if we have a rr and last modified is newer than the cache entry then throw it out (expire when cached entry\n                //     updated time is older/less than rr.lastModified)\n                long lastModified = rr != null ? rr.getLastModified() : 0L;\n                theTemplate = mCache.get(location, lastModified);\n            } else {\n                // TODO: doesn't support on the fly reloading without cache expire/clear!\n                theTemplate = templateFtlLocationCache.get(location);\n            }\n        }\n        if (theTemplate == null) theTemplate = makeTemplate(location, hasVersion);\n        if (theTemplate == null) throw new BaseArtifactException(\"Could not find template at \" + location);\n        return theTemplate;\n    }\n\n    private Template makeTemplate(final String location, boolean hasVersion) {\n        if (!hasVersion) {\n            Template theTemplate = templateFtlLocationCache.get(location);\n            if (theTemplate != null) return theTemplate;\n        }\n\n        Template newTemplate;\n        Reader templateReader = null;\n\n        InputStream is = ecfi.resourceFacade.getLocationStream(location);\n        if (is == null) throw new BaseArtifactException(\"Template not found at \" + location);\n\n        try {\n            templateReader = new InputStreamReader(is, StandardCharsets.UTF_8);\n            newTemplate = new Template(location, templateReader, getFtlConfiguration());\n        } catch (Exception e) {\n            throw new BaseArtifactException(\"Error while initializing template at \" + location, e);\n        } finally {\n            if (templateReader != null) {\n                try { templateReader.close(); }\n                catch (Exception e) { logger.error(\"Error closing template reader\", e); }\n            }\n        }\n\n        if (!hasVersion) templateFtlLocationCache.put(location, newTemplate);\n        return newTemplate;\n    }\n\n    public Configuration getFtlConfiguration() { return defaultFtlConfiguration; }\n\n    private static Configuration makeFtlConfiguration(ExecutionContextFactoryImpl ecfi) {\n        Configuration newConfig = new MoquiConfiguration(FTL_VERSION, ecfi);\n        BeansWrapper defaultWrapper = new BeansWrapperBuilder(FTL_VERSION).build();\n        newConfig.setObjectWrapper(defaultWrapper);\n        newConfig.setSharedVariable(\"Static\", defaultWrapper.getStaticModels());\n\n        /* not needed, using getTemplate override instead:\n        newConfig.setCacheStorage(new NullCacheStorage())\n        newConfig.setTemplateUpdateDelay(1)\n        newConfig.setTemplateLoader(new MoquiTemplateLoader(ecfi))\n        newConfig.setLocalizedLookup(false)\n        */\n        /*\n        String moquiRuntime = System.getProperty(\"moqui.runtime\");\n        if (moquiRuntime != null && !moquiRuntime.isEmpty()) {\n            File runtimeFile = new File(moquiRuntime);\n            try {\n                newConfig.setDirectoryForTemplateLoading(runtimeFile);\n            } catch (Exception e) {\n                logger.error(\"Error setting FTL template loading directory to \" + moquiRuntime, e);\n            }\n        }\n        */\n        newConfig.setTemplateExceptionHandler(new MoquiTemplateExceptionHandler());\n        newConfig.setLogTemplateExceptions(false);\n        newConfig.setWhitespaceStripping(true);\n        newConfig.setDefaultEncoding(\"UTF-8\");\n        return newConfig;\n    }\n\n    private static class MoquiConfiguration extends Configuration {\n        private ExecutionContextFactoryImpl ecfi;\n        MoquiConfiguration(Version version, ExecutionContextFactoryImpl ecfi) {\n            super(version);\n            this.ecfi = ecfi;\n        }\n\n        @Override\n        public Template getTemplate(String name, Locale locale, Object customLookupCondition, String encoding,\n                                    boolean parseAsFTL, boolean ignoreMissing) throws IOException {\n            //return super.getTemplate(name, locale, encoding, parse)\n            // NOTE: doing this because template loading behavior with cache/etc not desired and was having issues\n            Template theTemplate;\n            if (parseAsFTL) {\n                theTemplate = ecfi.resourceFacade.getFtlTemplateRenderer().getFtlTemplateByLocation(name);\n            } else {\n                String text = ecfi.resourceFacade.getLocationText(name, true);\n                theTemplate = Template.getPlainTextTemplate(name, text, this);\n            }\n\n            // NOTE: this is the same exception the standard FreeMarker code returns\n            if (theTemplate == null && !ignoreMissing) throw new FileNotFoundException(\"Template \" + name + \" not found.\");\n            return theTemplate;\n        }\n\n        public ExecutionContextFactoryImpl getEcfi() { return ecfi; }\n        public void setEcfi(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi; }\n    }\n\n    /*\n    private static class MoquiTemplateLoader implements TemplateLoader {\n        private ExecutionContextFactoryImpl ecfi;\n        MoquiTemplateLoader(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi; }\n        @Override public Object findTemplateSource(String name) throws IOException { return ecfi.resourceFacade.getLocationReference(name); }\n        @Override public long getLastModified(Object templateSource) { if (templateSource instanceof ResourceReference) { return ((ResourceReference) templateSource).getLastModified(); } else { return 0; } }\n        @Override public Reader getReader(Object templateSource, String encoding) throws IOException {\n            if (!(templateSource instanceof ResourceReference))\n                throw new IllegalArgumentException(\"Cannot get Reader, templateSource is not a ResourceReference\");\n            ResourceReference rr = (ResourceReference) templateSource;\n            InputStream is = rr.openStream();\n            if (is == null) throw new IOException(\"Template not found at \" + rr.getLocation());\n            return new InputStreamReader(is);\n        }\n        @Override public void closeTemplateSource(Object templateSource) throws IOException { }\n    }\n    */\n\n    private static class MoquiTemplateExceptionHandler implements TemplateExceptionHandler {\n        public void handleTemplateException(final TemplateException te, Environment env, Writer out) throws TemplateException {\n            try {\n                // TODO: encode error, something like: StringUtil.SimpleEncoder simpleEncoder = FreeMarkerWorker.getWrappedObject(\"simpleEncoder\", env);\n                // stackTrace = simpleEncoder.encode(stackTrace);\n                if (te.getCause() != null) {\n                    BaseException.filterStackTrace(te.getCause());\n                    logger.error(\"Error from code called in FTL render\", te.getCause());\n                    // NOTE: ScreenTestImpl looks for this string, ie \"[Template Error\"\n                    String causeMsg = te.getCause().getMessage();\n                    if (causeMsg == null || causeMsg.isEmpty()) causeMsg = te.getMessage();\n                    if (causeMsg == null || causeMsg.isEmpty()) causeMsg = \"no message available\";\n                    out.write(\"[Template Error: \");\n                    out.write(causeMsg);\n                    out.write(\"]\");\n                } else {\n                    // NOTE: if there is not cause it is an exception generated by FreeMarker and not some code called in the template\n                    if (te instanceof InvalidReferenceException) {\n                        // NOTE: ScreenTestImpl looks for this string, ie \"[Template Error\"\n                        logger.error(\"[Template Error: expression '\" + te.getBlamedExpressionString() + \"' was null or not found (\" + te.getTemplateSourceName() + \":\" + te.getLineNumber() + \",\" + te.getColumnNumber() + \")]\");\n                        out.write(\"[Template Error]\");\n                    } else {\n                        BaseException.filterStackTrace(te);\n                        logger.error(\"Error from FTL in render\", te);\n                        // NOTE: ScreenTestImpl looks for this string, ie \"[Template Error\"\n                        out.write(\"[Template Error: \");\n                        out.write(te.getMessage());\n                        out.write(\"]\");\n                    }\n                }\n            } catch (IOException e) {\n                throw new TemplateException(\"Failed to print error message. Cause: \" + e, env);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/renderer/GStringTemplateRenderer.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context.renderer\n\nimport groovy.text.GStringTemplateEngine\nimport groovy.text.Template\nimport groovy.transform.CompileStatic\nimport org.moqui.BaseArtifactException\nimport org.moqui.context.ExecutionContextFactory\nimport org.moqui.resource.ResourceReference\nimport org.moqui.context.TemplateRenderer\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.jcache.MCache\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport javax.cache.Cache\n\n@CompileStatic\nclass GStringTemplateRenderer implements TemplateRenderer {\n    protected final static Logger logger = LoggerFactory.getLogger(GStringTemplateRenderer.class)\n\n    protected ExecutionContextFactoryImpl ecfi\n    protected Cache<String, Template> templateGStringLocationCache\n\n    GStringTemplateRenderer() { }\n\n    TemplateRenderer init(ExecutionContextFactory ecf) {\n        this.ecfi = (ExecutionContextFactoryImpl) ecf\n        this.templateGStringLocationCache = ecfi.cacheFacade.getCache(\"resource.gstring.location\", String.class, Template.class)\n        return this\n    }\n\n    void render(String location, Writer writer) {\n        Template theTemplate = getGStringTemplateByLocation(location)\n        Writable writable = theTemplate.make(ecfi.executionContext.context)\n        writable.writeTo(writer)\n    }\n\n    String stripTemplateExtension(String fileName) {\n        return fileName.contains(\".gstring\") ? fileName.replace(\".gstring\", \"\") : fileName\n    }\n\n    void destroy() { }\n\n    Template getGStringTemplateByLocation(String location) {\n        Template theTemplate;\n        if (templateGStringLocationCache instanceof MCache) {\n            MCache<String, Template> mCache = (MCache) templateGStringLocationCache;\n            ResourceReference rr = ecfi.resourceFacade.getLocationReference(location);\n            long lastModified = rr != null ? rr.getLastModified() : 0L;\n            theTemplate = mCache.get(location, lastModified);\n        } else {\n            // TODO: doesn't support on the fly reloading without cache expire/clear!\n            theTemplate = templateGStringLocationCache.get(location);\n        }\n        if (!theTemplate) theTemplate = makeGStringTemplate(location)\n        if (!theTemplate) throw new BaseArtifactException(\"Could not find template at [${location}]\")\n        return theTemplate\n    }\n    protected Template makeGStringTemplate(String location) {\n        Template theTemplate = (Template) templateGStringLocationCache.get(location)\n        if (theTemplate) return theTemplate\n\n        Template newTemplate = null\n        Reader templateReader = null\n        try {\n            templateReader = new InputStreamReader(ecfi.resourceFacade.getLocationStream(location))\n            GStringTemplateEngine gste = new GStringTemplateEngine()\n            newTemplate = gste.createTemplate(templateReader)\n        } catch (Exception e) {\n            throw new BaseArtifactException(\"Error while initializing template at [${location}]\", e)\n        } finally {\n            if (templateReader != null) templateReader.close()\n        }\n\n        if (newTemplate) templateGStringLocationCache.put(location, newTemplate)\n        return newTemplate\n    }\n\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/renderer/MarkdownTemplateRenderer.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context.renderer\n\nimport com.vladsch.flexmark.ext.tables.TablesExtension\nimport com.vladsch.flexmark.ext.toc.TocExtension\nimport com.vladsch.flexmark.html.HtmlRenderer\nimport com.vladsch.flexmark.parser.Parser\nimport com.vladsch.flexmark.util.ast.KeepType\nimport com.vladsch.flexmark.util.data.MutableDataHolder\nimport com.vladsch.flexmark.util.data.MutableDataSet\nimport groovy.transform.CompileStatic\nimport org.moqui.context.ExecutionContextFactory\nimport org.moqui.resource.ResourceReference\nimport org.moqui.context.TemplateRenderer\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.jcache.MCache\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport javax.cache.Cache\n\n@CompileStatic\nclass MarkdownTemplateRenderer implements TemplateRenderer {\n    protected final static Logger logger = LoggerFactory.getLogger(MarkdownTemplateRenderer.class)\n\n    // ALL_WITH_OPTIONALS includes SMARTS and QUOTES so XOR them to remove them\n    // final static int pegDownOptions = Extensions.ALL_WITH_OPTIONALS ^ Extensions.SMARTS ^ Extensions.QUOTES\n\n    static final MutableDataHolder OPTIONS = new MutableDataSet()\n            .set(Parser.REFERENCES_KEEP, KeepType.LAST)\n            .set(Parser.SPACE_IN_LINK_URLS, true)\n            .set(HtmlRenderer.INDENT_SIZE, 2)\n            .set(HtmlRenderer.PERCENT_ENCODE_URLS, true)\n            // for full GitHub Flavored Markdown table compatibility add the following table extension options:\n            .set(TablesExtension.COLUMN_SPANS, false)\n            .set(TablesExtension.APPEND_MISSING_COLUMNS, true)\n            .set(TablesExtension.DISCARD_EXTRA_COLUMNS, true)\n            .set(TablesExtension.HEADER_SEPARATOR_COLUMN_MATCH, true)\n            .set(Parser.EXTENSIONS, (Iterable) Arrays.asList(TablesExtension.create(), TocExtension.create()))\n    static final Parser PARSER = Parser.builder(OPTIONS).build()\n    static final HtmlRenderer RENDERER = HtmlRenderer.builder(OPTIONS).build()\n\n    protected ExecutionContextFactoryImpl ecfi\n    protected Cache<String, String> templateMarkdownLocationCache\n\n    MarkdownTemplateRenderer() { }\n\n    TemplateRenderer init(ExecutionContextFactory ecf) {\n        this.ecfi = (ExecutionContextFactoryImpl) ecf\n        this.templateMarkdownLocationCache = ecfi.cacheFacade.getCache(\"resource.markdown.location\")\n        return this\n    }\n\n    void render(String location, Writer writer) {\n        boolean hasVersion = location.indexOf(\"#\") > 0\n        String mdText\n        if (!hasVersion) {\n            if (templateMarkdownLocationCache instanceof MCache) {\n                MCache<String, String> mCache = (MCache) templateMarkdownLocationCache\n                ResourceReference rr = ecfi.resourceFacade.getLocationReference(location)\n                long lastModified = rr != null ? rr.getLastModified() : 0L\n                mdText = (String) mCache.get(location, lastModified)\n            } else {\n                // TODO: doesn't support on the fly reloading without cache expire/clear!\n                mdText = (String) templateMarkdownLocationCache.get(location)\n            }\n            if (mdText != null && !mdText.isEmpty()) {\n                writer.write(mdText)\n                return\n            }\n        }\n\n        String sourceText = ecfi.resourceFacade.getLocationText(location, false)\n        if (sourceText == null || sourceText.isEmpty()) {\n            logger.warn(\"In Markdown template render got no text from location ${location}\")\n            return\n        }\n\n        //ScreenRenderImpl sri = (ScreenRenderImpl) ecfi.getExecutionContext().getContext().get(\"sri\")\n        // how to set base URL? if (sri != null) builder.setBase(sri.getBaseLinkUri())\n        /*\n        Markdown4jProcessor markdown4jProcessor = new Markdown4jProcessor()\n        mdText = markdown4jProcessor.process(sourceText)\n\n        PegDownProcessor pdp = new PegDownProcessor(pegDownOptions)\n        mdText = pdp.markdownToHtml(sourceText)\n        */\n\n        com.vladsch.flexmark.util.ast.Node document = PARSER.parse(sourceText)\n        mdText = RENDERER.render(document)\n\n        // logger.warn(\"==== render md at ${location} version ${hasVersion} sourceText ${sourceText.length() > 100 ? sourceText.substring(0, 100) : sourceText}\\nmdText ${mdText.length() > 100 ? mdText.substring(0, 100) : mdText}\")\n        if (mdText != null && !mdText.isEmpty()) {\n            if (!hasVersion) templateMarkdownLocationCache.put(location, mdText)\n            writer.write(mdText)\n        }\n    }\n\n    String stripTemplateExtension(String fileName) {\n        if (fileName.contains(\".md\")) return fileName.replace(\".md\", \"\")\n        else if (fileName.contains(\".markdown\")) return fileName.replace(\".markdown\", \"\")\n        else return fileName\n    }\n\n    void destroy() { }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/renderer/NoTemplateRenderer.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context.renderer\n\nimport groovy.transform.CompileStatic\nimport org.moqui.context.TemplateRenderer\nimport org.moqui.context.ExecutionContextFactory\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\n\n@CompileStatic\nclass NoTemplateRenderer implements TemplateRenderer {\n    protected ExecutionContextFactoryImpl ecfi\n\n    NoTemplateRenderer() { }\n\n    TemplateRenderer init(ExecutionContextFactory ecf) {\n        this.ecfi = (ExecutionContextFactoryImpl) ecf\n        return this\n    }\n\n    void render(String location, Writer writer) {\n        String text = ecfi.resourceFacade.getLocationText(location, true)\n        if (text) writer.write(text)\n    }\n\n    String stripTemplateExtension(String fileName) { return fileName }\n\n    void destroy() { }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/runner/GroovyScriptRunner.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context.runner\n\nimport groovy.transform.CompileStatic\nimport org.codehaus.groovy.runtime.InvokerHelper\n\nimport org.moqui.context.ExecutionContext\nimport org.moqui.context.ExecutionContextFactory\nimport org.moqui.context.ScriptRunner\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.util.StringUtilities\n\nimport javax.cache.Cache\n\n@CompileStatic\nclass GroovyScriptRunner implements ScriptRunner {\n    private ExecutionContextFactoryImpl ecfi\n    private Cache<String, Class> scriptGroovyLocationCache\n\n    GroovyScriptRunner() { }\n\n    @Override\n    ScriptRunner init(ExecutionContextFactory ecf) {\n        this.ecfi = (ExecutionContextFactoryImpl) ecf\n        this.scriptGroovyLocationCache = ecfi.cacheFacade.getCache(\"resource.groovy.location\", String.class, Class.class)\n        return this\n    }\n\n    @Override\n    Object run(String location, String method, ExecutionContext ec) {\n        Script script = InvokerHelper.createScript(getGroovyByLocation(location), ec.contextBinding)\n        Object result\n        if (method != null && !method.isEmpty()) {\n            result = script.invokeMethod(method, null)\n        } else {\n            result = script.run()\n        }\n        return result\n    }\n\n    @Override\n    void destroy() { }\n\n    Class getGroovyByLocation(String location) {\n        Class gc = (Class) scriptGroovyLocationCache.get(location)\n        if (gc == null) gc = loadGroovy(location)\n        return gc\n    }\n    private synchronized Class loadGroovy(String location) {\n        Class gc = (Class) scriptGroovyLocationCache.get(location)\n        if (gc == null) {\n            String groovyText = ecfi.resourceFacade.getLocationText(location, false)\n            gc = ecfi.compileGroovy(groovyText, StringUtilities.cleanStringForJavaName(location))\n            scriptGroovyLocationCache.put(location, gc)\n        }\n        return gc\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/runner/JavaxScriptRunner.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context.runner\n\nimport groovy.transform.CompileStatic\nimport org.moqui.BaseException\nimport org.moqui.context.ExecutionContext\nimport org.moqui.context.ExecutionContextFactory\nimport org.moqui.context.ScriptRunner\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\n\nimport javax.cache.Cache\nimport javax.script.Bindings\nimport javax.script.Compilable\nimport javax.script.CompiledScript\nimport javax.script.SimpleBindings\nimport javax.script.ScriptEngine\nimport javax.script.ScriptEngineManager\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n@CompileStatic\nclass JavaxScriptRunner implements ScriptRunner {\n    protected final static Logger logger = LoggerFactory.getLogger(JavaxScriptRunner.class)\n\n    protected ScriptEngineManager mgr = new ScriptEngineManager();\n\n    protected ExecutionContextFactoryImpl ecfi\n    protected Cache scriptLocationCache\n    protected String engineName\n\n    JavaxScriptRunner() { this.engineName = \"groovy\" }\n    JavaxScriptRunner(String engineName) { this.engineName = engineName }\n\n    ScriptRunner init(ExecutionContextFactory ecf) {\n        this.ecfi = (ExecutionContextFactoryImpl) ecf\n        this.scriptLocationCache = ecfi.cacheFacade.getCache(\"resource.${engineName}.location\")\n        return this\n    }\n\n    Object run(String location, String method, ExecutionContext ec) {\n        // this doesn't support methods, so if passed warn about that\n        if (method) logger.warn(\"Tried to invoke script at [${location}] with method [${method}] through javax.script (JSR-223) runner which does NOT support methods, so it is being ignored.\", new BaseException(\"Script Run Location\"))\n\n        ScriptEngine engine = mgr.getEngineByName(engineName)\n        return bindAndRun(location, ec, engine, scriptLocationCache)\n    }\n\n    void destroy() { }\n\n    static Object bindAndRun(String location, ExecutionContext ec, ScriptEngine engine, Cache scriptLocationCache) {\n        Bindings bindings = new SimpleBindings()\n        for (Map.Entry ce in ec.getContext().entrySet()) bindings.put((String) ce.getKey(), ce.getValue())\n\n        Object result\n        if (engine instanceof Compilable) {\n            // cache the CompiledScript\n            CompiledScript script = (CompiledScript) scriptLocationCache.get(location)\n            if (script == null) {\n                script = engine.compile(ec.getResource().getLocationText(location, false))\n                scriptLocationCache.put(location, script)\n            }\n            result = script.eval(bindings)\n        } else {\n            // cache the script text\n            String scriptText = (String) scriptLocationCache.get(location)\n            if (scriptText == null) {\n                scriptText = ec.getResource().getLocationText(location, false)\n                scriptLocationCache.put(location, scriptText)\n            }\n            result = engine.eval(scriptText, bindings)\n        }\n\n        return result\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/context/runner/XmlActionsScriptRunner.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.context.runner\n\nimport freemarker.template.Template\nimport groovy.transform.CompileStatic\nimport org.moqui.context.ExecutionContextFactory\nimport org.moqui.context.ScriptRunner\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.actions.XmlAction\nimport org.moqui.context.ExecutionContext\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport javax.cache.Cache\n\n@CompileStatic\nclass XmlActionsScriptRunner implements ScriptRunner {\n    protected final static Logger logger = LoggerFactory.getLogger(XmlActionsScriptRunner.class)\n\n    protected ExecutionContextFactoryImpl ecfi\n    protected Cache<String, XmlAction> scriptXmlActionLocationCache\n    protected Template xmlActionsTemplate = null\n\n    XmlActionsScriptRunner() { }\n\n    ScriptRunner init(ExecutionContextFactory ecf) {\n        this.ecfi = (ExecutionContextFactoryImpl) ecf\n        this.scriptXmlActionLocationCache = ecfi.cacheFacade.getCache(\"resource.xml-actions.location\", String.class, XmlAction.class)\n        return this\n    }\n\n    Object run(String location, String method, ExecutionContext ec) {\n        XmlAction xa = getXmlActionByLocation(location)\n        return xa.run((ExecutionContextImpl) ec)\n    }\n\n    void destroy() { }\n\n    XmlAction getXmlActionByLocation(String location) {\n        XmlAction xa = (XmlAction) scriptXmlActionLocationCache.get(location)\n        if (xa == null) xa = loadXmlAction(location)\n        return xa\n    }\n    protected synchronized XmlAction loadXmlAction(String location) {\n        XmlAction xa = (XmlAction) scriptXmlActionLocationCache.get(location)\n        if (xa == null) {\n            xa = new XmlAction(ecfi, ecfi.resourceFacade.getLocationText(location, false), location)\n            scriptXmlActionLocationCache.put(location, xa)\n        }\n        return xa\n    }\n\n    Template getXmlActionsTemplate() {\n        if (xmlActionsTemplate == null) makeXmlActionsTemplate()\n        return xmlActionsTemplate\n    }\n    protected synchronized void makeXmlActionsTemplate() {\n        if (xmlActionsTemplate != null) return\n\n        String templateLocation = ecfi.confXmlRoot.first(\"resource-facade\").attribute(\"xml-actions-template-location\")\n        Template newTemplate = null\n        Reader templateReader = null\n        try {\n            templateReader = new InputStreamReader(ecfi.resourceFacade.getLocationStream(templateLocation))\n            newTemplate = new Template(templateLocation, templateReader,\n                    ecfi.resourceFacade.ftlTemplateRenderer.getFtlConfiguration())\n        } catch (Exception e) {\n            logger.error(\"Error while initializing XMLActions template at [${templateLocation}]\", e)\n        } finally {\n            if (templateReader != null) templateReader.close()\n        }\n        xmlActionsTemplate = newTemplate\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/AggregationUtil.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity;\n\nimport groovy.lang.MissingPropertyException;\nimport groovy.lang.Script;\nimport org.codehaus.groovy.runtime.DefaultGroovyMethods;\nimport org.codehaus.groovy.runtime.InvokerHelper;\nimport org.moqui.BaseArtifactException;\nimport org.moqui.entity.EntityValue;\nimport org.moqui.impl.actions.XmlAction;\nimport org.moqui.impl.context.ExecutionContextImpl;\nimport org.moqui.util.ContextStack;\nimport org.moqui.util.ObjectUtilities;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.util.*;\n\npublic class AggregationUtil {\n    protected final static Logger logger = LoggerFactory.getLogger(AggregationUtil.class);\n    protected final static boolean isTraceEnabled = logger.isTraceEnabled();\n\n    public enum AggregateFunction { MIN, MAX, SUM, AVG, COUNT, FIRST, LAST }\n    private static final BigDecimal BIG_DECIMAL_TWO = new BigDecimal(2);\n\n    public static class AggregateField {\n        public final String fieldName;\n        public final AggregateFunction function;\n        public final AggregateFunction showTotal;\n        public final boolean groupBy, subList;\n        public final Class fromExpr;\n        public AggregateField(String fn, AggregateFunction func, boolean gb, boolean sl, String st, Class from) {\n            if (\"false\".equals(st)) st = null;\n            fieldName = fn; function = func; groupBy = gb; subList = sl; fromExpr = from;\n            showTotal = st != null ? AggregateFunction.valueOf(st.toUpperCase()) : null;\n        }\n    }\n\n    private String listName, listEntryName;\n    private AggregateField[] aggregateFields;\n    private boolean hasFromExpr = false;\n    private boolean hasSubListTotals = false;\n    private String[] groupFields;\n    private XmlAction rowActions;\n\n    public AggregationUtil(String listName, String listEntryName, AggregateField[] aggregateFields, String[] groupFields, XmlAction rowActions) {\n        this.listName = listName;\n        this.listEntryName = listEntryName;\n        if (this.listEntryName != null && this.listEntryName.isEmpty()) this.listEntryName = null;\n        this.aggregateFields = aggregateFields;\n        this.groupFields = groupFields;\n        this.rowActions = rowActions;\n        for (int i = 0; i < aggregateFields.length; i++) {\n            AggregateField aggField = aggregateFields[i];\n            if (aggField.fromExpr != null) hasFromExpr = true;\n            if (aggField.subList && aggField.showTotal != null) hasSubListTotals = true;\n        }\n    }\n\n\n    @SuppressWarnings(\"unchecked\")\n    public ArrayList<Map<String, Object>> aggregateList(Object listObj, Set<String> includeFields, boolean makeSubList, ExecutionContextImpl eci) {\n        if (groupFields == null || groupFields.length == 0) makeSubList = false;\n        ArrayList<Map<String, Object>> resultList = new ArrayList<>();\n        if (listObj == null) return resultList;\n\n        // for (Object result : (Iterable) listObj) logger.warn(\"Aggregate Input: \" + result.toString());\n\n        long startTime = System.currentTimeMillis();\n        Map<Map<String, Object>, Map<String, Object>> groupRows = new HashMap<>();\n        Map<String, Object> totalsMap = new HashMap<>();\n        int originalCount = 0;\n        if (listObj instanceof List) {\n            List listList = (List) listObj;\n            int listSize = listList.size();\n            if (listObj instanceof RandomAccess) {\n                for (int i = 0; i < listSize; i++) {\n                    Object curObject = listList.get(i);\n                    processAggregateOriginal(curObject, resultList, includeFields, groupRows, totalsMap, i, (i < (listSize - 1)), makeSubList, eci);\n                    originalCount++;\n                }\n            } else {\n                int i = 0;\n                for (Object curObject : listList) {\n                    processAggregateOriginal(curObject, resultList, includeFields, groupRows, totalsMap, i, (i < (listSize - 1)), makeSubList, eci);\n                    i++;\n                    originalCount++;\n                }\n            }\n        } else if (listObj instanceof Map) {\n            Iterator listIter = (Iterator) ((Map<?, ?>) listObj).entrySet().iterator();\n            int i = 0;\n            while (listIter.hasNext()) {\n                Object curObject = listIter.next();\n                processAggregateOriginal(curObject, resultList, includeFields, groupRows, totalsMap, i, listIter.hasNext(), makeSubList, eci);\n                i++;\n                originalCount++;\n            }\n        } else if (listObj instanceof Iterator) {\n            Iterator listIter = (Iterator) listObj;\n            int i = 0;\n            while (listIter.hasNext()) {\n                Object curObject = listIter.next();\n                processAggregateOriginal(curObject, resultList, includeFields, groupRows, totalsMap, i, listIter.hasNext(), makeSubList, eci);\n                i++;\n                originalCount++;\n            }\n        } else if (listObj.getClass().isArray()) {\n            Object[] listArray = (Object[]) listObj;\n            int listSize = listArray.length;\n            for (int i = 0; i < listSize; i++) {\n                Object curObject = listArray[i];\n                processAggregateOriginal(curObject, resultList, includeFields, groupRows, totalsMap, i, (i < (listSize - 1)), makeSubList, eci);\n                originalCount++;\n            }\n        } else {\n            throw new BaseArtifactException(\"form-list list \" + listName + \" is a type we don't know how to iterate: \" + listObj.getClass().getName());\n        }\n\n        if (hasSubListTotals) {\n            int resultSize = resultList.size();\n            for (int i = 0; i < resultSize; i++) {\n                Map<String, Object> resultMap = resultList.get(i);\n                ArrayList aggregateSubList = (ArrayList) resultMap.get(\"aggregateSubList\");\n                if (aggregateSubList != null) {\n                    Map aggregateSubListTotals = (Map) resultMap.get(\"aggregateSubListTotals\");\n                    if (aggregateSubListTotals != null) aggregateSubList.add(aggregateSubListTotals);\n                }\n            }\n        }\n        if (totalsMap.size() > 0) resultList.add(new HashMap<>(totalsMap));\n\n        if (logger.isTraceEnabled()) logger.trace(\"Processed list \" + listName + \", from \" + originalCount + \" items to \" + resultList.size() + \" items, in \" + (System.currentTimeMillis() - startTime) + \"ms\");\n        // for (Map<String, Object> result : resultList) logger.warn(\"Aggregate Result: \" + result.toString());\n\n        return resultList;\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private void processAggregateOriginal(Object curObject, ArrayList<Map<String, Object>> resultList, Set<String> includeFields,\n                                          Map<Map<String, Object>, Map<String, Object>> groupRows, Map<String, Object> totalsMap,\n                                          int index, boolean hasNext, boolean makeSubList, ExecutionContextImpl eci) {\n        Map curMap = null;\n        if (curObject instanceof EntityValue) {\n            curMap = ((EntityValue) curObject).getMap();\n        } else if (curObject instanceof Map) {\n            curMap = (Map) curObject;\n        }\n        boolean curIsMap = curMap != null;\n\n        ContextStack context = eci.contextStack;\n        Map<String, Object> contextTopMap;\n        if (curMap != null) { contextTopMap = new HashMap<>(curMap); } else { contextTopMap = new HashMap<>(); }\n        context.push(contextTopMap);\n\n        if (listEntryName != null) {\n            context.put(listEntryName, curObject);\n            context.put(listEntryName + \"_index\", index);\n            context.put(listEntryName + \"_has_next\", hasNext);\n        } else {\n            context.put(listName + \"_index\", index);\n            context.put(listName + \"_has_next\", hasNext);\n            context.put(listName + \"_entry\", curObject);\n        }\n\n        // if there are row actions run them\n        if (rowActions != null || hasFromExpr) {\n            if (rowActions != null) rowActions.run(eci);\n\n            // if any fields have a fromExpr get the value from that\n            for (int i = 0; i < aggregateFields.length; i++) {\n                AggregateField aggField = aggregateFields[i];\n                if (aggField.fromExpr != null) {\n                    Script script = InvokerHelper.createScript(aggField.fromExpr, eci.contextBindingInternal);\n                    Object newValue = script.run();\n                    context.put(aggField.fieldName, newValue);\n                }\n            }\n        }\n\n        Map<String, Object> resultMap = null;\n        Map<String, Object> groupByMap = null;\n        if (makeSubList) {\n            groupByMap = new HashMap<>();\n            for (int i = 0; i < groupFields.length; i++) {\n                String groupBy = groupFields[i];\n                if (!includeFields.contains(groupBy)) continue;\n                groupByMap.put(groupBy, getField(groupBy, context, curObject, curIsMap));\n            }\n            resultMap = groupRows.get(groupByMap);\n        }\n\n        if (resultMap == null) {\n            resultMap = contextTopMap;\n            Map<String, Object> subListMap = null;\n            Map<String, Object> subListTotalsMap = null;\n            for (int i = 0; i < aggregateFields.length; i++) {\n                AggregateField aggField = aggregateFields[i];\n                String fieldName = aggField.fieldName;\n                Object fieldValue = getField(fieldName, context, curObject, curIsMap);\n                // don't want to put null values, a waste of time/space; if count aggregate continue so it isn't counted\n                if (fieldValue == null) continue;\n\n                // handle subList\n                if (makeSubList && aggField.subList) {\n                    // NOTE: may have an issue here not using contextTopMap as starting point for sub-list entry, ie row-actions values lost if not referenced in a field name/from\n                    // NOTE2: if we start with contextTopMap should clone and perhaps remove aggregateFields that are not sub-list\n                    if (subListMap == null) subListMap = new HashMap<>();\n                    subListMap.put(fieldName, fieldValue);\n                    resultMap.remove(fieldName);\n                } else if (aggField.function == AggregateFunction.COUNT) {\n                    resultMap.put(fieldName, 1);\n                } else {\n                    resultMap.put(fieldName, fieldValue);\n                }\n                // handle showTotal\n                if (aggField.showTotal != null) {\n                    if (aggField.subList) {\n                        if (subListTotalsMap == null) subListTotalsMap = new HashMap<>();\n                        doFunction(aggField.showTotal, subListTotalsMap, fieldName, fieldValue);\n                    } else {\n                        doFunction(aggField.showTotal, totalsMap, fieldName, fieldValue);\n                    }\n                }\n            }\n\n            if (subListMap != null) {\n                ArrayList<Map<String, Object>> subList = new ArrayList<>();\n                subList.add(subListMap);\n                resultMap.put(\"aggregateSubList\", subList);\n            }\n            if (subListTotalsMap != null) resultMap.put(\"aggregateSubListTotals\", subListTotalsMap);\n\n            resultList.add(resultMap);\n            if (makeSubList) groupRows.put(groupByMap, resultMap);\n        } else {\n            // NOTE: if makeSubList == false this will never run\n            Map<String, Object> subListMap = null;\n            Map<String, Object> subListTotalsMap = (Map<String, Object>) resultMap.get(\"aggregateSubListTotals\");\n            for (int i = 0; i < aggregateFields.length; i++) {\n                AggregateField aggField = aggregateFields[i];\n                String fieldName = aggField.fieldName;\n                Object fieldValue = getField(fieldName, context, curObject, curIsMap);\n                // don't want to put null values, a waste of time/space; if count aggregate continue so it isn't counted\n                if (fieldValue == null) continue;\n\n                if (aggField.subList) {\n                    // NOTE: may have an issue here not using contextTopMap as starting point for sub-list entry, ie row-actions values lost if not referenced in a field name/from\n                    if (subListMap == null) subListMap = new HashMap<>();\n                    subListMap.put(fieldName, fieldValue);\n                } else if (aggField.function != null) {\n                    doFunction(aggField.function, resultMap, fieldName, fieldValue);\n                }\n                // handle showTotal\n                if (aggField.showTotal != null) {\n                    if (aggField.subList) {\n                        if (subListTotalsMap == null) {\n                            subListTotalsMap = new HashMap<>();\n                            resultMap.put(\"aggregateSubListTotals\", subListTotalsMap);\n                        }\n                        doFunction(aggField.showTotal, subListTotalsMap, fieldName, fieldValue);\n                    } else {\n                        doFunction(aggField.showTotal, totalsMap, fieldName, fieldValue);\n                    }\n                }\n            }\n            if (subListMap != null) {\n                ArrayList<Map<String, Object>> subList = (ArrayList<Map<String, Object>>) resultMap.get(\"aggregateSubList\");\n                if (subList != null) subList.add(subListMap);\n            }\n        }\n\n        // all done, pop the row context to clean up\n        context.pop();\n    }\n\n    private Object getField(String fieldName, ContextStack context, Object curObject, boolean curIsMap) {\n        Object value = context.getByString(fieldName);\n        if (curObject != null && !curIsMap && ObjectUtilities.isEmpty(value)) {\n            // try Groovy getAt for property access\n            try {\n                value = DefaultGroovyMethods.getAt(curObject, fieldName);\n            } catch (MissingPropertyException e) {\n                // ignore exception, we know this may not be a real property of the object\n                if (isTraceEnabled) logger.trace(\"Field \" + fieldName + \" is not a property of list-entry \" + listEntryName + \" in list \" + listName + \": \" + e.toString());\n            }\n        }\n        return value;\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private void doFunction(AggregateFunction function, Map<String, Object> resultMap, String fieldName, Object fieldValue) {\n        switch (function) {\n            case MIN:\n            case MAX:\n                Comparable existingComp = (Comparable) resultMap.get(fieldName);\n                Comparable newComp = (Comparable) fieldValue;\n                if (existingComp == null) {\n                    if (newComp != null) resultMap.put(fieldName, newComp);\n                } else {\n                    int compResult = existingComp.compareTo(newComp);\n                    if ((function == AggregateFunction.MIN && compResult > 0) || (function == AggregateFunction.MAX && compResult < 0))\n                        resultMap.put(fieldName, newComp);\n                }\n                break;\n            case SUM:\n                if (fieldValue != null) {\n                    Number curNumber;\n                    if (fieldValue instanceof Number) {\n                        curNumber = (Number) fieldValue;\n                    } else if (fieldValue instanceof CharSequence) {\n                        curNumber = new BigDecimal(fieldValue.toString());\n                    } else {\n                        throw new IllegalArgumentException(\"Tried to sum non-number value \" + fieldValue);\n                    }\n                    Number sumNum = ObjectUtilities.addNumbers((Number) resultMap.get(fieldName), curNumber);\n                    if (sumNum != null) resultMap.put(fieldName, sumNum);\n                }\n                break;\n            case AVG:\n                Number newNum = (Number) fieldValue;\n                if (newNum != null) {\n                    BigDecimal newNumBd = (newNum instanceof BigDecimal) ? (BigDecimal) newNum : new BigDecimal(newNum.toString());\n                    String fieldCountName = fieldName.concat(\"Count\");\n                    String fieldTotalName = fieldName.concat(\"Total\");\n                    Number existingNum = (Number) resultMap.get(fieldName);\n                    if (existingNum == null) {\n                        resultMap.put(fieldName, newNumBd);\n                        resultMap.put(fieldCountName, BigDecimal.ONE);\n                        resultMap.put(fieldTotalName, newNumBd);\n                    } else {\n                        BigDecimal count = (BigDecimal) resultMap.get(fieldCountName);\n                        BigDecimal total = (BigDecimal) resultMap.get(fieldTotalName);\n                        BigDecimal avgTotal = total.add(newNumBd);\n                        BigDecimal countPlusOne = count.add(BigDecimal.ONE);\n                        resultMap.put(fieldName, avgTotal.divide(countPlusOne, RoundingMode.HALF_EVEN));\n                        resultMap.put(fieldCountName, countPlusOne);\n                        resultMap.put(fieldTotalName, avgTotal);\n                    }\n                }\n                break;\n            case COUNT:\n                Integer existingCount = (Integer) resultMap.get(fieldName);\n                if (existingCount == null) existingCount = 0;\n                resultMap.put(fieldName, existingCount + 1);\n                break;\n            case FIRST:\n                if (!resultMap.containsKey(fieldName)) resultMap.put(fieldName, fieldValue);\n                break;\n            case LAST:\n                resultMap.put(fieldName, fieldValue);\n                break;\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityCache.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity\n\nimport groovy.transform.CompileStatic\n\nimport javax.cache.Cache\nimport org.moqui.entity.EntityCondition\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.context.CacheFacadeImpl\nimport org.moqui.util.MNode\nimport org.moqui.util.SimpleTopic\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.util.concurrent.ConcurrentHashMap\nimport java.util.concurrent.ConcurrentMap\n\n@CompileStatic\nclass EntityCache {\n    protected final static Logger logger = LoggerFactory.getLogger(EntityCache.class)\n\n    protected final EntityFacadeImpl efi\n    final CacheFacadeImpl cfi\n\n    static final String oneKeyBase = \"entity.record.one.\"\n    static final String oneRaKeyBase = \"entity.record.one_ra.\"\n    static final String oneViewRaKeyBase = \"entity.record.one_view_ra.\"\n    static final String listKeyBase = \"entity.record.list.\"\n    static final String listRaKeyBase = \"entity.record.list_ra.\"\n    static final String listViewRaKeyBase = \"entity.record.list_view_ra.\"\n    static final String countKeyBase = \"entity.record.count.\"\n\n    Cache<String, Set<EntityCondition>> oneBfCache\n    protected final Map<String, List<String>> cachedListViewEntitiesByMember = new HashMap<>()\n\n    protected final boolean distributedCacheInvalidate\n    /** Entity Cache Invalidate Topic */\n    private SimpleTopic<EntityCacheInvalidate> entityCacheInvalidateTopic = null\n\n    EntityCache(EntityFacadeImpl efi) {\n        this.efi = efi\n        this.cfi = efi.ecfi.cacheFacade\n\n        oneBfCache = cfi.getCache(\"entity.record.one_bf\")\n\n        MNode entityFacadeNode = efi.getEntityFacadeNode()\n        distributedCacheInvalidate = entityFacadeNode.attribute(\"distributed-cache-invalidate\") == \"true\" && entityFacadeNode.attribute(\"dci-topic-factory\")\n        logger.info(\"Entity Cache initialized, distributed cache invalidate enabled: ${distributedCacheInvalidate}\")\n\n        if (distributedCacheInvalidate) {\n            try {\n                String dciTopicFactory = entityFacadeNode.attribute(\"dci-topic-factory\")\n                entityCacheInvalidateTopic = (SimpleTopic<EntityCacheInvalidate>) efi.ecfi.getTool(dciTopicFactory, SimpleTopic.class)\n            } catch (Exception e) {\n                logger.error(\"Entity distributed cache invalidate is enabled but could not initialize\", e)\n            }\n        }\n    }\n\n    static class EntityCacheInvalidate implements Externalizable {\n        boolean isCreate\n        EntityValueBase evb\n\n        EntityCacheInvalidate() { }\n        EntityCacheInvalidate(EntityValueBase evb, boolean isCreate) {\n            this.isCreate = isCreate\n            this.evb = evb\n        }\n\n        @Override void writeExternal(ObjectOutput out) throws IOException {\n            out.writeBoolean(isCreate)\n            // NOTE: this would be faster but can't because don't know which impl of the abstract class was used: evb.writeExternal(out)\n            out.writeObject(evb)\n        }\n\n        @Override void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException {\n            isCreate = objectInput.readBoolean()\n            try {\n                evb = (EntityValueBase) objectInput.readObject()\n            } catch (Throwable t) {\n                logger.error(\"Error deserializing EntityValueBase for EntityCacheInvalidate, isCreate \" + isCreate, t)\n                throw t\n            }\n        }\n    }\n\n    static class EmptyRecord extends EntityValueImpl {\n        EmptyRecord() { }\n        EmptyRecord(EntityDefinition ed, EntityFacadeImpl efip) { super(ed, efip) }\n    }\n\n    void putInOneCache(EntityDefinition ed, EntityCondition whereCondition, EntityValueBase newEntityValue,\n                       Cache<EntityCondition, EntityValueBase> entityOneCache) {\n        if (entityOneCache == null) entityOneCache = ed.getCacheOne(this)\n\n        if (newEntityValue != null) newEntityValue.setFromCache()\n        entityOneCache.put(whereCondition, newEntityValue != null ? newEntityValue : new EmptyRecord(ed, efi))\n        // need to register an RA just in case the condition was not actually a primary key\n        registerCacheOneRa(ed.getFullEntityName(), whereCondition, newEntityValue)\n    }\n\n    EntityListImpl getFromListCache(EntityDefinition ed, EntityCondition whereCondition, List<String> orderByList,\n                                    Cache<EntityCondition, EntityListImpl> entityListCache) {\n        if (whereCondition == null) return null\n        if (entityListCache == null) entityListCache = ed.getCacheList(this)\n\n        EntityListImpl cacheHit = (EntityListImpl) entityListCache.get(whereCondition)\n        if (cacheHit != null && orderByList != null && orderByList.size() > 0) cacheHit.orderByFields(orderByList)\n        return cacheHit\n    }\n    void putInListCache(EntityDefinition ed, EntityListImpl el, EntityCondition whereCondition,\n                        Cache<EntityCondition, EntityListImpl> entityListCache) {\n        if (whereCondition == null) return\n        if (entityListCache == null) entityListCache = ed.getCacheList(this)\n\n        // EntityList elToCache = el != null ? el : EntityListImpl.EMPTY\n        EntityListImpl elToCache = el != null ? el : efi.getEmptyList()\n        elToCache.setFromCache()\n        entityListCache.put(whereCondition, elToCache)\n        registerCacheListRa(ed.getFullEntityName(), whereCondition, elToCache)\n    }\n    /*\n    Long getFromCountCache(EntityDefinition ed, EntityCondition whereCondition, Cache<EntityCondition, Long> entityCountCache) {\n        if (entityCountCache == null) entityCountCache = getCacheCount(ed.getFullEntityName())\n        return (Long) entityCountCache.get(whereCondition)\n    }\n    */\n\n    /** Called from EntityValueBase */\n    void clearCacheForValue(EntityValueBase evb, boolean isCreate) {\n        if (evb == null) return\n        EntityDefinition ed = evb.getEntityDefinition()\n        if (ed.entityInfo.neverCache) return\n\n        // String entityName = evb.resolveEntityName()\n        // if (!entityName.startsWith(\"moqui.\")) logger.info(\"========== ========== ========== clearCacheForValue ${entityName}\")\n        if (distributedCacheInvalidate && entityCacheInvalidateTopic != null) {\n            // NOTE: this takes some time to run and is done a LOT, for nearly all entity CrUD ops\n            // NOTE: have set many entities as never cache\n            // NOTE: can't avoid message when caches don't exist and not used in view-entity as it might be on another server\n            EntityCacheInvalidate eci = new EntityCacheInvalidate(evb, isCreate)\n            entityCacheInvalidateTopic.publish(eci)\n        } else {\n            clearCacheForValueActual(evb, isCreate)\n        }\n    }\n    /** Does actual cache clear, called directly or distributed through topic */\n    void clearCacheForValueActual(EntityValueBase evb, boolean isCreate) {\n        // logger.info(\"====== clearCacheForValueActual isCreate=${isCreate}, evb: ${evb}\")\n        try {\n            EntityDefinition ed = evb.getEntityDefinition()\n            // use getValueMap instead of getMap, faster and we don't want to cache localized values/etc\n            Map evbMap = evb.getValueMap()\n            // checked in clearCacheForValue(): if ('never'.equals(ed.getUseCache())) return\n            String fullEntityName = ed.entityInfo.fullEntityName\n\n            // init this as null, set below if needed (common case it isn't, will perform better)\n            EntityCondition pkCondition = null\n\n            // NOTE: use to check if caches exist ONLY, don't use to actually get cache\n            ConcurrentMap<String, Cache> localCacheMap = cfi.localCacheMap\n\n            // clear one cache\n            String oneKey = oneKeyBase.concat(fullEntityName)\n            if (localCacheMap.containsKey(oneKey)) {\n                pkCondition = efi.getConditionFactory().makeCondition(evb.getPrimaryKeys())\n\n                Cache<EntityCondition, EntityValueBase> entityOneCache = ed.getCacheOne(this)\n                // clear by PK, most common scenario\n                entityOneCache.remove(pkCondition)\n\n                // NOTE: these two have to be done whether or not it is a create because of non-pk updates, etc\n                // see if there are any one RA entries\n                Cache<EntityCondition, Set<EntityCondition>> oneRaCache = ed.getCacheOneRa(this)\n                Set<EntityCondition> raKeyList = (Set<EntityCondition>) oneRaCache.get(pkCondition)\n                if (raKeyList != null) {\n                    for (EntityCondition ec in raKeyList) {\n                        entityOneCache.remove(ec)\n                    }\n                    // we've cleared all entries that this was referring to, so clean it out too\n                    oneRaCache.remove(pkCondition)\n                }\n                // see if there are any cached entries with no result using the bf (brute-force) matching\n                Set<EntityCondition> bfKeySet = (Set<EntityCondition>) oneBfCache.get(fullEntityName)\n                if (bfKeySet != null && bfKeySet.size() > 0) {\n                    ArrayList<EntityCondition> keysToRemove = new ArrayList<EntityCondition>()\n                    Iterator<EntityCondition> bfKeySetIter = bfKeySet.iterator()\n                    while (bfKeySetIter.hasNext()) {\n                        EntityCondition bfKey = (EntityCondition) bfKeySetIter.next()\n                        if (bfKey.mapMatches(evbMap)) keysToRemove.add(bfKey)\n                    }\n                    int keysToRemoveSize = keysToRemove.size()\n                    for (int i = 0; i < keysToRemoveSize; i++) {\n                        EntityCondition key = (EntityCondition) keysToRemove.get(i)\n                        entityOneCache.remove(key)\n                        bfKeySet.remove(key)\n                    }\n                }\n            }\n\n            // check the One View RA entries for this entity\n            String oneViewRaKey = oneViewRaKeyBase.concat(fullEntityName)\n            if (localCacheMap.containsKey(oneViewRaKey)) {\n                if (pkCondition == null) pkCondition = efi.getConditionFactory().makeCondition(evb.getPrimaryKeys())\n\n                Cache<EntityCondition, Set<ViewRaKey>> oneViewRaCache = ed.getCacheOneViewRa(this)\n                Set<ViewRaKey> oneViewRaKeyList = (Set<ViewRaKey>) oneViewRaCache.get(pkCondition)\n                // if (fullEntityName.contains(\"FOO\")) logger.warn(\"======= clearCacheForValue ${fullEntityName}, PK ${pkCondition}, oneViewRaKeyList: ${oneViewRaKeyList}\")\n                if (oneViewRaKeyList != null) {\n                    for (ViewRaKey raKey in oneViewRaKeyList) {\n                        EntityDefinition raEd = efi.getEntityDefinition(raKey.entityName)\n                        Cache<EntityCondition, EntityValueBase> viewEntityOneCache = raEd.getCacheOne(this)\n                        // this may have already been cleared, but it is a waste of time to check for that explicitly\n                        viewEntityOneCache.remove(raKey.ec)\n                    }\n                    // we've cleared all entries that this was referring to, so clean it out too\n                    oneViewRaCache.remove(pkCondition)\n                }\n            }\n\n            // clear list cache, use reverse-associative Map (also a Cache)\n            String listKey = listKeyBase.concat(fullEntityName)\n            if (localCacheMap.containsKey(listKey)) {\n                if (pkCondition == null) pkCondition = efi.getConditionFactory().makeCondition(evb.getPrimaryKeys())\n\n                Cache<EntityCondition, EntityListImpl> entityListCache = ed.getCacheList(this)\n\n                // if this was a create the RA cache won't help, so go through EACH entry and see if it matches the created value\n                // The RA cache doesn't work for updates in the scenario where a record exists but its fields don't\n                //     match a find condition when the cached list find is initially done, but is then updated so the\n                //     fields do match\n                Iterator<Cache.Entry<EntityCondition, EntityListImpl>> elcIterator = entityListCache.iterator()\n                while (elcIterator.hasNext()) {\n                    Cache.Entry<EntityCondition, EntityListImpl> entry = (Cache.Entry<EntityCondition, EntityListImpl>) elcIterator.next()\n                    EntityCondition ec = (EntityCondition) entry.getKey()\n                    // any way to efficiently clear out the RA cache for these? for now just leave and they are handled eventually\n                    if (ec.mapMatches(evbMap)) entityListCache.remove(ec)\n                }\n\n                // if this is an update also check reverse associations (RA) as the condition check above may not match\n                //     against the new values, or partially updated records\n                if (!isCreate) {\n                    // First just the list RA cache\n                    Cache<EntityCondition, Set<EntityCondition>> listRaCache = ed.getCacheListRa(this)\n                    // logger.warn(\"============= clearing list for entity ${fullEntityName}, for pkCondition [${pkCondition}] listRaCache=${listRaCache}\")\n                    Set<EntityCondition> raKeyList = (Set<EntityCondition>) listRaCache.get(pkCondition)\n                    if (raKeyList != null) {\n                        // logger.warn(\"============= for entity ${fullEntityName}, for pkCondition [${pkCondition}], raKeyList for clear=${raKeyList}\")\n                        for (EntityCondition raKey in raKeyList) {\n                            // logger.warn(\"============= for entity ${fullEntityName}, removing raKey=${raKey} from ${entityListCache.getName()}\")\n                            EntityCondition ec = (EntityCondition) raKey\n                            // this may have already been cleared, but it is a waste of time to check for that explicitly\n                            entityListCache.remove(ec)\n                        }\n                        // we've cleared all entries that this was referring to, so clean it out too\n                        listRaCache.remove(pkCondition)\n                    }\n\n                    // Now to the same for the list view RA cache\n                    Cache<EntityCondition, Set<ViewRaKey>> listViewRaCache = ed.getCacheListViewRa(this)\n                    // logger.warn(\"============= clearing view list for entity ${fullEntityName}, for pkCondition [${pkCondition}] listViewRaCache=${listViewRaCache}\")\n                    Set<ViewRaKey> listViewRaKeyList = (Set<ViewRaKey>) listViewRaCache.get(pkCondition)\n                    if (listViewRaKeyList != null) {\n                        // logger.warn(\"============= for entity ${fullEntityName}, for pkCondition [${pkCondition}], listViewRaKeyList for clear=${listViewRaKeyList}\")\n                        for (ViewRaKey raKey in listViewRaKeyList) {\n                            // logger.warn(\"============= for entity ${fullEntityName}, removing raKey=${raKey} from ${entityListCache.getName()}\")\n                            EntityDefinition raEd = efi.getEntityDefinition(raKey.entityName)\n                            Cache<EntityCondition, EntityListImpl> viewEntityListCache = raEd.getCacheList(this)\n                            // this may have already been cleared, but it is a waste of time to check for that explicitly\n                            viewEntityListCache.remove(raKey.ec)\n                        }\n                        // we've cleared all entries that this was referring to, so clean it out too\n                        listViewRaCache.remove(pkCondition)\n                    }\n                }\n            }\n\n            // see if this entity is a member of a cached view-entity\n            List<String> cachedViewEntityNames = (List<String>) cachedListViewEntitiesByMember.get(fullEntityName)\n            if (cachedViewEntityNames != null) synchronized (cachedViewEntityNames) {\n                int cachedViewEntityNamesSize = cachedViewEntityNames.size()\n                for (int i = 0; i < cachedViewEntityNamesSize; i++) {\n                    String cachedViewEntityName = (String) cachedViewEntityNames.get(i)\n                    // logger.warn(\"Found ${cachedViewEntityName} as a cached view-entity for member ${fullEntityName}\")\n\n                    EntityDefinition viewEd = efi.getEntityDefinition(cachedViewEntityName)\n\n                    // generally match against view-entity aliases for fields on member entity\n                    // handle cases where current record (evbMap) has some keys from view-entity but not all (like UserPermissionCheck)\n                    Map<String, Object> viewMatchMap = new HashMap<>()\n                    Map<String, ArrayList<MNode>> memberFieldAliases = viewEd.getMemberFieldAliases(fullEntityName)\n                    for (Map.Entry<String, ArrayList<MNode>> mfAliasEntry in memberFieldAliases.entrySet()) {\n                        String fieldName = mfAliasEntry.getKey()\n                        if (!evbMap.containsKey(fieldName)) continue\n                        Object fieldValue = evbMap.get(fieldName)\n                        ArrayList<MNode> aliasNodeList = (ArrayList<MNode>) mfAliasEntry.getValue()\n                        int aliasNodeListSize = aliasNodeList.size()\n                        for (int j = 0 ; j < aliasNodeListSize; j++) {\n                            MNode aliasNode = (MNode) aliasNodeList.get(j)\n                            viewMatchMap.put(aliasNode.attribute(\"name\"), fieldValue)\n                        }\n                    }\n                    // logger.warn(\"========= viewMatchMap: ${viewMatchMap}\")\n\n                    Cache<EntityCondition, EntityListImpl> entityListCache = viewEd.getCacheList(this)\n\n                    Iterator<Cache.Entry<EntityCondition, EntityListImpl>> elcIterator = entityListCache.iterator()\n                    while (elcIterator.hasNext()) {\n                        Cache.Entry<EntityCondition, EntityListImpl> entry = (Cache.Entry<EntityCondition, EntityListImpl>) elcIterator.next()\n                        // in javax.cache.Cache next() may return null for expired, etc entries\n                        if (entry == null) continue;\n                        EntityCondition econd = (EntityCondition) entry.getKey()\n                        // logger.warn(\"======= entity ${fullEntityName} view-entity ${cachedViewEntityName} matches any? ${econd.mapMatchesAny(viewMatchMap)} keys not contained? ${econd.mapKeysNotContained(viewMatchMap)} econd: ${econd}\")\n                        // FUTURE: any way to efficiently clear out the RA cache for these? for now just leave and they are handled eventually\n                        // don't require a full match, if matches any part of condition clear it\n                        // NOTE: the mapKeysNotContained() call will handle cases where there is no negative match, but is overly\n                        //     inclusive and will clear cache entries that may not need to be cleared; a better approach might be\n                        //     possible; especially needed for cases where the list is queried by a field on the primary member-entity\n                        //     but another member-entity is updated\n                        if (econd.mapMatchesAny(viewMatchMap) || econd.mapKeysNotContained(viewMatchMap)) elcIterator.remove()\n                    }\n                }\n            }\n\n            // clear count cache (no RA because we only have a count to work with, just match by condition)\n            String countKey = countKeyBase.concat(fullEntityName)\n            if (localCacheMap.containsKey(countKey)) {\n                Cache<EntityCondition, Long> entityCountCache = ed.getCacheCount(this)\n                // with so little information about count cache results we can't do RA and checking conditions fails to clear in\n                //     cases where a value no longer matches, would handle newly matched clearing where count increases but not no\n                //     longer matches cases where count decreases\n                // no choice but to clear the whole cache\n                entityCountCache.clear()\n                /*\n                Iterator<Cache.Entry<EntityCondition, Long>> eccIterator = entityCountCache.iterator()\n                while (eccIterator.hasNext()) {\n                    Cache.Entry<EntityCondition, Long> entry = (Cache.Entry<EntityCondition, Long>) eccIterator.next()\n                    EntityCondition ec = (EntityCondition) entry.getKey()\n                    logger.warn(\"checking count condition: ${ec.toString()} matches? ${ec.mapMatchesAny(evbMap) || ec.mapKeysNotContained(evbMap)}\")\n                    if (ec.mapMatchesAny(evbMap) || ec.mapKeysNotContained(evbMap)) eccIterator.remove()\n                }\n                */\n            }\n        } catch (Throwable t) {\n            logger.error(\"Suppressed error in entity cache clearing [${evb.resolveEntityName()}; ${isCreate ? 'create' : 'non-create'}]\", t)\n        }\n    }\n    void registerCacheOneRa(String entityName, EntityCondition ec, EntityValueBase evb) {\n        // don't skip it for null values because we're caching those too: if (evb == null) return\n        if (evb == null) {\n            // can't use RA cache because we don't know the PK, so use a brute-force cache but keep it separate to perform better\n            Set<EntityCondition> bfKeySet = (Set<EntityCondition>) oneBfCache.get(entityName)\n            if (bfKeySet == null) {\n                bfKeySet = ConcurrentHashMap.newKeySet()\n                oneBfCache.put(entityName, bfKeySet)\n            }\n            bfKeySet.add(ec)\n        } else {\n            EntityDefinition ed = evb.getEntityDefinition()\n            Cache<EntityCondition, Set<EntityCondition>> oneRaCache = ed.getCacheOneRa(this)\n            EntityCondition pkCondition = efi.getConditionFactory().makeCondition(evb.getPrimaryKeys())\n            // if the condition matches the primary key, no need for an RA entry\n            if (pkCondition != ec) {\n                Set<EntityCondition> raKeyList = (Set<EntityCondition>) oneRaCache.get(pkCondition)\n                if (raKeyList == null) {\n                    raKeyList = ConcurrentHashMap.newKeySet()\n                    oneRaCache.put(pkCondition, raKeyList)\n                }\n                raKeyList.add(ec)\n            }\n\n            // if this is a view entity we need View RA entries for each member entity (that we have a PK for)\n            if (ed.isViewEntity) {\n                // go through each member-entity\n                ArrayList<MNode> memberEntityList = ed.getEntityNode().children('member-entity')\n                int memberEntityListSize = memberEntityList.size()\n                for (int i = 0; i < memberEntityListSize; i++) {\n                    MNode memberEntityNode = (MNode) memberEntityList.get(i)\n                    Map<String, String> mePkFieldToAliasNameMap = ed.getMePkFieldToAliasNameMap(memberEntityNode.attribute('entity-alias'))\n\n                    if (mePkFieldToAliasNameMap.isEmpty()) {\n                        logger.warn(\"for view-entity ${entityName}, member-entity ${memberEntityNode.attribute('@entity-name')}, got empty PK field to alias map\")\n                        continue\n                    }\n                    // create EntityCondition with pk fields\n                    // store with main ec with view-entity name in a RA cache for view entities for the member-entity name\n                    // with cache key of member-entity PK EntityCondition obj\n                    EntityDefinition memberEd = efi.getEntityDefinition(memberEntityNode.attribute('entity-name'))\n                    // String memberEntityName = memberEd.getFullEntityName()\n\n                    Map<String, Object> pkCondMap = new HashMap<>()\n                    for (Map.Entry<String, String> mePkEntry in mePkFieldToAliasNameMap.entrySet())\n                        pkCondMap.put(mePkEntry.getKey(), evb.getNoCheckSimple(mePkEntry.getValue()))\n                    // no PK fields? view-entity must not have them, skip it\n                    if (pkCondMap.size() == 0) continue\n\n                    // logger.warn(\"====== for view-entity ${entityName}, member-entity ${memberEd.fullEntityName}, got PK field to alias map: ${mePkFieldToAliasNameMap}\\npkCondMap: ${pkCondMap}\")\n\n                    Cache<EntityCondition, Set<ViewRaKey>> oneViewRaCache = memberEd.getCacheOneViewRa(this)\n                    EntityCondition memberPkCondition = efi.getConditionFactory().makeCondition(pkCondMap)\n                    Set<ViewRaKey> raKeyList = (Set<ViewRaKey>) oneViewRaCache.get(memberPkCondition)\n                    ViewRaKey newRaKey = new ViewRaKey(entityName, ec)\n                    if (raKeyList == null) {\n                        raKeyList = ConcurrentHashMap.newKeySet()\n                        oneViewRaCache.put(memberPkCondition, raKeyList)\n                        raKeyList.add(newRaKey)\n                        // logger.warn(\"===== added ViewRaKey for ${memberEntityName}, PK ${memberPkCondition}, raKeyList: ${raKeyList}\")\n                    } else if (!raKeyList.contains(newRaKey)) {\n                        raKeyList.add(newRaKey)\n                        // logger.warn(\"===== added ViewRaKey for ${memberEntityName}, PK ${memberPkCondition}, raKeyList: ${raKeyList}\")\n                    }\n                }\n            }\n        }\n    }\n\n    void registerCacheListRa(String entityName, EntityCondition ec, EntityList eli) {\n        EntityDefinition ed = efi.getEntityDefinition(entityName)\n        if (ed.isViewEntity) {\n            // go through each member-entity\n            ArrayList<MNode> memberEntityList = ed.getEntityNode().children('member-entity')\n            int memberEntityListSize = memberEntityList.size()\n            for (int j = 0; j < memberEntityListSize; j++) {\n                MNode memberEntityNode = (MNode) memberEntityList.get(j)\n                Map<String, String> mePkFieldToAliasNameMap = ed.getMePkFieldToAliasNameMap(memberEntityNode.attribute('entity-alias'))\n\n                if (mePkFieldToAliasNameMap.isEmpty()) {\n                    logger.warn(\"for view-entity ${entityName}, member-entity ${memberEntityNode.attribute('@entity-name')}, got empty PK field to alias map\")\n                    continue\n                }\n                // logger.warn(\"TOREMOVE for view-entity ${entityName}, member-entity ${memberEntityNode.'@entity-name'}, got PK field to alias map: ${mePkFieldToAliasNameMap}\")\n\n                // create EntityCondition with pk fields\n                // store with main ec with view-entity name in a RA cache for view entities for the member-entity name\n                // with cache key of member-entity PK EntityCondition obj\n                EntityDefinition memberEd = efi.getEntityDefinition(memberEntityNode.attribute('entity-name'))\n                String memberEntityName = memberEd.getFullEntityName()\n\n                // remember that this member entity has been used in a cached view entity\n                List<String> cachedViewEntityNames = cachedListViewEntitiesByMember.get(memberEntityName)\n                if (cachedViewEntityNames == null) {\n                    cachedViewEntityNames = Collections.synchronizedList(new ArrayList<>()) as List<String>\n                    cachedListViewEntitiesByMember.put(memberEntityName, cachedViewEntityNames)\n                    cachedViewEntityNames.add(entityName)\n                    // logger.info(\"Added ${entityName} as a cached view-entity for member ${memberEntityName}\")\n                } else if (!cachedViewEntityNames.contains(entityName)) {\n                    cachedViewEntityNames.add(entityName)\n                    // logger.info(\"Added ${entityName} as a cached view-entity for member ${memberEntityName}\")\n                }\n\n                Cache<EntityCondition, Set<ViewRaKey>> listViewRaCache = memberEd.getCacheListViewRa(this)\n                int eliSize = eli.size()\n                for (int i = 0; i < eliSize; i++) {\n                    EntityValue ev = (EntityValue) eli.get(i)\n                    Map pkCondMap = new HashMap()\n                    for (Map.Entry<String, String> mePkEntry in mePkFieldToAliasNameMap.entrySet())\n                        pkCondMap.put(mePkEntry.getKey(), ev.getNoCheckSimple(mePkEntry.getValue()))\n\n                    EntityCondition pkCondition = efi.getConditionFactory().makeCondition(pkCondMap)\n                    Set<ViewRaKey> raKeyList = (Set<ViewRaKey>) listViewRaCache.get(pkCondition)\n                    ViewRaKey newRaKey = new ViewRaKey(entityName, ec)\n                    if (raKeyList == null) {\n                        raKeyList = ConcurrentHashMap.newKeySet()\n                        listViewRaCache.put(pkCondition, raKeyList)\n                        raKeyList.add(newRaKey)\n                    } else if (!raKeyList.contains(newRaKey)) {\n                        raKeyList.add(newRaKey)\n                    }\n                    // logger.warn(\"TOREMOVE for view-entity ${entityName}, member-entity ${memberEntityNode.'@entity-name'}, for pkCondition [${pkCondition}], raKeyList after add=${raKeyList}\")\n                }\n            }\n        } else {\n            Cache<EntityCondition, Set<EntityCondition>> listRaCache = ed.getCacheListRa(this)\n            int eliSize = eli.size()\n            for (int i = 0; i < eliSize; i++) {\n                EntityValue ev = (EntityValue) eli.get(i)\n                EntityCondition pkCondition = efi.getConditionFactory().makeCondition(ev.getPrimaryKeys())\n                // NOTE: was memory leak here, using List it gets really large over time with duplicate find list conditions, use Set instead\n                Set<EntityCondition> raKeyList = (Set<EntityCondition>) listRaCache.get(pkCondition)\n                if (raKeyList == null) {\n                    raKeyList = ConcurrentHashMap.newKeySet()\n                    listRaCache.put(pkCondition, raKeyList)\n                }\n                raKeyList.add(ec)\n            }\n        }\n    }\n\n    static class ViewRaKey implements Serializable {\n        final String entityName\n        final EntityCondition ec\n        final int hashCodeVal\n        ViewRaKey(String entityName, EntityCondition ec) {\n            this.entityName = entityName; this.ec = ec;\n            hashCodeVal = entityName.hashCode() + ec.hashCode()\n        }\n\n        @Override int hashCode() { return hashCodeVal }\n        @Override boolean equals(Object obj) {\n            if (obj.getClass() != ViewRaKey.class) return false\n            ViewRaKey that = (ViewRaKey) obj\n            if (!entityName.equals(that.entityName)) return false\n            if (!ec.equals(that.ec)) return false\n            return true\n        }\n        @Override String toString() { return entityName + '(' + ec.toString() + ')' }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityConditionFactoryImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity\n\nimport groovy.transform.CompileStatic\nimport org.moqui.BaseArtifactException\nimport org.moqui.entity.EntityCondition\nimport org.moqui.entity.EntityCondition.ComparisonOperator\nimport org.moqui.entity.EntityCondition.JoinOperator\nimport org.moqui.entity.EntityConditionFactory\nimport org.moqui.entity.EntityException\nimport org.moqui.util.CollectionUtilities.KeyValue\nimport org.moqui.impl.entity.condition.*\nimport org.moqui.util.MNode\nimport org.moqui.util.ObjectUtilities\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.sql.Timestamp\n\n@CompileStatic\nclass EntityConditionFactoryImpl implements EntityConditionFactory {\n    protected final static Logger logger = LoggerFactory.getLogger(EntityConditionFactoryImpl.class)\n\n    protected final EntityFacadeImpl efi\n    protected final TrueCondition trueCondition\n\n    EntityConditionFactoryImpl(EntityFacadeImpl efi) {\n        this.efi = efi\n        trueCondition = new TrueCondition()\n    }\n\n    EntityFacadeImpl getEfi() { return efi }\n\n    @Override\n    EntityCondition getTrueCondition() { return trueCondition }\n\n    @Override\n    EntityCondition makeCondition(EntityCondition lhs, JoinOperator operator, EntityCondition rhs) {\n        return makeConditionImpl((EntityConditionImplBase) lhs, operator, (EntityConditionImplBase) rhs)\n    }\n    static EntityConditionImplBase makeConditionImpl(EntityConditionImplBase lhs, JoinOperator operator, EntityConditionImplBase rhs) {\n        if (lhs != null) {\n            if (rhs != null) {\n                // we have both lhs and rhs\n                if (lhs instanceof ListCondition) {\n                    ListCondition lhsLc = (ListCondition) lhs\n                    if (lhsLc.getOperator() == operator) {\n                        if (rhs instanceof ListCondition) {\n                            ListCondition rhsLc = (ListCondition) rhs\n                            if (rhsLc.getOperator() == operator) {\n                                lhsLc.addConditions(rhsLc)\n                                return lhsLc\n                            } else {\n                                lhsLc.addCondition(rhsLc)\n                                return lhsLc\n                            }\n                        } else {\n                            lhsLc.addCondition((EntityConditionImplBase) rhs)\n                            return lhsLc\n                        }\n                    }\n                }\n                // no special handling, create a BasicJoinCondition\n                return new BasicJoinCondition((EntityConditionImplBase) lhs, operator, (EntityConditionImplBase) rhs)\n            } else {\n                return lhs\n            }\n        } else {\n            if (rhs != null) {\n                return rhs\n            } else {\n                return null\n            }\n        }\n    }\n\n    @Override\n    EntityCondition makeCondition(String fieldName, ComparisonOperator operator, Object value) {\n        return new FieldValueCondition(new ConditionField(fieldName), operator, value)\n    }\n    @Override\n    EntityCondition makeCondition(String fieldName, ComparisonOperator operator, Object value, boolean orNull) {\n        EntityConditionImplBase cond = new FieldValueCondition(new ConditionField(fieldName), operator, value)\n        return orNull ? makeCondition(cond, JoinOperator.OR, makeCondition(fieldName, ComparisonOperator.EQUALS, null)) : cond\n    }\n    @Override\n    EntityCondition makeConditionToField(String fieldName, ComparisonOperator operator, String toFieldName) {\n        return new FieldToFieldCondition(new ConditionField(fieldName), operator, new ConditionField(toFieldName))\n    }\n\n    @Override\n    EntityCondition makeCondition(List<EntityCondition> conditionList) {\n        return this.makeCondition(conditionList, JoinOperator.AND)\n    }\n    @Override\n    EntityCondition makeCondition(List<EntityCondition> conditionList, JoinOperator operator) {\n        if (conditionList == null || conditionList.size() == 0) return null\n        ArrayList<EntityConditionImplBase> newList = new ArrayList()\n\n        if (conditionList instanceof RandomAccess) {\n            // avoid creating an iterator if possible\n            int listSize = conditionList.size()\n            for (int i = 0; i < listSize; i++) {\n                EntityCondition curCond = conditionList.get(i)\n                if (curCond == null) continue\n                // this is all they could be, all that is supported right now\n                if (curCond instanceof EntityConditionImplBase) newList.add((EntityConditionImplBase) curCond)\n                else throw new BaseArtifactException(\"EntityCondition of type [${curCond.getClass().getName()}] not supported\")\n            }\n        } else {\n            Iterator<EntityCondition> conditionIter = conditionList.iterator()\n            while (conditionIter.hasNext()) {\n                EntityCondition curCond = conditionIter.next()\n                if (curCond == null) continue\n                // this is all they could be, all that is supported right now\n                if (curCond instanceof EntityConditionImplBase) newList.add((EntityConditionImplBase) curCond)\n                else throw new BaseArtifactException(\"EntityCondition of type [${curCond.getClass().getName()}] not supported\")\n            }\n        }\n        if (newList == null || newList.size() == 0) return null\n        if (newList.size() == 1) {\n            return (EntityCondition) newList.get(0)\n        } else {\n            return new ListCondition(newList, operator)\n        }\n    }\n\n    @Override\n    EntityCondition makeCondition(List<Object> conditionList, String listOperator, String mapComparisonOperator, String mapJoinOperator) {\n        if (conditionList == null || conditionList.size() == 0) return null\n\n        JoinOperator listJoin = listOperator ? getJoinOperator(listOperator) : JoinOperator.AND\n        ComparisonOperator mapComparison = mapComparisonOperator ? getComparisonOperator(mapComparisonOperator) : ComparisonOperator.EQUALS\n        JoinOperator mapJoin = mapJoinOperator ? getJoinOperator(mapJoinOperator) : JoinOperator.AND\n\n        List<EntityConditionImplBase> newList = new ArrayList<EntityConditionImplBase>()\n        Iterator<Object> conditionIter = conditionList.iterator()\n        while (conditionIter.hasNext()) {\n            Object curObj = conditionIter.next()\n            if (curObj == null) continue\n            if (curObj instanceof Map) {\n                Map curMap = (Map) curObj\n                if (curMap.size() == 0) continue\n                EntityCondition curCond = makeCondition(curMap, mapComparison, mapJoin)\n                newList.add((EntityConditionImplBase) curCond)\n                continue\n            }\n            if (curObj instanceof EntityConditionImplBase) {\n                EntityConditionImplBase curCond = (EntityConditionImplBase) curObj\n                newList.add(curCond)\n                continue\n            }\n            throw new BaseArtifactException(\"The conditionList parameter must contain only Map and EntityCondition objects, found entry of type [${curObj.getClass().getName()}]\")\n        }\n        if (newList.size() == 0) return null\n        if (newList.size() == 1) {\n            return newList.get(0)\n        } else {\n            return new ListCondition(newList, listJoin)\n        }\n    }\n\n    @Override\n    EntityCondition makeCondition(Map<String, Object> fieldMap, ComparisonOperator comparisonOperator, JoinOperator joinOperator) {\n        return makeCondition(fieldMap, comparisonOperator, joinOperator, null, null, false)\n    }\n    EntityConditionImplBase makeCondition(Map<String, Object> fieldMap, ComparisonOperator comparisonOperator,\n            JoinOperator joinOperator, EntityDefinition findEd, Map<String, ArrayList<MNode>> memberFieldAliases, boolean excludeNulls) {\n        if (fieldMap == null || fieldMap.size() == 0) return (EntityConditionImplBase) null\n\n        JoinOperator joinOp = joinOperator != null ? joinOperator : JoinOperator.AND\n        ComparisonOperator compOp = comparisonOperator != null ? comparisonOperator : ComparisonOperator.EQUALS\n        ArrayList<EntityConditionImplBase> condList = new ArrayList<EntityConditionImplBase>()\n        ArrayList<KeyValue> fieldList = new ArrayList<KeyValue>()\n\n        for (Map.Entry<String, Object> entry in fieldMap.entrySet()) {\n            String key = entry.getKey()\n            Object value = entry.getValue()\n            if (key.startsWith(\"_\")) {\n                if (key == \"_comp\") {\n                    compOp = getComparisonOperator((String) value)\n                    continue\n                } else if (key == \"_join\") {\n                    joinOp = getJoinOperator((String) value)\n                    continue\n                } else if (key == \"_list\") {\n                    // if there is an _list treat each as a condition Map, ie call back into this method\n                    if (value instanceof List) {\n                        List valueList = (List) value\n                        for (Object listEntry in valueList) {\n                            if (listEntry instanceof Map) {\n                                EntityConditionImplBase entryCond = makeCondition((Map) listEntry, ComparisonOperator.EQUALS,\n                                        JoinOperator.AND, findEd, memberFieldAliases, excludeNulls)\n                                if (entryCond != null) condList.add(entryCond)\n                            } else {\n                                throw new EntityException(\"Entry in _list is not a Map: ${listEntry}\")\n                            }\n                        }\n                    } else {\n                        throw new EntityException(\"Value for _list entry is not a List: ${value}\")\n                    }\n                    continue\n                }\n            }\n\n            if (excludeNulls && value == null) {\n                if (logger.isTraceEnabled()) logger.trace(\"Tried to filter find on entity ${findEd.fullEntityName} on field ${key} but value was null, not adding condition\")\n                continue\n            }\n\n            // add field key/value to a list to iterate over later for conditions once we have _comp for sure\n            fieldList.add(new KeyValue(key, value))\n        }\n\n        // has fields? make conditions for them\n        if (fieldList.size() > 0) {\n            int fieldListSize = fieldList.size()\n            for (int i = 0; i < fieldListSize; i++) {\n                KeyValue fieldValue = (KeyValue) fieldList.get(i)\n                String fieldName = fieldValue.key\n                Object value = fieldValue.value\n\n                if (memberFieldAliases != null && memberFieldAliases.size() > 0) {\n                    // we have a view entity, more complex\n                    ArrayList<MNode> aliases = (ArrayList<MNode>) memberFieldAliases.get(fieldName)\n                    if (aliases == null || aliases.size() == 0)\n                        throw new EntityException(\"Tried to filter on field ${fieldName} which is not included in view-entity ${findEd.fullEntityName}\")\n\n                    for (int k = 0; k < aliases.size(); k++) {\n                        MNode aliasNode = (MNode) aliases.get(k)\n                        // could be same as field name, but not if aliased with different name\n                        String aliasName = aliasNode.attribute(\"name\")\n                        ConditionField cf = findEd != null ? findEd.getFieldInfo(aliasName).conditionField : new ConditionField(aliasName)\n                        if (ComparisonOperator.NOT_EQUAL.is(compOp) || ComparisonOperator.NOT_IN.is(compOp) || ComparisonOperator.NOT_LIKE.is(compOp)) {\n                            condList.add(makeConditionImpl(new FieldValueCondition(cf, compOp, value), JoinOperator.OR,\n                                    new FieldValueCondition(cf, ComparisonOperator.EQUALS, null)))\n                        } else {\n                            // in view-entities do or null for member entities that are join-optional\n                            String memberAlias = aliasNode.attribute(\"entity-alias\")\n                            MNode memberEntity = findEd.getMemberEntityNode(memberAlias)\n                            if (\"true\".equals(memberEntity.attribute(\"join-optional\"))) {\n                                condList.add(new BasicJoinCondition(new FieldValueCondition(cf, compOp, value), JoinOperator.OR,\n                                        new FieldValueCondition(cf, ComparisonOperator.EQUALS, null)))\n                            } else {\n                                condList.add(new FieldValueCondition(cf, compOp, value))\n                            }\n                        }\n                    }\n                } else {\n                    ConditionField cf = findEd != null ? findEd.getFieldInfo(fieldName).conditionField : new ConditionField(fieldName)\n                    if (ComparisonOperator.NOT_EQUAL.is(compOp) || ComparisonOperator.NOT_IN.is(compOp) || ComparisonOperator.NOT_LIKE.is(compOp)) {\n                        condList.add(makeConditionImpl(new FieldValueCondition(cf, compOp, value), JoinOperator.OR,\n                                new FieldValueCondition(cf, ComparisonOperator.EQUALS, null)))\n                    } else {\n                        condList.add(new FieldValueCondition(cf, compOp, value))\n                    }\n                }\n\n            }\n        }\n\n        if (condList.size() == 0) return (EntityConditionImplBase) null\n\n        if (condList.size() == 1) {\n            return (EntityConditionImplBase) condList.get(0)\n        } else {\n            return new ListCondition(condList, joinOp)\n        }\n    }\n    @Override\n    EntityCondition makeCondition(Map<String, Object> fieldMap) {\n        return makeCondition(fieldMap, ComparisonOperator.EQUALS, JoinOperator.AND, null, null, false)\n    }\n\n    @Override\n    EntityCondition makeConditionDate(String fromFieldName, String thruFieldName, Timestamp compareStamp) {\n        return new DateCondition(fromFieldName, thruFieldName,\n                (compareStamp != (Object) null) ? compareStamp : efi.ecfi.getEci().userFacade.getNowTimestamp())\n    }\n    EntityCondition makeConditionDate(String fromFieldName, String thruFieldName, Timestamp compareStamp, boolean ignoreIfEmpty, String ignore) {\n        if (ignoreIfEmpty && (Object) compareStamp == null) return null\n        if (efi.ecfi.resourceFacade.condition(ignore, null)) return null\n        return new DateCondition(fromFieldName, thruFieldName,\n                (compareStamp != (Object) null) ? compareStamp : efi.ecfi.getEci().userFacade.getNowTimestamp())\n    }\n\n    @Override\n    EntityCondition makeConditionWhere(String sqlWhereClause) {\n        if (!sqlWhereClause) return null\n        return new WhereCondition(sqlWhereClause)\n    }\n\n    ComparisonOperator comparisonOperatorFromEnumId(String enumId) {\n        switch (enumId) {\n            case \"ENTCO_LESS\": return EntityCondition.LESS_THAN\n            case \"ENTCO_GREATER\": return EntityCondition.GREATER_THAN\n            case \"ENTCO_LESS_EQ\": return EntityCondition.LESS_THAN_EQUAL_TO\n            case \"ENTCO_GREATER_EQ\": return EntityCondition.GREATER_THAN_EQUAL_TO\n            case \"ENTCO_EQUALS\": return EntityCondition.EQUALS\n            case \"ENTCO_NOT_EQUALS\": return EntityCondition.NOT_EQUAL\n            case \"ENTCO_IN\": return EntityCondition.IN\n            case \"ENTCO_NOT_IN\": return EntityCondition.NOT_IN\n            case \"ENTCO_BETWEEN\": return EntityCondition.BETWEEN\n            case \"ENTCO_NOT_BETWEEN\": return EntityCondition.NOT_BETWEEN\n            case \"ENTCO_LIKE\": return EntityCondition.LIKE\n            case \"ENTCO_NOT_LIKE\": return EntityCondition.NOT_LIKE\n            case \"ENTCO_IS_NULL\": return EntityCondition.IS_NULL\n            case \"ENTCO_IS_NOT_NULL\": return EntityCondition.IS_NOT_NULL\n            default: return null\n        }\n    }\n\n    static EntityConditionImplBase addAndListToCondition(EntityConditionImplBase baseCond, ArrayList<EntityConditionImplBase> condList) {\n        EntityConditionImplBase outCondition = baseCond\n        int condListSize = condList != null ? condList.size() : 0\n        if (condListSize > 0) {\n            if (baseCond == null) {\n                if (condListSize == 1) {\n                    outCondition = (EntityConditionImplBase) condList.get(0)\n                } else {\n                    outCondition = new ListCondition(condList, EntityCondition.AND)\n                }\n            } else {\n                ListCondition newListCond = (ListCondition) null\n                if (baseCond instanceof ListCondition) {\n                    ListCondition baseListCond = (ListCondition) baseCond\n                    if (EntityCondition.AND.is(baseListCond.operator)) {\n                        // modify in place\n                        newListCond = baseListCond\n                    }\n                }\n                if (newListCond == null) newListCond = new ListCondition([baseCond], EntityCondition.AND)\n                newListCond.addConditions(condList)\n                outCondition = newListCond\n            }\n        }\n        return outCondition\n    }\n\n    EntityCondition makeActionCondition(String fieldName, String operator, String fromExpr, String value, String toFieldName,\n                                        boolean ignoreCase, boolean ignoreIfEmpty, boolean orNull, String ignore) {\n        Object from = fromExpr ? this.efi.ecfi.resourceFacade.expression(fromExpr, \"\") : null\n        return makeActionConditionDirect(fieldName, operator, from, value, toFieldName, ignoreCase, ignoreIfEmpty, orNull, ignore)\n    }\n    EntityCondition makeActionConditionDirect(String fieldName, String operator, Object fromObj, String value, String toFieldName,\n                                              boolean ignoreCase, boolean ignoreIfEmpty, boolean orNull, String ignore) {\n        // logger.info(\"TOREMOVE makeActionCondition(fieldName ${fieldName}, operator ${operator}, fromExpr ${fromExpr}, value ${value}, toFieldName ${toFieldName}, ignoreCase ${ignoreCase}, ignoreIfEmpty ${ignoreIfEmpty}, orNull ${orNull}, ignore ${ignore})\")\n\n        if (efi.ecfi.resourceFacade.condition(ignore, null)) return null\n\n        if (toFieldName != null && toFieldName.length() > 0) {\n            EntityCondition ec = makeConditionToField(fieldName, getComparisonOperator(operator), toFieldName)\n            if (ignoreCase) ec.ignoreCase()\n            return ec\n        } else {\n            Object condValue\n            if (value != null && value.length() > 0) {\n                // NOTE: have to convert value (if needed) later on because we don't know which entity/field this is for, or change to pass in entity?\n                condValue = value\n            } else {\n                condValue = fromObj\n            }\n            if (ignoreIfEmpty && ObjectUtilities.isEmpty(condValue)) return null\n\n            EntityCondition mainEc = makeCondition(fieldName, getComparisonOperator(operator), condValue)\n            if (ignoreCase) mainEc.ignoreCase()\n\n            EntityCondition ec = mainEc\n            if (orNull) ec = makeCondition(mainEc, JoinOperator.OR, makeCondition(fieldName, ComparisonOperator.EQUALS, null))\n            return ec\n        }\n    }\n\n    EntityCondition makeActionCondition(MNode node) {\n        Map<String, String> attrs = node.attributes\n        return makeActionCondition(attrs.get(\"field-name\"),\n                attrs.get(\"operator\") ?: \"equals\", (attrs.get(\"from\") ?: attrs.get(\"field-name\")),\n                attrs.get(\"value\"), attrs.get(\"to-field-name\"), (attrs.get(\"ignore-case\") ?: \"false\") == \"true\",\n                (attrs.get(\"ignore-if-empty\") ?: \"false\") == \"true\", (attrs.get(\"or-null\") ?: \"false\") == \"true\",\n                (attrs.get(\"ignore\") ?: \"false\"))\n    }\n\n    EntityCondition makeActionConditions(MNode node, boolean isCached) {\n        ArrayList<EntityCondition> condList = new ArrayList()\n        ArrayList<MNode> subCondList = node.getChildren()\n        int subCondListSize = subCondList.size()\n        for (int i = 0; i < subCondListSize; i++) {\n            MNode subCond = (MNode) subCondList.get(i)\n            if (\"econdition\".equals(subCond.nodeName)) {\n                EntityCondition econd = makeActionCondition(subCond)\n                if (econd != null) condList.add(econd)\n            } else if (\"econditions\".equals(subCond.nodeName)) {\n                EntityCondition econd = makeActionConditions(subCond, isCached)\n                if (econd != null) condList.add(econd)\n            } else if (\"date-filter\".equals(subCond.nodeName)) {\n                if (!isCached) {\n                    Timestamp validDate = subCond.attribute(\"valid-date\") ?\n                            efi.ecfi.resourceFacade.expression(subCond.attribute(\"valid-date\"), null) as Timestamp : null\n                    condList.add(makeConditionDate(subCond.attribute(\"from-field-name\") ?: \"fromDate\",\n                            subCond.attribute(\"thru-field-name\") ?: \"thruDate\", validDate,\n                            'true'.equals(subCond.attribute(\"ignore-if-empty\")), subCond.attribute(\"ignore\") ?: 'false'))\n                }\n            } else if (\"econdition-object\".equals(subCond.nodeName)) {\n                Object curObj = efi.ecfi.resourceFacade.expression(subCond.attribute(\"field\"), null)\n                if (curObj == null) continue\n                if (curObj instanceof Map) {\n                    Map curMap = (Map) curObj\n                    if (curMap.size() == 0) continue\n                    EntityCondition curCond = makeCondition(curMap, ComparisonOperator.EQUALS, JoinOperator.AND)\n                    condList.add((EntityConditionImplBase) curCond)\n                    continue\n                }\n                if (curObj instanceof EntityConditionImplBase) {\n                    EntityConditionImplBase curCond = (EntityConditionImplBase) curObj\n                    condList.add(curCond)\n                    continue\n                }\n                throw new BaseArtifactException(\"The econdition-object field attribute must contain only Map and EntityCondition objects, found entry of type [${curObj.getClass().getName()}]\")\n            }\n        }\n        return makeCondition(condList, getJoinOperator(node.attribute(\"combine\")))\n    }\n\n    protected static final Map<ComparisonOperator, String> comparisonOperatorStringMap = new EnumMap(ComparisonOperator.class)\n    static {\n        comparisonOperatorStringMap.put(ComparisonOperator.EQUALS, \"=\")\n        comparisonOperatorStringMap.put(ComparisonOperator.NOT_EQUAL, \"<>\")\n        comparisonOperatorStringMap.put(ComparisonOperator.LESS_THAN, \"<\")\n        comparisonOperatorStringMap.put(ComparisonOperator.GREATER_THAN, \">\")\n        comparisonOperatorStringMap.put(ComparisonOperator.LESS_THAN_EQUAL_TO, \"<=\")\n        comparisonOperatorStringMap.put(ComparisonOperator.GREATER_THAN_EQUAL_TO, \">=\")\n        comparisonOperatorStringMap.put(ComparisonOperator.IN, \"IN\")\n        comparisonOperatorStringMap.put(ComparisonOperator.NOT_IN, \"NOT IN\")\n        comparisonOperatorStringMap.put(ComparisonOperator.BETWEEN, \"BETWEEN\")\n        comparisonOperatorStringMap.put(ComparisonOperator.NOT_BETWEEN, \"NOT BETWEEN\")\n        comparisonOperatorStringMap.put(ComparisonOperator.LIKE, \"LIKE\")\n        comparisonOperatorStringMap.put(ComparisonOperator.NOT_LIKE, \"NOT LIKE\")\n        comparisonOperatorStringMap.put(ComparisonOperator.IS_NULL, \"IS NULL\")\n        comparisonOperatorStringMap.put(ComparisonOperator.IS_NOT_NULL, \"IS NOT NULL\")\n    }\n    protected static final Map<String, ComparisonOperator> stringComparisonOperatorMap = [\n            \"=\":ComparisonOperator.EQUALS,\n            \"equals\":ComparisonOperator.EQUALS,\n\n            \"not-equals\":ComparisonOperator.NOT_EQUAL,\n            \"not-equal\":ComparisonOperator.NOT_EQUAL,\n            \"!=\":ComparisonOperator.NOT_EQUAL,\n            \"<>\":ComparisonOperator.NOT_EQUAL,\n\n            \"less-than\":ComparisonOperator.LESS_THAN,\n            \"less\":ComparisonOperator.LESS_THAN,\n            \"<\":ComparisonOperator.LESS_THAN,\n\n            \"greater-than\":ComparisonOperator.GREATER_THAN,\n            \"greater\":ComparisonOperator.GREATER_THAN,\n            \">\":ComparisonOperator.GREATER_THAN,\n\n            \"less-than-equal-to\":ComparisonOperator.LESS_THAN_EQUAL_TO,\n            \"less-equals\":ComparisonOperator.LESS_THAN_EQUAL_TO,\n            \"<=\":ComparisonOperator.LESS_THAN_EQUAL_TO,\n\n            \"greater-than-equal-to\":ComparisonOperator.GREATER_THAN_EQUAL_TO,\n            \"greater-equals\":ComparisonOperator.GREATER_THAN_EQUAL_TO,\n            \">=\":ComparisonOperator.GREATER_THAN_EQUAL_TO,\n\n            \"in\":ComparisonOperator.IN,\n            \"IN\":ComparisonOperator.IN,\n\n            \"not-in\":ComparisonOperator.NOT_IN,\n            \"NOT IN\":ComparisonOperator.NOT_IN,\n\n            \"between\":ComparisonOperator.BETWEEN,\n            \"BETWEEN\":ComparisonOperator.BETWEEN,\n\n            \"not-between\":ComparisonOperator.NOT_BETWEEN,\n            \"NOT BETWEEN\":ComparisonOperator.NOT_BETWEEN,\n\n            \"like\":ComparisonOperator.LIKE,\n            \"LIKE\":ComparisonOperator.LIKE,\n\n            \"not-like\":ComparisonOperator.NOT_LIKE,\n            \"NOT LIKE\":ComparisonOperator.NOT_LIKE,\n\n            \"is-null\":ComparisonOperator.IS_NULL,\n            \"IS NULL\":ComparisonOperator.IS_NULL,\n\n            \"is-not-null\":ComparisonOperator.IS_NOT_NULL,\n            \"IS NOT NULL\":ComparisonOperator.IS_NOT_NULL\n    ]\n\n    static String getJoinOperatorString(JoinOperator op) { return JoinOperator.OR.is(op) ? \"OR\" : \"AND\" }\n    static JoinOperator getJoinOperator(String opName) { return \"or\".equalsIgnoreCase(opName) ? JoinOperator.OR :JoinOperator.AND }\n\n    static String getComparisonOperatorString(ComparisonOperator op) { return comparisonOperatorStringMap.get(op) }\n    static ComparisonOperator getComparisonOperator(String opName) {\n        if (opName == null) return ComparisonOperator.EQUALS\n        ComparisonOperator co = stringComparisonOperatorMap.get(opName)\n        return co != null ? co : ComparisonOperator.EQUALS\n    }\n\n    static boolean compareByOperator(Object value1, ComparisonOperator op, Object value2) {\n        switch (op) {\n        case ComparisonOperator.EQUALS:\n            return value1 == value2\n        case ComparisonOperator.NOT_EQUAL:\n            return value1 != value2\n        case ComparisonOperator.LESS_THAN:\n            Comparable comp1 = ObjectUtilities.makeComparable(value1)\n            Comparable comp2 = ObjectUtilities.makeComparable(value2)\n            return comp1 < comp2\n        case ComparisonOperator.GREATER_THAN:\n            Comparable comp1 = ObjectUtilities.makeComparable(value1)\n            Comparable comp2 = ObjectUtilities.makeComparable(value2)\n            return comp1 > comp2\n        case ComparisonOperator.LESS_THAN_EQUAL_TO:\n            Comparable comp1 = ObjectUtilities.makeComparable(value1)\n            Comparable comp2 = ObjectUtilities.makeComparable(value2)\n            return comp1 <= comp2\n        case ComparisonOperator.GREATER_THAN_EQUAL_TO:\n            Comparable comp1 = ObjectUtilities.makeComparable(value1)\n            Comparable comp2 = ObjectUtilities.makeComparable(value2)\n            return comp1 >= comp2\n        case ComparisonOperator.IN:\n            if (value2 instanceof Collection) {\n                return ((Collection) value2).contains(value1)\n            } else {\n                // not a Collection, try equals\n                return value1 == value2\n            }\n        case ComparisonOperator.NOT_IN:\n            if (value2 instanceof Collection) {\n                return !((Collection) value2).contains(value1)\n            } else {\n                // not a Collection, try not-equals\n                return value1 != value2\n            }\n        case ComparisonOperator.BETWEEN:\n            if (value2 instanceof Collection && ((Collection) value2).size() == 2) {\n                Comparable comp1 = ObjectUtilities.makeComparable(value1)\n                Iterator iterator = ((Collection) value2).iterator()\n                Comparable lowObj = ObjectUtilities.makeComparable(iterator.next())\n                Comparable highObj = ObjectUtilities.makeComparable(iterator.next())\n                return lowObj <= comp1 && comp1 < highObj\n            } else {\n                return false\n            }\n        case ComparisonOperator.NOT_BETWEEN:\n            if (value2 instanceof Collection && ((Collection) value2).size() == 2) {\n                Comparable comp1 = ObjectUtilities.makeComparable(value1)\n                Iterator iterator = ((Collection) value2).iterator()\n                Comparable lowObj = ObjectUtilities.makeComparable(iterator.next())\n                Comparable highObj = ObjectUtilities.makeComparable(iterator.next())\n                return lowObj > comp1 && comp1 >= highObj\n            } else {\n                return false\n            }\n        case ComparisonOperator.LIKE:\n            return ObjectUtilities.compareLike(value1, value2)\n        case ComparisonOperator.NOT_LIKE:\n            return !ObjectUtilities.compareLike(value1, value2)\n        case ComparisonOperator.IS_NULL:\n            return value1 == null\n        case ComparisonOperator.IS_NOT_NULL:\n            return value1 != null\n        }\n        // default return false\n        return false\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityDataDocument.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity\n\nimport groovy.json.JsonOutput\nimport groovy.transform.CompileStatic\nimport org.moqui.entity.EntityCondition\nimport org.moqui.entity.EntityException\nimport org.moqui.entity.EntityFind\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityListIterator\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.impl.entity.condition.ConditionAlias\nimport org.moqui.impl.entity.condition.ConditionField\nimport org.moqui.impl.entity.condition.FieldValueCondition\nimport org.moqui.util.CollectionUtilities\nimport org.moqui.util.LiteStringMap\nimport org.moqui.util.MNode\nimport org.moqui.util.ObjectUtilities\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.sql.Timestamp\nimport java.time.ZoneOffset\nimport java.time.format.DateTimeFormatter\nimport java.util.concurrent.atomic.AtomicBoolean\nimport java.util.concurrent.atomic.AtomicInteger\n\n@CompileStatic\nclass EntityDataDocument {\n    protected final static Logger logger = LoggerFactory.getLogger(EntityDataDocument.class)\n\n    protected final EntityFacadeImpl efi\n\n    EntityDataDocument(EntityFacadeImpl efi) {\n        this.efi = efi\n    }\n\n    int writeDocumentsToFile(String filename, List<String> dataDocumentIds, EntityCondition condition,\n                             Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp, boolean prettyPrint) {\n        File outFile = new File(filename)\n        if (!outFile.createNewFile()) {\n            efi.ecfi.getEci().message.addError(efi.ecfi.resource.expand('File ${filename} already exists.','',[filename:filename]))\n            return 0\n        }\n\n        PrintWriter pw = new PrintWriter(outFile)\n\n        pw.write(\"[\\n\")\n        int valuesWritten = writeDocumentsToWriter(pw, dataDocumentIds, condition, fromUpdateStamp, thruUpdatedStamp, prettyPrint)\n        pw.write(\"{}\\n]\\n\")\n        pw.close()\n        efi.ecfi.getEci().message.addMessage(efi.ecfi.resource.expand('Wrote ${valuesWritten} documents to file ${filename}','',[valuesWritten:valuesWritten,filename:filename]))\n        return valuesWritten\n    }\n    int writeDocumentsToDirectory(String dirname, List<String> dataDocumentIds, EntityCondition condition,\n                                  Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp, boolean prettyPrint) {\n        File outDir = new File(dirname)\n        if (!outDir.exists()) outDir.mkdir()\n        if (!outDir.isDirectory()) {\n            efi.ecfi.getEci().message.addError(efi.ecfi.resource.expand('Path ${dirname} is not a directory.','',[dirname:dirname]))\n            return 0\n        }\n\n        int valuesWritten = 0\n\n        for (String dataDocumentId in dataDocumentIds) {\n            String filename = \"${dirname}/${dataDocumentId}.json\"\n            File outFile = new File(filename)\n            if (outFile.exists()) {\n                efi.ecfi.getEci().message.addError(efi.ecfi.resource.expand('File ${filename} already exists, skipping document ${dataDocumentId}.','',[filename:filename,dataDocumentId:dataDocumentId]))\n                continue\n            }\n            outFile.createNewFile()\n\n            PrintWriter pw = new PrintWriter(outFile)\n            pw.write(\"[\\n\")\n            valuesWritten += writeDocumentsToWriter(pw, [dataDocumentId], condition, fromUpdateStamp, thruUpdatedStamp, prettyPrint)\n            pw.write(\"{}\\n]\\n\")\n            pw.close()\n            efi.ecfi.getEci().message.addMessage(efi.ecfi.resource.expand('Wrote ${valuesWritten} records to file ${filename}','',[valuesWritten:valuesWritten, filename:filename]))\n        }\n\n        return valuesWritten\n    }\n    int writeDocumentsToWriter(Writer pw, List<String> dataDocumentIds, EntityCondition condition,\n                               Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp, boolean prettyPrint) {\n        if (dataDocumentIds == null || dataDocumentIds.size() == 0) return 0\n        int valuesWritten = 0\n        for (String dataDocumentId in dataDocumentIds) {\n            ArrayList<Map> documentList = getDataDocuments(dataDocumentId, condition, fromUpdateStamp, thruUpdatedStamp)\n            int docListSize = documentList.size()\n            for (int i = 0; i < docListSize; i++) {\n                if (valuesWritten > 0) pw.write(\",\\n\")\n                Map document = (Map) documentList.get(i)\n                String json = JsonOutput.toJson(document)\n                if (prettyPrint) {\n                    pw.write(JsonOutput.prettyPrint(json))\n                } else {\n                    pw.write(json)\n                }\n                valuesWritten++\n            }\n        }\n        if (valuesWritten > 0) pw.write(\"\\n\")\n\n        return valuesWritten\n    }\n\n    static class DataDocumentInfo {\n        String dataDocumentId\n        EntityValue dataDocument\n        EntityList dataDocumentFieldList\n        EntityList dataDocumentRelAliasList\n        EntityList dataDocumentConditionList\n        String primaryEntityName\n        EntityDefinition primaryEd\n        ArrayList<String> primaryPkFieldNames\n        int primaryPkFieldNamesSize\n        Map<String, Object> fieldTree = [:]\n        Map<String, String> fieldAliasPathMap = [:]\n        Map<String, String> relationshipAliasMap = [:]\n        boolean hasExpressionField = false\n        boolean hasAllPrimaryPks = true\n        EntityDefinition entityDef\n\n        DataDocumentInfo(String dataDocumentId, EntityFacadeImpl efi) {\n            this.dataDocumentId = dataDocumentId\n\n            dataDocument = efi.fastFindOne(\"moqui.entity.document.DataDocument\", true, false, dataDocumentId)\n            if (dataDocument == null) throw new EntityException(\"No DataDocument found with ID ${dataDocumentId}\")\n            dataDocumentFieldList = dataDocument.findRelated(\"moqui.entity.document.DataDocumentField\", null, ['sequenceNum', 'fieldPath'], true, false)\n            dataDocumentRelAliasList = dataDocument.findRelated(\"moqui.entity.document.DataDocumentRelAlias\", null, null, true, false)\n            dataDocumentConditionList = dataDocument.findRelated(\"moqui.entity.document.DataDocumentCondition\", null, null, true, false)\n\n            for (int rai = 0; rai < dataDocumentRelAliasList.size(); rai++) {\n                EntityValue dataDocumentRelAlias = (EntityValue) dataDocumentRelAliasList.get(rai)\n                relationshipAliasMap.put((String) dataDocumentRelAlias.getNoCheckSimple(\"relationshipName\"),\n                        (String) dataDocumentRelAlias.getNoCheckSimple(\"documentAlias\"))\n            }\n\n            primaryEntityName = (String) dataDocument.getNoCheckSimple(\"primaryEntityName\")\n            primaryEd = efi.getEntityDefinition(primaryEntityName)\n            primaryPkFieldNames = primaryEd.getPkFieldNames()\n            primaryPkFieldNamesSize = primaryPkFieldNames.size()\n\n            AtomicBoolean hasExprMut = new AtomicBoolean(false)\n            populateFieldTreeAndAliasPathMap(dataDocumentFieldList, primaryPkFieldNames, fieldTree, fieldAliasPathMap, hasExprMut, false)\n            hasExpressionField = hasExprMut.get()\n\n            for (int pki = 0; pki < primaryPkFieldNames.size(); pki++) {\n                String pkFieldName = (String) primaryPkFieldNames.get(pki)\n                if (!fieldAliasPathMap.containsKey(pkFieldName)) {\n                    hasAllPrimaryPks = false\n                    break\n                }\n            }\n\n            EntityDynamicViewImpl dynamicView = new EntityDynamicViewImpl(efi)\n            dynamicView.entityNode.attributes.put(\"package\", \"DataDocument\")\n            dynamicView.entityNode.attributes.put(\"entity-name\", dataDocumentId)\n\n            // add member entities and field aliases to dynamic view\n            dynamicView.addMemberEntity(\"PRIM\", primaryEntityName, null, null, null)\n            AtomicInteger incrementer = new AtomicInteger()\n            fieldTree.put(\"_ALIAS\", \"PRIM\")\n            addDataDocRelatedEntity(dynamicView, \"PRIM\", fieldTree, incrementer, makeDdfByAlias(dataDocumentFieldList))\n            // logger.warn(\"=========== ${dataDocumentId} fieldTree=${fieldTree}\")\n            // logger.warn(\"=========== ${dataDocumentId} fieldAliasPathMap=${fieldAliasPathMap}\")\n\n            entityDef = dynamicView.makeEntityDefinition()\n        }\n        String makeDocId(EntityValue ev) {\n            if (primaryPkFieldNamesSize == 1) {\n                // optimization for common simple case\n                String pkFieldName = (String) primaryPkFieldNames.get(0)\n                Object pkFieldValue = ev.getNoCheckSimple(pkFieldName)\n                return ObjectUtilities.toPlainString(pkFieldValue)\n            } else {\n                StringBuilder pkCombinedSb = new StringBuilder()\n                for (int pki = 0; pki < primaryPkFieldNamesSize; pki++) {\n                    String pkFieldName = (String) primaryPkFieldNames.get(pki)\n                    // don't do this, always use full PK even if not all aliased in doc, probably a bad DataDocument definition: if (!fieldAliasPathMap.containsKey(pkFieldName)) continue\n                    if (pkCombinedSb.length() > 0) pkCombinedSb.append(\"::\")\n                    Object pkFieldValue = ev.getNoCheckSimple(pkFieldName)\n                    pkCombinedSb.append(ObjectUtilities.toPlainString(pkFieldValue))\n                }\n                return pkCombinedSb.toString()\n            }\n        }\n    }\n\n    EntityDefinition makeEntityDefinition(String dataDocumentId) {\n        DataDocumentInfo ddi = new DataDocumentInfo(dataDocumentId, efi)\n        return ddi.entityDef\n    }\n\n    EntityFind makeDataDocumentFind(String dataDocumentId) {\n        DataDocumentInfo ddi = new DataDocumentInfo(dataDocumentId, efi)\n        return makeDataDocumentFind(ddi, null, null)\n    }\n\n    EntityFind makeDataDocumentFind(DataDocumentInfo ddi, Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp) {\n        // build the query condition for the primary entity and all related entities\n        EntityDefinition ed = ddi.entityDef\n        EntityFind mainFind = ed.makeEntityFind()\n\n        // add conditions\n        if (ddi.dataDocumentConditionList != null && ddi.dataDocumentConditionList.size() > 0) {\n            ExecutionContextImpl eci = efi.ecfi.getEci()\n            int dataDocumentConditionListSize = ddi.dataDocumentConditionList.size()\n            for (int ddci = 0; ddci < dataDocumentConditionListSize; ddci++) {\n                EntityValue dataDocumentCondition = (EntityValue) ddi.dataDocumentConditionList.get(ddci)\n                String fieldAlias = (String) dataDocumentCondition.getNoCheckSimple(\"fieldNameAlias\")\n                FieldInfo fi = ed.getFieldInfo(fieldAlias)\n                if (fi == null) throw new EntityException(\"Found DataDocument Condition with alias [${fieldAlias}] that is not aliased in DataDocument ${ddi.dataDocumentId}\")\n                if (dataDocumentCondition.getNoCheckSimple(\"postQuery\") != \"Y\") {\n                    String operator = ((String) dataDocumentCondition.getNoCheckSimple(\"operator\")) ?: 'equals'\n                    String toFieldAlias = (String) dataDocumentCondition.getNoCheckSimple(\"toFieldNameAlias\")\n                    if (toFieldAlias != null && !toFieldAlias.isEmpty()) {\n                        mainFind.conditionToField(fieldAlias, EntityConditionFactoryImpl.stringComparisonOperatorMap.get(operator), toFieldAlias)\n                    } else {\n                        String stringVal = (String) dataDocumentCondition.getNoCheckSimple(\"fieldValue\")\n                        Object objVal = fi.convertFromString(stringVal, eci.l10nFacade)\n                        mainFind.condition(fieldAlias, operator, objVal)\n                    }\n                }\n            }\n        }\n\n        // create a condition with an OR list of date range comparisons to check that at least one member-entity has lastUpdatedStamp in range\n        if ((Object) fromUpdateStamp != null || (Object) thruUpdatedStamp != null) {\n            List<EntityCondition> dateRangeOrCondList = []\n            for (MNode memberEntityNode in ed.entityNode.children(\"member-entity\")) {\n                ConditionField ludCf = new ConditionAlias(memberEntityNode.attribute(\"entity-alias\"),\n                        \"lastUpdatedStamp\", efi.getEntityDefinition(memberEntityNode.attribute(\"entity-name\")))\n                List<EntityCondition> dateRangeFieldCondList = []\n                if ((Object) fromUpdateStamp != null) {\n                    dateRangeFieldCondList.add(efi.getConditionFactory().makeCondition(\n                            new FieldValueCondition(ludCf, EntityCondition.EQUALS, null),\n                            EntityCondition.OR,\n                            new FieldValueCondition(ludCf, EntityCondition.GREATER_THAN_EQUAL_TO, fromUpdateStamp)))\n                }\n                if ((Object) thruUpdatedStamp != null) {\n                    dateRangeFieldCondList.add(efi.getConditionFactory().makeCondition(\n                            new FieldValueCondition(ludCf, EntityCondition.EQUALS, null),\n                            EntityCondition.OR,\n                            new FieldValueCondition(ludCf, EntityCondition.LESS_THAN, thruUpdatedStamp)))\n                }\n                dateRangeOrCondList.add(efi.getConditionFactory().makeCondition(dateRangeFieldCondList, EntityCondition.AND))\n            }\n            mainFind.condition(efi.getConditionFactory().makeCondition(dateRangeOrCondList, EntityCondition.OR))\n        }\n\n        // use a read only clone if available, this always runs async or for reporting anyway\n        mainFind.useClone(true)\n\n        // logger.warn(\"=========== DataDocument query condition for ${dataDocumentId} mainFind.condition=${((EntityFindImpl) mainFind).getWhereEntityCondition()}\\n${mainFind.toString()}\")\n        return mainFind\n    }\n\n    /** Build data document Maps from DB data, feed in batches to specified service. This is called from the SearchServices.index#DataFeedDocuments service */\n    int feedDataDocuments(String dataDocumentId, EntityCondition condition, Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp,\n            String feedReceiveServiceName, Integer batchSizeOvd) {\n        if (feedReceiveServiceName == null || feedReceiveServiceName.isEmpty()) {\n            logger.warn(\"In feedDataDocuments no feed receive service name specified, not searching and feeding ${dataDocumentId} documents\")\n            return 0\n        }\n        int batchSize = batchSizeOvd != null ? batchSizeOvd.intValue() : 1000\n        logger.info(\"Feeding data documents for dataDocumentId ${dataDocumentId} in batches of ${batchSize} to service ${feedReceiveServiceName}\")\n\n        DataDocumentInfo ddi = new DataDocumentInfo(dataDocumentId, efi)\n\n        long startTimeMillis = System.currentTimeMillis()\n        Timestamp docTimestamp = thruUpdatedStamp != (Timestamp) null ? thruUpdatedStamp : new Timestamp(startTimeMillis)\n        String docTsString = docTimestamp.toInstant().atZone(ZoneOffset.UTC.normalized()).format(DateTimeFormatter.ISO_INSTANT)\n\n        boolean hasAllPrimaryPks = ddi.hasAllPrimaryPks\n        if (!hasAllPrimaryPks) logger.warn(\"DataDocument ${dataDocumentId} does not have all primary keys for feed to service ${feedReceiveServiceName}\")\n        Map<String, Map> documentMapMap = hasAllPrimaryPks ? new LinkedHashMap<String, Map>(batchSize + 10) : null\n        ArrayList<Map> documentMapList = hasAllPrimaryPks ? null : new ArrayList<Map>(batchSize + 10)\n\n        EntityFind mainFind = makeDataDocumentFind(ddi, fromUpdateStamp, thruUpdatedStamp)\n        if (condition != null) mainFind.condition(condition)\n\n        // for this to work sort by primary key fields (of primary entity) so all records for a given document are together\n        mainFind.orderBy(ddi.primaryPkFieldNames)\n\n        // do the one big query\n        String lastDocId = null\n        int docCount = 0\n        try (EntityListIterator mainEli = mainFind.iterator()) {\n            logger.info(\"Feed dataDocumentId ${dataDocumentId} query complete (cursor opened) in ${System.currentTimeMillis() - startTimeMillis}ms\")\n            EntityValue ev\n            while ((ev = (EntityValue) mainEli.next()) != null) {\n                String curDocId = ddi.makeDocId(ev)\n                if (!curDocId.equals(lastDocId)) {\n                    docCount++\n\n                    // index the batch if time to, with sort by PK fields when we get a new combined doc ID\n                    //     we are in results between documents (single document often has multiple rows)\n                    int docsSoFar = hasAllPrimaryPks ? documentMapMap.size() : documentMapList.size()\n                    if (docsSoFar >= batchSize) {\n                        // logger.warn(\"curDocId ${curDocId} lastDocId ${lastDocId}\")\n\n                        if (hasAllPrimaryPks) {\n                            documentMapList = new ArrayList<>(documentMapMap.values())\n                        }\n                        postProcessDocMapList(documentMapList, ddi)\n\n                        // call the feed receive service\n                        efi.ecfi.serviceFacade.sync().name(feedReceiveServiceName).parameter(\"documentList\", documentMapList)\n                                .noRememberParameters().call()\n                        // stop if there was an error\n                        if (efi.ecfi.getEci().messageFacade.hasError()) break\n\n                        documentMapMap = hasAllPrimaryPks ? new LinkedHashMap<String, Map>(batchSize + 10) : null\n                        documentMapList = hasAllPrimaryPks ? null : new ArrayList<Map>(batchSize + 10)\n                    }\n                }\n\n                // continue current doc or ready to move on to next doc, merge the current result\n                lastDocId = mergeValueToDocMap(ev, ddi, documentMapMap, documentMapList, docTsString)\n            }\n            // feed remaining documents\n            if (documentMapMap != null && documentMapMap.size() > 0) {\n                documentMapList = new ArrayList<>(documentMapMap.values())\n            }\n            if (documentMapList != null && documentMapList.size() > 0) {\n                postProcessDocMapList(documentMapList, ddi)\n                // call the feed receive service\n                efi.ecfi.serviceFacade.sync().name(feedReceiveServiceName).parameter(\"documentList\", documentMapList).call()\n            }\n        } finally {\n            logger.info(\"Feed dataDocumentId ${dataDocumentId} feed complete and cursor closed in ${System.currentTimeMillis() - startTimeMillis}ms\")\n        }\n\n        logger.info(\"Fed ${docCount} data documents for dataDocumentId ${dataDocumentId} to service ${feedReceiveServiceName}\")\n        return docCount\n    }\n\n    ArrayList<Map> getDataDocuments(String dataDocumentId, EntityCondition condition, Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp) {\n        DataDocumentInfo ddi = new DataDocumentInfo(dataDocumentId, efi)\n\n        EntityFind mainFind = makeDataDocumentFind(ddi, fromUpdateStamp, thruUpdatedStamp)\n        if (condition != null) mainFind.condition(condition)\n\n        Timestamp docTimestamp = thruUpdatedStamp != (Timestamp) null ? thruUpdatedStamp : new Timestamp(System.currentTimeMillis())\n        String docTsString = docTimestamp.toInstant().atZone(ZoneOffset.UTC.normalized()).format(DateTimeFormatter.ISO_INSTANT)\n\n        Map<String, Map> documentMapMap = ddi.hasAllPrimaryPks ? new LinkedHashMap<String, Map>() : null\n        ArrayList<Map> documentMapList = ddi.hasAllPrimaryPks ? null : new ArrayList<Map>()\n\n        // do the one big query\n\n        mainFind.iterator().withCloseable ({mainEli->\n            EntityValue ev\n            while ((ev = (EntityValue) mainEli.next()) != null) {\n                // logger.warn(\"=========== DataDocument query result for ${dataDocumentId}: ${ev}\")\n                mergeValueToDocMap(ev, ddi, documentMapMap, documentMapList, docTsString)\n            }\n        })\n\n        // make the actual list and return it\n        if (ddi.hasAllPrimaryPks) {\n            documentMapList = new ArrayList<>(documentMapMap.size())\n            documentMapList.addAll(documentMapMap.values())\n        }\n\n        postProcessDocMapList(documentMapList, ddi)\n\n        return documentMapList\n    }\n\n    String mergeValueToDocMap(EntityValue ev, DataDocumentInfo ddi, Map<String, Map> documentMapMap,\n            ArrayList<Map> documentMapList, String docTsString) {\n        /*\n          - _index = DataDocument.indexName\n          - _type = dataDocumentId\n          - _id = pk field values from primary entity, double colon separated\n          - _timestamp = document created time\n          - Map for primary entity with primaryEntityName as key\n          - nested List of Maps for each related entity with aliased fields with relationship name as key\n         */\n        String docId = ddi.makeDocId(ev)\n        // logger.warn(\"DataDoc record PKs string: \" + docId)\n        Map<String, Object> docMap = ddi.hasAllPrimaryPks ? ((Map<String, Object>) documentMapMap.get(docId)) : (Map<String, Object>) null\n        if (docMap == null) {\n            // add special entries\n            docMap = new LiteStringMap<Object>()\n            docMap.put(\"_type\", ddi.dataDocumentId)\n            if (docId != null && !docId.isEmpty()) docMap.put(\"_id\", docId)\n            docMap.put('_timestamp', docTsString)\n            String _index = ddi.dataDocument.indexName\n            if (_index != null && !_index.isEmpty()) docMap.put('_index', _index.toLowerCase())\n            docMap.put('_entity', ddi.primaryEd.getShortOrFullEntityName())\n\n            // add Map for primary entity\n            for (Map.Entry<String, Object> fieldTreeEntry in ddi.fieldTree.entrySet()) {\n                Object entryValue = fieldTreeEntry.getValue()\n                // if (\"_ALIAS\".equals(fieldTreeEntry.getKey())) continue\n                if (entryValue instanceof ArrayList) {\n                    String fieldEntryKey = fieldTreeEntry.getKey()\n                    if (fieldEntryKey.startsWith(\"(\")) continue\n                    ArrayList<String> fieldAliasList = (ArrayList<String>) entryValue\n                    for (int i = 0; i < fieldAliasList.size(); i++) {\n                        String fieldAlias = (String) fieldAliasList.get(i)\n                        Object curVal = ev.get(fieldAlias)\n                        if (curVal != null) docMap.put(fieldAlias, curVal)\n                    }\n                }\n            }\n\n            if (ddi.hasAllPrimaryPks) documentMapMap.put(docId, docMap)\n            else documentMapList.add(docMap)\n        }\n\n        // recursively add Map or List of Maps for each related entity\n        populateDataDocRelatedMap(ev, docMap, ddi.primaryEd, ddi.fieldTree, ddi.relationshipAliasMap, false)\n\n        return docId\n    }\n    void postProcessDocMapList(ArrayList<Map> documentMapList, DataDocumentInfo ddi) {\n        String manualDataServiceName = (String) ddi.dataDocument.getNoCheckSimple(\"manualDataServiceName\")\n        // NOTE: have to get size() each time in case records are removed\n        for (int i = 0; i < documentMapList.size(); ) {\n            Map<String, Object> docMap = (Map<String, Object>) documentMapList.get(i)\n            // call the manualDataServiceName service for each document\n            if (manualDataServiceName != null && !manualDataServiceName.isEmpty()) {\n                // logger.warn(\"Calling ${manualDataServiceName} with doc: ${docMap}\")\n                Map result = efi.ecfi.serviceFacade.sync().name(manualDataServiceName)\n                        .parameter(\"dataDocumentId\", ddi.dataDocumentId).parameter(\"document\", docMap).call()\n                if (result == null || efi.ecfi.getEci().messageFacade.hasError()) {\n                    logger.error(\"Error calling manual data service for ${ddi.dataDocumentId}, document may be missing data: ${efi.ecfi.getEci().messageFacade.getErrorsString()}\")\n                    efi.ecfi.getEci().messageFacade.clearErrors()\n                } else {\n                    Map outDoc = (Map<String, Object>) result.get(\"document\")\n                    if (outDoc != null && outDoc.size() > 0) {\n                        docMap = outDoc\n                        documentMapList.set(i, docMap)\n                    }\n                }\n            }\n\n            // evaluate expression fields\n            if (ddi.hasExpressionField) {\n                runDocExpressions(docMap, null, ddi.primaryEd, ddi.fieldTree, ddi.relationshipAliasMap)\n            }\n\n            // check postQuery conditions\n            boolean allPassed = true\n            int dataDocumentConditionListSize = ddi.dataDocumentConditionList.size()\n            for (int ddci = 0; ddci < dataDocumentConditionListSize; ddci++) {\n                EntityValue dataDocumentCondition = (EntityValue) ddi.dataDocumentConditionList.get(ddci)\n                if (\"Y\".equals(dataDocumentCondition.postQuery)) {\n                    Set<Object> valueSet = new HashSet<Object>()\n                    CollectionUtilities.findAllFieldsNestedMap((String) dataDocumentCondition.getNoCheckSimple(\"fieldNameAlias\"), docMap, valueSet)\n                    if (valueSet.size() == 0) {\n                        if (!dataDocumentCondition.getNoCheckSimple(\"fieldValue\")) { continue }\n                        else { allPassed = false; break }\n                    }\n                    if (!dataDocumentCondition.getNoCheckSimple(\"fieldValue\")) { allPassed = false; break }\n                    Object fieldValueObj = dataDocumentCondition.getNoCheckSimple(\"fieldValue\").asType(valueSet.first().class)\n                    if (!(fieldValueObj in valueSet)) { allPassed = false; break }\n                }\n            }\n\n            if (allPassed) { i++ } else { documentMapList.remove(i) }\n        }\n    }\n\n    static ArrayList<String> fieldPathToList(String fieldPath) {\n        int openParenIdx = fieldPath.indexOf(\"(\")\n        ArrayList<String> fieldPathElementList = new ArrayList<>()\n        if (openParenIdx == -1) {\n            Collections.addAll(fieldPathElementList, fieldPath.split(\":\"))\n        } else {\n            if (openParenIdx > 0) {\n                // should end with a colon so subtract 1\n                String preParen = fieldPath.substring(0, openParenIdx - 1)\n                Collections.addAll(fieldPathElementList, preParen.split(\":\"))\n                fieldPathElementList.add(fieldPath.substring(openParenIdx))\n            } else {\n                fieldPathElementList.add(fieldPath)\n            }\n        }\n        return fieldPathElementList\n    }\n    static void populateFieldTreeAndAliasPathMap(EntityList dataDocumentFieldList, List<String> primaryPkFieldNames,\n                                          Map<String, Object> fieldTree, Map<String, String> fieldAliasPathMap, AtomicBoolean hasExprMut, boolean allPks) {\n        for (EntityValue dataDocumentField in dataDocumentFieldList) {\n            String fieldPath = (String) dataDocumentField.getNoCheckSimple(\"fieldPath\")\n            ArrayList<String> fieldPathElementList = fieldPathToList(fieldPath)\n            Map currentTree = fieldTree\n            int fieldPathElementListSize = fieldPathElementList.size()\n            for (int i = 0; i < fieldPathElementListSize; i++) {\n                String fieldPathElement = (String) fieldPathElementList.get(i)\n                if (i < (fieldPathElementListSize - 1)) {\n                    Map subTree = (Map) currentTree.get(fieldPathElement)\n                    if (subTree == null) { subTree = [:]; currentTree.put(fieldPathElement, subTree) }\n                    currentTree = subTree\n                } else {\n                    String fieldAlias = ((String) dataDocumentField.getNoCheckSimple(\"fieldNameAlias\")) ?: fieldPathElement\n                    CollectionUtilities.addToListInMap(fieldPathElement, fieldAlias, currentTree)\n                    fieldAliasPathMap.put(fieldAlias, fieldPath)\n                    if (fieldPathElement.startsWith(\"(\")) hasExprMut.set(true)\n                }\n            }\n        }\n        // make sure all PK fields of the primary entity are aliased\n        if (allPks) {\n            for (String pkFieldName in primaryPkFieldNames) if (!fieldAliasPathMap.containsKey(pkFieldName)) {\n                fieldTree.put(pkFieldName, pkFieldName)\n                fieldAliasPathMap.put(pkFieldName, pkFieldName)\n            }\n        }\n    }\n\n    protected void runDocExpressions(Map<String, Object> curDocMap, Map<String, Object> parentsMap, EntityDefinition parentEd,\n                                     Map<String, Object> fieldTreeCurrent, Map relationshipAliasMap) {\n        for (Map.Entry<String, Object> fieldTreeEntry in fieldTreeCurrent.entrySet()) {\n            String fieldEntryKey = fieldTreeEntry.getKey()\n            Object fieldEntryValue = fieldTreeEntry.getValue()\n            if (fieldEntryValue instanceof Map) {\n                String relationshipName = fieldEntryKey\n                Map<String, Object> fieldTreeChild = (Map<String, Object>) fieldEntryValue\n\n                EntityJavaUtil.RelationshipInfo relationshipInfo = parentEd.getRelationshipInfo(relationshipName)\n                String relDocumentAlias = relationshipAliasMap.get(relationshipName) ?: relationshipInfo.shortAlias ?: relationshipName\n                EntityDefinition relatedEd = relationshipInfo.relatedEd\n                boolean isOneRelationship = relationshipInfo.isTypeOne\n\n                if (isOneRelationship) {\n                    runDocExpressions(curDocMap, parentsMap, relatedEd, fieldTreeChild, relationshipAliasMap)\n                } else {\n                    List<Map> relatedEntityDocList = (List<Map>) curDocMap.get(relDocumentAlias)\n                    if (relatedEntityDocList != null) for (Map childMap in relatedEntityDocList) {\n                        Map<String, Object> newParentsMap\n                        if (parentsMap != null) {\n                            newParentsMap = new HashMap<String, Object>(parentsMap)\n                            newParentsMap.putAll(curDocMap)\n                        } else {\n                            newParentsMap = curDocMap\n                        }\n                        runDocExpressions(childMap, newParentsMap, relatedEd, fieldTreeChild, relationshipAliasMap)\n                    }\n                }\n            } else if (fieldEntryValue instanceof ArrayList) {\n                if (fieldEntryKey.startsWith(\"(\")) {\n                    // run expression to get value, set for all aliases (though will always be one)\n                    Map<String, Object> evalMap\n                    if (parentsMap != null) {\n                        evalMap = new HashMap<String, Object>(parentsMap)\n                        evalMap.putAll(curDocMap)\n                    } else {\n                        evalMap = curDocMap\n                    }\n                    try {\n                        Object curVal = efi.ecfi.resourceFacade.expression(fieldEntryKey, null, evalMap)\n                        if (curVal != null) {\n                            ArrayList<String> fieldAliasList = (ArrayList<String>) fieldEntryValue\n                            for (int i = 0; i < fieldAliasList.size(); i++) {\n                                String fieldAlias = (String) fieldAliasList.get(i)\n                                if (curVal != null) curDocMap.put(fieldAlias, curVal)\n                            }\n                        }\n                    } catch (Throwable t) {\n                        logger.error(\"Error evaluating DataDocumentField expression: ${fieldEntryKey}\", t)\n                    }\n                }\n            }\n        }\n    }\n\n    protected void populateDataDocRelatedMap(EntityValue ev, Map<String, Object> parentDocMap, EntityDefinition parentEd,\n                                             Map<String, Object> fieldTreeCurrent, Map relationshipAliasMap, boolean setFields) {\n        for (Map.Entry<String, Object> fieldTreeEntry in fieldTreeCurrent.entrySet()) {\n            String fieldEntryKey = fieldTreeEntry.getKey()\n            Object fieldEntryValue = fieldTreeEntry.getValue()\n            // if (\"_ALIAS\".equals(fieldEntryKey)) continue\n            if (fieldEntryValue instanceof Map) {\n                String relationshipName = fieldEntryKey\n                Map<String, Object> fieldTreeChild = (Map<String, Object>) fieldEntryValue\n\n                EntityJavaUtil.RelationshipInfo relationshipInfo = parentEd.getRelationshipInfo(relationshipName)\n                String relDocumentAlias = relationshipAliasMap.get(relationshipName) ?: relationshipInfo.shortAlias ?: relationshipName\n                EntityDefinition relatedEd = relationshipInfo.relatedEd\n                boolean isOneRelationship = relationshipInfo.isTypeOne\n\n                if (isOneRelationship) {\n                    // we only need a single Map\n                    populateDataDocRelatedMap(ev, parentDocMap, relatedEd, fieldTreeChild, relationshipAliasMap, true)\n                } else {\n                    // we need a List of Maps\n                    Map relatedEntityDocMap = (Map) null\n\n                    // see if there is a Map in the List in the matching entry\n                    List<Map> relatedEntityDocList = (List<Map>) parentDocMap.get(relDocumentAlias)\n                    if (relatedEntityDocList != null) {\n                        for (Map candidateMap in relatedEntityDocList) {\n                            boolean allMatch = true\n                            for (Map.Entry<String, Object> fieldTreeChildEntry in fieldTreeChild.entrySet()) {\n                                Object entryValue = fieldTreeChildEntry.getValue()\n                                if (entryValue instanceof ArrayList && !fieldTreeChildEntry.getKey().startsWith(\"(\")) {\n                                    ArrayList<String> fieldAliasList = (ArrayList<String>) entryValue\n                                    for (int i = 0; i < fieldAliasList.size(); i++) {\n                                        String fieldAlias = (String) fieldAliasList.get(i)\n                                        if (candidateMap.get(fieldAlias) != ev.get(fieldAlias)) {\n                                            allMatch = false\n                                            break\n                                        }\n                                    }\n                                }\n                            }\n                            if (allMatch) {\n                                relatedEntityDocMap = candidateMap\n                                break\n                            }\n                        }\n                    }\n\n                    if (relatedEntityDocMap == null) {\n                        // no matching Map? create a new one... and it will get populated in the recursive call\n                        relatedEntityDocMap = new LiteStringMap<Object>()\n                        // now time to recurse\n                        populateDataDocRelatedMap(ev, relatedEntityDocMap, relatedEd, fieldTreeChild, relationshipAliasMap, true)\n                        if (relatedEntityDocMap.size() > 0) {\n                            if (relatedEntityDocList == null) {\n                                // use ArrayList internally, avoid new object per entry with LinkedList\n                                relatedEntityDocList = new ArrayList<>()\n                                parentDocMap.put(relDocumentAlias, relatedEntityDocList)\n                            }\n                            relatedEntityDocList.add(relatedEntityDocMap)\n                        }\n                    } else {\n                        // now time to recurse\n                        populateDataDocRelatedMap(ev, relatedEntityDocMap, relatedEd, fieldTreeChild, relationshipAliasMap, false)\n                    }\n                }\n            } else if (fieldEntryValue instanceof ArrayList) {\n                if (setFields && !fieldEntryKey.startsWith(\"(\")) {\n                    // set the field(s)\n                    ArrayList<String> fieldAliasList = (ArrayList<String>) fieldEntryValue\n                    for (int i = 0; i < fieldAliasList.size(); i++) {\n                        String fieldAlias = (String) fieldAliasList.get(i)\n                        Object curVal = ev.get(fieldAlias)\n                        if (curVal != null) parentDocMap.put(fieldAlias, curVal)\n                    }\n                }\n            }\n        }\n    }\n\n    private static Map<String, EntityValue> makeDdfByAlias(EntityList dataDocumentFieldList) {\n        Map<String, EntityValue> ddfByAlias = new HashMap<>()\n        int ddfSize = dataDocumentFieldList.size()\n        for (int i = 0; i < ddfSize; i++) {\n            EntityValue ddf = (EntityValue) dataDocumentFieldList.get(i)\n            String alias = (String) ddf.getNoCheckSimple(\"fieldNameAlias\")\n            if (alias == null || alias.isEmpty()) {\n                String fieldPath = (String) ddf.getNoCheckSimple(\"fieldPath\")\n                ArrayList<String> fieldPathElementList = fieldPathToList(fieldPath)\n                alias = (String) fieldPathElementList.get(fieldPathElementList.size() - 1)\n            }\n            ddfByAlias.put(alias, ddf)\n        }\n        return ddfByAlias\n    }\n    private static void addDataDocRelatedEntity(EntityDynamicViewImpl dynamicView, String parentEntityAlias,\n            Map<String, Object> fieldTreeCurrent, AtomicInteger incrementer, Map<String, EntityValue> ddfByAlias) {\n        for (Map.Entry fieldTreeEntry in fieldTreeCurrent.entrySet()) {\n            String fieldEntryKey = (String) fieldTreeEntry.getKey()\n            if (\"_ALIAS\".equals(fieldEntryKey)) continue\n\n            Object entryValue = fieldTreeEntry.getValue()\n            if (entryValue instanceof Map) {\n                Map fieldTreeChild = (Map) entryValue\n                // add member entity, and entity alias in \"_ALIAS\" entry\n                String entityAlias = \"MBR\" + incrementer.getAndIncrement()\n                dynamicView.addRelationshipMember(entityAlias, parentEntityAlias, fieldEntryKey, true)\n                fieldTreeChild.put(\"_ALIAS\", entityAlias)\n                // now time to recurse\n                addDataDocRelatedEntity(dynamicView, entityAlias, fieldTreeChild, incrementer, ddfByAlias)\n            } else if (entryValue instanceof ArrayList) {\n                // add alias for field\n                String entityAlias = fieldTreeCurrent.get(\"_ALIAS\")\n                ArrayList<String> fieldAliasList = (ArrayList<String>) entryValue\n                for (int i = 0; i < fieldAliasList.size(); i++) {\n                    String fieldAlias = (String) fieldAliasList.get(i)\n                    EntityValue ddf = ddfByAlias.get(fieldAlias)\n                    if (ddf == null) throw new EntityException(\"Could not find DataDocumentField for field alias ${fieldEntryKey}\")\n                    String defaultDisplay = ddf.getNoCheckSimple(\"defaultDisplay\")\n\n                    if (fieldEntryKey.startsWith(\"(\")) {\n                        // handle expressions differently, expressions have to be meant for this but nice for various cases\n                        // TODO: somehow specify type, yet another new field on DataDocumentField entity? for now defaulting to 'text-long'\n                        dynamicView.addPqExprAlias(fieldAlias, fieldEntryKey, \"text-long\",\n                                \"N\".equals(defaultDisplay) ? \"false\" : (\"Y\".equals(defaultDisplay) ? \"true\" : null))\n                    } else {\n                        dynamicView.addAlias(entityAlias, fieldAlias, fieldEntryKey, (String) ddf.getNoCheckSimple(\"functionName\"),\n                                \"N\".equals(defaultDisplay) ? \"false\" : (\"Y\".equals(defaultDisplay) ? \"true\" : null))\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityDataFeed.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity\n\nimport groovy.transform.CompileStatic\nimport org.moqui.entity.EntityCondition\nimport org.moqui.entity.EntityException\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.impl.entity.EntityJavaUtil.RelationshipInfo\nimport org.moqui.jcache.MCache\n\nimport javax.cache.Cache\nimport jakarta.transaction.Status\nimport jakarta.transaction.Synchronization\nimport jakarta.transaction.Transaction\nimport jakarta.transaction.TransactionManager\nimport javax.transaction.xa.XAException\nimport java.sql.Timestamp\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.util.concurrent.RejectedExecutionException\n\n@CompileStatic\nclass EntityDataFeed {\n    protected final static Logger logger = LoggerFactory.getLogger(EntityDataFeed.class)\n\n    protected final EntityFacadeImpl efi\n\n    protected final MCache<String, ArrayList<DocumentEntityInfo>> dataFeedEntityInfo\n    Set<String> entitiesWithDataFeed = null\n\n    EntityDataFeed(EntityFacadeImpl efi) {\n        this.efi = efi\n        dataFeedEntityInfo = efi.ecfi.cacheFacade.getLocalCache(\"entity.data.feed.info\")\n    }\n\n    EntityFacadeImpl getEfi() { return efi }\n\n    /** This method gets the latest documents for a DataFeed based on DataFeed.lastFeedStamp, and updates lastFeedStamp\n     * to the current time. This method should be called in a service or something to manage the transaction.\n     * See the org.moqui.impl.EntityServices.get#DataFeedLatestDocuments service.*/\n    List<Map> getFeedLatestDocuments(String dataFeedId) {\n        EntityValue dataFeed = efi.find(\"moqui.entity.feed.DataFeed\").condition(\"dataFeedId\", dataFeedId)\n                .useCache(false).forUpdate(true).one()\n        Timestamp fromUpdateStamp = dataFeed.getTimestamp(\"lastFeedStamp\")\n        Timestamp thruUpdateStamp = new Timestamp(System.currentTimeMillis())\n        // get the List first, if no errors update lastFeedStamp\n        List<Map> documentList = getFeedDocuments(dataFeedId, fromUpdateStamp, thruUpdateStamp)\n        dataFeed.lastFeedStamp = thruUpdateStamp\n        dataFeed.update()\n        return documentList\n    }\n\n    ArrayList<Map> getFeedDocuments(String dataFeedId, Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp) {\n        EntityList dataFeedDocumentList = efi.find(\"moqui.entity.feed.DataFeedDocument\")\n                .condition(\"dataFeedId\", dataFeedId).useCache(true).list()\n\n        ArrayList<Map> fullDocumentList = new ArrayList<>()\n        for (EntityValue dataFeedDocument in dataFeedDocumentList) {\n            String dataDocumentId = dataFeedDocument.dataDocumentId\n            ArrayList<Map> curDocList = efi.getDataDocuments(dataDocumentId, null, fromUpdateStamp, thruUpdatedStamp)\n            fullDocumentList.addAll(curDocList)\n        }\n        return fullDocumentList\n    }\n\n    /* Notes for real-time push DataFeed:\n    - doing update on entity have entityNames updated, for each fieldNames updated, field values (query as needed based\n        on actual conditions if any conditions on fields not present in EntityValue\n    - do this based on a committed transaction of changes, not just on a single record...\n    - keep data for documents to include until transaction committed\n\n    to quickly lookup DataDocuments updated with a corresponding real time (DTFDTP_RT_PUSH) DataFeed need:\n    - don't have to constrain by real time DataFeed, will be done in advance for index\n    - Map with entityName as key\n    - value is List of Map with:\n      - dataFeedId\n      - List of DocumentEntityInfo objects with\n        - dataDocumentId\n        - Set of fields for DataDocument and the current entity\n        - primaryEntityName\n        - relationship path from primary to current entity\n        - Map of field conditions for current entity - and for entire document? no, false positives filtered out when doc data queried\n    - find with query on DataFeed and DataFeedDocument where DataFeed.dataFeedTypeEnumId=DTFDTP_RT_PUSH\n      - iterate through dataDocumentId and call getDataDocumentEntityInfo() for each\n\n    to produce the document with zero or minimal query\n    - during transaction save all created or updated records in EntityList updatedList (in EntityFacadeImpl?)\n    - EntityValues added to the list only if they are in the\n\n    - once we have dataDocumentIdSet use to lookup all DTFDTP_RT_PUSH DataFeed with a matching DataFeedDocument record\n    - look up primary entity value for the current updated value and use its PK fields as a condition to call\n        getDataDocuments() so that we get a document for just the updated record(s)\n\n     */\n\n    void dataFeedCheckAndRegister(EntityValue ev, boolean isUpdate, Map valueMap, Map oldValues) {\n        boolean shouldLogDetail = false\n        // if (ev.resolveEntityName().startsWith(\"WikiPage\")) logger.warn(\"============== DataFeed checking entity isModified=${ev.isModified()} [${ev.resolveEntityName()}] value: ${ev}\")\n        if (shouldLogDetail) logger.warn(\"======= dataFeedCheckAndRegister update? ${isUpdate} mod? ${ev.isModified()}\\nev: ${ev}\\noldValues=${oldValues}\")\n\n        // if the value isn't modified don't register for DataFeed at all\n        if (!ev.isModified()) {\n            if (shouldLogDetail) logger.warn(\"Not registering ${ev.resolveEntityName()} PK ${ev.getPrimaryKeys()}, is not modified\")\n            return\n        }\n        if (isUpdate && oldValues == null) {\n            if (shouldLogDetail) logger.warn(\"Not registering ${ev.resolveEntityName()} PK ${ev.getPrimaryKeys()}, isUpdate and oldValues is null\")\n            return\n        }\n\n        // see if this should be added to the feed\n        ArrayList<DocumentEntityInfo> entityInfoList\n        try {\n            entityInfoList = getDataFeedEntityInfoList(ev.resolveEntityName())\n        } catch (Throwable t) {\n            logger.error(\"Error getting DataFeed entity info, not registering value for entity ${ev.resolveEntityName()}\", t)\n            return\n        }\n        if (shouldLogDetail) logger.warn(\"======= dataFeedCheckAndRegister ${ev.resolveEntityName()} entityInfoList size ${entityInfoList.size()}\")\n        if (entityInfoList.size() > 0) {\n            // logger.warn(\"============== found registered entity [${ev.resolveEntityName()}] value: ${ev}\")\n\n            // populate and pass the dataDocumentIdSet, and/or other things needed?\n            Set<String> dataDocumentIdSet = new HashSet<String>()\n            for (DocumentEntityInfo entityInfo in entityInfoList) {\n                // only add value if a field in the document was changed\n                boolean fieldModified = false\n                for (String fieldName in entityInfo.fields) {\n                    // logger.warn(\"DataFeed ${entityInfo.dataDocumentId} check field ${fieldName} isUpdate ${isUpdate} isFieldModified ${ev.isFieldModified(fieldName)} value ${valueMap.get(fieldName)} oldValue ${oldValues?.get(fieldName)}\")\n                    if (ev.isFieldModified(fieldName)) { fieldModified = true; break }\n\n                    if (!valueMap.containsKey(fieldName)) continue\n\n                    Object value = valueMap.get(fieldName)\n                    Object oldValue = oldValues?.get(fieldName)\n\n                    // logger.warn(\"DataFeed ${entityInfo.dataDocumentId} check field ${fieldName} isUpdate ${isUpdate} value ${value} oldValue ${oldValue} continue ${(isUpdate && value == oldValue) || (!isUpdate && value == null)}\")\n\n                    // if isUpdate but old value == new value, then it hasn't been updated, so skip it\n                    if (isUpdate && value == oldValue) continue\n                    // if it's a create and there is no value don't log a change\n                    if (!isUpdate && value == null) continue\n\n                    fieldModified = true\n                }\n                if (!fieldModified) continue\n\n                // only add value and dataDocumentId if there are no conditions or if this record matches all conditions\n                //     (not necessary, but this is an optimization to avoid false positives)\n                boolean matchedConditions = true\n                if (entityInfo.conditions) for (Map.Entry<String, String> conditionEntry in entityInfo.conditions.entrySet()) {\n                    Object evValue = ev.get(conditionEntry.getKey())\n                    // if ev doesn't have field populated, ignore the condition; we'll pick it up later in the big document query\n                    if (evValue == null) continue\n                    if (evValue != conditionEntry.getValue()) { matchedConditions = false; break }\n                }\n\n                if (!matchedConditions) continue\n\n                // if we get here field(s) were modified and condition(s) passed\n                dataDocumentIdSet.add(entityInfo.dataDocumentId)\n            }\n\n            if (!dataDocumentIdSet.isEmpty()) {\n                // logger.warn(\"============== DataFeed registering entity value [${ev.resolveEntityName()}] value: ${ev.getPrimaryKeys()}\")\n                // NOTE: comment out this line to disable real-time push DataFeed in one simple place:\n                getDataFeedSynchronization().addValueToFeed(ev, dataDocumentIdSet)\n            } else if (shouldLogDetail) {\n                logger.warn(\"Not registering ${ev.resolveEntityName()} PK ${ev.getPrimaryKeys()}, dataDocumentIdSet is empty\")\n            }\n        }\n    }\n    void dataFeedCheckDelete(EntityValue ev) {\n        String entityName = ev.resolveEntityName()\n        if (entityName == null || entityName.isEmpty()) {\n            logger.error(\"Tried to do data feed delete with no entity name for ev: ${ev.toString()}\")\n            return\n        }\n        if (!ev.containsPrimaryKey()) {\n            logger.error(\"Tried to do data feed delete with missing PK field values, ev: ${ev.toString()}\")\n            return\n        }\n\n        // is this entity in any feeds?\n        ArrayList<DocumentEntityInfo> entityInfoList\n        try {\n            entityInfoList = getDataFeedEntityInfoList(ev.resolveEntityName())\n        } catch (Throwable t) {\n            logger.error(\"Error getting DataFeed entity info, not registering delete for entity ${ev.resolveEntityName()}\", t)\n            return\n        }\n\n        if (entityInfoList.size() > 0) {\n            // for each DataDocument if is the primary entity then delete, otherwise update (regenerate)\n            Set<String> updateDocumentIdSet = new HashSet<String>()\n            Set<String> deleteDocumentIdSet = new HashSet<String>()\n            for (DocumentEntityInfo entityInfo in entityInfoList) {\n                if (entityName.equals(entityInfo.primaryEntityName)) {\n                    // need to delete the DataDocument\n                    deleteDocumentIdSet.add(entityInfo.dataDocumentId)\n                } else {\n                    // need to update the DataDocument\n                    updateDocumentIdSet.add(entityInfo.dataDocumentId)\n                }\n            }\n\n            DataFeedSynchronization dfs = getDataFeedSynchronization()\n            if (!updateDocumentIdSet.isEmpty()) {\n                // logger.warn(\"============== DataFeed registering UPDATE entity value [${ev.resolveEntityName()}] value: ${ev.getPrimaryKeys()}\")\n                dfs.addValueToFeed(ev, updateDocumentIdSet)\n            }\n            if (!deleteDocumentIdSet.isEmpty()) {\n                // logger.warn(\"============== DataFeed registering DELETE entity value [${ev.resolveEntityName()}] value: ${ev.getPrimaryKeys()}\")\n                dfs.addDeleteToFeed(ev)\n            }\n        }\n    }\n\n    protected DataFeedSynchronization getDataFeedSynchronization() {\n        DataFeedSynchronization dfxr = (DataFeedSynchronization) efi.ecfi.transactionFacade.getActiveSynchronization(\"DataFeedSynchronization\")\n        if (dfxr == null) {\n            dfxr = new DataFeedSynchronization(this)\n            dfxr.enlist()\n        }\n        return dfxr\n    }\n\n    final Set<String> dataFeedSkipEntities = new HashSet<String>(['moqui.entity.SequenceValueItem'])\n    protected final static ArrayList<DocumentEntityInfo> emptyList = new ArrayList<DocumentEntityInfo>()\n\n    // NOTE: this is called frequently (every create/update/delete)\n    ArrayList<DocumentEntityInfo> getDataFeedEntityInfoList(String fullEntityName) {\n        // see if this is a known entity in a feed\n        // NOTE: this avoids issues with false negatives from the cache or excessive rebuilds (for every entity the\n        //     first time) but means if an entity is added to a DataDocument at runtime it won't pick it up!!!!\n        // NOTE2: this could be cleared explicitly when a DataDocument is added or changed, but that is done through\n        //     direct DB stuff now (data load, etc), there is no UI or services for it\n        if (entitiesWithDataFeed == null) rebuildDataFeedEntityInfo()\n        if (!entitiesWithDataFeed.contains(fullEntityName)) return emptyList\n\n        ArrayList<DocumentEntityInfo> cachedList = (ArrayList<DocumentEntityInfo>) dataFeedEntityInfo.get(fullEntityName)\n        if (cachedList != null) return cachedList\n\n        // if this is an entity to skip, return now (do after primary lookup to avoid additional performance overhead in common case)\n        if (dataFeedSkipEntities.contains(fullEntityName)) {\n            dataFeedEntityInfo.put(fullEntityName, emptyList)\n            return emptyList\n        }\n\n        // logger.warn(\"=============== getting DocumentEntityInfo for [${fullEntityName}], from cache: ${entityInfoList}\")\n        // MAYBE (often causes issues): only rebuild if the cache is empty, most entities won't have any entry in it and don't want a rebuild for each one\n        rebuildDataFeedEntityInfo()\n        // now we should have all document entityInfos for all entities\n        cachedList = (ArrayList<DocumentEntityInfo>) dataFeedEntityInfo.get(fullEntityName)\n        if (cachedList != null) return cachedList\n\n        // remember that we don't have any info\n        dataFeedEntityInfo.put(fullEntityName, emptyList)\n        return emptyList\n    }\n\n    // this should never be called except through getDataFeedEntityInfoList()\n    private long lastRebuildTime = 0\n    protected synchronized void rebuildDataFeedEntityInfo() {\n        // under load make sure waiting threads don't redo it, give it some time\n        // NOTE: no other good way to limit this, cache entries may expire individually so we can't check to see if any are missing without a full reload\n        if (dataFeedEntityInfo.size() > 0 && System.currentTimeMillis() < (lastRebuildTime + 5000)) return\n\n        // logger.info(\"Building entity.data.feed.info cache\")\n        long startTime = System.currentTimeMillis()\n\n        // rebuild from the DB for this and other entities, ie have to do it for all DataFeeds and\n        //     DataDocuments because we can't query it by entityName\n        Map<String, ArrayList<DocumentEntityInfo>> localInfo = new HashMap<>()\n\n        EntityList dataFeedAndDocumentList = efi.find(\"moqui.entity.feed.DataFeedAndDocument\")\n                .condition(\"dataFeedTypeEnumId\", \"DTFDTP_RT_PUSH\").useCache(true).disableAuthz().list()\n        //logger.warn(\"============= got dataFeedAndDocumentList: ${dataFeedAndDocumentList}\")\n        Set<String> fullDataDocumentIdSet = new HashSet<String>()\n        int dataFeedAndDocumentListSize = dataFeedAndDocumentList.size()\n        for (int i = 0; i < dataFeedAndDocumentListSize; i++) {\n            EntityValue dataFeedAndDocument = (EntityValue) dataFeedAndDocumentList.get(i)\n            fullDataDocumentIdSet.add(dataFeedAndDocument.getString(\"dataDocumentId\"))\n        }\n\n        for (String dataDocumentId in fullDataDocumentIdSet) {\n            try {\n                Map<String, DocumentEntityInfo> entityInfoMap = getDataDocumentEntityInfo(dataDocumentId)\n                if (entityInfoMap == null) {\n                    logger.error(\"Invalid or missing DataDocument ${dataDocumentId}, ignoring for real time feed\")\n                    continue\n                }\n                // got a Map for all entities in the document, now split them by entity and add to master list for the entity\n                for (Map.Entry<String, DocumentEntityInfo> entityInfoMapEntry in entityInfoMap.entrySet()) {\n                    String entityName = entityInfoMapEntry.getKey()\n                    ArrayList<DocumentEntityInfo> newEntityInfoList = (ArrayList<DocumentEntityInfo>) localInfo.get(entityName)\n                    if (newEntityInfoList == null) {\n                        newEntityInfoList = new ArrayList<DocumentEntityInfo>()\n                        localInfo.put(entityName, newEntityInfoList)\n                        // logger.warn(\"============= added dataFeedEntityInfo entry for entity [${entityInfoMapEntry.getKey()}]\")\n                    }\n                    newEntityInfoList.add(entityInfoMapEntry.getValue())\n                }\n            } catch (Throwable t) {\n                logger.error(\"Error loading DataFeed info for DataDocument ${dataDocumentId}\", t)\n            }\n        }\n\n        Set<String> entityNameSet = localInfo.keySet()\n        if (entitiesWithDataFeed == null) {\n            logger.info(\"Built entity.data.feed.info cache in ${System.currentTimeMillis() - startTime}ms, entries for ${entityNameSet.size()} entities\")\n            if (logger.isTraceEnabled()) logger.trace(\"Built entity.data.feed.info cache for in ${System.currentTimeMillis() - startTime}ms, entries for ${entityNameSet.size()} entities: ${entityNameSet}\")\n        } else {\n            logger.info(\"Rebuilt entity.data.feed.info cache in ${System.currentTimeMillis() - startTime}ms, entries for ${entityNameSet.size()} entities\")\n        }\n        dataFeedEntityInfo.putAll(localInfo)\n        entitiesWithDataFeed = entityNameSet\n        lastRebuildTime = System.currentTimeMillis()\n    }\n\n    Map<String, DocumentEntityInfo> getDataDocumentEntityInfo(String dataDocumentId) {\n        EntityValue dataDocument = null\n        EntityList dataDocumentFieldList = null\n        EntityList dataDocumentConditionList = null\n        boolean alreadyDisabled = efi.ecfi.getExecutionContext().getArtifactExecution().disableAuthz()\n        try {\n            dataDocument = efi.fastFindOne(\"moqui.entity.document.DataDocument\", true, false, dataDocumentId)\n            if (dataDocument == null) throw new EntityException(\"No DataDocument found with ID [${dataDocumentId}]\")\n            dataDocumentFieldList = dataDocument.findRelated(\"moqui.entity.document.DataDocumentField\", null, null, true, false)\n            dataDocumentConditionList = dataDocument.findRelated(\"moqui.entity.document.DataDocumentCondition\", null, null, true, false)\n        } finally {\n            if (!alreadyDisabled) efi.ecfi.getExecutionContext().getArtifactExecution().enableAuthz()\n        }\n\n        String primaryEntityName = dataDocument.primaryEntityName\n        if (!efi.isEntityDefined(primaryEntityName)) {\n            logger.error(\"Could not find primary entity ${primaryEntityName} for DataDocument ${dataDocumentId}\")\n            return null\n        }\n        EntityDefinition primaryEd = efi.getEntityDefinition(primaryEntityName)\n\n        Map<String, DocumentEntityInfo> entityInfoMap = [:]\n\n        // start with the primary entity\n        entityInfoMap.put(primaryEntityName, new DocumentEntityInfo(primaryEntityName, dataDocumentId, primaryEntityName, \"\"))\n\n        // have to go through entire fieldTree instead of entity names directly from fieldPath because may not have hash (#) separator\n        Map<String, Object> fieldTree = new LinkedHashMap<String, Object>()\n        for (EntityValue dataDocumentField in dataDocumentFieldList) {\n            String fieldPath = (String) dataDocumentField.fieldPath\n            if (fieldPath.contains(\"(\")) continue\n            Map currentTree = fieldTree\n            DocumentEntityInfo currentEntityInfo = entityInfoMap.get(primaryEntityName)\n            StringBuilder currentRelationshipPath = new StringBuilder()\n            EntityDefinition currentEd = primaryEd\n            ArrayList<String> fieldPathElementList = EntityDataDocument.fieldPathToList(fieldPath)\n            int fieldPathElementListSize = fieldPathElementList.size()\n            for (int i = 0; i < fieldPathElementListSize; i++) {\n                String fieldPathElement = (String) fieldPathElementList.get(i)\n                if (i < (fieldPathElementListSize - 1)) {\n                    if (currentRelationshipPath.length() > 0) currentRelationshipPath.append(\":\")\n                    currentRelationshipPath.append(fieldPathElement)\n\n                    Map subTree = (Map) currentTree.get(fieldPathElement)\n                    if (subTree == null) { subTree = [:]; currentTree.put(fieldPathElement, subTree) }\n                    currentTree = subTree\n\n                    // make sure we have an entityInfo Map\n                    RelationshipInfo relInfo = currentEd.getRelationshipInfo(fieldPathElement)\n                    if (relInfo == null) throw new EntityException(\"Could not find relationship [${fieldPathElement}] from entity [${currentEd.getFullEntityName()}] as part of DataDocumentField.fieldPath [${fieldPath}]\")\n                    String relEntityName = relInfo.relatedEntityName\n                    EntityDefinition relEd = relInfo.relatedEd\n\n                    // TODO: handle entity used multiple times on different paths, perhaps with List<DocumentEntityInfo> in Map\n                    // add entry for the related entity\n                    if (!entityInfoMap.containsKey(relEntityName)) entityInfoMap.put(relEntityName,\n                            new DocumentEntityInfo(relEntityName, dataDocumentId, primaryEntityName,\n                                    currentRelationshipPath.toString()))\n\n                    // add PK fields of the related entity as fields for the current entity so changes on them will also trigger a data feed\n                    Map relKeyMap = relInfo.keyMap\n                    for (String fkFieldName in relKeyMap.keySet()) {\n                        currentTree.put(fkFieldName, fkFieldName)\n                        // save the current field name (not the alias)\n                        currentEntityInfo.fields.add(fkFieldName)\n                    }\n\n                    currentEntityInfo = entityInfoMap.get(relEntityName)\n                    currentEd = relEd\n                } else {\n                    String ddfFieldNameAlias = (String) dataDocumentField.fieldNameAlias\n                    currentTree.put(fieldPathElement, ddfFieldNameAlias != null && !ddfFieldNameAlias.isEmpty() ? ddfFieldNameAlias : fieldPathElement)\n                    // save the current field name (not the alias)\n                    currentEntityInfo.fields.add(fieldPathElement)\n                    // see if there are any conditions for this alias, if so add the fieldName/value to the entity conditions Map\n                    for (EntityValue dataDocumentCondition in dataDocumentConditionList) {\n                        if (dataDocumentCondition.fieldNameAlias == ddfFieldNameAlias)\n                            currentEntityInfo.conditions.put(fieldPathElement, (String) dataDocumentCondition.fieldValue)\n                    }\n                }\n            }\n        }\n\n        // logger.warn(\"============ got entityInfoMap for doc [${dataDocumentId}]: ${entityInfoMap}\\n============ for fieldTree: ${fieldTree}\")\n\n        return entityInfoMap\n    }\n\n    static class DocumentEntityInfo implements Serializable {\n        String fullEntityName\n        String dataDocumentId\n        String primaryEntityName\n        String relationshipPath\n        Set<String> fields = new HashSet<String>()\n        Map<String, String> conditions = [:]\n        // will we need this? Map<String, DocumentEntityInfo> subEntities\n\n        DocumentEntityInfo(String fullEntityName, String dataDocumentId, String primaryEntityName, String relationshipPath) {\n            this.fullEntityName = fullEntityName\n            this.dataDocumentId = dataDocumentId\n            this.primaryEntityName = primaryEntityName\n            this.relationshipPath = relationshipPath\n        }\n\n        @Override\n        String toString() {\n            StringBuilder sb = new StringBuilder()\n            sb.append(\"DocumentEntityInfo [\")\n            sb.append(\"fullEntityName:\").append(fullEntityName).append(\",\")\n            sb.append(\"dataDocumentId:\").append(dataDocumentId).append(\",\")\n            sb.append(\"primaryEntityName:\").append(primaryEntityName).append(\",\")\n            sb.append(\"relationshipPath:\").append(relationshipPath).append(\",\")\n            sb.append(\"fields:\").append(fields).append(\",\")\n            sb.append(\"conditions:\").append(conditions).append(\",\")\n            sb.append(\"]\")\n            return sb.toString()\n        }\n    }\n\n    @CompileStatic\n    static class DataFeedSynchronization implements Synchronization {\n        protected final static Logger logger = LoggerFactory.getLogger(DataFeedSynchronization.class)\n\n        protected ExecutionContextFactoryImpl ecfi\n        protected EntityDataFeed edf\n\n        protected Transaction tx = null\n\n        protected EntityList feedValues\n        protected EntityList deleteValues\n        protected Set<String> allDataDocumentIds = new HashSet<String>()\n\n        DataFeedSynchronization(EntityDataFeed edf) {\n            // logger.warn(\"========= Creating new DataFeedSynchronization\")\n            this.edf = edf\n            ecfi = edf.getEfi().ecfi\n            feedValues = new EntityListImpl(edf.getEfi())\n            deleteValues = new EntityListImpl(edf.getEfi())\n        }\n\n        void enlist() {\n            // logger.warn(\"========= Enlisting new DataFeedSynchronization\")\n            TransactionManager tm = ecfi.transactionFacade.getTransactionManager()\n            if (tm == null || tm.getStatus() != Status.STATUS_ACTIVE) throw new XAException(\"Cannot enlist: no transaction manager or transaction not active\")\n            Transaction tx = tm.getTransaction()\n            if (tx == null) throw new XAException(XAException.XAER_NOTA)\n            this.tx = tx\n\n            // logger.warn(\"================= puttng and enlisting new DataFeedSynchronization\")\n            ecfi.transactionFacade.putAndEnlistActiveSynchronization(\"DataFeedSynchronization\", this)\n        }\n\n        void addValueToFeed(EntityValue ev, Set<String> dataDocumentIdSet) {\n            // this log message is for an issue where Atomikos seems to suspend and resume without calling start() on\n            //     this XAResource; everything seems to work fine, but it results in funny state\n            // this can be reproduced by running the data load with DataFeed/DataDocument data already in the DB\n            // if (!active && logger.isTraceEnabled()) logger.trace(\"Adding value to inactive DataFeedSynchronization! \\nThis shouldn't happen and may mean the same DataFeedSynchronization is being used after a TX suspend; suspended=${suspended}\")\n            feedValues.add(ev)\n            allDataDocumentIds.addAll(dataDocumentIdSet)\n        }\n\n        void addDeleteToFeed(EntityValue ev) {\n            deleteValues.add(ev)\n        }\n\n        @Override\n        void beforeCompletion() { }\n\n        @Override\n        void afterCompletion(int status) {\n            if (status == Status.STATUS_COMMITTED) {\n                // send feed in new thread and tx\n                FeedRunnable runnable = new FeedRunnable(ecfi, edf, feedValues, allDataDocumentIds, deleteValues)\n                try {\n                    ecfi.workerPool.execute(runnable)\n                } catch (RejectedExecutionException e) {\n                    logger.error(\"Worker pool rejected DataFeed run: \" + e.toString())\n                }\n                // logger.warn(\"================================================================\\n================ feeding DataFeed with documents ${allDataDocumentIds}\")\n            }\n        }\n    }\n\n    static class FeedRunnable implements Runnable {\n        private ExecutionContextFactoryImpl ecfi\n        private EntityDataFeed edf\n        private EntityList feedValues, deleteValues\n        private Set<String> allDataDocumentIds\n        FeedRunnable(ExecutionContextFactoryImpl ecfi, EntityDataFeed edf, EntityList feedValues, Set<String> allDataDocumentIds, EntityList deleteValues) {\n            this.ecfi = ecfi\n            this.edf = edf\n            this.allDataDocumentIds = allDataDocumentIds\n            this.feedValues = feedValues\n            this.deleteValues = deleteValues\n        }\n\n        @Override\n        void run() {\n            Timestamp feedStamp = new Timestamp(System.currentTimeMillis())\n            ExecutionContextImpl threadEci = ecfi.getEci()\n            try {\n                if (logger.isTraceEnabled()) logger.trace(\"Doing DataFeed with allDataDocumentIds: ${allDataDocumentIds}, feedValues: ${feedValues}\")\n                // iterate through dataDocumentIdSet and generate/update for each\n                for (String dataDocumentId in allDataDocumentIds) {\n                    try {\n                        feedDataDocument(dataDocumentId, feedStamp, threadEci)\n                    } catch (Throwable t) {\n                        logger.error(\"Error running Real-time DataFeed\", t)\n                    }\n                }\n                // iterate through deleteValues, handle differently from updates as these are primary entities for relevant DataDocuments only\n                if (deleteValues != null && deleteValues.size() > 0) {\n                    for (int di = 0; di < deleteValues.size(); di++) {\n                        EntityValue deleteEv = (EntityValue) deleteValues.get(di)\n                        deleteDataDocuments(deleteEv, feedStamp, threadEci)\n                    }\n                }\n            } finally {\n                if (threadEci != null) threadEci.destroy()\n            }\n        }\n\n        private void feedDataDocument(String dataDocumentId, Timestamp feedStamp, ExecutionContextImpl threadEci) {\n            boolean beganTransaction = ecfi.transactionFacade.begin(1800)\n            try {\n                EntityFacadeImpl efi = ecfi.entityFacade\n                // assemble data and call DataFeed services\n\n                EntityValue dataDocument = null\n                EntityList dataDocumentFieldList = null\n                boolean alreadyDisabled = threadEci.artifactExecutionFacade.disableAuthz()\n                try {\n                    // for each DataDocument go through feedValues and get the primary entity's PK field(s) for each\n                    dataDocument = efi.fastFindOne(\"moqui.entity.document.DataDocument\", true, false, dataDocumentId)\n                    dataDocumentFieldList =\n                            dataDocument.findRelated(\"moqui.entity.document.DataDocumentField\", null, null, true, false)\n                } finally {\n                    if (!alreadyDisabled) threadEci.artifactExecutionFacade.enableAuthz()\n                }\n\n                String primaryEntityName = dataDocument.primaryEntityName\n                EntityDefinition primaryEd = efi.getEntityDefinition(primaryEntityName)\n                ArrayList<String> primaryPkFieldNames = primaryEd.getPkFieldNames()\n                int primaryPkFieldNamesSize = primaryPkFieldNames.size()\n                Set primaryPkFieldValues = new HashSet<Map<String, Object>>()\n\n                Map<String, String> pkFieldAliasMap = [:]\n                for (int pki = 0; pki < primaryPkFieldNamesSize; pki++) {\n                    String pkFieldName = (String) primaryPkFieldNames.get(pki)\n                    boolean aliasSet = false\n                    for (EntityValue dataDocumentField in dataDocumentFieldList) {\n                        if (dataDocumentField.fieldPath == pkFieldName) {\n                            pkFieldAliasMap.put(pkFieldName, (String) dataDocumentField.fieldNameAlias ?: pkFieldName)\n                            aliasSet = true\n                        }\n                    }\n                    if (aliasSet) pkFieldAliasMap.put(pkFieldName, pkFieldName)\n                }\n\n\n                for (EntityValue currentEv in feedValues) {\n                    String currentEntityName = currentEv.resolveEntityName()\n                    List<DocumentEntityInfo> currentEntityInfoList = edf.getDataFeedEntityInfoList(currentEntityName)\n                    for (DocumentEntityInfo currentEntityInfo in currentEntityInfoList) {\n                        if (currentEntityInfo.dataDocumentId == dataDocumentId) {\n                            if (currentEntityName == primaryEntityName) {\n                                // this is the easy one, primary entity updated just use it's values\n                                Map pkFieldValue = new HashMap<String, Object>()\n                                for (int pki = 0; pki < primaryPkFieldNamesSize; pki++) {\n                                    String pkFieldName = (String) primaryPkFieldNames.get(pki)\n                                    pkFieldValue.put(pkFieldName, currentEv.get(pkFieldName))\n                                }\n                                primaryPkFieldValues.add(pkFieldValue)\n                            } else {\n                                // more complex, need to follow relationships backwards (reverse\n                                //     relationships) to get the primary entity's value(s)\n                                List<String> relationshipList = Arrays.asList(currentEntityInfo.relationshipPath.split(\":\"))\n                                // ArrayList<RelationshipInfo> relInfoList = new ArrayList<RelationshipInfo>()\n                                ArrayList<String> backwardRelList = new ArrayList<String>()\n                                // add the relationships backwards, get relInfo for each\n                                EntityDefinition lastRelEd = primaryEd\n                                for (String relElement in relationshipList) {\n                                    RelationshipInfo relInfo = lastRelEd.getRelationshipInfo(relElement)\n                                    backwardRelList.add(0, relInfo.relationshipName)\n                                    lastRelEd = relInfo.relatedEd\n                                }\n                                // add the primary entity name to the end as that is the target\n                                backwardRelList.add(primaryEntityName)\n\n                                String prevRelName = backwardRelList.get(0)\n                                List<EntityValueBase> prevRelValueList = [(EntityValueBase) currentEv]\n                                // skip the first one, it is the current entity\n                                for (int i = 1; i < backwardRelList.size(); i++) {\n                                    // try to find the relationship be the title of the previous\n                                    //     relationship name + the current entity name, then by the current\n                                    //     entity name alone\n                                    String currentRelName = backwardRelList.get(i)\n                                    String currentRelEntityName = currentRelName.contains(\"#\") ?\n                                            currentRelName.substring(currentRelName.indexOf(\"#\") + 1) :\n                                            currentRelName\n                                    // all values should be for the same entity, so just use the first\n                                    EntityDefinition prevRelValueEd = prevRelValueList.get(0).getEntityDefinition()\n\n\n                                    RelationshipInfo backwardRelInfo = null\n                                    // Node backwardRelNode = null\n                                    if (prevRelName.contains(\"#\")) {\n                                        String title = prevRelName.substring(0, prevRelName.indexOf(\"#\"))\n                                        backwardRelInfo = prevRelValueEd.getRelationshipInfo((String) title + \"#\" + currentRelEntityName)\n                                    }\n                                    if (backwardRelInfo == null)\n                                        backwardRelInfo = prevRelValueEd.getRelationshipInfo(currentRelEntityName)\n\n                                    if (backwardRelInfo == null) throw new EntityException(\"For DataFeed could not find backward relationship for DataDocument [${dataDocumentId}] from entity [${prevRelValueEd.getFullEntityName()}] to entity [${currentRelEntityName}], previous relationship is [${prevRelName}], current relationship is [${currentRelName}]\")\n\n                                    String backwardRelName = backwardRelInfo.relationshipName\n                                    List<EntityValueBase> currentRelValueList = []\n                                    alreadyDisabled = threadEci.artifactExecutionFacade.disableAuthz()\n                                    try {\n                                        for (EntityValueBase prevRelValue in prevRelValueList) {\n                                            EntityList backwardRelValueList = prevRelValue.findRelated(backwardRelName, null, null, false, false)\n                                            for (EntityValue backwardRelValue in backwardRelValueList)\n                                                currentRelValueList.add((EntityValueBase) backwardRelValue)\n                                        }\n                                    } finally {\n                                        if (!alreadyDisabled) threadEci.artifactExecutionFacade.enableAuthz()\n                                    }\n\n                                    prevRelName = currentRelName\n                                    prevRelValueList = currentRelValueList\n\n                                    if (!prevRelValueList) {\n                                        if (logger.isTraceEnabled()) logger.trace(\"Creating DataFeed for DataDocument [${dataDocumentId}], no backward rel values found for [${backwardRelName}] on updated values: ${prevRelValueList}\")\n                                        break\n                                    }\n                                }\n\n                                // go through final prevRelValueList (which should be for the primary\n                                //     entity) and get the PK for each\n                                if (prevRelValueList) for (EntityValue primaryEv in prevRelValueList) {\n                                    Map pkFieldValue = new HashMap<String, Object>()\n                                    for (int pki = 0; pki < primaryPkFieldNamesSize; pki++) {\n                                        String pkFieldName = (String) primaryPkFieldNames.get(pki)\n                                        pkFieldValue.put(pkFieldName, primaryEv.get(pkFieldName))\n                                    }\n                                    primaryPkFieldValues.add(pkFieldValue)\n                                }\n                            }\n                        }\n                    }\n                }\n\n                // if there aren't really any values for the document (a value updated that isn't really in\n                //    a document) then skip it, don't want to query with no constraints and get a huge document\n                if (!primaryPkFieldValues) {\n                    if (logger.isTraceEnabled()) {\n                        String errMsg = \"Skipping feed for DataDocument [${dataDocumentId}], no primary PK values found in feed values\"\n                        /*\n                        StringBuilder sb = new StringBuilder()\n                        sb.append(errMsg).append('\\n')\n                        sb.append(\"Primary Entity: \").append(primaryEntityName).append(\": \").append(primaryPkFieldNames).append('\\n')\n                        sb.append(\"Feed Values:\").append('\\n')\n                        for (EntityValue ev in feedValues) {\n                            sb.append('    ').append(ev).append('\\n')\n                        }\n                        */\n                        logger.trace(errMsg)\n                    }\n                    return\n                }\n\n                // logger.warn(\"Doing DataFeed with dataDocumentId: ${dataDocumentId}, feedValues: ${feedValues} primaryPkFieldValues ${primaryPkFieldValues.size()}\")\n\n                ArrayList primaryPkValueList = new ArrayList<Map<String, Object>>(primaryPkFieldValues)\n                int primaryPkValueListSize = primaryPkValueList.size()\n                int chunkSize = 200\n                for (int outer = 0; outer < primaryPkValueListSize; ) {\n                    int remaining = primaryPkValueListSize - outer\n                    int curSize = remaining > chunkSize ? chunkSize : remaining\n                    int toIndex = outer + curSize\n                    primaryPkValueList.subList(outer, toIndex)\n\n                    // for primary entity with 1 PK field do an IN condition, for >1 PK field do an and cond for\n                    //     each PK and an or list cond to combine them\n                    EntityCondition condition\n                    if (primaryPkFieldNames.size() == 1) {\n                        String pkFieldName = primaryPkFieldNames.get(0)\n                        Set<Object> pkValues = new HashSet<Object>()\n                        for (int inner = outer; inner < toIndex; inner++) {\n                            Map<String, Object> pkFieldValueMap = (Map<String, Object>) primaryPkValueList.get(inner)\n                            pkValues.add(pkFieldValueMap.get(pkFieldName))\n                        }\n                        // if pk field is aliased use the alias name\n                        String aliasedPkName = pkFieldAliasMap.get(pkFieldName) ?: pkFieldName\n                        condition = efi.getConditionFactory().makeCondition(aliasedPkName, EntityCondition.IN, pkValues)\n                    } else {\n                        List<EntityCondition> condList = []\n                        for (int inner = outer; inner < toIndex; inner++) {\n                            Map<String, Object> pkFieldValueMap = (Map<String, Object>) primaryPkValueList.get(inner)\n                            Map<String, Object> condAndMap = new LinkedHashMap<String, Object>()\n                            // if pk field is aliased used the alias name\n                            for (int pki = 0; pki < primaryPkFieldNamesSize; pki++) {\n                                String pkFieldName = (String) primaryPkFieldNames.get(pki)\n                                condAndMap.put(pkFieldAliasMap.get(pkFieldName), pkFieldValueMap.get(pkFieldName))\n                            }\n                            condList.add(efi.getConditionFactory().makeCondition(condAndMap))\n                        }\n                        condition = efi.getConditionFactory().makeCondition(condList, EntityCondition.OR)\n                    }\n\n                    alreadyDisabled = threadEci.artifactExecutionFacade.disableAuthz()\n                    try {\n                        // generate the document with the extra condition and send it to all DataFeeds\n                        //     associated with the DataDocument\n                        List<Map> documents = efi.getDataDocuments(dataDocumentId, condition, null, null)\n\n                        if (documents) {\n                            EntityList dataFeedAndDocumentList = efi.find(\"moqui.entity.feed.DataFeedAndDocument\")\n                                    .condition(\"dataFeedTypeEnumId\", \"DTFDTP_RT_PUSH\")\n                                    .condition(\"dataDocumentId\", dataDocumentId).useCache(true).list()\n\n                            // logger.warn(\"=========== FEED document ${dataDocumentId}, documents ${documents.size()}, condition: ${condition}\\n dataFeedAndDocumentList: ${dataFeedAndDocumentList.feedReceiveServiceName}\")\n\n                            // do the actual feed receive service calls (authz is disabled to allow the service\n                            //     call, but also allows anything in the services...)\n                            for (EntityValue dataFeedAndDocument in dataFeedAndDocumentList) {\n                                // NOTE: this is a sync call so authz disabled is preserved; it is in its own thread\n                                //     so user/etc are not inherited here\n                                String serviceName = (String) dataFeedAndDocument.feedReceiveServiceName ?: 'org.moqui.search.SearchServices.index#DataDocuments'\n                                try {\n                                    ecfi.serviceFacade.sync().name(serviceName).parameters([dataFeedId:dataFeedAndDocument.dataFeedId,\n                                            feedStamp:feedStamp, documentList:documents]).call()\n                                    if (threadEci.messageFacade.hasError()) {\n                                        logger.error(\"Error calling DataFeed ${dataFeedAndDocument.dataFeedId} service ${serviceName}: ${threadEci.messageFacade.getErrorsString()}\")\n                                        threadEci.messageFacade.clearErrors()\n                                    }\n                                } catch (Throwable t) {\n                                    logger.error(\"Error calling DataFeed ${dataFeedAndDocument.dataFeedId} service ${serviceName}\", t)\n                                }\n                            }\n                        } else {\n                            // this is pretty common, some operation done on a record that doesn't match the conditions for the feed\n                            if (logger.isTraceEnabled()) logger.trace(\"In DataFeed no documents found for dataDocumentId [${dataDocumentId}]\")\n                        }\n                    } finally {\n                        if (!alreadyDisabled) threadEci.artifactExecutionFacade.enableAuthz()\n                    }\n\n                    outer += curSize\n                }\n            } catch (Throwable t) {\n                logger.error(\"Error running Real-time DataFeed for DataDocument ${dataDocumentId}\", t)\n                ecfi.transactionFacade.rollback(beganTransaction, \"Error running Real-time DataFeed for DataDocument ${dataDocumentId}\", t)\n            } finally {\n                // commit transaction if we started one and still there\n                if (beganTransaction && ecfi.transactionFacade.isTransactionInPlace())\n                    ecfi.transactionFacade.commit()\n            }\n        }\n\n        private void deleteDataDocuments(EntityValue deleteEv, Timestamp feedStamp, ExecutionContextImpl threadEci) {\n            String entityName = deleteEv.resolveEntityName()\n\n            ArrayList<DocumentEntityInfo> entityInfoList\n            try {\n                entityInfoList = edf.getDataFeedEntityInfoList(entityName)\n            } catch (Throwable t) {\n                logger.error(\"Error getting DataFeed info for delete for entity ${entityName}\", t)\n                return\n            }\n\n            String documentId = deleteEv.getPrimaryKeysString()\n\n            int entityInfoListSize = entityInfoList != null ? entityInfoList.size() : 0\n            for (int ii = 0; ii < entityInfoListSize; ii++) {\n                DocumentEntityInfo documentEntityInfo = (DocumentEntityInfo) entityInfoList.get(ii)\n                if (!entityName.equals(documentEntityInfo.primaryEntityName)) continue\n\n                String dataDocumentId = documentEntityInfo.dataDocumentId\n                boolean alreadyDisabled = threadEci.artifactExecutionFacade.disableAuthz()\n                try {\n                    EntityList dataFeedAndDocumentList = ecfi.entityFacade.find(\"moqui.entity.feed.DataFeedAndDocument\")\n                            .condition(\"dataFeedTypeEnumId\", \"DTFDTP_RT_PUSH\")\n                            .condition(\"dataDocumentId\", dataDocumentId).useCache(true).list()\n\n                    // track servicesCalled to avoid redundant calls, on deletes subsequent calls with same parameters likely to result in errors\n                    HashSet<String> servicesCalled = new HashSet<>()\n                    for (EntityValue dataFeedAndDocument in dataFeedAndDocumentList) {\n                        // NOTE: this is a sync call so authz disabled is preserved; it is in its own thread\n                        //     so user/etc are not inherited here\n                        String serviceName = (String) dataFeedAndDocument.feedDeleteServiceName ?: 'org.moqui.search.SearchServices.delete#DataDocument'\n                        try {\n                            if (servicesCalled.contains(serviceName)) continue\n                            ecfi.serviceFacade.sync().name(serviceName)\n                                    .parameters([dataFeedId:dataFeedAndDocument.dataFeedId, feedStamp:feedStamp,\n                                            dataDocumentId:dataDocumentId, documentId:documentId]).call()\n                            servicesCalled.add(serviceName)\n                            if (threadEci.messageFacade.hasError()) {\n                                logger.error(\"Error calling DataFeed ${dataFeedAndDocument.dataFeedId} delete service ${serviceName} for entity ${entityName} PK ${deleteEv.getPrimaryKeys()}: ${threadEci.messageFacade.getErrorsString()}\")\n                                threadEci.messageFacade.clearErrors()\n                            }\n                        } catch (Throwable t) {\n                            logger.error(\"Error calling DataFeed ${dataFeedAndDocument.dataFeedId} delete service ${serviceName} for entity ${entityName} PK ${deleteEv.getPrimaryKeys()}\", t)\n                        }\n                    }\n\n                } catch (Throwable t) {\n                    logger.error(\"Error processing DataFeed delete for entity ${entityName} PK ${deleteEv.getPrimaryKeys()}\", t)\n                } finally {\n                    if (!alreadyDisabled) threadEci.artifactExecutionFacade.enableAuthz()\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityDataLoaderImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity\n\nimport groovy.json.JsonSlurper\nimport groovy.transform.CompileStatic\nimport org.apache.commons.csv.CSVFormat\nimport org.apache.commons.csv.CSVParser\nimport org.apache.commons.csv.CSVRecord\nimport org.moqui.BaseException\nimport org.moqui.context.NotificationMessage\nimport org.moqui.impl.context.TransactionFacadeImpl\nimport org.moqui.resource.ResourceReference\nimport org.moqui.context.TransactionFacade\nimport org.moqui.entity.EntityDataLoader\nimport org.moqui.entity.EntityException\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.impl.service.ServiceCallSyncImpl\nimport org.moqui.impl.service.ServiceDefinition\nimport org.moqui.impl.service.ServiceFacadeImpl\nimport org.moqui.impl.service.runner.EntityAutoServiceRunner\nimport org.moqui.service.ServiceCallSync\nimport org.moqui.util.MNode\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\nimport org.xml.sax.*\nimport org.xml.sax.helpers.DefaultHandler\n\nimport javax.sql.rowset.serial.SerialBlob\nimport javax.xml.parsers.SAXParser\nimport javax.xml.parsers.SAXParserFactory\nimport java.nio.charset.StandardCharsets\nimport java.util.zip.ZipEntry\nimport java.util.zip.ZipInputStream\n\n@CompileStatic\nclass EntityDataLoaderImpl implements EntityDataLoader {\n    protected final static Logger logger = LoggerFactory.getLogger(EntityDataLoaderImpl.class)\n\n    protected EntityFacadeImpl efi\n    protected ServiceFacadeImpl sfi\n\n    // NOTE: these are Groovy Beans style with no access modifier, results in private fields with implicit getters/setters\n\n    List<String> locationList = new LinkedList<String>()\n    String xmlText = null\n    String csvText = null\n    String jsonText = null\n    Set<String> dataTypes = new HashSet<String>()\n    List<String> componentNameList = new LinkedList<String>()\n\n    int transactionTimeout = 600\n    boolean useTryInsert = false\n    boolean onlyCreate = false\n    boolean dummyFks = false\n    boolean messageNoActionFiles = true\n    boolean disableEeca = false\n    boolean disableAuditLog = false\n    boolean disableFkCreate = false\n    boolean disableDataFeed = false\n\n    char csvDelimiter = ','\n    char csvCommentStart = '#'\n    char csvQuoteChar = '\"'\n\n    String csvEntityName = null\n    List<String> csvFieldNames = null\n    Map<String, Object> defaultValues = null\n\n    EntityDataLoaderImpl(EntityFacadeImpl efi) {\n        this.efi = efi\n        this.sfi = efi.ecfi.serviceFacade\n    }\n\n    EntityFacadeImpl getEfi() { return efi }\n\n    @Override EntityDataLoader location(String location) { this.locationList.add(location); return this }\n    @Override EntityDataLoader locationList(List<String> ll) { this.locationList.addAll(ll); return this }\n    @Override EntityDataLoader xmlText(String xmlText) { this.xmlText = xmlText; return this }\n    @Override EntityDataLoader csvText(String csvText) { this.csvText = csvText; return this }\n    @Override EntityDataLoader jsonText(String jsonText) { this.jsonText = jsonText; return this }\n    @Override EntityDataLoader dataTypes(Set<String> dataTypes) {\n        for (String dt in dataTypes) this.dataTypes.add(dt.trim())\n        return this\n    }\n    @Override EntityDataLoader componentNameList(List<String> componentNames) {\n        for (String cn in componentNames) this.componentNameList.add(cn.trim())\n        return this\n    }\n\n    @Override EntityDataLoader transactionTimeout(int tt) { this.transactionTimeout = tt; return this }\n    @Override EntityDataLoader useTryInsert(boolean useTryInsert) { this.useTryInsert = useTryInsert; return this }\n    @Override EntityDataLoader onlyCreate(boolean onlyCreate) { this.onlyCreate = onlyCreate; return this }\n    @Override EntityDataLoader dummyFks(boolean dummyFks) { this.dummyFks = dummyFks; return this }\n    @Override EntityDataLoader messageNoActionFiles(boolean message) { this.messageNoActionFiles = message; return this }\n\n    @Override EntityDataLoader disableEntityEca(boolean disable) { disableEeca = disable; return this }\n    @Override EntityDataLoader disableAuditLog(boolean disable) { disableAuditLog = disable; return this }\n    @Override EntityDataLoader disableFkCreate(boolean disable) { disableFkCreate = disable; return this }\n    @Override EntityDataLoader disableDataFeed(boolean disable) { disableDataFeed = disable; return this }\n\n    @Override EntityDataLoader csvDelimiter(char delimiter) { this.csvDelimiter = delimiter; return this }\n    @Override EntityDataLoader csvCommentStart(char commentStart) { this.csvCommentStart = commentStart; return this }\n    @Override EntityDataLoader csvQuoteChar(char quoteChar) { this.csvQuoteChar = quoteChar; return this }\n\n    @Override EntityDataLoader csvEntityName(String entityName) {\n        if (!efi.isEntityDefined(entityName) && !sfi.isServiceDefined(entityName))\n            throw new IllegalArgumentException(\"Name ${entityName} is not a valid entity or service name\")\n        this.csvEntityName = entityName\n        return this\n    }\n    @Override EntityDataLoader csvFieldNames(List<String> fieldNames) { this.csvFieldNames = fieldNames; return this }\n    @Override EntityDataLoader defaultValues(Map<String, Object> defaultValues) {\n        if (this.defaultValues == null) this.defaultValues = [:]\n        this.defaultValues.putAll(defaultValues)\n        return this\n    }\n\n    @Override\n    List<String> check() {\n        CheckValueHandler cvh = new CheckValueHandler(this)\n        EntityXmlHandler exh = new EntityXmlHandler(this, cvh)\n        EntityCsvHandler ech = new EntityCsvHandler(this, cvh)\n        EntityJsonHandler ejh = new EntityJsonHandler(this, cvh)\n\n        internalRun(exh, ech, ejh)\n        return cvh.messageList\n    }\n    @Override\n    long check(List<String> messageList) {\n        CheckValueHandler cvh = new CheckValueHandler(this, messageList)\n        EntityXmlHandler exh = new EntityXmlHandler(this, cvh)\n        EntityCsvHandler ech = new EntityCsvHandler(this, cvh)\n        EntityJsonHandler ejh = new EntityJsonHandler(this, cvh)\n\n        internalRun(exh, ech, ejh)\n        return cvh.getFieldsChecked()\n    }\n\n    @Override\n    List<Map<String, Object>> checkInfo() {\n        CheckInfoValueHandler civh = new CheckInfoValueHandler(this)\n        EntityXmlHandler exh = new EntityXmlHandler(this, civh)\n        EntityCsvHandler ech = new EntityCsvHandler(this, civh)\n        EntityJsonHandler ejh = new EntityJsonHandler(this, civh)\n\n        internalRun(exh, ech, ejh)\n\n        List<String> messageList = civh.messageList\n        if (messageList != null && messageList.size() > 0) {\n            ExecutionContextImpl eci = this.efi.ecfi.getEci()\n            for (String message in messageList) eci.messageFacade.addMessage(message, NotificationMessage.info)\n        }\n\n        return civh.getDiffInfoList()\n    }\n    @Override\n    long checkInfo(List<Map<String, Object>> diffInfoList, List<String> messageList) {\n        CheckInfoValueHandler civh = new CheckInfoValueHandler(this, diffInfoList, messageList)\n        EntityXmlHandler exh = new EntityXmlHandler(this, civh)\n        EntityCsvHandler ech = new EntityCsvHandler(this, civh)\n        EntityJsonHandler ejh = new EntityJsonHandler(this, civh)\n\n        internalRun(exh, ech, ejh)\n        return civh.getFieldsChecked()\n    }\n\n\n    @Override long load() { load(null) }\n    @Override long load(List<String> messageList) {\n        LoadValueHandler lvh = new LoadValueHandler(this, messageList)\n        EntityXmlHandler exh = new EntityXmlHandler(this, lvh)\n        EntityCsvHandler ech = new EntityCsvHandler(this, lvh)\n        EntityJsonHandler ejh = new EntityJsonHandler(this, lvh)\n\n        internalRun(exh, ech, ejh)\n        return exh.getValuesRead() + ech.getValuesRead() + ejh.getValuesRead()\n    }\n\n    @Override\n    EntityList list() {\n        ListValueHandler lvh = new ListValueHandler(this)\n        EntityXmlHandler exh = new EntityXmlHandler(this, lvh)\n        EntityCsvHandler ech = new EntityCsvHandler(this, lvh)\n        EntityJsonHandler ejh = new EntityJsonHandler(this, lvh)\n\n        internalRun(exh, ech, ejh)\n        return lvh.entityList\n    }\n\n    void internalRun(EntityXmlHandler exh, EntityCsvHandler ech, EntityJsonHandler ejh) {\n        // make sure reverse relationships exist\n        efi.createAllAutoReverseManyRelationships()\n        ExecutionContextImpl eci = efi.ecfi.getEci()\n\n        boolean reenableEeca = false\n        if (this.disableEeca) reenableEeca = !eci.artifactExecutionFacade.disableEntityEca()\n        boolean reenableAuditLog = false\n        if (this.disableAuditLog) reenableAuditLog = !eci.artifactExecutionFacade.disableEntityAuditLog()\n        boolean reenableFkCreate = false\n        if (this.disableFkCreate) reenableFkCreate = !eci.artifactExecutionFacade.disableEntityFkCreate()\n        boolean reenableDataFeed = false\n        if (this.disableDataFeed) reenableDataFeed = !eci.artifactExecutionFacade.disableEntityDataFeed()\n\n        // if no xmlText or locations, so find all of the component and entity-facade files\n        if (!this.xmlText && !this.csvText && !this.jsonText && !this.locationList) {\n            // if we're loading seed type data, add configured (Moqui Conf XML) entity def files to the list of locations to load\n            if (!componentNameList && (!dataTypes || dataTypes.contains(\"seed\"))) {\n                for (ResourceReference entityRr in efi.getConfEntityFileLocations())\n                    if (!entityRr.location.endsWith(\".eecas.xml\")) locationList.add(entityRr.location)\n            }\n\n            // loop through all of the entity-facade.load-data nodes\n            if (!componentNameList) {\n                for (MNode loadData in efi.ecfi.getConfXmlRoot().first(\"entity-facade\").children(\"load-data\")) {\n                    locationList.add((String) loadData.attribute(\"location\"))\n                }\n            }\n\n            LinkedHashMap<String, String> loadCompLocations\n            if (componentNameList) {\n                LinkedHashMap<String, String> allLocations = efi.ecfi.getComponentBaseLocations()\n                loadCompLocations = new LinkedHashMap<String, String>()\n                for (String cn in componentNameList) loadCompLocations.put(cn, allLocations.get(cn))\n            } else {\n                loadCompLocations = efi.ecfi.getComponentBaseLocations()\n            }\n\n            for (Map.Entry<String, String> compLocEntry in loadCompLocations) {\n                // if we're loading seed type data, add COMPONENT entity def files to the list of locations to load\n                if (!dataTypes || dataTypes.contains(\"seed\")) {\n                    for (ResourceReference entityRr in efi.getComponentEntityFileLocations([compLocEntry.key]))\n                        if (!entityRr.location.endsWith(\".eecas.xml\")) locationList.add(entityRr.location)\n                }\n\n                // load files in component data directory\n                String location = compLocEntry.value\n                ResourceReference dataDirRr = efi.ecfi.resourceFacade.getLocationReference(location + \"/data\")\n                if (dataDirRr.supportsAll()) {\n                    // if directory doesn't exist skip it, component doesn't have a data directory\n                    if (!dataDirRr.exists || !dataDirRr.isDirectory()) continue\n                    // get all files in the directory\n                    TreeMap<String, ResourceReference> dataDirEntries = new TreeMap<String, ResourceReference>()\n                    for (ResourceReference dataRr in dataDirRr.directoryEntries) {\n                        if (!dataRr.isFile() || (!dataRr.location.endsWith(\".xml\") && !dataRr.location.endsWith(\".csv\")\n                                && !dataRr.location.endsWith(\".json\"))) continue\n                        dataDirEntries.put(dataRr.getFileName(), dataRr)\n                    }\n                    for (Map.Entry<String, ResourceReference> dataDirEntry in dataDirEntries) {\n                        locationList.add(dataDirEntry.getValue().location)\n                    }\n                } else {\n                    // just warn here, no exception because any non-file component location would blow everything up\n                    logger.warn(\"Cannot load entity data file in component location [${location}] because protocol [${dataDirRr.uri.scheme}] is not yet supported.\")\n                }\n            }\n        }\n        if (locationList && logger.isInfoEnabled()) {\n            StringBuilder lm = new StringBuilder(\"Loading entity data from the following locations: \")\n            for (String loc in locationList) lm.append(\"\\n - \").append(loc)\n            logger.info(lm.toString())\n            logger.info(\"Loading data types: ${dataTypes ?: 'ALL'}\")\n        }\n\n        // efi.createAllAutoReverseManyRelationships()\n        // logger.warn(\"========== Waiting 45s to attach profiler\")\n        // Thread.sleep(45000)\n\n        TransactionFacadeImpl tf = efi.ecfi.transactionFacade\n        tf.runRequireNew(transactionTimeout, \"Error loading entity data\", false, true, {\n            // load the XML text in its own transaction\n            if (this.xmlText) {\n                tf.runUseOrBegin(transactionTimeout, \"Error loading XML entity data\", {\n                    XMLReader reader = SAXParserFactory.newInstance().newSAXParser().XMLReader\n                    exh.setLocation(\"xmlText\")\n                    reader.setContentHandler(exh)\n                    reader.parse(new InputSource(new StringReader(this.xmlText)))\n                })\n            }\n\n            // load the CSV text in its own transaction\n            if (this.csvText) {\n                InputStream csvInputStream = new ByteArrayInputStream(csvText.getBytes(\"UTF-8\"))\n                try {\n                    tf.runUseOrBegin(transactionTimeout, \"Error loading CSV entity data\", { ech.loadFile(\"csvText\", csvInputStream) })\n                } finally {\n                    if (csvInputStream != null) csvInputStream.close()\n                }\n            }\n\n            // load the JSON text in its own transaction\n            if (this.jsonText) {\n                InputStream jsonInputStream = new ByteArrayInputStream(jsonText.getBytes(\"UTF-8\"))\n                try {\n                    tf.runUseOrBegin(transactionTimeout, \"Error loading JSON entity data\", { ejh.loadFile(\"jsonText\", jsonInputStream) })\n                } finally {\n                    if (jsonInputStream != null) jsonInputStream.close()\n                }\n            }\n\n            // load each file in its own transaction\n            for (String location in this.locationList) {\n                try {\n                    loadSingleFile(location, exh, ech, ejh)\n                } catch (Throwable t) {\n                    logger.error(\"Skipping to next file after error: ${t.toString()} ${t.getCause() != null ? t.getCause().toString() : ''}\")\n                }\n            }\n        })\n\n        if (reenableEeca) eci.artifactExecutionFacade.enableEntityEca()\n        if (reenableAuditLog) eci.artifactExecutionFacade.enableEntityAuditLog()\n        if (reenableFkCreate) eci.artifactExecutionFacade.enableEntityFkCreate()\n        if (reenableDataFeed) eci.artifactExecutionFacade.enableEntityDataFeed()\n\n        // logger.warn(\"========== Done loading, waiting for a long time so process is still running for profiler\")\n        // Thread.sleep(60*1000*100)\n    }\n\n    void loadSingleFile(String location, EntityXmlHandler exh, EntityCsvHandler ech, EntityJsonHandler ejh) {\n        TransactionFacade tf = efi.ecfi.transactionFacade\n        boolean beganTransaction = tf.begin(transactionTimeout)\n        try {\n            InputStream inputStream = null\n            try {\n                logger.info(\"Loading entity data from ${location}\")\n                long beforeTime = System.currentTimeMillis()\n\n                inputStream = efi.ecfi.resourceFacade.getLocationStream(location)\n                if (inputStream == null) throw new BaseException(\"Data file not found at ${location}\")\n\n                long recordsLoaded = 0\n                int messagesBefore = exh.valueHandler.messageList != null ? exh.valueHandler.messageList.size() : 0\n\n                if (location.endsWith(\".xml\")) {\n                    long beforeRecords = exh.valuesRead ?: 0\n                    exh.setLocation(location)\n\n                    SAXParser parser = SAXParserFactory.newInstance().newSAXParser()\n                    parser.parse(inputStream, exh)\n\n                    recordsLoaded = (exh.valuesRead?:0) - beforeRecords\n                    logger.info(\"Loaded ${recordsLoaded} records from ${location} in ${((System.currentTimeMillis() - beforeTime)/1000)}s\")\n                } else if (location.endsWith(\".csv\")) {\n                    long beforeRecords = ech.valuesRead ?: 0\n                    if (ech.loadFile(location, inputStream)) {\n                        recordsLoaded = (ech.valuesRead?:0) - beforeRecords\n                        logger.info(\"Loaded ${recordsLoaded} records from ${location} in ${((System.currentTimeMillis() - beforeTime)/1000)}s\")\n                    }\n                } else if (location.endsWith(\".json\")) {\n                    long beforeRecords = ejh.valuesRead ?: 0\n                    if (ejh.loadFile(location, inputStream)) {\n                        recordsLoaded = (ejh.valuesRead?:0) - beforeRecords\n                        logger.info(\"Loaded ${recordsLoaded} records from ${location} in ${((System.currentTimeMillis() - beforeTime)/1000)}s\")\n                    }\n                } else if (location.endsWith(\".zip\")) {\n                    NoCloseZipStream zis = new NoCloseZipStream(inputStream)\n                    ZipEntry entry\n                    while((entry = zis.getNextEntry()) != null) {\n                        try {\n                            String entryFile = entry.getName()\n                            long entryBeforeTime = System.currentTimeMillis()\n                            if (entryFile.endsWith(\".xml\")) {\n                                long beforeRecords = exh.valuesRead ?: 0\n                                exh.setLocation(location)\n\n                                SAXParser parser = SAXParserFactory.newInstance().newSAXParser()\n                                parser.parse(zis, exh)\n\n                                long curFileLoaded = (exh.valuesRead?:0) - beforeRecords\n                                recordsLoaded += curFileLoaded\n                                logger.info(\"Loaded ${curFileLoaded} records from ${entryFile} in zip file ${location} in ${((System.currentTimeMillis() - entryBeforeTime)/1000)}s\")\n                            } else if (entryFile.endsWith(\".csv\")) {\n                                long beforeRecords = ech.valuesRead ?: 0\n                                if (ech.loadFile(entryFile, zis)) {\n                                    long curFileLoaded = (ech.valuesRead?:0) - beforeRecords\n                                    recordsLoaded += curFileLoaded\n                                    logger.info(\"Loaded ${curFileLoaded} records from ${entryFile} in zip file ${location} in ${((System.currentTimeMillis() - entryBeforeTime)/1000)}s\")\n                                }\n                            } else if (entryFile.endsWith(\".json\")) {\n                                long beforeRecords = ejh.valuesRead ?: 0\n                                if (ejh.loadFile(entryFile, zis)) {\n                                    long curFileLoaded = (ejh.valuesRead?:0) - beforeRecords\n                                    recordsLoaded += curFileLoaded\n                                    logger.info(\"Loaded ${curFileLoaded} records from ${entryFile} in zip file ${location} in ${((System.currentTimeMillis() - entryBeforeTime)/1000)}s\")\n                                }\n                            } else {\n                                logger.warn(\"Found file ${entryFile} in zip file ${location} that is not a .xml file, ignoring\")\n                            }\n                        } catch (TypeToSkipException e) {\n                            // nothing to do, this just stops the parsing when we know the file is not in the types we want\n                        } catch (Throwable t) {\n                            tf.rollback(beganTransaction, \"Error loading entity data\", t)\n                            throw new BaseException(\"Error loading entity data from ${entry.getName()} in zip file ${location}\", t)\n                        }\n                    }\n                }\n\n                int messagesAdded = (exh.valueHandler.messageList != null ? exh.valueHandler.messageList.size() : 0) - messagesBefore\n                if (exh.valueHandler instanceof CheckValueHandler) {\n                    if (messageNoActionFiles || messagesAdded > 0)\n                        exh.valueHandler.messageList.add(\"-- Checked data (${recordsLoaded} records) in ${location}\".toString())\n                } else if (exh.valueHandler?.messageList != null) {\n                    if (messageNoActionFiles || recordsLoaded > 0)\n                        exh.valueHandler.messageList.add(\"-- Loaded data (${recordsLoaded} records) from ${location}\".toString())\n                }\n            } catch (TypeToSkipException e) {\n                // nothing to do, this just stops the parsing when we know the file is not in the types we want\n            } finally {\n                if (inputStream != null) inputStream.close()\n            }\n        } catch (Throwable t) {\n            tf.rollback(beganTransaction, \"Error loading entity data\", t)\n            throw new BaseException(\"Error loading entity data from ${location}\", t)\n        } finally {\n            tf.commit(beganTransaction)\n\n            ExecutionContextImpl ec = efi.ecfi.getEci()\n            if (ec.messageFacade.hasError()) {\n                logger.error(\"Error messages loading entity data: \" + ec.messageFacade.getErrorsString())\n                ec.messageFacade.clearErrors()\n            }\n        }\n    }\n\n    private static class NoCloseZipStream extends ZipInputStream {\n        NoCloseZipStream(InputStream is) { super(is) }\n        @Override void close() throws IOException { /* do nothing, the point is to not get closed by SAXParser */ }\n        void reallyClose() { super.close() }\n    }\n\n    static abstract class ValueHandler {\n        protected List<String> messageList = (List<String>) null\n        protected EntityDataLoaderImpl edli\n\n        ValueHandler(EntityDataLoaderImpl edli) { this.edli = edli }\n\n        abstract void handleValue(EntityValue value, String location)\n        abstract void handlePlainMap(String entityName, Map value, String location)\n        abstract void handleService(ServiceCallSync scs, String location)\n    }\n    static class CheckValueHandler extends ValueHandler {\n        protected long fieldsChecked = 0\n\n        CheckValueHandler(EntityDataLoaderImpl edli) {\n            super(edli)\n            messageList = new LinkedList<>()\n        }\n        CheckValueHandler(EntityDataLoaderImpl edli, List<String> messages) {\n            super(edli)\n            messageList = messages\n            if (messageList == null) messageList = new LinkedList<>()\n        }\n\n        long getFieldsChecked() { return fieldsChecked }\n        void handleValue(EntityValue value, String location) { value.checkAgainstDatabase(messageList) }\n        void handlePlainMap(String entityName, Map value, String location) {\n            EntityList el = edli.getEfi().getValueListFromPlainMap(value, entityName)\n            // logger.warn(\"=========== Check value: ${value}\\nel: ${el}\")\n            for (EntityValue ev in el) fieldsChecked += ev.checkAgainstDatabase(messageList)\n        }\n        void handleService(ServiceCallSync scs, String location) {\n            messageList.add(\"Doing check only so not calling service [${scs.getServiceName()}] with parameters ${scs.getCurrentParameters()}\".toString()) }\n    }\n    static class CheckInfoValueHandler extends ValueHandler {\n        protected long fieldsChecked = 0\n        protected List<Map<String, Object>> diffInfoList\n\n        CheckInfoValueHandler(EntityDataLoaderImpl edli) {\n            super(edli)\n            messageList = new LinkedList<>()\n            diffInfoList = new LinkedList<>()\n        }\n        CheckInfoValueHandler(EntityDataLoaderImpl edli, List<Map<String, Object>> diffInfoList, List<String> messages) {\n            super(edli)\n            messageList = messages\n            if (messageList == null) messageList = new LinkedList<>()\n            this.diffInfoList = diffInfoList\n            if (this.diffInfoList == null) this.diffInfoList = new LinkedList<>()\n        }\n\n        long getFieldsChecked() { return fieldsChecked }\n        List<Map<String, Object>> getDiffInfoList() { return diffInfoList }\n\n        void handleValue(EntityValue value, String location) {\n            fieldsChecked += value.checkAgainstDatabaseInfo(diffInfoList, messageList, location)\n        }\n        void handlePlainMap(String entityName, Map value, String location) {\n            EntityList el = edli.getEfi().getValueListFromPlainMap(value, entityName)\n            // logger.warn(\"=========== Check value: ${value}\\nel: ${el}\")\n            for (EntityValue ev in el) {\n                fieldsChecked += ev.checkAgainstDatabaseInfo(diffInfoList, messageList, location)\n            }\n        }\n        void handleService(ServiceCallSync scs, String location) {\n            messageList.add(\"Doing check only so not calling service [${scs.getServiceName()}] with parameters ${scs.getCurrentParameters()}\".toString()) }\n    }\n    static class LoadValueHandler extends ValueHandler {\n        protected ServiceFacadeImpl sfi\n        protected ExecutionContextImpl ec\n\n        LoadValueHandler(EntityDataLoaderImpl edli) {\n            super(edli)\n            sfi = edli.getEfi().ecfi.serviceFacade\n            ec = edli.getEfi().ecfi.getEci()\n        }\n        LoadValueHandler(EntityDataLoaderImpl edli, List<String> messages) {\n            super(edli)\n            sfi = edli.getEfi().ecfi.serviceFacade\n            ec = edli.getEfi().ecfi.getEci()\n            messageList = messages\n        }\n\n        void handleValue(EntityValue value, String location) {\n            boolean tryInsert = edli.useTryInsert\n            if (tryInsert && value instanceof EntityValueBase) {\n                EntityValueBase evb = (EntityValueBase) value\n                MNode databaseNode = ec.entityFacade.getDatabaseNode(evb.getEntityDefinition().getEntityGroupName())\n                if (\"true\".equals(databaseNode.attribute(\"never-try-insert\"))) tryInsert = false\n            }\n\n            if (edli.onlyCreate) {\n                if (value.containsPrimaryKey()) {\n                    if (ec.entityFacade.find(value.resolveEntityName()).condition(value.getPrimaryKeys()).one() == null)\n                        value.create()\n                } else {\n                    String msg = \"Doing only insert, not loading entity ${value.resolveEntityName()} value with partial primary key ${value.getPrimaryKeys()}\"\n                    logger.info(msg)\n                    if (messageList != null) messageList.add(msg)\n                }\n            } else if (tryInsert) {\n                try {\n                    value.create()\n                } catch (EntityException e) {\n                    if (logger.isTraceEnabled()) logger.trace(\"Insert failed, trying update (${e.toString()})\")\n                    boolean noFksMissing = true\n                    if (edli.dummyFks) noFksMissing = value.checkFks(true)\n                    // retry, then if this fails we have a real error so let the exception fall through\n                    // if there were no FKs missing then just do an update, if there were that may have been the error so createOrUpdate\n                    if (noFksMissing) {\n                        value.update()\n                    } else {\n                        value.createOrUpdate()\n                    }\n                }\n            } else {\n                if (edli.dummyFks) value.checkFks(true)\n                value.createOrUpdate()\n            }\n        }\n        void handlePlainMap(String entityName, Map value, String location) {\n            EntityDefinition ed = ec.entityFacade.getEntityDefinition(entityName)\n            if (ed == null) throw new BaseException(\"Could not find entity ${entityName}\")\n            if (edli.onlyCreate) {\n                EntityList el = ec.entityFacade.getValueListFromPlainMap(value, entityName)\n                int elSize = el.size()\n                for (int i = 0; i < elSize; i++) {\n                    EntityValue curValue = (EntityValue) el.get(i)\n                    if (curValue.containsPrimaryKey()) {\n                        if (ec.entityFacade.find(curValue.resolveEntityName()).condition(curValue.getPrimaryKeys()).one() == null)\n                            curValue.create()\n                    } else {\n                        String msg = \"Doing only insert, not loading entity ${curValue.resolveEntityName()} value with partial primary key ${curValue.getPrimaryKeys()}\"\n                        logger.info(msg)\n                        if (messageList != null) messageList.add(msg)\n                    }\n                }\n            } else {\n                Map<String, Object> results = new HashMap()\n                EntityAutoServiceRunner.storeEntity(ec, ed, value, results, null)\n                // no need to call the store auto service, use storeEntity directly:\n                // Map results = sfi.sync().name('store', entityName).parameters(value).call()\n                if (logger.isTraceEnabled()) logger.trace(\"Called store service for entity [${entityName}] in data load, results: ${results}\")\n                if (ec.getMessage().hasError()) {\n                    String errStr = ec.getMessage().getErrorsString()\n                    ec.getMessage().clearErrors()\n                    throw new BaseException(\"Error handling data load plain Map: ${errStr}\")\n                }\n            }\n        }\n        void handleService(ServiceCallSync scs, String location) {\n            if (edli.onlyCreate) {\n                String msg = \"Not calling service ${scs.getServiceName()}, running with only insert\"\n                logger.info(msg)\n                if (messageList != null) messageList.add(msg)\n                return\n            }\n            Map results = scs.call()\n            String msg = \"Called service ${scs.getServiceName()} in data load, results: ${results}\"\n            logger.info(msg)\n            if (messageList != null) messageList.add(msg)\n            if (ec.getMessage().hasError()) {\n                String errStr = ec.getMessage().getErrorsString()\n                ec.getMessage().clearErrors()\n                throw new BaseException(\"Error handling data load service call: ${errStr}\")\n            }\n        }\n    }\n    static class ListValueHandler extends ValueHandler {\n        protected EntityList el\n        ListValueHandler(EntityDataLoaderImpl edli) { super(edli); el = new EntityListImpl(edli.efi) }\n        EntityList getEntityList() { return el }\n        void handleValue(EntityValue value, String location) {\n            el.add(value)\n        }\n        void handlePlainMap(String entityName, Map value, String location) {\n            EntityDefinition ed = edli.getEfi().getEntityDefinition(entityName)\n            edli.getEfi().addValuesFromPlainMapRecursive(ed, value, el, null)\n        }\n        void handleService(ServiceCallSync scs, String location) {\n            logger.warn(\"For load to EntityList not calling service [${scs.getServiceName()}] with parameters ${scs.getCurrentParameters()}\") }\n    }\n\n    static class TypeToSkipException extends RuntimeException {\n        TypeToSkipException() { }\n    }\n\n    static class EntityXmlHandler extends DefaultHandler {\n        protected Locator locator\n        protected EntityDataLoaderImpl edli\n        protected ValueHandler valueHandler\n\n        protected EntityDefinition currentEntityDef = (EntityDefinition) null\n        protected String entityOperation = (String) null\n        protected ServiceDefinition currentServiceDef = (ServiceDefinition) null\n        protected Map rootValueMap = (Map) null\n        // use a List as a stack, element 0 is the top\n        protected List<Map> valueMapStack = (List<Map>) null\n        protected List<EntityDefinition> relatedEdStack = (List<EntityDefinition>) null\n\n        protected String currentFieldName = (String) null\n        protected StringBuilder currentFieldValue = (StringBuilder) null\n        protected long valuesRead = 0\n        protected List<String> messageList = new LinkedList<>()\n        String location\n\n        protected boolean loadElements = false\n\n        EntityXmlHandler(EntityDataLoaderImpl edli, ValueHandler valueHandler) {\n            this.edli = edli\n            this.valueHandler = valueHandler\n        }\n\n        ValueHandler getValueHandler() { return valueHandler }\n        long getValuesRead() { return valuesRead }\n        List<String> getMessageList() { return messageList }\n\n        void startElement(String ns, String localName, String qName, Attributes attributes) {\n            // logger.info(\"startElement ns [${ns}], localName [${localName}] qName [${qName}]\")\n            String type = null\n            if (qName == \"entity-facade-xml\") { type = attributes.getValue(\"type\") }\n            else if (qName == \"seed-data\") { type = \"seed\" }\n            if (type && edli.dataTypes && !edli.dataTypes.contains(type)) {\n                if (logger.isInfoEnabled()) logger.info(\"Skipping file [${location}], is a type to skip (${type})\")\n                throw new TypeToSkipException()\n            }\n\n            if (qName == \"entity-facade-xml\") {\n                loadElements = true\n                return\n            } else if (qName == \"seed-data\") {\n                loadElements = true\n                return\n            }\n            if (!loadElements) return\n\n            String elementName = qName\n            // get everything after a colon, but replace - with # for verb#noun separation\n            if (elementName.contains(':')) elementName = elementName.substring(elementName.indexOf(':') + 1)\n            if (elementName.contains('-')) elementName = elementName.replace('-', '#')\n\n            if (currentEntityDef != null) {\n                EntityDefinition checkEd = currentEntityDef\n                if (relatedEdStack) checkEd = relatedEdStack.get(0)\n                if (checkEd.isField(elementName)) {\n                    // nested value/CDATA element\n                    currentFieldName = elementName\n                } else if (checkEd.getRelationshipInfo(elementName) != null) {\n                    EntityJavaUtil.RelationshipInfo relInfo = checkEd.getRelationshipInfo(elementName)\n                    Map curRelMap = getAttributesMap(attributes, relInfo.relatedEd)\n                    String relationshipName = relInfo.relationshipName\n                    if (valueMapStack) {\n                        Map prevValueMap = valueMapStack.get(0)\n                        if (prevValueMap.containsKey(relationshipName)) {\n                            Object prevRelValue = prevValueMap.get(relationshipName)\n                            if (prevRelValue instanceof List) {\n                                ((List) prevRelValue).add(curRelMap)\n                            } else {\n                                prevValueMap.put(relationshipName, [prevRelValue, curRelMap])\n                            }\n                        } else {\n                            prevValueMap.put(relationshipName, curRelMap)\n                        }\n                        valueMapStack.add(0, curRelMap)\n                        relatedEdStack.add(0, relInfo.relatedEd)\n                    } else {\n                        if (rootValueMap.containsKey(relationshipName)) {\n                            Object prevRelValue = rootValueMap.get(relationshipName)\n                            if (prevRelValue instanceof List) {\n                                ((List) prevRelValue).add(curRelMap)\n                            } else {\n                                rootValueMap.put(relationshipName, [prevRelValue, curRelMap])\n                            }\n                        } else {\n                            rootValueMap.put(relationshipName, curRelMap)\n                        }\n                        valueMapStack = [curRelMap] as List<Map>\n                        relatedEdStack = [relInfo.relatedEd]\n                    }\n                } else if (edli.efi.isEntityDefined(elementName)) {\n                    EntityDefinition subEd = edli.efi.getEntityDefinition(elementName)\n                    Map curRelMap = getAttributesMap(attributes, subEd)\n                    String relationshipName = subEd.getFullEntityName()\n                    if (valueMapStack) {\n                        Map prevValueMap = valueMapStack.get(0)\n                        if (prevValueMap.containsKey(relationshipName)) {\n                            Object prevRelValue = prevValueMap.get(relationshipName)\n                            if (prevRelValue instanceof List) {\n                                ((List) prevRelValue).add(curRelMap)\n                            } else {\n                                prevValueMap.put(relationshipName, [prevRelValue, curRelMap])\n                            }\n                        } else {\n                            prevValueMap.put(relationshipName, curRelMap)\n                        }\n                        valueMapStack.add(0, curRelMap)\n                        relatedEdStack.add(0, subEd)\n                    } else {\n                        if (rootValueMap.containsKey(relationshipName)) {\n                            Object prevRelValue = rootValueMap.get(relationshipName)\n                            if (prevRelValue instanceof List) {\n                                ((List) prevRelValue).add(curRelMap)\n                            } else {\n                                rootValueMap.put(relationshipName, [prevRelValue, curRelMap])\n                            }\n                        } else {\n                            rootValueMap.put(relationshipName, curRelMap)\n                        }\n                        valueMapStack = [curRelMap] as List<Map>\n                        relatedEdStack = [subEd]\n                    }\n                } else {\n                    logger.warn(\"Found element [${elementName}] under element for entity [${checkEd.getFullEntityName()}] and it is not a field or relationship so ignoring (file ${location} line ${locator?.lineNumber})\")\n                }\n            } else if (currentServiceDef != null) {\n                currentFieldName = qName\n                // TODO: support nested elements for services? ie look for attributes, somehow handle subelements, etc\n            } else {\n                if (edli.efi.isEntityDefined(elementName)) {\n                    currentEntityDef = edli.efi.getEntityDefinition(elementName)\n                    // logger.warn(\"Found entity ${currentEntityDef.getFullEntityName()} for ${entityName}\")\n                    rootValueMap = getAttributesMap(attributes, currentEntityDef)\n                } else if (edli.sfi.isServiceDefined(elementName)) {\n                    currentServiceDef = edli.sfi.getServiceDefinition(elementName)\n                    if (currentServiceDef == null) {\n                        int hashIndex = elementName.indexOf('#')\n                        entityOperation = elementName.substring(0, hashIndex)\n                        currentEntityDef = edli.efi.getEntityDefinition(elementName.substring(hashIndex + 1))\n                    }\n                    rootValueMap = getAttributesMap(attributes, null)\n                } else {\n                    throw new SAXException(\"Found element [${qName}] name, transformed to [${elementName}], that is not a valid entity name or service name (file ${location} line ${locator?.lineNumber})\")\n                }\n            }\n        }\n        Map getAttributesMap(Attributes attributes, EntityDefinition checkEd) {\n            Map attrMap = [:]\n            int length = attributes.getLength()\n            for (int i = 0; i < length; i++) {\n                String name = attributes.getLocalName(i)\n                String value = attributes.getValue(i)\n                if (!name) name = attributes.getQName(i)\n\n                if (checkEd == null || checkEd.isField(name)) {\n                    // treat empty strings as nulls\n                    if (value) {\n                        attrMap.put(name, value)\n                    } else {\n                        attrMap.put(name, null)\n                    }\n                } else {\n                    logger.warn(\"Ignoring invalid attribute name [${name}] for entity [${checkEd.getFullEntityName()}] with value [${value}] because it is not field of that entity (file ${location} line ${locator?.lineNumber})\")\n                }\n            }\n            return attrMap\n        }\n\n        void characters(char[] chars, int offset, int length) {\n            if (rootValueMap && currentFieldName) {\n                if (currentFieldValue == null) currentFieldValue = new StringBuilder()\n                currentFieldValue.append(chars, offset, length)\n            }\n        }\n        void endElement(String ns, String localName, String qName) {\n            if (qName == \"entity-facade-xml\" || qName == \"seed-data\") {\n                loadElements = false\n                return\n            }\n            if (!loadElements) return\n\n            if (currentFieldName != null) {\n                if (currentFieldValue) {\n                    EntityDefinition checkEd = currentEntityDef\n                    Map addToMap = rootValueMap\n                    if (relatedEdStack) {\n                        checkEd = relatedEdStack.get(0)\n                        addToMap = valueMapStack.get(0)\n                    }\n                    if (checkEd != null) {\n                        if (checkEd.isField(currentFieldName)) {\n                            FieldInfo fieldInfo = checkEd.getFieldInfo(currentFieldName)\n                            if (\"binary-very-long\".equals(fieldInfo.type)) {\n                                String curStringValue = currentFieldValue.toString()\n                                try {\n                                    byte[] binData = Base64.getDecoder().decode(curStringValue)\n                                    addToMap.put(currentFieldName, new SerialBlob(binData))\n                                } catch (IllegalArgumentException e) {\n                                    if (logger.isTraceEnabled()) logger.trace(\"Value for binary-very-long field ${currentFieldName} entity ${checkEd.getFullEntityName()} is not Base64, using UTF-8 bytes\")\n                                    addToMap.put(currentFieldName, new SerialBlob(curStringValue.getBytes(StandardCharsets.UTF_8)))\n                                }\n                            } else {\n                                addToMap.put(currentFieldName, currentFieldValue.toString())\n                            }\n                        } else {\n                            logger.warn(\"Ignoring invalid field name ${currentFieldName} found for entity ${checkEd.getFullEntityName()} (file ${location} line ${locator?.lineNumber}) with value: ${currentFieldValue}\")\n                        }\n                    } else if (currentServiceDef != null) {\n                        rootValueMap.put(currentFieldName, currentFieldValue)\n                    }\n                    currentFieldValue = null\n                }\n                currentFieldName = (String) null\n            } else if (valueMapStack) {\n                // end of nested relationship element, just pop the last\n                valueMapStack.remove(0)\n                relatedEdStack.remove(0)\n                valuesRead++\n            } else {\n                Map<String, Object> valueMap = [:]\n                if (edli.defaultValues != null && edli.defaultValues.size() > 0) valueMap.putAll(edli.defaultValues)\n                valueMap.putAll(rootValueMap)\n\n                if (currentEntityDef != null) {\n                    if (entityOperation == null) {\n                        try {\n                            // if (currentEntityDef.getFullEntityName().contains(\"DbForm\")) logger.warn(\"========= DbForm rootValueMap: ${rootValueMap}\")\n                            if (edli.dummyFks || edli.useTryInsert) {\n                                EntityValue curValue = currentEntityDef.makeEntityValue()\n                                curValue.setAll(valueMap)\n                                valueHandler.handleValue(curValue, location)\n                                valuesRead++\n                            } else {\n                                valueHandler.handlePlainMap(currentEntityDef.getFullEntityName(), valueMap, location)\n                                valuesRead++\n                            }\n                        } catch (EntityException e) {\n                            throw new SAXException(\"Error storing entity [${currentEntityDef.getFullEntityName()}] value (file ${location} line ${locator?.lineNumber}): \" + e.toString(), e)\n                        } finally {\n                            currentEntityDef = (EntityDefinition) null\n                        }\n                    } else {\n                        try {\n                            ServiceCallSync currentScs = edli.sfi.sync().name(entityOperation, currentEntityDef.getFullEntityName()).parameters(valueMap)\n                            valueHandler.handleService(currentScs, location)\n                            valuesRead++\n                        } catch (Exception e) {\n                            throw new SAXException(\"Error running service [${currentServiceDef.serviceName}] (file ${location} line ${locator?.lineNumber}): \" + e.toString(), e)\n                        } finally {\n                            currentEntityDef = (EntityDefinition) null\n                            entityOperation = (String) null\n                        }\n                    }\n                } else if (currentServiceDef != null) {\n                    try {\n                        ServiceCallSync currentScs = edli.sfi.sync().name(currentServiceDef.serviceName).parameters(valueMap)\n                        valueHandler.handleService(currentScs, location)\n                        valuesRead++\n                    } catch (Exception e) {\n                        throw new SAXException(\"Error running service [${currentServiceDef.serviceName}] (file ${location} line ${locator?.lineNumber}): \" + e.toString(), e)\n                    } finally {\n                        currentServiceDef = (ServiceDefinition) null\n                    }\n                }\n            }\n        }\n\n        void setDocumentLocator(Locator locator) { this.locator = locator }\n    }\n\n    static class EntityCsvHandler {\n        protected EntityDataLoaderImpl edli\n        protected ValueHandler valueHandler\n\n        protected long valuesRead = 0\n        protected List<String> messageList = new LinkedList()\n\n        EntityCsvHandler(EntityDataLoaderImpl edli, ValueHandler valueHandler) {\n            this.edli = edli\n            this.valueHandler = valueHandler\n        }\n\n        ValueHandler getValueHandler() { return valueHandler }\n        long getValuesRead() { return valuesRead }\n        List<String> getMessageList() { return messageList }\n\n        boolean loadFile(String location, InputStream is) {\n            BufferedReader reader = new BufferedReader(new InputStreamReader(is, \"UTF-8\"))\n\n            CSVParser parser = CSVFormat.newFormat(edli.csvDelimiter)\n                    .withCommentMarker(edli.csvCommentStart)\n                    .withQuote(edli.csvQuoteChar)\n                    .withSkipHeaderRecord(true) // TODO: remove this? does it even do anything?\n                    .withIgnoreEmptyLines(true)\n                    .withIgnoreSurroundingSpaces(true)\n                    .parse(reader)\n\n            Iterator<CSVRecord> iterator = parser.iterator()\n\n            if (!iterator.hasNext()) throw new BaseException(\"Not loading file [${location}], no data found\")\n\n            String entityName\n            boolean isService\n            if (edli.csvEntityName) {\n                entityName = edli.csvEntityName\n                // NOTE: when csvEntityName set it is checked to make sure it is a valid entity or service name, so\n                //     just check to see if it is a service\n                isService = edli.sfi.isServiceDefined(entityName)\n            } else {\n                CSVRecord firstLineRecord = iterator.next()\n                entityName = firstLineRecord.get(0)\n                if (edli.efi.isEntityDefined(entityName)) {\n                    isService = false\n                } else if (edli.sfi.isServiceDefined(entityName)) {\n                    isService = true\n                } else {\n                    throw new BaseException(\"CSV first line first field [${entityName}] is not a valid entity name or service name\")\n                }\n\n                if (firstLineRecord.size() > 1) {\n                    // second field is data type\n                    String type = firstLineRecord.get(1)\n                    if (type && edli.dataTypes && !edli.dataTypes.contains(type)) {\n                        if (logger.isInfoEnabled()) logger.info(\"Skipping file [${location}], is a type to skip (${type})\")\n                        return false\n                    }\n                }\n            }\n\n            Map<String, Integer> headerMap = [:]\n            if (edli.csvFieldNames) {\n                for (int i = 0; i < edli.csvFieldNames.size(); i++) headerMap.put(edli.csvFieldNames.get(i), i)\n            } else {\n                if (!iterator.hasNext()) throw new BaseException(\"Not loading file [${location}], no second (header) line found\")\n                CSVRecord headerRecord = iterator.next()\n                for (int i = 0; i < headerRecord.size(); i++) headerMap.put(headerRecord.get(i), i)\n            }\n\n            // logger.warn(\"======== CSV entity/service [${entityName}] headerMap: ${headerMap}\")\n            EntityDefinition entityDefinition = isService ? null : edli.efi.getEntityDefinition(entityName)\n            while (iterator.hasNext()) {\n                CSVRecord record = iterator.next()\n                // logger.warn(\"======== CSV record: ${record.toString()}\")\n                if (isService) {\n                    ServiceCallSyncImpl currentScs = (ServiceCallSyncImpl) edli.sfi.sync().name(entityName)\n                    if (edli.defaultValues) currentScs.parameters(edli.defaultValues)\n                    for (Map.Entry<String, Integer> header in headerMap) {\n                        // if not enough elements in the record for the index, skip it\n                        if (header.value >= record.size()) continue\n                        currentScs.parameter(header.key, record.get(header.value))\n                    }\n                    valueHandler.handleService(currentScs, location)\n                    valuesRead++\n                } else {\n                    EntityValueImpl currentEntityValue = (EntityValueImpl) edli.efi.makeValue(entityName)\n                    if (edli.defaultValues) currentEntityValue.setFields(edli.defaultValues, true, null, null)\n                    for (Map.Entry<String, Integer> header in headerMap) {\n                        String fieldStr = record.get(header.value)\n                        if (fieldStr == null) continue\n                        if (fieldStr.isEmpty()) {\n                            currentEntityValue.set(header.key, null)\n                            continue\n                        }\n\n                        // for BLOB field type do Base64 decode\n                        if (entityDefinition != null && fieldStr != null) {\n                            FieldInfo fi = entityDefinition.fieldInfoMap.get(header.key)\n                            if (fi.typeValue == 12) {\n                                byte[] bytes = Base64.getDecoder().decode(fieldStr)\n                                logger.warn(\"Load ${bytes.length} bytes: ${fieldStr}\")\n                                currentEntityValue.setBytes(header.key, bytes)\n                                continue\n                            }\n                        }\n\n                        // handle generally with setString()\n                        currentEntityValue.setString(header.key, fieldStr)\n                    }\n\n                    if (!currentEntityValue.containsPrimaryKey()) {\n                        if (currentEntityValue.getEntityDefinition().getPkFieldNames().size() == 1) {\n                            currentEntityValue.setSequencedIdPrimary()\n                        } else {\n                            throw new BaseException(\"Cannot process value with incomplete primary key for [${currentEntityValue.resolveEntityName()}] with more than 1 primary key field: \" + currentEntityValue)\n                        }\n                    }\n\n                    // logger.warn(\"======== CSV entity: ${currentEntityValue.toString()}\")\n                    valueHandler.handleValue(currentEntityValue, location)\n                    valuesRead++\n                }\n            }\n            return true\n        }\n    }\n\n    static class EntityJsonHandler {\n        protected EntityDataLoaderImpl edli\n        protected ValueHandler valueHandler\n\n        protected long valuesRead = 0\n        protected List<String> messageList = new LinkedList()\n\n        EntityJsonHandler(EntityDataLoaderImpl edli, ValueHandler valueHandler) {\n            this.edli = edli\n            this.valueHandler = valueHandler\n        }\n\n        ValueHandler getValueHandler() { return valueHandler }\n        long getValuesRead() { return valuesRead }\n        List<String> getMessageList() { return messageList }\n\n        boolean loadFile(String location, InputStream is) {\n            JsonSlurper slurper = new JsonSlurper()\n            Object jsonObj\n            try {\n                jsonObj = slurper.parse(new BufferedReader(new InputStreamReader(is, \"UTF-8\")))\n            } catch (Throwable t) {\n                String errMsg = \"Error parsing HTTP request body JSON: ${t.toString()}\"\n                logger.error(errMsg, t)\n                throw new BaseException(errMsg, t)\n            }\n\n            String type = null\n            List valueList\n            if (jsonObj instanceof Map) {\n                Map jsonMap = (Map) jsonObj\n                type = jsonMap.get(\"_dataType\")\n                valueList = [jsonObj]\n            } else if (jsonObj instanceof List) {\n                valueList = (List) jsonObj\n                Object firstValue = valueList?.get(0)\n                if (firstValue instanceof Map) {\n                    Map firstValMap = (Map) firstValue\n                    if (firstValMap.get(\"_dataType\")) {\n                        type = firstValMap.get(\"_dataType\")\n                        valueList.remove((int) 0I)\n                    }\n                }\n            } else {\n                throw new BaseException(\"Root JSON field was not a Map/object or List/array, type is ${jsonObj.getClass().getName()}\")\n            }\n\n            if (type && edli.dataTypes && !edli.dataTypes.contains(type)) {\n                if (logger.isInfoEnabled()) logger.info(\"Skipping file [${location}], is a type to skip (${type})\")\n                return false\n            }\n\n            for (Object valueObj in valueList) {\n                if (!(valueObj instanceof Map)) {\n                    logger.warn(\"Found non-Map object in JSON import, skipping: ${valueObj}\")\n                    continue\n                }\n\n                Map<String, Object> value = [:]\n                if (edli.defaultValues) value.putAll(edli.defaultValues)\n                value.putAll((Map) valueObj)\n\n                String entityName = value.\"_entity\"\n                boolean isService\n                if (edli.efi.isEntityDefined(entityName)) {\n                    isService = false\n                } else if (edli.sfi.isServiceDefined(entityName)) {\n                    isService = true\n                } else {\n                    throw new BaseException(\"JSON _entity value [${entityName}] is not a valid entity name or service name\")\n                }\n\n                if (isService) {\n                    ServiceCallSyncImpl currentScs = (ServiceCallSyncImpl) edli.sfi.sync().name(entityName).parameters(value)\n                    valueHandler.handleService(currentScs, location)\n                    valuesRead++\n                } else {\n                    valueHandler.handlePlainMap(entityName, value, location)\n                    // TODO: make this more complete, like counting nested Maps?\n                    valuesRead++\n                }\n            }\n\n            return true\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityDataWriterImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity\n\nimport groovy.json.JsonBuilder\nimport groovy.transform.CompileStatic\nimport org.moqui.entity.EntityValue\nimport org.moqui.util.ObjectUtilities\n\nimport javax.sql.rowset.serial.SerialBlob\nimport java.sql.Timestamp\n\nimport org.moqui.context.TransactionException\nimport org.moqui.context.TransactionFacade\nimport org.moqui.entity.EntityDataWriter\nimport org.moqui.entity.EntityListIterator\nimport org.moqui.entity.EntityFind\nimport org.moqui.entity.EntityCondition.ComparisonOperator\n\nimport org.slf4j.LoggerFactory\nimport org.slf4j.Logger\n\nimport java.time.ZoneOffset\nimport java.time.format.DateTimeFormatter\nimport java.util.zip.ZipEntry\nimport java.util.zip.ZipOutputStream\n\n@CompileStatic\nclass EntityDataWriterImpl implements EntityDataWriter {\n    private final static Logger logger = LoggerFactory.getLogger(EntityDataWriterImpl.class)\n\n    private EntityFacadeImpl efi\n\n    private FileType fileType = XML\n    private int txTimeout = 3600\n    private LinkedHashSet<String> entityNames = new LinkedHashSet<>()\n    private LinkedHashSet<String> skipEntityNames = new LinkedHashSet<>()\n\n    private int dependentLevels = 0\n    private String masterName = null\n    private String prefix = null\n    private Map<String, Object> filterMap = [:]\n    private List<String> orderByList = []\n    private Timestamp fromDate = null\n    private Timestamp thruDate = null\n\n    private boolean isoDateTime = false\n    private boolean tableColumnNames = false\n\n    EntityDataWriterImpl(EntityFacadeImpl efi) { this.efi = efi }\n\n    EntityFacadeImpl getEfi() { return efi }\n\n    EntityDataWriter fileType(FileType ft) { fileType = ft; return this }\n    EntityDataWriter fileType(String ft) { fileType = FileType.valueOf(ft); return this }\n    EntityDataWriter entityName(String entityName) { entityNames.add(entityName); return this }\n    EntityDataWriter entityNames(Collection<String> enList) { entityNames.addAll(enList); return this }\n    EntityDataWriter skipEntityName(String entityName) { skipEntityNames.add(entityName); return this }\n    EntityDataWriter skipEntityNames(Collection<String> enList) { skipEntityNames.addAll(enList); return this }\n    EntityDataWriter allEntities() {\n        LinkedHashSet<String> newEntities = new LinkedHashSet<>(efi.getAllNonViewEntityNames())\n        newEntities.removeAll(entityNames)\n        entityNames = newEntities\n        return this\n    }\n\n    EntityDataWriter dependentRecords(boolean dr) { if (dr) { dependentLevels = 2 } else { dependentLevels = 0 }; return this }\n    EntityDataWriter dependentLevels(int levels) { dependentLevels = levels; return this }\n    EntityDataWriter master(String mn) { masterName = mn; return this }\n    EntityDataWriter prefix(String p) { prefix = p; return this }\n    EntityDataWriter filterMap(Map<String, Object> fm) { filterMap.putAll(fm); return this }\n    EntityDataWriter orderBy(List<String> obl) { orderByList.addAll(obl); return this }\n    EntityDataWriter fromDate(Timestamp fd) { fromDate = fd; return this }\n    EntityDataWriter thruDate(Timestamp td) { thruDate = td; return this }\n\n    EntityDataWriter isoDateTime(boolean iso) { isoDateTime = iso; return this }\n    EntityDataWriter tableColumnNames(boolean tcn) { tableColumnNames = tcn; return this }\n\n    @Override\n    int file(String filename) {\n        File outFile = new File(filename)\n        if (!outFile.createNewFile()) {\n            efi.ecfi.executionContext.message.addError(efi.ecfi.resource.expand('File ${filename} already exists.','',[filename:filename]))\n            return 0\n        }\n\n        if (filename.endsWith('.json')) fileType(JSON)\n        else if (filename.endsWith('.xml')) fileType(XML)\n        else if (filename.endsWith('.csv')) fileType(CSV)\n\n        if (CSV.is(fileType) && entityNames.size() > 1) {\n            efi.ecfi.executionContext.message.addError('Cannot write to single CSV file with multiple entity names')\n            return 0\n        }\n\n        PrintWriter pw = new PrintWriter(outFile)\n        // NOTE: don't have to do anything different here for different file types, writer() method will handle that\n        int valuesWritten = this.writer(pw)\n        pw.close()\n        efi.ecfi.executionContext.message.addMessage(efi.ecfi.resource.expand('Wrote ${valuesWritten} records to file ${filename}', '', [valuesWritten:valuesWritten, filename:filename]))\n        return valuesWritten\n    }\n\n    @Override\n    int zipFile(String filenameWithinZip, String zipFilename) {\n        File zipFile = new File(zipFilename)\n        if (!zipFile.parentFile.exists()) zipFile.parentFile.mkdirs()\n        if (!zipFile.createNewFile()) {\n            efi.ecfi.executionContext.message.addError(efi.ecfi.resource.expand('File ${filename} already exists.', '', [filename:zipFilename]))\n            return 0\n        }\n\n        if (filenameWithinZip.endsWith('.json')) fileType(JSON)\n        else if (filenameWithinZip.endsWith('.xml')) fileType(XML)\n        else if (filenameWithinZip.endsWith('.csv')) fileType(CSV)\n\n        if (CSV.is(fileType) && entityNames.size() > 1) {\n            efi.ecfi.executionContext.message.addError('Cannot write to single CSV file with multiple entity names')\n            return 0\n        }\n\n        ZipOutputStream out = new ZipOutputStream(new FileOutputStream(zipFile))\n        try {\n            PrintWriter pw = new PrintWriter(out)\n            ZipEntry e = new ZipEntry(filenameWithinZip)\n            out.putNextEntry(e)\n            try {\n                int valuesWritten = this.writer(pw)\n                pw.flush()\n                efi.ecfi.executionContext.message.addMessage(efi.ecfi.resource.expand('Wrote ${valuesWritten} records to file ${filename}', '', [valuesWritten:valuesWritten, filename:zipFilename]))\n                return valuesWritten\n            } finally {\n                out.closeEntry()\n            }\n        } finally {\n            out.close()\n        }\n    }\n\n    @Override\n    int directory(String path) {\n        File outDir = new File(path)\n        if (!outDir.exists()) outDir.mkdir()\n        if (!outDir.isDirectory()) {\n            efi.ecfi.executionContext.message.addError(efi.ecfi.resource.expand('Path ${path} is not a directory.','',[path:path]))\n            return 0\n        }\n\n        if (dependentLevels > 0) efi.createAllAutoReverseManyRelationships()\n\n        int valuesWritten = 0\n\n        TransactionFacade tf = efi.ecfi.transactionFacade\n        boolean suspendedTransaction = false\n        try {\n            if (tf.isTransactionInPlace()) suspendedTransaction = tf.suspend()\n            boolean beganTransaction = tf.begin(txTimeout)\n            try {\n                for (String en in entityNames) {\n                    if (skipEntityNames.contains(en)) continue\n                    EntityDefinition ed = efi.getEntityDefinition(en)\n                    boolean useMaster = masterName != null && masterName.length() > 0 && ed.getMasterDefinition(masterName) != null\n                    EntityFind ef = makeEntityFind(en)\n\n\n                    try (EntityListIterator eli = ef.iterator()) {\n                        if (!eli.hasNext()) continue\n\n                        String filename = path + '/' + en + '.' + fileType.name().toLowerCase()\n                        File outFile = new File(filename)\n                        if (outFile.exists()) {\n                            efi.ecfi.getEci().message.addError(efi.ecfi.resource.expand('File ${filename} already exists, skipping entity ${en}.','',[filename:filename,en:en]))\n                            continue\n                        }\n                        outFile.createNewFile()\n\n                        PrintWriter pw = new PrintWriter(outFile)\n                        try {\n                            startFile(pw, ed)\n\n                            int curValuesWritten = 0\n                            EntityValue ev\n                            while ((ev = eli.next()) != null) {\n                                curValuesWritten += writeValue(ev, pw, useMaster)\n                            }\n\n                            endFile(pw)\n\n                            efi.ecfi.getEci().message.addMessage(efi.ecfi.resource.expand('Wrote ${curValuesWritten} records to file ${filename}','',[curValuesWritten:curValuesWritten,filename:filename]))\n                            valuesWritten += curValuesWritten\n                        } finally {\n                            pw.close()\n                        }\n                    }\n                }\n            } catch (Throwable t) {\n                logger.warn(\"Error writing data\", t)\n                tf.rollback(beganTransaction, \"Error writing data\", t)\n                efi.ecfi.getEci().messageFacade.addError(t.getMessage())\n            } finally {\n                if (beganTransaction && tf.isTransactionInPlace()) tf.commit()\n            }\n        } catch (TransactionException e) {\n            throw e\n        } finally {\n            try {\n                if (suspendedTransaction) tf.resume()\n            } catch (Throwable t) {\n                logger.error(\"Error resuming parent transaction after data write\", t)\n            }\n        }\n\n        return valuesWritten\n    }\n\n    @Override\n    int zipDirectory(String pathWithinZip, String zipFilename) {\n        File zipFile = new File(zipFilename)\n        if (!zipFile.parentFile.exists()) zipFile.parentFile.mkdirs()\n        if (!zipFile.createNewFile()) {\n            efi.ecfi.executionContext.message.addError(efi.ecfi.resource.expand('File ${filename} already exists.', '', [filename:zipFilename]))\n            return 0\n        }\n\n        return zipDirectory(pathWithinZip, new FileOutputStream(zipFile))\n    }\n    @Override\n    int zipDirectory(String pathWithinZip, OutputStream outputStream) {\n        if (dependentLevels > 0) efi.createAllAutoReverseManyRelationships()\n\n        int valuesWritten = 0\n        ZipOutputStream out = new ZipOutputStream(outputStream)\n        try {\n            PrintWriter pw = new PrintWriter(out)\n            for (String en in entityNames) {\n                if (skipEntityNames.contains(en)) continue\n                EntityDefinition ed = efi.getEntityDefinition(en)\n                boolean useMaster = masterName != null && masterName.length() > 0 && ed.getMasterDefinition(masterName) != null\n                EntityFind ef = makeEntityFind(en)\n                try (EntityListIterator eli = ef.iterator()) {\n                    if (!eli.hasNext()) continue\n\n                    String filenameBase = tableColumnNames ? ed.getTableName() : en\n                    String filenameWithinZip = (pathWithinZip ? pathWithinZip + '/' : '') + filenameBase + '.' + fileType.name().toLowerCase()\n                    ZipEntry e = new ZipEntry(filenameWithinZip)\n                    out.putNextEntry(e)\n                    try {\n                        startFile(pw, ed)\n\n                        int curValuesWritten = 0\n                        EntityValue ev\n                        while ((ev = eli.next()) != null) {\n                            curValuesWritten += writeValue(ev, pw, useMaster)\n                        }\n\n                        endFile(pw)\n\n                        pw.flush()\n                        efi.ecfi.getEci().message.addMessage(efi.ecfi.resource.expand('Wrote ${curValuesWritten} records to ${filename}','',[curValuesWritten:curValuesWritten,filename:filenameWithinZip]))\n\n                        valuesWritten += curValuesWritten\n                    } finally {\n                        out.closeEntry()\n                    }\n                }\n            }\n        } finally {\n            out.close()\n        }\n        return valuesWritten\n    }\n\n\n    @Override\n    int writer(Writer writer) {\n        if (dependentLevels > 0) efi.createAllAutoReverseManyRelationships()\n\n        LinkedHashSet<String> activeEntityNames\n        if (skipEntityNames.size() == 0) {\n            activeEntityNames = entityNames\n        } else {\n            activeEntityNames = new LinkedHashSet<>(entityNames)\n            activeEntityNames.removeAll(skipEntityNames)\n        }\n        EntityDefinition singleEd = null\n        if (activeEntityNames.size() == 1) singleEd = efi.getEntityDefinition(activeEntityNames.first())\n\n        TransactionFacade tf = efi.ecfi.transactionFacade\n        boolean suspendedTransaction = false\n        int valuesWritten = 0\n        try {\n            if (tf.isTransactionInPlace()) suspendedTransaction = tf.suspend()\n            boolean beganTransaction = tf.begin(txTimeout)\n            try {\n                startFile(writer, singleEd)\n\n                for (String en in activeEntityNames) {\n                    EntityDefinition ed = efi.getEntityDefinition(en)\n                    boolean useMaster = masterName != null && masterName.length() > 0 && ed.getMasterDefinition(masterName) != null\n                    try (EntityListIterator eli = makeEntityFind(en).iterator()) {\n                        EntityValue ev\n                        while ((ev = eli.next()) != null) {\n                            valuesWritten+= writeValue(ev, writer, useMaster)\n                        }\n                    }\n                }\n\n                endFile(writer)\n            } catch (Throwable t) {\n                logger.warn(\"Error writing data: \" + t.toString(), t)\n                tf.rollback(beganTransaction, \"Error writing data\", t)\n                efi.ecfi.getEci().messageFacade.addError(t.getMessage())\n            } finally {\n                if (beganTransaction && tf.isTransactionInPlace()) tf.commit()\n            }\n        } catch (TransactionException e) {\n            throw e\n        } finally {\n            try {\n                if (suspendedTransaction) tf.resume()\n            } catch (Throwable t) {\n                logger.error(\"Error resuming parent transaction after data write\", t)\n            }\n        }\n\n        return valuesWritten\n    }\n\n    private void startFile(Writer writer, EntityDefinition ed) {\n        if (JSON.is(fileType)) {\n            writer.println(\"[\")\n        } else if (XML.is(fileType)) {\n            writer.println(\"<?xml version=\\\"1.0\\\" encoding=\\\"UTF-8\\\"?>\")\n            writer.println(\"<entity-facade-xml>\")\n        } else if (CSV.is(fileType)) {\n            if (ed == null) throw new IllegalArgumentException(\"Tried to start CSV file with no single entity specified\")\n            // first record: entity name, 'export' for file type, then each PK field\n            if (tableColumnNames) {\n                writer.println(ed.getTableName() + \",export,\" + ed.getPkFieldNames().collect({ ed.getFieldInfo(it).columnName }).join(\",\"))\n            } else {\n                writer.println(ed.getFullEntityName() + \",export,\" + ed.getPkFieldNames().join(\",\"))\n            }\n            // second record: header row with all field names\n            if (tableColumnNames) {\n                writer.println(ed.getAllFieldNames().collect({ ed.getFieldInfo(it).columnName }).join(\",\"))\n            } else {\n                writer.println(ed.getAllFieldNames().join(\",\"))\n            }\n        }\n    }\n    private void endFile(Writer writer) {\n        if (JSON.is(fileType)) {\n            writer.println(\"]\")\n            writer.println(\"\")\n        } else if (XML.is(fileType)) {\n            writer.println(\"</entity-facade-xml>\")\n            writer.println(\"\")\n        } else if (CSV.is(fileType)) {\n            // could add empty line at end but is effectively an empty record, better to do nothing to end the file\n            // writer.println(\"\")\n        }\n    }\n    private int writeValue(EntityValue ev, Writer writer, boolean useMaster) {\n        int valuesWritten\n        if (JSON.is(fileType)) {\n            // TODO: support isoDateTime and tableColumnNames\n            Map<String, Object> plainMap\n            if (useMaster) {\n                plainMap = ev.getMasterValueMap(masterName)\n            } else {\n                plainMap = ev.getPlainValueMap(dependentLevels)\n            }\n            JsonBuilder jb = new JsonBuilder()\n            jb.call(plainMap)\n            String jsonStr = jb.toPrettyString()\n            writer.write(jsonStr)\n            writer.println(\",\")\n            // TODO: consider including dependent records in the count too... maybe write something to recursively count the nested Maps\n            valuesWritten = 1\n        } else if (XML.is(fileType)) {\n            // TODO: support isoDateTime and tableColumnNames\n            if (useMaster) {\n                valuesWritten = ev.writeXmlTextMaster(writer, prefix, masterName)\n            } else {\n                valuesWritten = ev.writeXmlText(writer, prefix, dependentLevels)\n            }\n        } else if (CSV.is(fileType)) {\n            EntityValueBase evb = (EntityValueBase) ev\n            // NOTE: master entity def concept doesn't apply to CSV, file format cannot handle multiple entities in single file\n            FieldInfo[] fieldInfoArray = evb.getEntityDefinition().entityInfo.allFieldInfoArray\n            for (int i = 0; i < fieldInfoArray.length; i++) {\n                Object fieldValue = evb.getKnownField(fieldInfoArray[i])\n                String fieldStr = convertFieldValue(fieldValue)\n\n                // write the field value\n                if (fieldStr.contains(\",\") || fieldStr.contains(\"\\\"\") || fieldStr.contains(\"\\n\")) {\n                    writer.write(\"\\\"\")\n                    writer.write(fieldStr.replace(\"\\\"\", \"\\\"\\\"\"))\n                    writer.write(\"\\\"\")\n                } else {\n                    writer.write(fieldStr)\n                }\n\n                // add the comma\n                if (i < (fieldInfoArray.length - 1)) writer.write(\",\")\n            }\n\n            // end the line\n            writer.println()\n            valuesWritten = 1\n        }\n        return valuesWritten\n    }\n\n    String convertFieldValue(Object fieldValue) {\n        String fieldStr\n        if (fieldValue instanceof byte[]) {\n            fieldStr = Base64.getEncoder().encodeToString((byte[]) fieldValue)\n        } else if (fieldValue instanceof SerialBlob) {\n            if (((SerialBlob) fieldValue).length() == 0) {\n                fieldStr = \"\"\n            } else {\n                byte[] objBytes = ((SerialBlob) fieldValue).getBytes(1, (int) ((SerialBlob) fieldValue).length())\n                fieldStr = Base64.getEncoder().encodeToString(objBytes)\n            }\n        } else if (isoDateTime && fieldValue instanceof java.util.Date) {\n            if (fieldValue instanceof Timestamp) {\n                fieldStr = fieldValue.toInstant().atZone(ZoneOffset.UTC.normalized()).format(DateTimeFormatter.ISO_INSTANT)\n            } else if (fieldValue instanceof java.sql.Date) {\n                fieldStr = efi.ecfi.getEci().l10nFacade.formatDate(fieldValue, \"yyyy-MM-dd\", null, null)\n            } else if (fieldValue instanceof java.sql.Time) {\n                fieldStr = efi.ecfi.getEci().l10nFacade.formatTime(fieldValue, \"HH:mm:ssZ\", null, TimeZone.getTimeZone(ZoneOffset.UTC))\n            } else {\n                fieldStr = fieldValue.toInstant().atZone(ZoneOffset.UTC.normalized()).format(DateTimeFormatter.ISO_DATE_TIME)\n            }\n        } else {\n            fieldStr = ObjectUtilities.toPlainString(fieldValue)\n        }\n        if (fieldStr == null) fieldStr = \"\"\n        return fieldStr\n    }\n\n    private EntityFind makeEntityFind(String en) {\n        EntityFind ef = efi.find(en).condition(filterMap).orderBy(orderByList)\n        EntityDefinition ed = efi.getEntityDefinition(en)\n        if (ed.isField(\"lastUpdatedStamp\")) {\n            if (fromDate) ef.condition(\"lastUpdatedStamp\", ComparisonOperator.GREATER_THAN_EQUAL_TO, fromDate)\n            if (thruDate) ef.condition(\"lastUpdatedStamp\", ComparisonOperator.LESS_THAN, thruDate)\n        }\n        return ef\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityDatasourceFactoryImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity\n\nimport groovy.transform.CompileStatic\nimport org.moqui.context.TransactionInternal\nimport org.moqui.entity.*\nimport org.moqui.util.MNode\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport javax.naming.Context\nimport javax.naming.InitialContext\nimport javax.naming.NamingException\nimport javax.sql.DataSource\n\n@CompileStatic\nclass EntityDatasourceFactoryImpl implements EntityDatasourceFactory {\n    protected final static Logger logger = LoggerFactory.getLogger(EntityDatasourceFactoryImpl.class)\n    protected final static int DS_RETRY_COUNT = 5\n    protected final static long DS_RETRY_SLEEP = 5000\n\n    protected EntityFacadeImpl efi = null\n    protected MNode datasourceNode = null\n\n    protected DataSource dataSource = null\n    EntityFacadeImpl.DatasourceInfo dsi = null\n\n\n    EntityDatasourceFactoryImpl() { }\n\n    @Override\n    EntityDatasourceFactory init(EntityFacade ef, MNode datasourceNode) {\n        // local fields\n        this.efi = (EntityFacadeImpl) ef\n        this.datasourceNode = datasourceNode\n\n        // init the DataSource\n        dsi = new EntityFacadeImpl.DatasourceInfo(efi, datasourceNode)\n        if (dsi.jndiName != null && !dsi.jndiName.isEmpty()) {\n            try {\n                InitialContext ic;\n                if (dsi.serverJndi) {\n                    Hashtable<String, Object> h = new Hashtable<String, Object>()\n                    h.put(Context.INITIAL_CONTEXT_FACTORY, dsi.serverJndi.attribute(\"initial-context-factory\"))\n                    h.put(Context.PROVIDER_URL, dsi.serverJndi.attribute(\"context-provider-url\"))\n                    if (dsi.serverJndi.attribute(\"url-pkg-prefixes\")) h.put(Context.URL_PKG_PREFIXES, dsi.serverJndi.attribute(\"url-pkg-prefixes\"))\n                    if (dsi.serverJndi.attribute(\"security-principal\")) h.put(Context.SECURITY_PRINCIPAL, dsi.serverJndi.attribute(\"security-principal\"))\n                    if (dsi.serverJndi.attribute(\"security-credentials\")) h.put(Context.SECURITY_CREDENTIALS, dsi.serverJndi.attribute(\"security-credentials\"))\n                    ic = new InitialContext(h)\n                } else {\n                    ic = new InitialContext()\n                }\n\n                this.dataSource = (DataSource) ic.lookup(dsi.jndiName)\n                if (this.dataSource == null) {\n                    logger.error(\"Could not find DataSource with name [${dsi.jndiName}] in JNDI server [${dsi.serverJndi ? dsi.serverJndi.attribute(\"context-provider-url\") : \"default\"}] for datasource with group-name [${datasourceNode.attribute(\"group-name\")}].\")\n                }\n            } catch (NamingException ne) {\n                logger.error(\"Error finding DataSource with name [${dsi.jndiName}] in JNDI server [${dsi.serverJndi ? dsi.serverJndi.attribute(\"context-provider-url\") : \"default\"}] for datasource with group-name [${datasourceNode.attribute(\"group-name\")}].\", ne)\n            }\n        } else if (dsi.inlineJdbc != null) {\n            // special thing for embedded derby, just set an system property; for derby.log, etc\n            if (datasourceNode.attribute(\"database-conf-name\") == \"derby\" && !System.getProperty(\"derby.system.home\")) {\n                System.setProperty(\"derby.system.home\", efi.ecfi.runtimePath + \"/db/derby\")\n                logger.info(\"Set property derby.system.home to [${System.getProperty(\"derby.system.home\")}]\")\n            }\n\n            TransactionInternal ti = efi.ecfi.transactionFacade.getTransactionInternal()\n            // init the DataSource, if it fails for any reason retry a few times\n            for (int retry = 1; retry <= DS_RETRY_COUNT; retry++) {\n                try {\n                    this.dataSource = ti.getDataSource(efi, datasourceNode)\n                    break\n                } catch (Throwable t) {\n                    if (retry < DS_RETRY_COUNT) {\n                        Throwable cause = t\n                        while (cause.getCause() != null) cause = cause.getCause()\n                        logger.error(\"Error connecting to DataSource ${datasourceNode.attribute(\"group-name\")} (${datasourceNode.attribute(\"database-conf-name\")}), try ${retry} of ${DS_RETRY_COUNT}: ${cause}\")\n                        sleep(DS_RETRY_SLEEP)\n                    } else {\n                        throw t\n                    }\n                }\n            }\n        } else {\n            throw new EntityException(\"Found datasource with no jdbc sub-element (in datasource with group-name ${datasourceNode.attribute(\"group-name\")})\")\n        }\n\n        return this\n    }\n\n    @Override\n    void destroy() {\n        // NOTE: TransactionInternal DataSource will be destroyed when the TransactionFacade is destroyed\n    }\n\n    @Override\n    boolean checkTableExists(String entityName) {\n        EntityDefinition ed\n        // just ignore EntityException on getEntityDefinition\n        try { ed = efi.getEntityDefinition(entityName) } catch (EntityException e) { return false }\n        // may happen if all entity names includes a DB view entity or other that doesn't really exist\n        if (ed == null) return false\n        return ed.tableExistsDbMetaOnly()\n    }\n    @Override\n    boolean checkAndAddTable(String entityName) {\n        EntityDefinition ed\n        // just ignore EntityException on getEntityDefinition\n        try { ed = efi.getEntityDefinition(entityName) } catch (EntityException e) { return false }\n        // may happen if all entity names includes a DB view entity or other that doesn't really exist\n        if (ed == null) return false\n        return efi.getEntityDbMeta().checkTableStartup(ed)\n    }\n    @Override\n    int checkAndAddAllTables() {\n        return efi.getEntityDbMeta().checkAndAddAllTables(datasourceNode.attribute(\"group-name\"))\n    }\n\n    @Override\n    EntityValue makeEntityValue(String entityName) {\n        EntityDefinition entityDefinition = efi.getEntityDefinition(entityName)\n        if (entityDefinition == null) throw new EntityException(\"Entity not found for name [${entityName}]\")\n        return new EntityValueImpl(entityDefinition, efi)\n    }\n\n    @Override\n    EntityFind makeEntityFind(String entityName) { return new EntityFindImpl(efi, entityName) }\n\n    @Override\n    void createBulk(List<EntityValue> valueList) {\n        // basic approach, can probably do better with some JDBC tricks\n        Iterator<EntityValue> valueIterator = valueList.iterator()\n        while (valueIterator.hasNext()) {\n            EntityValue ev = (EntityValue) valueIterator.next()\n            ev.create()\n        }\n    }\n\n    @Override\n    DataSource getDataSource() { return dataSource }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityDbMeta.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity\n\nimport groovy.transform.CompileStatic\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.entity.EntityJavaUtil.RelationshipInfo\nimport org.moqui.util.CollectionUtilities\nimport org.moqui.util.MNode\nimport org.moqui.util.SystemBinding\n\nimport java.sql.Connection\nimport java.sql.Statement\nimport java.sql.DatabaseMetaData\nimport java.sql.ResultSet\nimport java.sql.Timestamp\n\nimport org.moqui.entity.EntityException\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.util.concurrent.locks.ReentrantLock\n\n@CompileStatic\nclass EntityDbMeta {\n    protected final static Logger logger = LoggerFactory.getLogger(EntityDbMeta.class)\n\n    static final boolean useTxForMetaData = false\n\n    // this keeps track of when tables are checked and found to exist or are created\n    protected HashMap<String, Timestamp> entityTablesChecked = new HashMap<>()\n    // a separate Map for tables checked to exist only (used in finds) so repeated checks are needed for unused entities\n    protected HashMap<String, Boolean> entityTablesExist = new HashMap<>()\n\n    protected HashMap<String, Boolean> runtimeAddMissingMap = new HashMap<>()\n\n    protected EntityFacadeImpl efi\n\n    EntityDbMeta(EntityFacadeImpl efi) { this.efi = efi }\n\n    static boolean shouldCreateFks(ExecutionContextFactoryImpl ecfi) {\n        if (ecfi.getEci().artifactExecutionFacade.entityFkCreateDisabled()) return false\n        if (\"true\".equals(SystemBinding.getPropOrEnv(\"entity_disable_fk_create\"))) return false\n        return true\n    }\n\n    boolean checkTableRuntime(EntityDefinition ed) {\n        EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo\n        // most common case: not view entity and already checked\n        boolean alreadyChecked = entityTablesChecked.containsKey(entityInfo.fullEntityName)\n        if (alreadyChecked) return false\n\n        String groupName = entityInfo.groupName\n        Boolean runtimeAddMissing = (Boolean) runtimeAddMissingMap.get(groupName)\n        if (runtimeAddMissing == null) {\n            MNode datasourceNode = efi.getDatasourceNode(groupName)\n            MNode dbNode = efi.getDatabaseNode(groupName)\n            String ramAttr = datasourceNode?.attribute(\"runtime-add-missing\")\n            runtimeAddMissing = ramAttr ? !\"false\".equals(ramAttr) : !\"false\".equals(dbNode.attribute(\"default-runtime-add-missing\"))\n            runtimeAddMissingMap.put(groupName, runtimeAddMissing)\n        }\n        if (!runtimeAddMissing.booleanValue()) return false\n\n        if (entityInfo.isView) {\n            boolean tableCreated = false\n            for (MNode memberEntityNode in ed.entityNode.children(\"member-entity\")) {\n                EntityDefinition med = efi.getEntityDefinition(memberEntityNode.attribute(\"entity-name\"))\n                if (checkTableRuntime(med)) tableCreated = true\n            }\n            return tableCreated\n        } else {\n            // already looked above to see if this entity has been checked\n            // do the real check, in a synchronized method\n            return internalCheckTable(ed, false)\n        }\n    }\n    boolean checkTableStartup(EntityDefinition ed) {\n        if (ed.isViewEntity) {\n            boolean tableCreated = false\n            for (MNode memberEntityNode in ed.entityNode.children(\"member-entity\")) {\n                EntityDefinition med = efi.getEntityDefinition(memberEntityNode.attribute(\"entity-name\"))\n                if (checkTableStartup(med)) tableCreated = true\n            }\n            return tableCreated\n        } else {\n            return internalCheckTable(ed, true)\n        }\n    }\n\n    int checkAndAddAllTables(String groupName) {\n        int tablesAdded = 0\n\n        MNode datasourceNode = efi.getDatasourceNode(groupName)\n        // MNode databaseNode = efi.getDatabaseNode(groupName)\n        String schemaName = datasourceNode != null ? datasourceNode.attribute(\"schema-name\") : null\n        Set<String> groupEntityNames = efi.getAllEntityNamesInGroup(groupName)\n\n        String[] types = [\"TABLE\", \"VIEW\", \"ALIAS\", \"SYNONYM\", \"PARTITIONED TABLE\"]\n        Set<String> existingTableNames = new HashSet<>()\n\n        boolean beganTx = useTxForMetaData ? efi.ecfi.transactionFacade.begin(300) : false\n        try {\n            Connection con = efi.getConnection(groupName)\n\n            try {\n                DatabaseMetaData dbData = con.getMetaData()\n\n                ResultSet tableSet = null\n                try {\n                    tableSet = dbData.getTables(con.getCatalog(), schemaName, \"%\", types)\n                    while (tableSet.next()) {\n                        String tableName = tableSet.getString('TABLE_NAME')\n                        existingTableNames.add(tableName)\n                    }\n                } catch (Exception e) {\n                    throw new EntityException(\"Exception getting tables in group ${groupName}\", e)\n                } finally {\n                    if (tableSet != null && !tableSet.isClosed()) tableSet.close()\n                }\n\n                Map<String, Set<String>> existingColumnsByTable = new HashMap<>()\n                ResultSet colSet = null\n                try {\n                    colSet = dbData.getColumns(con.getCatalog(), schemaName, \"%\", \"%\")\n                    while (colSet.next()) {\n                        String tableName = colSet.getString(\"TABLE_NAME\")\n                        String colName = colSet.getString(\"COLUMN_NAME\")\n\n                        Set<String> existingColumns = existingColumnsByTable.get(tableName)\n                        if (existingColumns == null) {\n                            existingColumns = new HashSet<>()\n                            existingColumnsByTable.put(tableName, existingColumns)\n                        }\n                        existingColumns.add(colName)\n\n                        // FUTURE: while we're at it also get type info, etc to validate and warn?\n                    }\n                } catch (Exception e) {\n                    throw new EntityException(\"Exception getting columns in group ${groupName}\", e)\n                } finally {\n                    if (colSet != null && !colSet.isClosed()) colSet.close()\n                }\n\n                Set<String> remainingTableNames = new HashSet<>(existingTableNames)\n                for (String entityName in groupEntityNames) {\n                    EntityDefinition ed = efi.getEntityDefinition(entityName)\n                    if (ed.isViewEntity) continue\n\n                    String fullEntityName = ed.getFullEntityName()\n                    String tableName = ed.getTableName()\n                    boolean tableExists = existingTableNames.contains(tableName) || existingTableNames.contains(tableName.toLowerCase())\n                    try {\n                        if (tableExists) {\n                            // table exists, see if it is missing any columns\n                            ArrayList<FieldInfo> fieldInfos = new ArrayList<>(ed.allFieldInfoList)\n                            Set<String> existingColumns = (Set<String>) existingColumnsByTable.get(tableName)\n                            if (existingColumns == null) existingColumns = (Set<String>) existingColumnsByTable.get(tableName.toLowerCase())\n                            if (existingColumns == null || existingColumns.size() == 0) {\n                                logger.warn(\"No existing columns found for table ${tableName} entity ${fullEntityName}, not trying to add columns but this is bad, probably a DB meta data issue so we can't check columns\")\n                            } else {\n                                Set<String> remainingColumns = new HashSet<>(existingColumns)\n                                for (int fii = 0; fii < fieldInfos.size(); fii++) {\n                                    FieldInfo fi = (FieldInfo) fieldInfos.get(fii)\n                                    if (existingColumns.contains(fi.columnName) || existingColumns.contains(fi.columnName.toLowerCase())) {\n                                        remainingColumns.remove(fi.columnName)\n                                        remainingColumns.remove(fi.columnName.toLowerCase())\n                                    } else {\n                                        addColumn(ed, fi, con)\n                                    }\n                                }\n                                if (remainingColumns.size() > 0)\n                                    logger.warn(\"Found unknown columns on table ${tableName} for entity ${fullEntityName}: ${remainingColumns}\")\n                            }\n\n                            // FUTURE: also check all indexes? on large DBs may take a long time... maybe just warn about?\n\n                            // create foreign keys after checking each to see if it already exists\n                            // DON'T DO THIS, will check all later in one pass: createForeignKeys(ed, true)\n\n                            remainingTableNames.remove(tableName)\n                            remainingTableNames.remove(tableName.toLowerCase())\n                        } else {\n                            createTable(ed, con)\n                            existingTableNames.add(tableName)\n                            tablesAdded++\n\n                            // create explicit and foreign key auto indexes\n                            createIndexes(ed, false, con)\n                            // create foreign keys to all other tables that exist\n                            createForeignKeys(ed, false, existingTableNames, con)\n                        }\n                        entityTablesChecked.put(fullEntityName, new Timestamp(System.currentTimeMillis()))\n                        entityTablesExist.put(fullEntityName, true)\n                    } catch (Throwable t) {\n                        logger.error(\"Error ${tableExists ? 'updating' : 'creating'} table for for entity ${entityName}\", t)\n                    }\n                }\n\n                if (remainingTableNames.size() > 0)\n                    logger.warn(\"Found unknown tables in database for group ${groupName}: ${remainingTableNames}\")\n            } finally {\n                if (con != null) con.close()\n            }\n        } finally {\n            if (beganTx) efi.ecfi.transactionFacade.commit()\n        }\n\n        // do second pass to make sure all FKs created\n        if (tablesAdded > 0 && shouldCreateFks(efi.ecfi)) {\n            logger.info(\"Tables were created, checking FKs for all entities in group ${groupName}\")\n\n            beganTx = useTxForMetaData ? efi.ecfi.transactionFacade.begin(300) : false\n            try {\n                Connection con = efi.getConnection(groupName)\n\n                try {\n                    DatabaseMetaData dbData = con.getMetaData()\n\n                    // NOTE: don't need to get fresh results for existing table names as created tables are added to the Set above\n\n                    Map<String, Map<String, Set<String>>> fkInfoByFkTable = new HashMap<>()\n                    ResultSet ikSet = null\n                    try {\n                        // don't rely on constraint name, look at related table name, keys\n                        // get set of fields on main entity to match against (more unique than fields on related entity)\n                        ikSet = dbData.getImportedKeys(null, schemaName, \"%\")\n                        while (ikSet.next()) {\n                            // logger.info(\"Existing FK col: PKTABLE_NAME [${ikSet.getString(\"PKTABLE_NAME\")}] PKCOLUMN_NAME [${ikSet.getString(\"PKCOLUMN_NAME\")}] FKTABLE_NAME [${ikSet.getString(\"FKTABLE_NAME\")}] FKCOLUMN_NAME [${ikSet.getString(\"FKCOLUMN_NAME\")}]\")\n                            String pkTable = ikSet.getString(\"PKTABLE_NAME\")\n                            String fkTable = ikSet.getString(\"FKTABLE_NAME\")\n                            String fkCol = ikSet.getString(\"FKCOLUMN_NAME\")\n\n                            Map<String, Set<String>> fkInfo = (Map<String, Set<String>>) fkInfoByFkTable.get(fkTable)\n                            if (fkInfo == null) { fkInfo = new HashMap(); fkInfoByFkTable.put(fkTable, fkInfo) }\n                            Set<String> fkColsFound = (Set<String>) fkInfo.get(pkTable)\n                            if (fkColsFound == null) { fkColsFound = new HashSet<>(); fkInfo.put(pkTable, fkColsFound) }\n                            fkColsFound.add(fkCol)\n                        }\n                    } catch (Exception e) {\n                        logger.error(\"Error getting all foreign keys for group ${groupName}\", e)\n                    } finally {\n                        if (ikSet != null && !ikSet.isClosed()) ikSet.close()\n                    }\n\n                    if (fkInfoByFkTable.size() == 0) {\n                        logger.warn(\"Bulk find imported keys got no results for group ${groupName}, getting per table (slower)\")\n                        for (String entityName in groupEntityNames) {\n                            EntityDefinition ed = efi.getEntityDefinition(entityName)\n                            if (ed.isViewEntity) continue\n                            String fkTable = ed.getTableName()\n                            boolean gotIkResults = false\n                            try {\n                                ikSet = dbData.getImportedKeys(null, schemaName, fkTable)\n                                while (ikSet.next()) {\n                                    gotIkResults = true\n                                    String pkTable = ikSet.getString(\"PKTABLE_NAME\")\n                                    String fkCol = ikSet.getString(\"FKCOLUMN_NAME\")\n                                    Map<String, Set<String>> fkInfo = (Map<String, Set<String>>) fkInfoByFkTable.get(fkTable)\n                                    if (fkInfo == null) { fkInfo = new HashMap(); fkInfoByFkTable.put(fkTable, fkInfo) }\n                                    Set<String> fkColsFound = (Set<String>) fkInfo.get(pkTable)\n                                    if (fkColsFound == null) { fkColsFound = new HashSet<>(); fkInfo.put(pkTable, fkColsFound) }\n                                    fkColsFound.add(fkCol)\n                                }\n                            } catch (Exception e) {\n                                logger.error(\"Error getting foreign keys for entity ${entityName} group ${groupName}\", e)\n                            } finally {\n                                if (ikSet != null && !ikSet.isClosed()) ikSet.close()\n                            }\n                            if (!gotIkResults) {\n                                // no results found, try lower case table name\n                                try {\n                                    ikSet = dbData.getImportedKeys(null, schemaName, fkTable.toLowerCase())\n                                    while (ikSet.next()) {\n                                        String pkTable = ikSet.getString(\"PKTABLE_NAME\")\n                                        String fkCol = ikSet.getString(\"FKCOLUMN_NAME\")\n                                        Map<String, Set<String>> fkInfo = (Map<String, Set<String>>) fkInfoByFkTable.get(fkTable)\n                                        if (fkInfo == null) { fkInfo = new HashMap(); fkInfoByFkTable.put(fkTable, fkInfo) }\n                                        Set<String> fkColsFound = (Set<String>) fkInfo.get(pkTable)\n                                        if (fkColsFound == null) { fkColsFound = new HashSet<>(); fkInfo.put(pkTable, fkColsFound) }\n                                        fkColsFound.add(fkCol)\n                                    }\n                                } catch (Exception e) {\n                                    logger.error(\"Error getting foreign keys for entity ${entityName} group ${groupName}\", e)\n                                } finally {\n                                    if (ikSet != null && !ikSet.isClosed()) ikSet.close()\n                                }\n                            }\n                        }\n                    }\n\n                    for (String entityName in groupEntityNames) {\n                        EntityDefinition ed = efi.getEntityDefinition(entityName)\n                        if (ed.isViewEntity) continue\n\n                        // use one big query for all FKs instead of per entity/table\n                        // createForeignKeys(ed, true)\n\n                        // fkTable is current entity's table name\n                        String fkTable = ed.getTableName()\n                        Map<String, Set<String>> fkInfo = (Map<String, Set<String>>) fkInfoByFkTable.get(fkTable)\n                        if (fkInfo == null) fkInfo = (Map<String, Set<String>>) fkInfoByFkTable.get(fkTable.toLowerCase())\n                        // if (fkInfo == null) logger.warn(\"No FK info found for table ${fkTable}\")\n\n                        int fksCreated = 0\n                        int relOneCount = 0\n                        int noRelTableCount = 0\n                        for (RelationshipInfo relInfo in ed.getRelationshipsInfo(false)) {\n                            if (relInfo.type != \"one\") continue\n                            relOneCount++\n\n                            EntityDefinition relEd = relInfo.relatedEd\n                            String relTableName = relEd.getTableName()\n                            if (!existingTableNames.contains(relTableName) && !existingTableNames.contains(relTableName.toLowerCase())) {\n                                if (logger.traceEnabled) logger.trace(\"Not creating foreign key from entity ${ed.getFullEntityName()} to related entity ${relEd.getFullEntityName()} because related entity does not yet have a table ${relTableName}\")\n                                noRelTableCount++\n                                continue\n                            }\n\n                            Map keyMap = relInfo.keyMap\n                            ArrayList<String> fieldNames = new ArrayList(keyMap.keySet())\n\n                            if (fkInfo != null) {\n                                // pkTable is related entity's table name\n                                String pkTable = relTableName\n                                Set<String> fkColsFound = (Set<String>) fkInfo.get(pkTable)\n                                if (fkColsFound == null) fkColsFound = (Set<String>) fkInfo.get(pkTable.toLowerCase())\n\n                                if (fkColsFound != null) {\n                                    for (int fni = 0; fni < fieldNames.size(); ) {\n                                        String fieldName = (String) fieldNames.get(fni)\n                                        String colName = ed.getColumnName(fieldName)\n                                        if (fkColsFound.contains(colName) || fkColsFound.contains(colName.toLowerCase())) {\n                                            fieldNames.remove(fni)\n                                        } else {\n                                            fni++\n                                        }\n                                    }\n                                } else {\n                                    // logger.warn(\"No FK info found for FK table ${fkTable} PK table ${pkTable}\")\n                                }\n                                // logger.info(\"Checking FK exists for entity [${ed.getFullEntityName()}] relationship [${relNode.\"@title\"}${relEd.getFullEntityName()}] fields to match are [${keyMap.keySet()}] FK columns found [${fkColsFound}] final fieldNames (empty for match) [${fieldNames}]\")\n                            }\n\n                            // if we found all of the key-map field-names then fieldNames will be empty, and we have a full fk\n                            if (fieldNames.size() > 0) {\n                                try {\n                                    createForeignKey(ed, relInfo, relEd, con)\n                                    fksCreated++\n                                } catch (Throwable t) {\n                                    logger.error(\"Error creating foreign key from entity ${ed.getFullEntityName()} to related entity ${relEd.getFullEntityName()} with title ${relInfo.relNode.attribute(\"title\")}\", t)\n                                }\n                            }\n                        }\n                        if (noRelTableCount > 0) logger.warn(\"In full FK check found ${noRelTableCount} type one relationships where no table exists for related entity\")\n                        if (fksCreated > 0) logger.info(\"Created ${fksCreated} FKs out of ${relOneCount} type one relationships for entity ${entityName}\")\n                    }\n                } finally {\n                    if (con != null) con.close()\n                }\n            } finally {\n                if (beganTx) efi.ecfi.transactionFacade.commit()\n            }\n        }\n\n        return tablesAdded\n    }\n\n    void forceCheckTableRuntime(EntityDefinition ed) {\n        entityTablesExist.remove(ed.getFullEntityName())\n        entityTablesChecked.remove(ed.getFullEntityName())\n        checkTableRuntime(ed)\n    }\n    void forceCheckExistingTables() {\n        entityTablesExist.clear()\n        entityTablesChecked.clear()\n        for (String entityName in efi.getAllEntityNames()) {\n            EntityDefinition ed = efi.getEntityDefinition(entityName)\n            if (ed.isViewEntity) continue\n            if (tableExists(ed)) checkTableRuntime(ed)\n        }\n    }\n\n    synchronized boolean internalCheckTable(EntityDefinition ed, boolean startup) {\n        // if it's in this table we've already checked it\n        if (entityTablesChecked.containsKey(ed.getFullEntityName())) return false\n\n        MNode datasourceNode = efi.getDatasourceNode(ed.getEntityGroupName())\n        // if there is no @database-conf-name skip this, it's probably not a SQL/JDBC datasource\n        if (!datasourceNode.attribute('database-conf-name')) return false\n\n        long startTime = System.currentTimeMillis()\n        boolean doCreate = !tableExists(ed)\n        if (doCreate) {\n            createTable(ed, null)\n            // create explicit and foreign key auto indexes\n            createIndexes(ed, false, null)\n            // create foreign keys to all other tables that exist\n            createForeignKeys(ed, false, null, null)\n        } else {\n            // table exists, see if it is missing any columns\n            ArrayList<FieldInfo> mcs = getMissingColumns(ed)\n            int mcsSize = mcs.size()\n            for (int i = 0; i < mcsSize; i++) addColumn(ed, (FieldInfo) mcs.get(i), null)\n            // create foreign keys after checking each to see if it already exists\n            if (startup) {\n                createForeignKeys(ed, true, null, null)\n            } else {\n                MNode dbNode = efi.getDatabaseNode(ed.getEntityGroupName())\n                String runtimeAddFks = datasourceNode.attribute(\"runtime-add-fks\") ?: \"true\"\n                if ((!runtimeAddFks && \"true\".equals(dbNode.attribute(\"default-runtime-add-fks\"))) || \"true\".equals(runtimeAddFks))\n                    createForeignKeys(ed, true, null, null)\n            }\n        }\n        entityTablesChecked.put(ed.getFullEntityName(), new Timestamp(System.currentTimeMillis()))\n        entityTablesExist.put(ed.getFullEntityName(), true)\n\n        if (logger.isTraceEnabled()) logger.trace(\"Checked table for entity [${ed.getFullEntityName()}] in ${(System.currentTimeMillis()-startTime)/1000} seconds\")\n        return doCreate\n    }\n\n    boolean tableExists(EntityDefinition ed) {\n        Boolean exists = entityTablesExist.get(ed.getFullEntityName())\n        if (exists != null) return exists.booleanValue()\n\n        return tableExistsInternal(ed)\n    }\n    synchronized boolean tableExistsInternal(EntityDefinition ed) {\n        Boolean exists = entityTablesExist.get(ed.getFullEntityName())\n        if (exists != null) return exists.booleanValue()\n\n        Boolean dbResult = null\n        if (ed.isViewEntity) {\n            boolean anyExist = false\n            for (MNode memberEntityNode in ed.entityNode.children(\"member-entity\")) {\n                EntityDefinition med = efi.getEntityDefinition(memberEntityNode.attribute(\"entity-name\"))\n                if (tableExists(med)) { anyExist = true; break }\n            }\n            dbResult = anyExist\n        } else {\n            String groupName = ed.getEntityGroupName()\n            Connection con = null\n            ResultSet tableSet1 = null\n            ResultSet tableSet2 = null\n            boolean beganTx = useTxForMetaData ? efi.ecfi.transactionFacade.begin(5) : false\n            try {\n                try {\n                    con = efi.getConnection(groupName)\n                } catch (EntityException ee) {\n                    logger.warn(\"Could not get connection so treating entity ${ed.fullEntityName} in group ${groupName} as table does not exist: ${ee.toString()}\")\n                    return false\n                }\n                DatabaseMetaData dbData = con.getMetaData()\n\n                String[] types = [\"TABLE\", \"VIEW\", \"ALIAS\", \"SYNONYM\", \"PARTITIONED TABLE\"]\n                tableSet1 = dbData.getTables(con.getCatalog(), ed.getSchemaName(), ed.getTableName(), types)\n                if (tableSet1.next()) {\n                    dbResult = true\n                } else {\n                    // try lower case, just in case DB is case sensitive\n                    tableSet2 = dbData.getTables(con.getCatalog(), ed.getSchemaName(), ed.getTableName().toLowerCase(), types)\n                    if (tableSet2.next()) {\n                        dbResult = true\n                    } else {\n                        if (logger.isTraceEnabled()) logger.trace(\"Table for entity ${ed.getFullEntityName()} does NOT exist\")\n                        dbResult = false\n                    }\n                }\n            } catch (Exception e) {\n                throw new EntityException(\"Exception checking to see if table ${ed.getTableName()} exists\", e)\n            } finally {\n                if (tableSet1 != null && !tableSet1.isClosed()) tableSet1.close()\n                if (tableSet2 != null && !tableSet2.isClosed()) tableSet2.close()\n                if (con != null) con.close()\n                if (beganTx) efi.ecfi.transactionFacade.commit()\n            }\n        }\n\n        if (dbResult == null) throw new EntityException(\"No result checking if entity ${ed.getFullEntityName()} table exists\")\n\n        if (dbResult && !ed.isViewEntity) {\n            // on the first check also make sure all columns/etc exist; we'll do this even on read/exist check otherwise query will blow up when doesn't exist\n            ArrayList<FieldInfo> mcs = getMissingColumns(ed)\n            int mcsSize = mcs.size()\n            for (int i = 0; i < mcsSize; i++) addColumn(ed, (FieldInfo) mcs.get(i), null)\n        }\n        // don't remember the result for view-entities, get if from member-entities... if we remember it we have to set\n        //     it for all view-entities when a member-entity is created\n        if (!ed.isViewEntity) entityTablesExist.put(ed.getFullEntityName(), dbResult)\n        return dbResult\n    }\n\n    void createTable(EntityDefinition ed, Connection sharedCon) {\n        if (ed == null) throw new IllegalArgumentException(\"No EntityDefinition specified, cannot create table\")\n        if (ed.isViewEntity) throw new IllegalArgumentException(\"Cannot create table for a view entity\")\n\n        String groupName = ed.getEntityGroupName()\n        MNode databaseNode = efi.getDatabaseNode(groupName)\n\n        StringBuilder sql = new StringBuilder(\"CREATE TABLE \").append(ed.getFullTableName()).append(\" (\")\n\n        FieldInfo[] allFieldInfoArray = ed.entityInfo.allFieldInfoArray\n        for (int i = 0; i < allFieldInfoArray.length; i++) {\n            FieldInfo fi = (FieldInfo) allFieldInfoArray[i]\n            MNode fieldNode = fi.fieldNode\n            String sqlType = efi.getFieldSqlType(fi.type, ed)\n            String javaType = fi.javaType\n\n            sql.append(fi.columnName).append(\" \").append(sqlType)\n\n            if (\"String\" == javaType || \"java.lang.String\" == javaType) {\n                if (databaseNode.attribute(\"character-set\")) sql.append(\" CHARACTER SET \").append(databaseNode.attribute(\"character-set\"))\n                if (databaseNode.attribute(\"collate\")) sql.append(\" COLLATE \").append(databaseNode.attribute(\"collate\"))\n            }\n\n            if (fi.isPk || fieldNode.attribute(\"not-null\") == \"true\") {\n                if (databaseNode.attribute(\"always-use-constraint-keyword\") == \"true\") sql.append(\" CONSTRAINT\")\n                sql.append(\" NOT NULL\")\n            }\n            sql.append(\", \")\n        }\n\n        if (databaseNode.attribute(\"use-pk-constraint-names\") != \"false\") {\n            String pkName = \"PK_\" + ed.getTableName()\n            int constraintNameClipLength = (databaseNode.attribute(\"constraint-name-clip-length\")?:\"30\") as int\n            if (pkName.length() > constraintNameClipLength) pkName = pkName.substring(0, constraintNameClipLength)\n            sql.append(\"CONSTRAINT \")\n            if (databaseNode.attribute(\"use-schema-for-all\") == \"true\") sql.append(ed.getSchemaName() ? ed.getSchemaName() + \".\" : \"\")\n            sql.append(pkName)\n        }\n        sql.append(\" PRIMARY KEY (\")\n\n        FieldInfo[] pkFieldInfoArray = ed.entityInfo.pkFieldInfoArray\n        for (int i = 0; i < pkFieldInfoArray.length; i++) {\n            FieldInfo fi = (FieldInfo) pkFieldInfoArray[i]\n            if (i > 0) sql.append(\", \")\n            sql.append(fi.getFullColumnName())\n        }\n        sql.append(\"))\")\n\n        // some MySQL-specific inconveniences...\n        if (databaseNode.attribute(\"table-engine\")) sql.append(\" ENGINE \").append(databaseNode.attribute(\"table-engine\"))\n        if (databaseNode.attribute(\"character-set\")) sql.append(\" CHARACTER SET \").append(databaseNode.attribute(\"character-set\"))\n        if (databaseNode.attribute(\"collate\")) sql.append(\" COLLATE \").append(databaseNode.attribute(\"collate\"))\n\n        logger.info(\"Creating table for ${ed.getFullEntityName()} pks: ${ed.getPkFieldNames()}\")\n        if (logger.traceEnabled) logger.trace(\"Create Table with SQL: \" + sql.toString())\n\n        runSqlUpdate(sql, groupName, sharedCon)\n        if (logger.infoEnabled) logger.info(\"Created table ${ed.getFullTableName()} for entity ${ed.getFullEntityName()} in group ${groupName}\")\n    }\n\n    ArrayList<FieldInfo> getMissingColumns(EntityDefinition ed) {\n        if (ed.isViewEntity) return new ArrayList<FieldInfo>()\n\n        String groupName = ed.getEntityGroupName()\n        Connection con = null\n        ResultSet colSet1 = null\n        ResultSet colSet2 = null\n        boolean beganTx = useTxForMetaData ? efi.ecfi.transactionFacade.begin(5) : false\n        try {\n            con = efi.getConnection(groupName)\n            DatabaseMetaData dbData = con.getMetaData()\n            // con.setAutoCommit(false)\n\n            ArrayList<FieldInfo> fieldInfos = new ArrayList<>(ed.allFieldInfoList)\n            int fieldCount = fieldInfos.size()\n            colSet1 = dbData.getColumns(con.getCatalog(), ed.getSchemaName(), ed.getTableName(), \"%\")\n            if (colSet1.isClosed()) {\n                logger.error(\"Tried to get columns for entity ${ed.getFullEntityName()} but ResultSet was closed!\")\n                return new ArrayList<FieldInfo>()\n            }\n            while (colSet1.next()) {\n                String colName = colSet1.getString(\"COLUMN_NAME\")\n                int fieldInfosSize = fieldInfos.size()\n                for (int i = 0; i < fieldInfosSize; i++) {\n                    FieldInfo fi = (FieldInfo) fieldInfos.get(i)\n                    if (fi.columnName == colName || fi.columnName.toLowerCase() == colName) {\n                        fieldInfos.remove(i)\n                        break\n                    }\n                }\n            }\n\n            if (fieldInfos.size() == fieldCount) {\n                // try lower case table name\n                colSet2 = dbData.getColumns(con.getCatalog(), ed.getSchemaName(), ed.getTableName().toLowerCase(), \"%\")\n                if (colSet2.isClosed()) {\n                    logger.error(\"Tried to get columns for entity ${ed.getFullEntityName()} but ResultSet was closed!\")\n                    return new ArrayList<FieldInfo>()\n                }\n                while (colSet2.next()) {\n                    String colName = colSet2.getString(\"COLUMN_NAME\")\n                    int fieldInfosSize = fieldInfos.size()\n                    for (int i = 0; i < fieldInfosSize; i++) {\n                        FieldInfo fi = (FieldInfo) fieldInfos.get(i)\n                        if (fi.columnName == colName || fi.columnName.toLowerCase() == colName) {\n                            fieldInfos.remove(i)\n                            break\n                        }\n                    }\n                }\n\n                if (fieldInfos.size() == fieldCount) {\n                    logger.warn(\"Could not find any columns to match fields for entity ${ed.getFullEntityName()}\")\n                    return new ArrayList<FieldInfo>()\n                }\n            }\n            return fieldInfos\n        } catch (Exception e) {\n            logger.error(\"Exception checking for missing columns in table ${ed.getTableName()}\", e)\n            return new ArrayList<FieldInfo>()\n        } finally {\n            if (colSet1 != null && !colSet1.isClosed()) colSet1.close()\n            if (colSet2 != null && !colSet2.isClosed()) colSet2.close()\n            if (con != null && !con.isClosed()) con.close()\n            if (beganTx) efi.ecfi.transactionFacade.commit()\n        }\n    }\n\n    void addColumn(EntityDefinition ed, FieldInfo fi, Connection sharedCon) {\n        if (ed == null) throw new IllegalArgumentException(\"No EntityDefinition specified, cannot add column\")\n        if (ed.isViewEntity) throw new IllegalArgumentException(\"Cannot add column for a view entity\")\n\n        String groupName = ed.getEntityGroupName()\n        MNode databaseNode = efi.getDatabaseNode(groupName)\n\n        MNode fieldNode = fi.fieldNode\n\n        String sqlType = efi.getFieldSqlType(fieldNode.attribute(\"type\"), ed)\n        String javaType = efi.getFieldJavaType(fieldNode.attribute(\"type\"), ed)\n\n        StringBuilder sql = new StringBuilder(\"ALTER TABLE \").append(ed.getFullTableName())\n        String colName = fi.columnName\n        // NOTE: if any databases need \"ADD COLUMN\" instead of just \"ADD\", change this to try both or based on config\n        sql.append(\" ADD \").append(colName).append(\" \").append(sqlType)\n\n        if (\"String\" == javaType || \"java.lang.String\" == javaType) {\n            if (databaseNode.attribute(\"character-set\")) sql.append(\" CHARACTER SET \").append(databaseNode.attribute(\"character-set\"))\n            if (databaseNode.attribute(\"collate\")) sql.append(\" COLLATE \").append(databaseNode.attribute(\"collate\"))\n        }\n\n        runSqlUpdate(sql, groupName, sharedCon)\n        if (logger.infoEnabled) logger.info(\"Added column ${colName} to table ${ed.tableName} for field ${fi.name} of entity ${ed.getFullEntityName()} in group ${groupName}\")\n    }\n\n    int createIndexes(EntityDefinition ed, boolean checkIdxExists, Connection sharedCon) {\n        if (ed == null) throw new IllegalArgumentException(\"No EntityDefinition specified, cannot create indexes\")\n        if (ed.isViewEntity) throw new IllegalArgumentException(\"Cannot create indexes for a view entity\")\n\n        String groupName = ed.getEntityGroupName()\n        MNode databaseNode = efi.getDatabaseNode(groupName)\n\n        if (databaseNode.attribute(\"use-indexes\") == \"false\") return 0\n\n        int constraintNameClipLength = (databaseNode.attribute(\"constraint-name-clip-length\")?:\"30\") as int\n\n        // first do index elements\n        int created = 0\n        for (MNode indexNode in ed.entityNode.children(\"index\")) {\n            String indexName = indexNode.attribute('name')\n            if (checkIdxExists) {\n                Boolean idxExists = indexExists(ed, indexName, indexNode.children(\"index-field\").collect {it.attribute('name')})\n                if (idxExists != null && idxExists) {\n                    if (logger.infoEnabled) logger.info(\"Not creating index ${indexName} for entity ${ed.getFullEntityName()} because it already exists.\")\n                    continue\n                }\n            }\n            StringBuilder sql = new StringBuilder(\"CREATE \")\n            if (databaseNode.attribute(\"use-indexes-unique\") != \"false\" && indexNode.attribute(\"unique\") == \"true\") {\n                sql.append(\"UNIQUE \")\n                if (databaseNode.attribute(\"use-indexes-unique-where-not-null\") == \"true\") sql.append(\"WHERE NOT NULL \")\n            }\n            sql.append(\"INDEX \")\n            if (databaseNode.attribute(\"use-schema-for-all\") == \"true\") sql.append(ed.getSchemaName() ? ed.getSchemaName() + \".\" : \"\")\n            sql.append(indexNode.attribute(\"name\")).append(\" ON \").append(ed.getFullTableName())\n\n            sql.append(\" (\")\n            boolean isFirst = true\n            for (MNode indexFieldNode in indexNode.children(\"index-field\")) {\n                if (isFirst) isFirst = false else sql.append(\", \")\n                sql.append(ed.getColumnName(indexFieldNode.attribute(\"name\")))\n            }\n            sql.append(\")\")\n\n            Integer curCreated = runSqlUpdate(sql, groupName, sharedCon)\n            if (curCreated != null) {\n                if (logger.infoEnabled) logger.info(\"Created index ${indexName} for entity ${ed.getFullEntityName()}\")\n                created ++\n            }\n        }\n\n        // do fk auto indexes\n        // nothing after fk indexes to return now if disabled\n        if (databaseNode.attribute(\"use-foreign-key-indexes\") == \"false\") return created\n\n        for (RelationshipInfo relInfo in ed.getRelationshipsInfo(false)) {\n            if (relInfo.type != \"one\") continue\n\n            String indexName = makeFkIndexName(ed, relInfo, constraintNameClipLength)\n            if (checkIdxExists) {\n                Boolean idxExists = indexExists(ed, indexName, relInfo.keyMap.keySet())\n                if (idxExists != null && idxExists) {\n                    if (logger.infoEnabled) logger.info(\"Not creating index ${indexName} for entity ${ed.getFullEntityName()} because it already exists.\")\n                    continue\n                }\n            }\n            StringBuilder sql = new StringBuilder(\"CREATE INDEX \")\n            if (databaseNode.attribute(\"use-schema-for-all\") == \"true\") sql.append(ed.getSchemaName() ? ed.getSchemaName() + \".\" : \"\")\n            sql.append(indexName).append(\" ON \").append(ed.getFullTableName())\n\n            sql.append(\" (\")\n            Map keyMap = relInfo.keyMap\n            boolean isFirst = true\n            for (String fieldName in keyMap.keySet()) {\n                if (isFirst) isFirst = false else sql.append(\", \")\n                sql.append(ed.getColumnName(fieldName))\n            }\n            sql.append(\")\")\n\n            // logger.warn(\"====== create relationship index [${indexName}] for entity [${ed.getFullEntityName()}]\")\n            Integer curCreated = runSqlUpdate(sql, groupName, sharedCon)\n            if (curCreated != null) {\n                if (logger.infoEnabled) logger.info(\"Created index ${indexName} for entity ${ed.getFullEntityName()}\")\n                created ++\n            }\n        }\n        return created\n    }\n\n    static String makeFkIndexName(EntityDefinition ed, RelationshipInfo relInfo, int constraintNameClipLength) {\n        String relatedEntityName = relInfo.relatedEd.entityInfo.internalEntityName\n        StringBuilder indexName = new StringBuilder()\n        if (relInfo.relNode.attribute(\"fk-name\")) indexName.append(relInfo.relNode.attribute(\"fk-name\"))\n        if (!indexName) {\n            String title = relInfo.title ?: \"\"\n            String edEntityName = ed.entityInfo.internalEntityName\n            int edEntityNameLength = edEntityName.length()\n\n            int commonChars = 0\n            while (title.length() > commonChars && edEntityNameLength > commonChars &&\n                    title.charAt(commonChars) == edEntityName.charAt(commonChars)) commonChars++\n\n            int relLength = relatedEntityName.length()\n            int relEndCommonChars = relatedEntityName.length() - 1\n            while (relEndCommonChars > 0 && edEntityNameLength > relEndCommonChars &&\n                    relatedEntityName.charAt(relEndCommonChars) == edEntityName.charAt(edEntityNameLength - (relLength - relEndCommonChars)))\n                relEndCommonChars--\n\n            if (commonChars > 0) {\n                indexName.append(edEntityName)\n                for (char cc in title.substring(0, commonChars).chars) if (Character.isUpperCase(cc)) indexName.append(cc)\n                indexName.append(title.substring(commonChars))\n                indexName.append(relatedEntityName.substring(0, relEndCommonChars + 1))\n                if (relEndCommonChars < (relLength - 1)) for (char cc in relatedEntityName.substring(relEndCommonChars + 1).chars)\n                    if (Character.isUpperCase(cc)) indexName.append(cc)\n            } else {\n                indexName.append(edEntityName).append(title)\n                indexName.append(relatedEntityName.substring(0, relEndCommonChars + 1))\n                if (relEndCommonChars < (relLength - 1)) for (char cc in relatedEntityName.substring(relEndCommonChars + 1).chars)\n                    if (Character.isUpperCase(cc)) indexName.append(cc)\n            }\n\n            // logger.warn(\"Index for entity [${ed.getFullEntityName()}], title=${title}, commonChars=${commonChars}, indexName=${indexName}\")\n            // logger.warn(\"Index for entity [${ed.getFullEntityName()}], relatedEntityName=${relatedEntityName}, relEndCommonChars=${relEndCommonChars}, indexName=${indexName}\")\n        }\n        shrinkName(indexName, constraintNameClipLength - 3)\n        indexName.insert(0, \"IDX\")\n        return indexName.toString()\n    }\n\n    int createIndexesForExistingTables() {\n        int created = 0\n        for (String en in efi.getAllEntityNames()) {\n            EntityDefinition ed = efi.getEntityDefinition(en)\n            if (ed.isViewEntity) continue\n            if (tableExists(ed)) {\n                int result = createIndexes(ed, true, null)\n                created += result\n            }\n        }\n        return created\n    }\n    /** Loop through all known entities and for each that has an existing table check each foreign key to see if it\n     * exists in the database, and if it doesn't but the related table does exist then add the foreign key. */\n    int createForeignKeysForExistingTables() {\n        int created = 0\n        for (String en in efi.getAllEntityNames()) {\n            EntityDefinition ed = efi.getEntityDefinition(en)\n            if (ed.isViewEntity) continue\n            if (tableExists(ed)) {\n                int result = createForeignKeys(ed, true, null, null)\n                created += result\n            }\n        }\n        return created\n    }\n    int dropAllForeignKeys() {\n        int dropped = 0\n        for (String en in efi.getAllEntityNames()) {\n            EntityDefinition ed = efi.getEntityDefinition(en)\n            if (ed.isViewEntity) continue\n            if (tableExists(ed)) {\n                int result = dropForeignKeys(ed)\n                logger.info(\"Dropped ${result} FKs for entity ${ed.fullEntityName}\")\n                dropped += result\n            }\n        }\n        return dropped\n    }\n    Boolean indexExists(EntityDefinition ed, String indexName, Collection<String> indexFields) {\n        String groupName = ed.getEntityGroupName()\n        Connection con = null\n        ResultSet ikSet1 = null\n        ResultSet ikSet2 = null\n        try {\n            con = efi.getConnection(groupName)\n            DatabaseMetaData dbData = con.getMetaData()\n            Set<String> fieldNames = new HashSet(indexFields)\n\n            ikSet1 = dbData.getIndexInfo(null, ed.getSchemaName(), ed.getTableName(), false, true)\n            while (ikSet1.next()) {\n                String dbIdxName = ikSet1.getString(\"INDEX_NAME\")\n                if (dbIdxName == null || dbIdxName.toLowerCase() != indexName.toLowerCase()) continue\n                String idxCol = ikSet1.getString(\"COLUMN_NAME\")\n                for (String fn in fieldNames) {\n                    String fnColName = ed.getColumnName(fn)\n                    if (fnColName.toLowerCase() == idxCol.toLowerCase()) {\n                        fieldNames.remove(fn)\n                        break\n                    }\n                }\n            }\n            if (fieldNames.size() > 0) {\n                // try with lower case table name\n                ikSet2 = dbData.getIndexInfo(null, ed.getSchemaName(), ed.getTableName().toLowerCase(), false, true)\n                while (ikSet2.next()) {\n                    String dbIdxName = ikSet2.getString(\"INDEX_NAME\")\n                    if (dbIdxName == null || dbIdxName.toLowerCase() != indexName.toLowerCase()) continue\n                    String idxCol = ikSet2.getString(\"COLUMN_NAME\")\n                    for (String fn in fieldNames) {\n                        String fnColName = ed.getColumnName(fn)\n                        if (fnColName.toLowerCase() == idxCol.toLowerCase()) {\n                            fieldNames.remove(fn)\n                            break\n                        }\n                    }\n                }\n            }\n\n            // if we found all of the index-field field-names then fieldNames will be empty, and we have a full index\n            return (fieldNames.size() == 0)\n        } catch (Exception e) {\n            logger.error(\"Exception checking to see if index exists for table ${ed.getTableName()}\", e)\n            return null\n        } finally {\n            if (ikSet1 != null && !ikSet1.isClosed()) ikSet1.close()\n            if (ikSet2 != null && !ikSet2.isClosed()) ikSet2.close()\n            if (con != null) con.close()\n        }\n    }\n\n    Boolean foreignKeyExists(EntityDefinition ed, RelationshipInfo relInfo) {\n        String groupName = ed.getEntityGroupName()\n        EntityDefinition relEd = relInfo.relatedEd\n        Connection con = null\n        ResultSet ikSet1 = null\n        ResultSet ikSet2 = null\n        try {\n            con = efi.getConnection(groupName)\n            DatabaseMetaData dbData = con.getMetaData()\n\n            // don't rely on constraint name, look at related table name, keys\n            // get set of fields on main entity to match against (more unique than fields on related entity)\n            Map keyMap = relInfo.keyMap\n            Set<String> fieldNames = new HashSet(keyMap.keySet())\n            Set<String> fkColsFound = new HashSet()\n\n            ikSet1 = dbData.getImportedKeys(null, ed.getSchemaName(), ed.getTableName())\n            while (ikSet1.next()) {\n                String pkTable = ikSet1.getString(\"PKTABLE_NAME\")\n                // logger.info(\"FK exists [${ed.getFullEntityName()}] - [${relNode.\"@title\"}${relEd.getFullEntityName()}] PKTABLE_NAME [${ikSet.getString(\"PKTABLE_NAME\")}] PKCOLUMN_NAME [${ikSet.getString(\"PKCOLUMN_NAME\")}] FKCOLUMN_NAME [${ikSet.getString(\"FKCOLUMN_NAME\")}]\")\n                if (pkTable != relEd.getTableName() && pkTable != relEd.getTableName().toLowerCase()) continue\n                String fkCol = ikSet1.getString(\"FKCOLUMN_NAME\")\n                fkColsFound.add(fkCol)\n                for (String fn in fieldNames) {\n                    String fnColName = ed.getColumnName(fn)\n                    if (fnColName == fkCol || fnColName.toLowerCase() == fkCol) {\n                        fieldNames.remove(fn)\n                        break\n                    }\n                }\n            }\n            if (fieldNames.size() > 0) {\n                // try with lower case table name\n                ikSet2 = dbData.getImportedKeys(null, ed.getSchemaName(), ed.getTableName().toLowerCase())\n                while (ikSet2.next()) {\n                    String pkTable = ikSet2.getString(\"PKTABLE_NAME\")\n                    // logger.info(\"FK exists [${ed.getFullEntityName()}] - [${relNode.\"@title\"}${relEd.getFullEntityName()}] PKTABLE_NAME [${ikSet.getString(\"PKTABLE_NAME\")}] PKCOLUMN_NAME [${ikSet.getString(\"PKCOLUMN_NAME\")}] FKCOLUMN_NAME [${ikSet.getString(\"FKCOLUMN_NAME\")}]\")\n                    if (pkTable != relEd.getTableName() && pkTable != relEd.getTableName().toLowerCase()) continue\n                    String fkCol = ikSet2.getString(\"FKCOLUMN_NAME\")\n                    fkColsFound.add(fkCol)\n                    for (String fn in fieldNames) {\n                        String fnColName = ed.getColumnName(fn)\n                        if (fnColName == fkCol || fnColName.toLowerCase() == fkCol) {\n                            fieldNames.remove(fn)\n                            break\n                        }\n                    }\n                }\n            }\n\n            // logger.info(\"Checking FK exists for entity [${ed.getFullEntityName()}] relationship [${relNode.\"@title\"}${relEd.getFullEntityName()}] fields to match are [${keyMap.keySet()}] FK columns found [${fkColsFound}] final fieldNames (empty for match) [${fieldNames}]\")\n\n            // if we found all of the key-map field-names then fieldNames will be empty, and we have a full fk\n            return (fieldNames.size() == 0)\n        } catch (Exception e) {\n            logger.error(\"Exception checking to see if foreign key exists for table ${ed.getTableName()}\", e)\n            return null\n        } finally {\n            if (ikSet1 != null && !ikSet1.isClosed()) ikSet1.close()\n            if (ikSet2 != null && !ikSet2.isClosed()) ikSet2.close()\n            if (con != null) con.close()\n        }\n    }\n    String getForeignKeyName(EntityDefinition ed, RelationshipInfo relInfo) {\n        String groupName = ed.getEntityGroupName()\n        EntityDefinition relEd = relInfo.relatedEd\n        Connection con = null\n        ResultSet ikSet1 = null\n        ResultSet ikSet2 = null\n        try {\n            con = efi.getConnection(groupName)\n            DatabaseMetaData dbData = con.getMetaData()\n\n            // don't rely on constraint name, look at related table name, keys\n\n            // get set of fields on main entity to match against (more unique than fields on related entity)\n            Map keyMap = relInfo.keyMap\n            List<String> fieldNames = new ArrayList(keyMap.keySet())\n            Map<String, Set<String>> fieldsByFkName = new HashMap<>()\n\n            ikSet1 = dbData.getImportedKeys(null, ed.getSchemaName(), ed.getTableName())\n            while (ikSet1.next()) {\n                String pkTable = ikSet1.getString(\"PKTABLE_NAME\")\n                // logger.info(\"FK exists [${ed.getFullEntityName()}] - [${relNode.\"@title\"}${relEd.getFullEntityName()}] PKTABLE_NAME [${ikSet.getString(\"PKTABLE_NAME\")}] PKCOLUMN_NAME [${ikSet.getString(\"PKCOLUMN_NAME\")}] FKCOLUMN_NAME [${ikSet.getString(\"FKCOLUMN_NAME\")}]\")\n                if (pkTable != relEd.getTableName() && pkTable != relEd.getTableName().toLowerCase()) continue\n                String fkCol = ikSet1.getString(\"FKCOLUMN_NAME\")\n                String fkName = ikSet1.getString(\"FK_NAME\")\n                // logger.warn(\"FK pktable ${pkTable} fkcol ${fkCol} fkName ${fkName}\")\n                if (!fkName) continue\n                for (String fn in fieldNames) {\n                    String fnColName = ed.getColumnName(fn)\n                    if (fnColName == fkCol || fnColName.toLowerCase() == fkCol) {\n                        CollectionUtilities.addToSetInMap(fkName, fn, fieldsByFkName)\n                        break\n                    }\n                }\n            }\n            if (fieldNames.size() > 0) {\n                // try with lower case table name\n                ikSet2 = dbData.getImportedKeys(null, ed.getSchemaName(), ed.getTableName().toLowerCase())\n                while (ikSet2.next()) {\n                    String pkTable = ikSet2.getString(\"PKTABLE_NAME\")\n                    // logger.info(\"FK exists [${ed.getFullEntityName()}] - [${relNode.\"@title\"}${relEd.getFullEntityName()}] PKTABLE_NAME [${ikSet.getString(\"PKTABLE_NAME\")}] PKCOLUMN_NAME [${ikSet.getString(\"PKCOLUMN_NAME\")}] FKCOLUMN_NAME [${ikSet.getString(\"FKCOLUMN_NAME\")}]\")\n                    if (pkTable != relEd.getTableName() && pkTable != relEd.getTableName().toLowerCase()) continue\n                    String fkCol = ikSet2.getString(\"FKCOLUMN_NAME\")\n                    String fkName = ikSet2.getString(\"FK_NAME\")\n                    // logger.warn(\"FK pktable ${pkTable} fkcol ${fkCol} fkName ${fkName}\")\n                    if (!fkName) continue\n                    for (String fn in fieldNames) {\n                        String fnColName = ed.getColumnName(fn)\n                        if (fnColName == fkCol || fnColName.toLowerCase() == fkCol) {\n                            CollectionUtilities.addToSetInMap(fkName, fn, fieldsByFkName)\n                            break\n                        }\n                    }\n                }\n            }\n\n            // logger.warn(\"fieldNames: ${fieldNames}\"); logger.warn(\"fieldsByFkName: ${fieldsByFkName}\")\n            for (Map.Entry<String, Set<String>> entry in fieldsByFkName.entrySet()) {\n                if (entry.value.containsAll(fieldNames)) return entry.key\n            }\n            return null\n        } catch (Exception e) {\n            logger.error(\"Exception getting foreign key name for table ${ed.getTableName()}\", e)\n            return null\n        } finally {\n            if (ikSet1 != null && !ikSet1.isClosed()) ikSet1.close()\n            if (ikSet2 != null && !ikSet2.isClosed()) ikSet2.close()\n            if (con != null) con.close()\n        }\n    }\n\n    int createForeignKeys(EntityDefinition ed, boolean checkFkExists, Set<String> existingTableNames, Connection sharedCon) {\n        if (ed == null) throw new IllegalArgumentException(\"No EntityDefinition specified, cannot create foreign keys\")\n        if (ed.isViewEntity) throw new IllegalArgumentException(\"Cannot create foreign keys for a view entity\")\n\n        if (!shouldCreateFks(ed.getEfi().ecfi)) return 0\n\n        // NOTE: in order to get all FKs in place by the time they are used we will probably need to check all incoming\n        //     FKs as well as outgoing because of entity use order, tables not rechecked after first hit, etc\n        // NOTE2: with the createForeignKeysForExistingTables() method this isn't strictly necessary, that can be run\n        //     after the system is run for a bit and/or all tables desired have been created and it will take care of it\n\n        String groupName = ed.getEntityGroupName()\n        MNode databaseNode = efi.getDatabaseNode(groupName)\n\n        if (databaseNode.attribute(\"use-foreign-keys\") == \"false\") return 0\n\n        int created = 0\n        for (RelationshipInfo relInfo in ed.getRelationshipsInfo(false)) {\n            if (relInfo.type != \"one\") continue\n\n            EntityDefinition relEd = relInfo.relatedEd\n            String relTableName = relEd.getTableName()\n            boolean relTableExists\n            if (existingTableNames != null) {\n                relTableExists = existingTableNames.contains(relTableName) || existingTableNames.contains(relTableName.toLowerCase())\n            } else {\n                relTableExists = tableExists(relEd)\n            }\n            if (!relTableExists) {\n                if (logger.traceEnabled) logger.trace(\"Not creating foreign key from entity ${ed.getFullEntityName()} to related entity ${relEd.getFullEntityName()} because related entity does not yet have a table\")\n                continue\n            }\n            if (checkFkExists) {\n                Boolean fkExists = foreignKeyExists(ed, relInfo)\n                if (fkExists != null && fkExists) {\n                    if (logger.traceEnabled) logger.trace(\"Not creating foreign key from entity ${ed.getFullEntityName()} to related entity ${relEd.getFullEntityName()} with title ${relInfo.relNode.attribute(\"title\")} because it already exists (matched by key mappings)\")\n                    continue\n                }\n                // if we get a null back there was an error, and we'll try to create the FK, which may result in another error\n            }\n\n            try {\n                createForeignKey(ed, relInfo, relEd, sharedCon)\n                created++\n            } catch (Throwable t) {\n                logger.error(\"Error creating foreign key from entity ${ed.getFullEntityName()} to related entity ${relEd.getFullEntityName()} with title ${relInfo.relNode.attribute(\"title\")}\", t)\n            }\n        }\n        if (created > 0 && checkFkExists) logger.info(\"Created ${created} FKs for entity ${ed.fullEntityName}\")\n        return created\n    }\n    void createForeignKey(EntityDefinition ed, RelationshipInfo relInfo, EntityDefinition relEd, Connection sharedCon) {\n        String groupName = ed.getEntityGroupName()\n        MNode databaseNode = efi.getDatabaseNode(groupName)\n\n        int constraintNameClipLength = (databaseNode.attribute(\"constraint-name-clip-length\")?:\"30\") as int\n        String constraintName = makeFkConstraintName(ed, relInfo, constraintNameClipLength)\n\n        Map keyMap = relInfo.keyMap\n        List<String> keyMapKeys = new ArrayList(keyMap.keySet())\n        StringBuilder sql = new StringBuilder(\"ALTER TABLE \").append(ed.getFullTableName()).append(\" ADD \")\n        if (databaseNode.attribute(\"fk-style\") == \"name_fk\") {\n            sql.append(\"FOREIGN KEY \").append(constraintName).append(\" (\")\n            boolean isFirst = true\n            for (String fieldName in keyMapKeys) {\n                if (isFirst) isFirst = false else sql.append(\", \")\n                sql.append(ed.getColumnName(fieldName))\n            }\n            sql.append(\")\")\n        } else {\n            sql.append(\"CONSTRAINT \")\n            if (databaseNode.attribute(\"use-schema-for-all\") == \"true\") sql.append(ed.getSchemaName() ? ed.getSchemaName() + \".\" : \"\")\n            sql.append(constraintName).append(\" FOREIGN KEY (\")\n            boolean isFirst = true\n            for (String fieldName in keyMapKeys) {\n                if (isFirst) isFirst = false else sql.append(\", \")\n                sql.append(ed.getColumnName(fieldName))\n            }\n            sql.append(\")\")\n        }\n        sql.append(\" REFERENCES \").append(relEd.getFullTableName()).append(\" (\")\n        boolean isFirst = true\n        for (String keyName in keyMapKeys) {\n            if (isFirst) isFirst = false else sql.append(\", \")\n            sql.append(relEd.getColumnName((String) keyMap.get(keyName)))\n        }\n        sql.append(\")\")\n        if (databaseNode.attribute(\"use-fk-initially-deferred\") == \"true\") {\n            sql.append(\" INITIALLY DEFERRED\")\n        }\n\n        runSqlUpdate(sql, groupName, sharedCon)\n    }\n\n    int dropForeignKeys(EntityDefinition ed) {\n        if (ed == null) throw new IllegalArgumentException(\"No EntityDefinition specified, cannot drop foreign keys\")\n        if (ed.isViewEntity) throw new IllegalArgumentException(\"Cannot drop foreign keys for a view entity\")\n\n        // NOTE: in order to get all FKs in place by the time they are used we will probably need to check all incoming\n        //     FKs as well as outgoing because of entity use order, tables not rechecked after first hit, etc\n        // NOTE2: with the createForeignKeysForExistingTables() method this isn't strictly necessary, that can be run\n        //     after the system is run for a bit and/or all tables desired have been created and it will take care of it\n\n        String groupName = ed.getEntityGroupName()\n        MNode databaseNode = efi.getDatabaseNode(groupName)\n\n        if (databaseNode.attribute(\"use-foreign-keys\") == \"false\") return 0\n        int constraintNameClipLength = (databaseNode.attribute(\"constraint-name-clip-length\")?:\"30\") as int\n\n        int dropped = 0\n        for (RelationshipInfo relInfo in ed.getRelationshipsInfo(false)) {\n            if (relInfo.type != \"one\") continue\n\n            EntityDefinition relEd = relInfo.relatedEd\n            if (!tableExists(relEd)) {\n                if (logger.traceEnabled) logger.trace(\"Not dropping foreign key from entity ${ed.getFullEntityName()} to related entity ${relEd.getFullEntityName()} because related entity does not yet have a table\")\n                continue\n            }\n            Boolean fkExists = foreignKeyExists(ed, relInfo)\n            if (fkExists != null && !fkExists) {\n                if (logger.traceEnabled) logger.trace(\"Not dropping foreign key from entity ${ed.getFullEntityName()} to related entity ${relEd.getFullEntityName()} with title ${relInfo.relNode.attribute(\"title\")} because it does not exist (matched by key mappings)\")\n                continue\n            }\n\n            String fkName = getForeignKeyName(ed, relInfo)\n            String constraintName = fkName ?: makeFkConstraintName(ed, relInfo, constraintNameClipLength)\n\n            StringBuilder sql = new StringBuilder(\"ALTER TABLE \").append(ed.getFullTableName()).append(\" DROP \")\n            if (databaseNode.attribute(\"fk-style\") == \"name_fk\") {\n                sql.append(\"FOREIGN KEY \").append(constraintName.toString())\n            } else {\n                sql.append(\"CONSTRAINT \")\n                if (databaseNode.attribute(\"use-schema-for-all\") == \"true\") sql.append(ed.getSchemaName() ? ed.getSchemaName() + \".\" : \"\")\n                sql.append(constraintName.toString())\n            }\n\n            Integer records = runSqlUpdate(sql, groupName, null)\n            if (records != null) dropped++\n        }\n        return dropped\n    }\n\n    static String makeFkConstraintName(EntityDefinition ed, RelationshipInfo relInfo, int constraintNameClipLength) {\n        StringBuilder constraintName = new StringBuilder()\n        if (relInfo.relNode.attribute(\"fk-name\")) constraintName.append(relInfo.relNode.attribute(\"fk-name\"))\n        if (!constraintName) {\n            EntityDefinition relEd = relInfo.relatedEd\n            String title = relInfo.title ?: \"\"\n            String edEntityName = ed.entityInfo.internalEntityName\n            int commonChars = 0\n            while (title.length() > commonChars && edEntityName.length() > commonChars &&\n                    title.charAt(commonChars) == edEntityName.charAt(commonChars)) commonChars++\n            String relatedEntityName = relEd.entityInfo.internalEntityName\n            if (commonChars > 0) {\n                constraintName.append(ed.entityInfo.internalEntityName)\n                for (char cc in title.substring(0, commonChars).chars) if (Character.isUpperCase(cc)) constraintName.append(cc)\n                constraintName.append(title.substring(commonChars)).append(relatedEntityName)\n            } else {\n                constraintName.append(ed.entityInfo.internalEntityName).append(title).append(relatedEntityName)\n            }\n            // logger.warn(\"ed.getFullEntityName()=${ed.entityName}, title=${title}, commonChars=${commonChars}, constraintName=${constraintName}\")\n        }\n        shrinkName(constraintName, constraintNameClipLength)\n        return constraintName.toString()\n    }\n\n    static void shrinkName(StringBuilder name, int maxLength) {\n        if (name.length() > maxLength) {\n            // remove vowels from end toward beginning\n            for (int i = name.length()-1; i >= 0 && name.length() > maxLength; i--) {\n                if (\"AEIOUaeiou\".contains(name.charAt(i) as String)) name.deleteCharAt(i)\n            }\n            // clip\n            if (name.length() > maxLength) {\n                name.delete(maxLength-1, name.length())\n            }\n        }\n    }\n\n    final ReentrantLock sqlLock = new ReentrantLock()\n    Integer runSqlUpdate(CharSequence sql, String groupName, Connection sharedCon) {\n        // only do one DB meta data operation at a time; may lock above before checking for existence of something to make sure it doesn't get created twice\n        sqlLock.lock()\n        Integer records = null\n        try {\n            // use a short timeout here just in case this is in the middle of stuff going on with tables locked, may happen a lot for FK ops\n            efi.ecfi.transactionFacade.runRequireNew(10, \"Error in DB meta data change\", useTxForMetaData, true, {\n                Connection con = null\n                Statement stmt = null\n\n                try {\n                    con = sharedCon != null ? sharedCon : efi.getConnection(groupName)\n                    stmt = con.createStatement()\n                    records = stmt.executeUpdate(sql.toString())\n                } finally {\n                    if (stmt != null) stmt.close()\n                    if (con != null && sharedCon == null) con.close()\n                }\n            })\n        } catch (Throwable t) {\n            logger.error(\"SQL Exception while executing the following SQL [${sql.toString()}]: ${t.toString()}\")\n        } finally {\n            sqlLock.unlock()\n        }\n        return records\n    }\n\n    /* ================= */\n    /* Liquibase Methods */\n    /* ================= */\n\n    /** Generate a Liquibase Changelog for a set of entity definitions */\n    MNode liquibaseInitChangelog(String filterRegexp) {\n        MNode rootNode = new MNode(\"databaseChangeLog\", [xmlns:\"http://www.liquibase.org/xml/ns/dbchangelog\",\n                \"xmlns:xsi\":\"http://www.w3.org/2001/XMLSchema-instance\", \"xmlns:ext\":\"http://www.liquibase.org/xml/ns/dbchangelog-ext\",\n                \"xsi:schemaLocation\":\"http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-2.0.xsd http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd\"])\n\n        // add property elements for data type dictionary entry for each database\n        // see http://www.liquibase.org/documentation/changelog_parameters.html\n        // see http://www.liquibase.org/databases.html\n        // <property name=\"clob.type\" value=\"clob\" dbms=\"oracle\"/>\n        MNode databaseListNode = efi.ecfi.confXmlRoot.first(\"database-list\")\n        ArrayList<MNode> dictTypeList = databaseListNode.children(\"dictionary-type\")\n        ArrayList<MNode> databaseList = databaseListNode.children(\"database\")\n        for (MNode dictType in dictTypeList) {\n            String type = dictType.attribute(\"type\")\n            String propName = \"type.\" + type.replaceAll(\"-\", \"_\")\n            Set<String> dbmsDefault = new TreeSet<>()\n            for (MNode database in databaseList) {\n                String lbName = database.attribute(\"lb-name\") ?: database.attribute(\"name\")\n                MNode dbTypeNode = database.first({ MNode it -> it.name == 'database-type' && it.attribute(\"type\") == type })\n                if (dbTypeNode != null) {\n                    rootNode.append(\"property\", [name:propName, value:dbTypeNode.attribute(\"sql-type\"), dbms:lbName])\n                } else {\n                    dbmsDefault.add(lbName)\n                }\n            }\n            if (dbmsDefault.size() > 0)\n                rootNode.append(\"property\", [name:propName, value:dictType.attribute(\"default-sql-type\"),\n                        dbms:dbmsDefault.join(\",\")])\n        }\n\n        String dateStr = efi.ecfi.l10n.format(new Timestamp(System.currentTimeMillis()), \"yyyyMMdd\")\n        int changeSetIdx = 1\n        Set<String> entityNames = efi.getAllEntityNames(filterRegexp)\n\n        // add changeSet per entity\n        // see http://www.liquibase.org/documentation/generating_changelogs.html\n        for (String en in entityNames) {\n            EntityDefinition ed = null\n            try { ed = efi.getEntityDefinition(en) } catch (EntityException e) { logger.warn(\"Problem finding entity definition\", e) }\n            if (ed == null || ed.isViewEntity) continue\n\n            MNode changeSet = rootNode.append(\"changeSet\", [author:\"moqui-init\", id:\"${dateStr}-${changeSetIdx}\".toString()])\n            changeSetIdx++\n\n            // createTable\n            MNode createTable = changeSet.append(\"createTable\", [name:ed.getTableName()])\n            if (ed.getSchemaName()) createTable.attributes.put(\"schemaName\", ed.getSchemaName())\n            FieldInfo[] allFieldInfoArray = ed.entityInfo.allFieldInfoArray\n            for (int i = 0; i < allFieldInfoArray.length; i++) {\n                FieldInfo fi = (FieldInfo) allFieldInfoArray[i]\n                MNode fieldNode = fi.fieldNode\n                MNode column = createTable.append(\"column\", [name:fi.columnName, type:('${type.' + fi.type.replaceAll(\"-\", \"_\") + '}')])\n                if (fi.isPk || fieldNode.attribute(\"not-null\") == \"true\") {\n                    MNode constraints = column.append(\"constraints\", [nullable:\"false\"])\n                    if (fi.isPk) constraints.attributes.put(\"primaryKey\", \"true\")\n                }\n            }\n\n            // createIndex: first do index elements\n            for (MNode indexNode in ed.entityNode.children(\"index\")) {\n                MNode createIndex = changeSet.append(\"createIndex\",\n                        [indexName:indexNode.attribute(\"name\"), tableName:ed.getTableName()])\n                if (ed.getSchemaName()) createIndex.attributes.put(\"schemaName\", ed.getSchemaName())\n                createIndex.attributes.put(\"unique\", indexNode.attribute(\"unique\") ?: \"false\")\n\n                for (MNode indexFieldNode in indexNode.children(\"index-field\"))\n                    createIndex.append(\"column\", [name:ed.getColumnName(indexFieldNode.attribute(\"name\"))])\n            }\n\n            // do fk auto indexes\n            for (RelationshipInfo relInfo in ed.getRelationshipsInfo(false)) {\n                if (relInfo.type != \"one\") continue\n\n                String indexName = makeFkIndexName(ed, relInfo, 30)\n\n                MNode createIndex = changeSet.append(\"createIndex\",\n                        [indexName:indexName, tableName:ed.getTableName(), unique:\"false\"])\n                if (ed.getSchemaName()) createIndex.attributes.put(\"schemaName\", ed.getSchemaName())\n\n                Map keyMap = relInfo.keyMap\n                for (String fieldName in keyMap.keySet())\n                    createIndex.append(\"column\", [name:ed.getColumnName(fieldName)])\n            }\n        }\n\n        // do foreign keys in a separate pass\n        for (String en in entityNames) {\n            EntityDefinition ed = null\n            try { ed = efi.getEntityDefinition(en) } catch (EntityException e) { logger.warn(\"Problem finding entity definition\", e) }\n            if (ed == null || ed.isViewEntity) continue\n\n            MNode changeSet = rootNode.append(\"changeSet\", [author:\"moqui-init\", id:\"${dateStr}-${changeSetIdx}\".toString()])\n            changeSetIdx++\n\n            for (RelationshipInfo relInfo in ed.getRelationshipsInfo(false)) {\n                if (relInfo.type != \"one\") continue\n\n                EntityDefinition relEd = relInfo.relatedEd\n                String constraintName = makeFkConstraintName(ed, relInfo, 30)\n                Map keyMap = relInfo.keyMap\n                List<String> keyMapKeys = new ArrayList(keyMap.keySet())\n\n                StringBuilder baseNames = new StringBuilder()\n                for (String fieldName in keyMapKeys) {\n                    if (baseNames.length() > 0) baseNames.append(\",\")\n                    baseNames.append(ed.getColumnName(fieldName))\n                }\n                StringBuilder referencedNames = new StringBuilder()\n                for (String keyName in keyMapKeys) {\n                    if (referencedNames.length() > 0) referencedNames.append(\",\")\n                    referencedNames.append(relEd.getColumnName((String) keyMap.get(keyName)))\n                }\n\n                MNode addForeignKeyConstraint = changeSet.append(\"addForeignKeyConstraint\", [baseTableName:ed.getTableName(),\n                        baseColumnNames:baseNames.toString(), constraintName:constraintName,\n                        referencedTableName:relEd.getTableName(), referencedColumnNames:referencedNames.toString()])\n                if (ed.getSchemaName()) addForeignKeyConstraint.attributes.put(\"baseTableSchemaName\", ed.getSchemaName())\n                if (relEd.getSchemaName()) addForeignKeyConstraint.attributes.put(\"referencedTableSchemaName\", relEd.getSchemaName())\n            }\n        }\n\n        return rootNode\n    }\n    MNode liquibaseDiffChangelog(String filterRegexp) {\n        return null\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityDefinition.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity\n\nimport groovy.transform.CompileStatic\nimport org.moqui.BaseArtifactException\nimport org.moqui.entity.EntityFind\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.impl.entity.condition.ConditionAlias\nimport org.moqui.impl.entity.condition.DateCondition\nimport org.moqui.util.LiteStringMap\nimport org.moqui.util.ObjectUtilities\nimport org.moqui.util.StringUtilities\n\nimport javax.cache.Cache\nimport java.sql.Timestamp\n\nimport org.moqui.entity.EntityCondition\nimport org.moqui.entity.EntityCondition.JoinOperator\nimport org.moqui.entity.EntityException\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.entity.condition.EntityConditionImplBase\nimport org.moqui.impl.entity.condition.ConditionField\nimport org.moqui.impl.entity.condition.FieldValueCondition\nimport org.moqui.impl.entity.condition.FieldToFieldCondition\nimport org.moqui.impl.entity.EntityJavaUtil.RelationshipInfo\nimport org.moqui.util.MNode\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n@CompileStatic\nclass EntityDefinition {\n    protected final static Logger logger = LoggerFactory.getLogger(EntityDefinition.class)\n\n    protected final EntityFacadeImpl efi\n    public final MNode internalEntityNode\n    public final String fullEntityName\n    // NOTE: these fields were primitive type boolean, changed to object type Boolean because of issue introduced in\n    //    Groovy 3.0.10; with boolean it worked fine in 3.0.9, after that once the constructor completes true values in\n    //    these two fields get flipped to false; see commented logs at EntityDefinition.groovy:94-95, EntityFindBuild.java:112-114\n    public final Boolean isViewEntity, isDynamicView\n    public final String groupName\n    public final EntityJavaUtil.EntityInfo entityInfo\n\n    protected final HashMap<String, MNode> fieldNodeMap = new HashMap<>()\n    protected final HashMap<String, FieldInfo> fieldInfoMap = new HashMap<>()\n    // small lists, but very frequently accessed\n    protected final ArrayList<String> pkFieldNameList = new ArrayList<>()\n    protected final ArrayList<String> nonPkFieldNameList = new ArrayList<>()\n    protected final ArrayList<String> allFieldNameList = new ArrayList<>()\n    protected final ArrayList<FieldInfo> allFieldInfoList = new ArrayList<>()\n    protected Map<String, MNode> pqExpressionNodeMap = null\n    protected Map<String, Map<String, String>> mePkFieldToAliasNameMapMap = null\n    protected Map<String, Map<String, ArrayList<MNode>>> memberEntityFieldAliases = null\n    protected Map<String, MNode> memberEntityAliasMap = null\n    protected boolean hasSubSelectMembers = false\n    // these are used for every list find, so keep them here\n    public final MNode entityConditionNode\n    public final MNode entityHavingEconditions\n\n    protected boolean tableExistVerified = false\n\n    private List<MNode> expandedRelationshipList = null\n    // this is kept separately for quick access to relationships by name or short-alias\n    private Map<String, RelationshipInfo> relationshipInfoMap = null\n    private ArrayList<RelationshipInfo> relationshipInfoList = null\n    private boolean hasReverseRelationships = false\n    private Map<String, MasterDefinition> masterDefinitionMap = null\n\n    EntityDefinition(EntityFacadeImpl efi, MNode entityNode) {\n        this.efi = efi\n        // copy the entityNode because we may be modifying it\n        internalEntityNode = entityNode.deepCopy(null)\n\n        // prepare a few things needed by initFields() before calling it\n\n        String packageName = internalEntityNode.attribute(\"package\")\n        if (packageName == null || packageName.isEmpty()) packageName = internalEntityNode.attribute(\"package-name\")\n        fullEntityName = packageName + \".\" + internalEntityNode.attribute(\"entity-name\")\n\n        isViewEntity = \"view-entity\".equals(internalEntityNode.getName())\n        // if (fullEntityName.contains(\"ArtifactTarpitCheckView\") || fullEntityName.contains(\"DataFeedDocumentDetail\"))\n        //     logger.warn(\"===== TOREMOVE ===== entity ${fullEntityName} node ${internalEntityNode.getName()} isViewEntity ${isViewEntity} ${this}\")\n        isDynamicView = \"true\".equals(internalEntityNode.attribute(\"is-dynamic-view\"))\n\n        boolean memberNeverCache = false\n        if (isViewEntity) {\n            // init some view-entity only fields\n            memberEntityFieldAliases = [:]\n            memberEntityAliasMap = [:]\n\n            // expand member-relationship into member-entity\n            if (internalEntityNode.hasChild(\"member-relationship\")) for (MNode memberRel in internalEntityNode.children(\"member-relationship\")) {\n                String joinFromAlias = memberRel.attribute(\"join-from-alias\")\n                String relName = memberRel.attribute(\"relationship\")\n                MNode jfme = internalEntityNode.first(\"member-entity\", \"entity-alias\", joinFromAlias)\n                if (jfme == null) throw new EntityException(\"Could not find member-entity ${joinFromAlias} referenced in member-relationship ${memberRel.attribute(\"entity-alias\")} of view-entity ${fullEntityName}\")\n                String fromEntityName = jfme.attribute(\"entity-name\")\n                EntityDefinition jfed = efi.getEntityDefinition(fromEntityName)\n                if (jfed == null) throw new EntityException(\"No definition found for member-entity ${jfme.attribute(\"entity-alias\")} name ${fromEntityName} in view-entity ${fullEntityName}\")\n\n                // can't use getRelationshipInfo as not all entities loaded: RelationshipInfo relInfo = jfed.getRelationshipInfo(relName)\n                MNode relNode = jfed.internalEntityNode.first({ MNode it -> \"relationship\".equals(it.name) &&\n                        (relName.equals(it.attribute(\"short-alias\")) || relName.equals(it.attribute(\"related\")) ||\n                                relName.equals(it.attribute(\"related\") + '#' + it.attribute(\"related\"))) })\n                if (relNode == null) throw new EntityException(\"Could not find relationship ${relName} from member-entity ${joinFromAlias} referenced in member-relationship ${memberRel.attribute(\"entity-alias\")} of view-entity ${fullEntityName}\")\n\n                // mutate the current MNode\n                memberRel.setName(\"member-entity\")\n                memberRel.attributes.put(\"entity-name\", relNode.attribute(\"related\"))\n                ArrayList<MNode> kmList = relNode.children(\"key-map\")\n                if (kmList) {\n                    for (MNode keyMap in relNode.children(\"key-map\"))\n                        memberRel.append(\"key-map\", [\"field-name\":keyMap.attribute(\"field-name\"), \"related\":keyMap.attribute(\"related\")])\n                } else {\n                    EntityDefinition relEd = efi.getEntityDefinition(relNode.attribute(\"related\"))\n                    for (String pkName in relEd.getPkFieldNames()) memberRel.append(\"key-map\", [\"field-name\":pkName, \"related\":pkName])\n                }\n            }\n\n            if (internalEntityNode.hasChild(\"member-relationship\"))\n                logger.warn(\"view-entity ${fullEntityName} members: ${internalEntityNode.children(\"member-entity\")}\")\n\n            // get group, etc from member-entity\n            Set<String> allGroupNames = new TreeSet<>()\n            for (MNode memberEntity in internalEntityNode.children(\"member-entity\")) {\n                String memberEntityName = memberEntity.attribute(\"entity-name\")\n                memberEntityAliasMap.put(memberEntity.attribute(\"entity-alias\"), memberEntity)\n                String subSelectAttr = memberEntity.attribute(\"sub-select\")\n                if (\"true\".equals(subSelectAttr) || \"non-lateral\".equals(subSelectAttr)) hasSubSelectMembers = true\n\n                EntityDefinition memberEd = efi.getEntityDefinition(memberEntityName)\n                if (memberEd == null) throw new EntityException(\"No definition found for member-entity ${memberEntity.attribute(\"entity-alias\")} name ${memberEntityName} in view-entity ${fullEntityName}\")\n\n                MNode memberEntityNode = memberEd.getEntityNode()\n                String memberGroupAttr = memberEntityNode.attribute(\"group\") ?: memberEntityNode.attribute(\"group-name\")\n                if (memberGroupAttr == null || memberGroupAttr.length() == 0) {\n                    // use the default group\n                    memberGroupAttr = efi.getDefaultGroupName()\n                }\n                // only set on view-entity for the first/primary member-entity\n                String veGroupAttr = internalEntityNode.attribute(\"group\")\n                if (allGroupNames.size() == 0 && (veGroupAttr == null || veGroupAttr.isEmpty()))\n                    internalEntityNode.attributes.put(\"group\", memberGroupAttr)\n                // remember all group names applicable to the view entity\n                allGroupNames.add(memberGroupAttr)\n\n                // if is view entity and any member entities set to never cache set this to never cache\n                if (\"never\".equals(memberEntityNode.attribute(\"cache\"))) memberNeverCache = true\n            }\n            // warn if view-entity has members in more than one group (join will fail if deployed in different DBs)\n            if (allGroupNames.size() > 1) logger.warn(\"view-entity ${getFullEntityName()} has members in more than one group: ${allGroupNames}\")\n        }\n\n        // get group from entity node now that view-entity group handled\n        String groupAttr = internalEntityNode.attribute(\"group\")\n        if (groupAttr == null || groupAttr.isEmpty()) groupAttr = internalEntityNode.attribute(\"group-name\")\n        if (groupAttr == null || groupAttr.isEmpty()) groupAttr = efi.getDefaultGroupName()\n        groupName = groupAttr\n\n        // now initFields() and create EntityInfo\n        if (isViewEntity) {\n            // if this is a view-entity, expand the alias-all elements into alias elements here\n            this.expandAliasAlls()\n            // set @type, set is-pk on all alias Nodes if the related field is-pk\n            for (MNode aliasNode in internalEntityNode.children(\"alias\")) {\n                if (aliasNode.hasChild(\"complex-alias\") || aliasNode.hasChild(\"case\")) continue\n                if (aliasNode.attribute(\"pq-expression\")) continue\n\n                String entityAlias = aliasNode.attribute(\"entity-alias\")\n                MNode memberEntity = memberEntityAliasMap.get(entityAlias)\n                if (memberEntity == null) throw new EntityException(\"Could not find member-entity with entity-alias ${entityAlias} in view-entity ${fullEntityName}\")\n\n                EntityDefinition memberEd = efi.getEntityDefinition(memberEntity.attribute(\"entity-name\"))\n                String fieldName = aliasNode.attribute(\"field\") ?: aliasNode.attribute(\"name\")\n                MNode fieldNode = memberEd.getFieldNode(fieldName)\n                if (fieldNode == null) throw new EntityException(\"In view-entity ${fullEntityName} alias ${aliasNode.attribute(\"name\")} referred to field ${fieldName} that does not exist on entity ${memberEd.fullEntityName}.\")\n                if (!aliasNode.attribute(\"type\")) aliasNode.attributes.put(\"type\", fieldNode.attribute(\"type\"))\n                if (\"true\".equals(fieldNode.attribute(\"is-pk\"))) aliasNode.attributes.put(\"is-pk\", \"true\")\n                if (\"true\".equals(fieldNode.attribute(\"enable-localization\"))) aliasNode.attributes.put(\"enable-localization\", \"true\")\n                if (\"true\".equals(fieldNode.attribute(\"encrypt\"))) aliasNode.attributes.put(\"encrypt\", \"true\")\n\n                // add to aliases by field name by entity name\n                if (!memberEntityFieldAliases.containsKey(memberEd.getFullEntityName())) memberEntityFieldAliases.put(memberEd.getFullEntityName(), [:])\n                Map<String, ArrayList<MNode>> fieldInfoByEntity = memberEntityFieldAliases.get(memberEd.getFullEntityName())\n                if (!fieldInfoByEntity.containsKey(fieldName)) fieldInfoByEntity.put(fieldName, new ArrayList())\n                ArrayList<MNode> aliasByField = fieldInfoByEntity.get(fieldName)\n                aliasByField.add(aliasNode)\n            }\n\n            int curIndex = 0\n            for (MNode aliasNode in internalEntityNode.children(\"alias\")) {\n                if (aliasNode.attribute(\"pq-expression\")) {\n                    if (pqExpressionNodeMap == null) pqExpressionNodeMap = new HashMap<>()\n                    String pqFieldName = aliasNode.attribute(\"name\")\n                    pqExpressionNodeMap.put(pqFieldName, aliasNode)\n                    continue\n                }\n\n                FieldInfo fi = new FieldInfo(this, aliasNode, curIndex)\n                addFieldInfo(fi)\n                curIndex++\n            }\n\n            entityConditionNode = internalEntityNode.first(\"entity-condition\")\n            if (entityConditionNode != null) entityHavingEconditions = entityConditionNode.first(\"having-econditions\")\n            else entityHavingEconditions = null\n        } else {\n            if (internalEntityNode.attribute(\"no-update-stamp\") != \"true\") {\n                // automatically add the lastUpdatedStamp field\n                internalEntityNode.append(\"field\", [name:\"lastUpdatedStamp\", type:\"date-time\"])\n            }\n\n            ArrayList<MNode> fieldNodeList = internalEntityNode.children(\"field\")\n            for (int i = 0; i < fieldNodeList.size(); i++) {\n                MNode fieldNode = (MNode) fieldNodeList.get(i)\n                FieldInfo fi = new FieldInfo(this, fieldNode, i)\n                addFieldInfo(fi)\n            }\n\n            entityConditionNode = null\n            entityHavingEconditions = null\n        }\n\n        // finally create the EntityInfo object\n        entityInfo = new EntityJavaUtil.EntityInfo(this, memberNeverCache)\n    }\n\n    private void addFieldInfo(FieldInfo fi) {\n        fieldNodeMap.put(fi.name, fi.fieldNode)\n        fieldInfoMap.put(fi.name, fi)\n        allFieldNameList.add(fi.name)\n        allFieldInfoList.add(fi)\n        if (fi.isPk) {\n            pkFieldNameList.add(fi.name)\n        } else {\n            nonPkFieldNameList.add(fi.name)\n        }\n    }\n    private String getBasicFieldColName(String entityAlias, String fieldName) {\n        MNode memberEntity = memberEntityAliasMap.get(entityAlias)\n        if (memberEntity == null) throw new EntityException(\"Could not find member-entity with entity-alias [${entityAlias}] in view-entity [${getFullEntityName()}]\")\n        EntityDefinition memberEd = this.efi.getEntityDefinition(memberEntity.attribute(\"entity-name\"))\n        FieldInfo fieldInfo = memberEd.getFieldInfo(fieldName)\n        if (fieldInfo == null) throw new EntityException(\"Invalid field name ${fieldName} for entity ${memberEd.getFullEntityName()}\")\n        String subSelectAttr = memberEntity.attribute(\"sub-select\")\n        if (\"true\".equals(subSelectAttr) || \"non-lateral\".equals(subSelectAttr)) {\n            // sub-select uses alias field name changed to underscored\n            return EntityJavaUtil.camelCaseToUnderscored(fieldInfo.name)\n        } else {\n            return fieldInfo.getFullColumnName()\n        }\n    }\n    String makeFullColumnName(MNode fieldNode, boolean includeEntityAlias) {\n        if (!isViewEntity) return null\n\n        String memberAliasName = fieldNode.attribute(\"name\")\n        String memberFieldName = fieldNode.attribute(\"field\")\n        if (memberFieldName == null || memberFieldName.isEmpty()) memberFieldName = memberAliasName\n\n        String entityAlias = fieldNode.attribute(\"entity-alias\")\n        if (includeEntityAlias) {\n            if (entityAlias == null || entityAlias.isEmpty()) {\n                Set<String> entityAliasUsedSet = new HashSet<>()\n                ArrayList<MNode> cafList = fieldNode.descendants(\"complex-alias-field\")\n                int cafListSize = cafList.size()\n                for (int i = 0; i < cafListSize; i++) {\n                    MNode cafNode = (MNode) cafList.get(i)\n                    String cafEntityAlias = cafNode.attribute(\"entity-alias\")\n                    if (cafEntityAlias != null && cafEntityAlias.length() > 0) entityAliasUsedSet.add(cafEntityAlias)\n                }\n                if (entityAliasUsedSet.size() == 1) entityAlias = entityAliasUsedSet.iterator().next()\n            }\n            // might have added entityAlias so check again\n            if (entityAlias != null && !entityAlias.isEmpty()) {\n                // special case for member-entity with sub-select=true, use alias underscored\n                MNode memberEntity = (MNode) memberEntityAliasMap.get(entityAlias)\n                EntityDefinition memberEd = this.efi.getEntityDefinition(memberEntity.attribute(\"entity-name\"))\n                String subSelectAttr = memberEntity.attribute(\"sub-select\")\n                if (!memberEd.isViewEntity && (\"true\".equals(subSelectAttr) || \"non-lateral\".equals(subSelectAttr))) {\n                    return entityAlias + '.' + EntityJavaUtil.camelCaseToUnderscored(memberAliasName)\n                }\n            }\n        }\n\n        // NOTE: for view-entity the incoming fieldNode will actually be for an alias element\n        StringBuilder colNameBuilder = new StringBuilder()\n\n        MNode caseNode = fieldNode.first(\"case\")\n        MNode complexAliasNode = fieldNode.first(\"complex-alias\")\n        String function = fieldNode.attribute(\"function\")\n        boolean hasFunction = function != null && !function.isEmpty()\n\n        if (hasFunction) colNameBuilder.append(getFunctionPrefix(function))\n        if (caseNode != null) {\n            colNameBuilder.append(\"CASE\")\n            String caseExpr = caseNode.attribute(\"expression\")\n            if (caseExpr != null) colNameBuilder.append(\" \").append(caseExpr)\n\n            ArrayList<MNode> whenNodeList = caseNode.children(\"when\")\n            int whenNodeListSize = whenNodeList.size()\n            if (whenNodeListSize == 0) throw new EntityException(\"No when element under case in alias ${fieldNode.attribute(\"name\")} in view-entity ${getFullEntityName()}\")\n            for (int i = 0; i < whenNodeListSize; i++) {\n                MNode whenNode = (MNode) whenNodeList.get(i)\n                colNameBuilder.append(\" WHEN \").append(whenNode.attribute(\"expression\")).append(\" THEN \")\n                MNode whenComplexAliasNode = whenNode.first(\"complex-alias\")\n                if (whenComplexAliasNode == null) throw new EntityException(\"No complex-alias element under case.when in alias ${fieldNode.attribute(\"name\")} in view-entity ${getFullEntityName()}\")\n                buildComplexAliasName(whenComplexAliasNode, colNameBuilder, true, includeEntityAlias)\n            }\n\n            MNode elseNode = caseNode.first(\"else\")\n            if (elseNode != null) {\n                colNameBuilder.append(\" ELSE \")\n                MNode elseComplexAliasNode = elseNode.first(\"complex-alias\")\n                if (elseComplexAliasNode == null) throw new EntityException(\"No complex-alias element under case.else in alias ${fieldNode.attribute(\"name\")} in view-entity ${getFullEntityName()}\")\n                buildComplexAliasName(elseComplexAliasNode, colNameBuilder, true, includeEntityAlias)\n            }\n\n            colNameBuilder.append(\" END\")\n        } else if (complexAliasNode != null) {\n            buildComplexAliasName(complexAliasNode, colNameBuilder, !hasFunction, includeEntityAlias)\n        } else {\n            // column name for view-entity (prefix with \"${entity-alias}.\")\n            if (includeEntityAlias) colNameBuilder.append(entityAlias).append('.')\n            colNameBuilder.append(getBasicFieldColName(entityAlias, memberFieldName))\n        }\n        if (hasFunction) colNameBuilder.append(')')\n\n        return colNameBuilder.toString()\n    }\n    private void buildComplexAliasName(MNode parentNode, StringBuilder colNameBuilder, boolean addParens, boolean includeEntityAlias) {\n        String expression = parentNode.attribute(\"expression\")\n        // NOTE: this is expanded in FieldInfo.getFullColumnName() if needed\n        if (expression != null && expression.length() > 0) colNameBuilder.append(expression)\n\n        ArrayList<MNode> childList = parentNode.children\n        int childListSize = childList.size()\n        if (childListSize == 0) return\n\n        String caFunction = parentNode.attribute(\"function\")\n        if (caFunction != null && !caFunction.isEmpty()) {\n            colNameBuilder.append(caFunction).append('(')\n            for (int i = 0; i < childListSize; i++) {\n                MNode childNode = (MNode) childList.get(i)\n                if (i > 0) colNameBuilder.append(\", \")\n\n                if (\"complex-alias\".equals(childNode.name)) {\n                    buildComplexAliasName(childNode, colNameBuilder, true, includeEntityAlias)\n                } else if (\"complex-alias-field\".equals(childNode.name)) {\n                    appenComplexAliasField(childNode, colNameBuilder, includeEntityAlias)\n                }\n            }\n            colNameBuilder.append(')')\n        } else {\n            String operator = parentNode.attribute(\"operator\")\n            if (operator == null || operator.isEmpty()) operator = \"+\"\n\n            if (addParens && childListSize > 1) colNameBuilder.append('(')\n            for (int i = 0; i < childListSize; i++) {\n                MNode childNode = (MNode) childList.get(i)\n                if (i > 0) colNameBuilder.append(' ').append(operator).append(' ')\n\n                if (\"complex-alias\".equals(childNode.name)) {\n                    buildComplexAliasName(childNode, colNameBuilder, true, includeEntityAlias)\n                } else if (\"complex-alias-field\".equals(childNode.name)) {\n                    appenComplexAliasField(childNode, colNameBuilder, includeEntityAlias)\n                }\n            }\n            if (addParens && childListSize > 1) colNameBuilder.append(')')\n        }\n    }\n    private void appenComplexAliasField(MNode childNode, StringBuilder colNameBuilder, boolean includeEntityAlias) {\n        String entityAlias = childNode.attribute(\"entity-alias\")\n        String basicColName = getBasicFieldColName(entityAlias, childNode.attribute(\"field\"))\n        String colName = includeEntityAlias ? entityAlias + \".\" + basicColName : basicColName\n        String defaultValue = childNode.attribute(\"default-value\")\n        String function = childNode.attribute(\"function\")\n\n        if (function) colNameBuilder.append(getFunctionPrefix(function))\n        if (defaultValue) colNameBuilder.append(\"COALESCE(\")\n        colNameBuilder.append(colName)\n        if (defaultValue) colNameBuilder.append(',').append(defaultValue).append(')')\n        if (function) colNameBuilder.append(')')\n    }\n    protected static String getFunctionPrefix(String function) {\n        return (function == \"count-distinct\") ? \"COUNT(DISTINCT \" : function.toUpperCase() + '('\n    }\n    private void expandAliasAlls() {\n        if (!isViewEntity) return\n        Set<String> existingAliasNames = new HashSet<>()\n        ArrayList<MNode> aliasList = internalEntityNode.children(\"alias\")\n        int aliasListSize = aliasList.size()\n        for (int i = 0; i < aliasListSize; i++) {\n            MNode aliasNode = (MNode) aliasList.get(i)\n            existingAliasNames.add(aliasNode.attribute(\"name\"))\n        }\n\n        ArrayList<MNode> aliasAllList = internalEntityNode.children(\"alias-all\")\n        ArrayList<MNode> memberEntityList = internalEntityNode.children(\"member-entity\")\n        int memberEntityListSize = memberEntityList.size()\n        for (int aInd = 0; aInd < aliasAllList.size(); aInd++) {\n            MNode aliasAll = (MNode) aliasAllList.get(aInd)\n            String aliasAllEntityAlias = aliasAll.attribute(\"entity-alias\")\n            MNode memberEntity = memberEntityAliasMap.get(aliasAllEntityAlias)\n            if (memberEntity == null) {\n                logger.error(\"In view-entity ${getFullEntityName()} in alias-all with entity-alias [${aliasAllEntityAlias}], member-entity with same entity-alias not found, ignoring\")\n                continue\n            }\n\n            EntityDefinition aliasedEntityDefinition = efi.getEntityDefinition(memberEntity.attribute(\"entity-name\"))\n            if (aliasedEntityDefinition == null) {\n                logger.error(\"Entity [${memberEntity.attribute(\"entity-name\")}] referred to in member-entity with entity-alias [${aliasAllEntityAlias}] not found, ignoring\")\n                continue\n            }\n\n            FieldInfo[] aliasFieldInfos = aliasedEntityDefinition.entityInfo.allFieldInfoArray\n            for (int i = 0; i < aliasFieldInfos.length; i++) {\n                FieldInfo fi = (FieldInfo) aliasFieldInfos[i]\n                String aliasName = fi.name\n                // never auto-alias these\n                if (\"lastUpdatedStamp\".equals(aliasName)) continue\n                // if specified as excluded, leave it out\n                ArrayList<MNode> excludeList = aliasAll.children(\"exclude\")\n                int excludeListSize = excludeList.size()\n                boolean foundExclude = false\n                for (int j = 0; j < excludeListSize; j++) {\n                    MNode excludeNode = (MNode) excludeList.get(j)\n                    if (aliasName.equals(excludeNode.attribute(\"field\"))) {\n                        foundExclude = true\n                        break\n                    }\n                }\n                if (foundExclude) continue\n\n\n                if (aliasAll.attribute(\"prefix\")) {\n                    StringBuilder newAliasName = new StringBuilder(aliasAll.attribute(\"prefix\"))\n                    newAliasName.append(Character.toUpperCase(aliasName.charAt(0)))\n                    newAliasName.append(aliasName.substring(1))\n                    aliasName = newAliasName.toString()\n                }\n\n                // see if there is already an alias with this name\n                if (existingAliasNames.contains(aliasName)) {\n                    //log differently if this is part of a member-entity view link key-map because that is a common case when a field will be auto-expanded multiple times\n                    boolean isInViewLink = false\n                    for (int j = 0; j < memberEntityListSize; j++) {\n                        MNode viewMeNode = (MNode) memberEntityList.get(j)\n                        boolean isRel = false\n                        if (viewMeNode.attribute(\"entity-alias\") == aliasAllEntityAlias) {\n                            isRel = true\n                        } else if (viewMeNode.attribute(\"join-from-alias\") != aliasAllEntityAlias) {\n                            // not the rel-entity-alias or the entity-alias, so move along\n                            continue;\n                        }\n                        for (MNode keyMap in viewMeNode.children(\"key-map\")) {\n                            if (!isRel && keyMap.attribute(\"field-name\") == fi.name) {\n                                isInViewLink = true\n                                break\n                            } else if (isRel && ((keyMap.attribute(\"related\") ?: keyMap.attribute(\"related-field-name\") ?: keyMap.attribute(\"field-name\"))) == fi.name) {\n                                isInViewLink = true\n                                break\n                            }\n                        }\n                        if (isInViewLink) break\n                    }\n\n                    MNode existingAliasNode = internalEntityNode.children(\"alias\").find({ aliasName.equals(it.attribute(\"name\")) })\n                    // already exists... probably an override, but log just in case\n                    String warnMsg = \"Throwing out field alias in view entity \" + this.getFullEntityName() +\n                            \" because one already exists with the alias name [\" + aliasName + \"] and field name [\" +\n                            memberEntity.attribute(\"entity-alias\") + \"(\" + aliasedEntityDefinition.getFullEntityName() + \").\" +\n                            fi.name + \"], existing field name is [\" + existingAliasNode.attribute(\"entity-alias\") + \".\" +\n                            existingAliasNode.attribute(\"field\") + \"]\"\n                    if (isInViewLink) { if (logger.isTraceEnabled()) logger.trace(warnMsg) } else { logger.info(warnMsg) }\n\n                    // ship adding the new alias\n                    continue\n                }\n\n                existingAliasNames.add(aliasName)\n                MNode newAlias = this.internalEntityNode.append(\"alias\",\n                        [name:aliasName, field:fi.name, \"entity-alias\":aliasAllEntityAlias, \"is-from-alias-all\":\"true\"])\n                if (fi.fieldNode.hasChild(\"description\")) newAlias.append(fi.fieldNode.first(\"description\"))\n            }\n        }\n    }\n\n    EntityFacadeImpl getEfi() { return efi }\n    String getEntityName() { return entityInfo.internalEntityName }\n    String getFullEntityName() { return fullEntityName }\n    String getShortAlias() { return entityInfo.shortAlias }\n    String getShortOrFullEntityName() { return entityInfo.shortAlias != null ? entityInfo.shortAlias : entityInfo.fullEntityName }\n    MNode getEntityNode() { return internalEntityNode }\n\n    Map<String, ArrayList<MNode>> getMemberFieldAliases(String memberEntityName) {\n        return memberEntityFieldAliases?.get(memberEntityName)\n    }\n    String getEntityGroupName() { return groupName }\n\n    /** Returns the table name, ie table-name or converted entity-name */\n    String getTableName() { return entityInfo.tableName }\n    String getTableNameLowerCase() { return entityInfo.tableNameLowerCase }\n    String getFullTableName() { return entityInfo.fullTableName }\n    String getSchemaName() { return entityInfo.schemaName }\n\n    String getColumnName(String fieldName) {\n        FieldInfo fieldInfo = getFieldInfo(fieldName)\n        if (fieldInfo == null) throw new EntityException(\"Invalid field name ${fieldName} for entity ${this.getFullEntityName()}\")\n        return fieldInfo.getFullColumnName()\n    }\n\n    ArrayList<String> getPkFieldNames() { return pkFieldNameList }\n    ArrayList<String> getNonPkFieldNames() { return nonPkFieldNameList }\n    ArrayList<String> getAllFieldNames() { return allFieldNameList }\n    boolean isField(String fieldName) { return fieldInfoMap.containsKey(fieldName) }\n    boolean isPkField(String fieldName) {\n        FieldInfo fieldInfo = fieldInfoMap.get(fieldName)\n        if (fieldInfo == null) return false\n        return fieldInfo.isPk\n    }\n\n    boolean containsPrimaryKey(Map<String, Object> fields) {\n        if (fields == null || fields.size() == 0) return false\n        ArrayList<String> fieldNameList = this.getPkFieldNames()\n        int size = fieldNameList.size()\n        for (int i = 0; i < size; i++) {\n            String fieldName = (String) fieldNameList.get(i)\n            Object fieldValue = fields.get(fieldName)\n            if (ObjectUtilities.isEmpty(fieldValue)) return false\n        }\n        return true\n    }\n    LiteStringMap<Object> getPrimaryKeys(Map<String, Object> fields) {\n        // NOTE: for pks Map don't use manual indexes, want compact with no extra entries and causes issues\n        FieldInfo[] pkFieldInfos = this.entityInfo.pkFieldInfoArray\n        LiteStringMap<Object> pks = new LiteStringMap<>(pkFieldInfos.length)\n\n        if (fields instanceof LiteStringMap) {\n            LiteStringMap<Object> fieldsLsm = (LiteStringMap<Object>) fields\n            for (int i = 0; i < pkFieldInfos.length; i++) {\n                FieldInfo fi = pkFieldInfos[i]\n                pks.putByIString(fi.name, fieldsLsm.getByIString(fi.name))\n            }\n        } else {\n            for (int i = 0; i < pkFieldInfos.length; i++) {\n                FieldInfo fi = pkFieldInfos[i]\n                pks.putByIString(fi.name, fields.get(fi.name))\n            }\n        }\n\n        return pks\n    }\n    String getPrimaryKeysString(Map<String, Object> fieldValues) {\n        if (fieldValues == null) {\n            logger.warn(\"EntityDefinition.getPrimaryKeysString() fieldValues is null\", new Exception(\"location\"))\n            return null\n        }\n        FieldInfo[] pkFieldInfoArray = entityInfo.pkFieldInfoArray\n        if (pkFieldInfoArray.length == 1) {\n            FieldInfo fi = pkFieldInfoArray[0]\n            return ObjectUtilities.toPlainString(fieldValues.get(fi.name))\n        } else {\n            StringBuilder pkCombinedSb = new StringBuilder();\n            for (int pki = 0; pki < pkFieldInfoArray.length; pki++) {\n                FieldInfo fi = pkFieldInfoArray[pki]\n                // NOTE: separator of '::' matches separator used for combined PK String in EntityValueBase.getPrimaryKeysString() and EntityDataDocument.makeDocId()\n                if (pkCombinedSb.length() > 0) pkCombinedSb.append(\"::\")\n                pkCombinedSb.append(ObjectUtilities.toPlainString(fieldValues.get(fi.name)))\n            }\n            return pkCombinedSb.toString()\n        }\n    }\n\n    ArrayList<String> getFieldNames(boolean includePk, boolean includeNonPk) {\n        ArrayList<String> baseList\n        if (includePk) {\n            if (includeNonPk) baseList = getAllFieldNames()\n            else baseList = getPkFieldNames()\n        } else {\n            if (includeNonPk) baseList = getNonPkFieldNames()\n            // all false is weird, but okay\n            else baseList = new ArrayList<String>()\n        }\n        return baseList\n    }\n\n    String getDefaultDescriptionField() {\n        ArrayList<String> nonPkFields = nonPkFieldNameList\n        // find the first *Name\n        for (String fn in nonPkFields)\n            if (fn.endsWith(\"Name\")) return fn\n\n        // no name? try literal description\n        if (isField(\"description\")) return \"description\"\n\n        // no description? just use the first non-pk field: nonPkFields.get(0)\n        // not any more, can be confusing... just return empty String\n        return \"\"\n    }\n\n    MNode getMemberEntityNode(String entityAlias) { return memberEntityAliasMap.get(entityAlias) }\n    String getMemberEntityName(String entityAlias) {\n        MNode memberEntityNode = memberEntityAliasMap.get(entityAlias)\n        return memberEntityNode?.attribute(\"entity-name\")\n    }\n\n    MNode getFieldNode(String fieldName) { return (MNode) fieldNodeMap.get(fieldName) }\n    FieldInfo getFieldInfo(String fieldName) { return (FieldInfo) fieldInfoMap.get(fieldName) }\n\n    static Map<String, String> getRelationshipExpandedKeyMapInternal(MNode relationship, EntityDefinition relEd) {\n        Map<String, String> eKeyMap = [:]\n        ArrayList<MNode> keyMapList = relationship.children(\"key-map\")\n        if (!keyMapList && ((String) relationship.attribute(\"type\")).startsWith(\"one\")) {\n            // go through pks of related entity, assume field names match\n            ArrayList<String> relPkFields = relEd.getPkFieldNames()\n            int relPkFieldSize = relPkFields.size()\n            for (int i = 0; i < relPkFieldSize; i++) {\n                String pkFieldName = (String) relPkFields.get(i)\n                eKeyMap.put(pkFieldName, pkFieldName)\n            }\n        } else {\n            int keyMapListSize = keyMapList.size()\n            if (keyMapListSize == 1) {\n                MNode keyMap = (MNode) keyMapList.get(0)\n                String fieldName = keyMap.attribute(\"field-name\")\n                String relFn = keyMap.attribute(\"related\") ?: keyMap.attribute(\"related-field-name\")\n                if (relFn == null || relFn.isEmpty()) {\n                    ArrayList<String> relPks = relEd.getPkFieldNames()\n                    if (relationship.attribute(\"type\").startsWith(\"one\") && relPks.size() == 1) {\n                        relFn = (String) relPks.get(0)\n                    } else {\n                        relFn = fieldName\n                    }\n                }\n                eKeyMap.put(fieldName, relFn)\n            } else {\n                for (int i = 0; i < keyMapListSize; i++) {\n                    MNode keyMap = (MNode) keyMapList.get(i)\n                    String fieldName = keyMap.attribute(\"field-name\")\n                    String relFn = keyMap.attribute(\"related\") ?: keyMap.attribute(\"related-field-name\") ?: fieldName\n                    if (!relEd.isField(relFn) && relationship.attribute(\"type\").startsWith(\"one\")) {\n                        ArrayList<String> pks = relEd.getPkFieldNames()\n                        if (pks.size() == 1) relFn = (String) pks.get(0)\n                        // if we don't match these constraints and get this default we'll get an error later...\n                    }\n                    eKeyMap.put(fieldName, relFn)\n                }\n            }\n        }\n        return eKeyMap\n    }\n    static Map<String, String> getRelationshipKeyValueMapInternal(MNode relationship) {\n        ArrayList<MNode> keyValueList = relationship.children(\"key-value\")\n        int keyValueListSize = keyValueList.size()\n        if (keyValueListSize == 0) return null\n        Map<String, String> eKeyMap = [:]\n        for (int i = 0; i < keyValueListSize; i++) {\n            MNode keyValue = (MNode) keyValueList.get(i)\n            eKeyMap.put(keyValue.attribute(\"related\"), keyValue.attribute(\"value\"))\n        }\n        return eKeyMap\n    }\n\n    RelationshipInfo getRelationshipInfo(String relationshipName) {\n        if (relationshipName == null || relationshipName.isEmpty()) return null\n        return getRelationshipInfoMap().get(relationshipName)\n    }\n    Map<String, RelationshipInfo> getRelationshipInfoMap() {\n        if (relationshipInfoMap == null) makeRelInfoMap()\n        return relationshipInfoMap\n    }\n    private synchronized void makeRelInfoMap() {\n        if (relationshipInfoMap != null) return\n        Map<String, RelationshipInfo> relInfoMap = new HashMap<String, RelationshipInfo>()\n        List<RelationshipInfo> relInfoList = getRelationshipsInfo(false)\n        for (RelationshipInfo relInfo in relInfoList) {\n            // always use the full relationshipName\n            relInfoMap.put(relInfo.relationshipName, relInfo)\n            // if there is a shortAlias add it under that\n            if (relInfo.shortAlias) relInfoMap.put(relInfo.shortAlias, relInfo)\n            // if there is no title, allow referring to the relationship by just the simple entity name (no package)\n            if (!relInfo.title) relInfoMap.put(relInfo.relatedEd.entityInfo.internalEntityName, relInfo)\n        }\n        relationshipInfoMap = relInfoMap\n    }\n\n    ArrayList<RelationshipInfo> getRelationshipsInfo(boolean dependentsOnly) {\n        if (relationshipInfoList == null) makeRelInfoList()\n\n        if (!dependentsOnly) return new ArrayList(relationshipInfoList)\n        // just get dependents\n        ArrayList<RelationshipInfo> infoListCopy = new ArrayList<>()\n        for (RelationshipInfo info in relationshipInfoList) if (info.dependent) infoListCopy.add(info)\n        return infoListCopy\n    }\n    private synchronized void makeRelInfoList() {\n        if (relationshipInfoList != null) return\n\n        if (!this.expandedRelationshipList) {\n            // make sure this is done before as this isn't done by default\n            if (!hasReverseRelationships) efi.createAllAutoReverseManyRelationships()\n            this.expandedRelationshipList = this.internalEntityNode.children(\"relationship\")\n        }\n\n        ArrayList<RelationshipInfo> infoList = new ArrayList<>()\n        for (MNode relNode in this.expandedRelationshipList) {\n            RelationshipInfo relInfo = new RelationshipInfo(relNode, this, efi)\n            infoList.add(relInfo)\n        }\n        relationshipInfoList = infoList\n    }\n    void setHasReverseRelationships() { hasReverseRelationships = true }\n\n    MasterDefinition getMasterDefinition(String name) {\n        if (name == null || name.length() == 0) name = \"default\"\n        if (masterDefinitionMap == null) makeMasterDefinitionMap()\n        return masterDefinitionMap.get(name)\n    }\n    Map<String, MasterDefinition> getMasterDefinitionMap() {\n        if (masterDefinitionMap == null) makeMasterDefinitionMap()\n        return masterDefinitionMap\n    }\n    private synchronized void makeMasterDefinitionMap() {\n        if (masterDefinitionMap != null) return\n        Map<String, MasterDefinition> defMap = [:]\n        for (MNode masterNode in internalEntityNode.children(\"master\")) {\n            MasterDefinition curDef = new MasterDefinition(this, masterNode)\n            defMap.put(curDef.name, curDef)\n        }\n        masterDefinitionMap = defMap\n    }\n\n    Map<String, MNode> getPqExpressionNodeMap() { return pqExpressionNodeMap }\n    MNode getPqExpressionNode(String name) {\n        if (pqExpressionNodeMap == null) return null\n        return pqExpressionNodeMap.get(name)\n    }\n\n    @CompileStatic\n    static class MasterDefinition {\n        String name\n        ArrayList<MasterDetail> detailList = new ArrayList<MasterDetail>()\n        MasterDefinition(EntityDefinition ed, MNode masterNode) {\n            name = masterNode.attribute(\"name\") ?: \"default\"\n            List<MNode> detailNodeList = masterNode.children(\"detail\")\n            for (MNode detailNode in detailNodeList) {\n                try {\n                    detailList.add(new MasterDetail(ed, detailNode))\n                } catch (Exception e) {\n                    logger.error(\"Error adding detail ${detailNode.attribute(\"relationship\")} to master ${name} of entity ${ed.getFullEntityName()}: ${e.toString()}\")\n                }\n            }\n        }\n    }\n    @CompileStatic\n    static class MasterDetail {\n        String relationshipName\n        EntityDefinition parentEd\n        RelationshipInfo relInfo\n        String relatedMasterName\n        ArrayList<MasterDetail> internalDetailList = new ArrayList<>()\n        MasterDetail(EntityDefinition parentEd, MNode detailNode) {\n            this.parentEd = parentEd\n            relationshipName = detailNode.attribute(\"relationship\")\n            relInfo = parentEd.getRelationshipInfo(relationshipName)\n            if (relInfo == null) throw new BaseArtifactException(\"Invalid relationship name [${relationshipName}] for entity ${parentEd.getFullEntityName()}\")\n            // logger.warn(\"Following relationship ${relationshipName}\")\n\n            List<MNode> detailNodeList = detailNode.children(\"detail\")\n            for (MNode childNode in detailNodeList) internalDetailList.add(new MasterDetail(relInfo.relatedEd, childNode))\n\n            relatedMasterName = (String) detailNode.attribute(\"use-master\")\n        }\n\n        ArrayList<MasterDetail> getDetailList() {\n            if (relatedMasterName) {\n                ArrayList<MasterDetail> combinedList = new ArrayList<MasterDetail>(internalDetailList)\n                MasterDefinition relatedMaster = relInfo.relatedEd.getMasterDefinition(relatedMasterName)\n                if (relatedMaster == null) throw new BaseArtifactException(\"Invalid use-master value [${relatedMasterName}], master not found in entity ${relInfo.relatedEntityName}\")\n                // logger.warn(\"Including master ${relatedMasterName} on entity ${relInfo.relatedEd.getFullEntityName()}\")\n\n                combinedList.addAll(relatedMaster.detailList)\n\n                return combinedList\n            } else {\n                return internalDetailList\n            }\n        }\n    }\n\n    // NOTE: used in the DataEdit screen\n    EntityDependents getDependentsTree() {\n        EntityDependents edp = new EntityDependents(this, null, null)\n        return edp\n    }\n\n    static class EntityDependents {\n        String entityName\n        EntityDefinition ed\n        Map<String, EntityDependents> dependentEntities = new TreeMap<String, EntityDependents>()\n        Set<String> descendants = new TreeSet()\n        Map<String, RelationshipInfo> relationshipInfos = new HashMap<String, RelationshipInfo>()\n\n        EntityDependents(EntityDefinition ed, Deque<String> ancestorEntities, Map<String, EntityDependents> allDependents) {\n            this.ed = ed\n            entityName = ed.fullEntityName\n\n            if (ancestorEntities == null) ancestorEntities = new LinkedList()\n            ancestorEntities.addFirst(entityName)\n            if (allDependents == null) allDependents = new HashMap<String, EntityDependents>()\n            allDependents.put(entityName, this)\n\n            List<RelationshipInfo> relInfoList = ed.getRelationshipsInfo(true)\n            for (RelationshipInfo relInfo in relInfoList) {\n                if (!relInfo.dependent) continue\n                descendants.add(relInfo.relatedEntityName)\n                String relName = relInfo.relationshipName\n                relationshipInfos.put(relName, relInfo)\n                // if (relInfo.shortAlias) edp.relationshipInfos.put((String) relInfo.shortAlias, relInfo)\n                EntityDefinition relEd = ed.efi.getEntityDefinition((String) relInfo.relatedEntityName)\n                if (!dependentEntities.containsKey(relName) && !ancestorEntities.contains(relEd.fullEntityName)) {\n                    EntityDependents relEdp = allDependents.get(relEd.fullEntityName)\n                    if (relEdp == null) relEdp = new EntityDependents(relEd, ancestorEntities, allDependents)\n                    dependentEntities.put(relName, relEdp)\n                }\n            }\n\n            ancestorEntities.removeFirst()\n        }\n\n        // used in EntityDetail screen\n        TreeSet<String> getAllDescendants() {\n            TreeSet<String> allSet = new TreeSet()\n            populateAllDescendants(allSet)\n            return allSet\n        }\n        protected void populateAllDescendants(TreeSet<String> allSet) {\n            allSet.addAll(descendants)\n            for (EntityDependents edp in dependentEntities.values()) edp.populateAllDescendants(allSet)\n        }\n\n        String toString() {\n            StringBuilder builder = new StringBuilder(10000)\n            Set<String> entitiesVisited = new HashSet<>()\n            buildString(builder, 0, entitiesVisited)\n            return builder.toString()\n        }\n        static final String indentBase = \"- \"\n        void buildString(StringBuilder builder, int level, Set<String> entitiesVisited) {\n            StringBuilder ib = new StringBuilder()\n            for (int i = 0; i <= level; i++) ib.append(indentBase)\n            String indent = ib.toString()\n\n            for (Map.Entry<String, EntityDependents> entry in dependentEntities) {\n                RelationshipInfo relInfo = relationshipInfos.get(entry.getKey())\n                builder.append(indent).append(relInfo.relationshipName).append(\" \").append(relInfo.keyMap).append(\"\\n\")\n                if (level < 4 && !entitiesVisited.contains(entry.getValue().entityName)) {\n                    entry.getValue().buildString(builder, level + 1I, entitiesVisited)\n                    entitiesVisited.add(entry.getValue().entityName)\n                } else if (entitiesVisited.contains(entry.getValue().entityName)) {\n                    builder.append(indent).append(indentBase).append(\"Dependants already displayed\\n\")\n                } else if (level == 4) {\n                    builder.append(indent).append(indentBase).append(\"Reached level limit\\n\")\n                }\n            }\n        }\n    }\n\n    String getPrettyName(String title, String baseName) {\n        Set<String> baseNameParts = baseName != null ? new HashSet<>(Arrays.asList(baseName.split(\"(?=[A-Z])\"))) : null;\n        StringBuilder prettyName = new StringBuilder()\n        for (String part in entityInfo.internalEntityName.split(\"(?=[A-Z])\")) {\n            if (baseNameParts != null && baseNameParts.contains(part)) continue\n            if (prettyName.length() > 0) prettyName.append(\" \")\n            prettyName.append(part)\n        }\n        if (title) {\n            boolean addParens = prettyName.length() > 0\n            if (addParens) prettyName.append(\" (\")\n            for (String part in title.split(\"(?=[A-Z])\")) prettyName.append(part).append(\" \")\n            prettyName.deleteCharAt(prettyName.length()-1)\n            if (addParens) prettyName.append(\")\")\n        }\n        // make sure pretty name isn't empty, happens when baseName is a superset of entity name\n        if (prettyName.length() == 0) return StringUtilities.camelCaseToPretty(entityInfo.internalEntityName)\n        return prettyName.toString()\n    }\n\n    // used in EntityCache for view entities\n    Map<String, String> getMePkFieldToAliasNameMap(String entityAlias) {\n        if (mePkFieldToAliasNameMapMap == null) mePkFieldToAliasNameMapMap = new HashMap<String, Map<String, String>>()\n        Map<String, String> mePkFieldToAliasNameMap = (Map<String, String>) mePkFieldToAliasNameMapMap.get(entityAlias)\n\n        if (mePkFieldToAliasNameMap != null) return mePkFieldToAliasNameMap\n\n        mePkFieldToAliasNameMap = new HashMap<String, String>()\n\n        // do a reverse map on member-entity pk fields to view-entity aliases\n        MNode memberEntityNode = memberEntityAliasMap.get(entityAlias)\n        EntityDefinition med = this.efi.getEntityDefinition(memberEntityNode.attribute(\"entity-name\"))\n        ArrayList<String> pkFieldNames = med.getPkFieldNames()\n        int pkFieldNamesSize = pkFieldNames.size()\n        for (int pkIdx = 0; pkIdx < pkFieldNamesSize; pkIdx++) {\n            String pkName = (String) pkFieldNames.get(pkIdx)\n\n            MNode matchingAliasNode = entityNode.children(\"alias\").find({\n                it.attribute(\"entity-alias\") == memberEntityNode.attribute(\"entity-alias\") &&\n                (it.attribute(\"field\") == pkName || (!it.attribute(\"field\") && it.attribute(\"name\") == pkName)) })\n            if (matchingAliasNode != null) {\n                // found an alias Node\n                mePkFieldToAliasNameMap.put(pkName, matchingAliasNode.attribute(\"name\"))\n                continue\n            }\n\n            // no alias, try to find in join key-maps that map to other aliased fields\n\n            // first try the current member-entity\n            if (memberEntityNode.attribute(\"join-from-alias\") && memberEntityNode.hasChild(\"key-map\")) {\n                boolean foundOne = false\n                ArrayList<MNode> keyMapList = memberEntityNode.children(\"key-map\")\n                for (MNode keyMapNode in keyMapList) {\n                    String relatedField = keyMapNode.attribute(\"related\") ?: keyMapNode.attribute(\"related-field-name\")\n                    if (relatedField == null || relatedField.isEmpty()) {\n                        if (keyMapList.size() == 1 && pkFieldNamesSize == 1) {\n                            relatedField = pkName\n                        } else {\n                            relatedField = keyMapNode.attribute(\"field-name\")\n                        }\n                    }\n                    if (pkName.equals(relatedField)) {\n                        String relatedPkName = keyMapNode.attribute(\"field-name\")\n                        MNode relatedMatchingAliasNode = entityNode.children(\"alias\").find({\n                            it.attribute(\"entity-alias\") == memberEntityNode.attribute(\"join-from-alias\") &&\n                            (it.attribute(\"field\") == relatedPkName || (!it.attribute(\"field\") && it.attribute(\"name\") == relatedPkName)) })\n                        if (relatedMatchingAliasNode) {\n                            mePkFieldToAliasNameMap.put(pkName, relatedMatchingAliasNode.attribute(\"name\"))\n                            foundOne = true\n                            break\n                        }\n                    }\n                }\n                if (foundOne) continue\n            }\n\n            // then go through all other member-entity that might relate back to this one\n            for (MNode relatedMeNode in entityNode.children(\"member-entity\")) {\n                if (relatedMeNode.attribute(\"join-from-alias\") == entityAlias && relatedMeNode.hasChild(\"key-map\")) {\n                    boolean foundOne = false\n                    for (MNode keyMapNode in relatedMeNode.children(\"key-map\")) {\n                        if (keyMapNode.attribute(\"field-name\") == pkName) {\n                            String relatedPkName = keyMapNode.attribute(\"related\") ?:\n                                    keyMapNode.attribute(\"related-field-name\") ?: keyMapNode.attribute(\"field-name\")\n                            MNode relatedMatchingAliasNode = entityNode.children(\"alias\").find({\n                                it.attribute(\"entity-alias\") == relatedMeNode.attribute(\"entity-alias\") &&\n                                (it.attribute(\"field\") == relatedPkName || (!it.attribute(\"field\") && it.attribute(\"name\") == relatedPkName)) })\n                            if (relatedMatchingAliasNode) {\n                                mePkFieldToAliasNameMap.put(pkName, relatedMatchingAliasNode.attribute(\"name\"))\n                                foundOne = true\n                                break\n                            }\n                        }\n                    }\n                    if (foundOne) break\n                }\n            }\n        }\n\n        if (pkFieldNames.size() != mePkFieldToAliasNameMap.size()) {\n            logger.warn(\"Not all primary-key fields in view-entity [${fullEntityName}] for member-entity [${entityAlias}:${memberEntityNode.attribute(\"entity-name\")}], skipping cache reverse-association, and note that if this record is updated the cache won't automatically clear; pkFieldNames=${pkFieldNames}; partial mePkFieldToAliasNameMap=${mePkFieldToAliasNameMap}\")\n        }\n\n        mePkFieldToAliasNameMapMap.put(entityAlias, mePkFieldToAliasNameMap)\n\n        return mePkFieldToAliasNameMap\n    }\n\n    Object convertFieldString(String name, String value, ExecutionContextImpl eci) {\n        if (value == null) return null\n        FieldInfo fieldInfo = getFieldInfo(name)\n        if (fieldInfo == null) throw new EntityException(\"Invalid field name ${name} for entity ${fullEntityName}\")\n        return fieldInfo.convertFromString(value, eci.l10nFacade)\n    }\n\n    static String getFieldStringForFile(FieldInfo fieldInfo, Object value) {\n        if (value == null) return null\n\n        String outValue\n        if (value instanceof Timestamp) {\n            // use a Long number, no TZ issues\n            outValue = ((Timestamp) value).getTime() as String\n        } else if (value instanceof BigDecimal) {\n            outValue = ((BigDecimal) value).toPlainString()\n        } else {\n            outValue = fieldInfo.convertToString(value)\n        }\n\n        return outValue\n    }\n\n    EntityConditionImplBase makeViewWhereCondition() {\n        if (!isViewEntity || entityConditionNode == null) return (EntityConditionImplBase) null\n        // add the view-entity.entity-condition.econdition(s)\n        return makeViewListCondition(entityConditionNode, null)\n    }\n    EntityConditionImplBase makeViewHavingCondition() {\n        if (!isViewEntity || entityHavingEconditions == null) return (EntityConditionImplBase) null\n        // add the view-entity.entity-condition.having-econditions\n        return makeViewListCondition(entityHavingEconditions, null)\n    }\n\n    protected EntityConditionImplBase makeViewListCondition(MNode conditionsParent, MNode joinMemberEntityNode) {\n        if (conditionsParent == null) return null\n        ExecutionContextImpl eci = efi.ecfi.getEci()\n        EntityDefinition joinEntityDef = joinMemberEntityNode != null ? this.efi.getEntityDefinition(joinMemberEntityNode.attribute(\"entity-name\")) : null\n\n        List<EntityCondition> condList = new ArrayList()\n        for (MNode dateFilter in conditionsParent.children(\"date-filter\")) {\n            ConditionField fromField, thruField\n            String fromFieldName = dateFilter.attribute(\"from-field-name\") ?: \"fromDate\"\n            String thruFieldName = dateFilter.attribute(\"thru-field-name\") ?: \"thruDate\"\n\n            Timestamp validDate = dateFilter.attribute(\"valid-date\") ? efi.ecfi.resourceFacade.expand(dateFilter.attribute(\"valid-date\"), \"\") as Timestamp : null\n            if (validDate == (Timestamp) null) validDate = efi.ecfi.getEci().userFacade.getNowTimestamp()\n\n            String entityAliasAttr = dateFilter.attribute(\"entity-alias\")\n            // if no entity-alias specified, use entity-alias from join member-entity node (if field exists on join entity)\n            if (joinEntityDef != null && (entityAliasAttr == null || entityAliasAttr.isEmpty()) && joinEntityDef.isField(fromFieldName))\n                entityAliasAttr = joinMemberEntityNode.attribute(\"entity-alias\")\n\n            if (entityAliasAttr != null && !entityAliasAttr.isEmpty()) {\n                MNode memberEntity = (MNode) memberEntityAliasMap.get(entityAliasAttr)\n                if (memberEntity == null) throw new EntityException(\"The entity-alias [${entityAliasAttr}] was not found in view-entity [${entityInfo.internalEntityName}]\")\n                EntityDefinition aliasEntityDef = this.efi.getEntityDefinition(memberEntity.attribute(\"entity-name\"))\n                fromField = new ConditionAlias(entityAliasAttr, fromFieldName, aliasEntityDef)\n                thruField = new ConditionAlias(entityAliasAttr, thruFieldName, aliasEntityDef)\n            } else {\n                FieldInfo fromFi = getFieldInfo(fromFieldName)\n                FieldInfo thruFi = getFieldInfo(thruFieldName)\n                if (fromFi == null) throw new EntityException(\"Field ${fromFieldName} not found in entity ${fullEntityName}\")\n                if (thruFi == null) throw new EntityException(\"Field ${thruFieldName} not found in entity ${fullEntityName}\")\n                fromField = fromFi.conditionField\n                thruField = thruFi.conditionField\n            }\n\n            condList.add(new DateCondition(fromField, thruField, validDate))\n        }\n        for (MNode econdition in conditionsParent.children(\"econdition\")) {\n            String fieldNameAttr = econdition.attribute(\"field-name\")\n            ConditionField field\n            EntityConditionImplBase cond\n            EntityDefinition condEd\n\n            String entityAliasAttr = econdition.attribute(\"entity-alias\")\n            // if no entity-alias specified, use entity-alias from join member-entity node (if field exists on join entity)\n            if (joinEntityDef != null && (entityAliasAttr == null || entityAliasAttr.isEmpty()) && joinEntityDef.isField(fieldNameAttr)) {\n                String joinMemberAlias = joinMemberEntityNode.attribute(\"entity-alias\")\n                if (memberEntityAliasMap.containsKey(joinMemberAlias)) {\n                    entityAliasAttr = joinMemberAlias\n                } else {\n                    // special case for entity-condition.econdition under view-entity.member-entity with sub-select=true\n                    //     and when doing lateral joins, because WHERE clause is inside sub-select so should default to member-entity's internal alias\n                    // is the field an alias on this entity? use that entity-alias\n                    MNode aliasNode = this.getFieldNode(fieldNameAttr)\n                    if (aliasNode != null) entityAliasAttr = aliasNode.attribute(\"entity-alias\")\n                }\n            }\n\n            if (entityAliasAttr != null && !entityAliasAttr.isEmpty()) {\n                MNode memberEntity = (MNode) memberEntityAliasMap.get(entityAliasAttr)\n                if (memberEntity == null) throw new EntityException(\"The entity-alias [${entityAliasAttr}] was not found in view-entity [${entityInfo.internalEntityName}]\")\n                EntityDefinition aliasEntityDef = this.efi.getEntityDefinition(memberEntity.attribute(\"entity-name\"))\n                field = new ConditionAlias(entityAliasAttr, fieldNameAttr, aliasEntityDef)\n                condEd = aliasEntityDef\n            } else {\n                FieldInfo fi = getFieldInfo(fieldNameAttr)\n                if (fi == null) throw new EntityException(\"Field ${fieldNameAttr} not found in entity ${fullEntityName}\")\n                field = fi.conditionField\n                condEd = this\n            }\n\n            String toFieldNameAttr = econdition.attribute(\"to-field-name\")\n            if (toFieldNameAttr != null) {\n                String toEntityAliasAttr = econdition.attribute(\"to-entity-alias\")\n                if (joinEntityDef != null && (toEntityAliasAttr == null || toEntityAliasAttr.isEmpty()) && joinEntityDef.isField(toFieldNameAttr))\n                    toEntityAliasAttr = joinMemberEntityNode.attribute(\"entity-alias\")\n\n                ConditionField toField\n                if (toEntityAliasAttr != null && !toEntityAliasAttr.isEmpty()) {\n                    MNode memberEntity = (MNode) memberEntityAliasMap.get(toEntityAliasAttr)\n                    if (memberEntity == null) throw new EntityException(\"The entity-alias [${toEntityAliasAttr}] was not found in view-entity [${entityInfo.internalEntityName}]\")\n                    EntityDefinition aliasEntityDef = this.efi.getEntityDefinition(memberEntity.attribute(\"entity-name\"))\n                    toField = new ConditionAlias(toEntityAliasAttr, toFieldNameAttr, aliasEntityDef)\n                } else {\n                    FieldInfo fi = getFieldInfo(toFieldNameAttr)\n                    if (fi == null) throw new EntityException(\"Field ${toFieldNameAttr} not found in entity ${fullEntityName}\")\n                    toField = fi.conditionField\n                }\n                cond = new FieldToFieldCondition(field, EntityConditionFactoryImpl.getComparisonOperator(econdition.attribute(\"operator\")), toField)\n            } else {\n                // NOTE: may need to convert value from String to object for field\n                String condValue = econdition.attribute(\"value\") ?: null\n                // NOTE: only expand if contains \"${\", expanding normal strings does l10n and messes up key values; hopefully this won't result in a similar issue\n                if (condValue && condValue.contains(\"\\${\")) condValue = efi.ecfi.resourceFacade.expand(condValue, \"\") as String\n                Object condValueObj = condEd.convertFieldString(field.fieldName, condValue, eci);\n                cond = new FieldValueCondition(field, EntityConditionFactoryImpl.getComparisonOperator(econdition.attribute(\"operator\")), condValueObj)\n            }\n            if (cond != null) {\n                if (\"true\".equals(econdition.attribute(\"ignore-case\"))) cond.ignoreCase()\n\n                if (\"true\".equals(econdition.attribute(\"or-null\"))) {\n                    cond = (EntityConditionImplBase) this.efi.conditionFactory.makeCondition(cond, JoinOperator.OR,\n                            new FieldValueCondition(field, EntityCondition.EQUALS, null))\n                }\n\n                condList.add(cond)\n            }\n        }\n        for (MNode econditions in conditionsParent.children(\"econditions\")) {\n            EntityConditionImplBase cond = this.makeViewListCondition(econditions, joinMemberEntityNode)\n            if (cond) condList.add(cond)\n        }\n        if (condList == null || condList.size() == 0) return null\n        if (condList.size() == 1) return (EntityConditionImplBase) condList.get(0)\n        JoinOperator op = \"or\".equals(conditionsParent.attribute(\"combine\")) ? JoinOperator.OR : JoinOperator.AND\n        EntityConditionImplBase entityCondition = (EntityConditionImplBase) this.efi.conditionFactory.makeCondition(condList, op)\n        // logger.info(\"============== In makeViewListCondition for entity [${entityName}] resulting entityCondition: ${entityCondition}\")\n        return entityCondition\n    }\n\n    Cache<EntityCondition, EntityValueBase> internalCacheOne = null\n    Cache<EntityCondition, Set<EntityCondition>> internalCacheOneRa = null\n    Cache<EntityCondition, Set<EntityCache.ViewRaKey>> getCacheOneViewRa = null\n    Cache<EntityCondition, EntityListImpl> internalCacheList = null\n    Cache<EntityCondition, Set<EntityCondition>> internalCacheListRa = null\n    Cache<EntityCondition, Set<EntityCache.ViewRaKey>> internalCacheListViewRa = null\n    Cache<EntityCondition, Long> internalCacheCount = null\n\n    Cache<EntityCondition, EntityValueBase> getCacheOne(EntityCache ec) {\n        if (internalCacheOne == null) internalCacheOne = ec.cfi.getCache(ec.oneKeyBase.concat(fullEntityName))\n        return internalCacheOne\n    }\n    Cache<EntityCondition, Set<EntityCondition>> getCacheOneRa(EntityCache ec) {\n        if (internalCacheOneRa == null) internalCacheOneRa = ec.cfi.getCache(ec.oneRaKeyBase.concat(fullEntityName))\n        return internalCacheOneRa\n    }\n    Cache<EntityCondition, Set<EntityCache.ViewRaKey>> getCacheOneViewRa(EntityCache ec) {\n        if (getCacheOneViewRa == null) getCacheOneViewRa = ec.cfi.getCache(ec.oneViewRaKeyBase.concat(fullEntityName))\n        return getCacheOneViewRa\n    }\n\n    Cache<EntityCondition, EntityListImpl> getCacheList(EntityCache ec) {\n        if (internalCacheList == null) internalCacheList = ec.cfi.getCache(ec.listKeyBase.concat(fullEntityName))\n        return internalCacheList\n    }\n    Cache<EntityCondition, Set<EntityCondition>> getCacheListRa(EntityCache ec) {\n        if (internalCacheListRa == null) internalCacheListRa = ec.cfi.getCache(ec.listRaKeyBase.concat(fullEntityName))\n        return internalCacheListRa\n    }\n    Cache<EntityCondition, Set<EntityCache.ViewRaKey>> getCacheListViewRa(EntityCache ec) {\n        if (internalCacheListViewRa == null) internalCacheListViewRa = ec.cfi.getCache(ec.listViewRaKeyBase.concat(fullEntityName))\n        return internalCacheListViewRa\n    }\n\n    Cache<EntityCondition, Long> getCacheCount(EntityCache ec) {\n        if (internalCacheCount == null) internalCacheCount = ec.cfi.getCache(ec.countKeyBase.concat(fullEntityName))\n        return internalCacheCount\n    }\n\n    boolean tableExistsDbMetaOnly() {\n        if (tableExistVerified) return true\n        tableExistVerified = efi.getEntityDbMeta().tableExists(this)\n        return tableExistVerified\n    }\n\n    // these methods used by EntityFacadeImpl to avoid redundant lookups of entity info\n    EntityFind makeEntityFind() {\n        if (entityInfo.isEntityDatasourceFactoryImpl) {\n            return new EntityFindImpl(efi, this)\n        } else {\n            return entityInfo.datasourceFactory.makeEntityFind(fullEntityName)\n        }\n    }\n    EntityValue makeEntityValue() {\n        if (entityInfo.isEntityDatasourceFactoryImpl) {\n            return new EntityValueImpl(this, efi)\n        } else {\n            return entityInfo.datasourceFactory.makeEntityValue(fullEntityName)\n        }\n    }\n\n    @Override\n    int hashCode() { return this.fullEntityName.hashCode() }\n\n    @Override\n    boolean equals(Object o) {\n        if (o == null || o.getClass() != this.getClass()) return false\n        EntityDefinition that = (EntityDefinition) o\n        if (!this.fullEntityName.equals(that.fullEntityName)) return false\n        return true\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityDynamicViewImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity\n\nimport groovy.transform.CompileStatic\nimport org.moqui.entity.EntityDynamicView\nimport org.moqui.entity.EntityException\nimport org.moqui.util.MNode\n\n@CompileStatic\nclass EntityDynamicViewImpl implements EntityDynamicView {\n\n    protected EntityFacadeImpl efi\n\n    protected String entityName = \"DynamicView\"\n    protected MNode entityNode = new MNode(\"view-entity\", [\"package\":\"dynamic\", \"entity-name\":\"DynamicView\", \"is-dynamic-view\":\"true\"])\n\n    EntityDynamicViewImpl(EntityFindImpl entityFind) { this.efi = entityFind.efi }\n    EntityDynamicViewImpl(EntityFacadeImpl efi) { this.efi = efi }\n\n    EntityDefinition makeEntityDefinition() {\n        // System.out.println(\"========= MNode:\\n${entityNode.toString()}\")\n        return new EntityDefinition(efi, entityNode)\n    }\n\n    @Override\n    EntityDynamicView setEntityName(String entityName) {\n        entityNode.attributes.put(\"entity-name\", entityName)\n        return this\n    }\n\n    @Override\n    EntityDynamicView addMemberEntity(String entityAlias, String entityName, String joinFromAlias, Boolean joinOptional,\n                                      Map<String, String> entityKeyMaps) {\n        MNode memberEntity = entityNode.append(\"member-entity\", [\"entity-alias\":entityAlias, \"entity-name\":entityName])\n        if (joinFromAlias) {\n            memberEntity.attributes.put(\"join-from-alias\", joinFromAlias)\n            memberEntity.attributes.put(\"join-optional\", (joinOptional ? \"true\" : \"false\"))\n        }\n        if (entityKeyMaps) for (Map.Entry<String, String> keyMapEntry in entityKeyMaps.entrySet()) {\n            memberEntity.append(\"key-map\", [\"field-name\":keyMapEntry.getKey(), \"related\":keyMapEntry.getValue()])\n        }\n        return this\n    }\n\n    @Override\n    EntityDynamicView addRelationshipMember(String entityAlias, String joinFromAlias, String relationshipName,\n                                            Boolean joinOptional) {\n        MNode joinFromMemberEntityNode =\n                entityNode.first({ MNode it -> it.name == \"member-entity\" && it.attribute(\"entity-alias\") == joinFromAlias })\n        String entityName = joinFromMemberEntityNode.attribute(\"entity-name\")\n        EntityDefinition joinFromEd = efi.getEntityDefinition(entityName)\n        EntityJavaUtil.RelationshipInfo relInfo = joinFromEd.getRelationshipInfo(relationshipName)\n        if (relInfo == null) throw new EntityException(\"Relationship not found with name [${relationshipName}] on entity [${entityName}]\")\n\n        Map<String, String> relationshipKeyMap = relInfo.keyMap\n        MNode memberEntity = entityNode.append(\"member-entity\", [\"entity-alias\":entityAlias, \"entity-name\":relInfo.relatedEntityName])\n        memberEntity.attributes.put(\"join-from-alias\", joinFromAlias)\n        memberEntity.attributes.put(\"join-optional\", (joinOptional ? \"true\" : \"false\"))\n        for (Map.Entry<String, String> keyMapEntry in relationshipKeyMap.entrySet()) {\n            memberEntity.append(\"key-map\", [\"field-name\":keyMapEntry.getKey(), \"related\":keyMapEntry.getValue()])\n        }\n        if (relInfo.keyValueMap != null && relInfo.keyValueMap.size() > 0) {\n            Map<String, String> keyValueMap = relInfo.keyValueMap\n            MNode entityCondition = memberEntity.append(\"entity-condition\", null)\n            for (Map.Entry<String, String> keyValueEntry: keyValueMap.entrySet()) {\n                entityCondition.append(\"econdition\",\n                        ['entity-alias': entityAlias, 'field-name': keyValueEntry.getKey(), 'value': keyValueEntry.getValue()])\n            }\n        }\n        return this\n    }\n\n    MNode getViewEntityNode() { return entityNode }\n\n    @Override List<MNode> getMemberEntityNodes() { return entityNode.children(\"member-entity\") }\n\n    @Override\n    EntityDynamicView addAliasAll(String entityAlias, String prefix) {\n        entityNode.append(\"alias-all\", [\"entity-alias\":entityAlias, \"prefix\":prefix])\n        return this\n    }\n\n    @Override\n    EntityDynamicView addAlias(String entityAlias, String name) {\n        entityNode.append(\"alias\", [\"entity-alias\":entityAlias, \"name\":name])\n        return this\n    }\n    @Override\n    EntityDynamicView addAlias(String entityAlias, String name, String field, String function) {\n        return addAlias(entityAlias, name, field, function, null)\n    }\n    EntityDynamicView addAlias(String entityAlias, String name, String field, String function, String defaultDisplay) {\n        MNode aNode = entityNode.append(\"alias\", [\"entity-alias\":entityAlias, name:name])\n        if (field != null && !field.isEmpty()) aNode.attributes.put(\"field\", field)\n        if (function != null && !function.isEmpty()) aNode.attributes.put(\"function\", function)\n        if (defaultDisplay != null && !defaultDisplay.isEmpty()) aNode.attributes.put(\"default-display\", defaultDisplay)\n        return this\n    }\n    EntityDynamicView addPqExprAlias(String name, String pqExpression, String type, String defaultDisplay) {\n        MNode aNode = entityNode.append(\"alias\", [name:name, \"pq-expression\":pqExpression, type:(type ?: \"text-long\")])\n        if (defaultDisplay != null && !defaultDisplay.isEmpty()) aNode.attributes.put(\"default-display\", defaultDisplay)\n        return this\n    }\n    MNode getAlias(String name) { return entityNode.first(\"alias\", \"name\", name) }\n\n    @Override\n    EntityDynamicView addRelationship(String type, String title, String relatedEntityName, Map<String, String> entityKeyMaps) {\n        MNode viewLink = entityNode.append(\"relationship\", [\"type\":type, \"title\":title, \"related\":relatedEntityName])\n        for (Map.Entry<String, String> keyMapEntry in entityKeyMaps.entrySet()) {\n            viewLink.append(\"key-map\", [\"field-name\":keyMapEntry.getKey(), \"related\":keyMapEntry.getValue()])\n        }\n        return this\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityEcaRule.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity\n\nimport groovy.transform.CompileStatic\nimport org.moqui.impl.actions.XmlAction\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.entity.EntityFind\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.util.MNode\nimport org.moqui.util.StringUtilities\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n@CompileStatic\nclass EntityEcaRule {\n    protected final static Logger logger = LoggerFactory.getLogger(EntityEcaRule.class)\n\n    protected ExecutionContextFactoryImpl ecfi\n    protected MNode eecaNode\n    protected String location\n\n    protected XmlAction condition = null\n    protected XmlAction actions = null\n\n    EntityEcaRule(ExecutionContextFactoryImpl ecfi, MNode eecaNode, String location) {\n        this.ecfi = ecfi\n        this.eecaNode = eecaNode\n        this.location = location\n\n        // prep condition\n        if (eecaNode.hasChild(\"condition\") && eecaNode.first(\"condition\").children) {\n            // the script is effectively the first child of the condition element\n            condition = new XmlAction(ecfi, eecaNode.first(\"condition\").children.get(0), location + \".condition\")\n        }\n        // prep actions\n        if (eecaNode.hasChild(\"actions\")) {\n            String actionsLocation = null\n            String eecaId = eecaNode.attribute(\"id\")\n            if (eecaId != null && !eecaId.isEmpty()) actionsLocation = eecaId + \"_\" + StringUtilities.getRandomString(8)\n            actions = new XmlAction(ecfi, eecaNode.first(\"actions\"), actionsLocation) // was location + \".actions\" but not unique!\n        }\n    }\n\n    String getEntityName() { return eecaNode.attribute(\"entity\") }\n    MNode getEecaNode() { return eecaNode }\n\n    void runIfMatches(String entityName, Map fieldValues, String operation, boolean before, ExecutionContextImpl ec) {\n        // see if we match this event and should run\n\n        // check this first since it is the most common disqualifier\n        String attrName = \"on-\".concat(operation)\n        if (!\"true\".equals(eecaNode.attribute(attrName))) return\n\n        if (!entityName.equals(eecaNode.attribute(\"entity\"))) return\n        if (ec.messageFacade.hasError() && !\"true\".equals(eecaNode.attribute(\"run-on-error\"))) return\n\n        EntityValue curValue = null\n\n        boolean isDelete = \"delete\".equals(operation)\n        boolean isUpdate = !isDelete && \"update\".equals(operation)\n\n        // grab DB values before a delete so they are available after; this modifies fieldValues used by EntityValueBase\n        if (before && isDelete && eecaNode.attribute(\"get-entire-entity\") == \"true\") {\n            // fill in any missing (unset) values from the DB\n            if (curValue == null) curValue = getDbValue(fieldValues)\n            if (curValue != null) {\n                // only add fields that fieldValues does not contain\n                for (Map.Entry entry in curValue.entrySet())\n                    if (!fieldValues.containsKey(entry.getKey())) fieldValues.put(entry.getKey(), entry.getValue())\n            }\n        }\n\n        // do this before even if EECA rule runs after to get the original value from the DB and put in the entity's dbValue Map\n        EntityValue originalValue = null\n        if (before && (isUpdate || isDelete) && \"true\".equals(eecaNode.attribute(\"get-original-value\"))) {\n            if (curValue == null) curValue = getDbValue(fieldValues)\n            if (curValue != null) {\n                originalValue = curValue\n                // also put DB values in the fieldValues EntityValue if it isn't from DB (to have for future reference)\n                if (fieldValues instanceof EntityValueBase && !((EntityValueBase) fieldValues).getIsFromDb()) {\n                    // NOTE: fresh from the DB the valueMap will have clean values and the dbValueMap will be null\n                    ((EntityValueBase) fieldValues).setDbValueMap(((EntityValueBase) originalValue).getValueMap())\n                }\n            }\n        }\n\n        if (before && !\"true\".equals(eecaNode.attribute(\"run-before\"))) return\n        if (!before && \"true\".equals(eecaNode.attribute(\"run-before\"))) return\n\n        // now if we're running after the entity operation, pull the original value from the\n        if (!before && fieldValues instanceof EntityValueBase && ((EntityValueBase) fieldValues).getIsFromDb() &&\n                (isUpdate || isDelete) && eecaNode.attribute(\"get-original-value\") == \"true\") {\n            originalValue = ((EntityValueBase) fieldValues).cloneDbValue(true)\n        }\n\n        if ((isUpdate || isDelete) && eecaNode.attribute(\"get-entire-entity\") == \"true\") {\n            // fill in any missing (unset) values from the DB\n            if (curValue == null) curValue = getDbValue(fieldValues)\n            if (curValue != null) {\n                // only add fields that fieldValues does not contain\n                for (Map.Entry entry in curValue.entrySet())\n                    if (!fieldValues.containsKey(entry.getKey())) fieldValues.put(entry.getKey(), entry.getValue())\n            }\n        }\n\n        try {\n            Map<String, Object> contextMap = new HashMap<>()\n            ec.contextStack.push(contextMap)\n            ec.contextStack.putAll(fieldValues)\n            ec.contextStack.put(\"entityValue\", fieldValues)\n            ec.contextStack.put(\"originalValue\", originalValue)\n            ec.contextStack.put(\"eecaOperation\", operation)\n\n            // run the condition and if passes run the actions\n            boolean conditionPassed = true\n            if (condition != null) conditionPassed = condition.checkCondition(ec)\n            if (conditionPassed && actions != null) {\n                Object result = actions.run(ec)\n\n                // if anything was set in the context that matches a field name set it on the EntityValue\n                if (\"true\".equals(eecaNode.attribute(\"set-results\"))) {\n                    Map resultMap\n                    if (result instanceof Map) {\n                        resultMap = (Map<String, Object>) result\n                    } else {\n                        resultMap = contextMap\n                    }\n\n                    if (resultMap != null && resultMap.size() > 0) {\n                        EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(entityName)\n                        ArrayList<String> fieldNames = ed.getNonPkFieldNames()\n                        int fieldNamesSize = fieldNames.size()\n                        for (int i = 0; i < fieldNamesSize; i++) {\n                            String fieldName = (String) fieldNames.get(i)\n                            if (resultMap.containsKey(fieldName)) fieldValues.put(fieldName, resultMap.get(fieldName))\n                        }\n                    }\n                }\n            }\n        } finally {\n            ec.contextStack.pop()\n        }\n    }\n\n    EntityValue getDbValue(Map fieldValues) {\n        EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(entityName)\n        EntityFind ef = ecfi.entity.find(entityName)\n        for (String pkFieldName in ed.getPkFieldNames()) ef.condition(pkFieldName, fieldValues.get(pkFieldName))\n        return ef.one()\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityFacadeImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity\n\nimport groovy.transform.CompileStatic\nimport org.codehaus.groovy.runtime.typehandling.GroovyCastException\nimport org.moqui.BaseArtifactException\nimport org.moqui.BaseException\nimport org.moqui.context.ArtifactExecutionInfo\nimport org.moqui.etl.SimpleEtl\nimport org.moqui.impl.context.ArtifactExecutionInfoImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.impl.entity.condition.EntityConditionImplBase\nimport org.moqui.impl.entity.condition.FieldValueCondition\nimport org.moqui.impl.entity.condition.ListCondition\nimport org.moqui.impl.service.runner.EntityAutoServiceRunner\nimport org.moqui.resource.ResourceReference\nimport org.moqui.entity.*\nimport org.moqui.impl.context.ArtifactExecutionFacadeImpl\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.TransactionFacadeImpl\nimport org.moqui.impl.entity.EntityJavaUtil.RelationshipInfo\nimport org.moqui.util.CollectionUtilities\nimport org.moqui.util.LiteStringMap\nimport org.moqui.util.MNode\nimport org.moqui.util.ObjectUtilities\nimport org.moqui.util.StringUtilities\nimport org.moqui.util.SystemBinding\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\nimport org.w3c.dom.Element\n\nimport javax.cache.Cache\nimport javax.sql.DataSource\nimport javax.sql.XAConnection\nimport javax.sql.XADataSource\nimport java.math.RoundingMode\nimport java.sql.*\nimport java.util.concurrent.ArrayBlockingQueue\nimport java.util.concurrent.BlockingQueue\nimport java.util.concurrent.ConcurrentHashMap\nimport java.util.concurrent.ThreadFactory\nimport java.util.concurrent.ThreadLocalRandom\nimport java.util.concurrent.ThreadPoolExecutor\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.atomic.AtomicInteger\nimport java.util.concurrent.locks.Lock\nimport java.util.concurrent.locks.ReentrantLock\n\n@CompileStatic\nclass EntityFacadeImpl implements EntityFacade {\n    protected final static Logger logger = LoggerFactory.getLogger(EntityFacadeImpl.class)\n    protected final static boolean isTraceEnabled = logger.isTraceEnabled()\n\n    public final ExecutionContextFactoryImpl ecfi\n    public final EntityConditionFactoryImpl entityConditionFactory\n\n    protected final HashMap<String, EntityDatasourceFactory> datasourceFactoryByGroupMap = new HashMap()\n\n    /** Cache with entity name as the key and an EntityDefinition as the value; clear this cache to reload entity def */\n    final Cache<String, EntityDefinition> entityDefinitionCache\n    /** Cache with single entry so can be expired/cleared, contains Map with entity name as the key and List of file\n     * location Strings as the value */\n    final Cache<String, Map<String, List<String>>> entityLocationSingleCache\n    static final String entityLocSingleEntryName = \"ALL_ENTITIES\"\n    /** Map for framework entity definitions, avoid cache overhead and timeout issues */\n    final HashMap<String, EntityDefinition> frameworkEntityDefinitions = new HashMap<>()\n\n    /** Sequence name (often entity name) is the key and the value is an array of 2 Longs the first is the next\n     * available value and the second is the highest value reserved/cached in the bank. */\n    final Cache<String, long[]> entitySequenceBankCache\n    protected final ConcurrentHashMap<String, Lock> dbSequenceLocks = new ConcurrentHashMap<String, Lock>()\n    protected final ReentrantLock locationLoadLock = new ReentrantLock()\n\n    protected HashMap<String, ArrayList<EntityEcaRule>> eecaRulesByEntityName = new HashMap<>()\n    protected final HashMap<String, String> entityGroupNameMap = new HashMap<>()\n    protected final HashMap<String, MNode> databaseNodeByGroupName = new HashMap<>()\n    protected final HashMap<String, MNode> datasourceNodeByGroupName = new HashMap<>()\n    protected final String defaultGroupName\n    protected final TimeZone databaseTimeZone\n    protected final Locale databaseLocale\n    protected final ThreadLocal<Calendar> databaseTzLcCalendar = new ThreadLocal<>()\n    protected final String sequencedIdPrefix\n    boolean queryStats = false\n\n    protected EntityDbMeta dbMeta = null\n    protected final EntityCache entityCache\n    protected final EntityDataFeed entityDataFeed\n    protected final EntityDataDocument entityDataDocument\n\n    protected final EntityListImpl emptyList\n\n    private static class ExecThreadFactory implements ThreadFactory {\n        private final ThreadGroup workerGroup = new ThreadGroup(\"MoquiEntityExec\")\n        private final AtomicInteger threadNumber = new AtomicInteger(1)\n        Thread newThread(Runnable r) { return new Thread(workerGroup, r, \"MoquiEntityExec-\" + threadNumber.getAndIncrement()) }\n    }\n    protected BlockingQueue<Runnable> statementWorkQueue = new ArrayBlockingQueue<>(1024);\n    protected ThreadPoolExecutor statementExecutor = new ThreadPoolExecutor(5, 100, 60, TimeUnit.SECONDS, statementWorkQueue, new ExecThreadFactory());\n\n    EntityFacadeImpl(ExecutionContextFactoryImpl ecfi) {\n        this.ecfi = ecfi\n        entityConditionFactory = new EntityConditionFactoryImpl(this)\n\n        MNode entityFacadeNode = getEntityFacadeNode()\n        entityFacadeNode.setSystemExpandAttributes(true)\n        defaultGroupName = entityFacadeNode.attribute(\"default-group-name\")\n        sequencedIdPrefix = entityFacadeNode.attribute(\"sequenced-id-prefix\") ?: null\n        queryStats = entityFacadeNode.attribute(\"query-stats\") == \"true\"\n\n        TimeZone theTimeZone = null\n        if (entityFacadeNode.attribute(\"database-time-zone\")) {\n            try {\n                theTimeZone = TimeZone.getTimeZone((String) entityFacadeNode.attribute(\"database-time-zone\"))\n            } catch (Exception e) { logger.warn(\"Error parsing database-time-zone: ${e.toString()}\") }\n        }\n        databaseTimeZone = theTimeZone != null ? theTimeZone : TimeZone.getDefault()\n        logger.info(\"Database time zone is ${databaseTimeZone}\")\n        Locale theLocale = null\n        if (entityFacadeNode.attribute(\"database-locale\")) {\n            try {\n                String localeStr = entityFacadeNode.attribute(\"database-locale\")\n                if (localeStr) theLocale = localeStr.contains(\"_\") ?\n                        new Locale(localeStr.substring(0, localeStr.indexOf(\"_\")), localeStr.substring(localeStr.indexOf(\"_\")+1).toUpperCase()) :\n                        new Locale(localeStr)\n            } catch (Exception e) { logger.warn(\"Error parsing database-locale: ${e.toString()}\") }\n        }\n        databaseLocale = theLocale ?: Locale.getDefault()\n\n        // init entity meta-data\n        entityDefinitionCache = ecfi.cacheFacade.getCache(\"entity.definition\")\n        entityLocationSingleCache = ecfi.cacheFacade.getCache(\"entity.location\")\n        // NOTE: don't try to load entity locations before constructor is complete; this.loadAllEntityLocations()\n        entitySequenceBankCache = ecfi.cacheFacade.getCache(\"entity.sequence.bank\")\n\n        // init connection pool (DataSource) for each group\n        initAllDatasources()\n\n        entityCache = new EntityCache(this)\n        entityDataFeed = new EntityDataFeed(this)\n        entityDataDocument = new EntityDataDocument(this)\n\n        emptyList = new EntityListImpl(this)\n        emptyList.setFromCache()\n    }\n    void postFacadeInit() {\n        // ========== load a few things in advance so first page hit is faster in production (in dev mode will reload anyway as caches timeout)\n        // load entity definitions\n        logger.info(\"Loading entity definitions\")\n        long entityStartTime = System.currentTimeMillis()\n        loadAllEntityLocations()\n        int entityCount = loadAllEntityDefinitions()\n        // don't always load/warm framework entities, in production warms anyway and in dev not needed: entityFacade.loadFrameworkEntities()\n        logger.info(\"Loaded ${entityCount} entity definitions in ${System.currentTimeMillis() - entityStartTime}ms\")\n\n        // now that everything is started up, if configured check all entity tables\n        checkInitDatasourceTables()\n\n        // EECA rule tables\n        loadEecaRulesAll()\n    }\n\n    void destroy() {\n        Set<String> groupNames = this.datasourceFactoryByGroupMap.keySet()\n        for (String groupName in groupNames) {\n            EntityDatasourceFactory edf = this.datasourceFactoryByGroupMap.get(groupName)\n            this.datasourceFactoryByGroupMap.put(groupName, null)\n            edf.destroy()\n        }\n\n        if (statementExecutor != null) {\n            statementExecutor.shutdown()\n            statementExecutor.awaitTermination(5, TimeUnit.SECONDS)\n        }\n    }\n\n    EntityCache getEntityCache() { return entityCache }\n    EntityDataFeed getEntityDataFeed() { return entityDataFeed }\n    EntityDataDocument getEntityDataDocument() { return entityDataDocument }\n    String getDefaultGroupName() { return defaultGroupName }\n\n    // NOTE: used in scripts, etc\n    TimeZone getDatabaseTimeZone() { return databaseTimeZone }\n    Locale getDatabaseLocale() { return databaseLocale }\n\n    EntityListImpl getEmptyList() { return emptyList }\n\n    @Override\n    Calendar getCalendarForTzLc() {\n        // the OLD approach using user's TimeZone/Locale, bad idea because user may change for same record, getting different value, etc\n        // return efi.getEcfi().getExecutionContext().getUser().getCalendarForTzLcOnly()\n\n        // the safest approach but from profiling tests this is VERY slow\n        // return Calendar.getInstance(databaseTimeZone, databaseLocale)\n        // NOTE: this approach is faster but seems to cause errors with Derby (ERROR 22007: The string representation of a date/time value is out of range)\n        // return databaseTzLcCalendar // NOTE this field was a Calendar object, is now a ThreadLocal<Calendar>\n\n        // latest approach to avoid creating a Calendar object for each use, use a ThreadLocal field\n        Calendar dbCal = databaseTzLcCalendar.get()\n        if (dbCal == null) {\n            dbCal = Calendar.getInstance(databaseTimeZone, databaseLocale)\n            dbCal.clear()\n            databaseTzLcCalendar.set(dbCal)\n        } else {\n            dbCal.clear()\n        }\n        return dbCal\n    }\n\n    MNode getEntityFacadeNode() { return ecfi.getConfXmlRoot().first(\"entity-facade\") }\n    void checkInitDatasourceTables() {\n        // if startup-add-missing=true check tables now\n        long currentTime = System.currentTimeMillis()\n\n        Set<String> startupAddMissingGroups = new TreeSet<>()\n        Set<String> allConfiguredGroups = new TreeSet<>()\n        for (MNode datasourceNode in getEntityFacadeNode().children(\"datasource\")) {\n            String groupName = datasourceNode.attribute(\"group-name\")\n            MNode databaseNode = getDatabaseNode(groupName)\n            String startupAddMissing = datasourceNode.attribute(\"startup-add-missing\")\n            if ((!startupAddMissing && \"true\".equals(databaseNode.attribute(\"default-startup-add-missing\"))) || \"true\".equals(startupAddMissing)) {\n                startupAddMissingGroups.add(groupName)\n            }\n            allConfiguredGroups.add(groupName)\n        }\n\n        boolean defaultStartAddMissing = startupAddMissingGroups.contains(getEntityFacadeNode().attribute(\"default-group-name\"))\n        if (startupAddMissingGroups.size() > 0) {\n            logger.info(\"Checking tables for entities in groups ${startupAddMissingGroups}\")\n\n            // check and create all tables\n            boolean createdTables = false\n            for (String groupName in startupAddMissingGroups) {\n                EntityDatasourceFactory edf = getDatasourceFactory(groupName)\n                edf.checkAndAddAllTables()\n            }\n            /* old one at a time approach:\n            for (String entityName in getAllEntityNames()) {\n                String groupName = getEntityGroupName(entityName) ?: defaultGroupName\n                if (startupAddMissingGroups.contains(groupName) ||\n                        (!allConfiguredGroups.contains(groupName) && defaultStartAddMissing)) {\n                    EntityDatasourceFactory edf = getDatasourceFactory(groupName)\n                    if (edf.checkAndAddTable(entityName)) createdTables = true\n                }\n            }\n\n            // do second pass to make sure all FKs created\n            if (createdTables) {\n                logger.info(\"Tables were created, checking FKs for all entities in groups ${startupAddMissingGroups}\")\n                for (String entityName in getAllEntityNames()) {\n                    String groupName = getEntityGroupName(entityName) ?: defaultGroupName\n                    if (startupAddMissingGroups.contains(groupName) ||\n                            (!allConfiguredGroups.contains(groupName) && defaultStartAddMissing)) {\n                        EntityDatasourceFactory edf = getDatasourceFactory(groupName)\n                        if (edf instanceof EntityDatasourceFactoryImpl) {\n                            EntityDefinition ed = getEntityDefinition(entityName)\n                            if (ed.isViewEntity) continue\n                            getEntityDbMeta().createForeignKeys(ed, true)\n\n                        }\n                    }\n                }\n            }\n            */\n\n            logger.info(\"Checked tables for all entities in ${(System.currentTimeMillis() - currentTime)/1000} seconds\")\n        }\n    }\n\n    protected void initAllDatasources() {\n        for (MNode datasourceNode in getEntityFacadeNode().children(\"datasource\")) {\n            datasourceNode.setSystemExpandAttributes(true)\n            String groupName = datasourceNode.attribute(\"group-name\")\n\n            if (\"true\".equals(datasourceNode.attribute(\"disabled\"))) {\n                logger.info(\"Skipping disabled datasource ${groupName}\")\n                continue\n            }\n\n            String objectFactoryClass = datasourceNode.attribute(\"object-factory\") ?: \"org.moqui.impl.entity.EntityDatasourceFactoryImpl\"\n            EntityDatasourceFactory edf = (EntityDatasourceFactory) Thread.currentThread().getContextClassLoader().loadClass(objectFactoryClass).newInstance()\n            datasourceFactoryByGroupMap.put(groupName, edf.init(this, datasourceNode))\n        }\n    }\n\n    static class DatasourceInfo {\n        EntityFacadeImpl efi\n        MNode datasourceNode\n        String uniqueName\n        Map<String, String> dsDetails = new LinkedHashMap<>()\n\n        String jndiName\n        MNode serverJndi\n        String jdbcDriver = null, jdbcUri = null, jdbcUsername = null, jdbcPassword = null\n        String xaDsClass = null\n        Properties xaProps = null\n\n        MNode inlineJdbc = null\n        MNode database = null\n\n        DatasourceInfo(EntityFacadeImpl efi, MNode datasourceNode) {\n            this.efi = efi\n            this.datasourceNode = datasourceNode\n\n            String groupName = datasourceNode.attribute(\"group-name\")\n            uniqueName =  groupName + \"_DS\"\n\n            MNode jndiJdbcNode = datasourceNode.first(\"jndi-jdbc\")\n            inlineJdbc = datasourceNode.first(\"inline-jdbc\")\n            if (jndiJdbcNode == null && inlineJdbc == null) {\n                MNode dbNode = efi.getDatabaseNode(groupName)\n                inlineJdbc = dbNode.first(\"inline-jdbc\")\n            }\n            MNode xaProperties = inlineJdbc?.first(\"xa-properties\")\n            database = efi.getDatabaseNode(groupName)\n\n            if (jndiJdbcNode != null) {\n                serverJndi = efi.getEntityFacadeNode().first(\"server-jndi\")\n                if (serverJndi != null) serverJndi.setSystemExpandAttributes(true)\n                jndiName = jndiJdbcNode.attribute(\"jndi-name\")\n            } else if (xaProperties != null) {\n                xaDsClass = inlineJdbc.attribute(\"xa-ds-class\") ? inlineJdbc.attribute(\"xa-ds-class\") : database.attribute(\"default-xa-ds-class\")\n\n                xaProps = new Properties()\n                xaProperties.setSystemExpandAttributes(true)\n                for (String key in xaProperties.attributes.keySet()) {\n                    if (xaProps.containsKey(key)) continue\n                    // various H2, Derby, etc properties have a ${moqui.runtime} which is a System property, others may have it too\n                    String propValue = xaProperties.attribute(key)\n                    if (propValue) xaProps.setProperty(key, propValue)\n                }\n\n                for (String propName in xaProps.stringPropertyNames()) {\n                    if (propName.toLowerCase().contains(\"password\")) continue\n                    dsDetails.put(propName, xaProps.getProperty(propName))\n                }\n            } else if (inlineJdbc != null) {\n                inlineJdbc.setSystemExpandAttributes(true)\n                jdbcDriver = inlineJdbc.attribute(\"jdbc-driver\") ? inlineJdbc.attribute(\"jdbc-driver\") : database.attribute(\"default-jdbc-driver\")\n                jdbcUri = inlineJdbc.attribute(\"jdbc-uri\")\n                if (jdbcUri.contains('${')) jdbcUri = SystemBinding.expand(jdbcUri)\n                jdbcUsername = inlineJdbc.attribute(\"jdbc-username\")\n                jdbcPassword = inlineJdbc.attribute(\"jdbc-password\")\n\n                dsDetails.put(\"uri\", jdbcUri)\n                dsDetails.put(\"user\", jdbcUsername)\n            } else {\n                throw new EntityException(\"Data source for group ${groupName} has no inline-jdbc or jndi-jdbc configuration\")\n            }\n        }\n    }\n\n    void loadFrameworkEntities() {\n        // load framework entity definitions (moqui.*)\n        long startTime = System.currentTimeMillis()\n        Set<String> entityNames = getAllEntityNames()\n        int entityCount = 0\n        for (String entityName in entityNames) {\n            if (entityName.startsWith(\"moqui.\")) {\n                entityCount++\n                try {\n                    EntityDefinition ed = getEntityDefinition(entityName)\n                    ed.getRelationshipInfoMap()\n                    // must use EntityDatasourceFactory.checkTableExists, NOT entityDbMeta.tableExists(ed)\n                    ed.entityInfo.datasourceFactory.checkTableExists(ed.getFullEntityName())\n                } catch (Throwable t) { logger.warn(\"Error loading framework entity ${entityName} definitions: ${t.toString()}\", t) }\n            }\n        }\n        logger.info(\"Loaded ${entityCount} framework entity definitions in ${System.currentTimeMillis() - startTime}ms\")\n    }\n\n    final static Set<String> cachedCountEntities = new HashSet<>([\"moqui.basic.EnumerationType\"])\n    final static Set<String> cachedListEntities = new HashSet<>([ \"moqui.entity.document.DataDocument\",\n        \"moqui.entity.document.DataDocumentCondition\", \"moqui.entity.document.DataDocumentField\",\n        \"moqui.entity.feed.DataFeedAndDocument\", \"moqui.entity.view.DbViewEntity\", \"moqui.entity.view.DbViewEntityAlias\",\n        \"moqui.entity.view.DbViewEntityKeyMap\", \"moqui.entity.view.DbViewEntityMember\",\n\n        \"moqui.screen.ScreenThemeResource\", \"moqui.screen.SubscreensItem\", \"moqui.screen.form.DbFormField\",\n        \"moqui.screen.form.DbFormFieldAttribute\", \"moqui.screen.form.DbFormFieldEntOpts\", \"moqui.screen.form.DbFormFieldEntOptsCond\",\n        \"moqui.screen.form.DbFormFieldEntOptsOrder\", \"moqui.screen.form.DbFormFieldOption\", \"moqui.screen.form.DbFormLookup\",\n\n        \"moqui.security.ArtifactAuthzCheckView\", \"moqui.security.ArtifactTarpitCheckView\", \"moqui.security.ArtifactTarpitLock\",\n        \"moqui.security.UserGroupMember\", \"moqui.security.UserGroupPreference\"\n    ])\n    final static Set<String> cachedOneEntities = new HashSet<>([ \"moqui.basic.Enumeration\", \"moqui.basic.LocalizedMessage\",\n            \"moqui.entity.document.DataDocument\", \"moqui.entity.view.DbViewEntity\", \"moqui.screen.form.DbForm\",\n            \"moqui.security.UserAccount\", \"moqui.security.UserPreference\", \"moqui.security.UserScreenTheme\", \"moqui.server.Visit\"\n    ])\n    void warmCache()  {\n        logger.info(\"Warming cache for all entity definitions\")\n        long startTime = System.currentTimeMillis()\n        Set<String> entityNames = getAllEntityNames()\n        for (String entityName in entityNames) {\n            try {\n                EntityDefinition ed = getEntityDefinition(entityName)\n                ed.getRelationshipInfoMap()\n                // must use EntityDatasourceFactory.checkTableExists, NOT entityDbMeta.tableExists(ed)\n                ed.entityInfo.datasourceFactory.checkTableExists(ed.getFullEntityName())\n\n                if (cachedCountEntities.contains(entityName)) ed.getCacheCount(entityCache)\n                if (cachedListEntities.contains(entityName)) {\n                    ed.getCacheList(entityCache)\n                    ed.getCacheListRa(entityCache)\n                    ed.getCacheListViewRa(entityCache)\n                }\n                if (cachedOneEntities.contains(entityName)) {\n                    ed.getCacheOne(entityCache)\n                    ed.getCacheOneRa(entityCache)\n                    ed.getCacheOneViewRa(entityCache)\n                }\n            } catch (Throwable t) { logger.warn(\"Error warming entity cache: ${t.toString()}\") }\n        }\n\n        logger.info(\"Warmed entity definition cache for ${entityNames.size()} entities in ${System.currentTimeMillis() - startTime}ms\")\n    }\n\n    Set<String> getDatasourceGroupNames() {\n        Set<String> groupNames = new TreeSet<String>()\n        for (MNode datasourceNode in getEntityFacadeNode().children(\"datasource\")) {\n            groupNames.add((String) datasourceNode.attribute(\"group-name\"))\n        }\n        return groupNames\n    }\n\n    static int getTxIsolationFromString(String isolationLevel) {\n        if (!isolationLevel) return -1\n        if (\"Serializable\".equals(isolationLevel)) {\n            return Connection.TRANSACTION_SERIALIZABLE\n        } else if (\"RepeatableRead\".equals(isolationLevel)) {\n            return Connection.TRANSACTION_REPEATABLE_READ\n        } else if (\"ReadUncommitted\".equals(isolationLevel)) {\n            return Connection.TRANSACTION_READ_UNCOMMITTED\n        } else if (\"ReadCommitted\".equals(isolationLevel)) {\n            return Connection.TRANSACTION_READ_COMMITTED\n        } else if (\"None\".equals(isolationLevel)) {\n            return Connection.TRANSACTION_NONE\n        } else {\n            return -1\n        }\n    }\n\n    List<ResourceReference> getAllEntityFileLocations() {\n        List<ResourceReference> entityRrList = new LinkedList()\n        entityRrList.addAll(getConfEntityFileLocations())\n        entityRrList.addAll(getComponentEntityFileLocations(null))\n        return entityRrList\n    }\n    List<ResourceReference> getConfEntityFileLocations() {\n        List<ResourceReference> entityRrList = new LinkedList()\n\n        // loop through all of the entity-facade.load-entity nodes, check each for \"<entities>\" root element\n        for (MNode loadEntity in getEntityFacadeNode().children(\"load-entity\")) {\n            entityRrList.add(this.ecfi.resourceFacade.getLocationReference((String) loadEntity.attribute(\"location\")))\n        }\n\n        return entityRrList\n    }\n    List<ResourceReference> getComponentEntityFileLocations(List<String> componentNameList) {\n        List<ResourceReference> entityRrList = new LinkedList()\n\n        List<String> componentBaseLocations\n        if (componentNameList) {\n            componentBaseLocations = []\n            for (String cn in componentNameList)\n                componentBaseLocations.add(ecfi.getComponentBaseLocations().get(cn))\n        } else {\n            componentBaseLocations = new ArrayList(ecfi.getComponentBaseLocations().values())\n        }\n\n        // loop through components look for XML files in the entity directory, check each for \"<entities>\" root element\n        for (String location in componentBaseLocations) {\n            ResourceReference entityDirRr = ecfi.resourceFacade.getLocationReference(location + \"/entity\")\n            if (entityDirRr.supportsAll()) {\n                // if directory doesn't exist skip it, component doesn't have an entity directory\n                if (!entityDirRr.exists || !entityDirRr.isDirectory()) continue\n                // get all files in the directory\n                TreeMap<String, ResourceReference> entityDirEntries = new TreeMap<String, ResourceReference>()\n                for (ResourceReference entityRr in entityDirRr.directoryEntries) {\n                    if (!entityRr.isFile() || !entityRr.location.endsWith(\".xml\")) continue\n                    entityDirEntries.put(entityRr.getFileName(), entityRr)\n                }\n                for (Map.Entry<String, ResourceReference> entityDirEntry in entityDirEntries) {\n                    entityRrList.add(entityDirEntry.getValue())\n                }\n            } else {\n                // just warn here, no exception because any non-file component location would blow everything up\n                logger.warn(\"Cannot load entity directory in component location [${location}] because protocol [${entityDirRr.uri.scheme}] is not supported.\")\n            }\n        }\n\n        return entityRrList\n    }\n\n    Map<String, List<String>> loadAllEntityLocations() {\n        // lock or wait for lock, this lock used here and for checking entity defined\n        locationLoadLock.lock()\n\n        try {\n            // load all entity files based on ResourceReference\n            long startTime = System.currentTimeMillis()\n\n            Map<String, List<String>> entityLocationCache = entityLocationSingleCache.get(entityLocSingleEntryName)\n            // when loading all entity locations we expect this to be null, if it isn't no need to load\n            if (entityLocationCache != null) return entityLocationCache\n            entityLocationCache = new HashMap<>()\n\n            List<ResourceReference> allEntityFileLocations = getAllEntityFileLocations()\n            for (ResourceReference entityRr in allEntityFileLocations) this.loadEntityFileLocations(entityRr, entityLocationCache)\n            if (logger.isInfoEnabled()) logger.info(\"Found entities in ${allEntityFileLocations.size()} files in ${System.currentTimeMillis() - startTime}ms\")\n\n            // put in the cache for other code to use; needed before DbViewEntity load so DB queries work\n            entityLocationSingleCache.put(entityLocSingleEntryName, entityLocationCache)\n\n            // look for view-entity definitions in the database (moqui.entity.view.DbViewEntity)\n            if (entityLocationCache.get(\"moqui.entity.view.DbViewEntity\")) {\n                int numDbViewEntities = 0\n                for (EntityValue dbViewEntity in find(\"moqui.entity.view.DbViewEntity\").list()) {\n                    if (dbViewEntity.packageName) {\n                        List<String> pkgList = (List<String>) entityLocationCache.get((String) dbViewEntity.packageName + \".\" + dbViewEntity.dbViewEntityName)\n                        if (pkgList == null) {\n                            pkgList = new LinkedList<>()\n                            entityLocationCache.put((String) dbViewEntity.packageName + \".\" + dbViewEntity.dbViewEntityName, pkgList)\n                        }\n                        if (!pkgList.contains(\"_DB_VIEW_ENTITY_\")) pkgList.add(\"_DB_VIEW_ENTITY_\")\n                    }\n\n                    List<String> nameList = (List<String>) entityLocationCache.get((String) dbViewEntity.dbViewEntityName)\n                    if (nameList == null) {\n                        nameList = new LinkedList<>()\n                        // put in cache under both plain entityName and fullEntityName\n                        entityLocationCache.put((String) dbViewEntity.dbViewEntityName, nameList)\n                    }\n                    if (!nameList.contains(\"_DB_VIEW_ENTITY_\")) nameList.add(\"_DB_VIEW_ENTITY_\")\n\n                    numDbViewEntities++\n                }\n                if (logger.infoEnabled) logger.info(\"Found ${numDbViewEntities} view-entity definitions in database (DbViewEntity records)\")\n            } else {\n                logger.warn(\"Could not find view-entity definitions in database (moqui.entity.view.DbViewEntity), no location found for the moqui.entity.view.DbViewEntity entity.\")\n            }\n\n            /* a little code to show all entities and their locations\n            Set<String> enSet = new TreeSet(entityLocationCache.keySet())\n            for (String en in enSet) {\n                List lst = entityLocationCache.get(en)\n                entityLocationCache.put(en, Collections.unmodifiableList(lst))\n                logger.warn(\"TOREMOVE entity ${en}: ${lst}\")\n            }\n            */\n\n            return entityLocationCache\n        } finally {\n            locationLoadLock.unlock()\n        }\n    }\n\n    // NOTE: only called by loadAllEntityLocations() which is synchronized/locked, so doesn't need to be\n    protected void loadEntityFileLocations(ResourceReference entityRr, Map<String, List<String>> entityLocationCache) {\n        MNode entityRoot = getEntityFileRoot(entityRr)\n        if (entityRoot.name == \"entities\") {\n            // loop through all entity, view-entity, and extend-entity and add file location to List for any entity named\n            int numEntities = 0\n            for (MNode entity in entityRoot.children) {\n                String entityName = entity.attribute(\"entity-name\")\n                String packageName = entity.attribute(\"package\")\n                if (packageName == null || packageName.isEmpty()) packageName = entity.attribute(\"package-name\")\n                String shortAlias = entity.attribute(\"short-alias\")\n\n                if (entityName == null || entityName.length() == 0) {\n                    logger.warn(\"Skipping entity XML file [${entityRr.getLocation()}] element with no @entity-name: ${entity}\")\n                    continue\n                }\n\n                List<String> locList = (List<String>) entityLocationCache.get(entityName)\n                if (locList == null) {\n                    locList = new LinkedList<>()\n                    locList.add(entityRr.location)\n                    entityLocationCache.put(entityName, locList)\n                } else if (!locList.contains(entityRr.location)) {\n                    locList.add(entityRr.location)\n                }\n\n                if (packageName != null && packageName.length() > 0) {\n                    String fullEntityName = packageName.concat(\".\").concat(entityName)\n                    if (!entityLocationCache.containsKey(fullEntityName)) entityLocationCache.put(fullEntityName, locList)\n                }\n                if (shortAlias != null && shortAlias.length() > 0) {\n                    if (!entityLocationCache.containsKey(shortAlias)) entityLocationCache.put(shortAlias, locList)\n                }\n\n                numEntities++\n            }\n            if (isTraceEnabled) logger.trace(\"Found [${numEntities}] entity definitions in [${entityRr.location}]\")\n        }\n    }\n\n    protected static MNode getEntityFileRoot(ResourceReference entityRr) { return MNode.parse(entityRr) }\n\n    int loadAllEntityDefinitions() {\n        int entityCount = 0\n        for (String en in getAllEntityNames()) {\n            try {\n                getEntityDefinition(en)\n            } catch (EntityException e) {\n                logger.warn(\"Problem finding entity definition\", e)\n                continue\n            }\n            entityCount++\n        }\n        return entityCount\n    }\n\n\n    protected EntityDefinition loadEntityDefinition(String entityName) {\n        if (entityName.contains(\"#\")) {\n            // this is a relationship name, definitely not an entity name so just return null; this happens because we\n            //    check if a name is an entity name or not in various places including where relationships are checked\n            return null\n        }\n\n        EntityDefinition ed = (EntityDefinition) entityDefinitionCache.get(entityName)\n        if (ed != null) return ed\n\n        Map<String, List<String>> entityLocationCache = entityLocationSingleCache.get(entityLocSingleEntryName)\n        if (entityLocationCache == null) entityLocationCache = loadAllEntityLocations()\n\n        List<String> entityLocationList = (List<String>) entityLocationCache.get(entityName)\n        if (entityLocationList == null) {\n            if (logger.isWarnEnabled()) logger.warn(\"No location cache found for entity-name [${entityName}], reloading ALL entity file and DB locations\")\n            if (isTraceEnabled) logger.trace(\"Unknown entity name ${entityName} location\", new BaseException(\"Unknown entity name location\"))\n\n            // remove the single cache entry\n            entityLocationSingleCache.remove(entityLocSingleEntryName)\n            // reload all locations\n            entityLocationCache = this.loadAllEntityLocations()\n            entityLocationList = (List<String>) entityLocationCache.get(entityName)\n            // no locations found for this entity, entity probably doesn't exist\n            if (entityLocationList == null || entityLocationList.size() == 0) {\n                // TODO: while this is helpful, if another unknown non-existing entity is looked for this will be lost\n                entityLocationCache.put(entityName, new LinkedList<String>())\n                if (logger.isWarnEnabled()) logger.warn(\"No definition found for entity-name [${entityName}]\")\n                throw new EntityNotFoundException(\"No definition found for entity-name [${entityName}]\")\n            }\n        }\n\n        if (entityLocationList.size() == 0) {\n            if (isTraceEnabled) logger.trace(\"Entity name [${entityName}] is a known non-entity, returning null for EntityDefinition.\")\n            return null\n        }\n\n        String packageName = null\n        if (entityName.contains('.')) {\n            packageName = entityName.substring(0, entityName.lastIndexOf(\".\"))\n            entityName = entityName.substring(entityName.lastIndexOf(\".\")+1)\n        }\n\n        // if (!packageName) logger.warn(\"TOREMOVE finding entity def for [${entityName}] with no packageName, entityLocationList=${entityLocationList}\")\n\n        // If this is a moqui.entity.view.DbViewEntity, handle that in a special way (generate the Nodes from the DB records)\n        if (entityLocationList.contains(\"_DB_VIEW_ENTITY_\")) {\n            EntityValue dbViewEntity = find(\"moqui.entity.view.DbViewEntity\").condition(\"dbViewEntityName\", entityName).one()\n            if (dbViewEntity == null) {\n                logger.warn(\"Could not find DbViewEntity with name ${entityName}\")\n                return null\n            }\n            MNode dbViewNode = new MNode(\"view-entity\", [\"entity-name\":entityName, \"package\":(String) dbViewEntity.packageName])\n            if (dbViewEntity.cache == \"Y\") dbViewNode.attributes.put(\"cache\", \"true\")\n            else if (dbViewEntity.cache == \"N\") dbViewNode.attributes.put(\"cache\", \"false\")\n\n            EntityList memberList = find(\"moqui.entity.view.DbViewEntityMember\").condition(\"dbViewEntityName\", entityName).list()\n            for (EntityValue dbViewEntityMember in memberList) {\n                MNode memberEntity = dbViewNode.append(\"member-entity\",\n                        [\"entity-alias\":dbViewEntityMember.getString(\"entityAlias\"), \"entity-name\":dbViewEntityMember.getString(\"entityName\")])\n                if (dbViewEntityMember.joinFromAlias) {\n                    memberEntity.attributes.put(\"join-from-alias\", (String) dbViewEntityMember.joinFromAlias)\n                    if (dbViewEntityMember.joinOptional == \"Y\") memberEntity.attributes.put(\"join-optional\", \"true\")\n                }\n\n                EntityList dbViewEntityKeyMapList = find(\"moqui.entity.view.DbViewEntityKeyMap\")\n                        .condition([\"dbViewEntityName\":entityName, \"joinFromAlias\":dbViewEntityMember.joinFromAlias,\n                            \"entityAlias\":dbViewEntityMember.getString(\"entityAlias\")])\n                        .list()\n                for (EntityValue dbViewEntityKeyMap in dbViewEntityKeyMapList) {\n                    MNode keyMapNode = memberEntity.append(\"key-map\", [\"field-name\":(String) dbViewEntityKeyMap.fieldName])\n                    if (dbViewEntityKeyMap.relatedFieldName)\n                        keyMapNode.attributes.put(\"related\", (String) dbViewEntityKeyMap.relatedFieldName)\n                }\n            }\n            for (EntityValue dbViewEntityAlias in find(\"moqui.entity.view.DbViewEntityAlias\").condition(\"dbViewEntityName\", entityName).list()) {\n                MNode aliasNode = dbViewNode.append(\"alias\",\n                        [\"name\":(String) dbViewEntityAlias.fieldAlias, \"entity-alias\":(String) dbViewEntityAlias.entityAlias])\n                if (dbViewEntityAlias.fieldName) aliasNode.attributes.put(\"field\", (String) dbViewEntityAlias.fieldName)\n                if (dbViewEntityAlias.functionName) aliasNode.attributes.put(\"function\", (String) dbViewEntityAlias.functionName)\n            }\n\n            // create the new EntityDefinition\n            ed = new EntityDefinition(this, dbViewNode)\n\n            // cache it under entityName, fullEntityName, and short-alias\n            String fullEntityName = ed.fullEntityName\n            if (fullEntityName.startsWith(\"moqui.\")) {\n                frameworkEntityDefinitions.put(ed.entityInfo.internalEntityName, ed)\n                frameworkEntityDefinitions.put(fullEntityName, ed)\n                if (ed.entityInfo.shortAlias) frameworkEntityDefinitions.put(ed.entityInfo.shortAlias, ed)\n            } else {\n                entityDefinitionCache.put(ed.entityInfo.internalEntityName, ed)\n                entityDefinitionCache.put(fullEntityName, ed)\n                if (ed.entityInfo.shortAlias) entityDefinitionCache.put(ed.entityInfo.shortAlias, ed)\n            }\n            // send it on its way\n            return ed\n        }\n\n        // get entity, view-entity and extend-entity Nodes for entity from each location\n        MNode entityNode = null\n        List<MNode> extendEntityNodes = new ArrayList<MNode>()\n        for (String location in entityLocationList) {\n            MNode entityRoot = getEntityFileRoot(this.ecfi.resourceFacade.getLocationReference(location))\n            // filter by package if specified, otherwise grab whatever\n            List<MNode> packageChildren = entityRoot.children\n                    .findAll({ (it.attribute(\"entity-name\") == entityName || it.attribute(\"short-alias\") == entityName) &&\n                        (packageName ? (it.attribute(\"package\") == packageName || it.attribute(\"package-name\") == packageName) : true) })\n            for (MNode childNode in packageChildren) {\n                if (childNode.name == \"extend-entity\") {\n                    extendEntityNodes.add(childNode)\n                } else {\n                    if (entityNode != null) logger.warn(\"Entity [${entityName}] was found again at [${location}], so overriding definition from previous location\")\n                    entityNode = childNode.deepCopy(null)\n                }\n            }\n        }\n        if (entityNode == null) throw new EntityNotFoundException(\"No definition found for entity [${entityName}]${packageName ? ' in package ['+packageName+']' : ''}\")\n\n        // if entityName is a short-alias extend-entity elements won't match it, so find them again now that we have the main entityNode\n        if (entityName == entityNode.attribute(\"short-alias\")) {\n            entityName = entityNode.attribute(\"entity-name\")\n            packageName = entityNode.attribute(\"package\") ?: entityNode.attribute(\"package-name\")\n            for (String location in entityLocationList) {\n                MNode entityRoot = getEntityFileRoot(this.ecfi.resourceFacade.getLocationReference(location))\n                List<MNode> packageChildren = entityRoot.children\n                        .findAll({ it.attribute(\"entity-name\") == entityName &&\n                            (packageName ? (it.attribute(\"package\") == packageName || it.attribute(\"package-name\") == packageName) : true) })\n                for (MNode childNode in packageChildren) {\n                    if (childNode.name == \"extend-entity\") {\n                        extendEntityNodes.add(childNode)\n                    }\n                }\n            }\n        }\n        // if (entityName.endsWith(\"xample\")) logger.warn(\"======== Creating Example ED entityNode=${entityNode}\\nextendEntityNodes: ${extendEntityNodes}\")\n\n        // merge the extend-entity nodes\n        for (MNode extendEntity in extendEntityNodes) {\n            // if package attributes don't match, skip\n            String entityPackage = entityNode.attribute(\"package\") ?: entityNode.attribute(\"package-name\")\n            String extendPackage = extendEntity.attribute(\"package\") ?: extendEntity.attribute(\"package-name\")\n            if (entityPackage != extendPackage) continue\n            // merge attributes\n            entityNode.attributes.putAll(extendEntity.attributes)\n            // merge field nodes\n            for (MNode childOverrideNode in extendEntity.children(\"field\")) {\n                String keyValue = childOverrideNode.attribute(\"name\")\n                MNode childBaseNode = entityNode.first({ MNode it -> it.name == \"field\" && it.attribute(\"name\") == keyValue })\n                if (childBaseNode) childBaseNode.attributes.putAll(childOverrideNode.attributes)\n                else entityNode.append(childOverrideNode)\n            }\n            // add relationship, key-map (copy over, will get child nodes too\n            ArrayList<MNode> relNodeList = extendEntity.children(\"relationship\")\n            for (int i = 0; i < relNodeList.size(); i++) {\n                MNode copyNode = relNodeList.get(i)\n                int curNodeIndex = entityNode.children\n                        .findIndexOf({ MNode it ->\n                            String itRelated = it.attribute('related') ?: it.attribute('related-entity-name');\n                            String copyRelated = copyNode.attribute('related') ?: copyNode.attribute('related-entity-name');\n                            return it.name == \"relationship\" && itRelated == copyRelated &&\n                                    it.attribute('title') == copyNode.attribute('title'); })\n                if (curNodeIndex >= 0) {\n                    entityNode.children.set(curNodeIndex, copyNode)\n                } else {\n                    entityNode.append(copyNode)\n                }\n            }\n            // add index, index-field\n            for (MNode copyNode in extendEntity.children(\"index\")) {\n                int curNodeIndex = entityNode.children\n                        .findIndexOf({ MNode it -> it.name == \"index\" && it.attribute('name') == copyNode.attribute('name') })\n                if (curNodeIndex >= 0) {\n                    entityNode.children.set(curNodeIndex, copyNode)\n                } else {\n                    entityNode.append(copyNode)\n                }\n            }\n            // copy master nodes (will be merged on parse)\n            // TODO: check master/detail existence before append it into entityNode\n            for (MNode copyNode in extendEntity.children(\"master\")) entityNode.append(copyNode)\n        }\n\n        // create the new EntityDefinition\n        ed = new EntityDefinition(this, entityNode)\n        // cache it under entityName, fullEntityName, and short-alias\n        String fullEntityName = ed.fullEntityName\n        if (fullEntityName.startsWith(\"moqui.\")) {\n            frameworkEntityDefinitions.put(ed.entityInfo.internalEntityName, ed)\n            frameworkEntityDefinitions.put(fullEntityName, ed)\n            if (ed.entityInfo.shortAlias) frameworkEntityDefinitions.put(ed.entityInfo.shortAlias, ed)\n        } else {\n            entityDefinitionCache.put(ed.entityInfo.internalEntityName, ed)\n            entityDefinitionCache.put(fullEntityName, ed)\n            if (ed.entityInfo.shortAlias) entityDefinitionCache.put(ed.entityInfo.shortAlias, ed)\n        }\n        // send it on its way\n        return ed\n    }\n\n    synchronized void createAllAutoReverseManyRelationships() {\n        int relationshipsCreated = 0\n        Set<String> entityNameSet = getAllEntityNames()\n        for (String entityName in entityNameSet) {\n            EntityDefinition ed\n            // for auto reverse relationships just ignore EntityException on getEntityDefinition\n            try { ed = getEntityDefinition(entityName) } catch (EntityException e) { if (isTraceEnabled) logger.trace(\"Entity not found\", e); continue; }\n            // may happen if all entity names includes a DB view entity or other that doesn't really exist\n            if (ed == null) continue\n            String edEntityName = ed.entityInfo.internalEntityName\n            String edFullEntityName = ed.fullEntityName\n            List<String> pkSet = ed.getPkFieldNames()\n            ArrayList<MNode> relationshipList = ed.entityNode.children(\"relationship\")\n            int relationshipListSize = relationshipList.size()\n            for (int rlIndex = 0; rlIndex < relationshipListSize; rlIndex++) {\n                MNode relNode = (MNode) relationshipList.get(rlIndex)\n                // don't create reverse for auto reference relationships\n                if (\"true\".equals(relNode.attribute(\"is-auto-reverse\"))) continue\n                String relatedEntityName = relNode.attribute(\"related\")\n                if (relatedEntityName == null || relatedEntityName.length() == 0) relatedEntityName = relNode.attribute(\"related-entity-name\")\n                // don't create reverse relationships coming back to the same entity, since it will have the same title\n                //     it would create multiple relationships with the same name\n                if (entityName.equals(relatedEntityName)) continue\n\n                EntityDefinition reverseEd\n                try {\n                    reverseEd = getEntityDefinition(relatedEntityName)\n                } catch (EntityException e) {\n                    logger.warn(\"Error getting definition for entity [${relatedEntityName}] referred to in a relationship of entity [${entityName}]: ${e.toString()}\")\n                    continue\n                }\n                if (reverseEd == null) {\n                    logger.warn(\"Could not find definition for entity [${relatedEntityName}] referred to in a relationship of entity [${entityName}]\")\n                    continue\n                }\n\n                List<String> reversePkSet = reverseEd.getPkFieldNames()\n                String relType = reversePkSet.equals(pkSet) ? \"one-nofk\" : \"many\"\n                String title = relNode.attribute('title')\n                boolean hasTitle = title != null && title.length() > 0\n\n                // does a relationship coming back already exist?\n                boolean foundReverse = false\n                ArrayList<MNode> reverseRelList = reverseEd.entityNode.children(\"relationship\")\n                int reverseRelListSize = reverseRelList.size()\n                for (int i = 0; i < reverseRelListSize; i++) {\n                    MNode reverseRelNode = (MNode) reverseRelList.get(i)\n                    String related = reverseRelNode.attribute(\"related\")\n                    if (related == null || related.length() == 0) related = reverseRelNode.attribute(\"related-entity-name\")\n                    if (!edEntityName.equals(related) && !edFullEntityName.equals(related)) continue\n                    // TODO: instead of checking title check reverse expanded key-map\n                    String reverseTitle = reverseRelNode.attribute(\"title\")\n                    if (hasTitle) {\n                        if (!title.equals(reverseTitle)) continue\n                    } else {\n                        if (reverseTitle != null && reverseTitle.length() > 0) continue\n                    }\n                    foundReverse = true\n                }\n                // NOTE: removed \"it.\"@type\" == relType && \", if there is already any relationship coming back don't create the reverse\n                if (foundReverse) {\n                    // NOTE DEJ 20150314 Just track auto-reverse, not one-reverse\n                    // make sure has is-one-reverse=\"true\"\n                    // reverseRelNode.attributes().put(\"is-one-reverse\", \"true\")\n                    continue\n                }\n\n                // track the fact that the related entity has others pointing back to it, unless original relationship is type many (doesn't qualify)\n                if (!ed.isViewEntity && !\"many\".equals(relNode.attribute(\"type\"))) reverseEd.entityNode.attributes.put(\"has-dependents\", \"true\")\n\n                // create a new reverse-many relationship\n                Map<String, String> keyMap = EntityDefinition.getRelationshipExpandedKeyMapInternal(relNode, reverseEd)\n\n                MNode newRelNode = reverseEd.entityNode.append(\"relationship\",\n                        [\"related\":edFullEntityName, \"type\":relType, \"is-auto-reverse\":\"true\", \"mutable\":\"true\"])\n                if (hasTitle) newRelNode.attributes.put(\"title\", title)\n                for (Map.Entry<String, String> keyEntry in keyMap) {\n                    // add a key-map with the reverse fields\n                    newRelNode.append(\"key-map\", [\"field-name\":keyEntry.value, \"related\":keyEntry.key])\n                }\n                relationshipsCreated++\n            }\n        }\n        // all EntityDefinition objects now have reverse relationships in place, remember that so this will only be\n        //     called for new ones, not from cache\n        for (String entityName in entityNameSet) {\n            EntityDefinition ed\n            try { ed = getEntityDefinition(entityName) } catch (EntityException e) { if (isTraceEnabled) logger.trace(\"Entity not found\", e); continue; }\n            if (ed == null) continue\n            ed.setHasReverseRelationships()\n        }\n\n        if (logger.infoEnabled && relationshipsCreated > 0) logger.info(\"Created ${relationshipsCreated} automatic reverse relationships\")\n    }\n\n    // used in tools screen\n    int getEecaRuleCount() {\n        int count = 0\n        for (List ruleList in eecaRulesByEntityName.values()) count += ruleList.size()\n        return count\n    }\n\n    void loadEecaRulesAll() {\n        int numLoaded = 0\n        int numFiles = 0\n        HashMap<String, EntityEcaRule> ruleByIdMap = new HashMap<>()\n        LinkedList<EntityEcaRule> ruleNoIdList = new LinkedList<>()\n\n        List<ResourceReference> allEntityFileLocations = getAllEntityFileLocations()\n        for (ResourceReference rr in allEntityFileLocations) {\n            if (!rr.fileName.endsWith(\".eecas.xml\")) continue\n            numLoaded += loadEecaRulesFile(rr, ruleByIdMap, ruleNoIdList)\n            numFiles++\n        }\n\n        /*\n        // search for the service def XML file in the components\n        for (String location in this.ecfi.getComponentBaseLocations().values()) {\n            ResourceReference entityDirRr = this.ecfi.resourceFacade.getLocationReference(location + \"/entity\")\n            if (entityDirRr.supportsAll()) {\n                // if for some weird reason this isn't a directory, skip it\n                if (!entityDirRr.isDirectory()) continue\n                for (ResourceReference rr in entityDirRr.directoryEntries) {\n                    if (!rr.fileName.endsWith(\".eecas.xml\")) continue\n                    numLoaded += loadEecaRulesFile(rr, ruleByIdMap, ruleNoIdList)\n                    numFiles++\n                }\n            } else {\n                logger.warn(\"Can't load EECA rules from component at [${entityDirRr.location}] because it doesn't support exists/directory/etc\")\n            }\n        }\n        */\n\n        if (logger.infoEnabled) logger.info(\"Loaded ${numLoaded} Entity ECA rules from ${numFiles} .eecas.xml files, ${ruleNoIdList.size()} rules have no id, ${ruleNoIdList.size() + ruleByIdMap.size()} EECA rules active\")\n\n        HashMap<String, ArrayList<EntityEcaRule>> ruleMap = new HashMap<>()\n        ruleNoIdList.addAll(ruleByIdMap.values())\n        for (EntityEcaRule ecaRule in ruleNoIdList) {\n            EntityDefinition ed = getEntityDefinition(ecaRule.entityName)\n            String entityName = ed.getFullEntityName()\n\n            ArrayList<EntityEcaRule> lst = ruleMap.get(entityName)\n            if (lst == null) {\n                lst = new ArrayList<EntityEcaRule>()\n                ruleMap.put(entityName, lst)\n            }\n            lst.add(ecaRule)\n        }\n\n        // replace entire EECA rules Map in one operation\n        eecaRulesByEntityName = ruleMap\n    }\n    int loadEecaRulesFile(ResourceReference rr, HashMap<String, EntityEcaRule> ruleByIdMap, LinkedList<EntityEcaRule> ruleNoIdList) {\n        MNode eecasRoot = MNode.parse(rr)\n        int numLoaded = 0\n        for (MNode eecaNode in eecasRoot.children(\"eeca\")) {\n            String entityName = eecaNode.attribute(\"entity\")\n            if (!isEntityDefined(entityName)) {\n                logger.warn(\"Invalid entity name ${entityName} found in EECA file ${rr.location}, skipping\")\n                continue\n            }\n            EntityEcaRule ecaRule = new EntityEcaRule(ecfi, eecaNode, rr.location)\n            String ruleId = eecaNode.attribute(\"id\")\n            if (ruleId != null && !ruleId.isEmpty()) ruleByIdMap.put(ruleId, ecaRule)\n            else ruleNoIdList.add(ecaRule)\n            numLoaded++\n        }\n        if (logger.isTraceEnabled()) logger.trace(\"Loaded [${numLoaded}] Entity ECA rules from [${rr.location}]\")\n        return numLoaded\n    }\n\n    boolean hasEecaRules(String entityName) { return eecaRulesByEntityName.get(entityName) != null }\n    void runEecaRules(String entityName, Map fieldValues, String operation, boolean before) {\n        ArrayList<EntityEcaRule> lst = (ArrayList<EntityEcaRule>) eecaRulesByEntityName.get(entityName)\n        if (lst != null && lst.size() > 0) {\n            // if Entity ECA rules disabled in ArtifactExecutionFacade, just return immediately\n            // do this only if there are EECA rules to run, small cost in getEci, etc\n            if (ecfi.getEci().artifactExecutionFacade.entityEcaDisabled()) return\n\n            for (int i = 0; i < lst.size(); i++) {\n                EntityEcaRule eer = (EntityEcaRule) lst.get(i)\n                eer.runIfMatches(entityName, fieldValues, operation, before, ecfi.getEci())\n            }\n        }\n    }\n\n    // used in tools screen\n    void checkAllEntityTables(String groupName) {\n        // TODO: load framework entities first, then component/mantle/etc entities for better FKs on first pass\n        EntityDatasourceFactory edf = getDatasourceFactory(groupName)\n        for (String entityName in getAllEntityNamesInGroup(groupName)) edf.checkAndAddTable(entityName)\n    }\n\n    Set<String> getAllEntityNames() { return getAllEntityNames(null) }\n    Set<String> getAllEntityNames(String filterRegexp) {\n        Map<String, List<String>> entityLocationCache = entityLocationSingleCache.get(entityLocSingleEntryName)\n        if (entityLocationCache == null) entityLocationCache = loadAllEntityLocations()\n\n        TreeSet<String> allNames = new TreeSet()\n        // only add full entity names (with package in it, will always have at least one dot)\n        // only include entities that have a non-empty List of locations in the cache (otherwise are invalid entities)\n        for (Map.Entry<String, List<String>> entry in entityLocationCache.entrySet()) {\n            String en = entry.key\n            List<String> locList = entry.value\n            if (en.contains(\".\") && locList != null && locList.size() > 0) {\n                // Added (?i) to ignore the case and '*' in the starting and at ending to match if searched string is sub-part of entity name\n                if (filterRegexp != null && !en.matches(\"(?i).*\" + filterRegexp + \".*\")) continue\n                allNames.add(en)\n            }\n        }\n        return allNames\n    }\n\n    Set<String> getAllNonViewEntityNames() {\n        Set<String> allNames = getAllEntityNames()\n        Set<String> nonViewNames = new TreeSet<>()\n        for (String name in allNames) {\n            EntityDefinition ed = getEntityDefinition(name)\n            if (ed != null && !ed.isViewEntity) nonViewNames.add(name)\n        }\n        return nonViewNames\n    }\n    Set<String> getAllEntityNamesWithMaster() {\n        Set<String> allNames = getAllEntityNames()\n        Set<String> masterNames = new TreeSet<>()\n        for (String name in allNames) {\n            EntityDefinition ed\n            try { ed = getEntityDefinition(name) } catch (EntityException e) { if (isTraceEnabled) logger.trace(\"Entity not found\", e); continue; }\n            if (ed != null && !ed.isViewEntity && ed.masterDefinitionMap) masterNames.add(name)\n        }\n        return masterNames\n    }\n\n    // used in tools screens\n    List<Map> getAllEntityInfo(int levels, boolean excludeViewEntities) {\n        Map<String, Map> entityInfoMap = [:]\n        for (String entityName in getAllEntityNames()) {\n            EntityDefinition ed = getEntityDefinition(entityName)\n            boolean isView = ed.isViewEntity\n            if (excludeViewEntities && isView) continue\n            int lastDotIndex = 0\n            for (int i = 0; i < levels; i++) lastDotIndex = entityName.indexOf(\".\", lastDotIndex+1)\n            String name = lastDotIndex == -1 ? entityName : entityName.substring(0, lastDotIndex)\n            Map curInfo = entityInfoMap.get(name)\n            if (curInfo) {\n                if (isView) CollectionUtilities.addToBigDecimalInMap(\"viewEntities\", 1.0, curInfo)\n                else CollectionUtilities.addToBigDecimalInMap(\"entities\", 1.0, curInfo)\n            } else {\n                entityInfoMap.put(name, [name:name, entities:(isView ? 0 : 1), viewEntities:(isView ? 1 : 0)])\n            }\n        }\n        TreeSet<String> nameSet = new TreeSet(entityInfoMap.keySet())\n        List<Map> entityInfoList = []\n        for (String name in nameSet) entityInfoList.add(entityInfoMap.get(name))\n        return entityInfoList\n    }\n\n    /** This is used mostly by the service engine to quickly determine whether a noun is an entity. Called for all\n     * ServiceDefinition init to see if the noun is an entity name. Called by entity auto check if no path and verb is\n     * one of the entity-auto supported verbs. */\n    boolean isEntityDefined(String entityName) {\n        if (entityName == null) return false\n\n        // Special treatment for framework entities, quick Map lookup (also faster than Cache get)\n        if (frameworkEntityDefinitions.containsKey(entityName)) return true\n\n        Map<String, List<String>> entityLocationCache = (Map<String, List<String>>) entityLocationSingleCache.get(entityLocSingleEntryName)\n        if (entityLocationCache == null) entityLocationCache = loadAllEntityLocations()\n\n        List<String> locList = (List<String>) entityLocationCache.get(entityName)\n        return locList != null && locList.size() > 0\n    }\n\n    EntityDefinition getEntityDefinition(String entityName) {\n        if (entityName == null) return null\n        EntityDefinition ed = (EntityDefinition) frameworkEntityDefinitions.get(entityName)\n        if (ed != null) return ed\n        ed = (EntityDefinition) entityDefinitionCache.get(entityName)\n        if (ed != null) return ed\n        if (entityName.isEmpty()) return null\n        if (entityName.startsWith(\"DataDocument.\")) {\n            return entityDataDocument.makeEntityDefinition(entityName.substring(entityName.indexOf(\".\") + 1))\n        } else {\n            return loadEntityDefinition(entityName)\n        }\n    }\n\n    // used in tools screens\n    void clearEntityDefinitionFromCache(String entityName) {\n        EntityDefinition ed = (EntityDefinition) this.entityDefinitionCache.get(entityName)\n        if (ed != null) {\n            this.entityDefinitionCache.remove(ed.entityInfo.internalEntityName)\n            this.entityDefinitionCache.remove(ed.fullEntityName)\n            if (ed.entityInfo.shortAlias) this.entityDefinitionCache.remove(ed.entityInfo.shortAlias)\n        }\n    }\n\n    // used in tools screens\n    ArrayList<Map<String, Object>> getAllEntitiesInfo(String orderByField, String filterRegexp, boolean masterEntitiesOnly,\n                                                      boolean excludeViewEntities) {\n        if (masterEntitiesOnly) createAllAutoReverseManyRelationships()\n\n        ArrayList<Map<String, Object>> eil = new ArrayList<>()\n        for (String en in getAllEntityNames(filterRegexp)) {\n            EntityDefinition ed = null\n            try { ed = getEntityDefinition(en) } catch (EntityException e) { logger.warn(\"Problem finding entity definition\", e) }\n            if (ed == null) continue\n            if (excludeViewEntities && ed.isViewEntity) continue\n\n            if (masterEntitiesOnly) {\n                if (!(ed.entityNode.attribute(\"has-dependents\") == \"true\") || en.endsWith(\"Type\") ||\n                        en == \"moqui.basic.Enumeration\" || en == \"moqui.basic.StatusItem\") continue\n                if (ed.getPkFieldNames().size() > 1) continue\n            }\n\n            eil.add([entityName:ed.entityInfo.internalEntityName, \"package\":ed.entityNode.attribute(\"package\"),\n                    isView:(ed.isViewEntity ? \"true\" : \"false\"), fullEntityName:ed.fullEntityName, tableName:ed.tableName] as Map<String, Object>)\n        }\n\n        if (orderByField != null && !orderByField.isEmpty()) CollectionUtilities.orderMapList(eil, [orderByField])\n        return eil\n    }\n\n    // used in tools screen (EntityDbView)\n    ArrayList<Map<String, Object>> getAllEntityRelatedFields(String en, String orderByField, String dbViewEntityName) {\n        // make sure reverse-one many relationships exist\n        createAllAutoReverseManyRelationships()\n\n        EntityValue dbViewEntity = dbViewEntityName ? find(\"moqui.entity.view.DbViewEntity\").condition(\"dbViewEntityName\", dbViewEntityName).one() : null\n\n        ArrayList<Map<String, Object>> efl = new ArrayList<>()\n        EntityDefinition ed = null\n        try { ed = getEntityDefinition(en) } catch (EntityException e) { logger.warn(\"Problem finding entity definition\", e) }\n        if (ed == null) return efl\n\n        // first get fields of the main entity\n        for (String fn in ed.getAllFieldNames()) {\n            MNode fieldNode = ed.getFieldNode(fn)\n\n            boolean inDbView = false\n            String functionName = null\n            EntityValue aliasVal = find(\"moqui.entity.view.DbViewEntityAlias\")\n                .condition([dbViewEntityName:dbViewEntityName, entityAlias:\"MASTER\", fieldName:fn] as Map<String, Object>).one()\n            if (aliasVal) {\n                inDbView = true\n                functionName = aliasVal.functionName\n            }\n\n            efl.add([entityName:en, fieldName:fn, type:fieldNode.attribute(\"type\"), cardinality:\"one\",\n                    inDbView:inDbView, functionName:functionName] as Map<String, Object>)\n        }\n\n        // loop through all related entities and get their fields too\n        for (RelationshipInfo relInfo in ed.getRelationshipsInfo(false)) {\n            //[type:relNode.\"@type\", title:(relNode.\"@title\"?:\"\"), relatedEntityName:relNode.\"@related-entity-name\",\n            //        keyMap:keyMap, targetParameterMap:targetParameterMap, prettyName:prettyName]\n            EntityDefinition red = null\n            try { red = getEntityDefinition((String) relInfo.relatedEntityName) } catch (EntityException e) { logger.warn(\"Problem finding entity definition\", e) }\n            if (red == null) continue\n\n            EntityValue dbViewEntityMember = null\n            if (dbViewEntity) dbViewEntityMember = find(\"moqui.entity.view.DbViewEntityMember\")\n                    .condition([dbViewEntityName:dbViewEntityName, entityName:red.getFullEntityName()] as Map<String, Object>).one()\n\n            for (String fn in red.getAllFieldNames()) {\n                MNode fieldNode = red.getFieldNode(fn)\n                boolean inDbView = false\n                String functionName = null\n                if (dbViewEntityMember) {\n                    EntityValue aliasVal = find(\"moqui.entity.view.DbViewEntityAlias\")\n                        .condition([dbViewEntityName:dbViewEntityName, entityAlias:dbViewEntityMember.entityAlias, fieldName:fn]).one()\n                    if (aliasVal) {\n                        inDbView = true\n                        functionName = aliasVal.functionName\n                    }\n                }\n                efl.add([entityName:relInfo.relatedEntityName, fieldName:fn, type:fieldNode.attribute(\"type\"),\n                        cardinality:relInfo.type, title:relInfo.title, inDbView:inDbView, functionName:functionName] as Map<String, Object>)\n            }\n        }\n\n        if (orderByField) CollectionUtilities.orderMapList(efl, [orderByField])\n        return efl\n    }\n\n    MNode getDatabaseNode(String groupName) {\n        MNode node = databaseNodeByGroupName.get(groupName)\n        if (node != null) return node\n        return findDatabaseNode(groupName)\n    }\n    protected MNode findDatabaseNode(String groupName) {\n        MNode datasourceNode = getDatasourceNode(groupName)\n        String databaseConfName = datasourceNode.attribute(\"database-conf-name\")\n        MNode node = ecfi.confXmlRoot.first(\"database-list\")\n                .first({ MNode it -> it.name == 'database' && it.attribute(\"name\") == databaseConfName })\n        databaseNodeByGroupName.put(groupName, node)\n        return node\n    }\n    protected MNode getDatabaseNodeByConf(String confName) {\n        return ecfi.confXmlRoot.first(\"database-list\")\n                .first({ MNode it -> it.name == 'database' && it.attribute(\"name\") == confName })\n    }\n    String getDatabaseConfName(String entityName) {\n        MNode dsNode = getDatasourceNode(getEntityGroupName(entityName))\n        if (dsNode == null) return null\n        return dsNode.attribute(\"database-conf-name\")\n    }\n\n    MNode getDatasourceNode(String groupName) {\n        MNode node = datasourceNodeByGroupName.get(groupName)\n        if (node != null) return node\n        return findDatasourceNode(groupName)\n    }\n    protected MNode findDatasourceNode(String groupName) {\n        MNode dsNode = getEntityFacadeNode().first({ MNode it -> it.name == 'datasource' && it.attribute(\"group-name\") == groupName })\n        if (dsNode == null) dsNode = getEntityFacadeNode()\n                .first({ MNode it -> it.name == 'datasource' && it.attribute(\"group-name\") == defaultGroupName })\n        dsNode.setSystemExpandAttributes(true)\n        datasourceNodeByGroupName.put(groupName, dsNode)\n        return dsNode\n    }\n\n    EntityDbMeta getEntityDbMeta() { return dbMeta != null ? dbMeta : (dbMeta = new EntityDbMeta(this)) }\n\n    /** Get a JDBC Connection based on xa-properties configuration. The Conf Map should contain the default entity_ds properties\n     * including entity_ds_db_conf, entity_ds_host, entity_ds_port, entity_ds_database, entity_ds_user, entity_ds_password */\n    XAConnection getConfConnection(Map<String, String> confMap) {\n        String confName = confMap.entity_ds_db_conf\n        MNode databaseNode = getDatabaseNodeByConf(confName)\n        MNode xaPropsNode = databaseNode.first(\"inline-jdbc\")?.first(\"xa-properties\")\n        if (xaPropsNode == null) throw new IllegalArgumentException(\"Could not find database.inline-jdbc.xa-properties element for conf name ${confName}\")\n\n        String xaDsClassName = databaseNode.attribute(\"default-xa-ds-class\")\n        if (!xaDsClassName) throw new IllegalArgumentException(\"Could database conf ${confName} has no default-xa-ds-class attribute\")\n        XADataSource xaDs = (XADataSource) ecfi.classLoader.loadClass(xaDsClassName).newInstance()\n        for (Map.Entry<String, String> attrEntry in xaPropsNode.attributes.entrySet()) {\n            String propValue = ecfi.resourceFacade.expand(attrEntry.value, \"\", confMap)\n            try {\n                xaDs.putAt(attrEntry.key, propValue)\n            } catch (GroovyCastException e) {\n                if (isTraceEnabled) logger.trace(\"Cast failed, trying int\", e)\n                xaDs.putAt(attrEntry.key, propValue as int)\n            }\n        }\n\n        return xaDs.getXAConnection(confMap.entity_ds_user, confMap.entity_ds_password)\n    }\n    // used in services\n    int runSqlUpdateConf(CharSequence sql, Map<String, String> confMap) {\n        // only do one DB meta data operation at a time; may lock above before checking for existence of something to make sure it doesn't get created twice\n        int records = 0\n        ecfi.transactionFacade.runRequireNew(30, \"Error in DB meta data change\", false, true, {\n            XAConnection xacon = null\n            Connection con = null\n            Statement stmt = null\n            try {\n                xacon = getConfConnection(confMap)\n                con = xacon.getConnection()\n                stmt = con.createStatement()\n                records = stmt.executeUpdate(sql.toString())\n            } finally {\n                if (stmt != null) stmt.close()\n                if (con != null) con.close()\n                if (xacon != null) xacon.close()\n            }\n        })\n        return records\n    }\n    /* this needs more work, can't pass back ResultSet with Connection closed so need to somehow return Connection and ResultSet so both can be closed...\n    ResultSet runSqlQueryConf(CharSequence sql, Map<String, String> confMap) {\n        Connection con = null\n        Statement stmt = null\n        ResultSet rs = null\n        try {\n            con = getConfConnection(confMap)\n            stmt = con.createStatement()\n            rs = stmt.executeQuery(sql.toString())\n        } finally {\n            if (stmt != null) stmt.close()\n            if (con != null) con.close()\n        }\n        return rs\n    }\n    */\n    // used in services\n    long runSqlCountConf(CharSequence from, CharSequence where, Map<String, String> confMap) {\n        StringBuilder sqlSb = new StringBuilder(\"SELECT COUNT(*) FROM \").append(from).append(\" WHERE \").append(where)\n        XAConnection xacon = null\n        Connection con = null\n        Statement stmt = null\n        ResultSet rs = null\n        try {\n            xacon = getConfConnection(confMap)\n            con = xacon.getConnection()\n            stmt = con.createStatement()\n            rs = stmt.executeQuery(sqlSb.toString())\n            if (rs.next()) return rs.getLong(1)\n            return 0\n        } finally {\n            if (stmt != null) stmt.close()\n            if (rs != null) rs.close()\n            if (con != null) con.close()\n            if (xacon != null) xacon.close()\n        }\n    }\n\n    /* ========================= */\n    /* Interface Implementations */\n    /* ========================= */\n\n    @Override\n    EntityDatasourceFactory getDatasourceFactory(String groupName) {\n        EntityDatasourceFactory edf = (EntityDatasourceFactory) datasourceFactoryByGroupMap.get(groupName)\n        if (edf == null) edf = (EntityDatasourceFactory) datasourceFactoryByGroupMap.get(defaultGroupName)\n        if (edf == null) throw new EntityException(\"Could not find EntityDatasourceFactory for entity group ${groupName}\")\n        return edf\n    }\n    List<Map<String, Object>> getDataSourcesInfo() {\n        List<Map<String, Object>> dsiList = new LinkedList<>()\n        for (String groupName in datasourceFactoryByGroupMap.keySet()) {\n            EntityDatasourceFactory edf = datasourceFactoryByGroupMap.get(groupName)\n            if (edf instanceof EntityDatasourceFactoryImpl) {\n                EntityDatasourceFactoryImpl edfi = (EntityDatasourceFactoryImpl) edf\n                DatasourceInfo dsi = edfi.dsi\n                dsiList.add([group:groupName, uniqueName:dsi.uniqueName, database:dsi.database.attribute('name'), detail:dsi.dsDetails] as Map<String, Object>)\n            } else {\n                dsiList.add([group:groupName] as Map<String, Object>)\n            }\n        }\n        return dsiList\n    }\n    String getDatasourceCloneName(String groupName) {\n        String baseGroupName = groupName == null || groupName.isEmpty() ? defaultGroupName : groupName\n        String groupPrefix = baseGroupName.concat('#')\n\n        ArrayList<String> cloneGroupNames = new ArrayList<>(5)\n        for (String curGroup in datasourceFactoryByGroupMap.keySet())\n            if (curGroup.startsWith(groupPrefix)) cloneGroupNames.add(curGroup)\n\n        int cloneNamesSize = cloneGroupNames.size()\n        if (cloneNamesSize == 0) {\n            return baseGroupName\n        } else if (cloneNamesSize == 1) {\n            // logger.warn(\"Using DB clone ${cloneGroupNames.get(0)} instead of ${groupName}\")\n            return cloneGroupNames.get(0)\n        } else {\n            return cloneGroupNames.get(ThreadLocalRandom.current().nextInt(cloneNamesSize))\n        }\n    }\n\n    @Override EntityConditionFactory getConditionFactory() { return this.entityConditionFactory }\n    EntityConditionFactoryImpl getConditionFactoryImpl() { return this.entityConditionFactory }\n\n    @Override\n    EntityValue makeValue(String entityName) {\n        // don't check entityName empty, getEntityDefinition() does it\n        EntityDefinition ed = getEntityDefinition(entityName)\n        if (ed == null) throw new EntityException(\"No entity found with name ${entityName}\")\n        return ed.makeEntityValue()\n    }\n\n    @Override\n    EntityFind find(String entityName) {\n        // don't check entityName empty, getEntityDefinition() does it\n        EntityDefinition ed = getEntityDefinition(entityName)\n        if (ed == null) throw new EntityException(\"No entity found with name ${entityName}\")\n        if (ed.isDynamicView && entityName.startsWith(\"DataDocument.\")) {\n            // see if it happens to be a DataDocument and if so make a special find that has its conditions too\n            // TODO: consider addition condition methods to EntityDynamicView and handling this lower level instead of here\n            return entityDataDocument.makeDataDocumentFind(entityName.substring(entityName.indexOf(\".\") + 1))\n        }\n        return ed.makeEntityFind()\n    }\n    @Override\n    EntityFind find(MNode node) {\n        String entityName = node.attribute(\"entity-name\")\n        if (entityName != null && entityName.contains(\"\\${\")) entityName = ecfi.resourceFacade.expand(entityName, null)\n        // don't check entityName empty, getEntityDefinition() does it\n        EntityDefinition ed = getEntityDefinition(entityName)\n        if (ed == null) throw new EntityException(\"No entity found with name ${entityName}\")\n        EntityFind ef\n        if (ed.isDynamicView && entityName.startsWith(\"DataDocument.\")) {\n            // see if it happens to be a DataDocument and if so make a special find that has its conditions too\n            // TODO: consider addition condition methods to EntityDynamicView and handling this lower level instead of here\n            ef = entityDataDocument.makeDataDocumentFind(entityName.substring(entityName.indexOf(\".\") + 1))\n        } else {\n            ef = ed.makeEntityFind()\n        }\n\n        String cache = node.attribute(\"cache\")\n        if (cache != null && !cache.isEmpty()) { ef.useCache(\"true\".equals(cache)) }\n        String forUpdate = node.attribute(\"for-update\")\n        if (forUpdate != null && !forUpdate.isEmpty()) ef.forUpdate(\"true\".equals(forUpdate))\n        String distinct = node.attribute(\"distinct\")\n        if (distinct != null && !distinct.isEmpty()) ef.distinct(\"true\".equals(distinct))\n        String useClone = node.attribute(\"use-clone\")\n        if (useClone != null && !useClone.isEmpty()) ef.useClone(\"true\".equals(useClone))\n        String offset = node.attribute(\"offset\")\n        if (offset != null && !offset.isEmpty()) ef.offset(Integer.valueOf(offset))\n        String limit = node.attribute(\"limit\")\n        if (limit != null && !limit.isEmpty()) ef.limit(Integer.valueOf(limit))\n        for (MNode sf in node.children(\"select-field\")) {\n            String fieldToSelect = sf.attribute(\"field-name\")\n            if (fieldToSelect == null || fieldToSelect.isEmpty()) continue\n            if (fieldToSelect.contains('${')) fieldToSelect = ecfi.resourceFacade.expandNoL10n(fieldToSelect, null)\n            ef.selectField(fieldToSelect)\n        }\n        for (MNode ob in node.children(\"order-by\")) ef.orderBy(ob.attribute(\"field-name\"))\n\n        if (node.hasChild(\"search-form-inputs\")) {\n            MNode sfiNode = node.first(\"search-form-inputs\")\n            String requireParameters = ecfi.resourceFacade.expand(sfiNode.attribute(\"require-parameters\"), null)\n            if (\"true\".equals(requireParameters)) ef.requireSearchFormParameters(true)\n\n            boolean paginate = !\"false\".equals(sfiNode.attribute(\"paginate\"))\n            MNode defaultParametersNode = sfiNode.first(\"default-parameters\")\n            String inputFieldsMapName = sfiNode.attribute(\"input-fields-map\")\n\n            Map<String, Object> inf = inputFieldsMapName ? (Map<String, Object>) ecfi.resourceFacade.expression(inputFieldsMapName, \"\") : ecfi.getEci().context\n            ef.searchFormMap(inf, defaultParametersNode?.attributes?.collectEntries {[it.key, ecfi.resourceFacade.expandNoL10n(it.value, \"\")]} as Map<String, Object>, sfiNode.attribute(\"skip-fields\"), sfiNode.attribute(\"default-order-by\"), paginate)\n        }\n\n        // logger.warn(\"=== shouldCache ${this.entityName} ${shouldCache()}, limit=${this.limit}, offset=${this.offset}, useCache=${this.useCache}, getEntityDef().getUseCache()=${this.getEntityDef().getUseCache()}\")\n        EntityCondition mainCond = getConditionFactoryImpl().makeActionConditions(node, ef.shouldCache())\n        if (mainCond != null) ef.condition(mainCond)\n\n        if (node.hasChild(\"having-econditions\")) {\n            for (MNode havingCond in node.children(\"having-econditions\"))\n                ef.havingCondition(getConditionFactoryImpl().makeActionConditions(havingCond, ef.shouldCache()))\n        }\n\n        return ef\n    }\n\n    /** Simple, fast find by primary key; doesn't filter find based on authz; doesn't use TransactionCache\n     * For cached queries this is about 50% faster (6M/s vs 4M/s) for non-cached queries only about 10% faster (500K vs 450K) */\n    @Override\n    EntityValue fastFindOne(String entityName, Boolean useCache, boolean disableAuthz, Object... values) {\n        ExecutionContextImpl ec = ecfi.getEci()\n        ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade\n        boolean enableAuthz = disableAuthz ? !aefi.disableAuthz() : false\n        try {\n            EntityDefinition ed = getEntityDefinition(entityName)\n            if (ed == null) throw new EntityException(\"Entity not found with name ${entityName}\")\n            EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo\n            FieldInfo[] pkFieldInfoArray = entityInfo.pkFieldInfoArray\n\n            if (ed.isViewEntity || !entityInfo.isEntityDatasourceFactoryImpl) {\n                if (logger.infoEnabled) logger.info(\"fastFindOne used with entity ${entityName} which is view entity (${ed.isViewEntity}) or not from EntityDatasourceFactoryImpl (${entityInfo.isEntityDatasourceFactoryImpl})\")\n                EntityFind ef = find(entityName)\n                if (useCache) ef.useCache(true)\n                if (disableAuthz) ef.disableAuthz()\n                for (int i = 0; i < pkFieldInfoArray.length; i++) {\n                    FieldInfo fi = (FieldInfo) pkFieldInfoArray[i]\n                    Object fieldValue = values[i]\n                    ef.condition(fi.name, fieldValue)\n                }\n                return ef.one()\n            }\n\n            ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(ed.getFullEntityName(),\n                    ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_VIEW, \"one\")\n            // really worth the overhead? if so change to handle singleCondField: .setParameters(simpleAndMap)\n            aefi.pushInternal(aei, !ed.entityInfo.authorizeSkipView, false)\n\n            try {\n                boolean doCache = useCache != null ? (useCache.booleanValue() ? !entityInfo.neverCache : false) : \"true\".equals(entityInfo.useCache)\n\n                boolean hasEmptyPk = false\n                int pkSize = pkFieldInfoArray.length\n                if (values.length != pkSize) throw new EntityException(\"Cannot do fastFindOne for entity ${entityName} with ${pkSize} primary key fields and ${values.length} values\")\n                EntityConditionImplBase whereCondition = (EntityConditionImplBase) null\n                if (pkSize == 1) {\n                    Object fieldValue = values[0]\n                    if (ObjectUtilities.isEmpty(fieldValue)) {\n                        hasEmptyPk = true\n                    } else if (doCache) {\n                        FieldInfo fi = (FieldInfo) pkFieldInfoArray[0]\n                        whereCondition = new FieldValueCondition(fi.conditionField, EntityCondition.EQUALS, fieldValue)\n                    }\n                } else {\n                    ListCondition listCond = doCache ? new ListCondition(null, EntityCondition.AND) : (ListCondition) null\n                    for (int i = 0; i < pkSize; i++) {\n                        Object fieldValue = values[i]\n                        if (ObjectUtilities.isEmpty(fieldValue)) {\n                            hasEmptyPk = true\n                            break\n                        }\n                        if (doCache) {\n                            FieldInfo fi = (FieldInfo) pkFieldInfoArray[i]\n                            listCond.addCondition(new FieldValueCondition(fi.conditionField, EntityCondition.EQUALS, fieldValue))\n                        }\n                    }\n                    if (doCache) whereCondition = listCond\n                }\n                // if any PK fields are null, for whatever reason in calling code, the result is null so no need to send to DB or cache or anything\n                if (hasEmptyPk) return (EntityValue) null\n\n                Cache<EntityCondition, EntityValueBase> entityOneCache = doCache ?\n                        ed.getCacheOne(entityCache) : (Cache<EntityCondition, EntityValueBase>) null\n                EntityValueBase cacheHit = doCache ? (EntityValueBase) entityOneCache.get(whereCondition) : (EntityValueBase) null\n\n                EntityValueBase newEntityValue\n                if (cacheHit != null) {\n                    if (cacheHit instanceof EntityCache.EmptyRecord) newEntityValue = (EntityValueBase) null\n                    else newEntityValue = cacheHit\n                } else {\n                    newEntityValue = fastFindOneExtended(ed, values)\n                    // put it in whether null or not (already know cacheHit is null)\n                    if (doCache) entityCache.putInOneCache(ed, whereCondition, newEntityValue, entityOneCache)\n                }\n\n                return newEntityValue\n            } finally {\n                // pop the ArtifactExecutionInfo\n                aefi.pop(aei)\n            }\n        } finally {\n            if (enableAuthz) aefi.enableAuthz()\n        }\n    }\n    public EntityValueBase fastFindOneExtended(EntityDefinition ed, Object... values) throws EntityException {\n        // table doesn't exist, just return null\n        if (!ed.tableExistsDbMetaOnly()) return null\n\n        FieldInfo[] fieldInfoArray = ed.entityInfo.allFieldInfoArray\n        FieldInfo[] pkFieldInfoArray = ed.entityInfo.pkFieldInfoArray\n        int pkSize = pkFieldInfoArray.length\n\n        final StringBuilder sqlTopLevel = new StringBuilder(500)\n        sqlTopLevel.append(\"SELECT \").append(ed.entityInfo.allFieldsSqlSelect)\n\n        // FROM Clause\n        sqlTopLevel.append(\" FROM \")\n        sqlTopLevel.append(ed.getFullTableName())\n\n        // WHERE clause; whereCondition will always be FieldValueCondition or ListCondition with FieldValueCondition\n        sqlTopLevel.append(\" WHERE \")\n        for (int i = 0; i < pkSize; i++) {\n            FieldInfo fi = (FieldInfo) pkFieldInfoArray[i]\n            // Object fieldValue = values[i]\n            if (i > 0) sqlTopLevel.append(\" AND \")\n            sqlTopLevel.append(fi.getFullColumnName()).append(\" = ?\")\n        }\n\n        String finalSql = sqlTopLevel.toString()\n\n        // run the SQL now that it is built\n        EntityValueBase newEntityValue = (EntityValueBase) null\n        Connection connection = (Connection) null\n        PreparedStatement ps = (PreparedStatement) null\n        ResultSet rs = (ResultSet) null\n        try {\n            connection = getConnection(ed.getEntityGroupName())\n            ps = connection.prepareStatement(finalSql, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)\n            for (int i = 0; i < pkSize; i++) {\n                FieldInfo fi = (FieldInfo) pkFieldInfoArray[i]\n                Object fieldValue = values[i]\n                fi.setPreparedStatementValue(ps, i + 1, fieldValue, ed, this);\n            }\n\n            boolean queryStats = getQueryStats()\n            long beforeQuery = queryStats ? System.nanoTime() : 0\n            rs = ps.executeQuery()\n            if (queryStats) saveQueryStats(ed, finalSql, System.nanoTime() - beforeQuery, false)\n\n            if (rs.next()) {\n                newEntityValue = new EntityValueImpl(ed, this)\n                LiteStringMap valueMap = newEntityValue.getValueMap()\n                int size = fieldInfoArray.length;\n                for (int i = 0; i < size; i++) {\n                    FieldInfo fi = fieldInfoArray[i];\n                    if (fi == null) break;\n                    fi.getResultSetValue(rs, i + 1, valueMap, this)\n                }\n            }\n        } catch (SQLException e) {\n            throw new EntityException(\"Error finding value\", e);\n        } finally {\n            try {\n                if (ps != null) ps.close()\n                if (rs != null) rs.close()\n                if (connection != null) connection.close();\n            } catch (SQLException sqle) { throw new EntityException(\"Error finding value\", sqle); }\n        }\n\n        return newEntityValue;\n    }\n\n    @Override\n    void createBulk(List<EntityValue> valueList) {\n        if (valueList == null || valueList.isEmpty()) return\n\n        EntityValue firstEv = (EntityValue) valueList.get(0)\n        String groupName = getEntityGroupName(firstEv.resolveEntityName())\n\n        EntityDatasourceFactory datasourceFactory = getDatasourceFactory(groupName)\n        if (datasourceFactory == null) throw new EntityException(\"Datasource Factory not found for group \" + groupName)\n\n        datasourceFactory.createBulk(valueList)\n    }\n\n    final static Map<String, String> operationByMethod = [get:'find', post:'create', put:'store', patch:'update', delete:'delete']\n    @Override\n    Object rest(String operation, List<String> entityPath, Map parameters, boolean masterNameInPath) {\n        if (operation == null || operation.length() == 0) throw new EntityException(\"Operation (method) must be specified\")\n        operation = operationByMethod.get(operation.toLowerCase()) ?: operation\n        if (!(operation in ['find', 'create', 'store', 'update', 'delete']))\n            throw new EntityException(\"Operation [${operation}] not supported, must be one of: get, post, put, patch, or delete for HTTP request methods or find, create, store, update, or delete for direct entity operations\")\n\n        if (entityPath == null || entityPath.size() == 0) throw new EntityException(\"No entity name or alias specified in path\")\n\n        boolean dependents = (parameters.dependents == 'true' || parameters.dependents == 'Y')\n        int dependentLevels = (parameters.dependentLevels ?: (dependents ? '2' : '0')) as int\n        String masterName = parameters.master\n\n        List<String> localPath = new ArrayList<String>(entityPath)\n\n        String firstEntityName = localPath.remove(0)\n        EntityDefinition firstEd = getEntityDefinition(firstEntityName)\n        // this exception will be thrown at lower levels, but just in case check it again here\n        if (firstEd == null) throw new EntityNotFoundException(\"No entity found with name or alias [${firstEntityName}]\")\n\n        // look for a master definition name as the next path element\n        if (masterNameInPath) {\n            if (masterName == null || masterName.length() == 0) {\n                if (localPath.size() > 0 && firstEd.getMasterDefinition(localPath.get(0)) != null) {\n                    masterName = localPath.remove(0)\n                } else {\n                    masterName = \"default\"\n                }\n            }\n            if (firstEd.getMasterDefinition(masterName) == null)\n                throw new EntityException(\"Master definition not found for entity [${firstEd.getFullEntityName()}], tried master name [${masterName}]\")\n        }\n\n        // if there are more path elements use one for each PK field of the entity\n        if (localPath.size() > 0) {\n            for (String pkFieldName in firstEd.getPkFieldNames()) {\n                String pkValue = localPath.remove(0)\n                if (!ObjectUtilities.isEmpty(pkValue)) parameters.put(pkFieldName, pkValue)\n                if (localPath.size() == 0) break\n            }\n        }\n\n        EntityDefinition lastEd = firstEd\n\n        // if there is still more in the path the next should be a relationship name or alias\n        while (localPath) {\n            String relationshipName = localPath.remove(0)\n            RelationshipInfo relInfo = lastEd.getRelationshipInfoMap().get(relationshipName)\n            if (relInfo == null) throw new EntityNotFoundException(\"No relationship found with name or alias [${relationshipName}] on entity [${lastEd.getShortAlias()?:''}:${lastEd.getFullEntityName()}]\")\n\n            String relEntityName = relInfo.relatedEntityName\n            EntityDefinition relEd = relInfo.relatedEd\n            if (relEd == null) throw new EntityNotFoundException(\"No entity found with name [${relEntityName}], related to entity [${lastEd.getShortAlias()?:''}:${lastEd.getFullEntityName()}] by relationship [${relationshipName}]\")\n\n            // TODO: How to handle more exotic relationships where they are not a dependent record, ie join on a field\n            // TODO:     other than a PK field? Should we lookup interim records to get field values to lookup the final\n            // TODO:     one? This would assume that all records exist along the path... need any variation for different\n            // TODO:     operations?\n\n            // if there are more path elements use one for each PK field of the entity\n            if (localPath.size() > 0) {\n                for (String pkFieldName in relEd.getPkFieldNames()) {\n                    // do we already have a value for this PK field? if so skip it...\n                    if (parameters.containsKey(pkFieldName)) continue\n\n                    String pkValue = localPath.remove(0)\n                    if (!ObjectUtilities.isEmpty(pkValue)) parameters.put(pkFieldName, pkValue)\n                    if (localPath.size() == 0) break\n                }\n            }\n\n            lastEd = relEd\n        }\n\n        // at this point we should have the entity we actually want to operate on, and all PK field values from the path\n        if (operation == 'find') {\n            if (lastEd.containsPrimaryKey(parameters)) {\n                // if we have a full PK lookup by PK and return the single value\n                Map<String, Object> pkValues = [:]\n                lastEd.entityInfo.setFields(parameters, pkValues, false, null, true)\n\n                if (masterName != null && masterName.length() > 0) {\n                    Map resultMap = find(lastEd.getFullEntityName()).condition(pkValues).oneMaster(masterName)\n                    if (resultMap == null) throw new EntityValueNotFoundException(\"No value found for entity [${lastEd.getShortAlias()?:''}:${lastEd.getFullEntityName()}] with key ${pkValues}\")\n                    return resultMap\n                } else {\n                    EntityValueBase evb = (EntityValueBase) find(lastEd.getFullEntityName()).condition(pkValues).one()\n                    if (evb == null) throw new EntityValueNotFoundException(\"No value found for entity [${lastEd.getShortAlias()?:''}:${lastEd.getFullEntityName()}] with key ${pkValues}\")\n                    Map resultMap = evb.getPlainValueMap(dependentLevels)\n                    return resultMap\n                }\n            } else {\n                // otherwise do a list find\n                EntityFind ef = find(lastEd.fullEntityName).searchFormMap(parameters, null, null, null, false)\n                // we don't want to go overboard with these requests, never do an unlimited find, if no limit use 100\n                if (!ef.getLimit()) ef.limit(100)\n\n                // support pagination, at least \"X-Total-Count\" header if find is paginated\n                long count = ef.count()\n                long pageIndex = ef.getPageIndex()\n                long pageSize = ef.getPageSize()\n                long pageMaxIndex = ((count - 1) as BigDecimal).divide(pageSize as BigDecimal, 0, RoundingMode.DOWN).longValue()\n                long pageRangeLow = pageIndex * pageSize + 1\n                long pageRangeHigh = (pageIndex * pageSize) + pageSize\n                if (pageRangeHigh > count) pageRangeHigh = count\n\n                parameters.put('xTotalCount', count)\n                parameters.put('xPageIndex', pageIndex)\n                parameters.put('xPageSize', pageSize)\n                parameters.put('xPageMaxIndex', pageMaxIndex)\n                parameters.put('xPageRangeLow', pageRangeLow)\n                parameters.put('xPageRangeHigh', pageRangeHigh)\n\n                if (masterName != null && masterName.length() > 0) {\n                    List resultList = ef.listMaster(masterName)\n                    return resultList\n                } else {\n                    EntityList el = ef.list()\n                    List resultList = el.getPlainValueList(dependentLevels)\n                    return resultList\n                }\n            }\n        } else {\n            // use the entity auto service runner for other operations (create, store, update, delete)\n            Map result = ecfi.serviceFacade.sync().name(operation, lastEd.fullEntityName).parameters(parameters).call()\n            return result\n        }\n    }\n\n    EntityList getValueListFromPlainMap(Map value, String entityName) {\n        if (entityName == null || entityName.length() == 0) entityName = value.\"_entity\"\n        if (entityName == null || entityName.length() == 0) throw new EntityException(\"No entityName passed and no _entity field in value Map\")\n\n        EntityDefinition ed = getEntityDefinition(entityName)\n        if (ed == null) throw new EntityNotFoundException(\"Not entity found with name ${entityName}\")\n\n        EntityList valueList = new EntityListImpl(this)\n        addValuesFromPlainMapRecursive(ed, value, valueList, null)\n        return valueList\n    }\n    void addValuesFromPlainMapRecursive(EntityDefinition ed, Map value, EntityList valueList, Map<String, Object> parentPks) {\n        // add in all of the main entity's primary key fields, this is necessary for auto-generated, and to\n        //     allow them to be left out of related records\n        if (parentPks != null) {\n            for (Map.Entry<String, Object> entry in parentPks.entrySet())\n                if (!value.containsKey(entry.key)) value.put(entry.key, entry.value)\n        }\n\n        EntityValue newEntityValue = makeValue(ed.getFullEntityName())\n        newEntityValue.setFields(value, true, null, null)\n        valueList.add(newEntityValue)\n\n        Map<String, Object> sharedPkMap = newEntityValue.getPrimaryKeys()\n        if (parentPks != null) {\n            for (Map.Entry<String, Object> entry in parentPks.entrySet())\n                if (!sharedPkMap.containsKey(entry.key)) sharedPkMap.put(entry.key, entry.value)\n        }\n\n        // check parameters Map for relationships and other entities\n        Map nonFieldEntries = ed.entityInfo.cloneMapRemoveFields(value, null)\n        for (Map.Entry entry in nonFieldEntries.entrySet()) {\n            Object relParmObj = entry.getValue()\n            if (relParmObj == null) continue\n            // if the entry is not a Map or List ignore it, we're only looking for those\n            if (!(relParmObj instanceof Map) && !(relParmObj instanceof List)) continue\n\n            String entryName = (String) entry.getKey()\n            if (parentPks != null && parentPks.containsKey(entryName)) continue\n            if (EntityAutoServiceRunner.otherFieldsToSkip.contains(entryName)) continue\n\n            EntityDefinition subEd = null\n            Map<String, Object> pkMap = null\n            RelationshipInfo relInfo = ed.getRelationshipInfo(entryName)\n            if (relInfo != null) {\n                if (!relInfo.mutable) continue\n                subEd = relInfo.relatedEd\n                // this is a relationship so add mapped key fields to the parentPks if any field names are different\n                pkMap = new HashMap<>(sharedPkMap)\n                pkMap.putAll(relInfo.getTargetParameterMap(sharedPkMap))\n            } else if (isEntityDefined(entryName)) {\n                subEd = getEntityDefinition(entryName)\n                pkMap = sharedPkMap\n            }\n            if (subEd == null) continue\n\n            boolean isEntityValue = relParmObj instanceof EntityValue\n            if (relParmObj instanceof Map && !isEntityValue) {\n                addValuesFromPlainMapRecursive(subEd, (Map) relParmObj, valueList, pkMap)\n            } else if (relParmObj instanceof List) {\n                for (Object relParmEntry in relParmObj) {\n                    if (relParmEntry instanceof Map) {\n                        addValuesFromPlainMapRecursive(subEd, (Map) relParmEntry, valueList, pkMap)\n                    } else {\n                        logger.warn(\"In entity values from plain map for entity ${ed.getFullEntityName()} found list for sub-object ${entryName} with a non-Map entry: ${relParmEntry}\")\n                    }\n                }\n            } else {\n                if (isEntityValue) {\n                    if (logger.isTraceEnabled()) logger.trace(\"In entity values from plain map for entity ${ed.getFullEntityName()} found sub-object ${entryName} which is not a Map or List: ${relParmObj}\")\n                } else {\n                    logger.warn(\"In entity values from plain map for entity ${ed.getFullEntityName()} found sub-object ${entryName} which is not a Map or List: ${relParmObj}\")\n                }\n            }\n        }\n    }\n\n\n    @Override\n    EntityListIterator sqlFind(String sql, List<Object> sqlParameterList, String entityName, List<String> fieldList) {\n        EntityDefinition ed = this.getEntityDefinition(entityName)\n        this.entityDbMeta.checkTableRuntime(ed)\n\n        Connection con = getConnection(getEntityGroupName(entityName))\n        PreparedStatement ps\n        try {\n            FieldInfo[] fiArray\n            if (fieldList != null) {\n                fiArray = new FieldInfo[fieldList.size()]\n                int fiArrayIndex = 0\n                for (String fieldName in fieldList) {\n                    FieldInfo fi = ed.getFieldInfo(fieldName)\n                    if (fi == null) throw new BaseArtifactException(\"Field ${fieldName} not found for entity ${entityName}\")\n                    fiArray[fiArrayIndex] = fi\n                    fiArrayIndex++\n                }\n            } else {\n                fiArray = ed.entityInfo.allFieldInfoArray\n            }\n\n            // create the PreparedStatement\n            ps = con.prepareStatement(sql, ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY)\n            // set the parameter values\n            if (sqlParameterList != null) {\n                int paramIndex = 1\n                for (Object parameterValue in sqlParameterList) {\n                    FieldInfo fi = (FieldInfo) fiArray[paramIndex - 1]\n                    fi.setPreparedStatementValue(ps, paramIndex, parameterValue, ed, this)\n                    paramIndex++\n                }\n            }\n\n            // do the actual query\n            long timeBefore = System.currentTimeMillis()\n            ResultSet rs = ps.executeQuery()\n            if (logger.traceEnabled) logger.trace(\"Executed query with SQL [${sql}] and parameters [${sqlParameterList}] in [${(System.currentTimeMillis()-timeBefore)/1000}] seconds\")\n            // make and return the eli\n            EntityListIterator eli = new EntityListIteratorImpl(con, rs, ed, fiArray, this, null, null, null)\n            return eli\n        } catch (SQLException e) {\n            throw new EntityException(\"SQL Exception with statement:\" + sql + \"; \" + e.toString(), e)\n        }\n    }\n\n    @Override\n    ArrayList<Map> getDataDocuments(String dataDocumentId, EntityCondition condition, Timestamp fromUpdateStamp,\n                                Timestamp thruUpdatedStamp) {\n        return entityDataDocument.getDataDocuments(dataDocumentId, condition, fromUpdateStamp, thruUpdatedStamp)\n    }\n\n    @Override\n    ArrayList<Map> getDataFeedDocuments(String dataFeedId, Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp) {\n        return entityDataFeed.getFeedDocuments(dataFeedId, fromUpdateStamp, thruUpdatedStamp)\n    }\n\n    void tempSetSequencedIdPrimary(String seqName, long nextSeqNum, long bankSize) {\n        long[] bank = new long[2]\n        bank[0] = nextSeqNum\n        bank[1] = nextSeqNum + bankSize\n        entitySequenceBankCache.put(seqName, bank)\n    }\n    void tempResetSequencedIdPrimary(String seqName) {\n        entitySequenceBankCache.put(seqName, null)\n    }\n\n    @Override\n    String sequencedIdPrimary(String seqName, Long staggerMax, Long bankSize) {\n        try {\n            // is the seqName an entityName?\n            if (isEntityDefined(seqName)) {\n                EntityDefinition ed = getEntityDefinition(seqName)\n                if (ed.entityInfo.sequencePrimaryUseUuid) return UUID.randomUUID().toString()\n            }\n        } catch (EntityException e) {\n            // do nothing, just means seqName is not an entity name\n            if (isTraceEnabled) logger.trace(\"Ignoring exception for entity not found: ${e.toString()}\")\n        }\n        // fall through to default to the db sequenced ID\n        long staggerMaxPrim = staggerMax != null ? staggerMax.longValue() : 0L\n        long bankSizePrim = (bankSize != null && bankSize.longValue() > 0) ? bankSize.longValue() : defaultBankSize\n        return dbSequencedIdPrimary(seqName, staggerMaxPrim, bankSizePrim)\n    }\n\n    String sequencedIdPrimaryEd(EntityDefinition ed) {\n        EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo\n        try {\n            // is the seqName an entityName?\n            if (entityInfo.sequencePrimaryUseUuid) return UUID.randomUUID().toString()\n        } catch (EntityException e) {\n            // do nothing, just means seqName is not an entity name\n            if (isTraceEnabled) logger.trace(\"Ignoring exception for entity not found: ${e.toString()}\")\n        }\n        // fall through to default to the db sequenced ID\n        return dbSequencedIdPrimary(ed.getFullEntityName(), entityInfo.sequencePrimaryStagger, entityInfo.sequenceBankSize)\n    }\n\n    protected final static long defaultBankSize = 50L\n    protected Lock getDbSequenceLock(String seqName) {\n        Lock oldLock, dbSequenceLock = dbSequenceLocks.get(seqName)\n        if (dbSequenceLock == null) {\n            dbSequenceLock = new ReentrantLock()\n            oldLock = dbSequenceLocks.putIfAbsent(seqName, dbSequenceLock)\n            if (oldLock != null) return oldLock\n        }\n        return dbSequenceLock\n    }\n    protected String dbSequencedIdPrimary(String seqName, long staggerMax, long bankSize) {\n\n        // TODO: find some way to get this running non-synchronized for performance reasons (right now if not\n        // TODO:     synchronized the forUpdate won't help if the record doesn't exist yet, causing errors in high\n        // TODO:     traffic creates; is it creates only?)\n\n        Lock dbSequenceLock = getDbSequenceLock(seqName)\n        dbSequenceLock.lock()\n\n        // NOTE: simple approach with forUpdate, not using the update/select \"ethernet\" approach used in OFBiz; consider\n        // that in the future if there are issues with this approach\n\n        try {\n            // first get a bank if we don't have one already\n            long[] bank = (long[]) entitySequenceBankCache.get(seqName)\n            if (bank == null || bank[0] > bank[1]) {\n                if (bank == null) {\n                    bank = new long[2]\n                    bank[0] = 0\n                    bank[1] = -1\n                    entitySequenceBankCache.put(seqName, bank)\n                }\n\n                ecfi.transactionFacade.runRequireNew(null, \"Error getting primary sequenced ID\", true, true, {\n                    ArtifactExecutionFacadeImpl aefi = ecfi.getEci().artifactExecutionFacade\n                    boolean enableAuthz = !aefi.disableAuthz()\n                    try {\n                        EntityValue svi = find(\"moqui.entity.SequenceValueItem\").condition(\"seqName\", seqName)\n                                .useCache(false).forUpdate(true).one()\n                        if (svi == null) {\n                            svi = makeValue(\"moqui.entity.SequenceValueItem\")\n                            svi.set(\"seqName\", seqName)\n                            // a new tradition: start sequenced values at one hundred thousand instead of ten thousand\n                            bank[0] = 100000L\n                            bank[1] = bank[0] + bankSize\n                            svi.set(\"seqNum\", bank[1])\n                            svi.create()\n                        } else {\n                            Long lastSeqNum = svi.getLong(\"seqNum\")\n                            bank[0] = (lastSeqNum > bank[0] ? lastSeqNum + 1L : bank[0])\n                            bank[1] = bank[0] + bankSize\n                            svi.set(\"seqNum\", bank[1])\n                            svi.update()\n                        }\n                    } finally {\n                        if (enableAuthz) aefi.enableAuthz()\n                    }\n                })\n            }\n\n            long seqNum = bank[0]\n            if (staggerMax > 1L) {\n                long stagger = Math.round(Math.random() * staggerMax)\n                bank[0] = seqNum + stagger\n                // NOTE: if bank[0] > bank[1] because of this just leave it and the next time we try to get a sequence\n                //     value we'll get one from a new bank\n            } else {\n                bank[0] = seqNum + 1L\n            }\n\n            return sequencedIdPrefix != null ? sequencedIdPrefix + seqNum : seqNum\n        } finally {\n            dbSequenceLock.unlock()\n        }\n    }\n\n    Set<String> getAllEntityNamesInGroup(String groupName) {\n        Set<String> groupEntityNames = new TreeSet<String>()\n        for (String entityName in getAllEntityNames()) {\n            // use the entity/group cache handled by getEntityGroupName()\n            if (getEntityGroupName(entityName) == groupName) groupEntityNames.add(entityName)\n        }\n        return groupEntityNames\n    }\n\n    @Override\n    String getEntityGroupName(String entityName) {\n        String entityGroupName = (String) entityGroupNameMap.get(entityName)\n        if (entityGroupName != null) return entityGroupName\n        EntityDefinition ed\n        // for entity group name just ignore EntityException on getEntityDefinition\n        try { ed = getEntityDefinition(entityName) } catch (EntityException e) { return null }\n        // may happen if all entity names includes a DB view entity or other that doesn't really exist\n        if (ed == null) return null\n        // always intern the group name so it can be used with an identity compare\n        entityGroupName = ed.getEntityGroupName()?.intern()\n        entityGroupNameMap.put(entityName, entityGroupName)\n        return entityGroupName\n    }\n\n    @Override Connection getConnection(String groupName) { return getConnection(groupName, false) }\n    @Override Connection getConnection(String groupName, boolean useClone) {\n        TransactionFacadeImpl tfi = ecfi.transactionFacade\n        if (!tfi.isTransactionOperable()) throw new EntityException(\"Cannot get connection, transaction not in operable status (${tfi.getStatusString()})\")\n\n        String groupToUse = useClone ? getDatasourceCloneName(groupName) : groupName\n\n        Connection stashed = tfi.getTxConnection(groupToUse)\n        if (stashed != null) return stashed\n\n        EntityDatasourceFactory edf = getDatasourceFactory(groupToUse)\n        DataSource ds = edf.getDataSource()\n        if (ds == null) throw new EntityException(\"Cannot get JDBC Connection for group-name [${groupToUse}] because it has no DataSource\")\n        Connection newCon\n        if (ds instanceof XADataSource) {\n            newCon = tfi.enlistConnection(((XADataSource) ds).getXAConnection())\n        } else {\n            newCon = ds.getConnection()\n        }\n        if (newCon != null) newCon = tfi.stashTxConnection(groupToUse, newCon)\n        return newCon\n    }\n\n    @Override EntityDataLoader makeDataLoader() { return new EntityDataLoaderImpl(this) }\n    @Override EntityDataWriter makeDataWriter() { return new EntityDataWriterImpl(this) }\n\n    @Override SimpleEtl.Loader makeEtlLoader() { return new EtlLoader(this) }\n    static class EtlLoader implements SimpleEtl.Loader {\n        private boolean beganTransaction = false\n        private EntityFacadeImpl efi\n        private boolean useTryInsert = false, dummyFks = false\n        EtlLoader(EntityFacadeImpl efi) { this.efi = efi }\n        EtlLoader useTryInsert() { useTryInsert = true; return this }\n        EtlLoader dummyFks() { dummyFks = true; return this }\n\n        @Override void init(Integer timeout) {\n            if (!efi.ecfi.transactionFacade.isTransactionActive()) beganTransaction = efi.ecfi.transactionFacade.begin(timeout)\n        }\n        @Override void load(SimpleEtl.Entry entry) throws Exception {\n            String entityName = entry.getEtlType()\n            if (!efi.isEntityDefined(entityName)) {\n                logger.info(\"Tried to load ETL entry with invalid entity name \" + entityName)\n                return\n            }\n            EntityDefinition ed = efi.getEntityDefinition(entityName)\n            if (ed == null) throw new BaseArtifactException(\"Could not find entity ${entityName}\")\n            // NOTE: the following uses the same pattern as EntityDataLoaderImpl.LoadValueHandler\n            if (dummyFks || useTryInsert) {\n                EntityValue curValue = ed.makeEntityValue()\n                curValue.setAll(entry.getEtlValues())\n                if (useTryInsert) {\n                    try {\n                        curValue.create()\n                    } catch (EntityException ce) {\n                        if (logger.isTraceEnabled()) logger.trace(\"Insert failed, trying update (${ce.toString()})\")\n                        boolean noFksMissing = true\n                        if (dummyFks) noFksMissing = curValue.checkFks(true)\n                        // retry, then if this fails we have a real error so let the exception fall through\n                        // if there were no FKs missing then just do an update, if there were that may have been the error so createOrUpdate\n                        if (noFksMissing) {\n                            try {\n                                curValue.update()\n                            } catch (EntityException ue) {\n                                logger.error(\"Error in update after attempt to create (tryInsert), here is the create error: \", ce)\n                                throw ue\n                            }\n                        } else {\n                            curValue.createOrUpdate()\n                        }\n                    }\n                } else {\n                    if (dummyFks) curValue.checkFks(true)\n                    curValue.createOrUpdate()\n                }\n            } else {\n                Map<String, Object> results = new HashMap()\n                EntityAutoServiceRunner.storeEntity(efi.ecfi.getEci(), ed, entry.getEtlValues(), results, null)\n                if (results.size() > 0) entry.getEtlValues().putAll(results)\n            }\n        }\n        @Override void complete(SimpleEtl etl) {\n            if (etl.hasError()) {\n                efi.ecfi.transactionFacade.rollback(beganTransaction, \"Error in ETL load\", etl.getSingleErrorCause())\n            } else if (beganTransaction) {\n                efi.ecfi.transactionFacade.commit()\n            }\n        }\n    }\n\n    @Override\n    EntityValue makeValue(Element element) {\n        if (!element) return null\n\n        String entityName = element.getTagName()\n        if (entityName.indexOf('-') > 0) entityName = entityName.substring(entityName.indexOf('-') + 1)\n        if (entityName.indexOf(':') > 0) entityName = entityName.substring(entityName.indexOf(':') + 1)\n\n        EntityValueImpl newValue = (EntityValueImpl) makeValue(entityName)\n        EntityDefinition ed = newValue.getEntityDefinition()\n\n        for (String fieldName in ed.getAllFieldNames()) {\n            String attrValue = element.getAttribute(fieldName)\n            if (attrValue) {\n                newValue.setString(fieldName, attrValue)\n            } else {\n                org.w3c.dom.NodeList seList = element.getElementsByTagName(fieldName)\n                Element subElement = seList.getLength() > 0 ? (Element) seList.item(0) : null\n                if (subElement) newValue.setString(fieldName, StringUtilities.elementValue(subElement))\n            }\n        }\n\n        return newValue\n    }\n\n    /* =============== */\n    /* Utility Methods */\n    /* =============== */\n\n    protected Map<String, Map<String, String>> javaTypeByGroup = [:]\n    String getFieldJavaType(String fieldType, EntityDefinition ed) {\n        String groupName = ed.getEntityGroupName()\n        Map<String, String> javaTypeMap = javaTypeByGroup.get(groupName)\n        if (javaTypeMap != null) {\n            String ft = javaTypeMap.get(fieldType)\n            if (ft != null) return ft\n        }\n        return getFieldJavaTypeFromDbNode(groupName, fieldType, ed)\n    }\n    protected getFieldJavaTypeFromDbNode(String groupName, String fieldType, EntityDefinition ed) {\n        Map<String, String> javaTypeMap = javaTypeByGroup.get(groupName)\n        if (javaTypeMap == null) {\n            javaTypeMap = new HashMap()\n            javaTypeByGroup.put(groupName, javaTypeMap)\n        }\n\n        MNode databaseNode = this.getDatabaseNode(groupName)\n        MNode databaseTypeNode = databaseNode ?\n                databaseNode.first({ MNode it -> it.name == \"database-type\" && it.attribute('type') == fieldType }) : null\n        String javaType = databaseTypeNode?.attribute(\"java-type\")\n        if (!javaType) {\n            MNode databaseListNode = ecfi.confXmlRoot.first(\"database-list\")\n            MNode dictionaryTypeNode = databaseListNode.first({ MNode it -> it.name == \"dictionary-type\" && it.attribute('type') == fieldType })\n            javaType = dictionaryTypeNode?.attribute(\"java-type\")\n            if (!javaType) throw new EntityException(\"Could not find Java type for field type [${fieldType}] on entity [${ed.getFullEntityName()}]\")\n        }\n        javaTypeMap.put(fieldType, javaType)\n        return javaType\n    }\n\n    protected Map<String, Map<String, String>> sqlTypeByGroup = [:]\n    protected String getFieldSqlType(String fieldType, EntityDefinition ed) {\n        String groupName = ed.getEntityGroupName()\n        Map<String, String> sqlTypeMap = (Map<String, String>) sqlTypeByGroup.get(groupName)\n        if (sqlTypeMap != null) {\n            String st = (String) sqlTypeMap.get(fieldType)\n            if (st != null) return st\n        }\n        return getFieldSqlTypeFromDbNode(groupName, fieldType, ed)\n    }\n    protected getFieldSqlTypeFromDbNode(String groupName, String fieldType, EntityDefinition ed) {\n        Map<String, String> sqlTypeMap = sqlTypeByGroup.get(groupName)\n        if (sqlTypeMap == null) {\n            sqlTypeMap = new HashMap()\n            sqlTypeByGroup.put(groupName, sqlTypeMap)\n        }\n\n        MNode databaseNode = this.getDatabaseNode(groupName)\n        MNode databaseTypeNode = databaseNode ?\n                databaseNode.first({ MNode it -> it.name == \"database-type\" && it.attribute('type') == fieldType }) : null\n        String sqlType = databaseTypeNode?.attribute(\"sql-type\")\n        if (!sqlType) {\n            MNode databaseListNode = ecfi.confXmlRoot.first(\"database-list\")\n            MNode dictionaryTypeNode = databaseListNode\n                    .first({ MNode it -> it.name == \"dictionary-type\" && it.attribute('type') == fieldType })\n            sqlType = dictionaryTypeNode?.attribute(\"default-sql-type\")\n            if (!sqlType) throw new EntityException(\"Could not find SQL type for field type [${fieldType}] on entity [${ed.getFullEntityName()}]\")\n        }\n        sqlTypeMap.put(fieldType, sqlType)\n        return sqlType\n\n    }\n\n    /** For pretty-print of field values based on field type */\n    String formatFieldString(String entityName, String fieldName, String value) {\n        if (value == null || value.isEmpty()) return \"\"\n        EntityDefinition ed = getEntityDefinition(entityName)\n        if (ed == null) return value\n        FieldInfo fi = ed.getFieldInfo(fieldName)\n        if (fi == null) return value\n        String outVal = value\n        if (fi.typeValue == 2) {\n            if (value.matches(\"\\\\d*\")) {\n                // date-time with only digits, ms since epoch value\n                outVal = ecfi.l10n.format(new Timestamp(Long.parseLong(value)), null)\n            }\n        } else if (fi.type.startsWith(\"currency-\")) {\n            outVal = ecfi.l10n.format(new BigDecimal(value), \"#,##0.00#\")\n        }\n        // logger.warn(\"formatFieldString ${entityName}:${fieldName} value ${value} outVal ${outVal}\")\n        return outVal\n    }\n\n    // Entity Field Java Types\n    public static final int ENTITY_STRING = 1\n    public static final int ENTITY_TIMESTAMP = 2\n    public static final int ENTITY_TIME = 3\n    public static final int ENTITY_DATE = 4\n    public static final int ENTITY_INTEGER = 5\n    public static final int ENTITY_LONG = 6\n    public static final int ENTITY_FLOAT = 7\n    public static final int ENTITY_DOUBLE = 8\n    public static final int ENTITY_BIG_DECIMAL = 9\n    public static final int ENTITY_BOOLEAN = 10\n    public static final int ENTITY_OBJECT = 11\n    public static final int ENTITY_BLOB = 12\n    public static final int ENTITY_CLOB = 13\n    public static final int ENTITY_UTIL_DATE = 14\n    public static final int ENTITY_COLLECTION = 15\n\n    protected static final Map<String, Integer> fieldTypeIntMap = [\n            \"id\":ENTITY_STRING, \"id-long\":ENTITY_STRING, \"text-indicator\":ENTITY_STRING, \"text-short\":ENTITY_STRING,\n            \"text-medium\":ENTITY_STRING, \"text-intermediate\":ENTITY_STRING, \"text-long\":ENTITY_STRING, \"text-very-long\":ENTITY_STRING,\n            \"date-time\":ENTITY_TIMESTAMP, \"time\":ENTITY_TIME, \"date\":ENTITY_DATE,\n            \"number-integer\":ENTITY_LONG, \"number-float\":ENTITY_DOUBLE,\n            \"number-decimal\":ENTITY_BIG_DECIMAL, \"currency-amount\":ENTITY_BIG_DECIMAL, \"currency-precise\":ENTITY_BIG_DECIMAL,\n            \"binary-very-long\":ENTITY_BLOB ]\n    protected static final Map<String, String> fieldTypeJavaMap = [\n            \"id\":\"java.lang.String\", \"id-long\":\"java.lang.String\",\n            \"text-indicator\":\"java.lang.String\", \"text-short\":\"java.lang.String\", \"text-medium\":\"java.lang.String\",\n            \"text-intermediate\":\"java.lang.String\", \"text-long\":\"java.lang.String\", \"text-very-long\":\"java.lang.String\",\n            \"date-time\":\"java.sql.Timestamp\", \"time\":\"java.sql.Time\", \"date\":\"java.sql.Date\",\n            \"number-integer\":\"java.lang.Long\", \"number-float\":\"java.lang.Double\",\n            \"number-decimal\":\"java.math.BigDecimal\", \"currency-amount\":\"java.math.BigDecimal\", \"currency-precise\":\"java.math.BigDecimal\",\n            \"binary-very-long\":\"java.sql.Blob\" ]\n    protected static final Map<String, Integer> javaIntTypeMap = [\n            \"java.lang.String\":ENTITY_STRING, \"String\":ENTITY_STRING, \"org.codehaus.groovy.runtime.GStringImpl\":ENTITY_STRING, \"char[]\":ENTITY_STRING,\n            \"java.sql.Timestamp\":ENTITY_TIMESTAMP, \"Timestamp\":ENTITY_TIMESTAMP,\n            \"java.sql.Time\":ENTITY_TIME, \"Time\":ENTITY_TIME,\n            \"java.sql.Date\":ENTITY_DATE, \"Date\":ENTITY_DATE,\n            \"java.lang.Integer\":ENTITY_INTEGER, \"Integer\":ENTITY_INTEGER,\n            \"java.lang.Long\":ENTITY_LONG,\"Long\":ENTITY_LONG,\n            \"java.lang.Float\":ENTITY_FLOAT, \"Float\":ENTITY_FLOAT,\n            \"java.lang.Double\":ENTITY_DOUBLE, \"Double\":ENTITY_DOUBLE,\n            \"java.math.BigDecimal\":ENTITY_BIG_DECIMAL, \"BigDecimal\":ENTITY_BIG_DECIMAL,\n            \"java.lang.Boolean\":ENTITY_BOOLEAN, \"Boolean\":ENTITY_BOOLEAN,\n            \"java.lang.Object\":ENTITY_OBJECT, \"Object\":ENTITY_OBJECT,\n            \"java.sql.Blob\":ENTITY_BLOB, \"Blob\":ENTITY_BLOB, \"byte[]\":ENTITY_BLOB, \"java.nio.ByteBuffer\":ENTITY_BLOB, \"java.nio.HeapByteBuffer\":ENTITY_BLOB,\n            \"java.sql.Clob\":ENTITY_CLOB, \"Clob\":ENTITY_CLOB,\n            \"java.util.Date\":ENTITY_UTIL_DATE,\n            \"java.util.ArrayList\":ENTITY_COLLECTION, \"java.util.HashSet\":ENTITY_COLLECTION, \"java.util.LinkedHashSet\":ENTITY_COLLECTION, \"java.util.LinkedList\":ENTITY_COLLECTION]\n    static int getJavaTypeInt(String javaType) {\n        Integer typeInt = (Integer) javaIntTypeMap.get(javaType)\n        if (typeInt == null) throw new EntityException(\"Java type \" + javaType + \" not supported for entity fields\")\n        return typeInt\n    }\n\n    final Map<String, EntityJavaUtil.QueryStatsInfo> queryStatsInfoMap = new HashMap<>()\n    void saveQueryStats(EntityDefinition ed, String sql, long queryTime, boolean isError) {\n        EntityJavaUtil.QueryStatsInfo qsi = queryStatsInfoMap.get(sql)\n        if (qsi == null) {\n            qsi = new EntityJavaUtil.QueryStatsInfo(ed.getFullEntityName(), sql)\n            queryStatsInfoMap.put(sql, qsi)\n        }\n        qsi.countHit(this, queryTime, isError)\n    }\n    ArrayList<Map<String, Object>> getQueryStatsList(String orderByField, String entityFilter, String sqlFilter) {\n        ArrayList<Map<String, Object>> qsl = new ArrayList<>(queryStatsInfoMap.size())\n        boolean hasEntityFilter = entityFilter != null && entityFilter.length() > 0\n        boolean hasSqlFilter = sqlFilter != null && sqlFilter.length() > 0\n        for (EntityJavaUtil.QueryStatsInfo qsi in queryStatsInfoMap.values()) {\n            if (hasEntityFilter && !qsi.entityName.matches(\"(?i).*\" + entityFilter + \".*\")) continue\n            if (hasSqlFilter && !qsi.sql.matches(\"(?i).*\" + sqlFilter + \".*\")) continue\n            qsl.add(qsi.makeDisplayMap())\n        }\n        if (orderByField) CollectionUtilities.orderMapList(qsl, [orderByField])\n        return qsl\n    }\n    void clearQueryStats() { queryStatsInfoMap.clear() }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityFindBase.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity\n\nimport groovy.transform.CompileStatic\nimport org.moqui.BaseException\nimport org.moqui.context.ArtifactAuthorizationException\nimport org.moqui.context.ArtifactExecutionInfo\nimport org.moqui.entity.*\nimport org.moqui.etl.SimpleEtl\nimport org.moqui.etl.SimpleEtl.StopException\nimport org.moqui.impl.context.ArtifactExecutionFacadeImpl\nimport org.moqui.impl.context.ArtifactExecutionInfoImpl\nimport org.moqui.impl.context.ContextJavaUtil\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.impl.context.TransactionCache\nimport org.moqui.impl.context.TransactionFacadeImpl\nimport org.moqui.impl.entity.condition.*\nimport org.moqui.impl.entity.EntityJavaUtil.FieldOrderOptions\nimport org.moqui.util.CollectionUtilities\nimport org.moqui.util.MNode\nimport org.moqui.util.ObjectUtilities\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport javax.cache.Cache\nimport java.sql.ResultSet\nimport java.sql.SQLException\nimport java.sql.Timestamp\n\n@CompileStatic\nabstract class EntityFindBase implements EntityFind {\n    protected final static Logger logger = LoggerFactory.getLogger(EntityFindBase.class)\n\n    // these error strings are here for convenience for LocalizedMessage records\n    // NOTE: don't change these unless there is a really good reason, will break localization\n    private static final String ONE_ERROR = 'Error finding one ${entityName} by ${condition}'\n    private static final String LIST_ERROR = 'Error finding list of ${entityName} by ${condition}'\n    private static final String COUNT_ERROR = 'Error finding count of ${entityName} by ${condition}'\n\n    final static int defaultResultSetType = ResultSet.TYPE_FORWARD_ONLY\n\n    public final EntityFacadeImpl efi\n    public final TransactionCache txCache\n\n    protected String entityName\n    protected EntityDefinition entityDef = (EntityDefinition) null\n    protected EntityDynamicViewImpl dynamicView = (EntityDynamicViewImpl) null\n\n    protected String singleCondField = (String) null\n    protected Object singleCondValue = null\n    protected Map<String, Object> simpleAndMap = (Map<String, Object>) null\n    protected Boolean tempHasFullPk = (Boolean) null\n\n    protected EntityConditionImplBase whereEntityCondition = (EntityConditionImplBase) null\n    protected EntityConditionImplBase havingEntityCondition = (EntityConditionImplBase) null\n\n    protected ArrayList<String> fieldsToSelect = (ArrayList<String>) null\n    protected ArrayList<String> orderByFields = (ArrayList<String>) null\n\n    protected Boolean useCache = (Boolean) null\n\n    protected boolean distinct = false\n    protected Integer offset = (Integer) null\n    protected Integer limit = (Integer) null\n    protected boolean forUpdate = false\n    protected boolean useClone = false\n\n    protected int resultSetType = defaultResultSetType\n    protected int resultSetConcurrency = ResultSet.CONCUR_READ_ONLY\n    protected Integer fetchSize = (Integer) null\n    protected Integer maxRows = (Integer) null\n\n    protected boolean disableAuthz = false\n    protected boolean requireSearchFormParameters = false\n    protected boolean hasSearchFormParameters = false\n\n    protected ArrayList<String> queryTextList = new ArrayList<>()\n\n\n    EntityFindBase(EntityFacadeImpl efi, String entityName) {\n        this.efi = efi\n        this.entityName = entityName\n        TransactionFacadeImpl tfi = efi.ecfi.transactionFacade\n        txCache = tfi.getTransactionCache()\n        // if (!tfi.isTransactionInPlace()) logger.warn(\"No transaction in place, creating find for entity ${entityName}\")\n    }\n    EntityFindBase(EntityFacadeImpl efi, EntityDefinition ed) {\n        this.efi = efi\n        entityName = ed.fullEntityName\n        entityDef = ed\n        TransactionFacadeImpl tfi = efi.ecfi.transactionFacade\n        txCache = tfi.getTransactionCache()\n    }\n\n    @Override EntityFind entity(String name) { entityName = name; return this }\n    @Override String getEntity() { return entityName }\n\n    // ======================== Conditions (Where and Having) =================\n\n    @Override\n    EntityFind condition(String fieldName, Object value) {\n        boolean noSam = (simpleAndMap == null)\n        boolean noScf = (singleCondField == null)\n        if (noSam && noScf) {\n            singleCondField = fieldName\n            singleCondValue = value\n        } else {\n            if (noSam) simpleAndMap = new LinkedHashMap()\n            if (!noScf) {\n                simpleAndMap.put(singleCondField, singleCondValue)\n                singleCondField = (String) null\n                singleCondValue = null\n            }\n            simpleAndMap.put(fieldName, value)\n        }\n        return this\n    }\n\n    @Override\n    EntityFind condition(String fieldName, EntityCondition.ComparisonOperator operator, Object value) {\n        EntityDefinition ed = getEntityDef()\n        FieldInfo fi = ed.getFieldInfo(fieldName)\n        if (fi == null) throw new EntityException(\"Field ${fieldName} not found on entity ${entityName}, cannot add condition\")\n        if (operator == null) operator = EntityCondition.EQUALS\n        if (ed.isViewEntity && fi.fieldNode.attribute(\"function\")) {\n            return havingCondition(new FieldValueCondition(fi.conditionField, operator, value))\n        } else {\n            if (EntityCondition.EQUALS.is(operator)) return condition(fieldName, value)\n            return condition(new FieldValueCondition(fi.conditionField, operator, value))\n        }\n    }\n    @Override\n    EntityFind condition(String fieldName, String operator, Object value) {\n        EntityCondition.ComparisonOperator opObj = operator == null || operator.isEmpty() ?\n                EntityCondition.EQUALS : EntityConditionFactoryImpl.stringComparisonOperatorMap.get(operator)\n        if (opObj == null) throw new EntityException(\"Operator [${operator}] is not a valid field comparison operator\")\n        return condition(fieldName, opObj, value)\n    }\n\n    @Override\n    EntityFind conditionToField(String fieldName, EntityCondition.ComparisonOperator operator, String toFieldName) {\n        return condition(efi.entityConditionFactory.makeConditionToField(fieldName, operator, toFieldName))\n    }\n\n    @Override\n    EntityFind condition(Map<String, Object> fields) {\n        if (fields == null) return this\n\n        if (fields instanceof EntityValueBase) fields = ((EntityValueBase) fields).getValueMap()\n        int fieldsSize = fields.size()\n        if (fieldsSize == 0) return this\n\n        boolean noSam = simpleAndMap == null\n        boolean noScf = singleCondField == null\n        if (fieldsSize == 1 && noSam && noScf) {\n            // just set the singleCondField\n            Map.Entry<String, Object> onlyEntry = fields.entrySet().iterator().next()\n            singleCondField = (String) onlyEntry.key\n            singleCondValue = onlyEntry.value\n        } else {\n            if (noSam) simpleAndMap = new LinkedHashMap<String, Object>()\n            if (!noScf) {\n                simpleAndMap.put(singleCondField, singleCondValue)\n                singleCondField = (String) null\n                singleCondValue = null\n            }\n            getEntityDef().entityInfo.setFields(fields, simpleAndMap, true, null, null)\n        }\n        return this\n    }\n\n    @Override\n    EntityFind condition(EntityCondition condition) {\n        if (condition == null) return this\n\n        Class condClass = condition.getClass()\n        if (condClass == FieldValueCondition.class) {\n            // if this is a basic field/value EQUALS condition, just add to simpleAndMap\n            FieldValueCondition fvc = (FieldValueCondition) condition\n            if (EntityCondition.EQUALS.is(fvc.getOperator()) && !fvc.getIgnoreCase()) {\n                this.condition(fvc.getFieldName(), fvc.getValue())\n                return this\n            }\n        } else if (condClass == ListCondition.class) {\n            ListCondition lc = (ListCondition) condition\n            ArrayList<EntityConditionImplBase> condList = lc.getConditionList()\n            // if empty list add nothing\n            if (condList.size() == 0) return this\n            // if this is an AND list condition, just unroll it and add each one; could end up as another list, but may add to simpleAndMap\n            if (EntityCondition.AND.is(lc.getOperator())) {\n                for (int i = 0; i < condList.size(); i++) this.condition(condList.get(i))\n                return this\n            }\n        } else if (condClass == BasicJoinCondition.class) {\n            BasicJoinCondition basicCond = (BasicJoinCondition) condition\n            if (EntityCondition.AND.is(basicCond.getOperator())) {\n                if (basicCond.getLhs() != null) this.condition(basicCond.getLhs())\n                if (basicCond.getRhs() != null) this.condition(basicCond.getRhs())\n                return this\n            }\n        }\n\n        if (whereEntityCondition != null) {\n            // use ListCondition instead of ANDing two at a time to avoid a bunch of nested ANDs\n            if (whereEntityCondition instanceof ListCondition &&\n                    ((ListCondition) whereEntityCondition).getOperator() == EntityCondition.AND) {\n                ((ListCondition) whereEntityCondition).addCondition((EntityConditionImplBase) condition)\n            } else {\n                ArrayList<EntityConditionImplBase> condList = new ArrayList()\n                condList.add(whereEntityCondition)\n                condList.add((EntityConditionImplBase) condition)\n                whereEntityCondition = new ListCondition(condList, EntityCondition.AND)\n            }\n        } else {\n            whereEntityCondition = (EntityConditionImplBase) condition\n        }\n        return this\n    }\n\n    @Override\n    EntityFind conditionDate(String fromFieldName, String thruFieldName, Timestamp compareStamp) {\n        condition(efi.entityConditionFactory.makeConditionDate(fromFieldName, thruFieldName, compareStamp))\n        return this\n    }\n\n    @Override\n    boolean getHasCondition() {\n        if (singleCondField != null) return true\n        if (simpleAndMap != null && simpleAndMap.size() > 0) return true\n        if (whereEntityCondition != null) return true\n        return false\n    }\n    @Override boolean getHasHavingCondition() { havingEntityCondition != null }\n\n    @Override\n    EntityFind havingCondition(EntityCondition condition) {\n        if (condition == null) return this\n        if (havingEntityCondition != null) {\n            // use ListCondition instead of ANDing two at a time to avoid a bunch of nested ANDs\n            if (havingEntityCondition instanceof ListCondition) {\n                ((ListCondition) havingEntityCondition).addCondition((EntityConditionImplBase) condition)\n            } else {\n                ArrayList<EntityConditionImplBase> condList = new ArrayList()\n                condList.add(havingEntityCondition)\n                condList.add((EntityConditionImplBase) condition)\n                havingEntityCondition = new ListCondition(condList, EntityCondition.AND)\n            }\n        } else {\n            havingEntityCondition = (EntityConditionImplBase) condition\n        }\n        return this\n    }\n\n    @Override\n    EntityCondition getWhereEntityCondition() { return getWhereEntityConditionInternal(getEntityDef()) }\n    EntityConditionImplBase getWhereEntityConditionInternal(EntityDefinition localEd) {\n        boolean wecNull = (whereEntityCondition == null)\n        int samSize = simpleAndMap != null ? simpleAndMap.size() : 0\n\n        EntityConditionImplBase singleCond = (EntityConditionImplBase) null\n        if (singleCondField != null) {\n            if (samSize > 0) logger.warn(\"simpleAndMap size ${samSize} and singleCondField not null!\")\n            ConditionField cf\n            if (localEd != null) {\n                FieldInfo fi = localEd.getFieldInfo(singleCondField)\n                if (fi == null) throw new EntityException(\"Error in find, field ${singleCondField} does not exist in entity ${localEd.getFullEntityName()}\")\n                cf = fi.conditionField\n            } else {\n                cf = new ConditionField(singleCondField)\n            }\n            singleCond = new FieldValueCondition(cf, EntityCondition.EQUALS, singleCondValue)\n        }\n        // special case, frequent operation: find by single key\n        if (singleCond != null && wecNull && samSize == 0) return singleCond\n\n        // see if we need to combine singleCond, simpleAndMap, and whereEntityCondition\n        ArrayList<EntityConditionImplBase> condList = new ArrayList<EntityConditionImplBase>()\n        if (singleCond != null) condList.add(singleCond)\n\n        if (samSize > 0) {\n            // create a ListCondition from the Map to allow for combination (simplification) with other conditions\n            for (Map.Entry<String, Object> samEntry in simpleAndMap.entrySet()) {\n                ConditionField cf\n                if (localEd != null) {\n                    FieldInfo fi = localEd.getFieldInfo((String) samEntry.getKey())\n                    if (fi == null) throw new EntityException(\"Error in find, field ${samEntry.getKey()} does not exist in entity ${localEd.getFullEntityName()}\")\n                    cf = fi.conditionField\n                } else {\n                    cf = new ConditionField((String) samEntry.key)\n                }\n                condList.add(new FieldValueCondition(cf, EntityCondition.EQUALS, samEntry.value))\n            }\n        }\n        if (condList.size() > 0) {\n            if (!wecNull) {\n                Class whereEntCondClass = whereEntityCondition.getClass()\n                if (whereEntCondClass == ListCondition.class) {\n                    ListCondition listCond = (ListCondition) this.whereEntityCondition\n                    if (EntityCondition.AND.is(listCond.getOperator())) {\n                        condList.addAll(listCond.getConditionList())\n                        return new ListCondition(condList, EntityCondition.AND)\n                    } else {\n                        condList.add(listCond)\n                        return new ListCondition(condList, EntityCondition.AND)\n                    }\n                } else if (whereEntCondClass == FieldValueCondition.class || whereEntCondClass == DateCondition.class ||\n                        whereEntCondClass == FieldToFieldCondition.class) {\n                    condList.add(whereEntityCondition)\n                    return new ListCondition(condList, EntityCondition.AND)\n                } else if (whereEntCondClass == BasicJoinCondition.class) {\n                    BasicJoinCondition basicCond = (BasicJoinCondition) this.whereEntityCondition\n                    if (EntityCondition.AND.is(basicCond.getOperator())) {\n                        condList.add(basicCond.getLhs())\n                        condList.add(basicCond.getRhs())\n                        return new ListCondition(condList, EntityCondition.AND)\n                    } else {\n                        condList.add(basicCond)\n                        return new ListCondition(condList, EntityCondition.AND)\n                    }\n                } else {\n                    condList.add(whereEntityCondition)\n                    return new ListCondition(condList, EntityCondition.AND)\n                }\n            } else {\n                // no whereEntityCondition, just create a ListConditio for the simpleAndMap\n                return new ListCondition(condList, EntityCondition.AND)\n            }\n        } else {\n            return whereEntityCondition\n        }\n    }\n\n    /** Used by TransactionCache */\n    Map<String, Object> getSimpleMapPrimaryKeys() {\n        int samSize = simpleAndMap != null ? simpleAndMap.size() : 0\n        boolean scfNull = (singleCondField == null)\n        if (samSize > 0 && !scfNull) logger.warn(\"simpleAndMap size ${samSize} and singleCondField not null!\")\n        Map<String, Object> pks = new HashMap<>()\n        ArrayList<String> pkFieldNames = getEntityDef().getPkFieldNames()\n        int pkFieldNamesSize = pkFieldNames.size()\n        for (int i = 0; i < pkFieldNamesSize; i++) {\n            // only include PK fields which has a non-empty value, leave others out of the Map\n            String fieldName = (String) pkFieldNames.get(i)\n            Object value = null\n            if (samSize > 0) value = simpleAndMap.get(fieldName)\n            if (value == null && !scfNull && singleCondField.equals(fieldName)) value = singleCondValue\n            // if any fields have no value we don't have a full PK so bye bye\n            if (ObjectUtilities.isEmpty(value)) return null\n            pks.put(fieldName, value)\n        }\n        return pks\n    }\n\n    @Override EntityCondition getHavingEntityCondition() { return havingEntityCondition }\n\n    @Override\n    EntityFind searchFormInputs(String inputFieldsMapName, String defaultOrderBy, boolean alwaysPaginate) {\n        return searchFormInputs(inputFieldsMapName, null, null, defaultOrderBy, alwaysPaginate)\n    }\n    EntityFind searchFormInputs(String inputFieldsMapName, Map<String, Object> defaultParameters, String skipFields,\n                                String defaultOrderBy, boolean alwaysPaginate) {\n        ExecutionContextImpl ec = efi.ecfi.getEci()\n        Map<String, Object> inf = inputFieldsMapName ? (Map<String, Object>) ec.resource.expression(inputFieldsMapName, \"\") : ec.context\n        return searchFormMap(inf, defaultParameters, skipFields, defaultOrderBy, alwaysPaginate)\n    }\n\n    @Override\n    EntityFind searchFormMap(Map<String, Object> inputFieldsMap, Map<String, Object> defaultParameters, String skipFields,\n                             String defaultOrderBy, boolean alwaysPaginate) {\n        ExecutionContextImpl ec = efi.ecfi.getEci()\n\n        // to avoid issues with entities that have cache=true, if no cache value is specified for this set it to false (avoids pagination errors, etc)\n        if (useCache == null) useCache(false)\n\n        Set<String> skipFieldSet = new HashSet<>()\n        if (skipFields != null && !skipFields.isEmpty()) {\n            String[] skipFieldArray = skipFields.split(\",\")\n            for (int i = 0; i < skipFieldArray.length; i++) {\n                String skipField = skipFieldArray[i].trim()\n                if (skipField.length() > 0) skipFieldSet.add(skipField)\n            }\n        }\n\n        boolean addedConditions = false\n        if (inputFieldsMap != null && inputFieldsMap.size() > 0)\n            addedConditions = processInputFields(inputFieldsMap, skipFieldSet, ec)\n        hasSearchFormParameters = addedConditions\n\n        if (!addedConditions && defaultParameters != null && defaultParameters.size() > 0) {\n            processInputFields(defaultParameters, skipFieldSet, ec)\n            for (Map.Entry<String, Object> dpEntry in defaultParameters.entrySet()) ec.contextStack.put(dpEntry.key, dpEntry.value)\n        }\n\n        // always look for an orderByField parameter too\n        String orderByString = inputFieldsMap?.get(\"orderByField\") ?: defaultOrderBy\n        if (orderByString != null && orderByString.length() > 0) {\n            ec.contextStack.put(\"orderByField\", orderByString)\n            this.orderBy(orderByString)\n        }\n\n        // look for the pageIndex and optional pageSize parameters; don't set these if should cache as will disable the cached query\n        if ((alwaysPaginate || inputFieldsMap?.get(\"pageIndex\") || inputFieldsMap?.get(\"pageSize\")) && !shouldCache()) {\n            int pageIndex = (inputFieldsMap?.get(\"pageIndex\") ?: 0) as int\n            int pageSize = (inputFieldsMap?.get(\"pageSize\") ?: (this.limit ?: 20)) as int\n            offset(pageIndex, pageSize)\n            limit(pageSize)\n        }\n\n        // if there is a pageNoLimit clear out the limit regardless of other settings\n        if (\"true\".equals(inputFieldsMap?.get(\"pageNoLimit\")) || inputFieldsMap?.get(\"pageNoLimit\") == true) {\n            offset = null\n            limit = null\n        }\n\n        return this\n    }\n\n    protected boolean processInputFields(Map<String, Object> inputFieldsMap, Set<String> skipFieldSet, ExecutionContextImpl ec) {\n        EntityDefinition ed = getEntityDef()\n        boolean addedConditions = false\n        for (FieldInfo fi in ed.allFieldInfoList) {\n            String fn = fi.name\n            if (skipFieldSet.contains(fn)) continue\n\n            // NOTE: do we need to do type conversion here?\n\n            // this will handle text-find\n            if (inputFieldsMap.containsKey(fn) || inputFieldsMap.containsKey(fn + \"_op\")) {\n                Object value = inputFieldsMap.get(fn)\n                boolean valueEmpty = ObjectUtilities.isEmpty(value)\n                String op = inputFieldsMap.get(fn + \"_op\") ?: \"equals\"\n                boolean not = (inputFieldsMap.get(fn + \"_not\") == \"Y\" || inputFieldsMap.get(fn + \"_not\") == \"true\")\n                boolean ic = (inputFieldsMap.get(fn + \"_ic\") == \"Y\" || inputFieldsMap.get(fn + \"_ic\") == \"true\")\n\n                EntityCondition cond = null\n                switch (op) {\n                    case \"equals\":\n                        if (!valueEmpty) {\n                            Object convertedValue = value instanceof String ? ed.convertFieldString(fn, (String) value, ec) : value\n                            cond = efi.entityConditionFactory.makeCondition(fn,\n                                    not ? EntityCondition.NOT_EQUAL : EntityCondition.EQUALS, convertedValue, not)\n                            if (ic) cond.ignoreCase()\n                        }\n                        break\n                    case \"like\":\n                        if (!valueEmpty) {\n                            cond = efi.entityConditionFactory.makeCondition(fn,\n                                    not ? EntityCondition.NOT_LIKE : EntityCondition.LIKE, value)\n                            if (ic) cond.ignoreCase()\n                        }\n                        break\n                    case \"contains\":\n                        if (!valueEmpty) {\n                            cond = efi.entityConditionFactory.makeCondition(fn,\n                                    not ? EntityCondition.NOT_LIKE : EntityCondition.LIKE, \"%${value}%\")\n                            if (ic) cond.ignoreCase()\n                        }\n                        break\n                    case \"begins\":\n                        if (!valueEmpty) {\n                            cond = efi.entityConditionFactory.makeCondition(fn,\n                                    not ? EntityCondition.NOT_LIKE : EntityCondition.LIKE, \"${value}%\")\n                            if (ic) cond.ignoreCase()\n                        }\n                        break\n                    case \"empty\":\n                        cond = efi.entityConditionFactory.makeCondition(\n                                efi.entityConditionFactory.makeCondition(fn,\n                                        not ? EntityCondition.NOT_EQUAL : EntityCondition.EQUALS, null),\n                                not ? EntityCondition.JoinOperator.AND : EntityCondition.JoinOperator.OR,\n                                efi.entityConditionFactory.makeCondition(fn,\n                                        not ? EntityCondition.NOT_EQUAL : EntityCondition.EQUALS, \"\"))\n                        break\n                    case \"in\":\n                        if (!valueEmpty) {\n                            Collection valueList = null\n                            if (value instanceof CharSequence) {\n                                valueList = Arrays.asList(value.toString().split(\",\"))\n                            } else if (value instanceof Collection) {\n                                valueList = (Collection) value\n                            }\n                            if (valueList) {\n                                cond = efi.entityConditionFactory.makeCondition(fn,\n                                        not ? EntityCondition.NOT_IN : EntityCondition.IN, valueList, not)\n\n                            }\n                        }\n                        break\n                }\n                if (cond != null) {\n                    if (fi.hasAggregateFunction) { this.havingCondition(cond) } else { this.condition(cond) }\n                    addedConditions = true\n                }\n            } else if (inputFieldsMap.get(fn + \"_period\")) {\n                List<Timestamp> range = ec.user.getPeriodRange((String) inputFieldsMap.get(fn + \"_period\"),\n                        (String) inputFieldsMap.get(fn + \"_poffset\"), (String) inputFieldsMap.get(fn + \"_pdate\"))\n                EntityCondition fromCond = efi.entityConditionFactory.makeCondition(fn, EntityCondition.GREATER_THAN_EQUAL_TO, range.get(0))\n                EntityCondition thruCond = efi.entityConditionFactory.makeCondition(fn, EntityCondition.LESS_THAN, range.get(1))\n                if (fi.hasAggregateFunction) { this.havingCondition(fromCond); this.havingCondition(thruCond) }\n                else { this.condition(fromCond); this.condition(thruCond) }\n                addedConditions = true\n            } else {\n                // these will handle range-find and date-find\n                Object fromValue = inputFieldsMap.get(fn + \"_from\")\n                if (fromValue && fromValue instanceof CharSequence) {\n                    if (fi.typeValue == 2 && fromValue.length() < 12)\n                        fromValue = ec.l10nFacade.parseTimestamp(fromValue.toString() + \" 00:00:00.000\", \"yyyy-MM-dd HH:mm:ss.SSS\")\n                    else fromValue = ed.convertFieldString(fn, fromValue.toString(), ec)\n                }\n                Object thruValue = inputFieldsMap.get(fn + \"_thru\")\n                if (thruValue && thruValue instanceof CharSequence) {\n                    if (fi.typeValue == 2 && thruValue.length() < 12)\n                        thruValue = ec.l10nFacade.parseTimestamp(thruValue.toString() + \" 23:59:59.999\", \"yyyy-MM-dd HH:mm:ss.SSS\")\n                    else thruValue = ed.convertFieldString(fn, thruValue.toString(), ec)\n                }\n\n                if (!ObjectUtilities.isEmpty(fromValue)) {\n                    EntityCondition fromCond = efi.entityConditionFactory.makeCondition(fn, EntityCondition.GREATER_THAN_EQUAL_TO, fromValue)\n                    if (fi.hasAggregateFunction) { this.havingCondition(fromCond) } else { this.condition(fromCond) }\n                    addedConditions = true\n                }\n                if (!ObjectUtilities.isEmpty(thruValue)) {\n                    EntityCondition thruCond = efi.entityConditionFactory.makeCondition(fn, EntityCondition.LESS_THAN_EQUAL_TO, thruValue)\n                    if (fi.hasAggregateFunction) { this.havingCondition(thruCond) } else { this.condition(thruCond) }\n                    addedConditions = true\n                }\n            }\n        }\n        return addedConditions\n    }\n\n    // ======================== General/Common Options ========================\n\n    @Override\n    EntityFind selectField(String fieldToSelect) {\n        if (fieldToSelect == null || fieldToSelect.length() == 0) return this\n        if (fieldsToSelect == null) fieldsToSelect = new ArrayList<>()\n        if (fieldToSelect.contains(\",\")) {\n            for (String ftsPart in fieldToSelect.split(\",\")) {\n                String selectName = ftsPart.trim()\n                if (getEntityDef().isField(selectName) && !fieldsToSelect.contains(selectName)) fieldsToSelect.add(selectName)\n            }\n        } else {\n            if (getEntityDef().isField(fieldToSelect) && !fieldsToSelect.contains(fieldToSelect)) fieldsToSelect.add(fieldToSelect)\n        }\n        return this\n    }\n    @Override\n    EntityFind selectFields(Collection<String> selectFields) {\n        if (!selectFields) return this\n        for (String fieldToSelect in selectFields) selectField(fieldToSelect)\n        return this\n    }\n    @Override List<String> getSelectFields() { return fieldsToSelect }\n\n    @Override\n    EntityFind orderBy(String orderByFieldName) {\n        if (orderByFieldName == null || orderByFieldName.length() == 0) return this\n        if (this.orderByFields == null) this.orderByFields = new ArrayList<>()\n        if (orderByFieldName.contains(\",\")) {\n            for (String obsPart in orderByFieldName.split(\",\")) {\n                String orderByName = obsPart.trim()\n                FieldOrderOptions foo = new FieldOrderOptions(orderByName)\n                if (getEntityDef().isField(foo.fieldName) && !this.orderByFields.contains(orderByName)) this.orderByFields.add(orderByName)\n            }\n        } else {\n            FieldOrderOptions foo = new FieldOrderOptions(orderByFieldName)\n            if (getEntityDef().isField(foo.fieldName) && !this.orderByFields.contains(orderByFieldName)) this.orderByFields.add(orderByFieldName)\n        }\n        return this\n    }\n    @Override\n    EntityFind orderBy(List<String> orderByFieldNames) {\n        if (orderByFieldNames == null || orderByFieldNames.size() == 0) return this\n        if (orderByFieldNames instanceof RandomAccess) {\n            // avoid creating an iterator if possible\n            int listSize = orderByFieldNames.size()\n            for (int i = 0; i < listSize; i++) orderBy((String) orderByFieldNames.get(i))\n        } else {\n            for (String orderByFieldName in orderByFieldNames) orderBy(orderByFieldName)\n        }\n        return this\n    }\n    @Override List<String> getOrderBy() { return orderByFields != null ? Collections.unmodifiableList(orderByFields) : null }\n    ArrayList<String> getOrderByFields() { return orderByFields }\n\n    @Override EntityFind useCache(Boolean useCache) { this.useCache = useCache; return this }\n    @Override boolean getUseCache() { return this.useCache }\n\n    @Override EntityFind useClone(boolean uc) { useClone = uc; return this }\n\n    // ======================== Advanced Options ==============================\n\n    @Override EntityFind distinct(boolean distinct) { this.distinct = distinct; return this }\n    @Override boolean getDistinct() { return distinct }\n\n    @Override EntityFind offset(Integer offset) { this.offset = offset; return this }\n    @Override EntityFind offset(int pageIndex, int pageSize) { offset(pageIndex * pageSize) }\n    @Override Integer getOffset() { return offset }\n\n    @Override EntityFind limit(Integer limit) { this.limit = limit; return this }\n    @Override Integer getLimit() { return limit }\n\n    @Override int getPageIndex() { return offset == null ? 0 : (offset/getPageSize()).intValue() }\n    @Override int getPageSize() { return limit != null ? limit : 20 }\n\n    @Override\n    EntityFind forUpdate(boolean forUpdate) {\n        this.forUpdate = forUpdate\n        this.resultSetType = forUpdate ? ResultSet.TYPE_SCROLL_SENSITIVE : defaultResultSetType\n        return this\n    }\n    @Override boolean getForUpdate() { return this.forUpdate }\n\n    // ======================== JDBC Options ==============================\n\n    @Override EntityFind resultSetType(int resultSetType) { this.resultSetType = resultSetType; return this }\n    @Override int getResultSetType() { return this.resultSetType }\n\n    @Override EntityFind resultSetConcurrency(int rsc) { resultSetConcurrency = rsc; return this }\n    @Override int getResultSetConcurrency() { return this.resultSetConcurrency }\n\n    @Override EntityFind fetchSize(Integer fetchSize) { this.fetchSize = fetchSize; return this }\n    @Override Integer getFetchSize() { return this.fetchSize }\n\n    @Override EntityFind maxRows(Integer maxRows) { this.maxRows = maxRows; return this }\n    @Override Integer getMaxRows() { return this.maxRows }\n\n    // ======================== Misc Methods ========================\n\n    EntityDefinition getEntityDef() {\n        if (entityDef != null) return entityDef\n        if (dynamicView != null) {\n            entityDef = dynamicView.makeEntityDefinition()\n        } else {\n            entityDef = efi.getEntityDefinition(entityName)\n        }\n        return entityDef\n    }\n\n    @Override EntityFind disableAuthz() { disableAuthz = true; return this }\n    @Override EntityFind requireSearchFormParameters(boolean req) { this.requireSearchFormParameters = req; return this }\n\n    @Override\n    boolean shouldCache() {\n        if (dynamicView != null) return false\n        if (havingEntityCondition != null) return false\n        if (limit != null || offset != null) return false\n        if (forUpdate) return false\n        if (useCache != null) {\n            boolean useCacheLocal = useCache.booleanValue()\n            if (!useCacheLocal) return false\n            return !getEntityDef().entityInfo.neverCache\n        } else {\n            return \"true\".equals(getEntityDef().entityInfo.useCache)\n        }\n    }\n\n    @Override\n    String toString() {\n        return \"Find: ${entityName} WHERE [${singleCondField?:''}:${singleCondValue?:''}] [${simpleAndMap}] [${whereEntityCondition}] HAVING [${havingEntityCondition}] \" +\n                \"SELECT [${fieldsToSelect}] ORDER BY [${orderByFields}] CACHE [${useCache}] DISTINCT [${distinct}] \" +\n                \"OFFSET [${offset}] LIMIT [${limit}] FOR UPDATE [${forUpdate}]\"\n    }\n\n    private static String makeErrorMsg(String baseMsg, String expandMsg, EntityConditionImplBase cond, EntityDefinition ed, ExecutionContextImpl ec) {\n        Map<String, Object> errorContext = new HashMap<>()\n        errorContext.put(\"entityName\", ed.getEntityName()); errorContext.put(\"condition\", cond)\n        String errorMessage = null\n        // TODO: need a different approach for localization, getting from DB may not be reliable after an error and may cause other errors (especially with Postgres and the auto rollback only)\n        if (false && !\"LocalizedMessage\".equals(ed.getEntityName())) {\n            try { errorMessage = ec.resourceFacade.expand(expandMsg, null, errorContext) }\n            catch (Throwable t) { logger.trace(\"Error expanding error message\", t) }\n        }\n        if (errorMessage == null) errorMessage = baseMsg + \" \" + ed.getEntityName() + \" by \" + cond\n        return errorMessage\n    }\n\n    private void registerForUpdateLock(Map<String, Object> fieldValues) {\n        if (fieldValues == null || fieldValues.size() == 0) return\n        if (!forUpdate) return\n        final TransactionFacadeImpl tfi = efi.ecfi.transactionFacade\n        if (!tfi.getUseLockTrack()) return\n\n        EntityDefinition ed = getEntityDef()\n\n        ArrayList<ArtifactExecutionInfo> stackArray = efi.ecfi.getEci().artifactExecutionFacade.getStackArray()\n        tfi.registerRecordLock(new ContextJavaUtil.EntityRecordLock(ed.getFullEntityName(), ed.getPrimaryKeysString(fieldValues), stackArray))\n    }\n\n    // ======================== Find and Abstract Methods ========================\n\n    abstract EntityDynamicView makeEntityDynamicView()\n\n    @Override\n    EntityValue one() throws EntityException {\n        ExecutionContextImpl ec = efi.ecfi.getEci()\n        ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade\n        boolean enableAuthz = disableAuthz ? !aefi.disableAuthz() : false\n        try {\n            EntityDefinition ed = getEntityDef()\n\n            ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(ed.getFullEntityName(),\n                    ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_VIEW, \"one\")\n            // really worth the overhead? if so change to handle singleCondField: .setParameters(simpleAndMap)\n            aefi.pushInternal(aei, !ed.entityInfo.authorizeSkipView, false)\n\n            try {\n                return oneInternal(ec, ed)\n            } finally {\n                // pop the ArtifactExecutionInfo\n                aefi.pop(aei)\n            }\n        } finally {\n            if (enableAuthz) aefi.enableAuthz()\n        }\n    }\n    @Override\n    Map<String, Object> oneMaster(String name) {\n        ExecutionContextImpl ec = efi.ecfi.getEci()\n        ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade\n        boolean enableAuthz = disableAuthz ? !aefi.disableAuthz() : false\n        try {\n            EntityDefinition ed = getEntityDef()\n\n            ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(ed.getFullEntityName(),\n                    ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_VIEW, \"one\")\n            aefi.pushInternal(aei, !ed.entityInfo.authorizeSkipView, false)\n\n            try {\n                EntityValue ev = oneInternal(ec, ed)\n                if (ev == null) return null\n                return ev.getMasterValueMap(name)\n            } finally {\n                // pop the ArtifactExecutionInfo\n                aefi.pop(aei)\n            }\n        } finally {\n            if (enableAuthz) aefi.enableAuthz()\n        }\n    }\n\n    protected EntityValue oneInternal(ExecutionContextImpl ec, EntityDefinition ed) throws EntityException, SQLException {\n        if (this.dynamicView != null) throw new EntityException(\"Dynamic View not supported for 'one' find.\")\n\n        boolean isViewEntity = ed.isViewEntity\n        EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo\n\n        if (entityInfo.isInvalidViewEntity) throw new EntityException(\"Cannot do find for view-entity with name ${entityName} because it has no member entities or no aliased fields.\")\n\n        // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(ed.getFullEntityName(), simpleAndMap, \"find-one\", true)\n\n        boolean hasEmptyPk = false\n        boolean hasFullPk = true\n        if (singleCondField != null && ed.isPkField(singleCondField) && ObjectUtilities.isEmpty(singleCondValue)) {\n            hasEmptyPk = true; hasFullPk = false }\n        ArrayList<String> pkNameList = ed.getPkFieldNames()\n        int pkSize = pkNameList.size()\n        int samSize = simpleAndMap != null ? simpleAndMap.size() : 0\n        if (hasFullPk && samSize > 1) {\n            for (int i = 0; i < pkSize; i++) {\n                String fieldName = (String) pkNameList.get(i)\n                Object fieldValue = simpleAndMap.get(fieldName)\n                if (ObjectUtilities.isEmpty(fieldValue)) {\n                    if (simpleAndMap.containsKey(fieldName)) hasEmptyPk = true\n                    hasFullPk = false\n                    break\n                }\n            }\n        }\n        // if over-constrained (anything in addition to a full PK), just use the full PK\n        if (hasFullPk && samSize > 1) {\n            Map<String, Object> pks = new HashMap<>()\n            if (singleCondField != null) {\n                // this shouldn't generally happen, added to simpleAndMap internally on the fly when needed, but just in case\n                pks.put(singleCondField, singleCondValue)\n                singleCondField = (String) null; singleCondValue = null\n            }\n            for (int i = 0; i < pkSize; i++) {\n                String fieldName = (String) pkNameList.get(i)\n                pks.put(fieldName, simpleAndMap.get(fieldName))\n            }\n            simpleAndMap = pks\n        }\n\n        // if any PK fields are null, for whatever reason in calling code, the result is null so no need to send to DB or cache or anything\n        if (hasEmptyPk) return (EntityValue) null\n\n        boolean doCache = shouldCache()\n        // NOTE: artifactExecutionFacade.filterFindForUser() no longer called here, called in EntityFindBuilder after trimming if needed for view-entity\n        if (doCache) {\n            // don't cache if there are any applicable filter conditions\n            ArrayList findFilterList = ec.artifactExecutionFacade.getFindFiltersForUser(ed, null)\n            if (findFilterList != null && findFilterList.size() > 0) doCache = false\n        }\n\n        EntityConditionImplBase whereCondition = getWhereEntityConditionInternal(ed)\n\n        // no condition means no condition/parameter set, so return null for find.one()\n        if (whereCondition == null) return (EntityValue) null\n\n        // try the TX cache before the entity cache, should be more up-to-date\n        EntityValueBase txcValue = (EntityValueBase) null\n        if (txCache != null) {\n            txcValue = txCache.oneGet(this)\n            // NOTE: don't do this, opt to get latest from tx cache instead of from DB instead of trying to merge, lock\n            //     only query done below; tx cache causes issues when for update used after non for update query if\n            //     latest values from DB are needed!\n            // if we got a value from txCache and we're doing a for update and it was not created in this tx cache then\n            //     don't use it, we want the latest value from the DB (may have been queried without for update in this tx)\n            // if (txcValue != null && forUpdate && !txCache.isTxCreate(txcValue)) txcValue = (EntityValueBase) null\n        }\n\n        // if (txcValue != null && ed.getEntityName() == \"foo\") logger.warn(\"========= TX cache one value: ${txcValue}\")\n\n        Cache<EntityCondition, EntityValueBase> entityOneCache = doCache ?\n                ed.getCacheOne(efi.getEntityCache()) : (Cache<EntityCondition, EntityValueBase>) null\n        EntityValueBase cacheHit = (EntityValueBase) null\n        if (doCache && txcValue == null && !forUpdate) cacheHit = (EntityValueBase) entityOneCache.get(whereCondition)\n\n        // we always want fieldInfoArray populated so that we know the order of the results coming back\n        int ftsSize = fieldsToSelect != null ? fieldsToSelect.size() : 0\n        FieldInfo[] fieldInfoArray\n        FieldOrderOptions[] fieldOptionsArray = (FieldOrderOptions[]) null\n        if (ftsSize == 0 || (txCache != null && txcValue == null) || (doCache && cacheHit == null)) {\n            fieldInfoArray = entityInfo.allFieldInfoArray\n        } else {\n            fieldInfoArray = new FieldInfo[ftsSize]\n            fieldOptionsArray = new FieldOrderOptions[ftsSize]\n            boolean hasFieldOptions = false\n            int fieldInfoArrayIndex = 0\n            for (int i = 0; i < ftsSize; i++) {\n                String fieldName = (String) fieldsToSelect.get(i)\n                FieldInfo fi = ed.getFieldInfo(fieldName)\n                if (fi == null) {\n                    FieldOrderOptions foo = new FieldOrderOptions(fieldName)\n                    fi = ed.getFieldInfo(foo.fieldName)\n                    if (fi == null) throw new EntityException(\"Field to select ${fieldName} not found in entity ${ed.getFullEntityName()}\")\n\n                    fieldInfoArray[fieldInfoArrayIndex] = fi\n                    fieldOptionsArray[fieldInfoArrayIndex] = foo\n                    fieldInfoArrayIndex++\n                    hasFieldOptions = true\n                } else {\n                    fieldInfoArray[fieldInfoArrayIndex] = fi\n                    fieldInfoArrayIndex++\n                }\n            }\n            if (!hasFieldOptions) fieldOptionsArray = (FieldOrderOptions[]) null\n            if (fieldOptionsArray == null && ftsSize == entityInfo.allFieldInfoArray.length)\n                fieldInfoArray = entityInfo.allFieldInfoArray\n        }\n\n        // if (ed.getEntityName() == \"Asset\") logger.warn(\"=========== find one of Asset ${this.simpleAndMap.get('assetId')}\", new Exception(\"Location\"))\n\n        // call the abstract method\n        EntityValueBase newEntityValue = (EntityValueBase) null\n        if (txcValue != null) {\n            if (txcValue instanceof EntityValueBase.DeletedEntityValue) {\n                // is deleted value, so leave newEntityValue as null\n                // put in cache as null since this was deleted\n                if (doCache) efi.getEntityCache().putInOneCache(ed, whereCondition, null, entityOneCache)\n            } else {\n                // if forUpdate unless this was a TX CREATE it'll be in the DB and should be locked, so do the query\n                //     anyway, but ignore the result unless it's a read only tx cache\n                if (forUpdate && !txCache.isKnownLocked(txcValue) && !txCache.isTxCreate(txcValue)) {\n                    EntityValueBase fuDbValue\n                    EntityConditionImplBase cond = isViewEntity ? getConditionForQuery(ed, whereCondition) : whereCondition\n\n                    // register lock before if we have a full pk, otherwise after\n                    if (hasFullPk && efi.ecfi.transactionFacade.getUseLockTrack())\n                        registerForUpdateLock(simpleAndMap != null ? simpleAndMap : [(singleCondField):singleCondValue])\n\n                    try {\n                        fuDbValue = oneExtended(cond, fieldInfoArray, fieldOptionsArray)\n                    } catch (SQLException e) {\n                        throw new EntitySqlException(makeErrorMsg(\"Error finding one\", ONE_ERROR, cond, ed, ec), e)\n                    } catch (Exception e) {\n                        throw new EntityException(makeErrorMsg(\"Error finding one\", ONE_ERROR, cond, ed, ec), e)\n                    }\n\n                    // register lock before if we have a full pk, otherwise after; this particular one doesn't make sense, shouldn't happen, so just in case\n                    if (!hasFullPk && efi.ecfi.transactionFacade.getUseLockTrack()) registerForUpdateLock(fuDbValue)\n\n                    if (txCache.isReadOnly()) {\n                        // is read only tx cache so use the value from the DB\n                        newEntityValue = fuDbValue\n                        // tell the tx cache about the new value\n                        txCache.update(fuDbValue)\n                    } else {\n                        // we could try to merge the TX cache value and the latest DB value, but for now opt for the\n                        //     TX cache value over the DB value\n                        // if txcValue has been modified (fields in dbValueMap) see if those match what is coming from DB\n                        Map<String, Object> txDbValueMap = txcValue.getDbValueMap()\n                        Map<String, Object> fuDbValueMap = fuDbValue.getValueMap()\n                        if (txDbValueMap != null && txDbValueMap.size() > 0 &&\n                                !CollectionUtilities.mapMatchesFields(fuDbValueMap, txDbValueMap)) {\n                            StringBuilder fieldDiffBuilder = new StringBuilder()\n                            for (Map.Entry<String, Object> entry in txDbValueMap.entrySet()) {\n                                Object compareObj = txDbValueMap.get(entry.getKey())\n                                Object baseObj = fuDbValueMap.get(entry.getKey())\n                                if (compareObj != baseObj) fieldDiffBuilder.append(\"- \").append(entry.key).append(\": \")\n                                        .append(compareObj).append(\" (txc) != \").append(baseObj).append(\" (db)\\n\")\n                            }\n                            logger.warn(\"Did for update query on ${ed.getFullEntityName()} and result did not match value in transaction cache: \\n${fieldDiffBuilder}\", new BaseException(\"location\"))\n                        }\n                        newEntityValue = txcValue\n                    }\n                } else {\n                    newEntityValue = txcValue\n                }\n                // put it in whether null or not (already know cacheHit is null)\n                if (doCache) efi.getEntityCache().putInOneCache(ed, whereCondition, newEntityValue, entityOneCache)\n            }\n        } else if (cacheHit != null) {\n            if (cacheHit instanceof EntityCache.EmptyRecord) newEntityValue = (EntityValueBase) null\n            else newEntityValue = cacheHit\n        } else {\n            // for find one we'll always use the basic result set type and concurrency:\n            this.resultSetType = ResultSet.TYPE_FORWARD_ONLY\n            this.resultSetConcurrency = ResultSet.CONCUR_READ_ONLY\n\n            EntityConditionImplBase cond = isViewEntity ? getConditionForQuery(ed, whereCondition) : whereCondition\n\n            // register lock before if we have a full pk, otherwise after\n            if (forUpdate && hasFullPk && efi.ecfi.transactionFacade.getUseLockTrack())\n                registerForUpdateLock(simpleAndMap != null ? simpleAndMap : [(singleCondField):singleCondValue])\n\n            try {\n                tempHasFullPk = hasFullPk\n                newEntityValue = oneExtended(cond, fieldInfoArray, fieldOptionsArray)\n            } catch (SQLException e) {\n                throw new EntitySqlException(makeErrorMsg(\"Error finding one\", ONE_ERROR, cond, ed, ec), e)\n            } catch (Exception e) {\n                throw new EntityException(makeErrorMsg(\"Error finding one\", ONE_ERROR, cond, ed, ec), e)\n            } finally {\n                tempHasFullPk = null\n            }\n\n            // register lock before if we have a full pk, otherwise after\n            if (forUpdate && !hasFullPk && efi.ecfi.transactionFacade.getUseLockTrack())\n                registerForUpdateLock(newEntityValue)\n\n            // it didn't come from the txCache so put it there\n            if (txCache != null) txCache.onePut(newEntityValue, forUpdate)\n\n            // put it in whether null or not (already know cacheHit is null)\n            if (doCache) efi.getEntityCache().putInOneCache(ed, whereCondition, newEntityValue, entityOneCache)\n        }\n\n        // if (logger.traceEnabled) logger.trace(\"Find one on entity [${ed.fullEntityName}] with condition [${whereCondition}] found value [${newEntityValue}]\")\n\n        // final ECA trigger\n        // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(ed.getFullEntityName(), newEntityValue, \"find-one\", false)\n\n        return newEntityValue\n    }\n\n    EntityConditionImplBase getConditionForQuery(EntityDefinition ed, EntityConditionImplBase whereCondition) {\n        // NOTE: do actual query condition as a separate condition because this will always be added on and isn't a\n        //     part of the original where to use for the cache\n        EntityConditionImplBase conditionForQuery\n        EntityConditionImplBase viewWhere = ed.makeViewWhereCondition()\n        if (viewWhere != null) {\n            if (whereCondition != null) conditionForQuery = (EntityConditionImplBase) efi.getConditionFactory()\n                    .makeCondition(whereCondition, EntityCondition.JoinOperator.AND, viewWhere)\n            else conditionForQuery = viewWhere\n        } else {\n            conditionForQuery = whereCondition\n        }\n\n        return conditionForQuery\n    }\n\n    /** The abstract oneExtended method to implement */\n    abstract EntityValueBase oneExtended(EntityConditionImplBase whereCondition, FieldInfo[] fieldInfoArray,\n                                         FieldOrderOptions[] fieldOptionsArray) throws SQLException\n\n    @Override\n    EntityList list() throws EntityException {\n        ExecutionContextImpl ec = efi.ecfi.getEci()\n        ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade\n        boolean enableAuthz = disableAuthz ? !aefi.disableAuthz() : false\n        try {\n            EntityDefinition ed = getEntityDef()\n\n            ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(ed.getFullEntityName(),\n                    ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_VIEW, \"list\")\n            aefi.pushInternal(aei, !ed.entityInfo.authorizeSkipView, false)\n            try {\n                return listInternal(ec, ed)\n            } finally {\n                aefi.pop(aei)\n            }\n        } finally {\n            if (enableAuthz) aefi.enableAuthz()\n        }\n    }\n    @Override\n    List<Map<String, Object>> listMaster(String name) {\n        ExecutionContextImpl ec = efi.ecfi.getEci()\n        ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade\n        boolean enableAuthz = disableAuthz ? !aefi.disableAuthz() : false\n        try {\n            EntityDefinition ed = getEntityDef()\n\n            ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(ed.getFullEntityName(),\n                    ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_VIEW, \"list\")\n            aefi.pushInternal(aei, !ed.entityInfo.authorizeSkipView, false)\n            try {\n                EntityList el = listInternal(ec, ed)\n                return el.getMasterValueList(name)\n            } finally {\n                // pop the ArtifactExecutionInfo\n                aefi.pop(aei)\n            }\n        } finally {\n            if (enableAuthz) aefi.enableAuthz()\n        }\n    }\n\n    protected EntityList listInternal(ExecutionContextImpl ec, EntityDefinition ed) throws EntityException, SQLException {\n        if (requireSearchFormParameters && !hasSearchFormParameters) {\n            ec.contextStack.getSharedMap().put(\"_entityListNoSearchParms\", true)\n            logger.info(\"No parameters for list find on ${ed.fullEntityName}, not doing search\")\n            return new EntityListImpl(efi)\n        }\n\n        EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo\n        boolean isViewEntity = entityInfo.isView\n\n        if (entityInfo.isInvalidViewEntity) throw new EntityException(\"Cannot do find for view-entity with name ${entityName} because it has no member entities or no aliased fields.\")\n\n        // there may not be a simpleAndMap, but that's all we have that can be treated directly by the EECA\n        // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(ed.getFullEntityName(), simpleAndMap, \"find-list\", true)\n\n        ArrayList<String> orderByExpanded = new ArrayList()\n        // add the manually specified ones, then the ones in the view entity's entity-condition\n        if (orderByFields != null) orderByExpanded.addAll(orderByFields)\n\n        if (isViewEntity) {\n            MNode entityConditionNode = ed.entityConditionNode\n            if (entityConditionNode != null) {\n                ArrayList<MNode> ecObList = entityConditionNode.children(\"order-by\")\n                if (ecObList != null) for (int i = 0; i < ecObList.size(); i++) {\n                    MNode orderBy = (MNode) ecObList.get(i)\n                    String fieldName = orderBy.attribute(\"field-name\")\n                    if(!orderByExpanded.contains(fieldName)) orderByExpanded.add(fieldName)\n                }\n                if (\"true\".equals(entityConditionNode.attribute(\"distinct\"))) this.distinct(true)\n            }\n        }\n\n        boolean doEntityCache = shouldCache()\n\n        // NOTE: artifactExecutionFacade.filterFindForUser() no longer called here, called in EntityFindBuilder after trimming if needed for view-entity\n        if (doEntityCache) {\n            // don't cache if there are any applicable filter conditions\n            ArrayList findFilterList = ec.artifactExecutionFacade.getFindFiltersForUser(ed, null)\n            if (findFilterList != null && findFilterList.size() > 0) doEntityCache = false\n        }\n\n        EntityConditionImplBase whereCondition = getWhereEntityConditionInternal(ed)\n        // don't cache if no whereCondition\n        if (whereCondition == null) doEntityCache = false\n\n        // try the txCache first, more recent than general cache (and for update general cache entries will be cleared anyway)\n        EntityListImpl txcEli = txCache != null ? txCache.listGet(ed, whereCondition, orderByExpanded) : (EntityListImpl) null\n\n        // NOTE: don't cache if there is a having condition, for now just support where\n        // NOTE: could avoid caching lists if it is a filtered find, but mostly by org so reusable: && !filteredFind\n        Cache<EntityCondition, EntityListImpl> entityListCache = doEntityCache ?\n                ed.getCacheList(efi.getEntityCache()) : (Cache<EntityCondition, EntityListImpl>) null\n        EntityListImpl cacheList = (EntityListImpl) null\n        if (doEntityCache && txcEli == null && !forUpdate)\n            cacheList = efi.getEntityCache().getFromListCache(ed, whereCondition, orderByExpanded, entityListCache)\n\n        EntityListImpl el\n        if (txcEli != null) {\n            el = txcEli\n            // if (ed.getFullEntityName().contains(\"OrderItem\")) logger.warn(\"======== Got OrderItem from txCache ${el.size()} results where: ${whereCondition}\")\n        } else if (cacheList != null) {\n            el = cacheList\n        } else {\n            // order by fields need to be selected (at least on some databases, Derby is one of them)\n            int orderByExpandedSize = orderByExpanded.size()\n            if (getDistinct() && fieldsToSelect != null && fieldsToSelect.size() > 0 && orderByExpandedSize > 0) {\n                for (int i = 0; i < orderByExpandedSize; i++) {\n                    String orderByField = (String) orderByExpanded.get(i)\n                    FieldOrderOptions foo = new FieldOrderOptions(orderByField)\n                    if (!fieldsToSelect.contains(foo.fieldName)) fieldsToSelect.add(foo.fieldName)\n                }\n            }\n\n            // we always want fieldInfoArray populated so that we know the order of the results coming back\n            int ftsSize = fieldsToSelect != null ? fieldsToSelect.size() : 0\n            FieldInfo[] fieldInfoArray\n            FieldOrderOptions[] fieldOptionsArray = (FieldOrderOptions[]) null\n            if (ftsSize == 0 || doEntityCache) {\n                fieldInfoArray = entityInfo.allFieldInfoArray\n            } else {\n                fieldInfoArray = new FieldInfo[ftsSize]\n                fieldOptionsArray = new FieldOrderOptions[ftsSize]\n                boolean hasFieldOptions = false\n                int fieldInfoArrayIndex = 0\n                for (int i = 0; i < ftsSize; i++) {\n                    String fieldName = (String) fieldsToSelect.get(i)\n                    FieldInfo fi = (FieldInfo) ed.getFieldInfo(fieldName)\n                    if (fi == null) {\n                        FieldOrderOptions foo = new FieldOrderOptions(fieldName)\n                        fi = ed.getFieldInfo(foo.fieldName)\n                        if (fi == null) throw new EntityException(\"Field to select ${fieldName} not found in entity ${ed.getFullEntityName()}\")\n\n                        fieldInfoArray[fieldInfoArrayIndex] = fi\n                        fieldOptionsArray[fieldInfoArrayIndex] = foo\n                        fieldInfoArrayIndex++\n                        hasFieldOptions = true\n                    } else {\n                        fieldInfoArray[fieldInfoArrayIndex] = fi\n                        fieldInfoArrayIndex++\n                    }\n                }\n                if (!hasFieldOptions) fieldOptionsArray = (FieldOrderOptions[]) null\n                if (fieldOptionsArray == null && ftsSize == entityInfo.allFieldInfoArray.length)\n                    fieldInfoArray = entityInfo.allFieldInfoArray\n            }\n\n            EntityConditionImplBase queryWhereCondition = whereCondition\n            EntityConditionImplBase havingCondition = havingEntityCondition\n            if (isViewEntity) {\n                EntityConditionImplBase viewWhere = ed.makeViewWhereCondition()\n                queryWhereCondition = EntityConditionFactoryImpl.makeConditionImpl(whereCondition, EntityCondition.AND, viewWhere)\n\n                havingCondition = havingEntityCondition\n                EntityConditionImplBase viewHaving = ed.makeViewHavingCondition()\n                havingCondition = EntityConditionFactoryImpl.makeConditionImpl(havingCondition, EntityCondition.AND, viewHaving)\n            }\n\n            // call the abstract method\n            try (EntityListIterator eli = iteratorExtended(queryWhereCondition, havingCondition, orderByExpanded, fieldInfoArray, fieldOptionsArray)) {\n                MNode databaseNode = this.efi.getDatabaseNode(ed.getEntityGroupName())\n                if (limit != null && databaseNode != null && \"cursor\".equals(databaseNode.attribute(\"offset-style\"))) {\n                    el = (EntityListImpl) eli.getPartialList(offset != null ? offset : 0, limit, false)\n                } else {\n                    el = (EntityListImpl) eli.getCompleteList(false);\n                }\n            }\n            catch (SQLException e) { throw new EntitySqlException(makeErrorMsg(\"Error finding list of\", LIST_ERROR, queryWhereCondition, ed, ec), e) }\n            catch (ArtifactAuthorizationException e) { throw e }\n            catch (Exception e) { throw new EntityException(makeErrorMsg(\"Error finding list of\", LIST_ERROR, queryWhereCondition, ed, ec), e) }\n\n            // register lock after because we can't before, don't know which records will be returned\n            if (forUpdate && !isViewEntity && efi.ecfi.transactionFacade.getUseLockTrack()) {\n                int elSize = el.size()\n                for (int i = 0; i < elSize; i++) {\n                    EntityValue ev = (EntityValue) el.get(i)\n                    registerForUpdateLock(ev)\n                }\n            }\n\n            // don't put in tx cache if it is going in list cache\n            if (txCache != null && !doEntityCache && ftsSize == 0) txCache.listPut(ed, whereCondition, el)\n            if (doEntityCache) efi.getEntityCache().putInListCache(ed, el, whereCondition, entityListCache)\n\n            // if (ed.getFullEntityName().contains(\"OrderItem\")) logger.warn(\"======== Got OrderItem from DATABASE ${el.size()} results where: ${whereCondition}\")\n            // logger.warn(\"======== Got ${ed.getFullEntityName()} from DATABASE ${el.size()} results where: ${whereCondition}\")\n        }\n\n        // run the final rules\n        // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(ed.getFullEntityName(), simpleAndMap, \"find-list\", false)\n\n        return el\n    }\n\n    @Override\n    EntityListIterator iterator() throws EntityException {\n        ExecutionContextImpl ec = efi.ecfi.getEci()\n        ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade\n        boolean enableAuthz = disableAuthz ? !ec.artifactExecutionFacade.disableAuthz() : false\n        try {\n            EntityDefinition ed = getEntityDef()\n\n            ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(ed.getFullEntityName(),\n                    ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_VIEW, \"iterator\")\n            aefi.pushInternal(aei, !ed.entityInfo.authorizeSkipView, false)\n            try {\n                return iteratorInternal(ec, ed)\n            } finally {\n                aefi.pop(aei)\n            }\n        } finally {\n            if (enableAuthz) ec.artifactExecutionFacade.enableAuthz()\n        }\n    }\n    protected EntityListIterator iteratorInternal(ExecutionContextImpl ec, EntityDefinition ed) throws EntityException, SQLException {\n        if (requireSearchFormParameters && !hasSearchFormParameters) return null\n\n        EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo\n        boolean isViewEntity = entityInfo.isView\n\n        if (entityInfo.isInvalidViewEntity) throw new EntityException(\"Cannot do find for view-entity with name ${entityName} because it has no member entities or no aliased fields.\")\n\n        // there may not be a simpleAndMap, but that's all we have that can be treated directly by the EECA\n        // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(ed.getFullEntityName(), simpleAndMap, \"find-iterator\", true)\n\n        ArrayList<String> orderByExpanded = new ArrayList()\n        // add the manually specified ones, then the ones in the view entity's entity-condition\n        if (this.orderByFields != null) orderByExpanded.addAll(this.orderByFields)\n\n        if (isViewEntity) {\n            MNode entityConditionNode = ed.entityConditionNode\n            if (entityConditionNode != null) {\n                ArrayList<MNode> ecObList = entityConditionNode.children(\"order-by\")\n                if (ecObList != null) for (int i = 0; i < ecObList.size(); i++) {\n                    MNode orderBy = ecObList.get(i)\n                    String fieldName = orderBy.attribute(\"field-name\")\n                    if(!orderByExpanded.contains(fieldName)) orderByExpanded.add(fieldName)\n                }\n                if (\"true\".equals(entityConditionNode.attribute(\"distinct\"))) this.distinct(true)\n            }\n        }\n\n        // order by fields need to be selected (at least on some databases, Derby is one of them)\n        if (getDistinct() && fieldsToSelect != null && fieldsToSelect.size() > 0 && orderByExpanded.size() > 0) {\n            for (String orderByField in orderByExpanded) {\n                FieldOrderOptions foo = new FieldOrderOptions(orderByField)\n                if (!fieldsToSelect.contains(foo.fieldName)) fieldsToSelect.add(foo.fieldName)\n            }\n        }\n\n        // we always want fieldInfoArray populated so that we know the order of the results coming back\n        int ftsSize = fieldsToSelect != null ? fieldsToSelect.size() : 0\n        FieldInfo[] fieldInfoArray\n        FieldOrderOptions[] fieldOptionsArray = (FieldOrderOptions[]) null\n        if (ftsSize == 0) {\n            fieldInfoArray = entityInfo.allFieldInfoArray\n        } else {\n            fieldInfoArray = new FieldInfo[ftsSize]\n            fieldOptionsArray = new FieldOrderOptions[ftsSize]\n            boolean hasFieldOptions = false\n            int fieldInfoArrayIndex = 0\n            for (int i = 0; i < ftsSize; i++) {\n                String fieldName = (String) fieldsToSelect.get(i)\n                FieldInfo fi = ed.getFieldInfo(fieldName)\n                if (fi == null) {\n                    FieldOrderOptions foo = new FieldOrderOptions(fieldName)\n                    fi = ed.getFieldInfo(foo.fieldName)\n                    if (fi == null) throw new EntityException(\"Field to select ${fieldName} not found in entity ${ed.getFullEntityName()}\")\n\n                    fieldInfoArray[fieldInfoArrayIndex] = fi\n                    fieldOptionsArray[fieldInfoArrayIndex] = foo\n                    fieldInfoArrayIndex++\n                    hasFieldOptions = true\n                } else {\n                    fieldInfoArray[fieldInfoArrayIndex] = fi\n                    fieldInfoArrayIndex++\n                }\n            }\n            if (!hasFieldOptions) fieldOptionsArray = (FieldOrderOptions[]) null\n            if (fieldOptionsArray == null && ftsSize == entityInfo.allFieldInfoArray.length)\n                fieldInfoArray = entityInfo.allFieldInfoArray\n        }\n\n        // NOTE: artifactExecutionFacade.filterFindForUser() no longer called here, called in EntityFindBuilder after trimming if needed for view-entity\n\n        EntityConditionImplBase whereCondition = getWhereEntityConditionInternal(ed)\n        EntityConditionImplBase havingCondition = havingEntityCondition\n        if (isViewEntity) {\n            EntityConditionImplBase viewWhere = ed.makeViewWhereCondition()\n            whereCondition = EntityConditionFactoryImpl.makeConditionImpl(whereCondition, EntityCondition.AND, viewWhere)\n\n            EntityConditionImplBase viewHaving = ed.makeViewHavingCondition()\n            havingCondition = EntityConditionFactoryImpl.makeConditionImpl(havingCondition, EntityCondition.AND, viewHaving)\n        }\n\n        // call the abstract method\n        EntityListIterator eli\n        try { eli = iteratorExtended(whereCondition, havingCondition, orderByExpanded, fieldInfoArray, fieldOptionsArray) }\n        catch (SQLException e) { throw new EntitySqlException(makeErrorMsg(\"Error finding list of\", LIST_ERROR, whereCondition, ed, ec), e) }\n        catch (ArtifactAuthorizationException e) { throw e }\n        catch (Exception e) { throw new EntityException(makeErrorMsg(\"Error finding list of\", LIST_ERROR, whereCondition, ed, ec), e) }\n\n        // NOTE: if we are doing offset/limit with a cursor no good way to limit results, but we'll at least jump to the offset\n        MNode databaseNode = this.efi.getDatabaseNode(ed.getEntityGroupName())\n        // NOTE: allow databaseNode to be null because custom (non-JDBC) datasources may not have one\n        if (this.offset != null && databaseNode != null && \"cursor\".equals(databaseNode.attribute(\"offset-style\"))) {\n            if (!eli.absolute(offset)) {\n                // can't seek to desired offset? not enough results, just go to after last result\n                eli.afterLast()\n            }\n        }\n\n        // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(ed.getFullEntityName(), simpleAndMap, \"find-iterator\", false)\n\n        return eli\n    }\n\n    abstract EntityListIterator iteratorExtended(EntityConditionImplBase whereCondition, EntityConditionImplBase havingCondition,\n            ArrayList<String> orderByExpanded, FieldInfo[] fieldInfoArray, FieldOrderOptions[] fieldOptionsArray) throws SQLException\n\n    @Override\n    long count() throws EntityException {\n        ExecutionContextImpl ec = efi.ecfi.getEci()\n        ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade\n        boolean enableAuthz = disableAuthz ? !ec.artifactExecutionFacade.disableAuthz() : false\n        try {\n            EntityDefinition ed = getEntityDef()\n\n            ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(ed.getFullEntityName(),\n                    ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_VIEW, \"count\")\n            aefi.pushInternal(aei, !ed.entityInfo.authorizeSkipView, false)\n            try {\n                return countInternal(ec, ed)\n            } finally {\n                aefi.pop(aei)\n            }\n        } finally {\n            if (enableAuthz) ec.artifactExecutionFacade.enableAuthz()\n        }\n    }\n    protected long countInternal(ExecutionContextImpl ec, EntityDefinition ed) throws EntityException, SQLException {\n        if (requireSearchFormParameters && !hasSearchFormParameters) return 0L\n\n        EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo\n        boolean isViewEntity = entityInfo.isView\n\n        // there may not be a simpleAndMap, but that's all we have that can be treated directly by the EECA\n        // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(ed.getFullEntityName(), simpleAndMap, \"find-count\", true)\n\n        boolean doCache = shouldCache()\n\n        // NOTE: artifactExecutionFacade.filterFindForUser() no longer called here, called in EntityFindBuilder after trimming if needed for view-entity\n        if (doCache) {\n            // don't cache if there are any applicable filter conditions\n            ArrayList findFilterList = ec.artifactExecutionFacade.getFindFiltersForUser(ed, null)\n            if (findFilterList != null && findFilterList.size() > 0) doCache = false\n        }\n\n        EntityConditionImplBase whereCondition = getWhereEntityConditionInternal(ed)\n        // don't cache if no whereCondition\n        if (whereCondition == null) doCache = false\n        // NOTE: don't cache if there is a having condition, for now just support where\n\n        Cache<EntityCondition, Long> entityCountCache = doCache ? ed.getCacheCount(efi.getEntityCache()) : (Cache) null\n        Long cacheCount = (Long) null\n        if (doCache) cacheCount = (Long) entityCountCache.get(whereCondition)\n\n        long count\n        if (cacheCount != null) {\n            count = cacheCount\n        } else {\n            // select all pk and nonpk fields to match what list() or iterator() would do\n            int ftsSize = fieldsToSelect != null ? fieldsToSelect.size() : 0\n            FieldInfo[] fieldInfoArray\n            FieldOrderOptions[] fieldOptionsArray = (FieldOrderOptions[]) null\n            if (ftsSize == 0) {\n                fieldInfoArray = entityInfo.allFieldInfoArray\n            } else {\n                fieldInfoArray = new FieldInfo[ftsSize]\n                fieldOptionsArray = new FieldOrderOptions[ftsSize]\n                boolean hasFieldOptions = false\n                int fieldInfoArrayIndex = 0\n                for (int i = 0; i < ftsSize; i++) {\n                    String fieldName = (String) fieldsToSelect.get(i)\n                    FieldInfo fi = ed.getFieldInfo(fieldName)\n                    if (fi == null) {\n                        FieldOrderOptions foo = new FieldOrderOptions(fieldName)\n                        fi = ed.getFieldInfo(foo.fieldName)\n                        if (fi == null) throw new EntityException(\"Field to select ${fieldName} not found in entity ${ed.getFullEntityName()}\")\n\n                        fieldInfoArray[fieldInfoArrayIndex] = fi\n                        fieldOptionsArray[fieldInfoArrayIndex] = foo\n                        fieldInfoArrayIndex++\n                        hasFieldOptions = true\n                    } else {\n                        fieldInfoArray[fieldInfoArrayIndex] = fi\n                        fieldInfoArrayIndex++\n                    }\n                }\n                if (!hasFieldOptions) fieldOptionsArray = (FieldOrderOptions[]) null\n                if (fieldOptionsArray == null && ftsSize == entityInfo.allFieldInfoArray.length)\n                    fieldInfoArray = entityInfo.allFieldInfoArray\n            }\n            // logger.warn(\"fieldsToSelect: ${fieldsToSelect} fieldInfoArray: ${fieldInfoArray}\")\n\n            if (isViewEntity) {\n                MNode entityConditionNode = ed.entityConditionNode\n                if (entityConditionNode != null && \"true\".equals(entityConditionNode.attribute(\"distinct\"))) this.distinct(true)\n            }\n\n            EntityConditionImplBase queryWhereCondition = whereCondition\n            EntityConditionImplBase havingCondition = havingEntityCondition\n            if (isViewEntity) {\n                EntityConditionImplBase viewWhere = ed.makeViewWhereCondition()\n                queryWhereCondition = EntityConditionFactoryImpl.makeConditionImpl(whereCondition, EntityCondition.AND, viewWhere)\n\n                havingCondition = havingEntityCondition\n                EntityConditionImplBase viewHaving = ed.makeViewHavingCondition()\n                havingCondition = EntityConditionFactoryImpl.makeConditionImpl(havingCondition, EntityCondition.AND, viewHaving)\n            }\n\n            // call the abstract method\n            try { count = countExtended(queryWhereCondition, havingCondition, fieldInfoArray, fieldOptionsArray) }\n            catch (SQLException e) { throw new EntitySqlException(makeErrorMsg(\"Error finding count of\", COUNT_ERROR, queryWhereCondition, ed, ec), e) }\n            catch (Exception e) { throw new EntityException(makeErrorMsg(\"Error finding count of\", COUNT_ERROR, queryWhereCondition, ed, ec), e) }\n\n            if (doCache) entityCountCache.put(whereCondition, count)\n        }\n\n        // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(ed.getFullEntityName(), simpleAndMap, \"find-count\", false)\n\n        return count\n    }\n\n    abstract long countExtended(EntityConditionImplBase whereCondition, EntityConditionImplBase havingCondition,\n                                FieldInfo[] fieldInfoArray, FieldOrderOptions[] fieldOptionsArray) throws SQLException\n\n    @Override\n    long updateAll(Map<String, Object> fieldsToSet) {\n        boolean enableAuthz = disableAuthz ? !efi.ecfi.getEci().artifactExecutionFacade.disableAuthz() : false\n        try {\n            return updateAllInternal(fieldsToSet)\n        } finally {\n            if (enableAuthz) efi.ecfi.getEci().artifactExecutionFacade.enableAuthz()\n        }\n    }\n    protected long updateAllInternal(Map<String, Object> fieldsToSet) {\n        // NOTE: this code isn't very efficient, but will do the trick and cause all EECAs to be fired\n        // NOTE: consider expanding this to do a bulk update in the DB if there are no EECAs for the entity\n\n        EntityDefinition ed = getEntityDef()\n        if (ed.entityInfo.createOnly) throw new EntityException(\"Entity ${ed.getFullEntityName()} is create-only (immutable), cannot be updated.\")\n\n        this.useCache(false)\n        long totalUpdated = 0\n        iterator().withCloseable ({eli ->\n            EntityValue value\n            while ((value = eli.next()) != null) {\n                value.putAll(fieldsToSet)\n                if (value.isModified()) {\n                    // NOTE: consider implement and use the eli.set(value) method to update within a ResultSet\n                    value.update()\n                    totalUpdated++\n                }\n            }\n        })\n        return totalUpdated\n    }\n\n    @Override\n    long deleteAll() {\n        boolean enableAuthz = disableAuthz ? !efi.ecfi.getEci().artifactExecutionFacade.disableAuthz() : false\n        try {\n            return deleteAllInternal()\n        } finally {\n            if (enableAuthz) efi.ecfi.getEci().artifactExecutionFacade.enableAuthz()\n        }\n    }\n    protected long deleteAllInternal() {\n        // NOTE: this code isn't very efficient (though eli.remove() is a little bit more), but will do the trick and cause all EECAs to be fired\n\n        EntityDefinition ed = getEntityDef()\n        if (ed.entityInfo.createOnly) throw new EntityException(\"Entity ${ed.getFullEntityName()} is create-only (immutable), cannot be deleted.\")\n\n        // if there are no EECAs for the entity OR there is a TransactionCache in place just call ev.delete() on each\n        // NOTE DEJ 20200716 always use EV delete, not all JDBC drivers support ResultSet.deleteRow()... like MySQL Connector/J 8.0.20\n        // boolean useEvDelete = txCache != null || efi.hasEecaRules(ed.getFullEntityName())\n        boolean useEvDelete = true\n        this.useCache(false)\n        long totalDeleted = 0\n        if (useEvDelete) {\n            // TODO: use EntityListIterator to avoid OutOfMemoryError\n            EntityList el = list()\n            int elSize = el.size()\n            for (int i = 0; i < elSize; i++) {\n                EntityValue ev = (EntityValue) el.get(i)\n                ev.delete()\n                totalDeleted++\n            }\n        } else {\n            this.resultSetConcurrency(ResultSet.CONCUR_UPDATABLE)\n            iterator().withCloseable ({eli->\n\n                while (eli.next() != null) {\n                    // no longer need to clear cache, eli.remove() does that\n                    eli.remove()\n                    totalDeleted++\n                }\n            })\n        }\n        return totalDeleted\n    }\n\n    @Override\n    void extract(SimpleEtl etl) {\n        try (EntityListIterator eli = iterator()) {\n            EntityValue ev\n            while ((ev = eli.next()) != null) {\n                etl.processEntry(ev)\n            }\n        } catch (StopException e) {\n            logger.warn(\"EntityFind extract stopped on: \" + (e.getCause()?.toString() ?: e.toString()))\n        }\n    }\n\n    @Override\n    ArrayList<String> getQueryTextList() { return queryTextList }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityFindBuilder.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity;\n\nimport org.moqui.BaseArtifactException;\nimport org.moqui.entity.EntityCondition;\nimport org.moqui.entity.EntityException;\nimport org.moqui.impl.entity.condition.EntityConditionImplBase;\nimport org.moqui.impl.entity.EntityJavaUtil.FieldOrderOptions;\nimport org.moqui.util.MNode;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.sql.PreparedStatement;\nimport java.sql.SQLException;\nimport java.util.ArrayList;\nimport java.util.HashSet;\nimport java.util.Set;\nimport java.util.TreeSet;\n\npublic class EntityFindBuilder extends EntityQueryBuilder {\n    private static final Logger logger = LoggerFactory.getLogger(EntityFindBuilder.class);\n    private static final boolean isDebugEnabled = logger.isDebugEnabled();\n\n    private EntityFindBase entityFindBase;\n    private EntityConditionImplBase whereCondition;\n    private FieldInfo[] fieldInfoArray;\n\n    public EntityFindBuilder(EntityDefinition entityDefinition, EntityFindBase entityFindBase,\n                             EntityConditionImplBase whereCondition, FieldInfo[] fieldInfoArray) {\n        super(entityDefinition, entityFindBase.efi);\n        this.entityFindBase = entityFindBase;\n        this.whereCondition = whereCondition;\n        this.fieldInfoArray = fieldInfoArray;\n\n        // this is always going to start with \"SELECT \", so just set it here\n        sqlTopLevel.append(\"SELECT \");\n    }\n\n    public void makeDistinct() { sqlTopLevel.append(\"DISTINCT \"); }\n\n    public void makeCountFunction(FieldOrderOptions[] fieldOptionsArray, boolean isDistinct, boolean isGroupBy) {\n        int fiaLength = fieldInfoArray.length;\n        if (isGroupBy || (isDistinct && fiaLength > 0)) {\n            sqlTopLevel.append(\"COUNT(*) FROM (SELECT \");\n            if (isDistinct) sqlTopLevel.append(\"DISTINCT \");\n            // NOTE: regardless of DB configuration (database.@add-unique-as) it is always needed across various DBs in this case, including MySQL\n            makeSqlSelectFields(fieldInfoArray, fieldOptionsArray, true);\n            // NOTE: this will be closed by closeCountSubSelect()\n        } else {\n            if (isDistinct) {\n                sqlTopLevel.append(\"COUNT(DISTINCT *) \");\n            } else {\n                // NOTE: on H2 COUNT(*) is faster than COUNT(1) (and perhaps other databases? docs hint may be faster in MySQL)\n                sqlTopLevel.append(\"COUNT(*) \");\n            }\n        }\n    }\n\n    public void closeCountSubSelect(int fiaLength, boolean isDistinct, boolean isGroupBy) {\n        if (isGroupBy || (isDistinct && fiaLength > 0)) sqlTopLevel.append(\") TEMP_NAME\");\n    }\n\n    public void expandJoinFromAlias(final MNode entityNode, final String searchEntityAlias, Set<String> entityAliasUsedSet,\n                                    Set<String> entityAliasesJoinedInSet) {\n        // first see if it needs expanding\n        if (entityAliasesJoinedInSet.contains(searchEntityAlias)) return;\n\n        // find the a link back one in the set\n        MNode memberEntityNode = entityNode.first(\"member-entity\", \"entity-alias\", searchEntityAlias);\n        if (memberEntityNode == null) throw new EntityException(\"Could not find member-entity with entity-alias \" +\n                searchEntityAlias + \" in view-entity \" + entityNode.attribute(\"entity-name\"));\n        String joinFromAlias = memberEntityNode.attribute(\"join-from-alias\");\n        if (joinFromAlias == null || joinFromAlias.length() == 0) throw new EntityException(\"In view-entity \" +\n                entityNode.attribute(\"entity-name\") + \" the member-entity for entity-alias \" + searchEntityAlias +\n                \" has no join-from-alias and is not the first member-entity\");\n        if (entityAliasesJoinedInSet.contains(joinFromAlias)) {\n            entityAliasesJoinedInSet.add(searchEntityAlias);\n            entityAliasUsedSet.add(joinFromAlias);\n            entityAliasUsedSet.add(searchEntityAlias);\n        } else {\n            // recurse to find member-entity with joinFromAlias, add in its joinFromAlias until one is found that is already in the set\n            expandJoinFromAlias(entityNode, joinFromAlias, entityAliasUsedSet, entityAliasesJoinedInSet);\n            // if no exception from an alias not found or not joined in then we found a join path back so add in the current search alias\n            entityAliasesJoinedInSet.add(searchEntityAlias);\n            entityAliasUsedSet.add(searchEntityAlias);\n        }\n    }\n\n    public void makeSqlFromClause() {\n        whereCondition = makeSqlFromClause(mainEntityDefinition, sqlTopLevel, whereCondition,\n                (EntityConditionImplBase) entityFindBase.getHavingEntityCondition(), null);\n    }\n\n    public EntityConditionImplBase makeSqlFromClause(final EntityDefinition localEntityDefinition, StringBuilder localBuilder,\n                EntityConditionImplBase localWhereCondition, EntityConditionImplBase localHavingCondition, Set<String> additionalFieldsUsed) {\n        localBuilder.append(\" FROM \");\n\n        EntityConditionImplBase outWhereCondition = localWhereCondition;\n\n        // TODO: bug in Groovy 3.0.10 that somehow flips the isViewEntity boolean; can remove this once resolved with a future version of Groovy\n        // if (localEntityDefinition.fullEntityName.contains(\"ArtifactTarpitCheckView\") || localEntityDefinition.fullEntityName.contains(\"DataFeedDocumentDetail\"))\n        //     logger.warn(\"===== TOREMOVE ===== localEntityDefinition \" + localEntityDefinition.fullEntityName + \" isViewEntity \" + localEntityDefinition.isViewEntity + \" \" + localEntityDefinition);\n\n        if (localEntityDefinition.isViewEntity) {\n            final MNode entityNode = localEntityDefinition.getEntityNode();\n            final MNode databaseNode = efi.getDatabaseNode(localEntityDefinition.getEntityGroupName());\n            String jsAttr = databaseNode.attribute(\"join-style\");\n            final String joinStyle = jsAttr != null && jsAttr.length() > 0 ? jsAttr : \"ansi\";\n\n            if (!\"ansi\".equals(joinStyle) && !\"ansi-no-parenthesis\".equals(joinStyle)) {\n                throw new BaseArtifactException(\"The join-style \" + joinStyle + \" is not supported, found on database \" +\n                        databaseNode.attribute(\"name\"));\n            }\n\n            boolean useParenthesis = \"ansi\".equals(joinStyle);\n\n            ArrayList<MNode> memberEntityNodes = entityNode.children(\"member-entity\");\n            int memberEntityNodesSize = memberEntityNodes.size();\n\n            // get a list of all aliased fields selected or ordered by and don't bother joining in a member-entity\n            //     that is not selected or ordered by\n            Set<String> entityAliasUsedSet = new HashSet<>();\n            Set<String> fieldUsedSet = new HashSet<>();\n\n            // add aliases used to fields used\n            EntityConditionImplBase viewWhere = localEntityDefinition.makeViewWhereCondition();\n            if (viewWhere != null) viewWhere.getAllAliases(entityAliasUsedSet, fieldUsedSet);\n            if (localWhereCondition != null) localWhereCondition.getAllAliases(entityAliasUsedSet, fieldUsedSet);\n            if (localHavingCondition != null) localHavingCondition.getAllAliases(entityAliasUsedSet, fieldUsedSet);\n\n            // logger.warn(\"SQL from viewWhere \" + viewWhere + \" localWhereCondition \" + localWhereCondition + \" localHavingCondition \" + localHavingCondition);\n            // logger.warn(\"SQL from fieldUsedSet \" + fieldUsedSet + \" additionalFieldsUsed \" + additionalFieldsUsed);\n            if (additionalFieldsUsed == null) {\n                // add selected fields\n                for (int i = 0; i < fieldInfoArray.length; i++) {\n                    FieldInfo fi = fieldInfoArray[i];\n                    if (fi == null) break;\n                    fieldUsedSet.add(fi.name);\n                }\n                // add order by fields\n                ArrayList<String> orderByFields = entityFindBase.orderByFields;\n                if (orderByFields != null) {\n                    int orderByFieldsSize = orderByFields.size();\n                    for (int i = 0; i < orderByFieldsSize; i++) {\n                        String orderByField = orderByFields.get(i);\n                        EntityJavaUtil.FieldOrderOptions foo = new EntityJavaUtil.FieldOrderOptions(orderByField);\n                        fieldUsedSet.add(foo.getFieldName());\n                    }\n                }\n            } else {\n                // additional fields to look for, when this is a sub-select for a member-entity that is a view-entity\n                fieldUsedSet.addAll(additionalFieldsUsed);\n            }\n\n            // get a list of entity aliases used\n            for (String fieldName : fieldUsedSet) {\n                FieldInfo fi = localEntityDefinition.getFieldInfo(fieldName);\n                if (fi == null) throw new EntityException(\"Could not find field \" + fieldName + \" in entity \" + localEntityDefinition.getFullEntityName());\n                entityAliasUsedSet.addAll(fi.entityAliasUsedSet);\n            }\n\n            // if (localEntityDefinition.getFullEntityName().contains(\"Example\"))\n            //    logger.warn(\"============== entityAliasUsedSet=${entityAliasUsedSet} for entity ${localEntityDefinition.entityName}\\n fieldUsedSet=${fieldUsedSet}\\n fieldInfoList=${fieldInfoList}\\n orderByFields=${entityFindBase.orderByFields}\")\n\n            // make sure each entityAlias in the entityAliasUsedSet links back to the main\n            MNode memberEntityNode = null;\n            for (int i = 0; i < memberEntityNodesSize; i++) {\n                MNode curMeNode = memberEntityNodes.get(i);\n                String jfa = curMeNode.attribute(\"join-from-alias\");\n                if (jfa == null || jfa.length() == 0) {\n                    memberEntityNode = curMeNode;\n                    break;\n                }\n            }\n\n            String mainEntityAlias = memberEntityNode != null ? memberEntityNode.attribute(\"entity-alias\") : null;\n\n            Set<String> entityAliasesJoinedInSet = new HashSet<>();\n            if (mainEntityAlias != null) entityAliasesJoinedInSet.add(mainEntityAlias);\n            for (String entityAlias : new HashSet<>(entityAliasUsedSet)) {\n                expandJoinFromAlias(entityNode, entityAlias, entityAliasUsedSet, entityAliasesJoinedInSet);\n            }\n\n            // logger.warn(\"============== entityAliasUsedSet=${entityAliasUsedSet} for entity ${localEntityDefinition.entityName}\\nfieldUsedSet=${fieldUsedSet}\\n fieldInfoList=${fieldInfoList}\\n orderByFields=${entityFindBase.orderByFields}\")\n\n            // at this point entityAliasUsedSet is finalized so do authz filter if needed\n            ArrayList<EntityConditionImplBase> filterCondList = efi.ecfi.getEci().artifactExecutionFacade.filterFindForUser(localEntityDefinition, entityAliasUsedSet);\n            outWhereCondition = EntityConditionFactoryImpl.addAndListToCondition(outWhereCondition, filterCondList);\n\n            // keep a set of all aliases in the join so far and if the left entity alias isn't there yet, and this\n            // isn't the first one, throw an exception\n            final Set<String> joinedAliasSet = new TreeSet<>();\n\n            // on initial pass only add opening parenthesis since easier than going back and inserting them, then insert the rest\n            boolean isFirst = true;\n            boolean fromEmpty = true;\n            for (int meInd = 0; meInd < memberEntityNodesSize; meInd++) {\n                MNode relatedMemberEntityNode = memberEntityNodes.get(meInd);\n\n                String entityAlias = relatedMemberEntityNode.attribute(\"entity-alias\");\n                final String joinFromAlias = relatedMemberEntityNode.attribute(\"join-from-alias\");\n                // logger.warn(\"=================== joining member-entity ${relatedMemberEntity}\")\n\n                // if this isn't joined in skip it (should be first one only); the first is handled below\n                if (joinFromAlias == null || joinFromAlias.length() == 0) continue;\n\n                // if entity alias not used don't join it in\n                if (!entityAliasUsedSet.contains(entityAlias)) continue;\n                if (!entityAliasUsedSet.contains(joinFromAlias)) continue;\n\n                if (isFirst && useParenthesis) localBuilder.append(\"(\");\n\n                // adding to from, then it's not empty\n                fromEmpty = false;\n\n                MNode linkMemberNode = null;\n                for (int i = 0; i < memberEntityNodesSize; i++) {\n                    MNode curMeNode = memberEntityNodes.get(i);\n                    if (joinFromAlias.equals(curMeNode.attribute(\"entity-alias\"))) {\n                        linkMemberNode = curMeNode;\n                        break;\n                    }\n                }\n\n                String linkEntityName = linkMemberNode != null ? linkMemberNode.attribute(\"entity-name\") : null;\n                EntityDefinition linkEntityDefinition = efi.getEntityDefinition(linkEntityName);\n                String relatedLinkEntityName = relatedMemberEntityNode.attribute(\"entity-name\");\n                EntityDefinition relatedLinkEntityDefinition = efi.getEntityDefinition(relatedLinkEntityName);\n\n                if (isFirst) {\n                    // first link, add link entity for this one only, for others add related link entity\n                    outWhereCondition = makeSqlViewTableName(linkEntityDefinition, localBuilder, outWhereCondition, localHavingCondition);\n                    localBuilder.append(\" \").append(joinFromAlias);\n\n                    joinedAliasSet.add(joinFromAlias);\n                } else {\n                    // make sure the left entity alias is already in the join...\n                    if (!joinedAliasSet.contains(joinFromAlias)) {\n                        logger.error(\"For view-entity [\" + localEntityDefinition.fullEntityName +\n                                \"] found member-entity with @join-from-alias [\" + joinFromAlias +\n                                \"] that is not in the joinedAliasSet: \" + joinedAliasSet + \"; view-entity Node: \" + entityNode);\n                        throw new EntityException(\"Tried to link the \" + entityAlias + \" alias to the \" + joinFromAlias +\n                                \" alias of the \" + localEntityDefinition.fullEntityName +\n                                \" view-entity, but it is not the first member-entity and has not been joined to a previous member-entity. In other words, the left/main alias isn't connected to the rest of the member-entities yet.\");\n                    }\n                }\n                // now put the rel (right) entity alias into the set that is in the join\n                joinedAliasSet.add(entityAlias);\n\n                String fromLateralStyle = \"none\";\n                String subSelectAttr = relatedMemberEntityNode.attribute(\"sub-select\");\n                boolean subSelect = \"true\".equals(subSelectAttr) || \"non-lateral\".equals(subSelectAttr);\n                if (\"true\".equals(subSelectAttr)) {\n                    fromLateralStyle = databaseNode.attribute(\"from-lateral-style\");\n                    if (fromLateralStyle == null || fromLateralStyle.isEmpty()) fromLateralStyle = \"none\";\n                }\n                boolean isLateralStyle = \"lateral\".equals(fromLateralStyle);\n                boolean isApplyStyle = \"apply\".equals(fromLateralStyle);\n                if (isApplyStyle) logger.warn(\"from-lateral-style=apply not yet supported, using non-lateral join for sub-select in \" + localEntityDefinition.getFullEntityName());\n\n                // TODO: for isApplyStyle need to use CROSS APPLY or OUTER APPLY for join-optional=true INSTEAD of [INNER|OUTER LEFT] JOIN in calling code\n                if (\"true\".equals(relatedMemberEntityNode.attribute(\"join-optional\"))) {\n                    localBuilder.append(\" LEFT OUTER JOIN \");\n                } else {\n                    localBuilder.append(\" INNER JOIN \");\n                }\n\n                if (subSelect) {\n                    makeSqlMemberSubSelect(entityAlias, relatedMemberEntityNode, relatedLinkEntityDefinition, linkEntityDefinition, localBuilder);\n                } else {\n                    outWhereCondition = makeSqlViewTableName(relatedLinkEntityDefinition, localBuilder, outWhereCondition, localHavingCondition);\n                }\n                localBuilder.append(\" \").append(entityAlias);\n\n                // TODO: for isApplyStyle skip ON clause completely\n                localBuilder.append(\" ON \");\n                if (isLateralStyle) {\n                    localBuilder.append(\"1=1\");\n                } else {\n                    appendJoinConditions(relatedMemberEntityNode, entityAlias, localEntityDefinition, linkEntityDefinition,\n                            relatedLinkEntityDefinition, localBuilder);\n                }\n\n                isFirst = false;\n            }\n\n            if (!fromEmpty && useParenthesis) localBuilder.append(\")\");\n\n            // handle member-entities not referenced in any member-entity.@join-from-alias attribute\n            for (int meInd = 0; meInd < memberEntityNodesSize; meInd++) {\n                MNode memberEntity = memberEntityNodes.get(meInd);\n                String memberEntityAlias = memberEntity.attribute(\"entity-alias\");\n\n                // if entity alias not used don't join it in\n                if (!entityAliasUsedSet.contains(memberEntityAlias)) continue;\n                if (joinedAliasSet.contains(memberEntityAlias)) continue;\n\n                EntityDefinition fromEntityDefinition = efi.getEntityDefinition(memberEntity.attribute(\"entity-name\"));\n                if (fromEmpty) { fromEmpty = false; } else { localBuilder.append(\", \"); }\n                String subSelectAttr = memberEntity.attribute(\"sub-select\");\n                if (\"true\".equals(subSelectAttr) || \"non-lateral\".equals(subSelectAttr)) {\n                    makeSqlMemberSubSelect(memberEntityAlias, memberEntity, fromEntityDefinition, null, localBuilder);\n                } else {\n                    outWhereCondition = makeSqlViewTableName(fromEntityDefinition, localBuilder, outWhereCondition, localHavingCondition);\n                }\n                localBuilder.append(\" \").append(memberEntityAlias);\n            }\n        } else {\n            // not a view-entity so do authz filter now if needed\n            ArrayList<EntityConditionImplBase> filterCondList = efi.ecfi.getEci().artifactExecutionFacade.filterFindForUser(localEntityDefinition, null);\n            outWhereCondition = EntityConditionFactoryImpl.addAndListToCondition(outWhereCondition, filterCondList);\n\n            localBuilder.append(localEntityDefinition.getFullTableName());\n        }\n\n        return outWhereCondition;\n    }\n\n    public void appendJoinConditions(MNode relatedMemberEntityNode, String entityAlias, EntityDefinition localEntityDefinition,\n            EntityDefinition linkEntityDefinition, EntityDefinition relatedLinkEntityDefinition, StringBuilder localBuilder) {\n        final String joinFromAlias = relatedMemberEntityNode.attribute(\"join-from-alias\");\n\n        String subSelectAttr = relatedMemberEntityNode.attribute(\"sub-select\");\n        boolean subSelect = \"true\".equals(subSelectAttr) || \"non-lateral\".equals(subSelectAttr);\n\n        ArrayList<MNode> keyMaps = relatedMemberEntityNode.children(\"key-map\");\n        ArrayList<MNode> entityConditionList = relatedMemberEntityNode.children(\"entity-condition\");\n        if ((keyMaps == null || keyMaps.size() == 0) && (entityConditionList == null || entityConditionList.size() == 0)) {\n            throw new EntityException(\"No member-entity/join key-maps found for the \" + joinFromAlias +\n                    \" and the \" + entityAlias + \" member-entities of the \" + localEntityDefinition.fullEntityName + \" view-entity.\");\n        }\n\n        int keyMapsSize = keyMaps != null ? keyMaps.size() : 0;\n        for (int i = 0; i < keyMapsSize; i++) {\n            MNode keyMap = keyMaps.get(i);\n            String joinFromField = keyMap.attribute(\"field-name\");\n            if (i > 0) localBuilder.append(\" AND \");\n\n            ArrayList<MNode> aliasNodes = localEntityDefinition.getEntityNode().children(\"alias\");\n            MNode outerAliasNode = null;\n            for (int ai = 0; ai < aliasNodes.size(); ai++) {\n                MNode curAliasNode = aliasNodes.get(ai);\n                if (joinFromAlias.equals(curAliasNode.attribute(\"entity-alias\"))) {\n                    // must match field name\n                    String curFieldName = curAliasNode.attribute(\"field\");\n                    if (curFieldName == null || curFieldName.isEmpty()) curFieldName = curAliasNode.attribute(\"name\");\n                    // must not have a function (not valid in JOIN ON clause)\n                    String curFunction = curAliasNode.attribute(\"function\");\n                    if (joinFromField.equals(curFieldName) && (curFunction == null || curFunction.isEmpty())) {\n                        outerAliasNode = curAliasNode;\n                        break;\n                    }\n                }\n            }\n            if (outerAliasNode != null) {\n                localBuilder.append(localEntityDefinition.getColumnName(outerAliasNode.attribute(\"name\")));\n            } else {\n                localBuilder.append(joinFromAlias).append(\".\");\n                localBuilder.append(linkEntityDefinition.getColumnName(joinFromField));\n            }\n\n            localBuilder.append(\" = \");\n\n            final String relatedAttr = keyMap.attribute(\"related\");\n            String relatedFieldName = relatedAttr != null && !relatedAttr.isEmpty() ? relatedAttr : keyMap.attribute(\"related-field-name\");\n            if (relatedFieldName == null || relatedFieldName.length() == 0)\n                relatedFieldName = keyMap.attribute(\"field-name\");\n            if (!relatedLinkEntityDefinition.isField(relatedFieldName) &&\n                    relatedLinkEntityDefinition.getPkFieldNames().size() == 1 && keyMaps.size() == 1) {\n                relatedFieldName = relatedLinkEntityDefinition.getPkFieldNames().get(0);\n                // if we don't match these constraints and get this default we'll get an error later...\n            }\n\n            if (entityAlias != null && !entityAlias.isEmpty()) localBuilder.append(entityAlias).append(\".\");\n            FieldInfo relatedFieldInfo = relatedLinkEntityDefinition.getFieldInfo(relatedFieldName);\n            if (relatedFieldInfo == null) throw new EntityException(\"Invalid field name \" + relatedFieldName + \" for entity \" + relatedLinkEntityDefinition.fullEntityName);\n            if (subSelect && entityAlias != null && !entityAlias.isEmpty()) {\n                localBuilder.append(EntityJavaUtil.camelCaseToUnderscored(relatedFieldInfo.name));\n            } else {\n                localBuilder.append(relatedFieldInfo.getFullColumnName());\n            }\n            // NOTE: sanitizeColumnName here breaks the generated SQL, in the case of a view within a view we want EAO.EAI.COL_NAME...\n            // localBuilder.append(sanitizeColumnName(relatedLinkEntityDefinition.getColumnName(relatedFieldName, false)))\n        }\n\n        if (entityConditionList != null && entityConditionList.size() > 0) {\n            // add any additional manual conditions for the member-entity view link here\n            MNode entityCondition = entityConditionList.get(0);\n            // logger.warn(\"======== appendJoinConditions() localEntityDefinition \" + localEntityDefinition.fullEntityName + \" linkEntityDefinition \" + linkEntityDefinition.fullEntityName + \" relatedLinkEntityDefinition \" + relatedLinkEntityDefinition.fullEntityName);\n            EntityConditionImplBase linkEcib = localEntityDefinition.makeViewListCondition(entityCondition, relatedMemberEntityNode);\n            if (keyMapsSize > 0) localBuilder.append(\" AND \");\n            // TODO: does this need to use localBuilder? seems to be working so far...\n            linkEcib.makeSqlWhere(this, null);\n        }\n    }\n\n    public EntityConditionImplBase makeSqlViewTableName(EntityDefinition localEntityDefinition, StringBuilder localBuilder,\n                EntityConditionImplBase localWhereCondition, EntityConditionImplBase localHavingCondition) {\n        EntityJavaUtil.EntityInfo entityInfo = localEntityDefinition.entityInfo;\n        EntityConditionImplBase outWhereCondition = localWhereCondition;\n        if (entityInfo.isView) {\n            localBuilder.append(\"(SELECT \");\n\n            // fields used for group by clause\n            Set<String> localFieldsToSelect = new HashSet<>();\n            // additional fields to consider when trimming the member-entities to join\n            Set<String> additionalFieldsUsed = new HashSet<>();\n            ArrayList<MNode> aliasList = localEntityDefinition.getEntityNode().children(\"alias\");\n            int aliasListSize = aliasList.size();\n            for (int i = 0; i < aliasListSize; i++) {\n                MNode aliasNode = aliasList.get(i);\n                String aliasName = aliasNode.attribute(\"name\");\n                String aliasField = aliasNode.attribute(\"field\");\n                if (aliasField == null || aliasField.length() == 0) aliasField = aliasName;\n                localFieldsToSelect.add(aliasName);\n                additionalFieldsUsed.add(aliasField);\n                if (i > 0) localBuilder.append(\", \");\n                localBuilder.append(localEntityDefinition.getColumnName(aliasName));\n                // TODO: are the next two lines really needed? have removed AS stuff elsewhere since it is not commonly used and not needed\n                //localBuilder.append(\" AS \")\n                //localBuilder.append(sanitizeColumnName(localEntityDefinition.getColumnName(aliasName), false)))\n            }\n\n            // pass through localWhereCondition in case changed\n            outWhereCondition = makeSqlFromClause(localEntityDefinition, localBuilder, localWhereCondition, localHavingCondition, additionalFieldsUsed);\n\n            // TODO: refactor this like below to do in the main loop; this is currently unused though (view-entity as member-entity for sub-select)\n            StringBuilder gbClause = new StringBuilder();\n            if (entityInfo.hasFunctionAlias) {\n                // do a different approach to GROUP BY: add all fields that are selected and don't have a function\n                for (int i = 0; i < aliasListSize; i++) {\n                    MNode aliasNode = aliasList.get(i);\n                    String nameAttr = aliasNode.attribute(\"name\");\n                    String functionAttr = aliasNode.attribute(\"function\");\n                    String isAggregateAttr = aliasNode.attribute(\"is-aggregate\");\n                    boolean isAggFunction = isAggregateAttr != null ? \"true\".equalsIgnoreCase(isAggregateAttr) :\n                            FieldInfo.aggFunctions.contains(functionAttr);\n                    if (localFieldsToSelect.contains(nameAttr) && !isAggFunction) {\n                        if (gbClause.length() > 0) gbClause.append(\", \");\n                        gbClause.append(localEntityDefinition.getColumnName(nameAttr));\n                    }\n                }\n            }\n\n            if (gbClause.length() > 0) {\n                localBuilder.append(\" GROUP BY \");\n                localBuilder.append(gbClause.toString());\n            }\n\n            localBuilder.append(\")\");\n        } else {\n            localBuilder.append(localEntityDefinition.getFullTableName());\n        }\n        return outWhereCondition;\n    }\n\n    public void makeSqlMemberSubSelect(String entityAlias, MNode memberEntity, EntityDefinition localEntityDefinition,\n                                       EntityDefinition linkEntityDefinition, StringBuilder localBuilder) {\n        String fromLateralStyle = \"none\";\n        if (\"true\".equals(memberEntity.attribute(\"sub-select\"))) {\n            final MNode databaseNode = efi.getDatabaseNode(localEntityDefinition.getEntityGroupName());\n            fromLateralStyle = databaseNode.attribute(\"from-lateral-style\");\n            if (fromLateralStyle == null || fromLateralStyle.isEmpty()) fromLateralStyle = \"none\";\n        }\n        boolean isLateralStyle = \"lateral\".equals(fromLateralStyle);\n        boolean isApplyStyle = \"apply\".equals(fromLateralStyle);\n\n        if (isLateralStyle) localBuilder.append(\" LATERAL \");\n        localBuilder.append(\"(SELECT \");\n\n        // add any fields needed to join this to another member-entity, even if not in the main set of selected fields\n        TreeSet<String> joinFields = new TreeSet<>();\n        ArrayList<MNode> keyMapList = memberEntity.children(\"key-map\");\n        for (int i = 0; i < keyMapList.size(); i++) {\n            MNode keyMap = keyMapList.get(i);\n            String relFn = keyMap.attribute(\"related\");\n            if (relFn == null || relFn.isEmpty()) relFn = keyMap.attribute(\"field-name\");\n            joinFields.add(relFn);\n        }\n        ArrayList<MNode> entityConditionList = memberEntity.children(\"entity-condition\");\n        if (entityConditionList != null && entityConditionList.size() > 0) {\n            MNode entCondNode = entityConditionList.get(0);\n            ArrayList<MNode> econdNodes = entCondNode.descendants(\"econdition\");\n            for (int i = 0; i < econdNodes.size(); i++) {\n                MNode econd = econdNodes.get(i);\n                if (entityAlias.equals(econd.attribute(\"entity-alias\"))) joinFields.add(econd.attribute(\"field-name\"));\n                if (entityAlias.equals(econd.attribute(\"to-entity-alias\"))) joinFields.add(econd.attribute(\"to-field-name\"));\n            }\n        }\n\n        EntityConditionImplBase viewCondition = null;\n        ArrayList<MNode> viewEntityConditionList = localEntityDefinition.getEntityNode().children(\"entity-condition\");\n        if (viewEntityConditionList != null && viewEntityConditionList.size() > 0) {\n            MNode entCondNode = viewEntityConditionList.get(0);\n            viewCondition = localEntityDefinition.makeViewListCondition(entCondNode, null);\n        }\n\n        // additional fields to consider when trimming the member-entities to join\n        Set<String> additionalFieldsUsed = new HashSet<>();\n        boolean hasAggregateFunction = false;\n        boolean hasSelected = false;\n        StringBuilder gbClause = new StringBuilder();\n        for (int i = 0; i < fieldInfoArray.length; i++) {\n            FieldInfo aliasFi = fieldInfoArray[i];\n            if (!aliasFi.entityAliasUsedSet.contains(entityAlias)) continue;\n\n            if (localEntityDefinition.isViewEntity) {\n                // get the outer alias node\n                String outerAliasField = aliasFi.aliasFieldName;\n                // get the local entity (sub-select) field node (may be alias node if sub-select on view-entity)\n                FieldInfo localFi = localEntityDefinition.getFieldInfo(outerAliasField);\n\n                MNode aliasNode = aliasFi.fieldNode;\n                MNode complexAliasNode = aliasNode.first(\"complex-alias\");\n                if (complexAliasNode != null) {\n                    boolean foundOtherEntityAlias = false;\n                    ArrayList<MNode> complexAliasFields = complexAliasNode.descendants(\"complex-alias-field\");\n                    for (int cafIdx = 0; cafIdx < complexAliasFields.size(); cafIdx++) {\n                        MNode cafNode = complexAliasFields.get(cafIdx);\n                        if (entityAlias.equals(cafNode.attribute(\"entity-alias\"))) {\n                            String cafField = cafNode.attribute(\"field\");\n                            additionalFieldsUsed.add(cafField);\n                            joinFields.remove(cafField);\n                        } else {\n                            foundOtherEntityAlias = true;\n                        }\n                    }\n                    if (!foundOtherEntityAlias) {\n                        if (localFi == null) throw new EntityException(\"Could not find field \" + outerAliasField + \" on entity \" + entityAlias + \":\" + localEntityDefinition.fullEntityName);\n                        String colName = localFi.getFullColumnName();\n                        if (hasSelected) { localBuilder.append(\", \"); } else { hasSelected = true; }\n                        localBuilder.append(colName).append(\" AS \").append(EntityJavaUtil.camelCaseToUnderscored(localFi.name));\n                        if (localFi.hasAggregateFunction) {\n                            hasAggregateFunction = true;\n                        } else {\n                            if (gbClause.length() > 0) gbClause.append(\", \");\n                            gbClause.append(EntityJavaUtil.camelCaseToUnderscored(localFi.name));\n                        }\n                        // } else {\n                        // if we found another entity alias not all on this sub-select entity (or view-entity)\n                        // TODO only select part that is - IFF not already selected to make sure is selected for outer select\n                    }\n                } else {\n                    if (localFi == null) throw new EntityException(\"Could not find field \" + outerAliasField + \" on entity \" + entityAlias + \":\" + localEntityDefinition.fullEntityName);\n                    additionalFieldsUsed.add(localFi.name);\n                    joinFields.remove(localFi.name);\n\n                    if (hasSelected) { localBuilder.append(\", \"); } else { hasSelected = true; }\n                    localBuilder.append(localFi.getFullColumnName()).append(\" AS \").append(EntityJavaUtil.camelCaseToUnderscored(localFi.name));\n                    if (localFi.hasAggregateFunction) {\n                        hasAggregateFunction = true;\n                    } else {\n                        if (gbClause.length() > 0) gbClause.append(\", \");\n                        gbClause.append(EntityJavaUtil.camelCaseToUnderscored(localFi.name));\n                    }\n                }\n            } else {\n                MNode aliasNode = aliasFi.fieldNode;\n                String aliasName = aliasFi.name;\n                String aliasField = aliasNode.attribute(\"field\");\n                if (aliasField == null || aliasField.isEmpty()) aliasField = aliasName;\n                additionalFieldsUsed.add(aliasField);\n                joinFields.remove(aliasField);\n                if (hasSelected) { localBuilder.append(\", \"); } else { hasSelected = true; }\n                // NOTE: this doesn't support various things that EntityDefinition.makeFullColumnName() does like case/when, complex-alias, etc\n                // those are difficult to pick out in nested XML elements where the 'alias' element has no entity-alias, and may not be needed at this level (try to handle at top level)\n                String function = aliasNode.attribute(\"function\");\n                String isAggregateAttr = aliasNode.attribute(\"is-aggregate\");\n                boolean isAggFunction = isAggregateAttr != null ? \"true\".equalsIgnoreCase(isAggregateAttr) :\n                        FieldInfo.aggFunctions.contains(function);\n                hasAggregateFunction = hasAggregateFunction || isAggFunction;\n                MNode complexAliasNode = aliasNode.first(\"complex-alias\");\n                if (complexAliasNode != null) {\n                    String colName = mainEntityDefinition.makeFullColumnName(aliasNode, false);\n                    localBuilder.append(colName).append(\" AS \").append(EntityJavaUtil.camelCaseToUnderscored(aliasName));\n                    if (!isAggFunction) {\n                        if (gbClause.length() > 0) gbClause.append(\", \");\n                        gbClause.append(sanitizeColumnName(colName));\n                    }\n                } else if (function != null && !function.isEmpty()) {\n                    String colName = EntityDefinition.getFunctionPrefix(function) + localEntityDefinition.getColumnName(aliasField) + \")\";\n                    localBuilder.append(colName).append(\" AS \").append(EntityJavaUtil.camelCaseToUnderscored(aliasName));\n                    if (!isAggFunction) {\n                        if (gbClause.length() > 0) gbClause.append(\", \");\n                        gbClause.append(sanitizeColumnName(colName));\n                    }\n                } else {\n                    String colName = localEntityDefinition.getColumnName(aliasField);\n                    localBuilder.append(colName);\n                    if (gbClause.length() > 0) gbClause.append(\", \");\n                    gbClause.append(colName);\n                }\n            }\n        }\n\n        // do the actual add of join field columns to select and group by\n        // TODO: for isApplyStyle also don't do this\n        if (!isLateralStyle) {\n            for (String joinField : joinFields) {\n                if (hasSelected) { localBuilder.append(\", \"); } else { hasSelected = true; }\n                String asName = EntityJavaUtil.camelCaseToUnderscored(joinField);\n                String colName = localEntityDefinition.getColumnName(joinField);\n                localBuilder.append(colName).append(\" AS \").append(asName);\n\n                if (gbClause.length() > 0) gbClause.append(\", \");\n                gbClause.append(colName);\n\n                if (localEntityDefinition.isViewEntity) additionalFieldsUsed.add(joinField);\n            }\n        }\n\n        // where condition to use for FROM clause (field filtering) and for sub-select WHERE clause\n        EntityConditionImplBase condition = whereCondition != null ? whereCondition.filter(entityAlias, mainEntityDefinition) : null;\n        condition = EntityConditionFactoryImpl.makeConditionImpl(condition, EntityCondition.AND, viewCondition);\n\n        // logger.warn(\"makeSqlMemberSubSelect SQL so far \" + localBuilder.toString());\n        // logger.warn(\"Calling makeSqlFromClause for \" + entityAlias + \":\" + localEntityDefinition.getEntityName() + \" condition \" + condition);\n        // logger.warn(\"Calling makeSqlFromClause for \" + entityAlias + \":\" + localEntityDefinition.getEntityName() + \" addtl fields \" + additionalFieldsUsed);\n        condition = makeSqlFromClause(localEntityDefinition, localBuilder, condition, null, additionalFieldsUsed);\n\n        // add where clause, just for conditions on aliased fields on this entity-alias\n        // TODO: for isApplyStyle also do this\n        if (condition != null || isLateralStyle) localBuilder.append(\" WHERE \");\n        // TODO: for isApplyStyle also do this\n        if (isLateralStyle) {\n            // TODO how to get this... is per field on inner/local view entity? probably should be otherwise all fields that join to outer view must be on first member-entity\n            String joinToIeThisAlias = localEntityDefinition.isViewEntity ? null : localEntityDefinition.getTableName();\n            appendJoinConditions(memberEntity, joinToIeThisAlias, localEntityDefinition, linkEntityDefinition, localEntityDefinition, localBuilder);\n            if (condition != null) localBuilder.append(\" AND \");\n        }\n        if (condition != null) {\n            // TODO: does this need to use localBuilder? seems to be working so far...\n            condition.makeSqlWhere(this, localEntityDefinition);\n        }\n\n        if (hasAggregateFunction && gbClause.length() > 0) {\n            localBuilder.append(\" GROUP BY \");\n            localBuilder.append(gbClause.toString());\n        }\n\n        localBuilder.append(\")\");\n    }\n\n    public void makeWhereClause() {\n        if (whereCondition == null) return;\n        EntityConditionImplBase condition = whereCondition;\n        if (mainEntityDefinition.hasSubSelectMembers) {\n            condition = condition.filter(null, mainEntityDefinition);\n            if (condition == null) return;\n        }\n        sqlTopLevel.append(\" WHERE \");\n        condition.makeSqlWhere(this, null);\n    }\n\n    public void makeGroupByClause() {\n        EntityJavaUtil.EntityInfo entityInfo = mainEntityDefinition.entityInfo;\n        if (!entityInfo.isView) return;\n\n        StringBuilder gbClause = new StringBuilder();\n        if (entityInfo.hasFunctionAlias) {\n            // do a different approach to GROUP BY: add all fields that are selected and don't have a function or that are in a sub-select\n            for (int j = 0; j < fieldInfoArray.length; j++) {\n                FieldInfo fi = fieldInfoArray[j];\n                if (fi == null) continue;\n                boolean doGroupBy = !fi.hasAggregateFunction;\n                if (fi.hasAggregateFunction && fi.memberEntityNode != null) {\n                    String subSelectAttr = fi.memberEntityNode.attribute(\"sub-select\");\n                    if (\"true\".equals(subSelectAttr) || \"non-lateral\".equals(subSelectAttr)) {\n                        // TODO we have a sub-select, if it is on a non-view entity we want to group by (on a view-entity would be only if no aggregate in wrapping alias)\n                        EntityDefinition fromEntityDefinition = efi.getEntityDefinition(fi.memberEntityNode.attribute(\"entity-name\"));\n                        if (!fromEntityDefinition.isViewEntity) doGroupBy = true;\n                    }\n                }\n                if (doGroupBy) {\n                    if (gbClause.length() > 0) gbClause.append(\", \");\n                    gbClause.append(fi.getFullColumnName());\n                }\n            }\n        }\n\n        if (gbClause.length() > 0) {\n            sqlTopLevel.append(\" GROUP BY \");\n            sqlTopLevel.append(gbClause.toString());\n        }\n    }\n\n    public void makeHavingClause(EntityConditionImplBase condition) {\n        if (condition == null) return;\n        sqlTopLevel.append(\" HAVING \");\n        condition.makeSqlWhere(this, null);\n    }\n\n    public void makeOrderByClause(ArrayList<String> orderByFieldList, boolean hasLimitOffset) {\n        int obflSize = orderByFieldList.size();\n        if (obflSize == 0) {\n            if (hasLimitOffset) sqlTopLevel.append(\" ORDER BY 1\");\n            return;\n        }\n\n        MNode databaseNode = efi.getDatabaseNode(mainEntityDefinition.getEntityGroupName());\n        sqlTopLevel.append(\" ORDER BY \");\n        for (int i = 0; i < obflSize; i++) {\n            String fieldName = orderByFieldList.get(i);\n            if (fieldName == null || fieldName.length() == 0) continue;\n            if (i > 0) sqlTopLevel.append(\", \");\n\n            // Parse the fieldName (can have other stuff in it, need to tear down to just the field name)\n            EntityJavaUtil.FieldOrderOptions foo = new EntityJavaUtil.FieldOrderOptions(fieldName);\n            fieldName = foo.getFieldName();\n\n            FieldInfo fieldInfo = getMainEd().getFieldInfo(fieldName);\n            if (fieldInfo == null) throw new EntityException(\"Making ORDER BY clause, could not find field \" +\n                    fieldName + \" in entity \" + getMainEd().fullEntityName);\n            int typeValue = fieldInfo.typeValue;\n\n            // now that it's all torn down, build it back up using the column name\n            if (foo.getCaseUpperLower() != null && typeValue == 1) sqlTopLevel.append(foo.getCaseUpperLower() ? \"UPPER(\" : \"LOWER(\");\n            sqlTopLevel.append(fieldInfo.getFullColumnName());\n            if (foo.getCaseUpperLower() != null && typeValue == 1) sqlTopLevel.append(\")\");\n            sqlTopLevel.append(foo.getDescending() ? \" DESC\" : \" ASC\");\n            if (!\"true\".equals(databaseNode.attribute(\"never-nulls\"))) {\n                if (foo.getNullsFirstLast() != null) sqlTopLevel.append(foo.getNullsFirstLast() ? \" NULLS FIRST\" : \" NULLS LAST\");\n                else sqlTopLevel.append(\" NULLS LAST\");\n            }\n        }\n    }\n    public void addLimitOffset(Integer limit, Integer offset) {\n        if (limit == null && offset == null) return;\n\n        MNode databaseNode = efi.getDatabaseNode(mainEntityDefinition.getEntityGroupName());\n        // if no databaseNode do nothing, means it is not a standard SQL/JDBC database\n        if (databaseNode != null) {\n            String offsetStyle = databaseNode.attribute(\"offset-style\");\n            if (\"limit\".equals(offsetStyle)) {\n                // use the LIMIT/OFFSET style\n                sqlTopLevel.append(\" LIMIT \").append(limit != null && limit > 0 ? limit : \"ALL\");\n                sqlTopLevel.append(\" OFFSET \").append(offset != null ? offset : 0);\n            } else if (offsetStyle == null || offsetStyle.length() == 0 || \"fetch\".equals(offsetStyle)) {\n                // use SQL2008 OFFSET/FETCH style by default\n                sqlTopLevel.append(\" OFFSET \").append(offset != null ? offset.toString() : '0').append(\" ROWS\");\n                if (limit != null) sqlTopLevel.append(\" FETCH FIRST \").append(limit).append(\" ROWS ONLY\");\n            }\n            // do nothing here for offset-style=cursor, taken care of in EntityFindImpl\n        }\n    }\n\n    /** Adds FOR UPDATE, should be added to end of query */\n    public void makeForUpdate() {\n        MNode databaseNode = efi.getDatabaseNode(mainEntityDefinition.getEntityGroupName());\n        String forUpdateStr = databaseNode.attribute(\"for-update\");\n        if (forUpdateStr != null && forUpdateStr.length() > 0) {\n            sqlTopLevel.append(\" \").append(forUpdateStr);\n        } else {\n            sqlTopLevel.append(\" FOR UPDATE\");\n        }\n    }\n\n    @Override\n    public PreparedStatement makePreparedStatement() {\n        if (connection == null) throw new IllegalStateException(\"Cannot make PreparedStatement, no Connection in place\");\n        finalSql = sqlTopLevel.toString();\n        // if (this.mainEntityDefinition.getEntityName().contains(\"FooBar\")) logger.warn(\"========= making find PreparedStatement for SQL: \" + finalSql + \"; parameters: \" + parameters);\n        if (isDebugEnabled) logger.debug(\"making find PreparedStatement for SQL: \" + finalSql);\n        try {\n            ps = connection.prepareStatement(finalSql, entityFindBase.getResultSetType(), entityFindBase.getResultSetConcurrency());\n            Integer maxRows = entityFindBase.getMaxRows();\n            Integer fetchSize = entityFindBase.getFetchSize();\n            if (maxRows != null && maxRows > 0) ps.setMaxRows(maxRows);\n            // NOTE: always set a fetch size, without explicit fetch size some JDBC drivers (like MySQL Connector/J) will try to fetch all rows\n            // NOTE: the default here of 1000 is a balance between memory use and network overhead, 100 rows generally being easy to accommodate\n            if (fetchSize != null && fetchSize > 0) { ps.setFetchSize(fetchSize); } else { ps.setFetchSize(100); }\n        } catch (SQLException e) {\n            EntityQueryBuilder.handleSqlException(e, finalSql);\n        }\n\n        return ps;\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityFindImpl.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity;\n\nimport org.moqui.entity.EntityDynamicView;\nimport org.moqui.entity.EntityListIterator;\nimport org.moqui.impl.entity.condition.EntityConditionImplBase;\nimport org.moqui.impl.entity.EntityJavaUtil.FieldOrderOptions;\nimport org.moqui.util.LiteStringMap;\nimport org.moqui.util.MNode;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.sql.Connection;\nimport java.sql.ResultSet;\nimport java.sql.SQLException;\nimport java.util.ArrayList;\nimport java.util.Map;\n\npublic class EntityFindImpl extends EntityFindBase {\n    protected static final Logger logger = LoggerFactory.getLogger(EntityFindImpl.class);\n    protected static final boolean isTraceEnabled = logger.isTraceEnabled();\n\n    public EntityFindImpl(EntityFacadeImpl efi, String entityName) { super(efi, entityName); }\n    public EntityFindImpl(EntityFacadeImpl efi, EntityDefinition ed) { super(efi, ed); }\n\n    @Override\n    public EntityDynamicView makeEntityDynamicView() {\n        if (this.dynamicView != null) return this.dynamicView;\n        this.entityDef = null;\n        this.dynamicView = new EntityDynamicViewImpl(this);\n        return this.dynamicView;\n    }\n\n    @Override\n    public EntityValueBase oneExtended(EntityConditionImplBase whereCondition, FieldInfo[] fieldInfoArray,\n                                       FieldOrderOptions[] fieldOptionsArray) throws SQLException {\n        EntityDefinition ed = getEntityDef();\n\n        // table doesn't exist, just return null\n        if (!ed.tableExistsDbMetaOnly()) return null;\n\n        EntityFindBuilder efb = new EntityFindBuilder(ed, this, whereCondition, fieldInfoArray);\n        // flag as a find one, small changes to internal behavior to reduce overhead\n        efb.isFindOne();\n\n        // SELECT fields\n        efb.makeSqlSelectFields(fieldInfoArray, fieldOptionsArray, \"true\".equals(efi.getDatabaseNode(ed.groupName).attribute(\"add-unique-as\")));\n        // FROM Clause\n        efb.makeSqlFromClause();\n        // WHERE clause only for one/pk query\n        efb.makeWhereClause();\n        // GROUP BY clause\n        efb.makeGroupByClause();\n        // NOTE 20200707 don't do this, databases such as Oracle (error ORA-02014) do not allow use of limit/offset with for update: LIMIT/OFFSET clause - for find one always limit to 1: efb.addLimitOffset(1, 0);\n        // FOR UPDATE\n        if (getForUpdate()) efb.makeForUpdate();\n\n        // run the SQL now that it is built\n        EntityValueBase newEntityValue = null;\n        try {\n            // don't check create, above tableExists check is done:\n            // efi.getEntityDbMeta().checkTableRuntime(ed)\n            // if this is a view-entity and any table in it exists check/create all or will fail with optional members, etc\n            if (ed.isViewEntity) efi.getEntityDbMeta().checkTableRuntime(ed);\n\n            efb.makeConnection(useClone);\n            efb.makePreparedStatement();\n            efb.setPreparedStatementValues();\n\n            final String condSql = isTraceEnabled && whereCondition != null ? whereCondition.toString() : null;\n            ResultSet rs = efb.executeQuery();\n            if (rs.next()) {\n                newEntityValue = new EntityValueImpl(ed, efi);\n                LiteStringMap<Object> valueMap = newEntityValue.valueMapInternal;\n                int size = fieldInfoArray.length;\n                for (int i = 0; i < size; i++) {\n                    FieldInfo fi = fieldInfoArray[i];\n                    if (fi == null) break;\n                    fi.getResultSetValue(rs, i + 1, valueMap, efi);\n                }\n            } else {\n                if (isTraceEnabled) logger.trace(\"Result set was empty for find on entity \" + entityName + \" with condition \" + condSql);\n            }\n\n            if (isTraceEnabled && rs.next()) logger.trace(\"Found more than one result for condition \" + condSql + \" on entity \" + entityName);\n            queryTextList.add(efb.finalSql);\n        } finally {\n            try { efb.closeAll(); }\n            catch (SQLException sqle) { logger.error(\"Error closing query\", sqle); }\n        }\n\n        return newEntityValue;\n    }\n\n    @Override\n    public EntityListIterator iteratorExtended(EntityConditionImplBase whereCondition, EntityConditionImplBase havingCondition,\n                                               ArrayList<String> orderByExpanded, FieldInfo[] fieldInfoArray,\n                                               FieldOrderOptions[] fieldOptionsArray) throws SQLException {\n        EntityDefinition ed = this.getEntityDef();\n\n        // table doesn't exist, just return empty ELI\n        if (!ed.tableExistsDbMetaOnly()) return new EntityListIteratorWrapper(new ArrayList<>(), ed, efi, null, null);\n\n        EntityFindBuilder efb = new EntityFindBuilder(ed, this, whereCondition, fieldInfoArray);\n        if (getDistinct()) efb.makeDistinct();\n\n        // select fields\n        efb.makeSqlSelectFields(fieldInfoArray, fieldOptionsArray, \"true\".equals(efi.getDatabaseNode(ed.groupName).attribute(\"add-unique-as\")));\n        // FROM Clause\n        efb.makeSqlFromClause();\n        // WHERE clause\n        efb.makeWhereClause();\n        // GROUP BY clause\n        efb.makeGroupByClause();\n        // HAVING clause\n        efb.makeHavingClause(havingCondition);\n\n        boolean hasLimitOffset = limit != null || offset != null;\n        // ORDER BY clause\n        efb.makeOrderByClause(orderByExpanded, hasLimitOffset);\n        // LIMIT/OFFSET clause\n        if (hasLimitOffset) efb.addLimitOffset(limit, offset);\n        // FOR UPDATE\n        if (getForUpdate()) efb.makeForUpdate();\n\n        // run the SQL now that it is built\n        EntityListIteratorImpl elii;\n        try {\n            // don't check create, above tableExists check is done:\n            // efi.getEntityDbMeta().checkTableRuntime(ed)\n            // if this is a view-entity and any table in it exists check/create all or will fail with optional members, etc\n            if (ed.isViewEntity) efi.getEntityDbMeta().checkTableRuntime(ed);\n\n            Connection con = efb.makeConnection(useClone);\n            efb.makePreparedStatement();\n            efb.setPreparedStatementValues();\n\n            ResultSet rs = efb.executeQuery();\n            elii = new EntityListIteratorImpl(con, rs, ed, fieldInfoArray, efi, txCache, whereCondition, orderByExpanded);\n            // ResultSet will be closed in the EntityListIterator\n            efb.releaseAll();\n            queryTextList.add(efb.finalSql);\n        } catch (Throwable t) {\n            // close the ResultSet/etc on error as there won't be an ELI\n            try { efb.closeAll(); }\n            catch (SQLException sqle) { logger.error(\"Error closing query\", sqle); }\n            throw t;\n        }\n        // no finally block to close ResultSet, etc because contained in EntityListIterator and closed with it\n\n        return elii;\n    }\n\n    @Override\n    public long countExtended(EntityConditionImplBase whereCondition, EntityConditionImplBase havingCondition,\n                              FieldInfo[] fieldInfoArray, FieldOrderOptions[] fieldOptionsArray) throws SQLException {\n        EntityDefinition ed = getEntityDef();\n\n        // table doesn't exist, just return 0\n        if (!ed.tableExistsDbMetaOnly()) return 0;\n\n        EntityFindBuilder efb = new EntityFindBuilder(ed, this, whereCondition, fieldInfoArray);\n\n        ArrayList<MNode> entityConditionList = ed.internalEntityNode.children(\"entity-condition\");\n        MNode condNode = entityConditionList != null && entityConditionList.size() > 0 ? entityConditionList.get(0) : null;\n        boolean isDistinct = getDistinct() || (ed.isViewEntity && condNode != null && \"true\".equals(condNode.attribute(\"distinct\")));\n        boolean isGroupBy = ed.entityInfo.hasFunctionAlias;\n\n        // count function instead of select fields\n        efb.makeCountFunction(fieldOptionsArray, isDistinct, isGroupBy);\n        // FROM Clause\n        efb.makeSqlFromClause();\n        // WHERE clause\n        efb.makeWhereClause();\n        // GROUP BY clause\n        efb.makeGroupByClause();\n        // HAVING clause\n        efb.makeHavingClause(havingCondition);\n\n        efb.closeCountSubSelect(fieldInfoArray.length, isDistinct, isGroupBy);\n\n        // run the SQL now that it is built\n        long count = 0;\n        try {\n            // don't check create, above tableExists check is done:\n            // efi.getEntityDbMeta().checkTableRuntime(ed)\n            // if this is a view-entity and any table in it exists check/create all or will fail with optional members, etc\n            if (ed.isViewEntity) efi.getEntityDbMeta().checkTableRuntime(ed);\n\n            efb.makeConnection(useClone);\n            efb.makePreparedStatement();\n            efb.setPreparedStatementValues();\n\n            ResultSet rs = efb.executeQuery();\n            if (rs.next()) count = rs.getLong(1);\n            queryTextList.add(efb.finalSql);\n        } finally {\n            try { efb.closeAll(); }\n            catch (SQLException sqle) { logger.error(\"Error closing query\", sqle); }\n        }\n\n        return count;\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityJavaUtil.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity;\n\nimport org.moqui.BaseException;\nimport org.moqui.context.ArtifactExecutionInfo;\nimport org.moqui.entity.EntityCondition;\nimport org.moqui.entity.EntityDatasourceFactory;\nimport org.moqui.entity.EntityException;\nimport org.moqui.entity.EntityNotFoundException;\nimport org.moqui.impl.context.ExecutionContextImpl;\nimport org.moqui.util.MNode;\n\nimport org.moqui.util.ObjectUtilities;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport javax.crypto.Cipher;\nimport javax.crypto.SecretKey;\nimport javax.crypto.SecretKeyFactory;\nimport javax.crypto.spec.IvParameterSpec;\nimport javax.crypto.spec.PBEKeySpec;\nimport javax.crypto.spec.PBEParameterSpec;\nimport jakarta.xml.bind.DatatypeConverter;\n\nimport java.math.BigDecimal;\nimport java.security.SecureRandom;\nimport java.util.*;\n\npublic class EntityJavaUtil {\n    protected final static Logger logger = LoggerFactory.getLogger(EntityJavaUtil.class);\n    protected final static boolean isTraceEnabled = logger.isTraceEnabled();\n\n    private static final int saltBytes = 8;\n    static String enDeCrypt(String value, boolean encrypt, EntityFacadeImpl efi) {\n        MNode entityFacadeNode = efi.ecfi.getConfXmlRoot().first(\"entity-facade\");\n        if (encrypt) {\n            return enDeCrypt(value, true, entityFacadeNode);\n        } else {\n            // decrypt a bit different, use entity-facade as config node and then decrypt-alt until success, or fail with original error\n            try {\n                return enDeCrypt(value, false, entityFacadeNode);\n            } catch (Exception e) {\n                ArrayList<MNode> decryptAltNodes = entityFacadeNode.children(\"decrypt-alt\");\n                for (int i = 0; i < decryptAltNodes.size(); i++) {\n                    MNode decryptAltNode = decryptAltNodes.get(i);\n                    decryptAltNode.setSystemExpandAttributes(true);\n                    try {\n                        return enDeCrypt(value, false, decryptAltNode);\n                    } catch (Exception inner) {\n                        // do nothing, ignore exception\n                        logger.warn(\"Error in decrypt-alt \" + i);\n                    }\n                }\n                // if we got here no luck, throw original exception\n                throw e;\n            }\n        }\n    }\n\n    static final String CONSTANT_IV = \"WeNeedAtLeast32CharactersFor256BitBlockSizeToHaveAConstantIVForQueryByEncryptedValue\";\n    static String enDeCrypt(String value, boolean encrypt, MNode configNode) {\n        String pwStr = configNode.attribute(\"crypt-pass\");\n        if (pwStr == null || pwStr.length() == 0)\n            throw new EntityException(\"No entity-facade.@crypt-pass setting found, NOT doing encryption\");\n\n        String saltStr = configNode.attribute(\"crypt-salt\");\n        byte[] salt = (saltStr != null && saltStr.length() > 0 ? saltStr : \"default1\").getBytes();\n        if (salt.length > saltBytes) {\n            byte[] trimmed = new byte[saltBytes];\n            System.arraycopy(salt, 0, trimmed, 0, saltBytes);\n            salt = trimmed;\n        }\n        if (salt.length < saltBytes) {\n            byte[] newSalt = new byte[saltBytes];\n            for (int i = 0; i < saltBytes; i++) {\n                if (i < salt.length) newSalt[i] = salt[i];\n                else newSalt[i] = 0x45;\n            }\n            salt = newSalt;\n        }\n\n        String iterStr = configNode.attribute(\"crypt-iter\");\n        int count = iterStr != null && iterStr.length() > 0 ? Integer.valueOf(iterStr) : 10;\n        char[] pass = pwStr.toCharArray();\n\n        String algo = configNode.attribute(\"crypt-algo\");\n        if (algo == null || algo.length() == 0) algo = \"PBEWithHmacSHA256AndAES_128\";\n\n        // logger.info(\"TOREMOVE salt [\" + salt + \"] count [\" + count + \"] pass [${pass}] algo [\" + algo + \"][\" + configNode.attribute(\"crypt-algo\") + \"]\");\n\n        try {\n            Cipher pbeCipher = Cipher.getInstance(algo);\n\n            byte[] inBytes;\n            byte[] initVectorBytes = CONSTANT_IV.substring(0, pbeCipher.getBlockSize()).getBytes();\n            byte[] defaultInitVectorBytes = initVectorBytes;\n            if (encrypt) {\n                inBytes = value.getBytes();\n                /* more secure for larger multi-block values, but makes find by encrypted value impossible, maybe optionally enable with another field.@encrypt attribute if ever needed\n                initVectorBytes = new byte[pbeCipher.getBlockSize()];\n                new SecureRandom().nextBytes(initVectorBytes);\n                 */\n            } else {\n                // if contains ':' is the new format: split IV and value then decode using Base64, otherwise decode value as hex\n                // NOTE: URL Base64 is letters, digits, '-', '_'\n                int colonIdx = value.indexOf(\":\");\n                if (colonIdx >= 0) {\n                    // base64 decode each part as ${IV}:${encrypted}\n                    if (colonIdx > 0) initVectorBytes = Base64.getUrlDecoder().decode(value.substring(0, colonIdx));\n                    inBytes = Base64.getUrlDecoder().decode(value.substring(colonIdx + 1));\n                } else {\n                    inBytes = DatatypeConverter.parseHexBinary(value);\n                }\n            }\n\n            PBEParameterSpec pbeParamSpec = initVectorBytes == null ? new PBEParameterSpec(salt, count) :\n                    new PBEParameterSpec(salt, count, new IvParameterSpec(initVectorBytes));\n            PBEKeySpec pbeKeySpec = new PBEKeySpec(pass);\n\n            SecretKeyFactory keyFac = SecretKeyFactory.getInstance(algo);\n            SecretKey pbeKey = keyFac.generateSecret(pbeKeySpec);\n\n            pbeCipher.init(encrypt ? Cipher.ENCRYPT_MODE : Cipher.DECRYPT_MODE, pbeKey, pbeParamSpec);\n\n            byte[] outBytes = pbeCipher.doFinal(inBytes);\n            // change to Base64 encode always (2/3 size with 6 bits/char base64 vs 4 bits/char hex), always include IV + ':' + encrypted value\n            if (encrypt) {\n                // old hex approach, now supported for decrypt only: return DatatypeConverter.printHexBinary(outBytes);\n                if (defaultInitVectorBytes == initVectorBytes) {\n                    return \":\" + Base64.getUrlEncoder().encodeToString(outBytes);\n                } else {\n                    return Base64.getUrlEncoder().encodeToString(initVectorBytes) + ':' + Base64.getUrlEncoder().encodeToString(outBytes);\n                }\n            } else {\n                return new String(outBytes);\n            }\n        } catch (Exception e) {\n            // logger.warn(\"crypt-pass \" + pwStr + \" salt \" + saltStr + \" algo \" + algo + \" count \" + count);\n            throw new EntityException(\"Encryption error with algo \" + algo, e);\n        }\n    }\n\n    @SuppressWarnings(\"unused\")\n    public static FieldOrderOptions makeFieldOrderOptions(String orderByName) { return new FieldOrderOptions(orderByName); }\n    public static class FieldOrderOptions {\n        final static char spaceChar = ' ';\n        final static char minusChar = '-';\n        final static char plusChar = '+';\n        final static char caretChar = '^';\n        final static char openParenChar = '(';\n        final static char closeParenChar = ')';\n\n        String fieldName = null;\n        Boolean nullsFirstLast = null;\n        boolean descending = false;\n        Boolean caseUpperLower = null;\n\n        public String getFieldName() { return fieldName; }\n        Boolean getNullsFirstLast() { return nullsFirstLast; }\n        public boolean getDescending() { return descending; }\n        Boolean getCaseUpperLower() { return caseUpperLower; }\n\n        public FieldOrderOptions(String orderByName) {\n            StringBuilder fnSb = new StringBuilder(40);\n            // simple first parse pass, single run through and as fast as possible\n            boolean containsSpace = false;\n            boolean foundNonSpace = false;\n            boolean containsOpenParen = false;\n            int obnLength = orderByName.length();\n            char[] obnCharArray = orderByName.toCharArray();\n            for (int i = 0; i < obnLength; i++) {\n                char curChar = obnCharArray[i];\n                if (curChar == spaceChar) {\n                    if (foundNonSpace) {\n                        containsSpace = true;\n                        fnSb.append(curChar);\n                    }\n                    // otherwise ignore the space\n                } else {\n                    // leading characters (-,+,^), don't consider them non-spaces so we'll remove spaces after\n                    if (curChar == minusChar) {\n                        descending = true;\n                    } else if (curChar == plusChar) {\n                        descending = false;\n                    } else if (curChar == caretChar) {\n                        caseUpperLower = true;\n                    } else {\n                        foundNonSpace = true;\n                        fnSb.append(curChar);\n                        if (curChar == openParenChar) containsOpenParen = true;\n                    }\n                }\n            }\n\n            if (fnSb.length() == 0) return;\n\n            if (containsSpace) {\n                // trim ending spaces\n                while (fnSb.charAt(fnSb.length() - 1) == spaceChar) fnSb.delete(fnSb.length() - 1, fnSb.length());\n\n                String orderByUpper = fnSb.toString().toUpperCase();\n                int fnSbLength = fnSb.length();\n                if (orderByUpper.endsWith(\" NULLS FIRST\")) {\n                    nullsFirstLast = true;\n                    fnSb.delete(fnSbLength - 12, fnSbLength);\n                    // remove from orderByUpper as we'll use it below\n                    orderByUpper = orderByUpper.substring(0, orderByName.length() - 12);\n                } else if (orderByUpper.endsWith(\" NULLS LAST\")) {\n                    nullsFirstLast = false;\n                    fnSb.delete(fnSbLength - 11, fnSbLength);\n                    // remove from orderByUpper as we'll use it below\n                    orderByUpper = orderByUpper.substring(0, orderByName.length() - 11);\n                }\n\n                fnSbLength = fnSb.length();\n                if (orderByUpper.endsWith(\" DESC\")) {\n                    descending = true;\n                    fnSb.delete(fnSbLength - 5, fnSbLength);\n                } else if (orderByUpper.endsWith(\" ASC\")) {\n                    descending = false;\n                    fnSb.delete(fnSbLength - 4, fnSbLength);\n                }\n            }\n            if (containsOpenParen) {\n                String upperText = fnSb.toString().toUpperCase();\n                if (upperText.startsWith(\"UPPER(\")) {\n                    caseUpperLower = true;\n                    fnSb.delete(0, 6);\n                } else if (upperText.startsWith(\"LOWER(\")) {\n                    caseUpperLower = false;\n                    fnSb.delete(0, 6);\n                }\n                int fnSbLength = fnSb.length();\n                if (fnSb.charAt(fnSbLength - 1) == closeParenChar) fnSb.delete(fnSbLength - 1, fnSbLength);\n            }\n\n            fieldName = fnSb.toString();\n        }\n    }\n\n    public static class EntityInfo {\n        private final EntityDefinition ed;\n        private final EntityFacadeImpl efi;\n        public final String internalEntityName, fullEntityName, shortAlias, groupName;\n        public final String tableName, tableNameLowerCase, schemaName, fullTableName;\n\n        public final EntityDatasourceFactory datasourceFactory;\n        public final boolean isEntityDatasourceFactoryImpl;\n        public final boolean isView, isDynamicView, isInvalidViewEntity;\n        final boolean hasFunctionAlias;\n        public final boolean createOnly, createOnlyFields;\n        final boolean optimisticLock, needsAuditLog, needsEncrypt;\n        public final String useCache;\n        public final boolean neverCache;\n        final String sequencePrimaryPrefix;\n        public final long sequencePrimaryStagger, sequenceBankSize;\n        public final boolean sequencePrimaryUseUuid;\n\n        final boolean hasFieldDefaults;\n        final String authorizeSkipStr;\n        final boolean authorizeSkipTrue;\n        final boolean authorizeSkipCreate;\n        public final boolean authorizeSkipView;\n\n        public final FieldInfo[] pkFieldInfoArray, nonPkFieldInfoArray, allFieldInfoArray;\n        final FieldInfo lastUpdatedStampInfo;\n        public final String allFieldsSqlSelect;\n        final Map<String, String> pkFieldDefaults, nonPkFieldDefaults;\n\n\n        EntityInfo(EntityDefinition ed, boolean memberNeverCache) {\n            this.ed = ed;\n            this.efi = ed.efi;\n            MNode internalEntityNode = ed.internalEntityNode;\n            EntityFacadeImpl efi = ed.efi;\n            ArrayList<FieldInfo> allFieldInfoList = ed.allFieldInfoList;\n\n            internalEntityName = internalEntityNode.attribute(\"entity-name\");\n            String packageName = internalEntityNode.attribute(\"package\");\n            if (packageName == null || packageName.isEmpty()) packageName = internalEntityNode.attribute(\"package-name\");\n            fullEntityName = packageName + \".\" + internalEntityName;\n            String shortAliasAttr = internalEntityNode.attribute(\"short-alias\");\n            shortAlias = shortAliasAttr != null && !shortAliasAttr.isEmpty() ? shortAliasAttr : null;\n\n            isView = ed.isViewEntity;\n            isDynamicView = ed.isDynamicView;\n            createOnly = \"true\".equals(internalEntityNode.attribute(\"create-only\"));\n            isInvalidViewEntity = isView && (!internalEntityNode.hasChild(\"member-entity\") || !internalEntityNode.hasChild(\"alias\"));\n\n            groupName = ed.groupName;\n            datasourceFactory = efi.getDatasourceFactory(groupName);\n            isEntityDatasourceFactoryImpl = datasourceFactory instanceof EntityDatasourceFactoryImpl;\n            MNode datasourceNode = efi.getDatasourceNode(groupName);\n            MNode databaseNode = efi.getDatabaseNode(groupName);\n\n            String tableNameAttr = internalEntityNode.attribute(\"table-name\");\n            if (tableNameAttr == null || tableNameAttr.isEmpty()) tableNameAttr = EntityJavaUtil.camelCaseToUnderscored(internalEntityName);\n            tableName = tableNameAttr;\n            tableNameLowerCase = tableName.toLowerCase();\n            String schemaNameAttr = datasourceNode != null ? datasourceNode.attribute(\"schema-name\") : null;\n            if (schemaNameAttr != null && schemaNameAttr.length() == 0) schemaNameAttr = null;\n            schemaName = schemaNameAttr;\n            if (databaseNode == null || !\"false\".equals(databaseNode.attribute(\"use-schemas\"))) {\n                fullTableName = schemaName != null ? schemaName + \".\" + tableNameAttr : tableNameAttr;\n            } else {\n                fullTableName = tableNameAttr;\n            }\n\n            String sppAttr = internalEntityNode.attribute(\"sequence-primary-prefix\");\n            if (sppAttr == null) sppAttr = \"\";\n            sequencePrimaryPrefix = sppAttr;\n\n            String spsAttr = internalEntityNode.attribute(\"sequence-primary-stagger\");\n            if (spsAttr != null && !spsAttr.isEmpty()) sequencePrimaryStagger = Long.parseLong(spsAttr);\n            else sequencePrimaryStagger = 1;\n\n            String sbsAttr = internalEntityNode.attribute(\"sequence-bank-size\");\n            if (sbsAttr != null && !sbsAttr.isEmpty()) sequenceBankSize = Long.parseLong(sbsAttr);\n            else sequenceBankSize = EntityFacadeImpl.defaultBankSize;\n\n            sequencePrimaryUseUuid = \"true\".equals(internalEntityNode.attribute(\"sequence-primary-use-uuid\")) ||\n                    (datasourceNode != null && \"true\".equals(datasourceNode.attribute(\"sequence-primary-use-uuid\")));\n\n            optimisticLock = \"true\".equals(internalEntityNode.attribute(\"optimistic-lock\"));\n\n            authorizeSkipStr = internalEntityNode.attribute(\"authorize-skip\");\n            authorizeSkipTrue = \"true\".equals(authorizeSkipStr);\n            authorizeSkipCreate = authorizeSkipTrue || (authorizeSkipStr != null && authorizeSkipStr.contains(\"create\"));\n            authorizeSkipView = authorizeSkipTrue || (authorizeSkipStr != null && authorizeSkipStr.contains(\"view\"));\n\n            // NOTE: see code in initFields that may set this to never if any member-entity is set to cache=never\n            if (memberNeverCache) {\n                useCache = \"never\";\n                neverCache = true;\n            } else {\n                String cacheAttr = internalEntityNode.attribute(\"cache\");\n                if (cacheAttr == null || cacheAttr.isEmpty()) cacheAttr = \"false\";\n                useCache = cacheAttr;\n                neverCache = \"never\".equals(useCache);\n            }\n\n            // init the FieldInfo arrays and see if we have create only fields, etc\n            int allFieldInfoSize = allFieldInfoList.size();\n            ArrayList<FieldInfo> pkFieldInfoList = new ArrayList<>();\n            ArrayList<FieldInfo> nonPkFieldInfoList = new ArrayList<>();\n            allFieldInfoArray = new FieldInfo[allFieldInfoSize];\n            boolean createOnlyFieldsTemp = false;\n            boolean needsAuditLogTemp = false;\n            boolean needsEncryptTemp = false;\n            boolean hasFunctionAliasTemp = false;\n            Map<String, String> pkFieldDefaultsTemp = new HashMap<>();\n            Map<String, String> nonPkFieldDefaultsTemp = new HashMap<>();\n            FieldInfo lastUpdatedTemp = null;\n            for (int i = 0; i < allFieldInfoSize; i++) {\n                FieldInfo fi = allFieldInfoList.get(i);\n                allFieldInfoArray[i] = fi;\n                if (fi.isPk) pkFieldInfoList.add(fi); else nonPkFieldInfoList.add(fi);\n                if (fi.createOnly) createOnlyFieldsTemp = true;\n                if (\"true\".equals(fi.enableAuditLog) || \"update\".equals(fi.enableAuditLog)) needsAuditLogTemp = true;\n                if (\"true\".equals(fi.fieldNode.attribute(\"encrypt\"))) needsEncryptTemp = true;\n                if (isView && fi.hasAggregateFunction) {\n                    MNode memberEntity = fi.memberEntityNode;\n                    if (memberEntity == null) {\n                        hasFunctionAliasTemp = true;\n                    } else {\n                        String subSelectAttr = memberEntity.attribute(\"sub-select\");\n                        if (subSelectAttr == null || subSelectAttr.isEmpty() || \"false\".equals(subSelectAttr))\n                            hasFunctionAliasTemp = true;\n                    }\n                }\n                String defaultStr = fi.fieldNode.attribute(\"default\");\n                if (defaultStr != null && !defaultStr.isEmpty()) {\n                    if (fi.isPk) pkFieldDefaultsTemp.put(fi.name, defaultStr);\n                    else nonPkFieldDefaultsTemp.put(fi.name, defaultStr);\n                }\n                if (\"lastUpdatedStamp\".equals(fi.name)) lastUpdatedTemp = fi;\n            }\n            createOnlyFields = createOnlyFieldsTemp;\n            needsAuditLog = needsAuditLogTemp;\n            needsEncrypt = needsEncryptTemp;\n            hasFunctionAlias = hasFunctionAliasTemp;\n            hasFieldDefaults = pkFieldDefaultsTemp.size() > 0 || nonPkFieldDefaultsTemp.size() > 0;\n            pkFieldDefaults = pkFieldDefaultsTemp.size() > 0 ? pkFieldDefaultsTemp : null;\n            nonPkFieldDefaults = nonPkFieldDefaultsTemp.size() > 0 ? nonPkFieldDefaultsTemp : null;\n            lastUpdatedStampInfo = lastUpdatedTemp;\n\n            pkFieldInfoArray = new FieldInfo[pkFieldInfoList.size()];\n            pkFieldInfoList.toArray(pkFieldInfoArray);\n            nonPkFieldInfoArray = new FieldInfo[nonPkFieldInfoList.size()];\n            nonPkFieldInfoList.toArray(nonPkFieldInfoArray);\n\n            // init allFieldsSqlSelect\n            if (isView) {\n                allFieldsSqlSelect = null;\n            } else {\n                StringBuilder sb = new StringBuilder();\n                for (int i = 0; i < allFieldInfoList.size(); i++) {\n                    FieldInfo fi = allFieldInfoList.get(i);\n                    if (i > 0) sb.append(\", \");\n                    sb.append(fi.fullColumnNameInternal);\n                }\n                allFieldsSqlSelect = sb.toString();\n            }\n        }\n\n        void setFields(Map<String, Object> src, Map<String, Object> dest, boolean setIfEmpty, String namePrefix, Boolean pks) {\n            if (src == null || dest == null) return;\n\n            ExecutionContextImpl eci = efi.ecfi.getEci();\n            boolean destIsEntityValueBase = dest instanceof EntityValueBase;\n            EntityValueBase destEvb = destIsEntityValueBase ? (EntityValueBase) dest : null;\n\n            boolean hasNamePrefix = namePrefix != null && namePrefix.length() > 0;\n            boolean srcIsEntityValueBase = src instanceof EntityValueBase;\n            EntityValueBase srcEvb = srcIsEntityValueBase ? (EntityValueBase) src : null;\n            FieldInfo[] fieldInfoArray = pks == null ? allFieldInfoArray :\n                    (pks == Boolean.TRUE ? pkFieldInfoArray : nonPkFieldInfoArray);\n            // use integer iterator, saves quite a bit of time, improves time for this method by about 20% with this alone\n            int size = fieldInfoArray.length;\n            for (int i = 0; i < size; i++) {\n                FieldInfo fi = fieldInfoArray[i];\n                String fieldName = fi.name;\n                String srcName;\n                if (hasNamePrefix) {\n                    srcName = namePrefix + Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1);\n                } else {\n                    srcName = fieldName;\n                }\n\n                Object value;\n                boolean srcContains;\n                if (srcIsEntityValueBase) {\n                    value = hasNamePrefix ? srcEvb.valueMapInternal.get(srcName) : srcEvb.valueMapInternal.getByIString(fi.name, fi.index);\n                    srcContains = value != null || (hasNamePrefix ? srcEvb.valueMapInternal.containsKey(srcName) : srcEvb.valueMapInternal.containsKeyIString(fi.name, fi.index));\n                } else {\n                    value = src.get(srcName);\n                    srcContains = value != null || src.containsKey(srcName);\n                }\n                if (srcContains) {\n                    boolean isCharSequence = false;\n                    boolean isEmpty = false;\n                    if (value == null) {\n                        isEmpty = true;\n                    } else if (value instanceof CharSequence) {\n                        isCharSequence = true;\n                        if (((CharSequence) value).length() == 0) isEmpty = true;\n                    }\n\n                    if (!isEmpty) {\n                        if (isCharSequence) {\n                            try {\n                                Object converted = fi.convertFromString(value.toString(), eci.l10nFacade);\n                                if (destIsEntityValueBase) destEvb.putKnownField(fi, converted);\n                                else dest.put(fieldName, converted);\n                            } catch (BaseException be) {\n                                eci.messageFacade.addValidationError(null, fieldName, null, be.getMessage(), be);\n                            }\n                        } else {\n                            if (destIsEntityValueBase) destEvb.putKnownField(fi, value);\n                            else dest.put(fieldName, value);\n                        }\n                    } else if (setIfEmpty) {\n                        // treat empty String as null, otherwise set as whatever null or empty type it is\n                        if (value != null && isCharSequence) {\n                            if (destIsEntityValueBase) destEvb.putKnownField(fi, null);\n                            else dest.put(fieldName, null);\n                        } else {\n                            if (destIsEntityValueBase) destEvb.putKnownField(fi, value);\n                            else dest.put(fieldName, value);\n                        }\n                    }\n                }\n            }\n        }\n\n        void setFieldsEv(Map<String, Object> src, EntityValueBase dest, Boolean pks) {\n            // like above with setIfEmpty=true, namePrefix=null, pks=null\n            if (src == null || dest == null) return;\n\n            ExecutionContextImpl eci = efi.ecfi.getEci();\n            boolean srcIsEntityValueBase = src instanceof EntityValueBase;\n            EntityValueBase srcEvb = srcIsEntityValueBase ? (EntityValueBase) src : null;\n            FieldInfo[] fieldInfoArray = pks == null ? allFieldInfoArray :\n                    (pks == Boolean.TRUE ? pkFieldInfoArray : nonPkFieldInfoArray);\n            // use integer iterator, saves quite a bit of time, improves time for this method by about 20% with this alone\n            int size = fieldInfoArray.length;\n            for (int i = 0; i < size; i++) {\n                FieldInfo fi = fieldInfoArray[i];\n                String fieldName = fi.name;\n\n                Object value;\n                boolean srcContains;\n                if (srcIsEntityValueBase) {\n                    value = srcEvb.valueMapInternal.getByIString(fi.name, fi.index);\n                    srcContains = value != null || srcEvb.valueMapInternal.containsKeyIString(fi.name, fi.index);\n                } else {\n                    value = src.get(fieldName);\n                    srcContains = value != null || src.containsKey(fieldName);\n                }\n                if (srcContains) {\n                    boolean isCharSequence = false;\n                    boolean isEmpty = false;\n                    if (value == null) {\n                        isEmpty = true;\n                    } else if (value instanceof CharSequence) {\n                        isCharSequence = true;\n                        if (((CharSequence) value).length() == 0) isEmpty = true;\n                    }\n\n                    if (!isEmpty) {\n                        if (isCharSequence) {\n                            try {\n                                Object converted = fi.convertFromString(value.toString(), eci.l10nFacade);\n                                dest.putKnownField(fi, converted);\n                            } catch (BaseException be) {\n                                eci.messageFacade.addValidationError(null, fieldName, null, be.getMessage(), be);\n                            }\n                        } else {\n                            dest.putKnownField(fi, value);\n                        }\n                    } else {\n                        // treat empty String as null, otherwise set as whatever null or empty type it is\n                        dest.putKnownField(fi, null);\n                    }\n                }\n            }\n        }\n\n        public Map<String, Object> cloneMapRemoveFields(Map<String, Object> theMap, Boolean pks) {\n            Map<String, Object> newMap = new HashMap<>(theMap);\n            //ArrayList<String> fieldNameList = (pks != null ? this.getFieldNames(pks, !pks, !pks) : this.getAllFieldNames())\n            FieldInfo[] fieldInfoArray = pks == null ? allFieldInfoArray :\n                    (pks == Boolean.TRUE ? pkFieldInfoArray : nonPkFieldInfoArray);\n            int size = fieldInfoArray.length;\n            for (int i = 0; i < size; i++) {\n                FieldInfo fi = fieldInfoArray[i];\n                newMap.remove(fi.name);\n            }\n            return newMap;\n        }\n    }\n\n    public static class RelationshipInfo {\n        public final String type;\n        public final boolean isTypeOne, isFk;\n        public final String title;\n        public final String relatedEntityName;\n        final EntityDefinition fromEd;\n        public final EntityDefinition relatedEd;\n        public final MNode relNode;\n\n        public final String relationshipName;\n        public final String shortAlias;\n        public final String prettyName;\n        public final Map<String, String> keyMap, keyValueMap;\n        public final ArrayList<String> keyFieldList, keyFieldValueList;\n        public final boolean dependent, mutable, isAutoReverse;\n\n        RelationshipInfo(MNode relNode, EntityDefinition fromEd, EntityFacadeImpl efi) {\n            this.relNode = relNode;\n            this.fromEd = fromEd;\n            type = relNode.attribute(\"type\");\n            isTypeOne = type.startsWith(\"one\");\n            isFk = \"one\".equals(type);\n            isAutoReverse = \"true\".equals(relNode.attribute(\"is-auto-reverse\"));\n\n            String titleAttr = relNode.attribute(\"title\");\n            title = titleAttr != null && !titleAttr.isEmpty() ? titleAttr : null;\n            String relatedAttr = relNode.attribute(\"related\");\n            if (relatedAttr == null || relatedAttr.isEmpty()) relatedAttr = relNode.attribute(\"related-entity-name\");\n            relatedEd = efi.getEntityDefinition(relatedAttr);\n            if (relatedEd == null) throw new EntityNotFoundException(\"Invalid entity relationship, \" + relatedAttr + \" not found in definition for entity \" + fromEd.getFullEntityName());\n            relatedEntityName = relatedEd.getFullEntityName();\n\n            relationshipName = (title != null ? title + '#' : \"\") + relatedEntityName;\n            String shortAliasAttr = relNode.attribute(\"short-alias\");\n            shortAlias =  shortAliasAttr != null && !shortAliasAttr.isEmpty() ? shortAliasAttr : null;\n            prettyName = relatedEd.getPrettyName(title, fromEd.entityInfo.internalEntityName);\n            keyMap = EntityDefinition.getRelationshipExpandedKeyMapInternal(relNode, relatedEd);\n            keyFieldList = new ArrayList<>(keyMap.keySet());\n            keyValueMap = EntityDefinition.getRelationshipKeyValueMapInternal(relNode);\n            keyFieldValueList = keyValueMap != null ? new ArrayList<>(keyValueMap.keySet()) : null;\n            dependent = hasReverse();\n            String mutableAttr = relNode.attribute(\"mutable\");\n            if (mutableAttr != null && !mutableAttr.isEmpty()) {\n                mutable = \"true\".equals(relNode.attribute(\"mutable\"));\n            } else {\n                // by default type one not mutable, type many are mutable\n                mutable = !isTypeOne;\n            }\n        }\n\n        // some methods for FTL templates that don't access member fields, just call getters; don't follow getter pattern so groovy code won't pick them up\n        public String riPrettyName() { return prettyName; }\n        public String riRelatedEntityName() { return relatedEntityName; }\n\n        private boolean hasReverse() {\n            ArrayList<MNode> relatedRelList = relatedEd.internalEntityNode.children(\"relationship\");\n            int relatedRelListSize = relatedRelList.size();\n            for (int i = 0; i < relatedRelListSize; i++) {\n                MNode reverseRelNode = relatedRelList.get(i);\n                String relatedAttr = reverseRelNode.attribute(\"related\");\n                if (relatedAttr == null || relatedAttr.isEmpty()) relatedAttr = reverseRelNode.attribute(\"related-entity-name\");\n                String typeAttr = reverseRelNode.attribute(\"type\");\n                // TODO: instead of checking title check reverse expanded key-map\n                String titleAttr = reverseRelNode.attribute(\"title\");\n                if ((fromEd.entityInfo.fullEntityName.equals(relatedAttr) || fromEd.entityInfo.internalEntityName.equals(relatedAttr)) &&\n                        (\"one\".equals(typeAttr) || \"one-nofk\".equals(typeAttr)) &&\n                        (title == null ? titleAttr == null || titleAttr.isEmpty() : title.equals(titleAttr))) {\n                    return true;\n                }\n            }\n            return false;\n        }\n        public RelationshipInfo findReverse() {\n            ArrayList<RelationshipInfo> relInfoList = relatedEd.getRelationshipsInfo(false);\n            int relInfoListSize = relInfoList.size();\n            for (int i = 0; i < relInfoListSize; i++) {\n                EntityJavaUtil.RelationshipInfo relInfo = relInfoList.get(i);\n                // TODO: instead of checking title check reverse expanded key-map\n                if (fromEd.fullEntityName.equals(relInfo.relatedEntityName) &&\n                        ((title == null && relInfo.title == null) || (title != null && title.equals(relInfo.title)))) {\n                    return relInfo;\n                }\n            }\n            return null;\n        }\n        public Map<String, Object> getTargetParameterMap(Map valueSource) {\n            if (valueSource == null || valueSource.isEmpty()) return new LinkedHashMap<>();\n            Map<String, Object> targetParameterMap = new HashMap<>();\n            for (Map.Entry<String, String> keyEntry: keyMap.entrySet()) {\n                Object value = valueSource.get(keyEntry.getKey());\n                if (!ObjectUtilities.isEmpty(value)) targetParameterMap.put(keyEntry.getValue(), value);\n            }\n            if (keyValueMap != null) {\n                for (Map.Entry<String, String> keyValueEntry: keyValueMap.entrySet())\n                    targetParameterMap.put(keyValueEntry.getKey(), keyValueEntry.getValue());\n            }\n            return targetParameterMap;\n        }\n        public String toString() { return relationshipName + (shortAlias != null ? \" (\" + shortAlias + \")\" : \"\") +\n                \", type \" + type + \", one? \" + isTypeOne + \", dependent? \" + dependent; }\n    }\n\n    private static Map<String, String> camelToUnderscoreMap = new HashMap<>();\n    public static String camelCaseToUnderscored(String camelCase) {\n        if (camelCase == null || camelCase.length() == 0) return \"\";\n        String usv = camelToUnderscoreMap.get(camelCase);\n        if (usv != null) return usv;\n\n        StringBuilder underscored = new StringBuilder();\n        underscored.append(Character.toUpperCase(camelCase.charAt(0)));\n        int inPos = 1;\n        while (inPos < camelCase.length()) {\n            char curChar = camelCase.charAt(inPos);\n            if (Character.isUpperCase(curChar)) underscored.append('_');\n            underscored.append(Character.toUpperCase(curChar));\n            inPos++;\n        }\n\n        usv = underscored.toString();\n        camelToUnderscoreMap.put(camelCase, usv);\n        return usv;\n    }\n    public static String underscoredToCamelCase(String underscored, boolean firstUpper) {\n        if (underscored == null || underscored.length() == 0) return \"\";\n\n        StringBuilder camelCased = new StringBuilder();\n        camelCased.append(firstUpper ? Character.toUpperCase(underscored.charAt(0)) : Character.toLowerCase(underscored.charAt(0)));\n        int inPos = 1;\n        boolean lastUnderscore = false;\n        while (inPos < underscored.length()) {\n            char curChar = underscored.charAt(inPos);\n            if (curChar == '_') {\n                lastUnderscore = true;\n            } else {\n                if (lastUnderscore) {\n                    camelCased.append(Character.toUpperCase(curChar));\n                    lastUnderscore = false;\n                } else {\n                    camelCased.append(Character.toLowerCase(curChar));\n                }\n            }\n            inPos++;\n        }\n\n        return camelCased.toString();\n    }\n\n    public static class EntityConditionParameter {\n        protected FieldInfo fieldInfo;\n        protected Object value;\n        protected EntityQueryBuilder eqb;\n\n        public EntityConditionParameter(FieldInfo fieldInfo, Object value, EntityQueryBuilder eqb) {\n            this.fieldInfo = fieldInfo;\n            this.value = value;\n            this.eqb = eqb;\n        }\n\n        public FieldInfo getFieldInfo() { return fieldInfo; }\n\n        public Object getValue() { return value; }\n\n        void setPreparedStatementValue(int index) throws EntityException {\n            eqb.setPreparedStatementValue(index, value, fieldInfo);\n        }\n\n        @Override\n        public String toString() { return fieldInfo.name + ':' + value; }\n    }\n\n    public static class QueryStatsInfo {\n        private String entityName;\n        private String sql;\n        private long hitCount = 0, errorCount = 0;\n        private long minTimeNanos = Long.MAX_VALUE, maxTimeNanos = 0, totalTimeNanos = 0, totalSquaredTime = 0;\n        private Map<String, Integer> artifactCounts = new HashMap<>();\n        public QueryStatsInfo(String entityName, String sql) {\n            this.entityName = entityName;\n            this.sql = sql;\n        }\n        public void countHit(EntityFacadeImpl efi, long runTimeNanos, boolean isError) {\n            hitCount++;\n            if (isError) errorCount++;\n            if (runTimeNanos < minTimeNanos) minTimeNanos = runTimeNanos;\n            if (runTimeNanos > maxTimeNanos) maxTimeNanos = runTimeNanos;\n            totalTimeNanos += runTimeNanos;\n            totalSquaredTime += runTimeNanos * runTimeNanos;\n            // this gets much more expensive, consider commenting in the future\n            ArtifactExecutionInfo aei = efi.ecfi.getEci().artifactExecutionFacade.peek();\n            if (aei != null) aei = aei.getParent();\n            if (aei != null) {\n                String artifactName = aei.getName();\n                Integer artifactCount = artifactCounts.get(artifactName);\n                artifactCounts.put(artifactName, artifactCount != null ? artifactCount + 1 : 1);\n            }\n        }\n        public String getEntityName() { return entityName; }\n        public String getSql() { return sql; }\n        // public long getHitCount() { return hitCount; }\n        // public long getErrorCount() { return errorCount; }\n        // public long getMinTimeNanos() { return minTimeNanos; }\n        // public long getMaxTimeNanos() { return maxTimeNanos; }\n        // public long getTotalTimeNanos() { return totalTimeNanos; }\n        // public long getTotalSquaredTime() { return totalSquaredTime; }\n        double getAverage() { return hitCount > 0 ? totalTimeNanos / hitCount : 0; }\n        double getStdDev() {\n            if (hitCount < 2) return 0;\n            return Math.sqrt(Math.abs(totalSquaredTime - ((totalTimeNanos * totalTimeNanos) / hitCount)) / (hitCount - 1L));\n        }\n        final static long nanosDivisor = 1000;\n        public Map<String, Object> makeDisplayMap() {\n            Map<String, Object> dm = new HashMap<>();\n            dm.put(\"entityName\", entityName); dm.put(\"sql\", sql);\n            dm.put(\"hitCount\", hitCount); dm.put(\"errorCount\", errorCount);\n            dm.put(\"minTime\", new BigDecimal(minTimeNanos/nanosDivisor)); dm.put(\"maxTime\", new BigDecimal(maxTimeNanos/nanosDivisor));\n            dm.put(\"totalTime\", new BigDecimal(totalTimeNanos/nanosDivisor)); dm.put(\"totalSquaredTime\", new BigDecimal(totalSquaredTime/nanosDivisor));\n            dm.put(\"average\", new BigDecimal(getAverage()/nanosDivisor)); dm.put(\"stdDev\", new BigDecimal(getStdDev()/nanosDivisor));\n            dm.put(\"artifactCounts\", new HashMap<>(artifactCounts));\n            return dm;\n        }\n    }\n\n    public enum WriteMode { CREATE, UPDATE, DELETE }\n    public static class EntityWriteInfo {\n        public WriteMode writeMode;\n        public EntityValueBase evb;\n        Map<String, Object> pkMap;\n        public EntityWriteInfo(EntityValueBase evb, WriteMode writeMode) {\n            // clone value so that create/update/delete stays the same no matter what happens after\n            this.evb = (EntityValueBase) evb.cloneValue();\n            this.writeMode = writeMode;\n            this.pkMap = evb.getPrimaryKeys();\n        }\n    }\n    public static class FindAugmentInfo {\n        public final ArrayList<EntityValueBase> valueList;\n        public final int valueListSize;\n        public final Set<Map<String, Object>> foundUpdated;\n        public final EntityCondition econd;\n        public FindAugmentInfo(ArrayList<EntityValueBase> valueList, Set<Map<String, Object>> foundUpdated, EntityCondition econd) {\n            this.valueList = valueList; valueListSize = valueList.size(); this.foundUpdated = foundUpdated; this.econd = econd;\n        }\n    }\n\n    /* added as a possibility for EntityValueBase.checkAgainstDatabaseInfo() but simpler for interfaces, sorting, etc to use a Map:\n    public static class EntityValueDiffInfo {\n        public String entityName, fieldName;\n        public Map<String, Object> pkValues;\n        public Object checkValue, dbValue;\n        public boolean notFound;\n        public EntityValueDiffInfo(String entityName, Map<String, Object> pkValues) {\n            this.entityName = entityName; this.fieldName = null; this.pkValues = pkValues;\n            this.checkValue = null; this.dbValue = null;\n            this.notFound = true;\n        }\n        public EntityValueDiffInfo(String entityName, Map<String, Object> pkValues, String fieldName,\n                                   Object checkValue, Object dbValue) {\n            this.entityName = entityName; this.fieldName = fieldName; this.pkValues = pkValues;\n            this.checkValue = checkValue; this.dbValue = dbValue;\n            this.notFound = false;\n        }\n    }\n    */\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityListImpl.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity;\n\nimport groovy.lang.Closure;\nimport org.moqui.Moqui;\nimport org.moqui.entity.EntityCondition;\nimport org.moqui.entity.EntityException;\nimport org.moqui.entity.EntityList;\nimport org.moqui.entity.EntityValue;\nimport org.moqui.impl.context.ExecutionContextFactoryImpl;\nimport org.moqui.util.CollectionUtilities;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport javax.annotation.Nonnull;\nimport java.io.IOException;\nimport java.io.ObjectInput;\nimport java.io.ObjectOutput;\nimport java.io.Writer;\nimport java.sql.Date;\nimport java.sql.Timestamp;\nimport java.util.*;\n\npublic class EntityListImpl implements EntityList {\n    protected static final Logger logger = LoggerFactory.getLogger(EntityConditionFactoryImpl.class);\n    private transient EntityFacadeImpl efiTransient;\n    private ArrayList<EntityValue> valueList;\n    private boolean fromCache = false;\n    protected Integer offset = null;\n    protected Integer limit = null;\n\n    /** Default constructor for deserialization ONLY. */\n    public EntityListImpl() { }\n\n    public EntityListImpl(EntityFacadeImpl efi) {\n        this.efiTransient = efi;\n        valueList = new ArrayList<>(30);// default size, at least enough for common pagination\n    }\n\n    public EntityListImpl(EntityFacadeImpl efi, int initialCapacity) {\n        this.efiTransient = efi;\n        valueList = new ArrayList<>(initialCapacity);\n    }\n\n    @Override public void writeExternal(ObjectOutput out) throws IOException {\n        out.writeObject(valueList);\n        // don't serialize fromCache, will default back to false which is fine for a copy\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    @Override public void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException {\n        valueList = (ArrayList<EntityValue>) objectInput.readObject();\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    public EntityFacadeImpl getEfi() {\n        if (efiTransient == null)\n            efiTransient = ((ExecutionContextFactoryImpl) Moqui.getExecutionContextFactory()).entityFacade;\n        return efiTransient;\n    }\n\n    @Override public EntityValue getFirst() { return valueList != null && valueList.size() > 0 ? valueList.get(0) : null; }\n\n    @Override public EntityList filterByDate(String fromDateName, String thruDateName, Timestamp moment) {\n        if (fromCache) return this.cloneList().filterByDate(fromDateName, thruDateName, moment);\n\n        // default to now\n        long momentLong = moment != null ? moment.getTime() : System.currentTimeMillis();\n        long momentDateLong = new Date(momentLong).getTime();\n        if (fromDateName == null || fromDateName.length() == 0) fromDateName = \"fromDate\";\n        if (thruDateName == null || thruDateName.length() == 0) thruDateName = \"thruDate\";\n\n        int valueIndex = 0;\n        while (valueIndex < valueList.size()) {\n            EntityValue value = valueList.get(valueIndex);\n            Object fromDateObj = value.get(fromDateName);\n            Object thruDateObj = value.get(thruDateName);\n\n            Long fromDateLong = getDateLong(fromDateObj);\n            Long thruDateLong = getDateLong(thruDateObj);\n\n            if (fromDateObj instanceof Date || thruDateObj instanceof Date) {\n                if (!((thruDateLong == null || thruDateLong >= momentDateLong) && (fromDateLong == null || fromDateLong <= momentDateLong))) {\n                    valueList.remove(valueIndex);\n                } else {\n                    valueIndex++;\n                }\n\n            } else {\n                if (!((thruDateLong == null || thruDateLong >= momentLong) && (fromDateLong == null || fromDateLong <= momentLong))) {\n                    valueList.remove(valueIndex);\n                } else {\n                    valueIndex++;\n                }\n            }\n        }\n\n        return this;\n    }\n\n    private static Long getDateLong(Object dateObj) {\n        if (dateObj instanceof java.util.Date) return ((java.util.Date) dateObj).getTime();\n        else if (dateObj instanceof Long) return (Long) dateObj;\n        else return null;\n    }\n\n    @Override public EntityList filterByDate(String fromDateName, String thruDateName, Timestamp moment, boolean ignoreIfEmpty) {\n        if (ignoreIfEmpty && moment == null) return this;\n        return filterByDate(fromDateName, thruDateName, moment);\n    }\n\n    @Override public EntityList filterByAnd(Map<String, Object> fields) { return filterByAnd(fields, true); }\n    @Override public EntityList filterByAnd(Map<String, Object> fields, Boolean include) {\n        if (fromCache) return this.cloneList().filterByAnd(fields, include);\n\n        // iterate fields once, then use indexes within big loop\n        int fieldsSize = fields.size();\n        String[] names = new String[fieldsSize];\n        Object[] values = new Object[fieldsSize];\n        boolean hasSetValue = false;\n        int fieldIndex = 0;\n        for (Map.Entry<String, Object> entry : fields.entrySet()) {\n            names[fieldIndex] = entry.getKey();\n            Object val = entry.getValue();\n            values[fieldIndex] = val;\n            if (val instanceof Collection) {\n                hasSetValue = true;\n                if (!(val instanceof Set)) values[fieldIndex] = new HashSet<Object>((Collection<? extends Object>) val);\n            }\n            fieldIndex++;\n        }\n\n        filterInternal(names, values, hasSetValue, include);\n        return this;\n    }\n    private void filterInternal(String[] names, Object[] values, boolean hasSetValue, Boolean include) {\n        if (include == null) include = true;\n        int valueIndex = 0;\n        while (valueIndex < valueList.size()) {\n            EntityValue value = valueList.get(valueIndex);\n            boolean matches = valueMatches(value, names, values, hasSetValue);\n            if ((matches && !include) || (!matches && include)) {\n                valueList.remove(valueIndex);\n            } else {\n                valueIndex++;\n            }\n        }\n    }\n    private boolean valueMatches(EntityValue value, String[] names, Object[] values, boolean hasSetValue) {\n        boolean matches = true;\n        int fieldsSize = names.length;\n        for (int i = 0; i < fieldsSize; i++) {\n            Object curValue = value.getNoCheckSimple(names[i]);\n            Object compValue = values[i];\n            if (curValue == null) {\n                matches = compValue == null;\n                if (!matches) { matches = false; break; }\n            } else {\n                if (hasSetValue && compValue instanceof Set) {\n                    Set valSet = (Set) compValue;\n                    if (!valSet.contains(curValue)) { matches = false; break; }\n                } else if (!curValue.equals(compValue)) {\n                    matches = false;\n                    break;\n                }\n            }\n        }\n        return matches;\n    }\n\n    @Override public EntityList filterByAnd(Object... namesAndValues) {\n        if (namesAndValues.length == 0) return this;\n        if (namesAndValues.length % 2 != 0) throw new IllegalArgumentException(\"Must pass an even number of parameters for name/value pairs\");\n\n        if (fromCache) return this.cloneList().filterByAnd(namesAndValues);\n\n        int fieldsSize = namesAndValues.length / 2;\n        String[] names = new String[fieldsSize];\n        Object[] values = new Object[fieldsSize];\n        boolean hasSetValue = false;\n\n        for (int i = 0; i < fieldsSize; i++) {\n            int navIdx = i * 2;\n            names[i] = (String) namesAndValues[navIdx];\n\n            Object value = namesAndValues[navIdx+1];\n            if (value instanceof Collection) {\n                hasSetValue = true;\n                if (!(value instanceof Set)) value = new HashSet<Object>((Collection<? extends Object>) value);\n            }\n            values[i] = value;\n        }\n\n        filterInternal(names, values, hasSetValue, true);\n        return this;\n    }\n\n\n    @Override public EntityList filter(Closure<Boolean> closure, Boolean include) {\n        if (fromCache) return this.cloneList().filter(closure, include);\n        int valueIndex = 0;\n        while (valueIndex < valueList.size()) {\n            EntityValue value = valueList.get(valueIndex);\n            boolean matches = closure.call(value);\n            if ((matches && !include) || (!matches && include)) {\n                valueList.remove(valueIndex);\n            } else {\n                valueIndex++;\n            }\n        }\n        return this;\n    }\n\n    @Override public EntityValue find(Closure<Boolean> closure) {\n        int valueListSize = valueList.size();\n        for (int i = 0; i < valueListSize; i++) {\n            EntityValue value = valueList.get(i);\n            boolean matches = closure.call(value);\n            if (matches) return value;\n        }\n        return null;\n    }\n    @Override public EntityValue findByAnd(Map<String, Object> fields) {\n        // iterate fields once, then use indexes within big loop\n        int fieldsSize = fields.size();\n        String[] names = new String[fieldsSize];\n        Object[] values = new Object[fieldsSize];\n        boolean hasSetValue = false;\n        int fieldIndex = 0;\n        for (Map.Entry<String, Object> entry : fields.entrySet()) {\n            names[fieldIndex] = entry.getKey();\n            Object val = entry.getValue();\n            values[fieldIndex] = val;\n            if (val instanceof Collection) {\n                hasSetValue = true;\n                if (!(val instanceof Set)) values[fieldIndex] = new HashSet<Object>((Collection<? extends Object>) val);\n            }\n            fieldIndex++;\n        }\n\n        int valueListSize = valueList.size();\n        for (int valueIndex = 0; valueIndex < valueListSize; valueIndex++) {\n            EntityValue value = valueList.get(valueIndex);\n            boolean matches = valueMatches(value, names, values, hasSetValue);\n            if (matches) return value;\n        }\n        return null;\n    }\n    @Override public EntityValue findByAnd(Object... namesAndValues) {\n        if (namesAndValues.length == 0) return getFirst();\n        if (namesAndValues.length % 2 != 0) throw new IllegalArgumentException(\"Must pass an even number of parameters for name/value pairs\");\n\n        int fieldsSize = namesAndValues.length / 2;\n        String[] names = new String[fieldsSize];\n        Object[] values = new Object[fieldsSize];\n        boolean hasSetValue = false;\n\n        for (int i = 0; i < fieldsSize; i++) {\n            int navIdx = i * 2;\n            names[i] = (String) namesAndValues[navIdx];\n\n            Object value = namesAndValues[navIdx+1];\n            if (value instanceof Collection) {\n                hasSetValue = true;\n                if (!(value instanceof Set)) value = new HashSet<Object>((Collection<? extends Object>) value);\n            }\n            values[i] = value;\n        }\n\n        int valueListSize = valueList.size();\n        for (int valueIndex = 0; valueIndex < valueListSize; valueIndex++) {\n            EntityValue value = valueList.get(valueIndex);\n            boolean matches = valueMatches(value, names, values, hasSetValue);\n            if (matches) return value;\n        }\n        return null;\n    }\n\n    @Override public EntityList findAll(Closure<Boolean> closure) {\n        EntityListImpl newList = new EntityListImpl(getEfi());\n        int valueListSize = valueList.size();\n        for (int i = 0; i < valueListSize; i++) {\n            EntityValue value = valueList.get(i);\n            boolean matches = closure.call(value);\n            if (matches) newList.add(value);\n        }\n\n        return newList;\n    }\n\n    @Override public EntityList removeByAnd(Map<String, Object> fields) { return filterByAnd(fields, false); }\n\n    @Override public EntityList filterByCondition(EntityCondition condition, Boolean include) {\n        if (fromCache) return this.cloneList().filterByCondition(condition, include);\n        if (include == null) include = true;\n        int valueIndex = 0;\n        while (valueIndex < valueList.size()) {\n            EntityValue value = valueList.get(valueIndex);\n            boolean matches = condition.mapMatches(value);\n            // logger.warn(\"TOREMOVE filter value [${value}] with condition [${condition}] include=${include}, matches=${matches}\")\n            // matched: if include is not true or false (default exclude) remove it\n            // didn't match, if include is true remove it\n            if ((matches && !include) || (!matches && include)) {\n                valueList.remove(valueIndex);\n            } else {\n                valueIndex++;\n            }\n        }\n\n        return this;\n    }\n\n    @Override public EntityList filterByLimit(Integer offset, Integer limit) {\n        if (fromCache) return this.cloneList().filterByLimit(offset, limit);\n        if (offset == null && limit == null) return this;\n        if (offset == null) offset = 0;\n        this.offset = offset;\n        this.limit = limit;\n\n        int vlSize = valueList.size();\n        int toIndex = limit != null ? offset + limit : vlSize;\n        if (toIndex > vlSize) toIndex = vlSize;\n        ArrayList<EntityValue> newList = new ArrayList<>(limit != null && limit > 0 ? limit : (vlSize - offset));\n        for (int i = offset; i < toIndex; i++) newList.add(valueList.get(i));\n        valueList = newList;\n\n        return this;\n    }\n\n    @Override public EntityList filterByLimit(String inputFieldsMapName, boolean alwaysPaginate) {\n        if (fromCache) return this.cloneList().filterByLimit(inputFieldsMapName, alwaysPaginate);\n        Map inf = inputFieldsMapName != null && inputFieldsMapName.length() > 0 ?\n                (Map) getEfi().ecfi.getEci().contextStack.get(inputFieldsMapName) :\n                getEfi().ecfi.getEci().contextStack;\n        if (alwaysPaginate || inf.get(\"pageIndex\") != null) {\n            final Object pageIndexObj = inf.get(\"pageIndex\");\n            int pageIndex;\n            if (pageIndexObj instanceof Number) { pageIndex = ((Number) pageIndexObj).intValue(); }\n            else { pageIndex = Integer.parseInt(pageIndexObj.toString()); }\n\n            final Object pageSizeObj = inf.get(\"pageSize\");\n            int pageSize;\n            if (pageSizeObj != null) {\n                if (pageSizeObj instanceof Number) { pageSize = ((Number) pageSizeObj).intValue(); }\n                else { pageSize = Integer.parseInt(pageSizeObj.toString()); }\n            } else {\n                pageSize = 20;\n            }\n\n            int offset = pageIndex * pageSize;\n            return filterByLimit(offset, pageSize);\n        } else {\n            return this;\n        }\n\n    }\n\n    @Override public Integer getOffset() { return this.offset; }\n    @Override public Integer getLimit() { return this.limit; }\n\n    @Override public int getPageIndex() { return (offset != null ? offset : 0) / getPageSize(); }\n    @Override public int getPageSize() { return limit != null ? limit : 20; }\n\n    @Override public EntityList orderByFields(List<String> fieldNames) {\n        if (fromCache) return this.cloneList().orderByFields(fieldNames);\n        if (fieldNames != null && fieldNames.size() > 0) valueList.sort(new CollectionUtilities.MapOrderByComparator(fieldNames));\n        return this;\n    }\n    @Override public void sort(Comparator<? super EntityValue> comparator) { valueList.sort(comparator); }\n\n    @Override public int indexMatching(Map<String, Object> valueMap) {\n        ListIterator<EntityValue> li = valueList.listIterator();\n        int index = 0;\n        while (li.hasNext()) {\n            EntityValue ev = li.next();\n            if (ev.mapMatches(valueMap)) return index;\n            index++;\n        }\n        return -1;\n    }\n\n    @Override public void move(int fromIndex, int toIndex) {\n        if (fromIndex == toIndex) return;\n\n        EntityValue val = remove(fromIndex);\n        if (toIndex > fromIndex) toIndex--;\n        add(toIndex, val);\n    }\n\n    @Override public EntityList addIfMissing(EntityValue value) {\n        if (fromCache) throw new EntityException(\"Cannot modify EntityList from cache\");\n        if (!valueList.contains(value)) valueList.add(value);\n        return this;\n    }\n    @Override public EntityList addAllIfMissing(EntityList el) {\n        for (EntityValue value : el) addIfMissing(value);\n        return this;\n    }\n\n    @Override public int writeXmlText(Writer writer, String prefix, int dependentLevels) {\n        int recordsWritten = 0;\n        for (EntityValue ev : this) recordsWritten += ev.writeXmlText(writer, prefix, dependentLevels);\n        return recordsWritten;\n    }\n\n    @Override public @Nonnull Iterator<EntityValue> iterator() { return new EntityIterator(); }\n    private class EntityIterator implements Iterator<EntityValue> {\n        int curIndex = -1;\n        boolean valueRemoved = false;\n        @Override public boolean hasNext() { return (curIndex + 1) < valueList.size(); }\n        @Override public EntityValue next() {\n            if ((curIndex + 1) >= valueList.size()) throw new NoSuchElementException(\"Next is beyond end of list (index \" + (curIndex + 1) + \", size \" + valueList.size() + \")\");\n            curIndex++;\n            valueRemoved = false;\n            return valueList.get(curIndex);\n        }\n        @Override public void remove() {\n            if (fromCache) throw new UnsupportedOperationException(\"Cannot modify EntityList from cache\");\n            if (curIndex == -1) throw new IllegalStateException(\"Cannot remove, next() has not been called\");\n            if (valueRemoved) throw new IllegalStateException(\"Cannot remove, next() has not been called since last remove\");\n            valueList.remove(curIndex);\n            curIndex--;\n            valueRemoved = true;\n        }\n    }\n\n    @Override public List<Map<String, Object>> getPlainValueList(int dependentLevels) {\n        List<Map<String, Object>> plainRelList = new ArrayList<>(valueList.size());\n        for (EntityValue ev : valueList) plainRelList.add(ev.getPlainValueMap(dependentLevels));\n        return plainRelList;\n    }\n    @Override public List<Map<String, Object>> getMasterValueList(String name) {\n        List<Map<String, Object>> masterRelList = new ArrayList<>(valueList.size());\n        for (EntityValue ev : valueList) masterRelList.add(ev.getMasterValueMap(name));\n        return masterRelList;\n    }\n    @Override public ArrayList<Map<String, Object>> getValueMapList() {\n        int elSize = valueList.size();\n        ArrayList<Map<String, Object>> al = new ArrayList<>(elSize);\n        for (int i = 0; i < elSize; i++) {\n            EntityValue ev = valueList.get(i);\n            Map<String, Object> evMap = ev.getMap();\n            CollectionUtilities.removeNullsFromMap(evMap);\n            al.add(evMap);\n        }\n        return al;\n    }\n\n    @Override public Object clone() { return this.cloneList(); }\n\n    @Override public EntityList cloneList() {\n        EntityListImpl newObj = new EntityListImpl(this.getEfi(), valueList.size());\n        newObj.valueList.addAll(valueList);\n        // NOTE: when cloning don't clone the fromCache value (normally when from cache will be cloned before filtering)\n        return newObj;\n    }\n    public EntityListImpl deepCloneList() {\n        EntityListImpl newObj = new EntityListImpl(this.getEfi(), valueList.size());\n        int valueListSize = valueList.size();\n        for (int i = 0; i < valueListSize; i++) {\n            EntityValue ev = valueList.get(i);\n            newObj.valueList.add(ev.cloneValue());\n        }\n        return newObj;\n    }\n\n    @Override public void setFromCache() {\n        fromCache = true;\n        for (EntityValue ev : valueList) if (ev instanceof EntityValueBase) ((EntityValueBase) ev).setFromCache();\n    }\n\n    @Override public boolean isFromCache() { return fromCache; }\n    @Override public int size() { return valueList.size(); }\n    @Override public boolean isEmpty() { return valueList.isEmpty(); }\n    @Override public boolean contains(Object o) { return valueList.contains(o); }\n    @Override public @Nonnull Object[] toArray() { return valueList.toArray(); }\n\n    @SuppressWarnings(\"SuspiciousToArrayCall\")\n    @Override public @Nonnull <T> T[] toArray(@Nonnull T[] ts) { return valueList.toArray(ts); }\n\n    @Override public boolean add(EntityValue e) {\n        if (fromCache) throw new EntityException(\"Cannot modify EntityList from cache\");\n        return e != null && valueList.add(e);\n    }\n    @Override public boolean remove(Object o) {\n        if (fromCache) throw new EntityException(\"Cannot modify EntityList from cache\");\n        return valueList.remove(o);\n    }\n\n    @Override public boolean containsAll(@Nonnull Collection<?> objects) { return valueList.containsAll(objects); }\n\n    @Override public boolean addAll(@Nonnull Collection<? extends EntityValue> es) {\n        if (fromCache) throw new EntityException(\"Cannot modify EntityList from cache\");\n        return valueList.addAll(es);\n    }\n    @Override public boolean addAll(int i, @Nonnull Collection<? extends EntityValue> es) {\n        if (fromCache) throw new EntityException(\"Cannot modify EntityList from cache\");\n        return valueList.addAll(i, es);\n    }\n\n    @Override public boolean removeAll(@Nonnull Collection<?> objects) {\n        if (fromCache) throw new EntityException(\"Cannot modify EntityList from cache\");\n        return valueList.removeAll(objects);\n    }\n    @Override public boolean retainAll(@Nonnull Collection<?> objects) {\n        if (fromCache) throw new EntityException(\"Cannot modify EntityList from cache\");\n        return valueList.retainAll(objects);\n    }\n    @Override public void clear() {\n        if (fromCache) throw new EntityException(\"Cannot modify EntityList from cache\");\n        valueList.clear();\n    }\n\n    @Override public EntityValue get(int i) { return valueList.get(i); }\n    @Override public EntityValue set(int i, EntityValue e) {\n        if (fromCache) throw new EntityException(\"Cannot modify EntityList from cache\");\n        return valueList.set(i, e);\n    }\n    @Override public void add(int i, EntityValue e) {\n        if (fromCache) throw new EntityException(\"Cannot modify EntityList from cache\");\n        valueList.add(i, e);\n    }\n    @Override public EntityValue remove(int i) {\n        if (fromCache) throw new EntityException(\"Cannot modify EntityList from cache\");\n        return valueList.remove(i);\n    }\n\n    @Override public int indexOf(Object o) { return valueList.indexOf(o); }\n    @Override public int lastIndexOf(Object o) { return valueList.lastIndexOf(o); }\n    @Override public @Nonnull ListIterator<EntityValue> listIterator() { return valueList.listIterator(); }\n    @Override public @Nonnull ListIterator<EntityValue> listIterator(int i) { return valueList.listIterator(i); }\n    @Override public @Nonnull List<EntityValue> subList(int start, int end) { return valueList.subList(start, end); }\n    @Override public String toString() { return valueList.toString(); }\n\n    @SuppressWarnings(\"unused\")\n    public static class EmptyEntityList implements EntityList {\n        public EmptyEntityList() { }\n        @Override public void writeExternal(ObjectOutput out) throws IOException { }\n        @Override public void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException { }\n        @Override public EntityValue getFirst() { return null; }\n        @Override public EntityList filterByDate(String fromDateName, String thruDateName, Timestamp moment) { return this; }\n        @Override public EntityList filterByDate(String fromDateName, String thruDateName, Timestamp moment, boolean ignoreIfEmpty) { return this; }\n        @Override public EntityList filterByAnd(Map<String, Object> fields) { return this; }\n        @Override public EntityList filterByAnd(Map<String, Object> fields, Boolean include) { return this; }\n        @Override public EntityList filterByAnd(Object... namesAndValues) { return this; }\n        @Override public EntityList removeByAnd(Map<String, Object> fields) { return this; }\n        @Override public EntityList filterByCondition(EntityCondition condition, Boolean include) { return this; }\n        @Override public EntityList filter(Closure<Boolean> closure, Boolean include) { return this; }\n        @Override public EntityValue find(Closure<Boolean> closure) { return null; }\n        @Override public EntityValue findByAnd(Map<String, Object> fields) { return null; }\n        @Override public EntityValue findByAnd(Object... namesAndValues) { return null; }\n        @Override public EntityList findAll(Closure<Boolean> closure) { return this; }\n        @Override public EntityList filterByLimit(Integer offset, Integer limit) { this.offset = offset; this.limit = limit; return this; }\n        @Override public EntityList filterByLimit(String inputFieldsMapName, boolean alwaysPaginate) { return this; }\n        @Override public Integer getOffset() { return this.offset; }\n        @Override public Integer getLimit() { return this.limit; }\n        @Override public int getPageIndex() { return (offset != null ? offset : 0) / getPageSize(); }\n        @Override public int getPageSize() { return limit != null ? limit : 20; }\n        @Override public EntityList orderByFields(List<String> fieldNames) { return this; }\n        @Override public int indexMatching(Map valueMap) { return -1; }\n        @Override public void move(int fromIndex, int toIndex) { throw new IllegalArgumentException(\"EmptyEntityList does not support move\"); }\n        @Override public EntityList addIfMissing(EntityValue value) { throw new IllegalArgumentException(\"EmptyEntityList does not support add\"); }\n        @Override public EntityList addAllIfMissing(EntityList el) { throw new IllegalArgumentException(\"EmptyEntityList does not support add\"); }\n        @Override public @Nonnull Iterator<EntityValue> iterator() { return emptyIterator; }\n        @Override public Object clone() { return this.cloneList(); }\n        @Override public int writeXmlText(Writer writer, String prefix, int dependentLevels) { return 0; }\n        @Override public List<Map<String, Object>> getPlainValueList(int dependentLevels) { return new ArrayList<>(); }\n        @Override public List<Map<String, Object>> getMasterValueList(String name) { return new ArrayList<>(); }\n        @Override public ArrayList<Map<String, Object>> getValueMapList() { return new ArrayList<>(); }\n        @Override public EntityList cloneList() { return this; }\n        @Override public void setFromCache() { }\n        @Override public boolean isFromCache() { return false; }\n        @Override public int size() { return 0; }\n        @Override public boolean isEmpty() { return true; }\n        @Override public boolean contains(Object o) { return false; }\n        @SuppressWarnings(\"unchecked\") @Override public @Nonnull Object[] toArray() { return new Object[0]; }\n        @SuppressWarnings(\"unchecked\") @Override public @Nonnull <T> T[] toArray(@Nonnull T[] ts) { return ((T[]) new EntityValue[0]); }\n        @Override public boolean add(EntityValue e) { throw new IllegalArgumentException(\"EmptyEntityList does not support add\"); }\n        @Override public boolean remove(Object o) { return false; }\n        @Override public boolean containsAll(@Nonnull Collection<?> objects) { return false; }\n        @Override public boolean addAll(@Nonnull Collection<? extends EntityValue> es) { throw new IllegalArgumentException(\"EmptyEntityList does not support addAll\"); }\n        @Override public boolean addAll(int i, @Nonnull Collection<? extends EntityValue> es) { throw new IllegalArgumentException(\"EmptyEntityList does not support addAll\"); }\n        @Override public boolean removeAll(@Nonnull Collection<?> objects) { return false; }\n        @Override public boolean retainAll(@Nonnull Collection<?> objects) { return false; }\n        @Override public void clear() { }\n        @Override public EntityValue get(int i) { return null; }\n        @Override public EntityValue set(int i, EntityValue e) { throw new IllegalArgumentException(\"EmptyEntityList does not support set\"); }\n        @Override public void add(int i, EntityValue e) { throw new IllegalArgumentException(\"EmptyEntityList does not support add\"); }\n        @Override public EntityValue remove(int i) { return null; }\n        @Override public int indexOf(Object o) { return -1; }\n        @Override public int lastIndexOf(Object o) { return -1; }\n        @Override public @Nonnull ListIterator<EntityValue> listIterator() { return emptyIterator; }\n        @Override public @Nonnull ListIterator<EntityValue> listIterator(int i) { return emptyIterator; }\n        @Override public @Nonnull List<EntityValue> subList(int start, int end) { return this; }\n        @Override public String toString() { return \"[]\"; }\n\n        public static ListIterator getEmptyIterator() { return emptyIterator; }\n        private static final ListIterator<EntityValue> emptyIterator = new LinkedList<EntityValue>().listIterator();\n        protected Integer offset = null;\n        protected Integer limit = null;\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityListIteratorImpl.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity;\n\nimport org.moqui.BaseArtifactException;\nimport org.moqui.context.ArtifactExecutionInfo;\nimport org.moqui.entity.*;\nimport org.moqui.impl.context.TransactionCache;\nimport org.moqui.impl.entity.EntityJavaUtil.FindAugmentInfo;\nimport org.moqui.impl.entity.condition.EntityConditionImplBase;\nimport org.moqui.util.CollectionUtilities;\nimport org.moqui.util.LiteStringMap;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.Writer;\nimport java.sql.Connection;\nimport java.sql.ResultSet;\nimport java.sql.SQLException;\nimport java.util.ArrayList;\n\npublic class EntityListIteratorImpl implements EntityListIterator {\n    protected static final Logger logger = LoggerFactory.getLogger(EntityListIteratorImpl.class);\n    protected final EntityFacadeImpl efi;\n    private final TransactionCache txCache;\n    protected final Connection con;\n    private final ResultSet rs;\n    private final FindAugmentInfo findAugmentInfo;\n    private final int txcListSize;\n    private int txcListIndex = -1;\n    private final EntityDefinition entityDefinition;\n    protected final FieldInfo[] fieldInfoArray;\n    private final int fieldInfoListSize;\n    private final EntityConditionImplBase queryCondition;\n    private final CollectionUtilities.MapOrderByComparator orderByComparator;\n    /** This is needed to determine if the ResultSet is empty as cheaply as possible. */\n    private boolean haveMadeValue = false;\n    protected boolean closed = false;\n    private StackTraceElement[] constructStack = null;\n    private final ArrayList<ArtifactExecutionInfo> artifactStack;\n\n    public EntityListIteratorImpl(Connection con, ResultSet rs, EntityDefinition entityDefinition, FieldInfo[] fieldInfoArray,\n            EntityFacadeImpl efi, TransactionCache txCache, EntityConditionImplBase queryCondition, ArrayList<String> obf) {\n        this.efi = efi;\n        this.con = con;\n        this.rs = rs;\n        this.entityDefinition = entityDefinition;\n        fieldInfoListSize = fieldInfoArray.length;\n        this.fieldInfoArray = fieldInfoArray;\n        this.queryCondition = queryCondition;\n        this.txCache = txCache;\n        if (txCache != null && queryCondition != null) {\n            orderByComparator = obf != null && obf.size() > 0 ? new CollectionUtilities.MapOrderByComparator(obf) : null;\n            // add all created values (updated and deleted values will be handled by the next() method\n            findAugmentInfo = txCache.getFindAugmentInfo(entityDefinition.getFullEntityName(), queryCondition);\n            if (findAugmentInfo.valueListSize > 0) {\n                // update the order if we know the order by field list\n                if (orderByComparator != null) findAugmentInfo.valueList.sort(orderByComparator);\n                txcListSize = findAugmentInfo.valueListSize;\n            } else {\n                txcListSize = 0;\n            }\n        } else {\n            findAugmentInfo = null;\n            txcListSize = 0;\n            orderByComparator = null;\n        }\n\n        // capture the current artifact stack for finalize not closed debugging, has minimal performance impact (still ~0.0038ms per call compared to numbers below)\n        artifactStack = efi.ecfi.getEci().artifactExecutionFacade.getStackArray();\n\n        /* uncomment only if needed temporarily: huge performance impact, ~0.036ms per call with, ~0.0037ms without (~10x difference!)\n        StackTraceElement[] tempStack = Thread.currentThread().getStackTrace();\n        if (tempStack.length > 20) tempStack = java.util.Arrays.copyOfRange(tempStack, 0, 20);\n        constructStack = tempStack;\n         */\n    }\n\n    @Override public void close() {\n        if (this.closed) {\n            logger.warn(\"EntityListIterator for entity [\" + this.entityDefinition.getFullEntityName() + \"] is already closed, not closing again\");\n        } else {\n            if (rs != null) {\n                try { rs.close(); }\n                catch (SQLException e) { throw new EntityException(\"Could not close ResultSet in EntityListIterator\", e); }\n            }\n            if (con != null) {\n                try { con.close(); }\n                catch (SQLException e) { throw new EntityException(\"Could not close Connection in EntityListIterator\", e); }\n            }\n\n            /* leaving commented as might be useful for future con pool debugging:\n            try {\n                def dataSource = efi.getDatasourceFactory(entityDefinition.getEntityGroupName()).getDataSource()\n                logger.warn(\"=========== elii after close pool available size: ${dataSource.poolAvailableSize()}/${dataSource.poolTotalSize()}; ${dataSource.getMinPoolSize()}-${dataSource.getMaxPoolSize()}\")\n            } catch (Throwable t) {\n                logger.warn(\"========= pool size error ${t.toString()}\")\n            }\n            */\n            this.closed = true;\n        }\n\n    }\n\n    @Override public void afterLast() {\n        try { rs.afterLast();  }\n        catch (SQLException e) { throw new EntityException(\"Error moving EntityListIterator to afterLast\", e); }\n        txcListIndex = txcListSize;\n    }\n    @Override public void beforeFirst() {\n        txcListIndex = -1;\n        try { rs.beforeFirst(); }\n        catch (SQLException e) { throw new EntityException(\"Error moving EntityListIterator to beforeFirst\", e); }\n    }\n\n    @Override public boolean last() {\n        if (txcListSize > 0) {\n            try { rs.afterLast(); }\n            catch (SQLException e) { throw new EntityException(\"Error moving EntityListIterator to last\", e); }\n            txcListIndex = txcListSize - 1;\n            return true;\n        } else {\n            try { return rs.last(); }\n            catch (SQLException e) { throw new EntityException(\"Error moving EntityListIterator to last\", e); }\n        }\n    }\n    @Override public boolean first() {\n        txcListIndex = -1;\n        try { return rs.first(); }\n        catch (SQLException e) { throw new EntityException(\"Error moving EntityListIterator to first\", e); }\n    }\n\n    @Override public EntityValue currentEntityValue() { return currentEntityValueBase(); }\n    public EntityValueBase currentEntityValueBase() {\n        if (txcListIndex >= 0) {\n            return findAugmentInfo.valueList.get(txcListIndex);\n        }\n\n        EntityValueImpl newEntityValue = new EntityValueImpl(entityDefinition, efi);\n        LiteStringMap<Object> valueMap = newEntityValue.valueMapInternal;\n        for (int i = 0; i < fieldInfoListSize; i++) {\n            FieldInfo fi = fieldInfoArray[i];\n            if (fi == null) break;\n            fi.getResultSetValue(rs, i + 1, valueMap, efi);\n        }\n\n        // if txCache in place always put in cache for future reference (onePut handles any stale from DB issues too)\n        if (txCache != null) txCache.onePut(newEntityValue, false);\n        haveMadeValue = true;\n\n        return newEntityValue;\n    }\n\n    @Override public int currentIndex() {\n        try { return rs.getRow() + txcListIndex + 1; }\n        catch (SQLException e) { throw new EntityException(\"Error getting current index\", e); }\n    }\n    @Override public boolean absolute(final int rowNum) {\n        // TODO: somehow implement this for txcList? would need to know how many rows after last we tried to go\n        if (txcListSize > 0) throw new EntityException(\"Cannot go to absolute row number when transaction cache is in place and there are augmenting creates; disable the tx cache before this operation\");\n        try { return rs.absolute(rowNum); }\n        catch (SQLException e) { throw new EntityException(\"Error going to absolute row number \" + rowNum, e); }\n    }\n    @Override public boolean relative(final int rows) {\n        // TODO: somehow implement this for txcList? would need to know how many rows after last we tried to go\n        if (txcListSize > 0) throw new EntityException(\"Cannot go to relative row number when transaction cache is in place and there are augmenting creates; disable the tx cache before this operation\");\n        try { return rs.relative(rows); }\n        catch (SQLException e) { throw new EntityException(\"Error moving relative rows \" + rows, e); }\n    }\n\n    @Override public boolean hasNext() {\n        try {\n            if (rs.isLast() || rs.isAfterLast()) {\n                return txcListIndex < (txcListSize - 1);\n            } else {\n                // if not in the first or beforeFirst positions and haven't made any values yet, the result set is empty\n                return !(!haveMadeValue && !rs.isBeforeFirst() && !rs.isFirst());\n            }\n        } catch (SQLException e) {\n            throw new EntityException(\"Error while checking to see if there is a next result\", e);\n        }\n    }\n    @Override public boolean hasPrevious() {\n        try {\n            if (rs.isFirst() || rs.isBeforeFirst()) {\n                return false;\n            } else {\n                // if not in the last or afterLast positions and we haven't made any values yet, the result set is empty\n                return !(!haveMadeValue && !rs.isAfterLast() && !rs.isLast());\n            }\n        } catch (SQLException e) {\n            throw new EntityException(\"Error while checking to see if there is a previous result\", e);\n        }\n    }\n\n    @Override public EntityValue next() {\n        // first try the txcList if we are in it\n        if (txcListIndex >= 0) {\n            if (txcListIndex >= txcListSize) return null;\n            txcListIndex++;\n            if (txcListIndex >= txcListSize) return null;\n            return currentEntityValue();\n        }\n        // not in txcList, try the DB\n        try {\n            if (rs.next()) {\n                EntityValueBase evb = currentEntityValueBase();\n                if (txCache != null) {\n                    EntityJavaUtil.WriteMode writeMode = txCache.checkUpdateValue(evb, findAugmentInfo);\n                    // if deleted skip this value\n                    if (writeMode == EntityJavaUtil.WriteMode.DELETE) return next();\n                }\n                return evb;\n            } else {\n                if (txcListSize > 0) {\n                    // txcListIndex should be -1, but instead of incrementing set to 0 just to make sure\n                    txcListIndex = 0;\n                    return currentEntityValue();\n                } else {\n                    return null;\n                }\n            }\n        } catch (SQLException e) {\n            throw new EntityException(\"Error getting next result\", e);\n        }\n    }\n    @Override public int nextIndex() { return currentIndex() + 1; }\n\n    @Override public EntityValue previous() {\n        // first try the txcList if we are in it\n        if (txcListIndex >= 0) {\n            txcListIndex--;\n            if (txcListIndex >= 0) return currentEntityValue();\n        }\n        try {\n            if (rs.previous()) {\n                EntityValueBase evb = (EntityValueBase) currentEntityValue();\n                if (txCache != null) {\n                    EntityJavaUtil.WriteMode writeMode = txCache.checkUpdateValue(evb, findAugmentInfo);\n                    // if deleted skip this value\n                    if (writeMode == EntityJavaUtil.WriteMode.DELETE) return this.previous();\n                }\n                return evb;\n            } else {\n                return null;\n            }\n        } catch (SQLException e) {\n            throw new EntityException(\"Error getting previous result\", e);\n        }\n    }\n    @Override public int previousIndex() { return currentIndex() - 1; }\n\n    @Override public void setFetchSize(int rows) {\n        try { rs.setFetchSize(rows); }\n        catch (SQLException e) { throw new EntityException(\"Error setting fetch size\", e); }\n    }\n\n    @Override public EntityList getCompleteList(boolean closeAfter) {\n        try {\n            // move back to before first if we need to\n            if (haveMadeValue && !rs.isBeforeFirst()) rs.beforeFirst();\n\n            EntityList list = new EntityListImpl(efi);\n            EntityValue value;\n            while ((value = next()) != null) list.add(value);\n\n            if (findAugmentInfo != null) {\n                // all created, updated, and deleted values will be handled by the next() method\n                // update the order if we know the order by field list\n                if (orderByComparator != null) list.sort(orderByComparator);\n            }\n\n            return list;\n        } catch (SQLException e) {\n            throw new EntityException(\"Error getting all results\", e);\n        } finally {\n            //TODO: Remove closeAfter with respect to try-with-resource implementation\n            if (closeAfter) close();\n        }\n    }\n\n    @Override public EntityList getPartialList(int offset, int limit, boolean closeAfter) {\n        // TODO: somehow handle txcList after DB list? same issue as absolute() and relative() methods\n        if (txcListSize > 0) throw new EntityException(\"Cannot get partial list when transaction cache is in place and there are augmenting creates; disable the tx cache before this operation\");\n        try {\n            EntityList list = new EntityListImpl(this.efi);\n            if (limit == 0) return list;\n\n            // list is 1 based\n            if (offset == 0) offset = 1;\n\n            // jump to start index, or just get the first result\n            if (!this.absolute(offset)) {\n                // not that many results, get empty list\n                return list;\n            }\n\n            // get the first as the current one\n            list.add(this.currentEntityValue());\n\n            int numberSoFar = 1;\n            EntityValue nextValue;\n            while (limit > numberSoFar && (nextValue = this.next()) != null) {\n                list.add(nextValue);\n                numberSoFar++;\n            }\n\n            return list;\n        } finally {\n            if (closeAfter) close();\n        }\n    }\n\n    @Override\n    public int writeXmlText(Writer writer, String prefix, int dependentLevels) {\n        int recordsWritten = 0;\n        try {\n            // move back to before first if we need to\n            if (haveMadeValue && !rs.isBeforeFirst()) rs.beforeFirst();\n            EntityValue value;\n            while ((value = this.next()) != null) recordsWritten += value.writeXmlText(writer, prefix, dependentLevels);\n        } catch (SQLException e) {\n            throw new EntityException(\"Error writing XML for all results\", e);\n        }\n\n        return recordsWritten;\n    }\n    @Override\n    public int writeXmlTextMaster(Writer writer, String prefix, String masterName) {\n        int recordsWritten = 0;\n        try {\n            // move back to before first if we need to\n            if (haveMadeValue && !rs.isBeforeFirst()) rs.beforeFirst();\n            EntityValue value;\n            while ((value = this.next()) != null)\n                recordsWritten += value.writeXmlTextMaster(writer, prefix, masterName);\n        } catch (SQLException e) {\n            throw new EntityException(\"Error writing XML for all results\", e);\n        }\n\n        return recordsWritten;\n    }\n\n    @Override\n    public void remove() {\n        // TODO: call EECAs\n        try {\n            efi.getEntityCache().clearCacheForValue((EntityValueBase) currentEntityValue(), false);\n            rs.deleteRow();\n        } catch (SQLException e) {\n            throw new EntityException(\"Error removing row\", e);\n        }\n    }\n\n    @Override\n    public void set(EntityValue e) {\n        throw new BaseArtifactException(\"EntityListIterator.set() not currently supported\");\n        // TODO implement this\n        // TODO: call EECAs\n        // TODO: notify cache clear\n    }\n\n    @Override\n    public void add(EntityValue e) {\n        throw new BaseArtifactException(\"EntityListIterator.add() not currently supported\");\n        // TODO implement this\n    }\n\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityListIteratorWrapper.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity;\n\nimport org.moqui.BaseArtifactException;\nimport org.moqui.entity.EntityCondition;\nimport org.moqui.entity.EntityList;\nimport org.moqui.entity.EntityListIterator;\nimport org.moqui.entity.EntityValue;\nimport org.moqui.impl.context.TransactionCache;\nimport org.moqui.util.CollectionUtilities;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.Writer;\nimport java.util.ArrayList;\nimport java.util.List;\n\nclass EntityListIteratorWrapper implements EntityListIterator {\n    protected static final Logger logger = LoggerFactory.getLogger(EntityListIteratorWrapper.class);\n    protected EntityFacadeImpl efi;\n    private List<EntityValue> valueList;\n    private int internalIndex = -1;\n    private EntityDefinition entityDefinition;\n    /** This is needed to determine if the ResultSet is empty as cheaply as possible. */\n    private boolean haveMadeValue = false;\n    protected boolean closed = false;\n\n    EntityListIteratorWrapper(List<EntityValue> valList, EntityDefinition entityDefinition, EntityFacadeImpl efi,\n                              EntityCondition queryCondition, ArrayList<String> obf) {\n        valueList = new ArrayList<>(valList);\n        this.efi = efi;\n        this.entityDefinition = entityDefinition;\n        TransactionCache txCache = efi.ecfi.transactionFacade.getTransactionCache();\n        if (txCache != null && queryCondition != null) {\n            // add all created values (updated and deleted values will be handled by the next() method\n            EntityJavaUtil.FindAugmentInfo tempFai = txCache.getFindAugmentInfo(entityDefinition.getFullEntityName(), queryCondition);\n            if (tempFai.valueListSize > 0) {\n                // remove update values already in list\n                if (tempFai.foundUpdated.size() > 0) {\n                    for (int i = 0; i < valueList.size(); ) {\n                        EntityValue ev = valueList.get(i);\n                        if (tempFai.foundUpdated.contains(ev.getPrimaryKeys())) {\n                            valueList.remove(i);\n                        } else {\n                            i++;\n                        }\n                    }\n                }\n                valueList.addAll(tempFai.valueList);\n                // update the order if we know the order by field list\n                if (obf != null && obf.size() > 0) valueList.sort(new CollectionUtilities.MapOrderByComparator(obf));\n            }\n        }\n    }\n\n    @Override public void close() {\n        if (this.closed) {\n            logger.warn(\"EntityListIterator for entity \" + entityDefinition.fullEntityName + \" is already closed, not closing again\");\n        } else {\n            this.closed = true;\n        }\n    }\n\n    @Override public void afterLast() { this.internalIndex = valueList.size(); }\n    @Override public void beforeFirst() { internalIndex = -1; }\n    @Override public boolean last() { internalIndex = (valueList.size() - 1); return true; }\n    @Override public boolean first() { internalIndex = 0; return true; }\n\n    @Override public EntityValue currentEntityValue() {\n        this.haveMadeValue = true;\n        return valueList.get(internalIndex);\n    }\n    @Override public int currentIndex() { return internalIndex; }\n\n    @Override public boolean absolute(int rowNum) {\n        internalIndex = rowNum;\n        return !(internalIndex < 0 || internalIndex >= valueList.size());\n    }\n    @Override public boolean relative(int rows) {\n        internalIndex += rows;\n        return !(internalIndex < 0 || internalIndex >= valueList.size());\n    }\n\n    @Override public boolean hasNext() { return internalIndex < (valueList.size() - 1); }\n    @Override public boolean hasPrevious() { return internalIndex > 0; }\n\n    @Override public EntityValue next() {\n        if (internalIndex >= valueList.size()) return null;\n        internalIndex++;\n        if (internalIndex >= valueList.size()) return null;\n        return currentEntityValue();\n    }\n    @Override public int nextIndex() { return internalIndex + 1; }\n\n    @Override public EntityValue previous() {\n        if (internalIndex < 0) return null;\n        internalIndex--;\n        if (internalIndex < 0) return null;\n        return currentEntityValue();\n    }\n    @Override public int previousIndex() { return internalIndex - 1; }\n\n    @Override public void setFetchSize(int rows) {/* do nothing, just ignore */}\n\n    @Override public EntityList getCompleteList(boolean closeAfter) {\n        try {\n            EntityList list = new EntityListImpl(efi);\n            EntityValue value;\n            while ((value = this.next()) != null) list.add(value);\n            return list;\n        } finally {\n            if (closeAfter) close();\n        }\n    }\n\n    @Override public EntityList getPartialList(int offset, int limit, boolean closeAfter) {\n        try {\n            EntityList list = new EntityListImpl(this.efi);\n            if (limit == 0) return list;\n\n            // jump to start index, or just get the first result\n            if (!this.absolute(offset)) {\n                // not that many results, get empty list\n                return list;\n            }\n\n            // get the first as the current one\n            list.add(this.currentEntityValue());\n\n            int numberSoFar = 1;\n            EntityValue nextValue;\n            while (limit > numberSoFar && (nextValue = this.next()) != null) {\n                list.add(nextValue);\n                numberSoFar++;\n            }\n\n            return list;\n        } finally {\n            if (closeAfter) close();\n        }\n    }\n\n    @Override public int writeXmlText(Writer writer, String prefix, int dependentLevels) {\n        int recordsWritten = 0;\n        if (haveMadeValue && internalIndex != -1) internalIndex = -1;\n        EntityValue value;\n        while ((value = this.next()) != null) recordsWritten += value.writeXmlText(writer, prefix, dependentLevels);\n        return recordsWritten;\n    }\n\n    @Override public int writeXmlTextMaster(Writer writer, String prefix, String masterName) {\n        int recordsWritten = 0;\n        if (haveMadeValue && internalIndex != -1) internalIndex = -1;\n        EntityValue value;\n        while ((value = this.next()) != null) recordsWritten += value.writeXmlTextMaster(writer, prefix, masterName);\n        return recordsWritten;\n    }\n\n    @Override public void remove() {\n        throw new BaseArtifactException(\"EntityListIteratorWrapper.remove() not currently supported\");\n        // TODO implement this\n        // TODO: call EECAs\n        // TODO: notify cache clear\n    }\n\n    @Override public void set(EntityValue e) {\n        throw new BaseArtifactException(\"EntityListIteratorWrapper.set() not currently supported\");\n        // TODO implement this\n        // TODO: call EECAs\n        // TODO: notify cache clear\n    }\n\n    @Override public void add(EntityValue e) {\n        throw new BaseArtifactException(\"EntityListIteratorWrapper.add() not currently supported\");\n        // TODO implement this\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityQueryBuilder.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity;\n\nimport org.moqui.entity.EntityException;\nimport org.moqui.impl.entity.EntityJavaUtil.EntityConditionParameter;\nimport org.moqui.impl.entity.EntityJavaUtil.FieldOrderOptions;\nimport org.moqui.util.LiteStringMap;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.sql.Connection;\nimport java.sql.PreparedStatement;\nimport java.sql.ResultSet;\nimport java.sql.SQLException;\nimport java.util.ArrayList;\nimport java.util.concurrent.Future;\nimport java.util.concurrent.TimeUnit;\n\npublic class EntityQueryBuilder implements Runnable {\n    protected static final Logger logger = LoggerFactory.getLogger(EntityQueryBuilder.class);\n    static final boolean isDebugEnabled = logger.isDebugEnabled();\n\n    public final EntityFacadeImpl efi;\n    public final EntityDefinition mainEntityDefinition;\n\n    private static final int sqlInitSize = 500;\n    public final StringBuilder sqlTopLevel = new StringBuilder(sqlInitSize);\n    String finalSql = null;\n\n    private static final int parametersInitSize = 20;\n    public final ArrayList<EntityConditionParameter> parameters = new ArrayList<>(parametersInitSize);\n\n    protected PreparedStatement ps = null;\n    private ResultSet rs = null;\n    protected Connection connection = null;\n    private boolean externalConnection = false;\n    private boolean isFindOne = false;\n\n    boolean execWithTimeout = false;\n    // cur tx timeout set in constructor\n    long execTimeout = 60000;\n\n    public EntityQueryBuilder(EntityDefinition entityDefinition, EntityFacadeImpl efi) {\n        this.mainEntityDefinition = entityDefinition;\n        this.efi = efi;\n\n        execWithTimeout = efi.ecfi.transactionFacade.getUseStatementTimeout();\n        if (execWithTimeout) this.execTimeout = efi.ecfi.transactionFacade.getTxTimeoutRemainingMillis();\n    }\n\n    public EntityDefinition getMainEd() { return mainEntityDefinition; }\n\n    Connection makeConnection(boolean useClone) {\n        connection = efi.getConnection(mainEntityDefinition.getEntityGroupName(), useClone);\n        return connection;\n    }\n\n    void useConnection(Connection c) {\n        connection = c;\n        externalConnection = true;\n    }\n\n    public void isFindOne() { isFindOne = true; }\n\n    protected static void handleSqlException(Exception e, String sql) {\n        throw new EntityException(\"SQL Exception with statement:\" + sql + \"; \" + e.toString(), e);\n    }\n\n    public PreparedStatement makePreparedStatement() {\n        if (connection == null)\n            throw new IllegalStateException(\"Cannot make PreparedStatement, no Connection in place\");\n        finalSql = sqlTopLevel.toString();\n        // if (this.mainEntityDefinition.getFullEntityName().contains(\"foo\")) logger.warn(\"========= making crud PreparedStatement for SQL: ${sql}\")\n        if (isDebugEnabled) logger.debug(\"making crud PreparedStatement for SQL: \" + finalSql);\n        try {\n            ps = connection.prepareStatement(finalSql);\n        } catch (SQLException sqle) {\n            handleSqlException(sqle, finalSql);\n        }\n\n        return ps;\n    }\n\n    // ======== execute methods + Runnable\n    Throwable uncaughtThrowable = null;\n    Boolean execQuery = null;\n    int rowsUpdated = -1;\n\n    public void run() {\n        if (execQuery == null) {\n            logger.warn(\"Called run() with no execQuery flag set, ignoring\", new Exception(\"run call location\"));\n            return;\n        }\n        try {\n            final long timeBefore = isDebugEnabled ? System.currentTimeMillis() : 0L;\n            if (execQuery) {\n                rs = ps.executeQuery();\n                if (isDebugEnabled) logger.debug(\"Executed query with SQL [\" + finalSql + \"] and parameters [\" + parameters +\n                        \"] in [\" + ((System.currentTimeMillis() - timeBefore) / 1000) + \"] seconds\");\n            } else {\n                rowsUpdated = ps.executeUpdate();\n                if (isDebugEnabled) logger.debug(\"Executed update with SQL [\" + finalSql + \"] and parameters [\" + parameters +\n                        \"] in [\" + ((System.currentTimeMillis() - timeBefore) / 1000) + \"] seconds changing [\" + rowsUpdated + \"] rows\");\n            }\n        } catch (Throwable t) {\n            uncaughtThrowable = t;\n        }\n    }\n\n    ResultSet executeQuery() throws SQLException {\n        if (ps == null) throw new IllegalStateException(\"Cannot Execute Query, no PreparedStatement in place\");\n        boolean isError = false;\n        boolean queryStats = !isFindOne && efi.getQueryStats();\n        long beforeQuery = queryStats ? System.nanoTime() : 0;\n\n        execQuery = true;\n        if (execWithTimeout) {\n            try {\n                Future<?> execFuture = efi.statementExecutor.submit(this);\n                // if (execTimeout != 60000L) logger.info(\"statement with timeout \" + execTimeout);\n                execFuture.get(execTimeout, TimeUnit.MILLISECONDS);\n            } catch (Exception e) {\n                uncaughtThrowable = e;\n            }\n        } else {\n            run();\n        }\n        if (rs == null && uncaughtThrowable == null)\n            uncaughtThrowable = new SQLException(\"JDBC query timed out after \" + execTimeout + \"ms for SQL \" + finalSql);\n        try {\n            if (uncaughtThrowable != null) {\n                isError = true;\n                if (uncaughtThrowable instanceof SQLException) {\n                    throw (SQLException) uncaughtThrowable;\n                } else {\n                    throw new SQLException(\"Error in JDBC query for SQL \" + finalSql, uncaughtThrowable);\n                }\n            }\n        } finally {\n            if (queryStats) efi.saveQueryStats(mainEntityDefinition, finalSql, System.nanoTime() - beforeQuery, isError);\n        }\n\n        return rs;\n    }\n\n    int executeUpdate() throws SQLException {\n        if (ps == null) throw new IllegalStateException(\"Cannot Execute Update, no PreparedStatement in place\");\n        // NOTE 20200704: removed query stat tracking for updates\n        // boolean isError = false;\n        // boolean queryStats = efi.getQueryStats();\n        // long beforeQuery = queryStats ? System.nanoTime() : 0;\n\n        execQuery = false;\n        if (execWithTimeout) {\n            try {\n                Future<?> execFuture = efi.statementExecutor.submit(this);\n                execFuture.get(execTimeout, TimeUnit.MILLISECONDS);\n            } catch (Exception e) {\n                uncaughtThrowable = e;\n            }\n        } else {\n            run();\n        }\n        if (rowsUpdated == -1 && uncaughtThrowable == null)\n            uncaughtThrowable = new SQLException(\"JDBC update timed out after \" + execTimeout + \"ms for SQL \" + finalSql);\n        // try {\n            if (uncaughtThrowable != null) {\n                // isError = true;\n                if (uncaughtThrowable instanceof SQLException) {\n                    throw (SQLException) uncaughtThrowable;\n                } else {\n                    throw new SQLException(\"Error in JDBC update for SQL \" + finalSql, uncaughtThrowable);\n                }\n            }\n        // } finally {\n            // if (queryStats) efi.saveQueryStats(mainEntityDefinition, finalSql, System.nanoTime() - beforeQuery, isError);\n        // }\n\n        return rowsUpdated;\n    }\n\n    /** NOTE: this should be called in a finally clause to make sure things are closed */\n    void closeAll() throws SQLException {\n        if (ps != null) {\n            ps.close();\n            ps = null;\n        }\n        if (rs != null) {\n            rs.close();\n            rs = null;\n        }\n        if (connection != null && !externalConnection) {\n            connection.close();\n            connection = null;\n        }\n    }\n\n    /** For when closing to be done in other places, like a EntityListIteratorImpl */\n    void releaseAll() {\n        ps = null;\n        rs = null;\n        connection = null;\n    }\n\n    public static String sanitizeColumnName(String colName) {\n        StringBuilder interim = new StringBuilder(colName);\n        boolean lastUnderscore = false;\n        for (int i = 0; i < interim.length(); ) {\n            char curChar = interim.charAt(i);\n            if (Character.isLetterOrDigit(curChar)) {\n                i++;\n                lastUnderscore = false;\n            } else {\n                if (lastUnderscore) {\n                    interim.deleteCharAt(i);\n                } else {\n                    interim.setCharAt(i, '_');\n                    i++;\n                    lastUnderscore = true;\n                }\n            }\n        }\n        while (interim.charAt(0) == '_') interim.deleteCharAt(0);\n        while (interim.charAt(interim.length() - 1) == '_') interim.deleteCharAt(interim.length() - 1);\n        int duIdx; while ((duIdx = interim.indexOf(\"__\")) >= 0) interim.deleteCharAt(duIdx);\n        return interim.toString();\n    }\n\n    void setPreparedStatementValue(int index, Object value, FieldInfo fieldInfo) throws EntityException {\n        fieldInfo.setPreparedStatementValue(this.ps, index, value, this.mainEntityDefinition, this.efi);\n    }\n\n    void setPreparedStatementValues() {\n        // set all of the values from the SQL building in efb\n        ArrayList<EntityConditionParameter> parms = parameters;\n        int size = parms.size();\n        for (int i = 0; i < size; i++) {\n            EntityConditionParameter entityConditionParam = parms.get(i);\n            entityConditionParam.setPreparedStatementValue(i + 1);\n        }\n    }\n\n    public void makeSqlSelectFields(FieldInfo[] fieldInfoArray, FieldOrderOptions[] fieldOptionsArray, boolean addUniqueAs) {\n        int size = fieldInfoArray.length;\n        if (size > 0) {\n            if (fieldOptionsArray == null && mainEntityDefinition.entityInfo.allFieldInfoArray.length == size) {\n                String allFieldsSelect = mainEntityDefinition.entityInfo.allFieldsSqlSelect;\n                if (allFieldsSelect != null) {\n                    sqlTopLevel.append(mainEntityDefinition.entityInfo.allFieldsSqlSelect);\n                    return;\n                }\n            }\n\n            for (int i = 0; i < size; i++) {\n                FieldInfo fi = fieldInfoArray[i];\n                if (fi == null) break;\n                if (i > 0) sqlTopLevel.append(\", \");\n                boolean appendCloseParen = false;\n                if (fieldOptionsArray != null) {\n                    FieldOrderOptions foo = fieldOptionsArray[i];\n                    if (foo != null && foo.getCaseUpperLower() != null && fi.typeValue == 1) {\n                        sqlTopLevel.append(foo.getCaseUpperLower() ? \"UPPER(\" : \"LOWER(\");\n                        appendCloseParen = true;\n                    }\n                }\n                String fullColName = fi.getFullColumnName();\n                sqlTopLevel.append(fullColName);\n                if (appendCloseParen) sqlTopLevel.append(\")\");\n                // H2 (and perhaps other DBs?) require a unique name for each selected column, even if not used elsewhere; seems like a bug...\n                if (addUniqueAs && fullColName.contains(\".\")) sqlTopLevel.append(\" AS \").append(sanitizeColumnName(fullColName));\n            }\n\n        } else {\n            sqlTopLevel.append(\"*\");\n        }\n    }\n\n    public void addWhereClause(FieldInfo[] pkFieldArray, LiteStringMap valueMapInternal) {\n        sqlTopLevel.append(\" WHERE \");\n        int sizePk = pkFieldArray.length;\n        for (int i = 0; i < sizePk; i++) {\n            FieldInfo fieldInfo = pkFieldArray[i];\n            if (fieldInfo == null) break;\n            if (i > 0) sqlTopLevel.append(\" AND \");\n            sqlTopLevel.append(fieldInfo.getFullColumnName()).append(\"=?\");\n            parameters.add(new EntityJavaUtil.EntityConditionParameter(fieldInfo, valueMapInternal.getByIString(fieldInfo.name, fieldInfo.index), this));\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntitySqlException.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity\n\nimport org.moqui.Moqui\nimport org.moqui.context.ExecutionContext\nimport org.moqui.entity.EntityException\nimport java.sql.SQLException\n\n/** Wrap an SqlException for more user friendly error messages */\nclass EntitySqlException extends EntityException {\n    // NOTE these are the messages to localize with LocalizedMessage\n    // NOTE: don't change these unless there is a really good reason, will break localization\n    private static Map<String, String> messageBySqlCode = [\n            '22':'invalid data', // data exception\n            '22001':'text value too long', // VALUE_TOO_LONG, char/varchar/etc (aka right truncation)\n            '22003':'number too big', // NUMERIC_VALUE_OUT_OF_RANGE\n            '22004':'empty value not allowed', // null value not allowed\n            '22018':'text value could not be converted', // DATA_CONVERSION_ERROR, invalid character value for cast\n            '23':'record already exists or related record does not exist', // integrity constraint violation, most likely problems\n            '23502':'empty value not allowed', // NULL_NOT_ALLOWED\n            '23503':'tried to delete record that other records refer to or record specified does not exist', // REFERENTIAL_INTEGRITY_VIOLATED_CHILD_EXISTS (in update or delete would orphan FK)\n            // NOTE: Postgres uses 23503 for parent and child fk violations, other DBs too? use same message for both\n            '23505':'record already exists', // DUPLICATE_KEY\n            '23506':'record specified does not exist', // REFERENTIAL_INTEGRITY_VIOLATED_PARENT_MISSING (in insert or update invalid FK reference)\n            '40':'record lock conflict found', // transaction rollback\n            '40001':'record lock conflict found', // DEADLOCK - serialization failure\n            '40002':'record lock conflict found', // integrity constraint violation\n            '40P01':'record lock conflict found', // postgres deadlock_detected\n            '50200':'timeout waiting for record lock', // LOCK_TIMEOUT H2\n            '57033':'record lock conflict found', // DB2 deadlock without automatic rollback\n            'HY':'timeout waiting for database', // lock or other timeout; is this really correct for this 2 letter code?\n            'HY000':'timeout waiting for record lock', // lock or other timeout\n            'HYT00':'timeout waiting for record lock', // lock or other timeout (H2)\n            // NOTE MySQL uses HY000 for a LOT of stuff, lock timeout distinguished by error code 1205\n    ]\n\n    /* see:\n        https://www.h2database.com/javadoc/org/h2/api/ErrorCode.html\n        https://dev.mysql.com/doc/refman/5.7/en/error-messages-server.html\n        https://www.postgresql.org/docs/current/static/errcodes-appendix.html\n        https://www.ibm.com/support/knowledgecenter/SSEPEK_12.0.0/codes/src/tpc/db2z_sqlstatevalues.html\n     */\n\n    private String sqlState = null\n\n    EntitySqlException(String str, SQLException nested) {\n        super(str, nested)\n        getSQLState(nested)\n    }\n\n    @Override String getMessage() {\n        String overrideMessage = super.getMessage()\n        if (sqlState != null) {\n            // try full string\n            String msg = messageBySqlCode.get(sqlState)\n            // try first 2 chars\n            if (msg == null && sqlState.length() >= 2) msg = messageBySqlCode.get(sqlState.substring(0,2))\n            // localize and append\n            if (msg != null) {\n                try {\n                    ExecutionContext ec = Moqui.getExecutionContext()\n                    // TODO: need a different approach for localization, getting from DB may not be reliable after an error and may cause other errors (especially with Postgres and the auto rollback only)\n                    // overrideMessage += ': ' + ec.l10n.localize(msg)\n                    overrideMessage += ': ' + msg\n                } catch (Throwable t) {\n                    System.out.println(\"Error localizing override message \" + t.toString())\n                }\n            }\n        }\n        overrideMessage += ' [' + sqlState + ']'\n        return overrideMessage\n    }\n    @Override String toString() { return getMessage() }\n\n    String getSQLState() { return sqlState }\n    String getSQLState(SQLException ex) {\n        if (sqlState != null) return sqlState\n        sqlState = ex.getSQLState()\n        if (sqlState == null) {\n            SQLException nestedEx = ex.getNextException()\n            if (nestedEx != null) sqlState = nestedEx.getSQLState()\n        }\n        return sqlState\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityValueBase.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity;\n\nimport org.codehaus.groovy.runtime.DefaultGroovyMethods;\n\nimport org.moqui.Moqui;\nimport org.moqui.context.ArtifactExecutionInfo;\nimport org.moqui.context.ExecutionContext;\nimport org.moqui.entity.EntityException;\nimport org.moqui.entity.EntityFind;\nimport org.moqui.entity.EntityList;\nimport org.moqui.entity.EntityValue;\nimport org.moqui.impl.context.*;\nimport org.moqui.impl.context.ContextJavaUtil.EntityRecordLock;\nimport org.moqui.util.*;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.w3c.dom.Document;\nimport org.w3c.dom.Element;\n\nimport javax.annotation.Nonnull;\nimport javax.sql.rowset.serial.SerialBlob;\nimport javax.sql.rowset.serial.SerialException;\nimport java.io.IOException;\nimport java.io.ObjectInput;\nimport java.io.ObjectOutput;\nimport java.io.Writer;\nimport java.math.BigDecimal;\nimport java.sql.SQLException;\nimport java.sql.Connection;\nimport java.sql.Date;\nimport java.sql.Time;\nimport java.sql.Timestamp;\nimport java.util.*;\n\npublic abstract class EntityValueBase implements EntityValue {\n    private static final long serialVersionUID = -4935076967225824138L;\n    protected static final Logger logger = LoggerFactory.getLogger(EntityValueBase.class);\n\n    // these error strings are here for convenience for LocalizedMessage records\n    // NOTE: don't change these unless there is a really good reason, will break localization\n    private static final String CREATE_ERROR = \"Error creating ${entityName} ${primaryKeys}\";\n    private static final String UPDATE_ERROR = \"Error updating ${entityName} ${primaryKeys}\";\n    private static final String DELETE_ERROR = \"Error deleting ${entityName} ${primaryKeys}\";\n    private static final String REFRESH_ERROR = \"Error finding ${entityName} ${primaryKeys}\";\n\n    private String entityName;\n    protected final LiteStringMap<Object> valueMapInternal;\n\n    private transient EntityFacadeImpl efiTransient = null;\n    private transient TransactionCache txCacheInternal = null;\n    private transient EntityDefinition entityDefinitionTransient = null;\n\n    protected transient LiteStringMap<Object> dbValueMap = null;\n    protected transient LiteStringMap<Object> oldDbValueMap = null;\n    private transient Map<String, Map<String, String>> localizedByLocaleByField = null;\n    private transient Set<String> touchedFields = null;\n\n    private transient boolean modified = false;\n    private transient boolean mutable = true;\n    private transient boolean isFromDb = false;\n    private static final String indentString = \"    \";\n\n    /** Default constructor for deserialization ONLY. */\n    public EntityValueBase() { valueMapInternal = new LiteStringMap<>().useManualIndex(); }\n\n    public EntityValueBase(EntityDefinition ed, EntityFacadeImpl efip) {\n        efiTransient = efip;\n        entityName = ed.fullEntityName;\n        entityDefinitionTransient = ed;\n        valueMapInternal = new LiteStringMap<>(ed.allFieldNameList.size()).useManualIndex();\n    }\n\n    @Override public void writeExternal(ObjectOutput out) throws IOException {\n        // NOTE: found that the serializer in Hazelcast is slow with writeUTF(), uses String.charAt() in a for loop\n        // NOTE2: in Groovy this results in castToType() overhead anyway, so for now use writeUTF/readUTF as other serialization might be more efficient\n        out.writeUTF(entityName);\n        out.writeObject(valueMapInternal);\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    @Override public void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException {\n        entityName = objectInput.readUTF();\n        LiteStringMap<Object> lsm;\n        try {\n            lsm = (LiteStringMap<Object>) objectInput.readObject();\n        } catch (Throwable t) {\n            logger.error(\"Error deserializing fields Map for entity \" + entityName, t);\n            throw t;\n        }\n        FieldInfo[] fieldInfos = getEntityDefinition().entityInfo.allFieldInfoArray;\n        valueMapInternal.ensureCapacity(fieldInfos.length);\n        for (int i = 0; i < fieldInfos.length; i++) {\n            FieldInfo fieldInfo = fieldInfos[i];\n            int oldIndex = lsm.findIndexIString(fieldInfo.name);\n            if (oldIndex == -1) continue;\n            valueMapInternal.putByIString(fieldInfo.name, lsm.getValue(oldIndex), fieldInfo.index);\n        }\n    }\n\n    protected EntityFacadeImpl getEntityFacadeImpl() {\n        // handle null after deserialize; this requires a static reference in Moqui.java or we'll get an error\n        if (efiTransient == null) {\n            ExecutionContextFactoryImpl ecfi = (ExecutionContextFactoryImpl) Moqui.getExecutionContextFactory();\n            if (ecfi == null) throw new EntityException(\"No ExecutionContextFactory found, cannot get EntityFacade for new EVB for entity \" + entityName);\n            efiTransient = ecfi.entityFacade;\n        }\n        return efiTransient;\n    }\n    private TransactionCache getTxCache(ExecutionContextFactoryImpl ecfi) {\n        if (txCacheInternal == null) txCacheInternal = ecfi.transactionFacade.getTransactionCache();\n        return txCacheInternal;\n    }\n    public EntityDefinition getEntityDefinition() {\n        if (entityDefinitionTransient == null)\n            entityDefinitionTransient = getEntityFacadeImpl().getEntityDefinition(entityName);\n        return entityDefinitionTransient;\n    }\n\n    public LiteStringMap<Object> getValueMap() { return valueMapInternal; }\n    protected LiteStringMap<Object> getDbValueMap() { return dbValueMap; }\n\n    protected void setDbValueMap(Map<String, Object> map) {\n        FieldInfo[] allFields = getEntityDefinition().entityInfo.allFieldInfoArray;\n        dbValueMap = new LiteStringMap<>(allFields.length).useManualIndex();\n        // copy all fields, including pk to fix false positives in the old approach of only non-pk fields\n        for (int i = 0; i < allFields.length; i++) {\n            FieldInfo fi = allFields[i];\n            if (!map.containsKey(fi.name)) continue;\n            Object curValue = map.get(fi.name);\n            dbValueMap.putByIString(fi.name, curValue, fi.index);\n            if (!valueMapInternal.containsKeyIString(fi.name, fi.index)) valueMapInternal.putByIString(fi.name, curValue, fi.index);\n        }\n        isFromDb = true;\n    }\n    public void setSyncedWithDb() {\n        oldDbValueMap = dbValueMap;\n        dbValueMap = null;\n        modified = false;\n        isFromDb = true;\n    }\n    public boolean getIsFromDb() { return isFromDb; }\n\n    @Override public String resolveEntityName() { return entityName; }\n    @Override public String resolveEntityNamePretty() { return StringUtilities.camelCaseToPretty(getEntityDefinition().getEntityName()); }\n    @Override public boolean isModified() { return modified; }\n    @Override public boolean isFieldModified(String name) { if (name == null) return false; return isFieldModifiedIString(name.intern()); }\n    private boolean isFieldModifiedIString(String name) {\n        int valueMapIdx = valueMapInternal.findIndexIString(name);\n        if (valueMapIdx == -1) return false;\n        if (touchedFields != null && touchedFields.contains(name)) return true;\n\n        if (dbValueMap == null) return true;\n        int dbIdx = dbValueMap.findIndexIString(name);\n        if (dbIdx == -1) return true;\n\n        Object valueMapValue = valueMapInternal.getValue(valueMapIdx);\n        Object dbValue = dbValueMap.getValue(dbIdx);\n        return (valueMapValue == null && dbValue != null) || (valueMapValue != null && !valueMapValue.equals(dbValue));\n\n        /*\n        if (!valueMapInternal.containsKey(name)) return false;\n        if (dbValueMap == null || !dbValueMap.containsKey(name)) return true;\n        Object valueMapValue = valueMapInternal.get(name);\n        Object dbValue = dbValueMap.get(name);\n        return (valueMapValue == null && dbValue != null) || (valueMapValue != null && !valueMapValue.equals(dbValue));\n        */\n    }\n    @Override public EntityValue touchField(String name) {\n        if (!getEntityDefinition().isField(name)) throw new IllegalArgumentException(\"Cannot touch field name \" + name + \", does not exist on entity \" + entityName);\n        modified = true;\n        if (touchedFields == null) touchedFields = new HashSet<>();\n        touchedFields.add(name);\n        return this;\n    }\n\n    @Override public boolean isFieldSet(String name) { return valueMapInternal.containsKey(name); }\n    @Override public boolean isField(String name) { return getEntityDefinition().isField(name); }\n    @Override public boolean isMutable() { return mutable; }\n    public void setFromCache() { mutable = false; }\n\n    @Override\n    public Map<String, Object> getMap() {\n        // call get() for each field for localization, etc\n        Map<String, Object> theMap = new LinkedHashMap<>();\n\n        EntityDefinition ed = getEntityDefinition();\n        FieldInfo[] allFieldInfos = ed.entityInfo.allFieldInfoArray;\n        int allFieldInfosSize = allFieldInfos.length;\n        for (int i = 0; i < allFieldInfosSize; i++) {\n            FieldInfo fieldInfo = allFieldInfos[i];\n            Object fieldValue = getKnownField(fieldInfo);\n            // NOTE DEJ20151117 also put nulls in Map, make more complete, removed: if (fieldValue != null)\n            theMap.put(fieldInfo.name, fieldValue);\n        }\n\n        if (ed.isViewEntity) {\n            Map<String, MNode> pqExpressionNodeMap = ed.getPqExpressionNodeMap();\n            if (pqExpressionNodeMap != null) for (String fieldName : pqExpressionNodeMap.keySet()) {\n                theMap.put(fieldName, get(fieldName));\n            }\n        }\n\n        return theMap;\n    }\n\n    @Override\n    public Object get(final String name) {\n        EntityDefinition ed = getEntityDefinition();\n\n        FieldInfo fieldInfo = ed.getFieldInfo(name);\n        if (fieldInfo != null) return getKnownField(fieldInfo);\n\n        // if this is not a valid field name but is a valid relationship name, do a getRelated or getRelatedOne to return an EntityList or an EntityValue\n        EntityJavaUtil.RelationshipInfo relInfo = ed.getRelationshipInfo(name);\n        // logger.warn(\"====== get related relInfo: ${relInfo}\")\n        if (relInfo != null) {\n            if (relInfo.isTypeOne) {\n                return this.findRelatedOne(name, null, null);\n            } else {\n                return this.findRelated(name, null, null, null, null);\n            }\n        }\n\n        // special case, see if this is a alias with a pq-expression, if so evaluate\n        if (ed.isViewEntity) {\n            MNode pqExprNode = ed.getPqExpressionNode(name);\n            if (pqExprNode != null) {\n                String pqExpression = pqExprNode.attribute(\"pq-expression\");\n                try {\n                    EntityFacadeImpl efi = getEntityFacadeImpl();\n                    return efi.ecfi.resourceFacade.expression(pqExpression, null, valueMapInternal);\n                } catch (Throwable t) {\n                    throw new EntityException(\"Error evaluating pq-expression for \" + entityName + \".\" + name, t);\n                }\n            }\n        }\n\n        // logger.warn(\"========== relInfo Map keys: ${ed.getRelationshipInfoMap().keySet()}, relInfoList: ${ed.getRelationshipsInfo(false)}\")\n        throw new EntityException(\"The name [\" + name + \"] is not a valid field name or relationship name for entity \" + entityName);\n    }\n\n    public Object getKnownField(FieldInfo fieldInfo) {\n        EntityDefinition ed = fieldInfo.ed;\n        // if this is a simple field (is field, no l10n, not user field) just get the value right away (vast majority of use)\n        if (fieldInfo.isSimple) return valueMapInternal.getByIString(fieldInfo.name, fieldInfo.index);\n\n        // if enabled use moqui.basic.LocalizedEntityField for any localized fields\n        if (fieldInfo.enableLocalization) {\n            String name = fieldInfo.name;\n            Locale locale = getEntityFacadeImpl().ecfi.getEci().userFacade.getLocale();\n            String localeStr = locale != null ? locale.toString() : null;\n            if (localeStr != null) {\n                Object internalValue = valueMapInternal.getByIString(fieldInfo.name, fieldInfo.index);\n\n                boolean knownNoLocalized = false;\n                if (localizedByLocaleByField == null) {\n                    localizedByLocaleByField = new HashMap<>();\n                } else {\n                    Map<String, String> localizedByLocale = localizedByLocaleByField.get(name);\n                    if (localizedByLocale != null) {\n                        String cachedLocalized = localizedByLocale.get(localeStr);\n                        if (cachedLocalized != null && cachedLocalized.length() > 0) {\n                            // logger.warn(\"======== field ${name}:${internalValue} found cached localized ${cachedLocalized}\")\n                            return cachedLocalized;\n                        } else {\n                            // logger.warn(\"======== field ${name}:${internalValue} known no localized\")\n                            knownNoLocalized = localizedByLocale.containsKey(localeStr);\n                        }\n                    }\n                }\n\n                if (!knownNoLocalized) {\n                    List<String> pks;\n                    MNode aliasNode = null;\n                    String memberEntityName = null;\n                    if (ed.isViewEntity && !ed.entityInfo.isDynamicView) {\n                        // NOTE: there are issues with dynamic view entities here, may be possible to fix them but for now not running for EntityDynamicView\n                        aliasNode = ed.getFieldNode(name);\n                        memberEntityName = ed.getMemberEntityName(aliasNode.attribute(\"entity-alias\"));\n                        EntityDefinition memberEd = getEntityFacadeImpl().getEntityDefinition(memberEntityName);\n                        pks = memberEd.getPkFieldNames();\n                    } else {\n                        pks = ed.getPkFieldNames();\n                    }\n\n                    if (pks.size() == 1) {\n                        String pk = pks.get(0);\n                        if (aliasNode != null) {\n                            pk = null;\n                            Map<String, String> pkToAliasMap = ed.getMePkFieldToAliasNameMap(aliasNode.attribute(\"entity-alias\"));\n                            Set<String> pkSet = pkToAliasMap.keySet();\n                            if (pkSet.size() == 1) pk = pkToAliasMap.get(pkSet.iterator().next());\n                        }\n\n                        String pkValue = pk != null ? (String) valueMapInternal.get(pk) : null;\n                        if (pkValue != null) {\n                            // logger.warn(\"======== field ${name}:${internalValue} finding LocalizedEntityField, localizedByLocaleByField=${localizedByLocaleByField}\")\n                            String entityName = ed.getFullEntityName();\n                            String fieldName = name;\n                            if (aliasNode != null) {\n                                entityName = memberEntityName;\n                                final String fieldAttr = aliasNode.attribute(\"field\");\n                                fieldName = fieldAttr != null && !fieldAttr.isEmpty() ? fieldAttr : aliasNode.attribute(\"name\");\n                                // logger.warn(\"localizing field for ViewEntity ${ed.fullEntityName} field ${name}, using entityName: ${entityName}, fieldName: ${fieldName}, pkValue: ${pkValue}, locale: ${localeStr}\")\n                            }\n\n                            EntityFind lefFind = getEntityFacadeImpl().find(\"moqui.basic.LocalizedEntityField\")\n                                    .condition(\"entityName\", entityName).condition(\"fieldName\", fieldName)\n                                    .condition(\"pkValue\", pkValue).condition(\"locale\", localeStr);\n                            EntityValue lefValue = lefFind.useCache(true).one();\n                            if (lefValue != null) {\n                                String localized = (String) lefValue.get(\"localized\");\n                                CollectionUtilities.addToMapInMap(name, localeStr, localized, localizedByLocaleByField);\n                                return localized;\n                            }\n\n                            // no result found, try with shortened locale\n                            if (localeStr.contains(\"_\")) {\n                                lefFind.condition(\"locale\", localeStr.substring(0, localeStr.indexOf(\"_\")));\n                                lefValue = lefFind.useCache(true).one();\n                                if (lefValue != null) {\n                                    String localized = (String) lefValue.get(\"localized\");\n                                    CollectionUtilities.addToMapInMap(name, localeStr, localized, localizedByLocaleByField);\n                                    return localized;\n                                }\n                            }\n\n                            // no result found, try \"default\" locale\n                            lefFind.condition(\"locale\", \"default\");\n                            lefValue = lefFind.useCache(true).one();\n                            if (lefValue != null) {\n                                String localized = (String) lefValue.get(\"localized\");\n                                CollectionUtilities.addToMapInMap(name, localeStr, localized, localizedByLocaleByField);\n                                return localized;\n                            }\n                        }\n                    }\n\n                    // no luck? try getting a localized value from moqui.basic.LocalizedMessage\n                    // logger.warn(\"======== field ${name}:${internalValue} finding LocalizedMessage\")\n                    EntityFind lmFind = getEntityFacadeImpl().find(\"moqui.basic.LocalizedMessage\")\n                            .condition(\"original\", internalValue).condition(\"locale\", localeStr);\n                    EntityValue lmValue = lmFind.useCache(true).one();\n                    if (lmValue != null) {\n                        String localized = (String) lmValue.get(\"localized\");\n                        CollectionUtilities.addToMapInMap(name, localeStr, localized, localizedByLocaleByField);\n                        return localized;\n                    }\n\n                    if (localeStr.contains(\"_\")) {\n                        lmFind.condition(\"locale\", localeStr.substring(0, localeStr.indexOf(\"_\")));\n                        lmValue = lmFind.useCache(true).one();\n                        if (lmValue != null) {\n                            String localized = (String) lmValue.get(\"localized\");\n                            CollectionUtilities.addToMapInMap(name, localeStr, localized, localizedByLocaleByField);\n                            return localized;\n                        }\n                    }\n\n                    lmFind.condition(\"locale\", \"default\");\n                    lmValue = lmFind.useCache(true).one();\n                    if (lmValue != null) {\n                        String localized = (String) lmValue.get(\"localized\");\n                        CollectionUtilities.addToMapInMap(name, localeStr, localized, localizedByLocaleByField);\n                        return localized;\n                    }\n\n                    // we didn't find a localized value, remember that so we don't do the queries again (common case)\n                    CollectionUtilities.addToMapInMap(name, localeStr, null, localizedByLocaleByField);\n                    // logger.warn(\"======== field ${name}:${internalValue} remembering no localized, localizedByLocaleByField=${localizedByLocaleByField}\")\n                }\n\n                return internalValue;\n            }\n        }\n\n\n        return valueMapInternal.getByIString(fieldInfo.name, fieldInfo.index);\n    }\n\n    @Override public Object getNoCheckSimple(String name) { return valueMapInternal.get(name); }\n\n    @Override public Object getOriginalDbValue(String name) {\n        return (dbValueMap != null && dbValueMap.containsKey(name)) ? dbValueMap.get(name) : valueMapInternal.get(name);\n    }\n    protected Object getOldDbValue(String name) {\n        if (oldDbValueMap != null && oldDbValueMap.containsKey(name)) return oldDbValueMap.get(name);\n        return getOriginalDbValue(name);\n    }\n\n    @Override public boolean containsPrimaryKey() { return this.getEntityDefinition().containsPrimaryKey(valueMapInternal); }\n    @Override public Map<String, Object> getPrimaryKeys() {\n        /* don't use cached internalPkMap, would have to make sure to capture all set, put, setFields, setFieldsEv, etc to invalidate otherwise may be stale\n         * is just as fast to recreate by index gets on valueMapInternal vs cloning the cached LiteStringMap\n        protected transient LiteStringMap<Object> internalPkMap = null;\n        if (internalPkMap != null) return new LiteStringMap<Object>(internalPkMap);\n        internalPkMap = getEntityDefinition().getPrimaryKeys(this.valueMapInternal);\n        return new LiteStringMap<Object>(internalPkMap);\n         */\n\n        FieldInfo[] pkFieldInfos = getEntityDefinition().entityInfo.pkFieldInfoArray;\n        LiteStringMap<Object> pks = new LiteStringMap<>(pkFieldInfos.length);\n\n        for (int i = 0; i < pkFieldInfos.length; i++) {\n            FieldInfo fi = pkFieldInfos[i];\n            pks.putByIString(fi.name, this.valueMapInternal.getByIString(fi.name, fi.index));\n        }\n\n        return pks;\n    }\n    @Override public String getPrimaryKeysString() {\n        FieldInfo[] pkFieldInfoArray = getEntityDefinition().entityInfo.pkFieldInfoArray;\n        if (pkFieldInfoArray.length == 1) {\n            FieldInfo fi = pkFieldInfoArray[0];\n            return ObjectUtilities.toPlainString(this.valueMapInternal.getByIString(fi.name, fi.index));\n        } else {\n            StringBuilder pkCombinedSb = new StringBuilder();\n            for (int pki = 0; pki < pkFieldInfoArray.length; pki++) {\n                FieldInfo fi = pkFieldInfoArray[pki];\n                // NOTE: separator of '::' matches separator used for combined PK String in EntityDefinition.getPrimaryKeysString() and EntityDataDocument.makeDocId()\n                if (pkCombinedSb.length() > 0) pkCombinedSb.append(\"::\");\n                pkCombinedSb.append(ObjectUtilities.toPlainString(this.valueMapInternal.getByIString(fi.name, fi.index)));\n            }\n            return pkCombinedSb.toString();\n        }\n    }\n\n    public boolean primaryKeyMatches(EntityValueBase evb) {\n        if (evb == null) return false;\n        FieldInfo[] pkFieldInfos = getEntityDefinition().entityInfo.pkFieldInfoArray;\n        boolean allMatch = true;\n        for (int i = 0; i < pkFieldInfos.length; i++) {\n            FieldInfo pkFi = pkFieldInfos[i];\n            Object thisValue = valueMapInternal.getByIString(pkFi.name, pkFi.index);\n            Object thatValue = evb.valueMapInternal.getByIString(pkFi.name, pkFi.index);\n            if (thisValue == null) {\n                if (thatValue != null) { allMatch = false; break; }\n            } else {\n                if (!thisValue.equals(thatValue)) { allMatch = false; break; }\n            }\n        }\n        return allMatch;\n    }\n\n    @Override public EntityValue set(String name, Object value) { put(name, value); return this; }\n    @Override public EntityValue setAll(Map<String, Object> fields) {\n        if (!mutable) throw new EntityException(\"Cannot set fields, this entity value is not mutable (it is read-only)\");\n        getEntityDefinition().entityInfo.setFieldsEv(fields, this, null);\n        return this;\n    }\n    @Override public EntityValue setString(String name, String value) {\n        // this will do a field name check\n        ExecutionContextImpl eci = getEntityFacadeImpl().ecfi.getEci();\n        FieldInfo fi = getEntityDefinition().getFieldInfo(name);\n        Object converted = fi.convertFromString(value, eci.l10nFacade);\n        putKnownField(fi, converted);\n        return this;\n    }\n\n    @Override public Boolean getBoolean(String name) { return DefaultGroovyMethods.asType(get(name), Boolean.class); }\n    @Override public String getString(String name) {\n        EntityDefinition ed = getEntityDefinition();\n        FieldInfo fieldInfo = ed.getFieldInfo(name);\n\n        Object valueObj = getKnownField(fieldInfo);\n        return fieldInfo.convertToString(valueObj);\n    }\n    @Override public Timestamp getTimestamp(String name) { return DefaultGroovyMethods.asType(get(name), Timestamp.class); }\n    @Override public Time getTime(String name) { return DefaultGroovyMethods.asType(this.get(name), Time.class); }\n    @Override public java.sql.Date getDate(String name) { return DefaultGroovyMethods.asType(this.get(name), Date.class); }\n    @Override public Long getLong(String name) { return DefaultGroovyMethods.asType(this.get(name), Long.class); }\n    @Override public Double getDouble(String name) { return DefaultGroovyMethods.asType(this.get(name), Double.class); }\n    @Override public BigDecimal getBigDecimal(String name) { return DefaultGroovyMethods.asType(this.get(name), BigDecimal.class); }\n\n    @Override public byte[] getBytes(String name) {\n        Object o = this.get(name);\n        if (o == null) return null;\n        if (o instanceof SerialBlob) {\n            try {\n                if (((SerialBlob) o).length() == 0) return new byte[0];\n                return ((SerialBlob) o).getBytes(1, (int) ((SerialBlob) o).length());\n            } catch (Exception e) {\n                throw new EntityException(\"Error getting bytes for field \" + name + \" in entity \" + entityName, e);\n            }\n        }\n\n        if (o instanceof byte[]) return (byte[]) o;\n        // try groovy...\n        return DefaultGroovyMethods.asType(o, byte[].class);\n    }\n    @Override public EntityValue setBytes(String name, byte[] theBytes) {\n        try {\n            if (theBytes != null) set(name, new SerialBlob(theBytes));\n        } catch (Exception e) {\n            throw new EntityException(\"Error setting bytes for field \" + name + \" in entity \" + entityName, e);\n        }\n        return this;\n    }\n    @Override public SerialBlob getSerialBlob(String name) {\n        Object o = this.get(name);\n        if (o == null) return null;\n        if (o instanceof SerialBlob) return (SerialBlob) o;\n        try {\n            if (o instanceof byte[]) return new SerialBlob((byte[]) o);\n        } catch (Exception e) {\n            throw new EntityException(\"Error getting SerialBlob for field \" + name + \" in entity \" + entityName, e);\n        }\n        // try groovy...\n        return DefaultGroovyMethods.asType(o, SerialBlob.class);\n    }\n\n    @Override public EntityValue setFields(Map<String, Object> fields, boolean setIfEmpty, String namePrefix, Boolean pks) {\n        if (!setIfEmpty && (namePrefix == null || namePrefix.length() == 0)) {\n            getEntityDefinition().entityInfo.setFields(fields, this, false, namePrefix, pks);\n        } else {\n            getEntityDefinition().entityInfo.setFieldsEv(fields, this, pks);\n        }\n\n        return this;\n    }\n\n    @Override\n    public EntityValue setSequencedIdPrimary() {\n        EntityDefinition ed = getEntityDefinition();\n        EntityFacadeImpl localEfi = getEntityFacadeImpl();\n\n        // get the entity-specific prefix, support string expansion for it too\n        String entityPrefix = null;\n        String rawPrefix = ed.entityInfo.sequencePrimaryPrefix;\n        if (rawPrefix != null && rawPrefix.length() > 0)\n            entityPrefix = localEfi.ecfi.resourceFacade.expand(rawPrefix, null, valueMapInternal);\n        String sequenceValue = localEfi.sequencedIdPrimaryEd(ed);\n\n        putKnownField(ed.entityInfo.pkFieldInfoArray[0], entityPrefix != null ? entityPrefix + sequenceValue : sequenceValue);\n        return this;\n    }\n\n    @Override\n    public EntityValue setSequencedIdSecondary() {\n        EntityDefinition ed = getEntityDefinition();\n        List<String> pkFields = ed.getPkFieldNames();\n        if (pkFields.size() < 2) throw new EntityException(\"Cannot call setSequencedIdSecondary() on entity \" + entityName + \", must have at least 2 primary key fields.\");\n        // sequenced field will be the last pk\n        final String seqFieldName = pkFields.get(pkFields.size() - 1);\n        String paddedLengthStr = ed.getEntityNode().attribute(\"sequence-secondary-padded-length\");\n        int paddedLength = 2;\n        if (paddedLengthStr != null && paddedLengthStr.length() > 0) paddedLength = Integer.valueOf(paddedLengthStr);\n\n        this.remove(seqFieldName);\n        Map<String, Object> otherPkMap = new LinkedHashMap<>();\n        getEntityDefinition().entityInfo.setFields(this, otherPkMap, false, null, true);\n\n        // temporarily disable authz for this, just doing lookup to get next value and to allow for a\n        //     authorize-skip=\"create\" with authorize-skip of view too this is necessary\n        EntityFind ef = getEntityFacadeImpl().find(resolveEntityName()).selectField(seqFieldName).condition(otherPkMap);\n        // logger.warn(\"TOREMOVE in setSequencedIdSecondary ef WHERE=${ef.getWhereEntityCondition()}\")\n        EntityList allValues = ef.disableAuthz().list();\n\n        Integer highestSeqVal = null;\n        for (EntityValue curValue : allValues) {\n            final String currentSeqId = (String) curValue.getNoCheckSimple(seqFieldName);\n            if (currentSeqId != null && !currentSeqId.isEmpty()) {\n                try {\n                    int seqVal = Integer.parseInt(currentSeqId);\n                    if (highestSeqVal == null || seqVal > highestSeqVal) highestSeqVal = seqVal;\n                } catch (Exception e) {\n                    logger.warn(\"Error in secondary sequenced ID converting SeqId [\" + currentSeqId + \"] in field [\" + seqFieldName + \"] from entity [\" + resolveEntityName() + \"] to a number: \" + e.toString());\n                }\n            }\n        }\n\n        int seqValToUse = highestSeqVal != null ? highestSeqVal + 1 : 1;\n        this.set(seqFieldName, StringUtilities.paddedNumber(seqValToUse, paddedLength));\n        return this;\n    }\n\n    @Override\n    public int compareTo(EntityValue that) {\n        // nulls go earlier\n        // not needed? IDE says never null: if (that == null) return -1;\n\n        // first entity names\n        int result = entityName.compareTo(that.resolveEntityName());\n        if (result != 0) return result;\n\n        // next compare all fields (will compare PK fields first, generally first in list)\n        ArrayList<String> allFieldNames = getEntityDefinition().getAllFieldNames();\n        int allFieldNamesSize = allFieldNames.size();\n        for (int i = 0; i < allFieldNamesSize; i++) {\n            String pkFieldName = allFieldNames.get(i);\n            result = compareFields(that, pkFieldName);\n            if (result != 0) return result;\n        }\n\n        // all the same, result should be 0\n        return result;\n    }\n    @SuppressWarnings(\"unchecked\")\n    private int compareFields(EntityValue that, String name) {\n        Comparable thisVal = (Comparable) this.valueMapInternal.getByString(name);\n        Comparable thatVal = (Comparable) that.get(name);\n        // NOTE: nulls go earlier in the list\n        if (thisVal == null) {\n            return thatVal == null ? 0 : 1;\n        } else {\n            return (thatVal == null ? -1 : thisVal.compareTo(thatVal));\n        }\n    }\n\n    @Override\n    public boolean mapMatches(Map<String, Object> theMap) {\n        boolean matches = true;\n        for (Entry<String, Object> entry : theMap.entrySet()) {\n            if (!entry.getValue().equals(this.valueMapInternal.getByString(entry.getKey()))) {\n                matches = false;\n                break;\n            }\n        }\n        return matches;\n    }\n\n    @Override\n    public EntityValue createOrUpdate() {\n        EntityDefinition ed = getEntityDefinition();\n        boolean pkModified = false;\n        if (isFromDb) {\n            pkModified = (ed.getPrimaryKeys(this.valueMapInternal).equals(ed.getPrimaryKeys(this.dbValueMap)));\n        } else {\n            // make sure PK fields with defaults are filled in BEFORE doing the refresh to see if it exists\n            checkSetFieldDefaults(getEntityDefinition(), getEntityFacadeImpl().ecfi.getEci(), true);\n        }\n\n        // logger.warn(\"createOrUpdate isFromDb \" + isFromDb + \" pkModified \" + pkModified);\n        if ((isFromDb && !pkModified) || this.cloneValue().refresh()) {\n            return update();\n        } else {\n            return create();\n        }\n    }\n\n    @Override\n    public EntityValue store() { return createOrUpdate(); }\n\n    private void handleAuditLog(boolean isUpdate, LiteStringMap<Object> oldValues, EntityDefinition ed, ExecutionContextImpl ec) {\n        if ((isUpdate && oldValues == null) || !ed.entityInfo.needsAuditLog || ec.artifactExecutionFacade.entityAuditLogDisabled()) return;\n\n        Timestamp nowTimestamp = ec.userFacade.getNowTimestamp();\n\n        LiteStringMap<Object> pksValueMap = new LiteStringMap<>(ed.entityInfo.pkFieldInfoArray.length).useManualIndex();\n        addThreeFieldPkValues(pksValueMap, ed);\n\n        FieldInfo[] fieldInfoList = ed.entityInfo.allFieldInfoArray;\n        for (int i = 0; i < fieldInfoList.length; i++) {\n            FieldInfo fieldInfo = fieldInfoList[i];\n            boolean isLogUpdate = \"update\".equals(fieldInfo.enableAuditLog);\n            if ((!isLogUpdate && \"true\".equals(fieldInfo.enableAuditLog)) || (isUpdate && isLogUpdate)) {\n                String fieldName = fieldInfo.name;\n\n                // is there a new value? if not continue\n                if (!this.valueMapInternal.containsKeyIString(fieldInfo.name, fieldInfo.index)) continue;\n\n                Object value = getKnownField(fieldInfo);\n                Object oldValue = oldValues != null ? oldValues.getByIString(fieldInfo.name, fieldInfo.index) : null;\n                // if set to log updates and old value is null don't consider it an update (is initial set of value)\n                if (isLogUpdate && oldValue == null) continue;\n                if (isUpdate) {\n                    // if isUpdate but old value == new value, then it hasn't been updated, so skip it\n                    if (value == null) {\n                        if (oldValue == null) continue;\n                    } else {\n                        if (value instanceof BigDecimal && oldValue instanceof BigDecimal) {\n                            // better handling for BigDecimal, perhaps others\n                            if (((BigDecimal) value).compareTo((BigDecimal) oldValue) == 0) continue;\n                        } else {\n                            if (value.equals(oldValue)) continue;\n                        }\n                    }\n                } else {\n                    // if it's a create and there is no value don't log a change\n                    if (value == null) continue;\n                }\n                // logger.warn(\"EntityAuditLog field \" + fieldName + \" old \" + oldValue + \" (\" + (oldValue != null ? oldValue.getClass().getName() : \"null\") + \") new \" + value + \" (\" + (value != null ? value.getClass().getName() : \"null\") + \")\");\n\n                // don't skip for this, if a field was reset then we want to record that: if (!value) continue\n\n                // check for a changeReason\n                String changeReason = null;\n                Object changeReasonObj = ec.contextStack.getByString(fieldName.concat(\"_changeReason\"));\n                if (changeReasonObj != null) {\n                    changeReason = changeReasonObj.toString();\n                    if (changeReason.isEmpty()) changeReason = null;\n                }\n\n                String stackNameString = ec.artifactExecutionFacade.getStackNameString();\n                if (stackNameString.length() > 4000) stackNameString = stackNameString.substring(0, 4000);\n                LinkedHashMap<String, Object> parms = new LinkedHashMap<>();\n                parms.put(\"changedEntityName\", resolveEntityName());\n                parms.put(\"changedFieldName\", fieldName);\n                if (changeReason != null) parms.put(\"changeReason\", changeReason);\n                parms.put(\"changedDate\", nowTimestamp);\n                parms.put(\"changedByUserId\", ec.getUser().getUserId());\n                parms.put(\"changedInVisitId\", ec.getUser().getVisitId());\n                parms.put(\"artifactStack\", stackNameString);\n\n                // prep values, encrypt if needed\n                if (value != null) {\n                    String newValueText = ObjectUtilities.toPlainString(value);\n                    if (fieldInfo.encrypt) newValueText = EntityJavaUtil.enDeCrypt(newValueText, true, ec.getEntityFacade());\n                    if (newValueText.length() > 4000) newValueText = newValueText.substring(0, 4000);\n                    parms.put(\"newValueText\", newValueText);\n                }\n                if (oldValue != null) {\n                    String oldValueText = ObjectUtilities.toPlainString(oldValue);\n                    if (fieldInfo.encrypt) oldValueText = EntityJavaUtil.enDeCrypt(oldValueText, true, ec.getEntityFacade());\n                    if (oldValueText.length() > 4000) oldValueText = oldValueText.substring(0, 4000);\n                    parms.put(\"oldValueText\", oldValueText);\n                }\n\n                // set all pk fields by name to support EntityAuditLog extensions for specific pk fields, will usually all get ignored\n                parms.putAll(pksValueMap);\n\n                // logger.warn(\"TOREMOVE: in handleAuditLog for [${ed.entityName}.${fieldName}] value=[${value}], oldValue=[${oldValue}], oldValues=[${oldValues}]\", new Exception(\"AuditLog location\"))\n\n                // NOTE: if this is changed to async the time zone on nowTimestamp gets messed up (user's time zone lost)\n                getEntityFacadeImpl().ecfi.serviceFacade.sync().name(\"create#moqui.entity.EntityAuditLog\")\n                        .parameters(parms).disableAuthz().call();\n            }\n        }\n    }\n\n    private void addThreeFieldPkValues(Map<String, Object> parms, EntityDefinition ed) {\n        // get pkPrimaryValue, pkSecondaryValue, pkRestCombinedValue (just like the AuditLog stuff)\n        ArrayList<FieldInfo> pkFieldList = new ArrayList<>();\n        Collections.addAll(pkFieldList, ed.entityInfo.pkFieldInfoArray);\n        FieldInfo firstPkField = pkFieldList.size() > 0 ? pkFieldList.remove(0) : null;\n        FieldInfo secondPkField = pkFieldList.size() > 0 ? pkFieldList.remove(0) : null;\n        StringBuilder pkTextSb = new StringBuilder();\n        for (int i = 0; i < pkFieldList.size(); i++) {\n            FieldInfo curFieldInfo = pkFieldList.get(i);\n            if (i > 0) pkTextSb.append(\",\");\n            pkTextSb.append(curFieldInfo.name).append(\":'\")\n                    .append(EntityDefinition.getFieldStringForFile(curFieldInfo, getKnownField(curFieldInfo))).append(\"'\");\n        }\n        String pkText = pkTextSb.toString();\n\n        if (firstPkField != null) parms.put(\"pkPrimaryValue\", getKnownField(firstPkField));\n        if (secondPkField != null) parms.put(\"pkSecondaryValue\", getKnownField(secondPkField));\n        if (!pkText.isEmpty()) parms.put(\"pkRestCombinedValue\", pkText);\n    }\n\n    @Override\n    public EntityList findRelated(final String relationshipName, Map<String, Object> byAndFields, List<String> orderBy,\n                                  Boolean useCache, Boolean forUpdate) {\n        EntityJavaUtil.RelationshipInfo relInfo = getEntityDefinition().getRelationshipInfo(relationshipName);\n        if (relInfo == null) throw new EntityException(\"Relationship \" + relationshipName + \" not found in entity \" + entityName);\n        return findRelated(relInfo, byAndFields, orderBy, useCache, forUpdate);\n    }\n\n    private EntityList findRelated(final EntityJavaUtil.RelationshipInfo relInfo, Map<String, Object> byAndFields,\n                                   List<String> orderBy, Boolean useCache, Boolean forUpdate) {\n        String relatedEntityName = relInfo.relatedEntityName;\n        Map<String, String> keyMap = relInfo.keyMap;\n        if (keyMap == null || keyMap.size() == 0) throw new EntityException(\"Relationship \" + relInfo.relationshipName + \" in entity \" + entityName + \" has no key-map sub-elements and no default values\");\n\n        // make a Map where the key is the related entity's field name, and the value is the value from this entity\n        Map<String, Object> condMap = new HashMap<>();\n        for (Entry<String, String> entry : keyMap.entrySet())\n            condMap.put(entry.getValue(), valueMapInternal.getByString(entry.getKey()));\n        if (relInfo.keyValueMap != null) {\n            for (Map.Entry<String, String> keyValueEntry: relInfo.keyValueMap.entrySet())\n                condMap.put(keyValueEntry.getKey(), keyValueEntry.getValue());\n        }\n        if (byAndFields != null && byAndFields.size() > 0) condMap.putAll(byAndFields);\n\n        EntityFind find = getEntityFacadeImpl().find(relatedEntityName);\n        return find.condition(condMap).orderBy(orderBy).useCache(useCache).forUpdate(forUpdate != null ? forUpdate : false).list();\n    }\n\n    @Override\n    public EntityValue findRelatedOne(final String relationshipName, Boolean useCache, Boolean forUpdate) {\n        EntityJavaUtil.RelationshipInfo relInfo = getEntityDefinition().getRelationshipInfo(relationshipName);\n        if (relInfo == null) throw new EntityException(\"Relationship \" + relationshipName + \" not found in entity \" + entityName);\n        return findRelatedOne(relInfo, useCache, forUpdate);\n    }\n\n    private EntityValue findRelatedOne(final EntityJavaUtil.RelationshipInfo relInfo, Boolean useCache, Boolean forUpdate) {\n        String relatedEntityName = relInfo.relatedEntityName;\n        Map<String, String> keyMap = relInfo.keyMap;\n        if (keyMap == null || keyMap.size() == 0) throw new EntityException(\"Relationship \" + relInfo.relationshipName + \" in entity \" + entityName + \" has no key-map sub-elements and no default values\");\n\n        // make a Map where the key is the related entity's field name, and the value is the value from this entity\n        Map<String, Object> condMap = new HashMap<>();\n        for (Entry<String, String> entry : keyMap.entrySet()) condMap.put(entry.getValue(), valueMapInternal.getByString(entry.getKey()));\n        if (relInfo.keyValueMap != null) {\n            for (Map.Entry<String, String> keyValueEntry: relInfo.keyValueMap.entrySet())\n                condMap.put(keyValueEntry.getKey(), keyValueEntry.getValue());\n        }\n\n        // logger.warn(\"========== findRelatedOne ${relInfo.relationshipName} keyMap=${keyMap}, condMap=${condMap}\")\n\n        EntityFind find = getEntityFacadeImpl().find(relatedEntityName);\n        return find.condition(condMap).useCache(useCache).forUpdate(forUpdate != null ? forUpdate : false).one();\n    }\n\n    @Override\n    public long findRelatedCount(final String relationshipName, Boolean useCache) {\n        EntityJavaUtil.RelationshipInfo relInfo = getEntityDefinition().getRelationshipInfo(relationshipName);\n        if (relInfo == null) throw new EntityException(\"Relationship \" + relationshipName + \" not found in entity \" + entityName);\n\n        String relatedEntityName = relInfo.relatedEntityName;\n        Map<String, String> keyMap = relInfo.keyMap;\n        if (keyMap == null || keyMap.size() == 0) throw new EntityException(\"Relationship \" + relInfo.relationshipName + \" in entity \" + entityName + \" has no key-map sub-elements and no default values\");\n\n        // make a Map where the key is the related entity's field name, and the value is the value from this entity\n        Map<String, Object> condMap = new HashMap<>();\n        for (Entry<String, String> entry : keyMap.entrySet()) condMap.put(entry.getValue(), valueMapInternal.getByString(entry.getKey()));\n        if (relInfo.keyValueMap != null) {\n            for (Map.Entry<String, String> keyValueEntry: relInfo.keyValueMap.entrySet())\n                condMap.put(keyValueEntry.getKey(), keyValueEntry.getValue());\n        }\n\n        EntityFind find = getEntityFacadeImpl().find(relatedEntityName);\n        return find.condition(condMap).useCache(useCache).count();\n    }\n\n    @Override\n    public EntityList findRelatedFk(Set<String> skipEntities) {\n        EntityList relatedList = new EntityListImpl(getEntityFacadeImpl());\n        ArrayList<EntityJavaUtil.RelationshipInfo> relInfoList = getEntityDefinition().getRelationshipsInfo(false);\n        int relInfoListSize = relInfoList.size();\n        for (int i = 0; i < relInfoListSize; i++) {\n            EntityJavaUtil.RelationshipInfo relInfo = relInfoList.get(i);\n            EntityJavaUtil.RelationshipInfo reverseInfo = relInfo.findReverse();\n            if (reverseInfo == null || !reverseInfo.isTypeOne || (skipEntities != null && (skipEntities.contains(reverseInfo.fromEd.fullEntityName) ||\n                    skipEntities.contains(reverseInfo.fromEd.getShortAlias()) || skipEntities.contains(reverseInfo.fromEd.getEntityName())))) continue;\n            EntityList curList = findRelated(relInfo, null, null, null, null);\n            relatedList.addAll(curList);\n        }\n        return relatedList;\n    }\n\n    @Override\n    public void deleteRelated(String relationshipName) {\n        // NOTE: this does a select for update, may consider not doing that by default\n        EntityList relatedList = findRelated(relationshipName, null, null, false, true);\n        for (EntityValue relatedValue : relatedList) relatedValue.delete();\n    }\n\n    @Override\n    public boolean deleteWithRelated(Set<String> relationshipsToDelete) {\n        if (relationshipsToDelete == null) relationshipsToDelete = new HashSet<>();\n        ArrayList<EntityJavaUtil.RelationshipInfo> relInfoList = getEntityDefinition().getRelationshipsInfo(false);\n        int relInfoListSize = relInfoList.size();\n\n        // look for related records that exist and that we won't delete, if any return true\n        boolean foundNonDeleteRelated = false;\n        for (int i = 0; i < relInfoListSize; i++) {\n            EntityJavaUtil.RelationshipInfo relInfo = relInfoList.get(i);\n            if (relInfo.isTypeOne) continue;\n            if (relationshipsToDelete.contains(relInfo.shortAlias) || relationshipsToDelete.contains(relInfo.relationshipName)) continue;\n\n            if (findRelatedCount(relInfo.relationshipName, false) > 0) {\n                if (logger.isInfoEnabled()) logger.info(\"Not deleting entity \" + entityName + \" value with PK \" + getPrimaryKeys() + \", found record in relationship \" + relInfo.relationshipName);\n                foundNonDeleteRelated = true;\n                break;\n            }\n        }\n        if (foundNonDeleteRelated) return false;\n\n        // delete related records to delete\n        for (String delRelName : relationshipsToDelete) deleteRelated(delRelName);\n        // delete this record\n        delete();\n        // done, successful delete\n        return true;\n    }\n\n    @Override\n    public void deleteWithCascade(Set<String> clearRefEntities, Set<String> validateAllowDeleteEntities) {\n        ArrayList<EntityJavaUtil.RelationshipInfo> relInfoList = getEntityDefinition().getRelationshipsInfo(false);\n        int relInfoListSize = relInfoList.size();\n        for (int i = 0; i < relInfoListSize; i++) {\n            // find relationships with a type one reverse (relationships for records that depend on this)\n            EntityJavaUtil.RelationshipInfo relInfo = relInfoList.get(i);\n            EntityJavaUtil.RelationshipInfo reverseInfo = relInfo.findReverse();\n            if (reverseInfo == null || !reverseInfo.isTypeOne) continue;\n            // see if we should clear ref fields or delete\n            EntityDefinition relEd = relInfo.relatedEd;\n            boolean clearRef = clearRefEntities != null && (clearRefEntities.contains(relEd.fullEntityName) ||\n                    clearRefEntities.contains(relEd.getShortAlias()) || clearRefEntities.contains(relEd.getEntityName()));\n            // find records\n            EntityList relList = findRelated(relInfo, null, null, null, null);\n            int relListSize = relList.size();\n            for (int j = 0; j < relListSize; j++) {\n                EntityValue relVal = relList.get(j);\n                if (clearRef) {\n                    for (String fieldName : reverseInfo.keyMap.keySet()) {\n                        if (relEd.isPkField(fieldName)) throw new EntityException(\"In deleteWithCascade on entity \" + resolveEntityName() + \" related entity \" + relEd.fullEntityName + \" is in the clear ref set but field \" + fieldName + \" is a primary key field and cannot be cleared\");\n                        relVal.set(fieldName, null);\n                    }\n                    relVal.update();\n                } else {\n                    // if we should validate entities we are attempting to delete do that now\n                    if (validateAllowDeleteEntities != null && !validateAllowDeleteEntities.contains(relEd.fullEntityName))\n                        throw new EntityException(\"Cannot delete \" + resolveEntityNamePretty() + \" \" + getPrimaryKeys() + \", found \" + relVal.resolveEntityNamePretty() + \" \" + relVal.getPrimaryKeys() + \" that depends on it\");\n                    // delete with cascade\n                    relVal.deleteWithCascade(clearRefEntities, validateAllowDeleteEntities);\n                }\n            }\n        }\n        // delete this record\n        delete();\n    }\n\n    @Override\n    public boolean checkFks(boolean insertDummy) {\n        boolean noneMissing = true;\n        ExecutionContextImpl ec = getEntityFacadeImpl().ecfi.getEci();\n        for (EntityJavaUtil.RelationshipInfo relInfo : getEntityDefinition().getRelationshipsInfo(false)) {\n            if (!\"one\".equals(relInfo.type)) continue;\n\n            EntityValue value = findRelatedOne(relInfo, false, false);\n            // if (resolveEntityName().contains(\"foo\")) logger.info(\"Checking fk \" + resolveEntityName() + ':' + relInfo.relationshipName + \" value: \" + value);\n            if (value == null) {\n                if (insertDummy) {\n                    noneMissing = false;\n                    EntityValue newValue = relInfo.relatedEd.makeEntityValue();\n                    if (relInfo.relatedEd.entityInfo.hasFieldDefaults && newValue instanceof EntityValueBase)\n                        ((EntityValueBase) newValue).checkSetFieldDefaults(relInfo.relatedEd, ec, null);\n                    Map<String, String> keyMap = relInfo.keyMap;\n                    if (keyMap == null || keyMap.isEmpty()) throw new EntityException(\"Relationship \" + relInfo.relationshipName + \" in entity \" + entityName + \" has no key-map sub-elements and no default values\");\n\n                    // make a Map where the key is the related entity's field name, and the value is the value from this entity\n                    for (Entry<String, String> entry : keyMap.entrySet())\n                        newValue.set(entry.getValue(), valueMapInternal.getByString(entry.getKey()));\n\n                    if (newValue.containsPrimaryKey()) {\n                        newValue.checkFks(true);\n                        newValue.create();\n                        logger.warn(\"Created dummy \" + newValue.resolveEntityName() + \" PK \" + newValue.getPrimaryKeys());\n                    }\n                } else {\n                    return false;\n                }\n            }\n        }\n        return noneMissing;\n    }\n\n    @Override\n    @SuppressWarnings(\"unchecked\")\n    public long checkAgainstDatabaseInfo(List<Map<String, Object>> diffInfoList, List<String> messages, String location) {\n        long fieldsChecked = 0;\n        try {\n            EntityValue dbValue = this.cloneValue();\n            if (!dbValue.refresh()) {\n                Map<String, Object> diffInfo = new HashMap<>();\n                diffInfo.put(\"entity\", resolveEntityName());\n                diffInfo.put(\"pk\", getPrimaryKeys());\n                diffInfo.put(\"createValues\", getValueMap());\n                diffInfo.put(\"notFound\", true);\n                diffInfo.put(\"pkComplete\", containsPrimaryKey());\n                diffInfo.put(\"location\", location);\n                diffInfoList.add(diffInfo);\n                // alternative object based, more efficient but way less convenient: diffInfoList.add(new EntityJavaUtil.EntityValueDiffInfo(resolveEntityName(), getPrimaryKeys()));\n                return 0;\n            }\n\n            for (String nonpkFieldName : this.getEntityDefinition().getNonPkFieldNames()) {\n                // skip the lastUpdatedStamp field\n                if (\"lastUpdatedStamp\".equals(nonpkFieldName)) continue;\n\n                final Object checkFieldValue = this.get(nonpkFieldName);\n                final Object dbFieldValue = dbValue.get(nonpkFieldName);\n\n                // use compareTo if available, generally more lenient (for BigDecimal ignores scale, etc)\n                if (checkFieldValue != null) {\n                    boolean areSame = true;\n                    if (checkFieldValue instanceof Comparable && dbFieldValue != null) {\n                        Comparable cfComp = (Comparable) checkFieldValue;\n                        if (cfComp.compareTo(dbFieldValue) != 0) areSame = false;\n                    } else {\n                        if (!checkFieldValue.equals(dbFieldValue)) areSame = false;\n                    }\n                    if (!areSame) {\n                        Map<String, Object> diffInfo = new HashMap<>();\n                        diffInfo.put(\"entity\", resolveEntityName());\n                        diffInfo.put(\"pk\", getPrimaryKeys());\n                        diffInfo.put(\"field\", nonpkFieldName);\n                        diffInfo.put(\"value\", checkFieldValue);\n                        diffInfo.put(\"dbValue\", dbFieldValue);\n                        diffInfo.put(\"notFound\", false);\n                        diffInfo.put(\"pkComplete\", containsPrimaryKey());\n                        diffInfo.put(\"location\", location);\n                        diffInfoList.add(diffInfo);\n                        // alternative object based, more efficient but way less convenient: diffInfoList.add(new EntityJavaUtil.EntityValueDiffInfo(resolveEntityName(), getPrimaryKeys(), nonpkFieldName, checkFieldValue, dbFieldValue));\n                    }\n                }\n                fieldsChecked++;\n            }\n        } catch (EntityException e) {\n            throw e;\n        } catch (Throwable t) {\n            String errMsg = \"Error checking entity \" + resolveEntityName() + \" with pk \" + getPrimaryKeys() + \": \" + t.toString();\n            if (messages != null) messages.add(errMsg);\n            logger.error(errMsg, t);\n        }\n\n        return fieldsChecked;\n    }\n    @Override\n    @SuppressWarnings(\"unchecked\")\n    public long checkAgainstDatabase(List<String> messages) {\n        long fieldsChecked = 0;\n        try {\n            EntityValue dbValue = this.cloneValue();\n            if (!dbValue.refresh()) {\n                messages.add(\"Entity \" + resolveEntityName() + \" record not found for primary key \" + getPrimaryKeys());\n                return 0;\n            }\n\n            for (String nonpkFieldName : this.getEntityDefinition().getNonPkFieldNames()) {\n                // skip the lastUpdatedStamp field\n                if (\"lastUpdatedStamp\".equals(nonpkFieldName)) continue;\n\n                final Object checkFieldValue = this.get(nonpkFieldName);\n                final Object dbFieldValue = dbValue.get(nonpkFieldName);\n\n                // use compareTo if available, generally more lenient (for BigDecimal ignores scale, etc)\n                if (checkFieldValue != null) {\n                    boolean areSame = true;\n                    if (checkFieldValue instanceof Comparable && dbFieldValue != null) {\n                        Comparable cfComp = (Comparable) checkFieldValue;\n                        if (cfComp.compareTo(dbFieldValue) != 0) areSame = false;\n                    } else {\n                        if (!checkFieldValue.equals(dbFieldValue)) areSame = false;\n                    }\n                    if (!areSame) messages.add(\"Field \" + resolveEntityName() + \".\" + nonpkFieldName + \" did not match; check (file) value [\" + checkFieldValue + \"], db value [\" + dbFieldValue + \"] for primary key \" + getPrimaryKeys());\n                }\n                fieldsChecked++;\n            }\n        } catch (EntityException e) {\n            throw e;\n        } catch (Throwable t) {\n            String errMsg = \"Error checking entity \" + resolveEntityName() + \" with pk \" + getPrimaryKeys() + \": \" + t.toString();\n            messages.add(errMsg);\n            logger.error(errMsg, t);\n        }\n\n        return fieldsChecked;\n    }\n\n    @Override\n    public Element makeXmlElement(Document document, String prefix) {\n        if (prefix == null) prefix = \"\";\n        Element element = null;\n        if (document != null) element = document.createElement(prefix + entityName);\n        if (element == null) return null;\n\n        for (String fieldName : getEntityDefinition().getAllFieldNames()) {\n            String value = getString(fieldName);\n            if (value != null && !value.isEmpty()) {\n                if (value.contains(\"\\n\") || value.contains(\"\\r\")) {\n                    Element childElement = document.createElement(fieldName);\n                    element.appendChild(childElement);\n                    childElement.appendChild(document.createCDATASection(value));\n                } else {\n                    element.setAttribute(fieldName, value);\n                }\n            }\n        }\n        return element;\n    }\n\n    @Override\n    public int writeXmlText(Writer pw, String prefix, int dependentLevels) {\n        Map<String, Object> plainMap = getPlainValueMap(dependentLevels);\n        EntityDefinition ed = getEntityDefinition();\n        try {\n            return plainMapXmlWriter(pw, prefix, ed.getShortOrFullEntityName(), plainMap, 1);\n        } catch (Exception e) {\n            throw new EntityException(\"Error writing XML test for entity \" + entityName + \" dependent levels \" + dependentLevels);\n        }\n    }\n\n    @Override\n    public int writeXmlTextMaster(Writer pw, String prefix, String masterName) {\n        Map<String, Object> plainMap = getMasterValueMap(masterName);\n        EntityDefinition ed = getEntityDefinition();\n        try {\n            return plainMapXmlWriter(pw, prefix, ed.getShortOrFullEntityName(), plainMap, 1);\n        } catch (Exception e) {\n            throw new EntityException(\"Error writing XML test for entity \" + entityName + \" master \" + masterName);\n        }\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private static int plainMapXmlWriter(Writer pw, String prefix, String objectName, Map<String, Object> plainMap, int level) throws IOException, SerialException {\n        if (prefix == null) prefix = \"\";\n        // if a CDATA element is needed for a field it goes in this Map to be added at the end\n        Map<String, String> cdataMap = new LinkedHashMap<>();\n        Map<String, Object> subPlainMap = new LinkedHashMap<>();\n        String curEntity = objectName != null && objectName.length() > 0 ? objectName : (String) plainMap.get(\"_entity\");\n\n        for (int i = 0; i < level; i++) pw.append(indentString);\n        // mostly for relationship names, see opposite code in the EntityDataLoaderImpl.startElement\n        if (curEntity.contains(\"#\")) curEntity = curEntity.replace(\"#\", \"-\");\n        pw.append(\"<\").append(prefix).append(curEntity);\n\n        int valueCount = 1;\n        for (Entry<String, Object> entry : plainMap.entrySet()) {\n            String fieldName = entry.getKey();\n            // leave this out, not needed for XML where the element name represents the entity or relationship\n            if (\"_entity\".equals(fieldName)) continue;\n            Object fieldValue = entry.getValue();\n\n            if (fieldValue instanceof Map || fieldValue instanceof List) {\n                subPlainMap.put(fieldName, fieldValue);\n                continue;\n            } else if (fieldValue instanceof byte[]) {\n                cdataMap.put(fieldName, Base64.getEncoder().encodeToString((byte[]) fieldValue));\n                continue;\n            } else if (fieldValue instanceof SerialBlob) {\n                if (((SerialBlob) fieldValue).length() == 0) continue;\n                byte[] objBytes = ((SerialBlob) fieldValue).getBytes(1, (int) ((SerialBlob) fieldValue).length());\n                cdataMap.put(fieldName, Base64.getEncoder().encodeToString(objBytes));\n                continue;\n            }\n\n            String valueStr = ObjectUtilities.toPlainString(fieldValue);\n            if (valueStr == null || valueStr.isEmpty()) continue;\n            if (valueStr.contains(\"\\n\") || valueStr.contains(\"\\r\") || valueStr.length() > 255) {\n                cdataMap.put(fieldName, valueStr);\n                continue;\n            }\n\n            pw.append(\" \").append(fieldName).append(\"=\\\"\");\n            pw.append(StringUtilities.encodeForXmlAttribute(valueStr)).append(\"\\\"\");\n        }\n\n\n        if (cdataMap.size() == 0 && subPlainMap.size() == 0) {\n            // self-close the entity element\n            pw.append(\"/>\\n\");\n        } else {\n            pw.append(\">\\n\");\n\n            // CDATA sub-elements\n            for (Entry<String, String> entry : cdataMap.entrySet()) {\n                pw.append(indentString).append(indentString);\n                pw.append(\"<\").append(entry.getKey()).append(\">\");\n                pw.append(\"<![CDATA[\").append(entry.getValue()).append(\"]]>\");\n                pw.append(\"</\").append(entry.getKey()).append(\">\\n\");\n            }\n\n            // related/dependent sub-elements\n            for (Entry<String, Object> entry : subPlainMap.entrySet()) {\n                final String entryKey = entry.getKey();\n                Object entryVal = entry.getValue();\n                if (entryVal instanceof List) {\n                    for (Object listEntry : (List) entryVal) {\n                        if (listEntry instanceof Map) {\n                            valueCount += plainMapXmlWriter(pw, prefix, entryKey, (Map) listEntry, level + 1);\n                        } else {\n                            logger.warn(\"In entity auto create for entity \" + curEntity + \" found list for sub-object \" + entryKey + \" with a non-Map entry: \" + String.valueOf(listEntry));\n                        }\n                    }\n                } else if (entryVal instanceof Map) {\n                    valueCount += plainMapXmlWriter(pw, prefix, entryKey, (Map) entryVal, level + 1);\n                }\n            }\n\n            // close the entity element\n            for (int i = 0; i < level; i++) pw.append(indentString);\n            pw.append(\"</\").append(curEntity).append(\">\\n\");\n        }\n\n        return valueCount;\n    }\n\n    @Override\n    public Map<String, Object> getPlainValueMap(int dependentLevels) {\n        return internalPlainValueMap(dependentLevels, null);\n    }\n\n    private Map<String, Object> internalPlainValueMap(int dependentLevels, Set<String> parentPkFields) {\n        Map<String, Object> vMap = new HashMap<>(valueMapInternal);\n        CollectionUtilities.removeNullsFromMap(vMap);\n        if (parentPkFields != null) for (String pkField : parentPkFields) vMap.remove(pkField);\n        EntityDefinition ed = getEntityDefinition();\n        vMap.put(\"_entity\", ed.getShortOrFullEntityName());\n\n        if (dependentLevels > 0) {\n            Set<String> curPkFields = new HashSet<>(ed.getPkFieldNames());\n            // keep track of all parent PK field names, even not part of this entity's PK, they will be inherited when read\n            if (parentPkFields != null) curPkFields.addAll(parentPkFields);\n\n            List<EntityJavaUtil.RelationshipInfo> relInfoList = getEntityDefinition().getRelationshipsInfo(true);\n            for (EntityJavaUtil.RelationshipInfo relInfo : relInfoList) {\n                String relationshipName = relInfo.relationshipName;\n                final String alias = relInfo.shortAlias;\n                String entryName = alias != null && !alias.isEmpty() ? alias : relationshipName;\n                if (relInfo.isTypeOne) {\n                    EntityValue relEv = findRelatedOne(relationshipName, null, false);\n                    if (relEv != null)\n                        vMap.put(entryName, ((EntityValueBase) relEv).internalPlainValueMap(dependentLevels - 1, curPkFields));\n                } else {\n                    EntityList relList = findRelated(relationshipName, null, null, null, false);\n                    if (relList != null && !relList.isEmpty()) {\n                        List<Map> plainRelList = new ArrayList<>();\n                        for (EntityValue relEv : relList) {\n                            plainRelList.add(((EntityValueBase) relEv).internalPlainValueMap(dependentLevels - 1, curPkFields));\n                        }\n                        vMap.put(entryName, plainRelList);\n                    }\n                }\n            }\n        }\n\n        return vMap;\n    }\n\n    @Override\n    public Map<String, Object> getMasterValueMap(final String name) {\n        EntityDefinition.MasterDefinition masterDefinition = getEntityDefinition().getMasterDefinition(name);\n        if (masterDefinition == null)\n            throw new EntityException(\"No master definition found for name [\" + name + \"] in entity [\" + entityName + \"]\");\n        return internalMasterValueMap(masterDefinition.getDetailList(), null, null);\n    }\n\n    private Map<String, Object> internalMasterValueMap(ArrayList<EntityDefinition.MasterDetail> detailList, Set<String> parentPkFields, EntityJavaUtil.RelationshipInfo parentRelInfo) {\n        Map<String, Object> vMap = new HashMap<>(valueMapInternal);\n        CollectionUtilities.removeNullsFromMap(vMap);\n        if (parentPkFields != null) {\n            if (parentRelInfo != null) {\n                // handle cases like the Product toAssocs relationship where ProductAssoc.productId != Product.productId, needs to look at relationship field map\n                for (String pkField : parentPkFields) {\n                    String relatedName = parentRelInfo.keyMap.get(pkField);\n                    if (pkField.equals(relatedName)) vMap.remove(pkField);\n                }\n            } else {\n                for (String pkField : parentPkFields) vMap.remove(pkField);\n            }\n        }\n        EntityDefinition ed = getEntityDefinition();\n        vMap.put(\"_entity\", ed.getShortOrFullEntityName());\n\n        if (detailList != null && !detailList.isEmpty()) {\n            Set<String> curPkFields = new HashSet<>(ed.getPkFieldNames());\n            // keep track of all parent PK field names, even not part of this entity's PK, they will be inherited when read\n            if (parentPkFields != null) curPkFields.addAll(parentPkFields);\n\n            int detailListSize = detailList.size();\n            for (int i = 0; i < detailListSize; i++) {\n                EntityDefinition.MasterDetail detail = detailList.get(i);\n\n                EntityJavaUtil.RelationshipInfo relInfo = detail.getRelInfo();\n                String relationshipName = relInfo.relationshipName;\n                final String relAlias = relInfo.shortAlias;\n                String entryName = relAlias != null && !relAlias.isEmpty() ? relAlias : relationshipName;\n                if (relInfo.isTypeOne) {\n                    EntityValue relEv = findRelatedOne(relationshipName, null, false);\n                    if (relEv != null) vMap.put(entryName, ((EntityValueBase) relEv).internalMasterValueMap(detail.getDetailList(), curPkFields, relInfo));\n                } else {\n                    EntityList relList = findRelated(relationshipName, null, null, null, false);\n                    if (relList != null && !relList.isEmpty()) {\n                        List<Map> plainRelList = new ArrayList<>();\n                        int relListSize = relList.size();\n                        for (int rlIndex = 0; rlIndex < relListSize; rlIndex++) {\n                            EntityValue relEv = relList.get(rlIndex);\n                            plainRelList.add(((EntityValueBase) relEv).internalMasterValueMap(detail.getDetailList(), curPkFields, relInfo));\n                        }\n                        vMap.put(entryName, plainRelList);\n                    }\n                }\n            }\n        }\n\n        return vMap;\n    }\n\n    @Override public int size() { return valueMapInternal.size(); }\n    @Override public boolean isEmpty() { return valueMapInternal.isEmpty(); }\n    @Override public boolean containsKey(Object o) { return valueMapInternal.containsKey(o); }\n    @Override public boolean containsValue(Object o) { return values().contains(o); }\n    @Override public Object get(Object o) {\n        if (o instanceof CharSequence) {\n            // This may throw an exception, and let it; the Map interface doesn't provide for EntityException\n            //   but it is far more useful than a log message that is likely to be ignored.\n            return this.get(o.toString());\n        } else {\n            return null;\n        }\n    }\n    @Override public Object put(final String name, Object value) {\n        FieldInfo fieldInfo = getEntityDefinition().getFieldInfo(name);\n        if (fieldInfo == null) throw new EntityException(\"The field name \" + name + \" is not valid for entity \" + entityName);\n        return putKnownField(fieldInfo, value);\n    }\n    public Object putNoCheck(final String name, Object value) {\n        // NOTE: for performance with LiteStringMap this is no longer useful, and invalid field names not allowed, so just use put()\n        FieldInfo fieldInfo = getEntityDefinition().getFieldInfo(name);\n        if (fieldInfo == null) throw new EntityException(\"The field name \" + name + \" is not valid for entity \" + entityName);\n        return putKnownField(fieldInfo, value);\n    }\n    protected Object putKnownField(final FieldInfo fieldInfo, Object value) {\n        if (!mutable) throw new EntityException(\"Cannot set field \" + fieldInfo.name + \", this entity value is not mutable (it is read-only)\");\n        Object curValue = null;\n        if (isFromDb) {\n            curValue = valueMapInternal.getByIString(fieldInfo.name, fieldInfo.index);\n            if (curValue == null) {\n                if (value != null) modified = true;\n            } else {\n                if (!curValue.equals(value)) {\n                    modified = true;\n                    if (dbValueMap == null) dbValueMap = new LiteStringMap<>(getEntityDefinition().allFieldNameList.size()).useManualIndex();\n                    dbValueMap.putByIString(fieldInfo.name, curValue, fieldInfo.index);\n                }\n            }\n        } else {\n            modified = true;\n        }\n\n        valueMapInternal.putByIString(fieldInfo.name, value, fieldInfo.index);\n        return curValue;\n    }\n\n    @Override public Object remove(Object o) {\n        if (o instanceof CharSequence) {\n            String name = o.toString();\n            if (valueMapInternal.containsKey(name)) modified = true;\n            return valueMapInternal.remove(name);\n        } else {\n            return null;\n        }\n    }\n\n    @Override public void putAll(Map<? extends String, ?> map) {\n        for (Entry entry : map.entrySet()) {\n            String key = (String) entry.getKey();\n            if (key == null) continue;\n            put(key, entry.getValue());\n        }\n    }\n\n    @Override public void clear() { modified = true; valueMapInternal.clear(); }\n    @Override public @Nonnull Set<String> keySet() { return new HashSet<>(getEntityDefinition().getAllFieldNames()); }\n    @Override public @Nonnull Collection<Object> values() {\n        // everything needs to go through the get method, so iterate through the fields and get the values\n        List<String> allFieldNames = getEntityDefinition().getAllFieldNames();\n        List<Object> values = new ArrayList<>(allFieldNames.size());\n        for (String fieldName : allFieldNames) values.add(get(fieldName));\n        return values;\n    }\n\n    @Override public @Nonnull Set<Entry<String, Object>> entrySet() {\n        // everything needs to go through the get method, so iterate through the fields and get the values\n        FieldInfo[] allFieldInfos = getEntityDefinition().entityInfo.allFieldInfoArray;\n        Set<Entry<String, Object>> entries = new HashSet<>();\n        int allFieldInfosSize = allFieldInfos.length;\n        for (int i = 0; i < allFieldInfosSize; i++) {\n            FieldInfo fi = allFieldInfos[i];\n            entries.add(new EntityFieldEntry(fi, this));\n        }\n        return entries;\n    }\n\n    @Override public boolean equals(Object obj) {\n        if (obj == null || !obj.getClass().equals(this.getClass())) return false;\n        // reuse the compare method\n        return this.compareTo((EntityValue) obj) == 0;\n    }\n\n    // NOTE: consider caching the hash code in the future for performance\n    @Override public int hashCode() { return entityName.hashCode() + valueMapInternal.hashCode(); }\n    @Override public String toString() { return \"[\" + entityName + \": \" + valueMapInternal.toString() + \"]\"; }\n    @Override public Object clone() { return cloneValue(); }\n    @Override public abstract EntityValue cloneValue();\n    public abstract EntityValue cloneDbValue(boolean getOld);\n\n    private boolean doDataFeed(ExecutionContextImpl ec) {\n        if (ec.artifactExecutionFacade.entityDataFeedDisabled()) return false;\n        // skip ArtifactHitBin, causes funny recursion\n        return !\"moqui.server.ArtifactHitBin\".equals(entityName);\n    }\n\n    private void checkSetFieldDefaults(EntityDefinition ed, ExecutionContext ec, Boolean pks) {\n        // allow updating a record without specifying default PK fields, so don't check this: if (isCreate) {\n        Map<String, String> pkDefaults = ed.entityInfo.pkFieldDefaults;\n        if ((pks == null || pks) && pkDefaults != null && pkDefaults.size() > 0) for (Entry<String, String> entry : pkDefaults.entrySet())\n            checkSetDefault(entry.getKey(), entry.getValue(), ec);\n        Map<String, String> nonPkDefaults = ed.entityInfo.nonPkFieldDefaults;\n        if ((pks == null || !pks) && nonPkDefaults != null && nonPkDefaults.size() > 0)\n            for (Entry<String, String> entry : nonPkDefaults.entrySet())\n                checkSetDefault(entry.getKey(), entry.getValue(), ec);\n    }\n\n    private void checkSetDefault(String fieldName, String defaultStr, ExecutionContext ec) {\n        FieldInfo fi = getEntityDefinition().getFieldInfo(fieldName);\n        Object curVal = null;\n        if (valueMapInternal.containsKeyIString(fi.name, fi.index)) {\n            curVal = valueMapInternal.getByIString(fi.name, fi.index);\n        } else if (dbValueMap != null) {\n            curVal = dbValueMap.getByIString(fi.name, fi.index);\n        }\n\n        if (ObjectUtilities.isEmpty(curVal)) {\n            if (dbValueMap != null) ec.getContext().push(dbValueMap);\n            ec.getContext().push(valueMapInternal);\n            try {\n                Object newVal = ec.getResource().expression(defaultStr, \"\");\n                if (newVal != null) valueMapInternal.putByIString(fi.name, newVal, fi.index);\n            } finally {\n                ec.getContext().pop();\n                if (dbValueMap != null) ec.getContext().pop();\n            }\n        }\n    }\n\n    private String makeErrorMsg(String baseMsg, String expandMsg, EntityDefinition ed, ExecutionContextImpl ec) {\n        Map<String, Object> errorContext = new HashMap<>();\n        errorContext.put(\"entityName\", ed.getEntityName()); errorContext.put(\"primaryKeys\", getPrimaryKeys());\n        String errorMessage = null;\n        // TODO: need a different approach for localization, getting from DB may not be reliable after an error and may cause other errors (especially with Postgres and the auto rollback only)\n        if (false && !\"LocalizedMessage\".equals(ed.getEntityName())) {\n            try { errorMessage = ec.resourceFacade.expand(expandMsg, null, errorContext); }\n            catch (Throwable t) { logger.trace(\"Error expanding error message\", t); }\n        }\n        if (errorMessage == null) errorMessage = baseMsg + \" \" + ed.getEntityName() + \" \" + getPrimaryKeys();\n        return errorMessage;\n    }\n\n    private void registerMutateLock() {\n        final EntityFacadeImpl efi = getEntityFacadeImpl();\n        final TransactionFacadeImpl tfi = efi.ecfi.transactionFacade;\n        if (!tfi.getUseLockTrack()) return;\n\n        final EntityDefinition ed = getEntityDefinition();\n        final ArtifactExecutionFacadeImpl aefi = efi.ecfi.getEci().artifactExecutionFacade;\n\n        ArrayList<ArtifactExecutionInfo> stackArray = aefi.getStackArray();\n\n        // add EntityRecordLock for this record\n        tfi.registerRecordLock(new EntityRecordLock(ed.getFullEntityName(), this.getPrimaryKeysString(), stackArray));\n\n        // add EntityRecordLock for each type one (with FK) relationship where FK fields not null\n        ArrayList<EntityJavaUtil.RelationshipInfo> relInfoList = ed.getRelationshipsInfo(false);\n        int relInfoListSize = relInfoList.size();\n        for (int ri = 0; ri < relInfoListSize; ri++) {\n            EntityJavaUtil.RelationshipInfo relInfo = relInfoList.get(ri);\n            if (!relInfo.isFk) continue;\n\n            String pkString = null;\n            int keyFieldSize = relInfo.keyFieldList.size();\n            if (keyFieldSize == 1) {\n                String keyFieldName = relInfo.keyFieldList.get(0);\n                FieldInfo fieldInfo = ed.getFieldInfo(keyFieldName);\n                Object keyValue = this.getKnownField(fieldInfo);\n                if (keyValue != null) pkString = ObjectUtilities.toPlainString(keyValue);\n            } else {\n                boolean hasAllValues = true;\n                Map<String, Object> relFieldValues = new HashMap<>();\n                for (int ki = 0; ki < keyFieldSize; ki++) {\n                    String keyFieldName = relInfo.keyFieldList.get(ki);\n                    FieldInfo fieldInfo = ed.getFieldInfo(keyFieldName);\n                    Object keyValue = this.getKnownField(fieldInfo);\n                    if (keyValue == null) {\n                        hasAllValues = false;\n                        break;\n                    } else {\n                        // use relInfo.keyMap to get the field name of the PK field on the related entity\n                        relFieldValues.put(relInfo.keyMap.get(keyFieldName), keyValue);\n                    }\n                }\n                if (hasAllValues) pkString = relInfo.relatedEd.getPrimaryKeysString(relFieldValues);\n            }\n\n            if (pkString != null) {\n                tfi.registerRecordLock(new EntityRecordLock(relInfo.relatedEd.getFullEntityName(), pkString, stackArray));\n            }\n        }\n    }\n\n    @Override\n    public EntityValue create() {\n        final EntityDefinition ed = getEntityDefinition();\n        final EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo;\n        final EntityFacadeImpl efi = getEntityFacadeImpl();\n        final ExecutionContextFactoryImpl ecfi = efi.ecfi;\n        final ExecutionContextImpl ec = ecfi.getEci();\n        final ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade;\n\n        // check/set defaults\n        if (entityInfo.hasFieldDefaults) checkSetFieldDefaults(ed, ec, null);\n\n        // set lastUpdatedStamp\n        final Long time = ecfi.transactionFacade.getCurrentTransactionStartTime();\n        Long lastUpdatedLong = time != null && time > 0 ? time : System.currentTimeMillis();\n        FieldInfo lastUpdatedStampInfo = ed.entityInfo.lastUpdatedStampInfo;\n        if (lastUpdatedStampInfo != null && valueMapInternal.getByIString(lastUpdatedStampInfo.name, lastUpdatedStampInfo.index) == null)\n            valueMapInternal.putByIString(lastUpdatedStampInfo.name, new Timestamp(lastUpdatedLong), lastUpdatedStampInfo.index);\n\n        // do the artifact push/authz\n        ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(entityName, ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_CREATE, \"create\").setParameters(valueMapInternal);\n        aefi.pushInternal(aei, !entityInfo.authorizeSkipCreate, false);\n\n        try {\n            // run EECA before rules\n            efi.runEecaRules(entityName, this, \"create\", true);\n\n            // do this before the db change so modified flag isn't cleared\n            if (doDataFeed(ec)) efi.getEntityDataFeed().dataFeedCheckAndRegister(this, false, valueMapInternal, null);\n\n            // if there is not a txCache or the txCache doesn't handle the create, call the abstract method to create the main record\n            TransactionCache curTxCache = getTxCache(ecfi);\n            if (curTxCache == null || !curTxCache.create(this)) {\n                // NOTE: calls basicCreate() instead of createExtended() directly so don't register lock here\n\n                this.basicCreate(null);\n            }\n\n            // NOTE: cache clear is the same for create, update, delete; even on create need to clear one cache because it\n            // might have a null value for a previous query attempt\n            efi.getEntityCache().clearCacheForValue(this, true);\n            // save audit log(s) if applicable\n            handleAuditLog(false, null, ed, ec);\n            // run EECA after rules\n            efi.runEecaRules(entityName, this, \"create\", false);\n        } catch (SQLException e) {\n            throw new EntitySqlException(makeErrorMsg(\"Error creating\", CREATE_ERROR, ed, ec), e);\n        } catch (Exception e) {\n            throw new EntityException(makeErrorMsg(\"Error creating\", CREATE_ERROR, ed, ec), e);\n        } finally {\n            // pop the ArtifactExecutionInfo to clean it up, also counts artifact hit\n            aefi.pop(aei);\n        }\n\n        return this;\n    }\n\n    public void basicCreate(Connection con) throws SQLException {\n        EntityDefinition ed = getEntityDefinition();\n        FieldInfo[] allFieldArray = ed.entityInfo.allFieldInfoArray;\n        FieldInfo[] fieldArray = new FieldInfo[allFieldArray.length];\n        int size = allFieldArray.length;\n        int fieldArrayIndex = 0;\n        for (int i = 0; i < size; i++) {\n            FieldInfo fi = allFieldArray[i];\n            if (valueMapInternal.containsKeyIString(fi.name, fi.index)) {\n                fieldArray[fieldArrayIndex] = fi;\n                fieldArrayIndex++;\n            }\n        }\n\n        // if enabled register locks before operation\n        registerMutateLock();\n\n        createExtended(fieldArray, con);\n    }\n\n    /**\n     * This method should create a corresponding record in the datasource. NOTE: fieldInfoArray may have null values\n     * after valid ones, the length is not the actual number of fields.\n     */\n    public abstract void createExtended(FieldInfo[] fieldInfoArray, Connection con) throws SQLException;\n\n    @Override\n    public EntityValue update() {\n        final EntityDefinition ed = getEntityDefinition();\n        final EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo;\n        final EntityFacadeImpl efi = getEntityFacadeImpl();\n        final ExecutionContextFactoryImpl ecfi = efi.ecfi;\n        final ExecutionContextImpl ec = ecfi.getEci();\n        final ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade;\n        final TransactionCache curTxCache = getTxCache(ecfi);\n        final boolean optimisticLock = entityInfo.optimisticLock;\n        final boolean hasFieldDefaults = entityInfo.hasFieldDefaults;\n        final boolean needsAuditLog = entityInfo.needsAuditLog;\n        final boolean createOnlyAny = entityInfo.createOnly || entityInfo.createOnlyFields;\n\n        // check/set defaults for pk fields, do this first to fill in optional pk fields\n        if (hasFieldDefaults) checkSetFieldDefaults(ed, ec, true);\n\n        // if there is one or more DataFeed configs associated with this entity get info about them\n        boolean curDataFeed = doDataFeed(ec);\n        if (curDataFeed) {\n            ArrayList<EntityDataFeed.DocumentEntityInfo> entityInfoList = efi.getEntityDataFeed().getDataFeedEntityInfoList(entityName);\n            if (entityInfoList.size() == 0) curDataFeed = false;\n        }\n\n        // need actual DB values for various scenarios? get them here\n        if (needsAuditLog || createOnlyAny || curDataFeed || optimisticLock || hasFieldDefaults) {\n            EntityValueBase refreshedValue = (EntityValueBase) this.cloneValue();\n            refreshedValue.refresh();\n            this.setDbValueMap(refreshedValue.getValueMap());\n        }\n\n        // check/set defaults for non-pk fields, after getting dbValueMap\n        if (hasFieldDefaults) checkSetFieldDefaults(ed, ec, false);\n\n        // Save original values before anything is changed for DataFeed and audit log\n        LiteStringMap<Object> originalValues = dbValueMap != null && !dbValueMap.isEmpty() ? new LiteStringMap<>(dbValueMap).useManualIndex() : null;\n\n        // do the artifact push/authz\n        ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(entityName, ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_UPDATE, \"update\").setParameters(valueMapInternal);\n        aefi.pushInternal(aei, !entityInfo.authorizeSkipTrue, false);\n\n        try {\n            // run EECA before rules\n            efi.runEecaRules(entityName, this, \"update\", true);\n\n            FieldInfo[] pkFieldArray = entityInfo.pkFieldInfoArray;\n            FieldInfo[] allNonPkFieldArray = entityInfo.nonPkFieldInfoArray;\n            FieldInfo[] nonPkFieldArray = new FieldInfo[allNonPkFieldArray.length];\n            ArrayList<String> changedCreateOnlyFields = null;\n            boolean modifiedLastUpdatedStamp = false;\n            int size = allNonPkFieldArray.length;\n            int nonPkFieldArrayIndex = 0;\n            for (int i = 0; i < size; i++) {\n                FieldInfo fieldInfo = allNonPkFieldArray[i];\n                if (isFieldModifiedIString(fieldInfo.name)) {\n                    if (fieldInfo.isLastUpdatedStamp) {\n                        // more stringent is modified check for lastUpdatedStamp\n                        if (dbValueMap == null || dbValueMap.getByIString(fieldInfo.name, fieldInfo.index) == null) continue;\n                        modifiedLastUpdatedStamp = true;\n                    }\n                    nonPkFieldArray[nonPkFieldArrayIndex] = fieldInfo;\n                    nonPkFieldArrayIndex++;\n                    if (createOnlyAny && fieldInfo.createOnly) {\n                        if (changedCreateOnlyFields == null) changedCreateOnlyFields = new ArrayList<>();\n                        changedCreateOnlyFields.add(fieldInfo.name);\n                    }\n                }\n            }\n\n            // if (ed.getEntityName() == \"foo\") logger.warn(\"================ evb.update() ${resolveEntityName()} nonPkFieldList=${nonPkFieldList};\\nvalueMap=${valueMap};\\noldValues=${oldValues}\")\n            if (nonPkFieldArrayIndex == 0 || (nonPkFieldArrayIndex == 1 && modifiedLastUpdatedStamp)) {\n                if (logger.isTraceEnabled()) logger.trace(\"Not doing update on entity with no changed non-PK fields; value=\" + this.toString());\n                return this;\n            }\n\n            // do this after the empty nonPkFieldList check so that if nothing has changed then ignore the attempt to update\n            if (changedCreateOnlyFields != null && changedCreateOnlyFields.size() > 0)\n                throw new EntityException(\"Cannot update create-only (immutable) fields \" + changedCreateOnlyFields + \" on entity \" + resolveEntityName());\n\n            // check optimistic lock with lastUpdatedStamp; if optimisticLock() dbValueMap will have latest from DB\n            FieldInfo lastUpdatedStampInfo = ed.entityInfo.lastUpdatedStampInfo;\n            if (optimisticLock) {\n                Object valueLus = valueMapInternal.getByIString(lastUpdatedStampInfo.name, lastUpdatedStampInfo.index);\n                Object dbLus = dbValueMap.getByIString(lastUpdatedStampInfo.name, lastUpdatedStampInfo.index);\n                if (valueLus != null && dbLus != null && !dbLus.equals(valueLus))\n                    throw new EntityException(\"This record was updated by someone else at \" + dbLus + \" which was after the version you loaded at \" + valueLus + \". Not updating to avoid overwriting data.\");\n            }\n\n            // set lastUpdatedStamp\n            if (!modifiedLastUpdatedStamp && lastUpdatedStampInfo != null) {\n                final Long time = ecfi.transactionFacade.getCurrentTransactionStartTime();\n                long lastUpdatedLong = time != null && time > 0 ? time : System.currentTimeMillis();\n                valueMapInternal.putByIString(lastUpdatedStampInfo.name, new Timestamp(lastUpdatedLong), lastUpdatedStampInfo.index);\n                nonPkFieldArray[nonPkFieldArrayIndex] = lastUpdatedStampInfo;\n                // never gets used after this point, but if ever does will need to: nonPkFieldArrayIndex++\n            }\n\n            // do this before the db change so modified flag isn't cleared\n            if (curDataFeed) efi.getEntityDataFeed().dataFeedCheckAndRegister(this, true, valueMapInternal, originalValues);\n\n            // if there is not a txCache or the txCache doesn't handle the update, call the abstract method to update the main record\n            if (curTxCache == null || !curTxCache.update(this)) {\n                // no TX cache update, etc: ready to do actual update\n\n                // if enabled register locks before operation\n                registerMutateLock();\n\n                updateExtended(pkFieldArray, nonPkFieldArray, null);\n                // if (\"OrderHeader\".equals(ed.getEntityName()) && \"55500\".equals(valueMapInternal.get(\"orderId\"))) logger.warn(\"Called updateExtended order \" + this.valueMapInternal.toString());\n            }\n\n            // clear the entity cache\n            efi.getEntityCache().clearCacheForValue(this, false);\n            // save audit log(s) if applicable\n            if (needsAuditLog) handleAuditLog(true, originalValues, ed, ec);\n            // run EECA after rules\n            efi.runEecaRules(entityName, this, \"update\", false);\n        } catch (SQLException e) {\n            throw new EntitySqlException(makeErrorMsg(\"Error updating\", UPDATE_ERROR, ed, ec), e);\n        } catch (Exception e) {\n            throw new EntityException(makeErrorMsg(\"Error updating\", UPDATE_ERROR, ed, ec), e);\n        } finally {\n            // pop the ArtifactExecutionInfo to clean it up, also counts artifact hit\n            aefi.pop(aei);\n        }\n\n        return this;\n    }\n\n    public void basicUpdate(Connection con) throws SQLException {\n        EntityDefinition ed = getEntityDefinition();\n\n        /* Shouldn't need this any more, was from a weird old issue:\n        boolean dbValueMapFromDb = false\n        // it may be that the oldValues map is full of null values because the EntityValue didn't come from the db\n        if (dbValueMap) for (Object val in dbValueMap.values()) if (val != null) { dbValueMapFromDb = true; break }\n        */\n\n        FieldInfo[] pkFieldArray = ed.entityInfo.pkFieldInfoArray;\n        FieldInfo[] allNonPkFieldArray = ed.entityInfo.nonPkFieldInfoArray;\n        FieldInfo[] nonPkFieldArray = new FieldInfo[allNonPkFieldArray.length];\n        int size = allNonPkFieldArray.length;\n        int nonPkFieldArrayIndex = 0;\n        for (int i = 0; i < size; i++) {\n            FieldInfo fi = allNonPkFieldArray[i];\n            if (isFieldModifiedIString(fi.name)) {\n                nonPkFieldArray[nonPkFieldArrayIndex] = fi;\n                nonPkFieldArrayIndex++;\n            }\n        }\n\n        // if enabled register locks before operation\n        registerMutateLock();\n\n        updateExtended(pkFieldArray, nonPkFieldArray, con);\n    }\n\n    /**\n     * This method should update the corresponding record in the datasource. NOTE: fieldInfoArray may have null values\n     * after valid ones, the length is not the actual number of fields.\n     */\n    public abstract void updateExtended(FieldInfo[] pkFieldArray, FieldInfo[] nonPkFieldArray, Connection con) throws SQLException;\n\n    @Override\n    public EntityValue delete() {\n        final EntityDefinition ed = getEntityDefinition();\n        final EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo;\n        final EntityFacadeImpl efi = getEntityFacadeImpl();\n        final ExecutionContextFactoryImpl ecfi = efi.ecfi;\n        final ExecutionContextImpl ec = ecfi.getEci();\n        final ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade;\n\n        // NOTE: this is create-only on the entity, ignores setting on fields (only considered in update)\n        if (entityInfo.createOnly) throw new EntityException(\"Entity [\" + resolveEntityName() + \"] is create-only (immutable), cannot be deleted.\");\n\n        // do the artifact push/authz\n        ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(entityName, ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_DELETE, \"delete\").setParameters(valueMapInternal);\n        aefi.pushInternal(aei, !entityInfo.authorizeSkipTrue, false);\n\n        try {\n            // run EECA before rules\n            efi.runEecaRules(entityName, this, \"delete\", true);\n\n            // check DataDocuments to update (if not primary entity) or delete (if primary entity)\n            efi.getEntityDataFeed().dataFeedCheckDelete(this);\n\n            // if there is not a txCache or the txCache doesn't handle the delete, call the abstract method to delete the main record\n            TransactionCache curTxCache = getTxCache(ecfi);\n            if (curTxCache == null || !curTxCache.delete(this)) {\n                // if enabled register locks before operation\n                registerMutateLock();\n\n                this.deleteExtended(null);\n            }\n\n            // clear the entity cache\n            efi.getEntityCache().clearCacheForValue(this, false);\n            // run EECA after rules\n            efi.runEecaRules(entityName, this, \"delete\", false);\n        } catch (SQLException e) {\n            throw new EntitySqlException(makeErrorMsg(\"Error deleting\", DELETE_ERROR, ed, ec), e);\n        } catch (Exception e) {\n            throw new EntityException(makeErrorMsg(\"Error deleting\", DELETE_ERROR, ed, ec), e);\n        } finally {\n            // pop the ArtifactExecutionInfo to clean it up, also counts artifact hit\n            aefi.pop(aei);\n        }\n\n        return this;\n    }\n\n    public abstract void deleteExtended(Connection con) throws SQLException;\n\n    @Override\n    public boolean refresh() {\n        final EntityDefinition ed = getEntityDefinition();\n        final EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo;\n        final EntityFacadeImpl efi = getEntityFacadeImpl();\n        final ExecutionContextFactoryImpl ecfi = efi.ecfi;\n        final ExecutionContextImpl ec = ecfi.getEci();\n        final ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade;\n\n        List<String> pkFieldList = ed.getPkFieldNames();\n        if (pkFieldList.size() == 0) {\n            // throw new EntityException(\"Entity ${resolveEntityName()} has no primary key fields, cannot do refresh.\")\n            if (logger.isTraceEnabled()) logger.trace(\"Entity \" + resolveEntityName() + \" has no primary key fields, cannot do refresh.\");\n            return false;\n        }\n\n        // check/set defaults\n        if (entityInfo.hasFieldDefaults) checkSetFieldDefaults(ed, ec, null);\n\n        // do the artifact push/authz\n        ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(entityName, ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_VIEW, \"refresh\").setParameters(valueMapInternal);\n        aefi.pushInternal(aei, !ed.entityInfo.authorizeSkipView, false);\n\n        boolean retVal = false;\n        try {\n            // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(fullEntityName, this, \"find-one\", true);\n\n            // if there is not a txCache or the txCache doesn't handle the refresh, call the abstract method to refresh\n            TransactionCache curTxCache = getTxCache(ecfi);\n            if (curTxCache != null) retVal = curTxCache.refresh(this);\n            // call the abstract method\n            if (!retVal) {\n                retVal = this.refreshExtended();\n                if (retVal && curTxCache != null) curTxCache.onePut(this, false);\n            }\n\n            // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(fullEntityName, this, \"find-one\", false);\n        } catch (SQLException e) {\n            throw new EntitySqlException(makeErrorMsg(\"Error finding\", REFRESH_ERROR, ed, ec), e);\n        } catch (Exception e) {\n            throw new EntityException(makeErrorMsg(\"Error finding\", REFRESH_ERROR, ed, ec), e);\n        } finally {\n            // pop the ArtifactExecutionInfo to clean it up, also counts artifact hit\n            aefi.pop(aei);\n        }\n\n        return retVal;\n    }\n    public abstract boolean refreshExtended() throws SQLException;\n\n    @Override public String getEtlType() { return entityName; }\n    @Override public Map<String, Object> getEtlValues() { return valueMapInternal; }\n\n    private static class EntityFieldEntry implements Entry<String, Object> {\n        protected FieldInfo fi;\n        EntityValueBase evb;\n        private EntityFieldEntry(FieldInfo fi, EntityValueBase evb) {\n            this.fi = fi;\n            this.evb = evb;\n        }\n        @Override public String getKey() { return fi.name; }\n        @Override public Object getValue() { return evb.getKnownField(fi); }\n        @Override public Object setValue(Object v) { return evb.set(fi.name, v); }\n        @Override public int hashCode() {\n            Object val = getValue();\n            return fi.name.hashCode() + (val != null ? val.hashCode() : 0);\n        }\n        @Override public boolean equals(Object obj) {\n            if (!(obj instanceof EntityFieldEntry)) return false;\n            EntityFieldEntry other = (EntityFieldEntry) obj;\n            if (!fi.name.equals(other.fi.name)) return false;\n            Object thisVal = getValue();\n            Object otherVal = other.getValue();\n            return thisVal == null ? otherVal == null : thisVal.equals(otherVal);\n        }\n    }\n\n    public static class DeletedEntityValue extends EntityValueBase {\n        public DeletedEntityValue(EntityDefinition ed, EntityFacadeImpl efip) { super(ed, efip); }\n        @Override public EntityValue cloneValue() { return this; }\n        @Override public EntityValue cloneDbValue(boolean getOld) { return this; }\n        @Override public void createExtended(FieldInfo[] fieldInfoArray, Connection con) {\n            throw new UnsupportedOperationException(\"Not implemented on DeletedEntityValue\"); }\n        @Override public void updateExtended(FieldInfo[] pkFieldArray, FieldInfo[] nonPkFieldArray, Connection con) {\n            throw new UnsupportedOperationException(\"Not implemented on DeletedEntityValue\"); }\n        @Override public void deleteExtended(Connection con) { throw new UnsupportedOperationException(\"Not implemented on DeletedEntityValue\"); }\n        @Override public boolean refreshExtended() { throw new UnsupportedOperationException(\"Not implemented on DeletedEntityValue\"); }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/EntityValueImpl.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity;\n\nimport org.moqui.entity.EntityException;\nimport org.moqui.entity.EntityValue;\nimport org.moqui.impl.entity.EntityJavaUtil.EntityConditionParameter;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.sql.Connection;\nimport java.sql.ResultSet;\nimport java.sql.SQLException;\nimport java.util.ArrayList;\nimport java.util.Map;\n\npublic class EntityValueImpl extends EntityValueBase {\n    protected static final Logger logger = LoggerFactory.getLogger(EntityValueImpl.class);\n\n    /** Default constructor for deserialization ONLY. */\n    public EntityValueImpl() { }\n    /** Primary constructor, generally used only internally by EntityFacade */\n    public EntityValueImpl(EntityDefinition ed, EntityFacadeImpl efip) { super(ed, efip); }\n\n    @Override\n    public EntityValue cloneValue() {\n        EntityValueImpl newObj = new EntityValueImpl(getEntityDefinition(), getEntityFacadeImpl());\n        newObj.valueMapInternal.putAll(this.valueMapInternal);\n        if (this.dbValueMap != null) newObj.setDbValueMap(this.dbValueMap);\n        // don't set immutable (default to mutable even if original was not) or modified (start out not modified)\n        return newObj;\n    }\n\n    @Override\n    public EntityValue cloneDbValue(boolean getOld) {\n        EntityValueImpl newObj = new EntityValueImpl(getEntityDefinition(), getEntityFacadeImpl());\n        newObj.valueMapInternal.putAll(this.valueMapInternal);\n        for (FieldInfo fieldInfo : getEntityDefinition().entityInfo.allFieldInfoArray)\n            newObj.putKnownField(fieldInfo, getOld ? getOldDbValue(fieldInfo.name) : getOriginalDbValue(fieldInfo.name));\n        newObj.setSyncedWithDb();\n        return newObj;\n    }\n\n    @SuppressWarnings(\"MismatchedQueryAndUpdateOfStringBuilder\")\n    @Override\n    public void createExtended(FieldInfo[] fieldInfoArray, Connection con) throws SQLException {\n        EntityDefinition ed = getEntityDefinition();\n        EntityFacadeImpl efi = getEntityFacadeImpl();\n        if (ed.isViewEntity) throw new EntityException(\"Create not yet implemented for view-entity\");\n\n        EntityQueryBuilder eqb = new EntityQueryBuilder(ed, efi);\n        StringBuilder sql = eqb.sqlTopLevel;\n        sql.append(\"INSERT INTO \").append(ed.getFullTableName()).append(\" (\");\n\n        int size = fieldInfoArray.length;\n        StringBuilder values = new StringBuilder(size*3);\n\n        for (int i = 0; i < size; i++) {\n            FieldInfo fieldInfo = fieldInfoArray[i];\n            if (fieldInfo == null) break;\n            if (i > 0) {\n                sql.append(\", \");\n                values.append(\", \");\n            }\n\n            sql.append(fieldInfo.getFullColumnName());\n            values.append(\"?\");\n        }\n\n        sql.append(\") VALUES (\").append(values.toString()).append(\")\");\n\n        try {\n            efi.getEntityDbMeta().checkTableRuntime(ed);\n\n            if (con != null) eqb.useConnection(con);\n            else eqb.makeConnection(false);\n            eqb.makePreparedStatement();\n            for (int i = 0; i < size; i++) {\n                FieldInfo fieldInfo = fieldInfoArray[i];\n                if (fieldInfo == null) break;\n                eqb.setPreparedStatementValue(i + 1, valueMapInternal.getByIString(fieldInfo.name, fieldInfo.index), fieldInfo);\n            }\n\n            // if (ed.entityName == \"Subscription\") logger.warn(\"Create ${this.toString()} tx ${efi.getEcfi().transaction.getTransactionManager().getTransaction()} con ${eqb.connection}\")\n            eqb.executeUpdate();\n            setSyncedWithDb();\n        } catch (SQLException e) {\n            String txName = \"[could not get]\";\n            try { txName = efi.ecfi.transactionFacade.getTransactionManager().getTransaction().toString(); }\n            catch (Exception txe) { if (logger.isTraceEnabled()) logger.trace(\"Error getting transaction name: \" + txe.toString()); }\n            logger.warn(\"Error creating \" + this.toString() + \" tx \" + txName + \" con \" + eqb.connection.toString() + \": \" + e.toString());\n            throw e;\n        } finally {\n            try { eqb.closeAll(); }\n            catch (SQLException sqle) { logger.error(\"Error in JDBC close in create of \" + this.toString(), sqle); }\n        }\n    }\n\n    @SuppressWarnings(\"MismatchedQueryAndUpdateOfStringBuilder\")\n    @Override\n    public void updateExtended(FieldInfo[] pkFieldArray, FieldInfo[] nonPkFieldArray, Connection con) throws SQLException {\n        EntityDefinition ed = getEntityDefinition();\n        final EntityFacadeImpl efi = getEntityFacadeImpl();\n        if (ed.isViewEntity) throw new EntityException(\"Update not yet implemented for view-entity\");\n\n        final EntityQueryBuilder eqb = new EntityQueryBuilder(ed, efi);\n        ArrayList<EntityConditionParameter> parameters = eqb.parameters;\n        StringBuilder sql = eqb.sqlTopLevel;\n        sql.append(\"UPDATE \").append(ed.getFullTableName()).append(\" SET \");\n\n        int size = nonPkFieldArray.length;\n        for (int i = 0; i < size; i++) {\n            FieldInfo fieldInfo = nonPkFieldArray[i];\n            if (fieldInfo == null) break;\n            if (i > 0) sql.append(\", \");\n            sql.append(fieldInfo.getFullColumnName()).append(\"=?\");\n            parameters.add(new EntityConditionParameter(fieldInfo, valueMapInternal.getByIString(fieldInfo.name, fieldInfo.index), eqb));\n        }\n\n        eqb.addWhereClause(pkFieldArray, valueMapInternal);\n\n        try {\n            efi.getEntityDbMeta().checkTableRuntime(ed);\n\n            if (con != null) eqb.useConnection(con);\n            else eqb.makeConnection(false);\n            eqb.makePreparedStatement();\n            eqb.setPreparedStatementValues();\n\n            // if (ed.entityName == \"Subscription\") logger.warn(\"Update ${this.toString()} tx ${efi.getEcfi().transaction.getTransactionManager().getTransaction()} con ${eqb.connection}\")\n            if (eqb.executeUpdate() == 0)\n                throw new EntityException(\"Tried to update a value that does not exist [\" + this.toString() + \"]. SQL used was \" + eqb.sqlTopLevel.toString() + \", parameters were \" + eqb.parameters.toString());\n            setSyncedWithDb();\n        } catch (SQLException e) {\n            String txName = \"[could not get]\";\n            try { txName = efi.ecfi.transactionFacade.getTransactionManager().getTransaction().toString(); }\n            catch (Exception txe) { if (logger.isTraceEnabled()) logger.trace(\"Error getting transaction name: \" + txe.toString()); }\n            logger.warn(\"Error updating \" + this.toString() + \" tx \" + txName + \" con \" + eqb.connection.toString() + \": \" + e.toString());\n            throw e;\n        } finally {\n            try { eqb.closeAll(); }\n            catch (SQLException sqle) { logger.error(\"Error in JDBC close in update of \" + this.toString(), sqle); }\n        }\n    }\n\n    @SuppressWarnings(\"MismatchedQueryAndUpdateOfStringBuilder\")\n    @Override\n    public void deleteExtended(Connection con) throws SQLException {\n        EntityDefinition ed = getEntityDefinition();\n        EntityFacadeImpl efi = getEntityFacadeImpl();\n        if (ed.isViewEntity) throw new EntityException(\"Delete not implemented for view-entity\");\n\n        EntityQueryBuilder eqb = new EntityQueryBuilder(ed, efi);\n        StringBuilder sql = eqb.sqlTopLevel;\n        sql.append(\"DELETE FROM \").append(ed.getFullTableName());\n\n        FieldInfo[] pkFieldArray = ed.entityInfo.pkFieldInfoArray;\n        eqb.addWhereClause(pkFieldArray, valueMapInternal);\n\n        try {\n            efi.getEntityDbMeta().checkTableRuntime(ed);\n\n            if (con != null) eqb.useConnection(con);\n            else eqb.makeConnection(false);\n            eqb.makePreparedStatement();\n            eqb.setPreparedStatementValues();\n            if (eqb.executeUpdate() == 0) logger.info(\"Tried to delete a value that does not exist \" + this.toString());\n        } catch (SQLException e) {\n            String txName = \"[could not get]\";\n            try { txName = efi.ecfi.transactionFacade.getTransactionManager().getTransaction().toString(); }\n            catch (Exception txe) { if (logger.isTraceEnabled()) logger.trace(\"Error getting transaction name: \" + txe.toString()); }\n            logger.warn(\"Error deleting \" + this.toString() + \" tx \" + txName + \" con \" + eqb.connection.toString() + \": \" + e.toString());\n            throw e;\n        } finally {\n            try { eqb.closeAll(); }\n            catch (SQLException sqle) { logger.error(\"Error in JDBC close in delete of \" + this.toString(), sqle); }\n        }\n    }\n\n    @SuppressWarnings(\"MismatchedQueryAndUpdateOfStringBuilder\")\n    @Override\n    public boolean refreshExtended() throws SQLException {\n        EntityDefinition ed = getEntityDefinition();\n        EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo;\n        EntityFacadeImpl efi = getEntityFacadeImpl();\n\n        // table doesn't exist, just return false\n        if (!ed.tableExistsDbMetaOnly()) return false;\n\n        // NOTE: this simple approach may not work for view-entities, but not restricting for now\n\n        FieldInfo[] pkFieldArray = entityInfo.pkFieldInfoArray;\n        FieldInfo[] allFieldArray = entityInfo.allFieldInfoArray;\n        // NOTE: even if there are no non-pk fields do a refresh in order to see if the record exists or not\n\n        EntityQueryBuilder eqb = new EntityQueryBuilder(ed, efi);\n        ArrayList<EntityConditionParameter> parameters = eqb.parameters;\n        StringBuilder sql = eqb.sqlTopLevel;\n        sql.append(\"SELECT \");\n        eqb.makeSqlSelectFields(allFieldArray, null, \"true\".equals(efi.getDatabaseNode(ed.groupName).attribute(\"add-unique-as\")));\n\n        sql.append(\" FROM \").append(ed.getFullTableName()).append(\" WHERE \");\n\n        int sizePk = pkFieldArray.length;\n        for (int i = 0; i < sizePk; i++) {\n            FieldInfo fi = pkFieldArray[i];\n            if (i > 0) sql.append(\" AND \");\n            sql.append(fi.getFullColumnName()).append(\"=?\");\n            parameters.add(new EntityConditionParameter(fi, valueMapInternal.getByIString(fi.name, fi.index), eqb));\n        }\n\n        boolean retVal = false;\n        try {\n            // don't check create, above tableExists check is done:\n            // efi.getEntityDbMeta().checkTableRuntime(ed)\n            // if this is a view-entity and any table in it exists check/create all or will fail with optional members, etc\n            if (ed.isViewEntity) efi.getEntityDbMeta().checkTableRuntime(ed);\n\n            eqb.makeConnection(false);\n            eqb.makePreparedStatement();\n            eqb.setPreparedStatementValues();\n\n            ResultSet rs = eqb.executeQuery();\n            if (rs.next()) {\n                int nonPkSize = allFieldArray.length;\n                for (int j = 0; j < nonPkSize; j++) {\n                    FieldInfo fi = allFieldArray[j];\n                    fi.getResultSetValue(rs, j + 1, valueMapInternal, efi);\n                }\n\n                retVal = true;\n                setSyncedWithDb();\n            } else {\n                if (logger.isTraceEnabled())\n                    logger.trace(\"No record found in refresh for entity [\" + resolveEntityName() + \"] with values [\" + String.valueOf(getValueMap()) + \"]\");\n            }\n        } catch (SQLException e) {\n            String txName = \"[could not get]\";\n            try { txName = efi.ecfi.transactionFacade.getTransactionManager().getTransaction().toString(); }\n            catch (Exception txe) { if (logger.isTraceEnabled()) logger.trace(\"Error getting transaction name: \" + txe.toString()); }\n            logger.warn(\"Error finding \" + this.toString() + \" tx \" + txName + \" con \" + eqb.connection.toString() + \": \" + e.toString());\n            throw e;\n        } finally {\n            try { eqb.closeAll(); }\n            catch (SQLException sqle) { logger.error(\"Error in JDBC close in refresh of \" + this.toString(), sqle); }\n        }\n\n        return retVal;\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/FieldInfo.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity;\n\nimport org.moqui.BaseArtifactException;\nimport org.moqui.entity.EntityException;\nimport org.moqui.impl.context.L10nFacadeImpl;\nimport org.moqui.impl.entity.condition.ConditionField;\nimport org.moqui.util.LiteStringMap;\nimport org.moqui.util.MNode;\nimport org.moqui.util.ObjectUtilities;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport javax.sql.rowset.serial.SerialBlob;\nimport javax.sql.rowset.serial.SerialClob;\nimport java.io.*;\nimport java.math.BigDecimal;\nimport java.nio.ByteBuffer;\nimport java.sql.*;\nimport java.util.*;\n\npublic class FieldInfo {\n    protected final static Logger logger = LoggerFactory.getLogger(FieldInfo.class);\n    protected final static boolean isTraceEnabled = logger.isTraceEnabled();\n    public final static String[] aggFunctionArray = {\"min\", \"max\", \"sum\", \"avg\", \"count\", \"count-distinct\"};\n    public final static Set<String> aggFunctions = new HashSet<>(Arrays.asList(aggFunctionArray));\n    public final static String decryptFailedMagicString = \"_DECRYPT_FAILED_\";\n\n    public final EntityDefinition ed;\n    public final MNode fieldNode;\n    public final int index;\n    public final String entityName, name, aliasFieldName;\n    public final ConditionField conditionField;\n    public final String type, columnName;\n    final String fullColumnNameInternal;\n    public final String expandColumnName, defaultStr, javaType, enableAuditLog;\n    public final int typeValue;\n    public final boolean isPk, isTextVeryLong, encrypt, isSimple, enableLocalization, createOnly, isLastUpdatedStamp;\n    public final MNode memberEntityNode;\n    public final MNode directMemberEntityNode;\n    public final boolean hasAggregateFunction;\n    final Set<String> entityAliasUsedSet = new HashSet<>();\n\n    public FieldInfo(EntityDefinition ed, MNode fieldNode, int index) {\n        this.ed = ed;\n        this.fieldNode = fieldNode;\n        this.index = index;\n        entityName = ed.getFullEntityName();\n\n        Map<String, String> fnAttrs = fieldNode.getAttributes();\n        String nameAttr = fnAttrs.get(\"name\");\n        if (nameAttr == null) throw new EntityException(\"No name attribute specified for field in entity \" + entityName);\n        // NOTE: intern a must here for use with LiteStringMap, without this all sorts of bad behavior, not finding any fields sort of thing\n        name = nameAttr.intern();\n        conditionField = new ConditionField(this);\n        // column name from attribute or underscored name, may have override per DB\n        String columnNameAttr = fnAttrs.get(\"column-name\");\n        String colNameToUse = columnNameAttr != null && columnNameAttr.length() > 0 ? columnNameAttr :\n                EntityJavaUtil.camelCaseToUnderscored(name);\n        // column name: see if there is a name-replace\n        String groupName = ed.getEntityGroupName();\n        MNode databaseNode = ed.efi.getDatabaseNode(groupName);\n        // some datasources do not have a database node, like the Elastic Entity one\n        if (databaseNode != null) {\n            ArrayList<MNode> nameReplaceNodes = databaseNode.children(\"name-replace\");\n            for (int i = 0; i < nameReplaceNodes.size(); i++) {\n                MNode nameReplaceNode = nameReplaceNodes.get(i);\n                if (colNameToUse.equalsIgnoreCase(nameReplaceNode.attribute(\"original\"))) {\n                    String replaceName = nameReplaceNode.attribute(\"replace\");\n                    logger.info(\"Replacing column name \" + colNameToUse + \" with replace name \" + replaceName + \" for entity \" + entityName);\n                    colNameToUse = replaceName;\n                }\n            }\n        }\n        columnName = colNameToUse;\n\n        defaultStr = fnAttrs.get(\"default\");\n\n        String typeAttr = fnAttrs.get(\"type\");\n        if ((typeAttr == null || typeAttr.length() == 0) && (fieldNode.hasChild(\"complex-alias\") || fieldNode.hasChild(\"case\")) && fnAttrs.get(\"function\") != null) {\n            // this is probably a calculated value, just default to number-decimal\n            typeAttr = \"number-decimal\";\n        }\n        type = typeAttr;\n        if (type != null && type.length() > 0) {\n            String fieldJavaType = ed.efi.getFieldJavaType(type, ed);\n            javaType = fieldJavaType != null ? fieldJavaType : \"String\";\n            typeValue = EntityFacadeImpl.getJavaTypeInt(javaType);\n            isTextVeryLong = \"text-very-long\".equals(type);\n        } else {\n            throw new EntityException(\"No type specified or found for field \" + name + \" on entity \" + entityName);\n        }\n        isPk = \"true\".equals(fnAttrs.get(\"is-pk\"));\n        encrypt = \"true\".equals(fnAttrs.get(\"encrypt\"));\n        enableLocalization = \"true\".equals(fnAttrs.get(\"enable-localization\"));\n        isSimple = !enableLocalization;\n        String createOnlyAttr = fnAttrs.get(\"create-only\");\n        createOnly = createOnlyAttr != null && createOnlyAttr.length() > 0 ?\n                \"true\".equals(fnAttrs.get(\"create-only\")) :\n                \"true\".equals(ed.internalEntityNode.attribute(\"create-only\"));\n        isLastUpdatedStamp = \"lastUpdatedStamp\".equals(name);\n        String enableAuditLogAttr = fieldNode.attribute(\"enable-audit-log\");\n        enableAuditLog = enableAuditLogAttr != null ? enableAuditLogAttr : ed.internalEntityNode.attribute(\"enable-audit-log\");\n\n        String fcn = ed.makeFullColumnName(fieldNode, true);\n        if (fcn == null) {\n            fullColumnNameInternal = columnName;\n            expandColumnName = null;\n        } else {\n            if (fcn.contains(\"${\")) {\n                expandColumnName = fcn;\n                fullColumnNameInternal = null;\n            } else {\n                fullColumnNameInternal = fcn;\n                expandColumnName = null;\n            }\n        }\n\n        if (ed.isViewEntity) {\n            String fieldAttr = fieldNode.attribute(\"field\");\n            aliasFieldName = fieldAttr != null && !fieldAttr.isEmpty() ? fieldAttr : name;\n            MNode tempMembEntNode = null;\n            String entityAlias = fieldNode.attribute(\"entity-alias\");\n            if (entityAlias != null && entityAlias.length() > 0) {\n                entityAliasUsedSet.add(entityAlias);\n                tempMembEntNode = ed.memberEntityAliasMap.get(entityAlias);\n            }\n            directMemberEntityNode = tempMembEntNode;\n            ArrayList<MNode> cafList = fieldNode.descendants(\"complex-alias-field\");\n            int cafListSize = cafList.size();\n            for (int i = 0; i < cafListSize; i++) {\n                MNode cafNode = cafList.get(i);\n                String cafEntityAlias = cafNode.attribute(\"entity-alias\");\n                if (cafEntityAlias != null && cafEntityAlias.length() > 0) entityAliasUsedSet.add(cafEntityAlias);\n            }\n            if (tempMembEntNode == null && entityAliasUsedSet.size() == 1) {\n                String singleEntityAlias = entityAliasUsedSet.iterator().next();\n                tempMembEntNode = ed.memberEntityAliasMap.get(singleEntityAlias);\n            }\n            memberEntityNode = tempMembEntNode;\n            String isAggregateAttr = fieldNode.attribute(\"is-aggregate\");\n            hasAggregateFunction = isAggregateAttr != null ? \"true\".equalsIgnoreCase(isAggregateAttr) :\n                    aggFunctions.contains(fieldNode.attribute(\"function\"));\n        } else {\n            aliasFieldName = null;\n            memberEntityNode = null;\n            directMemberEntityNode = null;\n            hasAggregateFunction = false;\n        }\n    }\n\n    /** Full column name for complex finds on view entities; plain entity column names are never expanded */\n    public String getFullColumnName() {\n        if (fullColumnNameInternal != null) return fullColumnNameInternal;\n        return ed.efi.ecfi.resourceFacade.expand(expandColumnName, \"\", null, false);\n    }\n\n    static BigDecimal safeStripZeroes(BigDecimal input) {\n        if (input == null) return null;\n        BigDecimal temp = input.stripTrailingZeros();\n        if (temp.scale() < 0) temp = temp.setScale(0);\n        return temp;\n    }\n\n    public Object convertFromString(String value, L10nFacadeImpl l10n) {\n        if (value == null) return null;\n        if (\"null\".equals(value)) return null;\n\n        Object outValue;\n        boolean isEmpty = value.length() == 0;\n\n        try {\n            switch (typeValue) {\n                case 1: outValue = value; break;\n                case 2: // outValue = java.sql.Timestamp.valueOf(value);\n                    if (isEmpty) { outValue = null; break; }\n                    outValue = l10n.parseTimestamp(value, null);\n                    if (outValue == null) throw new BaseArtifactException(\"The value [\" + value + \"] is not a valid date/time for field \" + entityName + \".\" + name);\n                    break;\n                case 3: // outValue = java.sql.Time.valueOf(value);\n                    if (isEmpty) { outValue = null; break; }\n                    outValue = l10n.parseTime(value, null);\n                    if (outValue == null) throw new BaseArtifactException(\"The value [\" + value + \"] is not a valid time for field \" + entityName + \".\" + name);\n                    break;\n                case 4: // outValue = java.sql.Date.valueOf(value);\n                    if (isEmpty) { outValue = null; break; }\n                    outValue = l10n.parseDate(value, null);\n                    if (outValue == null) throw new BaseArtifactException(\"The value [\" + value + \"] is not a valid date for field \" + entityName + \".\" + name);\n                    break;\n                case 5: // outValue = Integer.valueOf(value); break\n                case 6: // outValue = Long.valueOf(value); break\n                case 7: // outValue = Float.valueOf(value); break\n                case 8: // outValue = Double.valueOf(value); break\n                case 9: // outValue = new BigDecimal(value); break\n                    if (isEmpty) { outValue = null; break; }\n                    BigDecimal bdVal = l10n.parseNumber(value, null);\n                    if (bdVal == null) {\n                        throw new BaseArtifactException(\"The value [\" + value + \"] is not valid for type \" + javaType + \" for field \" + entityName + \".\" + name);\n                    } else {\n                        bdVal = safeStripZeroes(bdVal);\n                        switch (typeValue) {\n                            case 5: outValue = bdVal.intValue(); break;\n                            case 6: outValue = bdVal.longValue(); break;\n                            case 7: outValue = bdVal.floatValue(); break;\n                            case 8: outValue = bdVal.doubleValue(); break;\n                            default: outValue = bdVal; break;\n                        }\n                    }\n                    break;\n                case 10:\n                    if (isEmpty) { outValue = null; break; }\n                    outValue = Boolean.valueOf(value); break;\n                case 11: outValue = value; break;\n                case 12:\n                    try {\n                        outValue = new SerialBlob(value.getBytes());\n                    } catch (SQLException e) {\n                        throw new BaseArtifactException(\"Error creating SerialBlob for value [\" + value + \"] for field \" + entityName + \".\" + name);\n                    }\n                    break;\n                case 13: outValue = value; break;\n                case 14:\n                    if (isEmpty) { outValue = null; break; }\n                    Timestamp ts = l10n.parseTimestamp(value, null);\n                    outValue = new java.util.Date(ts.getTime());\n                    break;\n            // better way for Collection (15)? maybe parse comma separated, but probably doesn't make sense in the first place\n                case 15: outValue = value; break;\n                default: outValue = value; break;\n            }\n        } catch (IllegalArgumentException e) {\n            throw new BaseArtifactException(\"The value [\" + value + \"] is not valid for type \" + javaType + \" for field \" + entityName + \".\" + name, e);\n        }\n\n        return outValue;\n    }\n    public String convertToString(Object value) {\n        if (value == null) return null;\n        String outValue;\n        try {\n            switch (typeValue) {\n                case 1: outValue = value.toString(); break;\n                case 2:\n                case 3:\n                case 4:\n                case 5:\n                case 6:\n                case 7:\n                case 8:\n                case 9:\n                    if (value instanceof BigDecimal) value = safeStripZeroes((BigDecimal) value);\n                    L10nFacadeImpl l10n = ed.efi.ecfi.getEci().l10nFacade;\n                    outValue = l10n.format(value, null);\n                    break;\n                case 10: outValue = value.toString(); break;\n                case 11: outValue = value.toString(); break;\n                case 12:\n                    if (value instanceof byte[]) {\n                        outValue = Base64.getEncoder().encodeToString((byte[]) value);\n                    } else {\n                        logger.info(\"Field on entity is not of type byte[], is [\" + value + \"] so using plain toString() for field \" + entityName + \".\" + name);\n                        outValue = value.toString();\n                    }\n                    break;\n                case 13: outValue = value.toString(); break;\n                case 14: outValue = value.toString(); break;\n            // better way for Collection (15)? maybe parse comma separated, but probably doesn't make sense in the first place\n                case 15: outValue = value.toString(); break;\n                default: outValue = value.toString(); break;\n            }\n        } catch (IllegalArgumentException e) {\n            throw new BaseArtifactException(\"The value [\" + value + \"] is not valid for type \" + javaType + \" for field \" + entityName + \".\" + name, e);\n        }\n\n        return outValue;\n    }\n\n    void getResultSetValue(ResultSet rs, int index, LiteStringMap<Object> valueMap, EntityFacadeImpl efi) throws EntityException {\n        if (typeValue == -1) throw new EntityException(\"No typeValue found for \" + entityName + \".\" + name);\n\n        Object value = null;\n        try {\n            switch (typeValue) {\n            case 1:\n                // getMetaData and the column type are somewhat slow (based on profiling), and String values are VERY\n                //     common, so only do for text-very-long\n                if (isTextVeryLong) {\n                    ResultSetMetaData rsmd = rs.getMetaData();\n                    if (Types.CLOB == rsmd.getColumnType(index)) {\n                        // if the String is empty, try to get a text input stream, this is required for some databases\n                        // for larger fields, like CLOBs\n                        Clob valueClob = rs.getClob(index);\n                        Reader valueReader = null;\n                        if (valueClob != null) valueReader = valueClob.getCharacterStream();\n                        if (valueReader != null) {\n                            // read up to 4096 at a time\n                            char[] inCharBuffer = new char[4096];\n                            StringBuilder strBuf = new StringBuilder();\n                            try {\n                                int charsRead;\n                                while ((charsRead = valueReader.read(inCharBuffer, 0, 4096)) > 0) {\n                                    strBuf.append(inCharBuffer, 0, charsRead);\n                                }\n                                valueReader.close();\n                            } catch (IOException e) {\n                                throw new EntityException(\"Error reading long character stream for field \" + name + \" of entity \" + entityName, e);\n                            }\n                            value = strBuf.toString();\n                        }\n                    } else {\n                        value = rs.getString(index);\n                    }\n                } else {\n                    value = rs.getString(index);\n                }\n                break;\n            case 2:\n                try {\n                    value = rs.getTimestamp(index, efi.getCalendarForTzLc());\n                } catch (SQLException e) {\n                    if (logger.isTraceEnabled()) logger.trace(\"Ignoring SQLException for getTimestamp(), leaving null (found this in MySQL with a date/time value of [0000-00-00 00:00:00]): \" + e.toString());\n                }\n                break;\n            case 3: value = rs.getTime(index, efi.getCalendarForTzLc()); break;\n            // for Date don't pass 2nd param efi.getCalendarForTzLc(), causes issues when Java TZ different from DB TZ\n            // when the JDBC driver converts a string to a Date it uses the TZ from the Calendar but we want the Java default TZ\n            case 4: value = rs.getDate(index); break;\n            case 5: int intValue = rs.getInt(index); if (!rs.wasNull()) value = intValue; break;\n            case 6: long longValue = rs.getLong(index); if (!rs.wasNull()) value = longValue; break;\n            case 7: float floatValue = rs.getFloat(index); if (!rs.wasNull()) value = floatValue; break;\n            case 8: double doubleValue = rs.getDouble(index); if (!rs.wasNull()) value = doubleValue; break;\n            case 9: BigDecimal bdVal = rs.getBigDecimal(index); if (!rs.wasNull()) value = safeStripZeroes(bdVal); break;\n            case 10: boolean booleanValue = rs.getBoolean(index); if (!rs.wasNull()) value = booleanValue; break;\n            case 11:\n                Object obj = null;\n                byte[] originalBytes = rs.getBytes(index);\n                InputStream binaryInput = null;\n                if (originalBytes != null && originalBytes.length > 0) {\n                    binaryInput = new ByteArrayInputStream(originalBytes);\n                }\n                if (originalBytes != null && originalBytes.length <= 0) {\n                    logger.warn(\"Got byte array back empty for serialized Object with length [\" + originalBytes.length + \"] for field [\" + name + \"] (\" + index + \")\");\n                }\n                if (binaryInput != null) {\n                    ObjectInputStream inStream = null;\n                    try {\n                        inStream = new ObjectInputStream(binaryInput);\n                        obj = inStream.readObject();\n                    } catch (IOException ex) {\n                        if (logger.isTraceEnabled()) logger.trace(\"Unable to read BLOB from input stream for field [\" + name + \"] (\" + index + \"): \" + ex.toString());\n                    } catch (ClassNotFoundException ex) {\n                        if (logger.isTraceEnabled()) logger.trace(\"Class not found: Unable to cast BLOB data to an Java object for field [\" + name + \"] (\" + index + \"); most likely because it is a straight byte[], so just using the raw bytes: \" + ex.toString());\n                    } finally {\n                        if (inStream != null) {\n                            try { inStream.close(); }\n                            catch (IOException e) { logger.error(\"Unable to close binary input stream for field [\" + name + \"] (\" + index + \"): \" + e.toString(), e); }\n                        }\n                    }\n                }\n                if (obj != null) {\n                    value = obj;\n                } else {\n                    value = originalBytes;\n                }\n                break;\n            case 12:\n                SerialBlob sblob = null;\n                try {\n                    // NOTE: changed to try getBytes first because Derby blows up on getBlob and on then calling getBytes for the same field, complains about getting value twice\n                    byte[] fieldBytes = rs.getBytes(index);\n                    if (!rs.wasNull()) sblob = new SerialBlob(fieldBytes);\n                    // fieldBytes = theBlob != null ? theBlob.getBytes(1, (int) theBlob.length()) : null\n                } catch (SQLException e) {\n                    if (logger.isTraceEnabled()) logger.trace(\"Ignoring exception trying getBytes(), trying getBlob(): \" + e.toString());\n                    Blob theBlob = rs.getBlob(index);\n                    if (!rs.wasNull()) sblob = new SerialBlob(theBlob);\n                }\n                value = sblob;\n                break;\n            case 13: value = new SerialClob(rs.getClob(index)); break;\n            case 14:\n            case 15: value = rs.getObject(index); break;\n            }\n        } catch (SQLException sqle) {\n            logger.error(\"SQL Exception while getting value for field: \" + name + \" (\" + index + \")\", sqle);\n            throw new EntityException(\"SQL Exception while getting value for field: \" + name + \" (\" + index + \")\", sqle);\n        }\n\n        // if field is to be encrypted, do it now\n        if (value != null && encrypt) {\n            if (typeValue != 1) throw new EntityException(\"The encrypt attribute was set to true on non-String field \" + name + \" of entity \" + entityName);\n            String original = value.toString();\n            try {\n                value = EntityJavaUtil.enDeCrypt(original, false, efi);\n            } catch (Exception e) {\n                logger.error(\"Error decrypting field [\" + name + \"] of entity [\" + entityName + \"]\", e);\n                // NOTE DEJ 20200310 instead of using encrypted value return very clear fake placeholder; this is a bad design\n                //     because it uses a magic value that can't change as other code may use it; an alternative might be to have\n                //     the EntityValue internally handle it with a Set of fields that failed to decrypt, but this doesn't carry\n                //     through to or from web and other clients\n                value = decryptFailedMagicString;\n            }\n        }\n\n        valueMap.putByIString(this.name, value, this.index);\n    }\n\n    private static final boolean checkPreparedStatementValueType = false;\n    public void setPreparedStatementValue(PreparedStatement ps, int index, Object value,\n                                          EntityDefinition ed, EntityFacadeImpl efi) throws EntityException {\n        int localTypeValue = typeValue;\n        if (value != null) {\n            if (checkPreparedStatementValueType && !ObjectUtilities.isInstanceOf(value, javaType)) {\n                // this is only an info level message because under normal operation for most JDBC\n                // drivers this will be okay, but if not then the JDBC driver will throw an exception\n                // and when lower debug levels are on this should help give more info on what happened\n                String fieldClassName = value.getClass().getName();\n                if (value instanceof byte[]) {\n                    fieldClassName = \"byte[]\";\n                } else if (value instanceof char[]) {\n                    fieldClassName = \"char[]\";\n                }\n\n                if (isTraceEnabled) logger.trace(\"Type of field \" + ed.getFullEntityName() + \".\" + name +\n                        \" is \" + fieldClassName + \", was expecting \" + javaType + \" this may \" +\n                        \"indicate an error in the configuration or in the class, and may result \" +\n                        \"in an SQL-Java data conversion error. Will use the real field type: \" +\n                        fieldClassName + \", not the definition.\");\n                localTypeValue = EntityFacadeImpl.getJavaTypeInt(fieldClassName);\n            }\n\n            // if field is to be encrypted, do it now\n            if (encrypt) {\n                if (localTypeValue != 1) throw new EntityException(\"The encrypt attribute was set to true on non-String field \" + name + \" of entity \" + entityName);\n                String original = value.toString();\n                if (decryptFailedMagicString.equals(original)) {\n                    throw new EntityException(\"To prevent data loss, not allowing decrypt failed placeholder for field \" + name + \" of entity \" + entityName);\n                }\n                value = EntityJavaUtil.enDeCrypt(original, true, efi);\n            }\n        }\n\n        boolean useBinaryTypeForBlob = false;\n        if (localTypeValue == 11 || localTypeValue == 12) {\n            useBinaryTypeForBlob = (\"true\".equals(efi.getDatabaseNode(ed.getEntityGroupName()).attribute(\"use-binary-type-for-blob\")));\n        }\n        // if a count function used set as Long (type 6)\n        if (ed.isViewEntity) {\n            String function = fieldNode.attribute(\"function\");\n            if (function != null && function.startsWith(\"count\")) localTypeValue = 6;\n        }\n\n        try {\n            setPreparedStatementValue(ps, index, value, localTypeValue, useBinaryTypeForBlob, efi);\n        } catch (EntityException e) {\n            throw e;\n        } catch (Exception e) {\n            throw new EntityException(\"Error setting prepared statement field \" + name + \" of entity \" + entityName, e);\n        }\n    }\n\n    private void setPreparedStatementValue(PreparedStatement ps, int index, Object value, int localTypeValue,\n                                                 boolean useBinaryTypeForBlob, EntityFacadeImpl efi) throws EntityException {\n        try {\n            // allow setting, and searching for, String values for all types; JDBC driver should handle this okay\n            if (value instanceof CharSequence) {\n                ps.setString(index, value.toString());\n            } else {\n                switch (localTypeValue) {\n                case 1: if (value != null) { ps.setString(index, value.toString()); } else { ps.setNull(index, Types.VARCHAR); } break;\n                case 2:\n                    if (value != null) {\n                        Class valClass = value.getClass();\n                        if (valClass == Timestamp.class) {\n                            ps.setTimestamp(index, (Timestamp) value, efi.getCalendarForTzLc());\n                        } else if (valClass == java.sql.Date.class) {\n                            ps.setDate(index, (java.sql.Date) value, efi.getCalendarForTzLc());\n                        } else if (valClass == java.util.Date.class) {\n                            ps.setTimestamp(index, new Timestamp(((java.util.Date) value).getTime()), efi.getCalendarForTzLc());\n                        } else if (valClass == Long.class) {\n                            ps.setTimestamp(index, new Timestamp((Long) value), efi.getCalendarForTzLc());\n                        } else {\n                            throw new EntityException(\"Class \" + valClass.getName() + \" not allowed for date-time (Timestamp) fields, for field \" + entityName + \".\" + name);\n                        }\n                        // NOTE for Calendar use with different MySQL vs Oracle JDBC drivers: https://bugs.openjdk.java.net/browse/JDK-4986236\n                        //     the TimeZone is treated differently; should stay consistent but may result in unexpected times when looking at\n                        //     timestamp values directly in the database; not a huge issue, something to keep in mind when configuring the DB TimeZone\n                        // NOTE that some JDBC drivers clone the Calendar, others don't; see the efi.getCalendarForTzLc() which now uses a ThreadLocal<Calendar>\n                    } else { ps.setNull(index, Types.TIMESTAMP); }\n                    break;\n                case 3:\n                    Time tm = (Time) value;\n                    // logger.warn(\"=================== setting time tm=${tm} tm long=${tm.getTime()}, cal=${cal}\")\n                    if (value != null) { ps.setTime(index, tm, efi.getCalendarForTzLc()); }\n                    else { ps.setNull(index, Types.TIME); }\n                    break;\n                case 4:\n                    if (value != null) {\n                        Class valClass = value.getClass();\n                        if (valClass == java.sql.Date.class) {\n                            java.sql.Date dt = (java.sql.Date) value;\n                            // logger.warn(\"=================== setting date dt=${dt} dt long=${dt.getTime()}, cal=${cal}\")\n                            ps.setDate(index, dt);\n                            // NOTE: don't pass Calendar, Date was likely generated in Java TZ and that's what we want, if DB TZ is different we don't want it to use that\n                        } else if (valClass == Timestamp.class) {\n                            ps.setDate(index, new java.sql.Date(((Timestamp) value).getTime()), efi.getCalendarForTzLc());\n                        } else if (valClass == java.util.Date.class) {\n                            ps.setDate(index, new java.sql.Date(((java.util.Date) value).getTime()), efi.getCalendarForTzLc());\n                        } else if (valClass == Long.class) {\n                            ps.setDate(index, new java.sql.Date((Long) value), efi.getCalendarForTzLc());\n                        } else {\n                            throw new EntityException(\"Class \" + valClass.getName() + \" not allowed for date fields, for field \" + entityName + \".\" + name);\n                        }\n                    } else { ps.setNull(index, Types.DATE); }\n                    break;\n                case 5: if (value != null) { ps.setInt(index, ((Number) value).intValue()); } else { ps.setNull(index, Types.NUMERIC); } break;\n                case 6: if (value != null) { ps.setLong(index, ((Number) value).longValue()); } else { ps.setNull(index, Types.NUMERIC); } break;\n                case 7: if (value != null) { ps.setFloat(index, ((Number) value).floatValue()); } else { ps.setNull(index, Types.NUMERIC); } break;\n                case 8: if (value != null) { ps.setDouble(index, ((Number) value).doubleValue()); } else { ps.setNull(index, Types.NUMERIC); } break;\n                case 9:\n                    if (value != null) {\n                        Class valClass = value.getClass();\n                        // most common cases BigDecimal, Double, Float; then allow any Number\n                        if (valClass == BigDecimal.class) {\n                            ps.setBigDecimal(index, (BigDecimal) value);\n                        } else if (valClass == Double.class) {\n                            ps.setDouble(index, (Double) value);\n                        } else if (valClass == Float.class) {\n                            ps.setFloat(index, (Float) value);\n                        } else if (value instanceof Number) {\n                            ps.setDouble(index, ((Number) value).doubleValue());\n                        } else {\n                            throw new EntityException(\"Class \" + valClass.getName() + \" not allowed for number-decimal (BigDecimal) fields, for field \" + entityName + \".\" + name);\n                        }\n                    } else { ps.setNull(index, Types.NUMERIC); } break;\n                case 10: if (value != null) { ps.setBoolean(index, (Boolean) value); } else { ps.setNull(index, Types.BOOLEAN); } break;\n                case 11:\n                    if (value != null) {\n                        try {\n                            ByteArrayOutputStream os = new ByteArrayOutputStream();\n                            ObjectOutputStream oos = new ObjectOutputStream(os);\n                            oos.writeObject(value);\n                            oos.close();\n                            byte[] buf = os.toByteArray();\n                            os.close();\n\n                            ByteArrayInputStream is = new ByteArrayInputStream(buf);\n                            ps.setBinaryStream(index, is, buf.length);\n                            is.close();\n                        } catch (IOException ex) {\n                            throw new EntityException(\"Error setting serialized object, for field \" + entityName + \".\" + name, ex);\n                        }\n                    } else {\n                        if (useBinaryTypeForBlob) { ps.setNull(index, Types.BINARY); } else { ps.setNull(index, Types.BLOB); }\n                    }\n                    break;\n                case 12:\n                    if (value instanceof byte[]) {\n                        ps.setBytes(index, (byte[]) value);\n                    /*\n                    } else if (value instanceof ArrayList) {\n                        ArrayList valueAl = (ArrayList) value;\n                        byte[] theBytes = new byte[valueAl.size()];\n                        valueAl.toArray(theBytes);\n                        ps.setBytes(index, theBytes);\n                    */\n                    } else if (value instanceof ByteBuffer) {\n                        ByteBuffer valueBb = (ByteBuffer) value;\n                        ps.setBytes(index, valueBb.array());\n                    } else if (value instanceof Blob) {\n                        Blob valueBlob = (Blob) value;\n                        // calling setBytes instead of setBlob - old github.com/moqui/moqui repo issue #28 with Postgres JDBC driver\n                        // ps.setBlob(index, (Blob) value)\n                        // Blob blb = value\n                        try {\n                            ps.setBytes(index, valueBlob.getBytes(1, (int) valueBlob.length()));\n                        } catch (Exception bytesExc) {\n                            // try ps.setBlob for larger byte arrays that H2 throws an exception for\n                            try {\n                                ps.setBlob(index, valueBlob);\n                            } catch (Exception blobExc) {\n                                // throw the original exception from setBytes()\n                                throw bytesExc;\n                            }\n                        }\n                    } else {\n                        if (value != null) {\n                            throw new EntityException(\"Type not supported for BLOB field: \" + value.getClass().getName() + \", for field \" + entityName + \".\" + name);\n                        } else {\n                            if (useBinaryTypeForBlob) { ps.setNull(index, Types.BINARY); } else { ps.setNull(index, Types.BLOB); }\n                        }\n                    }\n                    break;\n                case 13: if (value != null) { ps.setClob(index, (Clob) value); } else { ps.setNull(index, Types.CLOB); } break;\n                case 14: if (value != null) { ps.setTimestamp(index, (Timestamp) value); } else { ps.setNull(index, Types.TIMESTAMP); } break;\n                // TODO: is this the best way to do collections and such?\n                case 15: if (value != null) { ps.setObject(index, value, Types.JAVA_OBJECT); } else { ps.setNull(index, Types.JAVA_OBJECT); } break;\n                }\n            }\n        } catch (SQLException sqle) {\n            throw new EntityException(\"SQL Exception while setting value [\" + value + \"](\" + (value != null ? value.getClass().getName() : \"null\") + \"), type \" + type + \", for field \" + entityName + \".\" + name + \": \" + sqle.toString(), sqle);\n        } catch (Exception e) {\n            throw new EntityException(\"Error while setting value for field \" + entityName + \".\" + name + \": \" + e.toString(), e);\n        }\n    }\n\n    @Override public String toString() { return name; }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/condition/BasicJoinCondition.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity.condition;\n\nimport org.moqui.entity.EntityCondition;\nimport org.moqui.entity.EntityException;\nimport org.moqui.impl.entity.EntityDefinition;\nimport org.moqui.impl.entity.EntityQueryBuilder;\nimport org.moqui.impl.entity.EntityConditionFactoryImpl;\nimport org.moqui.util.CollectionUtilities;\n\nimport java.io.IOException;\nimport java.io.ObjectInput;\nimport java.io.ObjectOutput;\nimport java.util.*;\n\npublic class BasicJoinCondition implements EntityConditionImplBase {\n    private static final Class thisClass = BasicJoinCondition.class;\n    private EntityConditionImplBase lhsInternal;\n    protected JoinOperator operator;\n    private EntityConditionImplBase rhsInternal;\n    private int curHashCode;\n\n    public BasicJoinCondition(EntityConditionImplBase lhs, JoinOperator operator, EntityConditionImplBase rhs) {\n        this.lhsInternal = lhs;\n        this.operator = operator != null ? operator : AND;\n        this.rhsInternal = rhs;\n        curHashCode = createHashCode();\n    }\n\n    public JoinOperator getOperator() { return operator; }\n    public EntityConditionImplBase getLhs() { return lhsInternal; }\n    public EntityConditionImplBase getRhs() { return rhsInternal; }\n\n    @Override\n    @SuppressWarnings(\"MismatchedQueryAndUpdateOfStringBuilder\")\n    public void makeSqlWhere(EntityQueryBuilder eqb, EntityDefinition subMemberEd) {\n        StringBuilder sql = eqb.sqlTopLevel;\n        sql.append('(');\n        lhsInternal.makeSqlWhere(eqb, subMemberEd);\n        sql.append(' ').append(EntityConditionFactoryImpl.getJoinOperatorString(this.operator)).append(' ');\n        rhsInternal.makeSqlWhere(eqb, subMemberEd);\n        sql.append(')');\n    }\n    @Override\n    public void makeSearchFilter(List<Map<String, Object>> filterList) {\n        List<Map<String, Object>> childList = new ArrayList<>(2);\n        lhsInternal.makeSearchFilter(childList);\n        rhsInternal.makeSearchFilter(childList);\n\n        Map<String, Object> boolMap = new HashMap<>();\n        if (operator == AND) boolMap.put(\"filter\", childList);\n        else boolMap.put(\"should\", childList);\n\n        filterList.add(CollectionUtilities.toHashMap(\"bool\", boolMap));\n    }\n\n    @Override\n    public boolean mapMatches(Map<String, Object> map) {\n        boolean lhsMatches = lhsInternal.mapMatches(map);\n\n        // handle cases where we don't need to evaluate rhs\n        if (lhsMatches && operator == OR) return true;\n        if (!lhsMatches && operator == AND) return false;\n\n        // handle opposite cases since we know cases above aren't true (ie if OR then lhs=false, if AND then lhs=true\n        // if rhs then result is true whether AND or OR\n        // if !rhs then result is false whether AND or OR\n        return rhsInternal.mapMatches(map);\n    }\n    @Override\n    public boolean mapMatchesAny(Map<String, Object> map) {\n        return lhsInternal.mapMatchesAny(map) || rhsInternal.mapMatchesAny(map);\n    }\n    @Override\n    public boolean mapKeysNotContained(Map<String, Object> map) {\n        return lhsInternal.mapKeysNotContained(map) && rhsInternal.mapKeysNotContained(map);\n    }\n\n    @Override\n    public boolean populateMap(Map<String, Object> map) {\n        return operator == AND && lhsInternal.populateMap(map) && rhsInternal.populateMap(map);\n    }\n\n    @Override\n    public void getAllAliases(Set<String> entityAliasSet, Set<String> fieldAliasSet) {\n        lhsInternal.getAllAliases(entityAliasSet, fieldAliasSet);\n        rhsInternal.getAllAliases(entityAliasSet, fieldAliasSet);\n    }\n    @Override\n    public EntityConditionImplBase filter(String entityAlias, EntityDefinition mainEd) {\n        EntityConditionImplBase filterLhs = lhsInternal.filter(entityAlias, mainEd);\n        EntityConditionImplBase filterRhs = rhsInternal.filter(entityAlias, mainEd);\n        if (filterLhs != null) {\n            if (filterRhs != null) return this;\n            else return filterLhs;\n        } else {\n            return filterRhs;\n        }\n    }\n\n    @Override\n    public EntityCondition ignoreCase() { throw new EntityException(\"Ignore case not supported for BasicJoinCondition\"); }\n\n    @Override\n    public String toString() {\n        // general SQL where clause style text with values included\n        return \"(\" + lhsInternal.toString() + \") \" + EntityConditionFactoryImpl.getJoinOperatorString(this.operator) + \" (\" + rhsInternal.toString() + \")\";\n    }\n\n    @Override\n    public int hashCode() { return curHashCode; }\n    private int createHashCode() {\n        return (lhsInternal != null ? lhsInternal.hashCode() : 0) + operator.hashCode() + (rhsInternal != null ? rhsInternal.hashCode() : 0);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (o == null || o.getClass() != thisClass) return false;\n        BasicJoinCondition that = (BasicJoinCondition) o;\n        if (!this.lhsInternal.equals(that.lhsInternal)) return false;\n        // NOTE: for Java Enums the != is WAY faster than the .equals\n        return this.operator == that.operator && this.rhsInternal.equals(that.rhsInternal);\n    }\n\n    @Override\n    public void writeExternal(ObjectOutput out) throws IOException {\n        out.writeObject(lhsInternal);\n        out.writeUTF(operator.name());\n        out.writeObject(rhsInternal);\n    }\n    @Override\n    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {\n        lhsInternal = (EntityConditionImplBase) in.readObject();\n        operator = JoinOperator.valueOf(in.readUTF());\n        rhsInternal = (EntityConditionImplBase) in.readObject();\n        curHashCode = createHashCode();\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/condition/ConditionAlias.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity.condition;\n\nimport org.moqui.BaseArtifactException;\nimport org.moqui.impl.entity.EntityDefinition;\nimport org.moqui.impl.entity.FieldInfo;\nimport org.moqui.util.MNode;\n\nimport java.io.Externalizable;\nimport java.io.IOException;\nimport java.io.ObjectInput;\nimport java.io.ObjectOutput;\n\npublic class ConditionAlias extends ConditionField implements Externalizable {\n    private static final Class thisClass = ConditionAlias.class;\n\n    String fieldName;\n    String entityAlias = null;\n    private String aliasEntityName = null;\n    private transient EntityDefinition aliasEntityDefTransient = null;\n    private int curHashCode;\n\n    public ConditionAlias() { }\n    public ConditionAlias(String entityAlias, String fieldName, EntityDefinition aliasEntityDef) {\n        if (fieldName == null) throw new BaseArtifactException(\"Empty fieldName not allowed\");\n        if (entityAlias == null) throw new BaseArtifactException(\"Empty entityAlias not allowed\");\n        if (aliasEntityDef == null) throw new BaseArtifactException(\"Null aliasEntityDef not allowed\");\n        this.fieldName = fieldName.intern();\n        this.entityAlias = entityAlias.intern();\n\n        aliasEntityDefTransient = aliasEntityDef;\n        String entName = aliasEntityDef.getFullEntityName();\n        aliasEntityName = entName.intern();\n        curHashCode = createHashCode();\n    }\n\n    public String getEntityAlias() { return entityAlias; }\n    public String getFieldName() { return fieldName; }\n    public String getAliasEntityName() { return aliasEntityName; }\n    private EntityDefinition getAliasEntityDef(EntityDefinition otherEd) {\n        if (aliasEntityDefTransient == null && aliasEntityName != null)\n            aliasEntityDefTransient = otherEd.getEfi().getEntityDefinition(aliasEntityName);\n        return aliasEntityDefTransient;\n    }\n\n    public String getColumnName(EntityDefinition ed) {\n        StringBuilder colName = new StringBuilder();\n        // NOTE: this could have issues with view-entities as member entities where they have functions/etc; we may\n        // have to pass the prefix in to have it added inside functions/etc\n        colName.append(entityAlias).append('.');\n        EntityDefinition memberEd = getAliasEntityDef(ed);\n        if (memberEd.isViewEntity) {\n            MNode memberEntity = ed.getMemberEntityNode(entityAlias);\n            String subSelectAttr = memberEntity.attribute(\"sub-select\");\n            if (\"true\".equals(subSelectAttr) || \"non-lateral\".equals(subSelectAttr)) colName.append(memberEd.getFieldInfo(fieldName).columnName);\n            else colName.append(memberEd.getColumnName(fieldName));\n        } else {\n            colName.append(memberEd.getColumnName(fieldName));\n        }\n        return colName.toString();\n    }\n\n    public FieldInfo getFieldInfo(EntityDefinition ed) {\n        if (aliasEntityName != null) {\n            return getAliasEntityDef(ed).getFieldInfo(fieldName);\n        } else {\n            return ed.getFieldInfo(fieldName);\n        }\n    }\n\n    @Override\n    public String toString() { return (entityAlias != null ? (entityAlias + \".\") : \"\") + fieldName; }\n\n    @Override\n    public int hashCode() { return curHashCode; }\n    private int createHashCode() {\n        return fieldName.hashCode() + (entityAlias != null ? entityAlias.hashCode() : 0) +\n                (aliasEntityName != null ? aliasEntityName.hashCode() : 0);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (o == null || o.getClass() != thisClass) return false;\n        ConditionAlias that = (ConditionAlias) o;\n        // both Strings are intern'ed so use != operator for object compare\n        if (fieldName != that.fieldName) return false;\n        if (entityAlias != that.entityAlias) return false;\n        if (aliasEntityName != that.aliasEntityName) return false;\n        return true;\n    }\n\n    @Override\n    public void writeExternal(ObjectOutput out) throws IOException {\n        out.writeUTF(fieldName);\n        out.writeUTF(entityAlias);\n        out.writeUTF(aliasEntityName);\n    }\n\n    @Override\n    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {\n        fieldName = in.readUTF().intern();\n        entityAlias = in.readUTF().intern();\n        aliasEntityName = in.readUTF().intern();\n        curHashCode = createHashCode();\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/condition/ConditionField.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity.condition;\n\nimport org.moqui.BaseArtifactException;\nimport org.moqui.impl.entity.EntityDefinition;\nimport org.moqui.impl.entity.FieldInfo;\n\nimport java.io.*;\n\npublic class ConditionField implements Externalizable {\n    private static final Class thisClass = ConditionField.class;\n    String fieldName;\n    private int curHashCode;\n    private FieldInfo fieldInfo = null;\n\n    public ConditionField() { }\n    public ConditionField(String fieldName) {\n        if (fieldName == null) throw new BaseArtifactException(\"Empty fieldName not allowed\");\n        this.fieldName = fieldName.intern();\n        curHashCode = this.fieldName.hashCode();\n    }\n    public ConditionField(FieldInfo fi) {\n        if (fi == null) throw new BaseArtifactException(\"FieldInfo required\");\n        fieldInfo = fi;\n        // fi.name is interned in makeFieldInfo()\n        fieldName = fi.name;\n        curHashCode = fieldName.hashCode();\n    }\n\n    public String getFieldName() { return fieldName; }\n    public String getColumnName(EntityDefinition ed) {\n        if (fieldInfo != null && fieldInfo.ed.fullEntityName.equals(ed.fullEntityName)) return fieldInfo.getFullColumnName();\n        return ed.getColumnName(fieldName);\n    }\n    public FieldInfo getFieldInfo(EntityDefinition ed) {\n        if (fieldInfo != null && fieldInfo.ed.fullEntityName.equals(ed.fullEntityName)) return fieldInfo;\n        return ed.getFieldInfo(fieldName);\n    }\n\n    @Override\n    public String toString() { return fieldName; }\n\n    @Override\n    public int hashCode() { return curHashCode; }\n\n    @Override\n    public boolean equals(Object o) {\n        if (o == null) return false;\n        // because of reuse from EntityDefinition this may be the same object, so check that first\n        if (this == o) return true;\n        if (o.getClass() != thisClass) return false;\n        ConditionField that = (ConditionField) o;\n        // intern'ed String to use == operator\n        return fieldName == that.fieldName;\n    }\n\n    @Override\n    public void writeExternal(ObjectOutput out) throws IOException {\n        out.writeObject(fieldName.toCharArray());\n    }\n\n    @Override\n    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {\n        fieldName = new String((char[]) in.readObject()).intern();\n        curHashCode = fieldName.hashCode();\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/condition/DateCondition.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity.condition\n\nimport groovy.transform.CompileStatic\nimport org.moqui.entity.EntityCondition\nimport org.moqui.entity.EntityException\nimport org.moqui.impl.entity.EntityDefinition\n\nimport java.sql.Timestamp\nimport org.moqui.impl.entity.EntityQueryBuilder\n\n@CompileStatic\nclass DateCondition implements EntityConditionImplBase, Externalizable {\n    protected ConditionField fromField\n    protected ConditionField thruField\n    protected Timestamp compareStamp\n    private EntityConditionImplBase conditionInternal\n    private int hashCodeInternal\n\n    DateCondition(ConditionField fromField, ConditionField thruField, Timestamp compareStamp) {\n        this.fromField = fromField\n        this.thruField = thruField\n        this.compareStamp = compareStamp\n        conditionInternal = makeConditionInternal()\n        hashCodeInternal = createHashCode()\n    }\n    DateCondition(String fromFieldName, String thruFieldName, Timestamp compareStamp) {\n        this.fromField = new ConditionField(fromFieldName ?: \"fromDate\")\n        this.thruField = new ConditionField(thruFieldName ?: \"thruDate\")\n        if (compareStamp == (Timestamp) null) compareStamp = new Timestamp(System.currentTimeMillis())\n        this.compareStamp = compareStamp\n        conditionInternal = makeConditionInternal()\n        hashCodeInternal = createHashCode()\n    }\n\n    @Override void makeSqlWhere(EntityQueryBuilder eqb, EntityDefinition subMemberEd) {\n        conditionInternal.makeSqlWhere(eqb, subMemberEd) }\n    @Override void makeSearchFilter(List<Map<String, Object>> filterList) {\n        conditionInternal.makeSearchFilter(filterList) }\n\n    @Override\n    void getAllAliases(Set<String> entityAliasSet, Set<String> fieldAliasSet) {\n        if (fromField instanceof ConditionAlias) {\n            entityAliasSet.add(((ConditionAlias) fromField).entityAlias)\n        } else {\n            fieldAliasSet.add(fromField.fieldName)\n        }\n        if (thruField instanceof ConditionAlias) {\n            entityAliasSet.add(((ConditionAlias) thruField).entityAlias)\n        } else {\n            fieldAliasSet.add(thruField.fieldName)\n        }\n    }\n    @Override EntityConditionImplBase filter(String entityAlias, EntityDefinition mainEd) { return conditionInternal.filter(entityAlias, mainEd) }\n\n    @Override boolean mapMatches(Map<String, Object> map) { return conditionInternal.mapMatches(map) }\n    @Override boolean mapMatchesAny(Map<String, Object> map) { return conditionInternal.mapMatchesAny(map) }\n    @Override boolean mapKeysNotContained(Map<String, Object> map) { return conditionInternal.mapKeysNotContained(map) }\n\n    @Override boolean populateMap(Map<String, Object> map) { return false }\n\n    @Override EntityCondition ignoreCase() { throw new EntityException(\"Ignore case not supported for DateCondition.\") }\n\n    @Override String toString() { return conditionInternal.toString() }\n\n    private EntityConditionImplBase makeConditionInternal() {\n        return new ListCondition([\n            new ListCondition([new FieldValueCondition(fromField, EQUALS, null),\n                               new FieldValueCondition(fromField, LESS_THAN_EQUAL_TO, compareStamp)] as List<EntityConditionImplBase>,\n                    EntityCondition.JoinOperator.OR),\n            new ListCondition([new FieldValueCondition(thruField, EQUALS, null),\n                               new FieldValueCondition(thruField, GREATER_THAN, compareStamp)] as List<EntityConditionImplBase>,\n                    EntityCondition.JoinOperator.OR)\n        ] as List<EntityConditionImplBase>, EntityCondition.JoinOperator.AND)\n    }\n\n    @Override int hashCode() { return hashCodeInternal }\n    private int createHashCode() { return compareStamp.hashCode() + fromField.hashCode() + thruField.hashCode() }\n\n    @Override\n    boolean equals(Object o) {\n        if (o == null || o.getClass() != this.getClass()) return false\n        DateCondition that = (DateCondition) o\n        if (!this.compareStamp.equals(that.compareStamp)) return false\n        if (!fromField.equals(that.fromField)) return false\n        if (!thruField.equals(that.thruField)) return false\n        return true\n    }\n\n    @Override\n    void writeExternal(ObjectOutput out) throws IOException {\n        fromField.writeExternal(out)\n        thruField.writeExternal(out)\n        out.writeLong(compareStamp.getTime())\n    }\n    @Override\n    void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException {\n        fromField = new ConditionField()\n        fromField.readExternal(objectInput)\n        thruField = new ConditionField()\n        thruField.readExternal(objectInput)\n        compareStamp = new Timestamp(objectInput.readLong());\n\n        hashCodeInternal = createHashCode();\n        conditionInternal = makeConditionInternal();\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/condition/EntityConditionImplBase.java",
    "content": "package org.moqui.impl.entity.condition;\n\nimport org.moqui.entity.EntityCondition;\nimport org.moqui.impl.entity.EntityDefinition;\nimport org.moqui.impl.entity.EntityQueryBuilder;\n\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\n\npublic interface EntityConditionImplBase extends EntityCondition {\n\n    /** Build SQL WHERE clause text to evaluate condition in a database. */\n    void makeSqlWhere(EntityQueryBuilder eqb, EntityDefinition subMemberEd);\n    /** Build ElasticSearch style search filter */\n    void makeSearchFilter(List<Map<String, Object>> filterList);\n\n    void getAllAliases(Set<String> entityAliasSet, Set<String> fieldAliasSet);\n    /** Get only conditions for fields in the member-entity of a view-entity, or if null then all aliases for member entities without sub-select=true */\n    EntityConditionImplBase filter(String entityAlias, EntityDefinition mainEd);\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/condition/FieldToFieldCondition.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity.condition\n\nimport groovy.transform.CompileStatic\nimport org.moqui.entity.EntityCondition\nimport org.moqui.impl.entity.EntityDefinition\nimport org.moqui.impl.entity.EntityQueryBuilder\nimport org.moqui.impl.entity.EntityConditionFactoryImpl\nimport org.moqui.impl.entity.FieldInfo\nimport org.moqui.util.MNode\n\n@CompileStatic\nclass FieldToFieldCondition implements EntityConditionImplBase {\n    protected static final Class thisClass = FieldValueCondition.class\n    protected ConditionField field\n    protected EntityCondition.ComparisonOperator operator\n    protected ConditionField toField\n    protected boolean ignoreCase = false\n    protected int curHashCode\n\n    FieldToFieldCondition(ConditionField field, EntityCondition.ComparisonOperator operator, ConditionField toField) {\n        this.field = field\n        this.operator = operator ?: EQUALS\n        this.toField = toField\n        curHashCode = createHashCode()\n    }\n\n    @Override\n    void makeSqlWhere(EntityQueryBuilder eqb, EntityDefinition subMemberEd) {\n        StringBuilder sql = eqb.sqlTopLevel\n        EntityDefinition mainEd = eqb.getMainEd()\n        FieldInfo fi = field.getFieldInfo(mainEd)\n        FieldInfo toFi = toField.getFieldInfo(mainEd)\n\n        int typeValue = -1\n        if (ignoreCase) {\n            typeValue = fi?.typeValue ?: 1\n            if (typeValue == 1) sql.append(\"UPPER(\")\n        }\n        if (subMemberEd != null) {\n            MNode aliasNode = fi.fieldNode\n            String aliasField = aliasNode.attribute(\"field\")\n            if (aliasField == null || aliasField.isEmpty()) aliasField = fi.name\n            sql.append(subMemberEd.getColumnName(aliasField))\n        } else {\n            sql.append(field.getColumnName(mainEd))\n        }\n        if (ignoreCase && typeValue == 1) sql.append(\")\")\n\n        sql.append(' ').append(EntityConditionFactoryImpl.getComparisonOperatorString(operator)).append(' ')\n\n        int toTypeValue = -1\n        if (ignoreCase) {\n            toTypeValue = toField.getFieldInfo(mainEd)?.typeValue ?: 1\n            if (toTypeValue == 1) sql.append(\"UPPER(\")\n        }\n        if (subMemberEd != null) {\n            MNode aliasNode = toFi.fieldNode\n            String aliasField = aliasNode.attribute(\"field\")\n            if (aliasField == null || aliasField.isEmpty()) aliasField = toFi.name\n            sql.append(subMemberEd.getColumnName(aliasField))\n        } else {\n            sql.append(toField.getColumnName(mainEd))\n        }\n        if (ignoreCase && toTypeValue == 1) sql.append(\")\")\n    }\n    @Override\n    void makeSearchFilter(List<Map<String, Object>> filterList) {\n        // TODO\n    }\n\n    @Override\n    boolean mapMatches(Map<String, Object> map) {\n        return EntityConditionFactoryImpl.compareByOperator(map.get(field.getFieldName()), operator, map.get(toField.getFieldName()))\n    }\n    @Override\n    boolean mapMatchesAny(Map<String, Object> map) { return mapMatches(map) }\n    @Override\n    boolean mapKeysNotContained(Map<String, Object> map) { return !map.containsKey(field.fieldName) && !map.containsKey(toField.fieldName) }\n\n    @Override\n    boolean populateMap(Map<String, Object> map) { return false }\n\n    @Override\n    void getAllAliases(Set<String> entityAliasSet, Set<String> fieldAliasSet) {\n        // this will only be called for view-entity, so we'll either have a entityAlias or an aliased fieldName\n        if (field instanceof ConditionAlias) {\n            entityAliasSet.add(((ConditionAlias) field).entityAlias)\n        } else {\n            fieldAliasSet.add(field.fieldName)\n        }\n        if (toField instanceof ConditionAlias) {\n            entityAliasSet.add(((ConditionAlias) toField).entityAlias)\n        } else {\n            fieldAliasSet.add(toField.fieldName)\n        }\n    }\n    @Override\n    EntityConditionImplBase filter(String entityAlias, EntityDefinition mainEd) {\n        // only called for view-entity\n        MNode fieldMe = field.getFieldInfo(mainEd).directMemberEntityNode\n        MNode toFieldMe = toField.getFieldInfo(mainEd).directMemberEntityNode\n        if (entityAlias == null) {\n            if (fieldMe != null && toFieldMe != null) {\n                String subSelectAttr = fieldMe.attribute(\"sub-select\");\n                String toSubSelectAttr = toFieldMe.attribute(\"sub-select\");\n                if ((\"true\".equals(subSelectAttr) || \"non-lateral\".equals(subSelectAttr)) &&\n                        (\"true\".equals(toSubSelectAttr) || \"non-lateral\".equals(toSubSelectAttr)) &&\n                        fieldMe.attribute(\"entity-alias\").equals(toFieldMe.attribute(\"entity-alias\")))\n                    return null\n            }\n            return this\n        } else {\n            if ((fieldMe != null && entityAlias.equals(fieldMe.attribute(\"entity-alias\"))) &&\n                    (toFieldMe != null && entityAlias.equals(toFieldMe.attribute(\"entity-alias\")))) return this\n            return null\n        }\n    }\n\n    @Override\n    EntityCondition ignoreCase() { ignoreCase = true; curHashCode++; return this }\n\n    @Override\n    String toString() {\n        return field.toString() + \" \" + EntityConditionFactoryImpl.getComparisonOperatorString(operator) + \" \" + toField.toString()\n    }\n\n    @Override\n    int hashCode() { return curHashCode }\n    private int createHashCode() {\n        return (field ? field.hashCode() : 0) + operator.hashCode() + (toField ? toField.hashCode() : 0) + (ignoreCase ? 1 : 0)\n    }\n\n    @Override\n    boolean equals(Object o) {\n        if (o == null || o.getClass() != thisClass) return false\n        FieldToFieldCondition that = (FieldToFieldCondition) o\n        if (!field.equals(that.field)) return false\n        // NOTE: for Java Enums the != is WAY faster than the .equals\n        if (operator != that.operator) return false\n        if (!toField.equals(that.toField)) return false\n        if (ignoreCase != that.ignoreCase) return false\n        return true\n    }\n\n    @Override\n    void writeExternal(ObjectOutput out) throws IOException {\n        field.writeExternal(out)\n        out.writeUTF(operator.name())\n        toField.writeExternal(out)\n        out.writeBoolean(ignoreCase)\n    }\n    @Override\n    void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException {\n        field = new ConditionField()\n        field.readExternal(objectInput)\n        operator = EntityCondition.ComparisonOperator.valueOf(objectInput.readUTF())\n        toField = new ConditionField()\n        toField.readExternal(objectInput)\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/condition/FieldValueCondition.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity.condition;\n\nimport org.moqui.entity.EntityCondition;\nimport org.moqui.entity.EntityException;\nimport org.moqui.impl.entity.*;\nimport org.moqui.impl.entity.EntityJavaUtil.EntityConditionParameter;\n\nimport org.moqui.util.CollectionUtilities;\nimport org.moqui.util.MNode;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.Externalizable;\nimport java.io.IOException;\nimport java.io.ObjectInput;\nimport java.io.ObjectOutput;\nimport java.util.*;\n\npublic class FieldValueCondition implements EntityConditionImplBase, Externalizable {\n    protected final static Logger logger = LoggerFactory.getLogger(FieldValueCondition.class);\n    private static final Class thisClass = FieldValueCondition.class;\n\n    protected ConditionField field;\n    protected ComparisonOperator operator;\n    protected Object value;\n    protected boolean ignoreCase = false;\n    private int curHashCode;\n\n    public FieldValueCondition() { }\n    public FieldValueCondition(ConditionField field, ComparisonOperator operator, Object value) {\n        this.field = field;\n        this.value = value;\n\n        // default to EQUALS\n        ComparisonOperator tempOp = operator != null ? operator : EQUALS;\n        // if EQUALS and we have a Collection value the IN operator is implied, similar with NOT_EQUAL\n        if (value instanceof Collection) {\n            if (tempOp == EQUALS) tempOp = IN;\n            else if (tempOp == NOT_EQUAL) tempOp = NOT_IN;\n        }\n        this.operator = tempOp;\n\n        curHashCode = createHashCode();\n    }\n\n    public ComparisonOperator getOperator() { return operator; }\n    public String getFieldName() { return field.fieldName; }\n    public Object getValue() { return value; }\n    public boolean getIgnoreCase() { return ignoreCase; }\n\n    @Override\n    public void makeSqlWhere(EntityQueryBuilder eqb, EntityDefinition subMemberEd) {\n        @SuppressWarnings(\"MismatchedQueryAndUpdateOfStringBuilder\")\n        StringBuilder sql = eqb.sqlTopLevel;\n        boolean valueDone = false;\n        EntityDefinition curEd = subMemberEd != null ? subMemberEd : eqb.getMainEd();\n        FieldInfo fi = field.getFieldInfo(curEd);\n        if (fi == null) throw new EntityException(\"Could not find field \" + field.fieldName + \" in entity \" + curEd.getFullEntityName());\n\n        if (value instanceof Collection && ((Collection) value).isEmpty()) {\n            if (operator == IN) {\n                sql.append(\" 1 = 2 \");\n                valueDone = true;\n            } else if (operator == NOT_IN) {\n                sql.append(\" 1 = 1 \");\n                valueDone = true;\n            }\n        } else {\n            if (ignoreCase && fi.typeValue == 1) sql.append(\"UPPER(\");\n            sql.append(field.getColumnName(curEd));\n            if (ignoreCase && fi.typeValue == 1) sql.append(')');\n            sql.append(' ');\n\n            if (value == null) {\n                if (operator == EQUALS || operator == LIKE || operator == IN || operator == BETWEEN) {\n                    sql.append(\" IS NULL\");\n                    valueDone = true;\n                } else if (operator == NOT_EQUAL || operator == NOT_LIKE || operator == NOT_IN || operator == NOT_BETWEEN) {\n                    sql.append(\" IS NOT NULL\");\n                    valueDone = true;\n                }\n            }\n        }\n        if (operator == IS_NULL || operator == IS_NOT_NULL) {\n            sql.append(EntityConditionFactoryImpl.getComparisonOperatorString(operator));\n            valueDone = true;\n        }\n        if (!valueDone) {\n            // append operator\n            sql.append(EntityConditionFactoryImpl.getComparisonOperatorString(operator));\n            // for IN/BETWEEN change string to collection\n            if (operator == IN || operator == NOT_IN || operator == BETWEEN || operator == NOT_BETWEEN)\n                value = valueToCollection(value);\n            if (operator == IN || operator == NOT_IN) {\n                if (value instanceof Collection) {\n                    sql.append(\" (\");\n                    boolean isFirst = true;\n                    for (Object curValue : (Collection) value) {\n                        if (isFirst) isFirst = false; else sql.append(\", \");\n                        sql.append(\"?\");\n                        if (ignoreCase && (curValue instanceof CharSequence)) curValue = curValue.toString().toUpperCase();\n                        eqb.parameters.add(new EntityConditionParameter(fi, curValue, eqb));\n                    }\n                    sql.append(')');\n                } else {\n                    if (ignoreCase && (value instanceof CharSequence)) value = value.toString().toUpperCase();\n                    sql.append(\" (?)\");\n                    eqb.parameters.add(new EntityConditionParameter(fi, value, eqb));\n                }\n            } else if ((operator == BETWEEN || operator == NOT_BETWEEN) && value instanceof Collection &&\n                    ((Collection) value).size() == 2) {\n                sql.append(\" ? AND ?\");\n                Iterator iterator = ((Collection) value).iterator();\n                Object value1 = iterator.next();\n                if (ignoreCase && (value1 instanceof CharSequence)) value1 = value1.toString().toUpperCase();\n                Object value2 = iterator.next();\n                if (ignoreCase && (value2 instanceof CharSequence)) value2 = value2.toString().toUpperCase();\n                eqb.parameters.add(new EntityConditionParameter(fi, value1, eqb));\n                eqb.parameters.add(new EntityConditionParameter(fi, value2, eqb));\n            } else {\n                if (ignoreCase && (value instanceof CharSequence)) value = value.toString().toUpperCase();\n                sql.append(\" ?\");\n                eqb.parameters.add(new EntityConditionParameter(fi, value, eqb));\n            }\n        }\n    }\n    Object valueToCollection(Object value) {\n        if (value instanceof CharSequence) {\n            String valueStr = value.toString();\n            // note: used to do this, now always put in List: if (valueStr.contains(\",\"))\n            value = Arrays.asList(valueStr.split(\",\"));\n        }\n        // TODO: any other useful types to convert?\n        return value;\n    }\n    @Override\n    public void makeSearchFilter(List<Map<String, Object>> filterList) {\n        boolean isNot = false;\n        switch (operator) {\n            case NOT_EQUAL:\n                isNot = true;\n            case EQUALS:\n                Map<String, Object> termMap = CollectionUtilities.toHashMap(\"term\",\n                    CollectionUtilities.toHashMap(field.fieldName,\n                        CollectionUtilities.toHashMap(\"value\", value, \"case_insensitive\", ignoreCase)));\n                if (isNot) {\n                    filterList.add(CollectionUtilities.toHashMap(\"bool\",\n                            CollectionUtilities.toHashMap(\"must_not\", termMap)));\n                } else {\n                    filterList.add(termMap);\n                }\n                break;\n            case NOT_IN:\n                isNot = true;\n            case IN:\n                value = valueToCollection(value);\n                Map<String, Object> termsMap = CollectionUtilities.toHashMap(\"terms\",\n                    CollectionUtilities.toHashMap(field.fieldName, value));\n                if (isNot) {\n                    filterList.add(CollectionUtilities.toHashMap(\"bool\",\n                            CollectionUtilities.toHashMap(\"must_not\", termsMap)));\n                } else {\n                    filterList.add(termsMap);\n                }\n                break;\n            case NOT_LIKE:\n                isNot = true;\n            case LIKE:\n                // this won't be quite the same as SQL, but close:\n                // - % => * same, zero to many of any char\n                // - _ => ? not same, _ is one of any char while ? is zero to one of any char\n                if (value instanceof CharSequence) {\n                    String valueStr = value.toString();\n                    valueStr = valueStr.replaceAll(\"%\", \"*\");\n                    valueStr = valueStr.replaceAll(\"_\", \"?\");\n                    value = valueStr;\n                }\n                Map<String, Object> wildcardMap = CollectionUtilities.toHashMap(\"wildcard\",\n                    CollectionUtilities.toHashMap(field.fieldName,\n                        CollectionUtilities.toHashMap(\"value\", value)));\n                if (isNot) {\n                    filterList.add(CollectionUtilities.toHashMap(\"bool\",\n                            CollectionUtilities.toHashMap(\"must_not\", wildcardMap)));\n                } else {\n                    filterList.add(wildcardMap);\n                }\n                break;\n            case NOT_BETWEEN:\n                isNot = true;\n            case BETWEEN:\n                value = valueToCollection(value);\n                if (value instanceof Collection && ((Collection) value).size() == 2) {\n                    Iterator iterator = ((Collection) value).iterator();\n                    Object value1 = iterator.next();\n                    Object value2 = iterator.next();\n\n                    Map<String, Object> rangeMap = CollectionUtilities.toHashMap(\"range\",\n                        CollectionUtilities.toHashMap(field.fieldName,\n                            CollectionUtilities.toHashMap(\"gte\", value1, \"lte\", value2)));\n                    if (isNot) {\n                        filterList.add(CollectionUtilities.toHashMap(\"bool\",\n                                CollectionUtilities.toHashMap(\"must_not\", rangeMap)));\n                    } else {\n                        filterList.add(rangeMap);\n                    }\n                } else {\n                    throw new IllegalArgumentException(\"BETWEEN requires a Collection type value with 2 entries\");\n                }\n                break;\n            case IS_NULL:\n                filterList.add(CollectionUtilities.toHashMap(\"bool\",\n                    CollectionUtilities.toHashMap(\"must_not\",\n                        CollectionUtilities.toHashMap(\"exists\",\n                            CollectionUtilities.toHashMap(\"field\", field.fieldName)))));\n                break;\n            case IS_NOT_NULL:\n                filterList.add(CollectionUtilities.toHashMap(\"exists\",\n                    CollectionUtilities.toHashMap(\"field\", field.fieldName)));\n                break;\n            case LESS_THAN:\n            case LESS_THAN_EQUAL_TO:\n            case GREATER_THAN:\n            case GREATER_THAN_EQUAL_TO:\n                filterList.add(CollectionUtilities.toHashMap(\"range\",\n                    CollectionUtilities.toHashMap(field.fieldName,\n                        CollectionUtilities.toHashMap(getElasticOperator(), value))));\n                break;\n        }\n    }\n    String getElasticOperator() {\n        switch (operator) {\n            case LESS_THAN: return \"lt\";\n            case LESS_THAN_EQUAL_TO: return \"lte\";\n            case GREATER_THAN: return \"gt\";\n            case GREATER_THAN_EQUAL_TO: return \"gte\";\n            default: return null;\n        }\n    }\n\n    @Override\n    public boolean mapMatches(Map<String, Object> map) {\n        return EntityConditionFactoryImpl.compareByOperator(map.get(field.fieldName), operator, value);\n    }\n    @Override\n    public boolean mapMatchesAny(Map<String, Object> map) { return mapMatches(map); }\n    @Override\n    public boolean mapKeysNotContained(Map<String, Object> map) { return !map.containsKey(field.fieldName); }\n\n    @Override\n    public boolean populateMap(Map<String, Object> map) {\n        if (operator != EQUALS || ignoreCase || field instanceof ConditionAlias) return false;\n        map.put(field.fieldName, value);\n        return true;\n    }\n\n    @Override\n    public void getAllAliases(Set<String> entityAliasSet, Set<String> fieldAliasSet) {\n        // this will only be called for view-entity, so we'll either have a entityAlias or an aliased fieldName\n        if (field instanceof ConditionAlias) {\n            entityAliasSet.add(((ConditionAlias) field).entityAlias);\n        } else {\n            fieldAliasSet.add(field.fieldName);\n        }\n    }\n    @Override\n    public EntityConditionImplBase filter(String entityAlias, EntityDefinition mainEd) {\n        // only called for view-entity\n        FieldInfo fi = field.getFieldInfo(mainEd);\n        MNode fieldMe = fi.directMemberEntityNode;\n        if (entityAlias == null) {\n            if (fieldMe != null) {\n                String subSelectAttr = fieldMe.attribute(\"sub-select\");\n                if (\"true\".equals(subSelectAttr) || \"non-lateral\".equals(subSelectAttr)) return null;\n            }\n            return this;\n        } else {\n            if (fieldMe != null && entityAlias.equals(fieldMe.attribute(\"entity-alias\"))) {\n                if (fi.aliasFieldName != null && !fi.aliasFieldName.equals(field.fieldName)) {\n                    FieldValueCondition newCond = new FieldValueCondition(new ConditionField(fi.aliasFieldName), operator, value);\n                    if (ignoreCase) newCond.ignoreCase();\n                    return newCond;\n                }\n                return this;\n            }\n            return null;\n        }\n    }\n\n    @Override\n    public EntityCondition ignoreCase() { this.ignoreCase = true; curHashCode++; return this; }\n\n    @Override\n    public String toString() {\n        return field.toString() + \" \" + EntityConditionFactoryImpl.getComparisonOperatorString(this.operator) + \" \" +\n                (value != null ? value.toString() + \" (\" + value.getClass().getName() + \")\" : \"null\");\n    }\n\n    @Override\n    public int hashCode() { return curHashCode; }\n    private int createHashCode() {\n        return (field != null ? field.hashCode() : 0) + operator.hashCode() + (value != null ? value.hashCode() : 0) + (ignoreCase ? 1 : 0);\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (o == null || o.getClass() != thisClass) return false;\n        FieldValueCondition that = (FieldValueCondition) o;\n        if (!field.equals(that.field)) return false;\n        if (value != null) {\n            if (!value.equals(that.value)) return false;\n        } else {\n            if (that.value != null) return false;\n        }\n        return operator == that.operator && ignoreCase == that.ignoreCase;\n    }\n\n    @Override\n    public void writeExternal(ObjectOutput out) throws IOException {\n        field.writeExternal(out);\n        // NOTE: found that the serializer in Hazelcast is REALLY slow with writeUTF(), uses String.chatAt() in a for loop, crazy\n        out.writeObject(operator.name().toCharArray());\n        out.writeObject(value);\n        out.writeBoolean(ignoreCase);\n    }\n    @Override\n    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {\n        field = new ConditionField();\n        field.readExternal(in);\n        operator = ComparisonOperator.valueOf(new String((char[]) in.readObject()));\n        value = in.readObject();\n        ignoreCase = in.readBoolean();\n        curHashCode = createHashCode();\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/condition/ListCondition.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity.condition;\n\nimport org.moqui.entity.EntityException;\nimport org.moqui.impl.entity.EntityConditionFactoryImpl;\nimport org.moqui.impl.entity.EntityDefinition;\nimport org.moqui.impl.entity.EntityQueryBuilder;\nimport org.moqui.entity.EntityCondition;\nimport org.moqui.util.CollectionUtilities;\n\nimport java.io.IOException;\nimport java.io.ObjectInput;\nimport java.io.ObjectOutput;\nimport java.util.*;\n\npublic class ListCondition implements EntityConditionImplBase {\n    private ArrayList<EntityConditionImplBase> conditionList = new ArrayList<>();\n    protected JoinOperator operator;\n    private int conditionListSize = 0;\n    private int curHashCode;\n    private static final Class thisClass = ListCondition.class;\n\n    public ListCondition(List<EntityConditionImplBase> conditionList, JoinOperator operator) {\n        this.operator = operator != null ? operator : AND;\n        if (conditionList != null) {\n            conditionListSize = conditionList.size();\n            if (conditionListSize > 0) {\n                if (conditionList instanceof RandomAccess) {\n                    // avoid creating an iterator if possible\n                    int listSize = conditionList.size();\n                    for (int i = 0; i < listSize; i++) {\n                        EntityConditionImplBase cond = conditionList.get(i);\n                        if (cond != null) this.conditionList.add(cond);\n                    }\n                } else {\n                    Iterator<EntityConditionImplBase> conditionIter = conditionList.iterator();\n                    while (conditionIter.hasNext()) {\n                        EntityConditionImplBase cond = conditionIter.next();\n                        if (cond != null) this.conditionList.add(cond);\n                    }\n                }\n            }\n        }\n\n        curHashCode = createHashCode();\n    }\n\n    public void addCondition(EntityConditionImplBase condition) {\n        if (condition != null) conditionList.add(condition);\n        curHashCode = createHashCode();\n        conditionListSize = conditionList.size();\n    }\n    public void addConditions(ArrayList<EntityConditionImplBase> condList) {\n        int condListSize = condList != null ? condList.size() : 0;\n        if (condListSize == 0) return;\n        for (int i = 0; i < condListSize; i++) addCondition(condList.get(i));\n        curHashCode = createHashCode();\n        conditionListSize = conditionList.size();\n    }\n    public void addConditions(ListCondition listCond) { addConditions(listCond.getConditionList()); }\n\n    public JoinOperator getOperator() { return operator; }\n    public ArrayList<EntityConditionImplBase> getConditionList() { return conditionList; }\n\n    @SuppressWarnings(\"MismatchedQueryAndUpdateOfStringBuilder\")\n    @Override\n    public void makeSqlWhere(EntityQueryBuilder eqb, EntityDefinition subMemberEd) {\n        if (conditionListSize == 0) return;\n\n        StringBuilder sql = eqb.sqlTopLevel;\n        String joinOpString = EntityConditionFactoryImpl.getJoinOperatorString(this.operator);\n        if (conditionListSize > 1) sql.append('(');\n        for (int i = 0; i < conditionListSize; i++) {\n            EntityConditionImplBase condition = conditionList.get(i);\n            if (i > 0) sql.append(' ').append(joinOpString).append(' ');\n            condition.makeSqlWhere(eqb, subMemberEd);\n        }\n        if (conditionListSize > 1) sql.append(')');\n    }\n    @Override\n    public void makeSearchFilter(List<Map<String, Object>> filterList) {\n        if (conditionListSize == 0) return;\n\n        List<Map<String, Object>> childList = new ArrayList<>(conditionListSize);\n        for (int i = 0; i < conditionListSize; i++) {\n            EntityConditionImplBase condition = conditionList.get(i);\n            condition.makeSearchFilter(childList);\n        }\n\n        Map<String, Object> boolMap = new HashMap<>();\n        if (operator == AND) boolMap.put(\"filter\", childList);\n        else boolMap.put(\"should\", childList);\n\n        filterList.add(CollectionUtilities.toHashMap(\"bool\", boolMap));\n    }\n\n    @Override\n    public boolean mapMatches(Map<String, Object> map) {\n        for (int i = 0; i < conditionListSize; i++) {\n            EntityConditionImplBase condition = conditionList.get(i);\n            boolean conditionMatches = condition.mapMatches(map);\n            if (conditionMatches && this.operator == OR) return true;\n            if (!conditionMatches && this.operator == AND) return false;\n        }\n        // if we got here it means that it's an OR with no trues, or an AND with no falses\n        return (this.operator == AND);\n    }\n    @Override\n    public boolean mapMatchesAny(Map<String, Object> map) {\n        for (int i = 0; i < conditionListSize; i++) {\n            EntityConditionImplBase condition = conditionList.get(i);\n            boolean conditionMatches = condition.mapMatchesAny(map);\n            if (conditionMatches) return true;\n        }\n        return false;\n    }\n    @Override\n    public boolean mapKeysNotContained(Map<String, Object> map) {\n        for (int i = 0; i < conditionListSize; i++) {\n            EntityConditionImplBase condition = conditionList.get(i);\n            boolean notContained = condition.mapKeysNotContained(map);\n            if (!notContained) return false;\n        }\n        // if we got here it means that it's an OR with no trues, or an AND with no falses\n        return true;\n    }\n\n    @Override\n    public boolean populateMap(Map<String, Object> map) {\n        if (operator != AND) return false;\n        for (int i = 0; i < conditionListSize; i++) {\n            EntityConditionImplBase condition = conditionList.get(i);\n            if (!condition.populateMap(map)) return false;\n        }\n        return true;\n    }\n\n    @Override\n    public void getAllAliases(Set<String> entityAliasSet, Set<String> fieldAliasSet) {\n        for (int i = 0; i < conditionListSize; i++) {\n            EntityConditionImplBase condition = conditionList.get(i);\n            condition.getAllAliases(entityAliasSet, fieldAliasSet);\n        }\n    }\n    @Override\n    public EntityConditionImplBase filter(String entityAlias, EntityDefinition mainEd) {\n        ArrayList<EntityConditionImplBase> filteredList = new ArrayList<>(conditionList.size());\n        for (int i = 0; i < conditionListSize; i++) {\n            EntityConditionImplBase curCond = conditionList.get(i);\n            EntityConditionImplBase filterCond = curCond.filter(entityAlias, mainEd);\n            if (filterCond != null) filteredList.add(filterCond);\n        }\n        int filteredSize = filteredList.size();\n        if (filteredSize == conditionListSize) return this;\n        if (filteredSize == 0) return null;\n        // keep OR conditions together: return all if entityAlias is null (top-level where) or null if not (sub-select where)\n        if (operator == OR) {\n            if (entityAlias == null) return this;\n            return null;\n        } else {\n            if (filteredSize == 1) return filteredList.get(0);\n            return new ListCondition(filteredList, operator);\n        }\n    }\n\n    @Override\n    public EntityCondition ignoreCase() { throw new EntityException(\"Ignore case not supported for this type of condition.\"); }\n\n    @Override\n    public String toString() {\n        StringBuilder sb = new StringBuilder();\n        for (int i = 0; i < conditionListSize; i++) {\n            EntityConditionImplBase condition = conditionList.get(i);\n            if (sb.length() > 0) sb.append(' ').append(EntityConditionFactoryImpl.getJoinOperatorString(this.operator)).append(' ');\n            sb.append('(').append(condition.toString()).append(')');\n        }\n        return sb.toString();\n    }\n\n    @Override public int hashCode() { return curHashCode; }\n    private int createHashCode() { return (conditionList != null ? conditionList.hashCode() : 0) + operator.hashCode(); }\n\n    @Override\n    public boolean equals(Object o) {\n        if (o == null || o.getClass() != thisClass) return false;\n        ListCondition that = (ListCondition) o;\n        // NOTE: for Java Enums the != is WAY faster than the .equals\n        return this.operator == that.operator && this.conditionList.equals(that.conditionList);\n    }\n\n    @Override\n    public void writeExternal(ObjectOutput out) throws IOException {\n        out.writeObject(conditionList);\n        out.writeObject(operator.name().toCharArray());\n    }\n    @Override\n    @SuppressWarnings(\"unchecked\")\n    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {\n        conditionList = (ArrayList<EntityConditionImplBase>) in.readObject();\n        operator = JoinOperator.valueOf(new String((char[]) in.readObject()));\n        curHashCode = createHashCode();\n        conditionListSize = conditionList != null ? conditionList.size() : 0;\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/condition/TrueCondition.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity.condition;\n\nimport org.moqui.entity.EntityCondition;\nimport org.moqui.impl.entity.EntityDefinition;\nimport org.moqui.impl.entity.EntityQueryBuilder;\n\nimport java.io.IOException;\nimport java.io.ObjectInput;\nimport java.io.ObjectOutput;\nimport java.util.*;\n\npublic class TrueCondition implements EntityConditionImplBase {\n    private static final Class thisClass = TrueCondition.class;\n\n    public TrueCondition() { }\n\n    @Override public void makeSqlWhere(EntityQueryBuilder eqb, EntityDefinition subMemberEd) { eqb.sqlTopLevel.append(\"1=1\"); }\n    @Override\n    public void makeSearchFilter(List<Map<String, Object>> filterList) {\n        // TODO how would this work in ES?\n    }\n\n    @Override public boolean mapMatches(Map<String, Object> map) { return true; }\n    @Override public boolean mapMatchesAny(Map<String, Object> map) { return true; }\n    @Override public boolean mapKeysNotContained(Map<String, Object> map) { return true; }\n\n    @Override public boolean populateMap(Map<String, Object> map) { return true; }\n    @Override public void getAllAliases(Set<String> entityAliasSet, Set<String> fieldAliasSet) { }\n    @Override public EntityConditionImplBase filter(String entityAlias, EntityDefinition mainEd) { return entityAlias == null ? this : null; }\n\n    @Override public EntityCondition ignoreCase() { return this; }\n    @Override public String toString() { return \"1=1\"; }\n\n    @Override public int hashCode() { return 127; }\n    @Override public boolean equals(Object o) { return !(o == null || o.getClass() != thisClass); }\n\n    @Override public void writeExternal(ObjectOutput out) throws IOException { }\n    @Override public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException { }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/condition/WhereCondition.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity.condition\n\nimport groovy.transform.CompileStatic\nimport org.moqui.entity.EntityCondition\nimport org.moqui.entity.EntityException\nimport org.moqui.impl.entity.EntityDefinition\nimport org.moqui.impl.entity.EntityQueryBuilder\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n@CompileStatic\nclass WhereCondition implements EntityConditionImplBase {\n    protected final static Logger logger = LoggerFactory.getLogger(WhereCondition.class)\n    protected String sqlWhereClause\n\n    WhereCondition(String sqlWhereClause) {\n        this.sqlWhereClause = sqlWhereClause != null ? sqlWhereClause : \"\"\n    }\n\n    @Override void makeSqlWhere(EntityQueryBuilder eqb, EntityDefinition subMemberEd) { eqb.sqlTopLevel.append(this.sqlWhereClause) }\n    @Override\n    public void makeSearchFilter(List<Map<String, Object>> filterList) {\n        throw new IllegalArgumentException(\"Where Condition not supported for Elastic Entity\")\n    }\n\n    @Override\n    boolean mapMatches(Map<String, Object> map) {\n        // NOTE: always return false unless we eventually implement some sort of SQL parsing, for caching/etc\n        // always consider not matching\n        logger.warn(\"The mapMatches for the SQL Where Condition is not supported, text is [${this.sqlWhereClause}]\")\n        return false\n    }\n    @Override\n    boolean mapMatchesAny(Map<String, Object> map) {\n        // NOTE: always return true unless we eventually implement some sort of SQL parsing, for caching/etc\n        // always consider matching so cache values are cleared\n        logger.warn(\"The mapMatchesAny for the SQL Where Condition is not supported, text is [${this.sqlWhereClause}]\")\n        return true\n    }\n    @Override\n    boolean mapKeysNotContained(Map<String, Object> map) {\n        // always consider matching so cache values are cleared\n        logger.warn(\"The mapMatchesAny for the SQL Where Condition is not supported, text is [${this.sqlWhereClause}]\")\n        return true\n    }\n\n    @Override boolean populateMap(Map<String, Object> map) { return false }\n    @Override void getAllAliases(Set<String> entityAliasSet, Set<String> fieldAliasSet) { }\n    @Override EntityConditionImplBase filter(String entityAlias, EntityDefinition mainEd) { return entityAlias == null ? this : null }\n\n    @Override EntityCondition ignoreCase() { throw new EntityException(\"Ignore case not supported for this type of condition.\") }\n    @Override String toString() { return sqlWhereClause }\n    @Override int hashCode() { return (sqlWhereClause != null ? sqlWhereClause.hashCode() : 0) }\n\n    @Override\n    boolean equals(Object o) {\n        if (o == null || o.getClass() != getClass()) return false\n        WhereCondition that = (WhereCondition) o\n        if (!sqlWhereClause.equals(that.sqlWhereClause)) return false\n        return true\n    }\n\n    @Override void writeExternal(ObjectOutput out) throws IOException { out.writeUTF(sqlWhereClause) }\n    @Override void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException { sqlWhereClause = objectInput.readUTF() }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/elastic/ElasticDatasourceFactory.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity.elastic\n\nimport groovy.json.JsonOutput\nimport groovy.transform.CompileStatic\nimport org.moqui.context.ElasticFacade\nimport org.moqui.entity.*\nimport org.moqui.impl.entity.EntityDefinition\nimport org.moqui.impl.entity.EntityFacadeImpl\nimport org.moqui.impl.entity.EntityValueBase\nimport org.moqui.impl.entity.FieldInfo\nimport org.moqui.util.LiteStringMap\nimport org.moqui.util.MNode\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport jakarta.xml.bind.DatatypeConverter\n\nimport javax.sql.DataSource\nimport java.sql.Time\nimport java.sql.Timestamp\n\n/**\n * To use this:\n * 1. add a datasource under the entity-facade element in the Moqui Conf file; for example:\n *      <datasource group-name=\"nontransactional\" object-factory=\"org.moqui.impl.entity.orientdb.OrientDatasourceFactory\">\n *          <inline-other uri=\"plocal:${ORIENTDB_HOME}/databases/MoquiNoSql\" username=\"admin\" password=\"admin\"/>\n *      </datasource>\n *\n * 2. to get OrientDB to automatically create the database, add a corresponding \"storage\" element to the\n *      orientdb-server-config.xml file\n *\n * 3. add the group attribute to entity elements as needed to point them to the new datasource; for example:\n *      group=\"nontransactional\"\n */\n@CompileStatic\nclass ElasticDatasourceFactory implements EntityDatasourceFactory {\n    protected final static Logger logger = LoggerFactory.getLogger(ElasticDatasourceFactory.class)\n\n    protected EntityFacadeImpl efi\n    protected MNode datasourceNode\n    protected String indexPrefix, clusterName\n\n    protected Set<String> checkedEntityIndexSet = new HashSet<String>()\n\n    ElasticDatasourceFactory() { }\n\n    @Override\n    EntityDatasourceFactory init(EntityFacade ef, MNode datasourceNode) {\n        // local fields\n        this.efi = (EntityFacadeImpl) ef\n        this.datasourceNode = datasourceNode\n\n        // init the DataSource\n        MNode inlineOtherNode = datasourceNode.first(\"inline-other\")\n        inlineOtherNode.setSystemExpandAttributes(true)\n        indexPrefix = inlineOtherNode.attribute(\"index-prefix\") ?: \"\"\n        clusterName = inlineOtherNode.attribute(\"cluster-name\") ?: \"default\"\n\n        return this\n    }\n\n    @Override\n    void destroy() {\n    }\n\n    @Override\n    boolean checkTableExists(String entityName) {\n        EntityDefinition ed\n        try { ed = efi.getEntityDefinition(entityName) }\n        catch (EntityException e) { return false }\n        if (ed == null) return false\n\n        String indexName = getIndexName(ed)\n        if (checkedEntityIndexSet.contains(indexName)) return true\n\n        if (!getElasticClient().indexExists(indexName)) return false\n\n        checkedEntityIndexSet.add(indexName)\n        return true\n    }\n    @Override\n    boolean checkAndAddTable(String entityName) {\n        EntityDefinition ed\n        try { ed = efi.getEntityDefinition(entityName) }\n        catch (EntityException e) { return false }\n        if (ed == null) return false\n        checkCreateDocumentIndex(ed)\n        return true\n    }\n    @Override\n    int checkAndAddAllTables() {\n        int tablesAdded = 0\n        String groupName = datasourceNode.attribute(\"group-name\")\n\n        for (String entityName in efi.getAllEntityNames()) {\n            String entGroupName = efi.getEntityGroupName(entityName) ?: efi.defaultGroupName\n            if (entGroupName.equals(groupName)) {\n                if (checkAndAddTable(entityName)) tablesAdded++\n            }\n        }\n\n        return tablesAdded\n    }\n\n    @Override\n    EntityValue makeEntityValue(String entityName) {\n        EntityDefinition ed = efi.getEntityDefinition(entityName)\n        if (ed == null) throw new EntityException(\"Entity not found for name ${entityName}\")\n        return new ElasticEntityValue(ed, efi, this)\n    }\n\n    @Override\n    EntityFind makeEntityFind(String entityName) { return new ElasticEntityFind(efi, entityName, this) }\n\n    @Override\n    void createBulk(List<EntityValue> valueList) {\n        if (valueList == null || valueList.isEmpty()) return\n        ElasticFacade.ElasticClient elasticClient = getElasticClient()\n\n        EntityValueBase firstEv = (EntityValueBase) valueList.get(0)\n        EntityDefinition ed = firstEv.getEntityDefinition()\n\n        FieldInfo[] pkFieldInfos = ed.entityInfo.pkFieldInfoArray\n        String idField = pkFieldInfos.length == 1 ? pkFieldInfos[0].name : \"_id\"\n\n        List<Map> mapList = new ArrayList<Map>(valueList.size())\n        Iterator<EntityValue> valueIterator = valueList.iterator()\n        while (valueIterator.hasNext()) {\n            EntityValueBase ev = (EntityValueBase) valueIterator.next()\n            LiteStringMap<Object> evMap = ev.getValueMap()\n            // to pass a key/id for each record it has to be in the Map, this will cause the LiteStringMap to grow\n            //     the array for the additional field, so there is a performance overhead to this\n            if (pkFieldInfos.length > 1) evMap.put(\"_id\", ev.getPrimaryKeysString())\n            mapList.add(evMap)\n        }\n\n        checkCreateDocumentIndex(ed)\n        elasticClient.bulkIndex(getIndexName(ed), (String) null, idField, (List<Map>) mapList, true)\n    }\n\n    @Override\n    DataSource getDataSource() {\n        //  no DataSource for this 'db', return nothing and EntityFacade ignores it and Connection parameters will be null (in ElasticEntityValue, etc)\n        return null\n    }\n\n    void checkCreateDocumentIndex(EntityDefinition ed) {\n        String indexName = getIndexName(ed)\n        if (checkedEntityIndexSet.contains(indexName)) return\n\n        ElasticFacade.ElasticClient elasticClient = efi.ecfi.elasticFacade.getClient(clusterName)\n        if (elasticClient == null) throw new IllegalStateException(\"No ElasticClient found for cluster name \" + clusterName)\n        if (!elasticClient.indexExists(indexName)) {\n            Map mapping = makeElasticEntityMapping(ed)\n            // logger.warn(\"Creating ES Index ${indexName} with mapping: ${JsonOutput.prettyPrint(JsonOutput.toJson(mapping))}\")\n            elasticClient.createIndex(indexName, mapping, null)\n        }\n\n        checkedEntityIndexSet.add(indexName)\n    }\n\n    ElasticFacade.ElasticClient getElasticClient() {\n        ElasticFacade.ElasticClient client = efi.ecfi.elasticFacade.getClient(clusterName)\n        if (client == null) throw new IllegalStateException(\"No ElasticClient found for cluster name \" + clusterName)\n        return client\n    }\n    String getIndexName(EntityDefinition ed) {\n        return indexPrefix + ed.getTableNameLowerCase()\n    }\n\n    static Object convertFieldValue(FieldInfo fi, Object fValue) {\n        if (fi.typeValue == EntityFacadeImpl.ENTITY_TIMESTAMP) {\n            if (fValue instanceof Number) {\n                return new Timestamp(((Number) fValue).longValue())\n            } else if (fValue instanceof CharSequence) {\n                Calendar cal = DatatypeConverter.parseDateTime(fValue.toString())\n                if (cal != null) return new Timestamp(cal.getTimeInMillis())\n            }\n        } else if (fi.typeValue == EntityFacadeImpl.ENTITY_DATE) {\n            if (fValue instanceof Number) {\n                return new java.sql.Date(((Number) fValue).longValue())\n            } else if (fValue instanceof CharSequence) {\n                Calendar cal = DatatypeConverter.parseDate(fValue.toString())\n                if (cal != null) return new java.sql.Date(cal.getTimeInMillis())\n            }\n        } else if (fi.typeValue == EntityFacadeImpl.ENTITY_TIME) {\n            if (fValue instanceof Number) {\n                return new Time(((Number) fValue).longValue())\n            } else if (fValue instanceof CharSequence) {\n                Calendar cal = DatatypeConverter.parseTime(fValue.toString())\n                if (cal != null) return new Time(cal.getTimeInMillis())\n            }\n        }\n        return fValue\n    }\n\n    static final Map<String, String> esEntityTypeMap = [id:'keyword', 'id-long':'keyword', date:'date', time:'keyword',\n            'date-time':'date', 'number-integer':'long', 'number-decimal':'double', 'number-float':'double',\n            'currency-amount':'double', 'currency-precise':'double', 'text-indicator':'keyword', 'text-short':'text',\n            'text-medium':'text', 'text-intermediate':'text', 'text-long':'text', 'text-very-long':'text', 'binary-very-long':'binary']\n    static final Set<String> esEntityIsKeywordSet = esEntityTypeMap.findAll({\"keyword\".equals(it.value)}).keySet()\n    static final Set<String> esEntityAddKeywordSet = new HashSet<>(['text-short', 'text-medium', 'text-intermediate'])\n\n    static Map makeElasticEntityMapping(EntityDefinition ed) {\n        Map<String, Object> rootProperties = [_entity:[type:'keyword']] as Map<String, Object>\n        Map<String, Object> mappingMap = [properties:rootProperties] as Map<String, Object>\n\n        FieldInfo[] allFieldInfo = ed.entityInfo.allFieldInfoArray\n        for (int i = 0; i < allFieldInfo.length; i++) {\n            FieldInfo fieldInfo = allFieldInfo[i]\n            rootProperties.put(fieldInfo.name, makeEntityFieldPropertyMap(fieldInfo))\n        }\n\n        return mappingMap\n    }\n    static Map makeEntityFieldPropertyMap(FieldInfo fieldInfo) {\n        String mappingType = esEntityTypeMap.get(fieldInfo.type) ?: 'text'\n        Map<String, Object> propertyMap = new LinkedHashMap<>()\n        propertyMap.put(\"type\", mappingType)\n        if (esEntityAddKeywordSet.contains(fieldInfo.type) && \"text\".equals(mappingType))\n            propertyMap.put(\"fields\", [keyword: [type: \"keyword\"]])\n        if (\"date-time\".equals(fieldInfo.type))\n            propertyMap.format = \"date_time||epoch_millis||date_time_no_millis||yyyy-MM-dd HH:mm:ss.SSS||yyyy-MM-dd HH:mm:ss.S||yyyy-MM-dd\"\n        else if (\"date\".equals(fieldInfo.type))\n            propertyMap.format = \"date||strict_date_optional_time||epoch_millis\"\n        return propertyMap\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/elastic/ElasticEntityFind.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity.elastic;\n\nimport org.moqui.context.ElasticFacade;\nimport org.moqui.entity.EntityDynamicView;\nimport org.moqui.entity.EntityException;\nimport org.moqui.entity.EntityListIterator;\nimport org.moqui.impl.entity.*;\nimport org.moqui.impl.entity.condition.EntityConditionImplBase;\nimport org.moqui.util.CollectionUtilities;\nimport org.moqui.util.LiteStringMap;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.*;\n\npublic class ElasticEntityFind extends EntityFindBase {\n    protected static final Logger logger = LoggerFactory.getLogger(ElasticEntityValue.class);\n    private final ElasticDatasourceFactory edf;\n\n    public ElasticEntityFind(EntityFacadeImpl efip, String entityName, ElasticDatasourceFactory edf) {\n        super(efip, entityName);\n        this.edf = edf;\n    }\n\n    @Override\n    public EntityDynamicView makeEntityDynamicView() {\n        throw new UnsupportedOperationException(\"EntityDynamicView is not yet supported for Orient DB\");\n    }\n\n    Map<String, Object> makeQueryMap(EntityConditionImplBase whereCondition) {\n        List<Map<String, Object>> filterList = new ArrayList<>();\n        whereCondition.makeSearchFilter(filterList);\n        Map<String, Object> boolMap = new HashMap<>();\n        boolMap.put(\"filter\", filterList);\n        Map<String, Object> queryMap = new HashMap<>();\n        queryMap.put(\"bool\", boolMap);\n        return queryMap;\n    }\n    List<Object> makeSortList(ArrayList<String> orderByExpanded, EntityDefinition ed) {\n        int orderByExpandedSize = orderByExpanded != null ? orderByExpanded.size() : 0;\n        if (orderByExpandedSize > 0) {\n            List<Object> sortList = new ArrayList<>(orderByExpandedSize);\n            for (int i = 0; i < orderByExpandedSize; i++) {\n                String sortField = orderByExpanded.get(i);\n                EntityJavaUtil.FieldOrderOptions foo = new EntityJavaUtil.FieldOrderOptions(sortField);\n                // to make this more fun, need to look for fields which have: keyword child field, text with no keyword (can't sort)\n                String fieldName = foo.getFieldName();\n                FieldInfo fi = ed.getFieldInfo(fieldName);\n                if (ElasticDatasourceFactory.getEsEntityAddKeywordSet().contains(fi.type))\n                    fieldName += \".keyword\";\n                else if (\"text\".equals(ElasticDatasourceFactory.getEsEntityTypeMap().get(fi.type)))\n                    throw new IllegalArgumentException(\"Cannot sort by field \" + fi.name + \" with type \" + fi.type);\n                if (foo.getDescending()) {\n                    sortList.add(CollectionUtilities.toHashMap(fieldName, \"desc\"));\n                } else {\n                    sortList.add(fieldName);\n                }\n            }\n            // logger.warn(\"new sortList \" + sortList + \" from orderBy \" + orderByExpanded);\n            return sortList;\n        }\n        return null;\n    }\n\n    @Override\n    public EntityValueBase oneExtended(EntityConditionImplBase whereCondition, FieldInfo[] fieldInfoArray,\n            EntityJavaUtil.FieldOrderOptions[] fieldOptionsArray) throws EntityException {\n        EntityDefinition ed = this.getEntityDef();\n        if (ed.isViewEntity) throw new EntityException(\"Multi-entity view entities are not supported, Elastic/OpenSearch does not support joins; single-entity view entities for aggregations are not yet supported (future feature)\");\n\n        edf.checkCreateDocumentIndex(ed);\n        ElasticFacade.ElasticClient elasticClient = edf.getElasticClient();\n\n        // TODO FUTURE: consider building a JSON string instead of Map/List structure with lots of objects,\n        //     will perform better and have way less memory overhead, but code will be a lot more complicated\n\n        // optimization if we have full PK: use ElasticClient.get()\n        if (tempHasFullPk) {\n            // we may have a singleCondField/Value OR simpleAndMap with the PK\n            String combinedId;\n            if (singleCondField != null) {\n                combinedId = singleCondValue.toString();\n            } else {\n                combinedId = ed.getPrimaryKeysString(simpleAndMap);\n            }\n            Map getResponse = elasticClient.get(edf.getIndexName(ed), combinedId);\n            if (getResponse == null) return null;\n            Map dbValue = (Map) getResponse.get(\"_source\");\n            if (dbValue == null) return null;\n\n            ElasticEntityValue newValue = new ElasticEntityValue(ed, efi, edf);\n            LiteStringMap<Object> valueMap = newValue.getValueMap();\n\n            FieldInfo[] allFieldArray = ed.entityInfo.allFieldInfoArray;\n            for (int j = 0; j < allFieldArray.length; j++) {\n                FieldInfo fi = allFieldArray[j];\n                Object fValue = ElasticDatasourceFactory.convertFieldValue(fi, dbValue.get(fi.name));\n                valueMap.putByIString(fi.name, fValue, fi.index);\n            }\n\n            newValue.setSyncedWithDb();\n            return newValue;\n        } else {\n            Map<String, Object> searchMap = new LinkedHashMap<>();\n            // query\n            if (whereCondition != null) searchMap.put(\"query\", makeQueryMap(whereCondition));\n            // _source or fields\n            // TODO: use _source or fields to get partial documents, some possible oddness to it: https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-source-field.html\n            // size\n            searchMap.put(\"size\", 1);\n\n            logger.warn(\"find one elastic searchMap \" + searchMap);\n            Map resultMap = elasticClient.search(edf.getIndexName(ed), searchMap);\n            Map hitsMap = (Map) resultMap.get(\"hits\");\n            List hitsList = (List) hitsMap.get(\"hits\");\n\n            if (hitsList != null && hitsList.size() > 0) {\n                Map firstHit = (Map) hitsList.get(0);\n                if (firstHit != null) {\n                    Map hitSource = (Map) firstHit.get(\"_source\");\n                    ElasticEntityValue newValue = new ElasticEntityValue(ed, efi, edf);\n                    LiteStringMap<Object> valueMap = newValue.getValueMap();\n                    int size = fieldInfoArray.length;\n                    for (int i = 0; i < size; i++) {\n                        FieldInfo fi = fieldInfoArray[i];\n                        if (fi == null) break;\n                        valueMap.putByIString(fi.name, hitSource.get(fi.name), fi.index);\n                    }\n                    newValue.setSyncedWithDb();\n                    return newValue;\n                }\n            }\n            return null;\n        }\n    }\n\n    @Override\n    public EntityListIterator iteratorExtended(EntityConditionImplBase whereCondition, EntityConditionImplBase havingCondition,\n            ArrayList<String> orderByExpanded, FieldInfo[] fieldInfoArray, EntityJavaUtil.FieldOrderOptions[] fieldOptionsArray)\n            throws EntityException {\n        EntityDefinition ed = this.getEntityDef();\n        if (ed.isViewEntity) throw new EntityException(\"Multi-entity view entities are not supported, Elastic/OpenSearch does not support joins; single-entity view entities for aggregations are not yet supported (future feature)\");\n        if (havingCondition != null) throw new EntityException(\"Having condition not supported, no view-entity support yet (future feature along with single-entity view entities for aggregations)\");\n        // also not supported: if (this.getDistinct()) efb.makeDistinct()\n\n        // TODO FUTURE: consider building a JSON string instead of Map/List structure with lots of objects,\n        //     will perform better and have way less memory overhead, but code will be a lot more complicated\n\n        Map<String, Object> searchMap = new LinkedHashMap<>();\n        // query\n        Map queryMap = whereCondition != null ? makeQueryMap(whereCondition) : null;\n        if (queryMap == null || queryMap.isEmpty())\n            queryMap = CollectionUtilities.toHashMap(\"match_all\", Collections.EMPTY_MAP);\n        searchMap.put(\"query\", queryMap);\n        // _source or fields\n        // TODO: use _source or fields to get partial documents, some possible oddness to it: https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-source-field.html\n        // sort with fieldOptionsArray\n        List<Object> sortList = makeSortList(orderByExpanded, ed);\n        if (sortList == null) {\n            // if no sort, sort by PK fields by default (for pagination over large queries a sort order is always required)\n            sortList = new LinkedList<>();\n            FieldInfo[] pkFieldInfos = ed.entityInfo.pkFieldInfoArray;\n            for (int i = 0; i < pkFieldInfos.length; i++) {\n                FieldInfo fi = pkFieldInfos[i];\n                sortList.add(fi.name);\n            }\n        }\n        searchMap.put(\"sort\", sortList);\n\n        // from & size\n        if (this.offset != null) searchMap.put(\"from\", this.offset);\n        if (this.limit != null) searchMap.put(\"size\", this.limit);\n\n        edf.checkCreateDocumentIndex(ed);\n\n        return new ElasticEntityListIterator(searchMap, ed, fieldInfoArray, fieldOptionsArray, edf, txCache,\n                whereCondition, orderByExpanded);\n    }\n\n    @Override\n    public long countExtended(EntityConditionImplBase whereCondition, EntityConditionImplBase havingCondition, FieldInfo[] fieldInfoArray, EntityJavaUtil.FieldOrderOptions[] fieldOptionsArray) throws EntityException {\n        EntityDefinition ed = this.getEntityDef();\n        if (ed.isViewEntity) throw new EntityException(\"Multi-entity view entities are not supported, Elastic/OpenSearch does not support joins; single-entity view entities for aggregations are not yet supported (future feature)\");\n        if (havingCondition != null) throw new EntityException(\"Having condition not supported, no view-entity support yet (future feature along with single-entity view entities for aggregations)\");\n        // also not supported: if (this.getDistinct()) efb.makeDistinct()\n\n        // TODO FUTURE: consider building a JSON string instead of Map/List structure with lots of objects,\n        //     will perform better and have way less memory overhead, but code will be a lot more complicated\n\n        Map<String, Object> countMap = new LinkedHashMap<>();\n        // query\n        if (whereCondition != null) countMap.put(\"query\", makeQueryMap(whereCondition));\n        // NOTE: if no where condition don't need to add default all query map, ElasticClient.countResponse() does this (used by count())\n\n        edf.checkCreateDocumentIndex(ed);\n        ElasticFacade.ElasticClient elasticClient = edf.getElasticClient();\n        return elasticClient.count(edf.getIndexName(ed), countMap);\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/elastic/ElasticEntityListIterator.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity.elastic;\n\nimport groovy.json.JsonOutput;\nimport org.moqui.BaseArtifactException;\nimport org.moqui.context.ArtifactExecutionInfo;\nimport org.moqui.context.ElasticFacade;\nimport org.moqui.entity.*;\nimport org.moqui.impl.context.TransactionCache;\nimport org.moqui.impl.entity.*;\nimport org.moqui.impl.entity.condition.EntityConditionImplBase;\nimport org.moqui.util.CollectionUtilities;\nimport org.moqui.util.LiteStringMap;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.Writer;\nimport java.util.*;\n\npublic class ElasticEntityListIterator implements EntityListIterator {\n    protected static final Logger logger = LoggerFactory.getLogger(ElasticEntityListIterator.class);\n\n    static final int MAX_FETCH_SIZE = 100;\n    static final int CUR_LIST_MAX_SIZE = MAX_FETCH_SIZE * 3;\n    private int fetchSize = 50;\n\n    protected final ElasticDatasourceFactory edf;\n    protected final EntityFacadeImpl efi;\n    protected final Map<String, Object> originalSearchMap;\n    private final EntityDefinition entityDefinition;\n    protected final FieldInfo[] fieldInfoArray;\n    private final int fieldInfoListSize;\n\n    private ArrayList<Map> currentDocList = new ArrayList<>(CUR_LIST_MAX_SIZE);\n    private int overallIndex = -1, currentListStartIndex = -1;\n    private Integer resultCount = null;\n    private final Integer maxResultCount, originalFromInt;\n    private String esPitId = null, esKeepAlive;\n    private List<Object> esSearchAfter = null;\n\n    private final TransactionCache txCache;\n    private final EntityJavaUtil.FindAugmentInfo findAugmentInfo;\n    private final int txcListSize;\n    private int txcListIndex = -1;\n    private final EntityConditionImplBase whereCondition;\n    private final CollectionUtilities.MapOrderByComparator orderByComparator;\n\n    private boolean haveMadeValue = false;\n    protected boolean closed = false;\n    private StackTraceElement[] constructStack = null;\n    private final ArrayList<ArtifactExecutionInfo> artifactStack;\n\n    public ElasticEntityListIterator(Map<String, Object> searchMap, EntityDefinition entityDefinition,\n            FieldInfo[] fieldInfoArray, EntityJavaUtil.FieldOrderOptions[] fieldOptionsArray,\n            ElasticDatasourceFactory edf, TransactionCache txCache, EntityConditionImplBase whereCondition,\n            ArrayList<String> obf) {\n        this.edf = edf;\n        this.efi = edf.efi;\n        this.originalSearchMap = searchMap;\n        this.entityDefinition = entityDefinition;\n        fieldInfoListSize = fieldInfoArray.length;\n        this.fieldInfoArray = fieldInfoArray;\n\n        this.whereCondition = whereCondition;\n        this.txCache = txCache;\n        if (txCache != null && whereCondition != null) {\n            orderByComparator = obf != null && obf.size() > 0 ? new CollectionUtilities.MapOrderByComparator(obf) : null;\n            // add all created values (updated and deleted values will be handled by the next() method\n            findAugmentInfo = txCache.getFindAugmentInfo(entityDefinition.getFullEntityName(), whereCondition);\n            if (findAugmentInfo.valueListSize > 0) {\n                // update the order if we know the order by field list\n                if (orderByComparator != null) findAugmentInfo.valueList.sort(orderByComparator);\n                txcListSize = findAugmentInfo.valueListSize;\n            } else {\n                txcListSize = 0;\n            }\n        } else {\n            findAugmentInfo = null;\n            txcListSize = 0;\n            orderByComparator = null;\n        }\n\n        // if there is a limit (size) then set that as the maxResultCount\n        maxResultCount = (Integer) searchMap.get(\"size\");\n        originalFromInt = (Integer) originalSearchMap.get(\"from\");\n        esKeepAlive = efi.ecfi.transactionFacade.getTransactionTimeout() + \"s\";\n\n        // capture the current artifact stack for finalize not closed debugging, has minimal performance impact (still ~0.0038ms per call compared to numbers below)\n        artifactStack = efi.ecfi.getEci().artifactExecutionFacade.getStackArray();\n\n        /* uncomment only if needed temporarily: huge performance impact, ~0.036ms per call with, ~0.0037ms without (~10x difference!)\n        StackTraceElement[] tempStack = Thread.currentThread().getStackTrace();\n        if (tempStack.length > 20) tempStack = java.util.Arrays.copyOfRange(tempStack, 0, 20);\n        constructStack = tempStack;\n         */\n    }\n\n    boolean isFirst() { return overallIndex == 0; }\n    boolean isBeforeFirst() { return overallIndex < 0; }\n    boolean isLast() { if (resultCount != null) { return overallIndex == resultCount - 1; } else { return false; } }\n    boolean isAfterLast() { if (resultCount != null) { return overallIndex >= resultCount; } else { return false; } }\n\n    boolean nextResult() {\n        if (resultCount != null && overallIndex >= resultCount) return false;\n        overallIndex++;\n        if (overallIndex >= (currentListStartIndex + currentDocList.size())) {\n            // make sure we aren't at the end\n            if (resultCount != null && overallIndex >= resultCount) return false;\n            // fetch next results\n            fetchNext();\n        }\n\n        // logger.warn(\"nextResult end resultCount \" + resultCount + \" overallIndex \" + overallIndex + \" currentListStartIndex \" + currentListStartIndex + \" currentDocList.size() \" + currentDocList.size());\n        return hasCurrentValue();\n    }\n    @SuppressWarnings(\"unchecked\")\n    void fetchNext() {\n        if (this.closed) throw new IllegalStateException(\"EntityListIterator is closed, cannot fetch next results\");\n\n        ElasticFacade.ElasticClient elasticClient = edf.getElasticClient();\n        Map<String, Object> searchMap = new LinkedHashMap<>(originalSearchMap);\n\n        // where to start (from)?\n        int curFrom = currentListStartIndex + currentDocList.size();\n        if (curFrom < 0) curFrom = 0;\n\n        // how many to get (size)?\n        int curSize = fetchSize;\n        if (resultCount != null && curFrom + curSize > resultCount) {\n            // no more to get, return\n            if (curFrom >= resultCount) return;\n            curSize = resultCount - curFrom;\n        }\n        if (maxResultCount != null && curFrom + curSize > maxResultCount) {\n            // no more to get, return\n            if (curFrom >= maxResultCount) return;\n            curSize = maxResultCount - curFrom;\n        }\n\n        // before doing the search, see if we need a PIT ID: if we can't get all in one fetch\n        if (esPitId == null && (maxResultCount == null || maxResultCount > fetchSize)) {\n            esPitId = elasticClient.getPitId(edf.getIndexName(entityDefinition), esKeepAlive);\n        }\n\n        // add PIT ID (pit.id, pit.keep_alive:1m (use tx length)), search_after\n        if (esPitId != null) searchMap.put(\"pit\", CollectionUtilities.toHashMap(\"id\", esPitId, \"keep_alive\", esKeepAlive));\n        if (esSearchAfter != null) {\n            // with search_after the from field should always be zero\n            searchMap.put(\"search_after\", esSearchAfter);\n            searchMap.put(\"from\", 0);\n        } else {\n            // if origFromInt has a value always add it just before the query, basically a starting offset for the whole query\n            searchMap.put(\"from\", originalFromInt != null ? curFrom + originalFromInt : curFrom);\n        }\n        searchMap.put(\"size\", curSize);\n\n        // if no resultCount yet then track_total_hits (also set to false for better performance on subsequent requests)\n        searchMap.put(\"track_total_hits\", resultCount == null);\n\n        // logger.info(\"fetchNext request: \" + JsonOutput.prettyPrint(JsonOutput.toJson(searchMap)));\n\n        // do the query\n        Map resultMap = elasticClient.search(esPitId != null ? null : edf.getIndexName(entityDefinition), searchMap);\n        Map hitsMap = (Map) resultMap.get(\"hits\");\n        List<?> hitsList = (List<?>) hitsMap.get(\"hits\");\n\n        // log response without hits\n        /*\n        Map resultNoHits = new LinkedHashMap(resultMap);\n        Map hitsNoHits = new LinkedHashMap(hitsMap);\n        hitsNoHits.remove(\"hits\");\n        resultNoHits.put(\"hits\", hitsNoHits);\n        logger.info(\"fetchNext response: \" + JsonOutput.prettyPrint(JsonOutput.toJson(resultNoHits)));\n        */\n\n        // set resultCount if we have one\n        Map totalMap = (Map) hitsMap.get(\"total\");\n        if (totalMap != null) {\n            Integer hitsTotal = (Integer) totalMap.get(\"value\");\n            String relation = (String) totalMap.get(\"relation\");\n\n            // TODO remove this log message, only for testing behavior\n            if (!\"eq\".equals(relation)) logger.warn(\"Got non eq total relation \" + relation + \" with value \" + hitsTotal + \" for entity \" + entityDefinition.fullEntityName);\n\n            if (hitsTotal != null && \"eq\".equals(relation))\n                resultCount = originalFromInt != null ? hitsTotal - originalFromInt : hitsTotal;\n        }\n\n        // process hits\n        if (hitsList != null && hitsList.size() > 0) {\n            int hitCount = hitsList.size();\n            if (hitCount > fetchSize) logger.warn(\"In ElasticEntityListIterator got back \" + hitCount + \" hits with fetchSize \" + fetchSize);\n\n            if (hitCount < curSize) {\n                // we found the end\n                int calcTotal = curFrom + hitCount;\n                if (resultCount != calcTotal)\n                    logger.warn(\"In ElasticEntityListIterator reached end of results at \" + calcTotal + \" but server claimed \" + resultCount + \" total hits\");\n                resultCount = calcTotal;\n            }\n\n            // do we need to make room in currentDocList?\n            if (currentListStartIndex == -1) {\n                currentListStartIndex = 0;\n            } else {\n                int avail = CUR_LIST_MAX_SIZE - currentDocList.size();\n                if (avail < hitCount) {\n                    // how many can we retain?\n                    int retain = hitCount - CUR_LIST_MAX_SIZE;\n                    if (retain < 0) retain = 0;\n                    int remove = currentDocList.size() - retain;\n                    if (retain == 0) {\n                        currentDocList.clear();\n                    } else {\n                        // this is two array copies instead of potential one, but better than iterating manually to move elements or something\n                        currentDocList = new ArrayList<>(currentDocList.subList(remove, currentDocList.size()));\n                        currentDocList.ensureCapacity(CUR_LIST_MAX_SIZE);\n                    }\n                    // update start index\n                    currentListStartIndex += remove;\n                }\n            }\n\n            // does the Jackson parser (used in ElasticFacade) use an ArrayList? probably not... Iterator overhead not too bad here anyway\n            Iterator<?> hitsIterator = hitsList.iterator();\n            while (hitsIterator.hasNext()) {\n                Map hit = (Map) hitsIterator.next();\n                Map hitSource = (Map) hit.get(\"_source\");\n                currentDocList.add(hitSource);\n\n                if (!hitsIterator.hasNext()) {\n                    // get search_after from sort on last result\n                    List<Object> hitSort = (List<Object>) hit.get(\"sort\");\n                    if (hitSort != null) esSearchAfter = hitSort;\n                }\n            }\n        }\n\n        // logger.warn(\"fetchNext resultCount \" + resultCount + \" currentListStartIndex \" + currentListStartIndex + \" currentDocList size \" + currentDocList.size());\n    }\n\n    boolean previousResult() {\n        if (overallIndex < 0) return false;\n        overallIndex--;\n        if (overallIndex < currentListStartIndex) {\n            // make sure we aren't at the beginning\n            if (overallIndex < 0) return false;\n            // fetch previous results\n            fetchPrevious();\n        }\n\n        return hasCurrentValue();\n    }\n    void fetchPrevious() {\n        if (this.closed) throw new IllegalStateException(\"EntityListIterator is closed, cannot fetch previous results\");\n\n        // TODO\n        throw new BaseArtifactException(\"ElasticEntityListIterator.fetchPrevious() TODO\");\n    }\n\n    boolean hasCurrentValue() {\n        // if the numbers are such that we have a result (after a fetchPrevious() if needed) then return true\n        return overallIndex >= currentListStartIndex && overallIndex < (currentListStartIndex + currentDocList.size());\n    }\n    void resetCurrentList() {\n        // TODO: given multi-fetch space in the current list this could be optimized to avoid future fetch if currentListStartIndex < CUR_LIST_MAX_SIZE\n        if (currentListStartIndex > 0) {\n            currentListStartIndex = -1;\n            currentDocList.clear();\n            esSearchAfter = null;\n        }\n    }\n\n    @Override public void close() {\n        if (this.closed) {\n            logger.warn(\"EntityListIterator for entity \" + this.entityDefinition.getFullEntityName() + \" is already closed, not closing again\");\n        } else {\n            if (esPitId != null) {\n                ElasticFacade.ElasticClient elasticClient = edf.getElasticClient();\n                elasticClient.deletePit(esPitId);\n                esPitId = null;\n            }\n\n            this.closed = true;\n        }\n\n    }\n\n    @Override public void afterLast() {\n        throw new BaseArtifactException(\"ElasticEntityListIterator.afterLast() not currently supported\");\n        // rs.afterLast();\n        // txcListIndex = txcListSize;\n    }\n    @Override public void beforeFirst() {\n        txcListIndex = -1;\n        overallIndex = -1;\n        resetCurrentList();\n    }\n\n    @Override public boolean last() {\n        throw new BaseArtifactException(\"ElasticEntityListIterator.last() not currently supported\");\n        /*\n        if (txcListSize > 0) {\n            try { rs.afterLast(); }\n            catch (SQLException e) { throw new EntityException(\"Error moving EntityListIterator to last\", e); }\n            txcListIndex = txcListSize - 1;\n            return true;\n        } else {\n            try { return rs.last(); }\n            catch (SQLException e) { throw new EntityException(\"Error moving EntityListIterator to last\", e); }\n        }\n         */\n    }\n    @Override public boolean first() {\n        txcListIndex = -1;\n        overallIndex = 0;\n        if (currentListStartIndex > 0) {\n            resetCurrentList();\n            if (currentListStartIndex < 0) fetchNext();\n        }\n        return hasCurrentValue();\n    }\n\n    @Override public EntityValue currentEntityValue() { return currentEntityValueBase(); }\n    public EntityValueBase currentEntityValueBase() {\n        if (txcListIndex >= 0) return findAugmentInfo.valueList.get(txcListIndex);\n        if (overallIndex == -1) return null;\n\n        int curIndex = overallIndex - currentListStartIndex;\n        Map docMap = currentDocList.get(curIndex);\n\n        EntityValueImpl newEntityValue = new EntityValueImpl(entityDefinition, efi);\n        LiteStringMap<Object> valueMap = newEntityValue.getValueMap();\n        for (int i = 0; i < fieldInfoListSize; i++) {\n            FieldInfo fi = fieldInfoArray[i];\n            if (fi == null) break;\n            Object fValue = ElasticDatasourceFactory.convertFieldValue(fi, docMap.get(fi.name));\n            valueMap.putByIString(fi.name, fValue, fi.index);\n        }\n        newEntityValue.setSyncedWithDb();\n\n        // if txCache in place always put in cache for future reference (onePut handles any stale from DB issues too)\n        // NOTE: because of this don't use txCache for very large result sets\n        if (txCache != null) txCache.onePut(newEntityValue, false);\n        haveMadeValue = true;\n\n        return newEntityValue;\n    }\n\n    @Override public int currentIndex() {\n        // NOTE: add one because this is based on the JDBC ResultSet object which is 1 based\n        return overallIndex + txcListIndex + 1;\n    }\n    @Override public boolean absolute(final int rowNum) {\n        // TODO: somehow implement this for txcList? would need to know how many rows after last we tried to go\n        if (txcListSize > 0) throw new EntityException(\"Cannot go to absolute row number when transaction cache is in place and there are augmenting creates; disable the tx cache before this operation\");\n\n        // subtract 1 to convet to zero based index\n        int internalIndex = rowNum - 1;\n        if (internalIndex >= currentListStartIndex && internalIndex < (currentListStartIndex + currentDocList.size())) {\n            overallIndex = internalIndex;\n        } else {\n            txcListIndex = -1;\n            overallIndex = internalIndex;\n            resetCurrentList();\n            fetchNext();\n        }\n\n        return hasCurrentValue();\n    }\n    @Override public boolean relative(final int rows) {\n        throw new BaseArtifactException(\"ElasticEntityListIterator.relative() not currently supported\");\n        // TODO: somehow implement this for txcList? would need to know how many rows after last we tried to go\n        // if (txcListSize > 0) throw new EntityException(\"Cannot go to relative row number when transaction cache is in place and there are augmenting creates; disable the tx cache before this operation\");\n        // return rs.relative(rows);\n    }\n\n    @Override public boolean hasNext() {\n        if (isLast() || isAfterLast()) {\n            return txcListIndex < (txcListSize - 1);\n        } else {\n            // if not in the first or beforeFirst positions and haven't made any values yet, the result set is empty\n            return !(!haveMadeValue && !isBeforeFirst() && !isFirst());\n        }\n    }\n    @Override public boolean hasPrevious() {\n        if (isFirst() || isBeforeFirst()) {\n            return false;\n        } else {\n            // if not in the last or afterLast positions and we haven't made any values yet, the result set is empty\n            return !(!haveMadeValue && !isAfterLast() && !isLast());\n        }\n    }\n\n    @Override public EntityValue next() {\n        // first try the txcList if we are in it\n        if (txcListIndex >= 0) {\n            if (txcListIndex >= txcListSize) return null;\n            txcListIndex++;\n            if (txcListIndex >= txcListSize) return null;\n            return currentEntityValue();\n        }\n        // not in txcList, try the DB\n        if (nextResult()) {\n            EntityValueBase evb = currentEntityValueBase();\n            if (txCache != null) {\n                EntityJavaUtil.WriteMode writeMode = txCache.checkUpdateValue(evb, findAugmentInfo);\n                // if deleted skip this value\n                if (writeMode == EntityJavaUtil.WriteMode.DELETE) return next();\n            }\n            return evb;\n        } else {\n            if (txcListSize > 0) {\n                // txcListIndex should be -1, but instead of incrementing set to 0 just to make sure\n                txcListIndex = 0;\n                return currentEntityValue();\n            } else {\n                return null;\n            }\n        }\n    }\n    @Override public int nextIndex() { return currentIndex() + 1; }\n\n    @Override public EntityValue previous() {\n        // first try the txcList if we are in it\n        if (txcListIndex >= 0) {\n            txcListIndex--;\n            if (txcListIndex >= 0) return currentEntityValue();\n        }\n        if (previousResult()) {\n            EntityValueBase evb = (EntityValueBase) currentEntityValue();\n            if (txCache != null) {\n                EntityJavaUtil.WriteMode writeMode = txCache.checkUpdateValue(evb, findAugmentInfo);\n                // if deleted skip this value\n                if (writeMode == EntityJavaUtil.WriteMode.DELETE) return this.previous();\n            }\n            return evb;\n        } else {\n            return null;\n        }\n    }\n    @Override public int previousIndex() { return currentIndex() - 1; }\n\n    @Override public void setFetchSize(int rows) {\n        if (rows > MAX_FETCH_SIZE) rows = MAX_FETCH_SIZE;\n        this.fetchSize = rows;\n    }\n\n    @Override public EntityList getCompleteList(boolean closeAfter) {\n        try {\n            // move back to before first if we need to\n            if (haveMadeValue && !isBeforeFirst()) beforeFirst();\n\n            EntityList list = new EntityListImpl(efi);\n            EntityValue value;\n            while ((value = next()) != null) list.add(value);\n\n            if (findAugmentInfo != null) {\n                // all created, updated, and deleted values will be handled by the next() method\n                // update the order if we know the order by field list\n                if (orderByComparator != null) list.sort(orderByComparator);\n            }\n\n            return list;\n        } finally {\n            if (closeAfter) close();\n        }\n    }\n\n    @Override public EntityList getPartialList(int offset, int limit, boolean closeAfter) {\n        // TODO: somehow handle txcList after DB list? same issue as absolute() and relative() methods\n        if (txcListSize > 0) throw new EntityException(\"Cannot get partial list when transaction cache is in place and there are augmenting creates; disable the tx cache before this operation\");\n        try {\n            EntityList list = new EntityListImpl(this.efi);\n            if (limit == 0) return list;\n\n            // list is 1 based\n            if (offset == 0) offset = 1;\n\n            // jump to start index, or just get the first result\n            if (!this.absolute(offset)) {\n                // not that many results, get empty list\n                return list;\n            }\n\n            // get the first as the current one\n            list.add(this.currentEntityValue());\n\n            int numberSoFar = 1;\n            EntityValue nextValue;\n            while (limit > numberSoFar && (nextValue = this.next()) != null) {\n                list.add(nextValue);\n                numberSoFar++;\n            }\n\n            return list;\n        } finally {\n            if (closeAfter) close();\n        }\n    }\n\n    @Override\n    public int writeXmlText(Writer writer, String prefix, int dependentLevels) {\n        int recordsWritten = 0;\n\n        // move back to before first if we need to\n        if (haveMadeValue && !isBeforeFirst()) beforeFirst();\n        EntityValue value;\n        while ((value = this.next()) != null) recordsWritten += value.writeXmlText(writer, prefix, dependentLevels);\n\n        return recordsWritten;\n    }\n    @Override\n    public int writeXmlTextMaster(Writer writer, String prefix, String masterName) {\n        int recordsWritten = 0;\n        // move back to before first if we need to\n        if (haveMadeValue && !isBeforeFirst()) beforeFirst();\n        EntityValue value;\n        while ((value = this.next()) != null)\n            recordsWritten += value.writeXmlTextMaster(writer, prefix, masterName);\n\n        return recordsWritten;\n    }\n\n    @Override\n    public void remove() {\n        throw new BaseArtifactException(\"ElasticEntityListIterator.remove() not currently supported\");\n        // TODO: call EECAs\n        // efi.getEntityCache().clearCacheForValue((EntityValueBase) currentEntityValue(), false);\n        // rs.deleteRow();\n    }\n\n    @Override\n    public void set(EntityValue e) {\n        throw new BaseArtifactException(\"ElasticEntityListIterator.set() not currently supported\");\n        // TODO implement this\n        // TODO: call EECAs\n        // TODO: notify cache clear\n    }\n\n    @Override\n    public void add(EntityValue e) {\n        throw new BaseArtifactException(\"ElasticEntityListIterator.add() not currently supported\");\n        // TODO implement this\n    }\n\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/elastic/ElasticEntityValue.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity.elastic;\n\nimport org.moqui.Moqui;\nimport org.moqui.context.ElasticFacade;\nimport org.moqui.entity.EntityException;\nimport org.moqui.entity.EntityFacade;\nimport org.moqui.entity.EntityValue;\nimport org.moqui.impl.entity.*;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport jakarta.xml.bind.DatatypeConverter;\nimport java.io.IOException;\nimport java.io.ObjectInput;\nimport java.io.ObjectOutput;\nimport java.sql.Connection;\nimport java.sql.Timestamp;\nimport java.util.Calendar;\nimport java.util.Map;\n\npublic class ElasticEntityValue extends EntityValueBase {\n    protected static final Logger logger = LoggerFactory.getLogger(ElasticEntityValue.class);\n    private ElasticDatasourceFactory edfInternal;\n\n    /** Default constructor for deserialization ONLY. */\n    public ElasticEntityValue() { }\n\n    public ElasticEntityValue(EntityDefinition ed, EntityFacadeImpl efip, ElasticDatasourceFactory edf) {\n        super(ed, efip);\n        this.edfInternal = edf;\n    }\n\n    @Override\n    public void writeExternal(ObjectOutput out) throws IOException {\n        super.writeExternal(out);\n    }\n\n    @Override\n    public void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException {\n        super.readExternal(objectInput);\n    }\n\n    public ElasticDatasourceFactory getEdf() {\n        if (edfInternal == null) {\n            // not much option other than static access via Moqui object\n            EntityFacade ef = Moqui.getExecutionContextFactory().getEntity();\n            edfInternal = (ElasticDatasourceFactory) ef.getDatasourceFactory(ef.getEntityGroupName(resolveEntityName()));\n        }\n\n        return edfInternal;\n    }\n\n    @Override\n    public EntityValue cloneValue() {\n        ElasticEntityValue newObj = new ElasticEntityValue(getEntityDefinition(), getEntityFacadeImpl(), edfInternal);\n        newObj.valueMapInternal.putAll(this.valueMapInternal);\n        if (this.dbValueMap != null) newObj.setDbValueMap(this.dbValueMap);\n        // don't set mutable (default to mutable even if original was not) or modified (start out not modified)\n        return newObj;\n    }\n\n    @Override\n    public EntityValue cloneDbValue(boolean getOld) {\n        ElasticEntityValue newObj = new ElasticEntityValue(getEntityDefinition(), getEntityFacadeImpl(), edfInternal);\n        newObj.valueMapInternal.putAll(this.valueMapInternal);\n        for (FieldInfo fieldInfo : getEntityDefinition().entityInfo.allFieldInfoArray)\n            newObj.putKnownField(fieldInfo, getOld ? getOldDbValue(fieldInfo.name) : getOriginalDbValue(fieldInfo.name));\n        newObj.setSyncedWithDb();\n        return newObj;\n    }\n\n    @Override\n    public void createExtended(FieldInfo[] fieldInfoArray, Connection con) {\n        EntityDefinition ed = getEntityDefinition();\n        if (ed.isViewEntity) throw new EntityException(\"View entities are not supported, Elastic/OpenSearch does not support joins\");\n        ElasticDatasourceFactory edf = getEdf();\n\n        edf.checkCreateDocumentIndex(ed);\n        ElasticFacade.ElasticClient elasticClient = edf.getElasticClient();\n\n        String combinedId = getPrimaryKeysString();\n        // logger.warn(\"create elastic combinedId \" + combinedId + \" valueMapInternal \" + valueMapInternal);\n        elasticClient.index(edf.getIndexName(ed), combinedId, valueMapInternal);\n        setSyncedWithDb();\n    }\n\n    @Override\n    public void updateExtended(FieldInfo[] pkFieldArray, FieldInfo[] nonPkFieldArray, Connection con) {\n        EntityDefinition ed = getEntityDefinition();\n        if (ed.isViewEntity) throw new EntityException(\"View entities are not supported, Elastic/OpenSearch does not support joins\");\n        ElasticDatasourceFactory edf = getEdf();\n\n        edf.checkCreateDocumentIndex(ed);\n        ElasticFacade.ElasticClient elasticClient = edf.getElasticClient();\n\n        String combinedId = getPrimaryKeysString();\n        // use ElasticClient.update() method, supports partial doc update, see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-update.html\n        elasticClient.update(edf.getIndexName(ed), combinedId, valueMapInternal);\n        setSyncedWithDb();\n    }\n\n    @Override\n    public void deleteExtended(Connection con) {\n        EntityDefinition ed = getEntityDefinition();\n        if (ed.isViewEntity) throw new EntityException(\"View entities are not supported, Elastic/OpenSearch does not support joins\");\n        ElasticDatasourceFactory edf = getEdf();\n\n        edf.checkCreateDocumentIndex(ed);\n        ElasticFacade.ElasticClient elasticClient = edf.getElasticClient();\n\n        String combinedId = getPrimaryKeysString();\n        elasticClient.delete(edf.getIndexName(ed), combinedId);\n    }\n\n    @Override\n    public boolean refreshExtended() {\n        EntityDefinition ed = getEntityDefinition();\n        if (ed.isViewEntity) throw new EntityException(\"View entities are not supported, Elastic/OpenSearch does not support joins\");\n        ElasticDatasourceFactory edf = getEdf();\n\n        edf.checkCreateDocumentIndex(ed);\n        ElasticFacade.ElasticClient elasticClient = edf.getElasticClient();\n\n        String combinedId = getPrimaryKeysString();\n        Map getResponse = elasticClient.get(edf.getIndexName(ed), combinedId);\n        if (getResponse == null) return false;\n        Map dbValue = (Map) getResponse.get(\"_source\");\n        if (dbValue == null) return false;\n\n        FieldInfo[] allFieldArray = ed.entityInfo.allFieldInfoArray;\n        for (int j = 0; j < allFieldArray.length; j++) {\n            FieldInfo fi = allFieldArray[j];\n            Object fValue = ElasticDatasourceFactory.convertFieldValue(fi, dbValue.get(fi.name));\n            valueMapInternal.putByIString(fi.name, fValue, fi.index);\n        }\n\n        setSyncedWithDb();\n        return true;\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/entity/elastic/ElasticSynchronization.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.entity.elastic\n\nimport groovy.transform.CompileStatic\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport jakarta.transaction.Status\nimport jakarta.transaction.Synchronization\nimport jakarta.transaction.Transaction\nimport javax.transaction.xa.XAException\n\n/** NOT YET IMPLEMENTED OR USED, may be used for future Elastic Entity transactional behavior (none so far...) */\n@CompileStatic\nclass ElasticSynchronization implements Synchronization {\n    protected final static Logger logger = LoggerFactory.getLogger(ElasticSynchronization.class)\n\n    protected ExecutionContextFactoryImpl ecfi\n    protected ElasticDatasourceFactory edf\n\n    protected Transaction tx = null\n\n\n    ElasticSynchronization(ExecutionContextFactoryImpl ecfi, ElasticDatasourceFactory edf) {\n        this.ecfi = ecfi\n        this.edf = edf\n    }\n\n    @Override\n    void beforeCompletion() { }\n\n    @Override\n    void afterCompletion(int status) {\n        /*\n        if (status == Status.STATUS_COMMITTED) {\n            try {\n                // TODO database.commit()\n            } catch (Exception e) {\n                logger.error(\"Error in OrientDB commit: ${e.toString()}\", e)\n                throw new XAException(\"Error in OrientDB commit: ${e.toString()}\")\n            } finally {\n                // TODO database.close()\n            }\n        } else {\n            try {\n                // TODO database.rollback()\n            } catch (Exception e) {\n                logger.error(\"Error in OrientDB rollback: ${e.toString()}\", e)\n                throw new XAException(\"Error in OrientDB rollback: ${e.toString()}\")\n            } finally {\n                // TODO database.close()\n            }\n        }\n        */\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/screen/ScreenDefinition.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.screen\n\nimport groovy.json.JsonOutput\nimport groovy.transform.CompileStatic\nimport org.codehaus.groovy.runtime.InvokerHelper\nimport org.moqui.BaseArtifactException\nimport org.moqui.BaseException\nimport org.moqui.context.ArtifactExecutionInfo\nimport org.moqui.context.ExecutionContext\nimport org.moqui.context.ResourceFacade\nimport org.moqui.impl.context.ContextJavaUtil\nimport org.moqui.impl.entity.EntityDefinition\nimport org.moqui.impl.screen.ScreenUrlInfo.UrlInstance\nimport org.moqui.impl.service.ServiceDefinition\nimport org.moqui.resource.ResourceReference\nimport org.moqui.context.WebFacade\nimport org.moqui.entity.EntityFind\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.actions.XmlAction\nimport org.moqui.impl.context.ArtifactExecutionInfoImpl\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.util.ContextStack\nimport org.moqui.util.MNode\nimport org.moqui.util.StringUtilities\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport jakarta.servlet.http.HttpServletResponse\n\n@CompileStatic\nclass ScreenDefinition {\n    private final static Logger logger = LoggerFactory.getLogger(ScreenDefinition.class)\n    private final static Set<String> scanWidgetNames = new HashSet<String>(\n            ['section', 'section-iterate', 'section-include', 'form-single', 'form-list', 'tree', 'subscreens-panel', 'subscreens-menu'])\n    private final static Set<String> screenStaticWidgetNames = new HashSet<String>(\n            ['subscreens-panel', 'subscreens-menu', 'subscreens-active'])\n\n    @SuppressWarnings(\"GrFinalVariableAccess\") protected final ScreenFacadeImpl sfi\n    @SuppressWarnings(\"GrFinalVariableAccess\") protected final MNode screenNode\n    @SuppressWarnings(\"GrFinalVariableAccess\") protected final MNode subscreensNode\n    @SuppressWarnings(\"GrFinalVariableAccess\") protected final MNode webSettingsNode\n    @SuppressWarnings(\"GrFinalVariableAccess\") protected final String location\n    @SuppressWarnings(\"GrFinalVariableAccess\") protected final String screenName\n    @SuppressWarnings(\"GrFinalVariableAccess\") final long screenLoadedTime\n    protected boolean standalone = false\n    protected boolean allowExtraPath = false\n    protected Set<String> renderModes = null\n    protected Set<String> serverStatic = null\n    Long sourceLastModified = null\n\n    protected Map<String, ParameterItem> parameterByName = new HashMap<>()\n    protected boolean hasRequired = false\n    protected Map<String, TransitionItem> transitionByName = new HashMap<>()\n    protected Map<String, SubscreensItem> subscreensByName = new HashMap<>()\n    protected ArrayList<SubscreensItem> subscreensItemsSorted = null\n    protected ArrayList<SubscreensItem> subscreensNoSubPath = null\n    protected String defaultSubscreensItem = null\n\n    protected XmlAction alwaysActions = null\n    protected XmlAction preActions = null\n\n    protected ScreenSection rootSection = null\n    protected Map<String, ScreenSection> sectionByName = new HashMap<>()\n    protected Map<String, ScreenForm> formByName = new LinkedHashMap<>()\n    protected Map<String, ScreenTree> treeByName = new HashMap<>()\n    protected final Set<String> dependsOnScreenLocations = new HashSet<>()\n    protected boolean hasTabMenu = false\n\n    protected Map<String, ResourceReference> subContentRefByPath = new HashMap()\n    protected Map<String, String> macroTemplateByRenderMode = null\n\n    ScreenDefinition(ScreenFacadeImpl sfi, MNode screenNode, String location) {\n        this.sfi = sfi\n\n        // merge screen-extend (before using anything from screenNode)\n        int locationSepLoc = location.indexOf(\"://\")\n        String locPath = locationSepLoc == -1 ? location : location.substring(locationSepLoc + 3)\n        String locPathAfterScreen = null\n        int locPathScreenLoc = locPath.indexOf(\"/screen/\")\n        if (locPathScreenLoc >= 0) locPathAfterScreen = locPath.substring(locPathScreenLoc + 8)\n\n        // search components for screen-extend files\n        ArrayList<MNode> screenExtendNodeList = new ArrayList<>()\n        for (String componentLoc in sfi.ecfi.getComponentBaseLocations().values()) {\n            ResourceReference screenExtendRr = sfi.ecfi.resourceFacade.getLocationReference(componentLoc + \"/screen-extend\")\n            if (!screenExtendRr.supportsExists()) {\n                logger.warn(\"For screen-extend skipping component that does not support exists check: ${componentLoc}\")\n                continue\n            }\n            // continue to next component if screen-extend directory does not exist (quit early)\n            if (!screenExtendRr.exists) continue\n\n            // try the after '/screen/' path after the full path so that different screens with the same after-screen path can be distinguished\n            ResourceReference matchingRr = screenExtendRr.findChildFile(locPath)\n            if (!matchingRr.exists && locPathAfterScreen != null)\n                matchingRr = screenExtendRr.findChildFile(locPathAfterScreen)\n            // still found nothing? move along\n            if (!matchingRr.exists) continue\n\n            logger.info(\"Found screen-extend at ${matchingRr.location} for screen at ${location}\")\n            MNode screenExtendNode = MNode.parse(matchingRr)\n            screenExtendNodeList.add(screenExtendNode)\n        }\n        // merge/etc screen-extend nodes from files\n        Map<String, ArrayList<MNode>> extendDescendantsMap = new HashMap<>()\n        for (int seIdx = 0; seIdx < screenExtendNodeList.size(); seIdx++) {\n            MNode screenExtendNode = (MNode) screenExtendNodeList.get(seIdx)\n            // NOTE: form-single, form-list merged below; various others overridden below (section, section-iterate)\n            screenExtendNode.descendants(scanWidgetNames, extendDescendantsMap)\n\n            // start with attributes and simple override by name/id elements\n            screenNode.attributes.putAll(screenExtendNode.attributes)\n            screenNode.mergeChildrenByKey(screenExtendNode, \"parameter\", \"name\", null)\n            screenNode.mergeChildrenByKey(screenExtendNode, \"transition\", \"name\", null)\n            screenNode.mergeChildrenByKey(screenExtendNode, \"transition-include\", \"name\", null)\n\n            MNode overrideSubscreensNode = screenExtendNode.first(\"subscreens\")\n            if (overrideSubscreensNode != null) {\n                MNode baseSubscreensNode = screenNode.first(\"subscreens\")\n                if (baseSubscreensNode == null) {\n                    screenNode.append(overrideSubscreensNode.deepCopy(screenNode))\n                } else {\n                    baseSubscreensNode.mergeNodeWithChildKey(overrideSubscreensNode, \"subscreens-item\", \"name\", null)\n                }\n            }\n\n            ArrayList<MNode> actionsExtendNodeList = screenExtendNode.children(\"actions-extend\")\n            for (int i = 0; i < actionsExtendNodeList.size(); i++) {\n                MNode actionsExtendNode = (MNode) actionsExtendNodeList.get(i)\n                String typeName = actionsExtendNode.attribute(\"type\") ?: \"actions\"\n                MNode curActionsNode = screenNode.first(typeName)\n                if (curActionsNode == null) curActionsNode = screenNode.append(typeName, null)\n\n                String when = actionsExtendNode.attribute(\"when\")\n                if (\"replace\".equals(when)) {\n                    curActionsNode.removeAll()\n                    curActionsNode.appendAll(actionsExtendNode.children, true)\n                } else if (\"before\".equals(when)) {\n                    curActionsNode.appendAll(actionsExtendNode.children, 0, true)\n                } else {\n                    // default to \"after\"\n                    curActionsNode.appendAll(actionsExtendNode.children, true)\n                }\n            }\n\n            ArrayList<MNode> widgetsExtendNodeList = screenExtendNode.children(\"widgets-extend\")\n            for (int weIdx = 0; weIdx < widgetsExtendNodeList.size(); weIdx++) {\n                // need any explicit support? form-single, form-list, section, section-iterate, container (id), container-box (id), container-dialog (id), dynamic-dialog (id)\n                // for now just look for any matching name or id attribute and go for it\n\n                MNode widgetsExtendNode = (MNode) widgetsExtendNodeList.get(weIdx)\n                String extendName = widgetsExtendNode.attribute(\"name\")\n                if (extendName == null || extendName.isEmpty()) continue\n\n                ArrayList<MNode> matchingNodes = screenNode.breadthFirst({ MNode it ->\n                    extendName.equals(it.attribute(\"name\")) || extendName.equals(it.attribute(\"id\")) })\n                // logger.warn(\"widgets-extend name=${extendName} matchingNodes ${matchingNodes}\")\n                for (int mnIdx = 0; mnIdx < matchingNodes.size(); mnIdx++) {\n                    MNode matchingNode = (MNode) matchingNodes.get(mnIdx)\n                    MNode matchParent = matchingNode.getParent()\n                    if (matchParent == null) {\n                        logger.warn(\"In screen-extend no parent found for element ${matchingNode.name} name=${matchingNode.attribute('name')} id=${matchingNode.attribute('id')} in target screen at ${location}\")\n                        continue\n                    }\n                    int childIdx = matchParent.firstIndex(matchingNode)\n                    if (childIdx == -1) {\n                        logger.warn(\"In screen-extend could not find index for element ${matchingNode.name} name=${matchingNode.attribute('name')} id=${matchingNode.attribute('id')} in target screen at ${location}\")\n                        continue\n                    }\n                    // if where=after (default before) then add 1 to childIdx\n                    if (\"after\".equals(widgetsExtendNode.attribute(\"where\"))) childIdx++\n\n                    // ready to go, append cloned widgets-extend child nodes\n                    matchParent.appendAll(widgetsExtendNode.children, childIdx, true)\n                }\n            }\n        }\n\n        // if (screenExtendNodeList.size() > 0) logger.warn(\"after extend of screen at ${location}:\\n${screenNode.toString()}\")\n\n        // init screen def fields\n        this.screenNode = screenNode\n        subscreensNode = screenNode.first(\"subscreens\")\n        webSettingsNode = screenNode.first(\"web-settings\")\n        this.location = location\n\n        ExecutionContextFactoryImpl ecfi = sfi.ecfi\n\n        long startTime = System.currentTimeMillis()\n        screenLoadedTime = startTime\n\n        String filename = location.contains(\"/\") ? location.substring(location.lastIndexOf(\"/\")+1) : location\n        screenName = filename.contains(\".\") ? filename.substring(0, filename.indexOf(\".\")) : filename\n\n        standalone = \"true\".equals(screenNode.attribute(\"standalone\"))\n        allowExtraPath = \"true\".equals(screenNode.attribute(\"allow-extra-path\"))\n        String renderModesStr = screenNode.attribute(\"render-modes\") ?: \"all\"\n        renderModes = new HashSet(Arrays.asList(renderModesStr.split(\",\")).collect({ it.trim() }))\n        String serverStaticStr = screenNode.attribute(\"server-static\")\n        if (serverStaticStr) serverStatic = new HashSet(Arrays.asList(serverStaticStr.split(\",\")).collect({ it.trim() }))\n\n        // parameter\n        for (MNode parameterNode in screenNode.children(\"parameter\")) {\n            ParameterItem parmItem = new ParameterItem(parameterNode, location, ecfi)\n            parameterByName.put(parameterNode.attribute(\"name\"), parmItem)\n            if (parmItem.required) hasRequired = true\n        }\n        // prep always-actions\n        if (screenNode.hasChild(\"always-actions\"))\n            alwaysActions = new XmlAction(ecfi, screenNode.first(\"always-actions\"), location + \".always_actions\")\n        // transition\n        for (MNode transitionNode in screenNode.children(\"transition\")) {\n            TransitionItem ti = new TransitionItem(transitionNode, this)\n            transitionByName.put(ti.method == \"any\" ? ti.name : ti.name + \"#\" + ti.method, ti)\n        }\n        // transition-include\n        for (MNode transitionInclNode in screenNode.children(\"transition-include\")) {\n            ScreenDefinition includeScreen = ecfi.screenFacade.getScreenDefinition(transitionInclNode.attribute(\"location\"))\n            if (includeScreen != null) dependsOnScreenLocations.add(includeScreen.location)\n            MNode transitionNode = includeScreen?.getTransitionItem(transitionInclNode.attribute(\"name\"), transitionInclNode.attribute(\"method\"))?.transitionNode\n            if (transitionNode == null) throw new BaseArtifactException(\"For transition-include could not find transition ${transitionInclNode.attribute(\"name\")} with method ${transitionInclNode.attribute(\"method\")} in screen at ${transitionInclNode.attribute(\"location\")}\")\n            TransitionItem ti = new TransitionItem(transitionNode, this)\n            transitionByName.put(ti.method == \"any\" ? ti.name : ti.name + \"#\" + ti.method, ti)\n        }\n\n        // default/automatic transitions\n        if (!transitionByName.containsKey(\"actions\")) transitionByName.put(\"actions\", new ActionsTransitionItem(this))\n        if (!transitionByName.containsKey(\"formSelectColumns\")) transitionByName.put(\"formSelectColumns\", new FormSelectColumnsTransitionItem(this))\n        if (!transitionByName.containsKey(\"formSaveFind\")) transitionByName.put(\"formSaveFind\", new FormSavedFindsTransitionItem(this))\n        if (!transitionByName.containsKey(\"screenDoc\")) transitionByName.put(\"screenDoc\", new ScreenDocumentTransitionItem(this))\n\n        // subscreens\n        defaultSubscreensItem = subscreensNode?.attribute(\"default-item\")\n        populateSubscreens()\n        for (SubscreensItem si in getSubscreensItemsSorted()) if (si.noSubPath) {\n            if (subscreensNoSubPath == null) subscreensNoSubPath = new ArrayList<>()\n            subscreensNoSubPath.add(si)\n        }\n\n        // macro-template - go through entire list and set all found, basically we want the last one if there are more than one\n        List<MNode> macroTemplateList = screenNode.children(\"macro-template\")\n        if (macroTemplateList.size() > 0) {\n            macroTemplateByRenderMode = new HashMap<>()\n            for (MNode mt in macroTemplateList) macroTemplateByRenderMode.put(mt.attribute('type'), mt.attribute('location'))\n        }\n\n        // prep pre-actions\n        if (screenNode.hasChild(\"pre-actions\"))\n            preActions = new XmlAction(ecfi, screenNode.first(\"pre-actions\"), location + \".pre_actions\")\n\n        // get the root section\n        rootSection = new ScreenSection(ecfi, screenNode, location + \".screen\")\n\n        if (rootSection != null && rootSection.widgets != null) {\n            Map<String, ArrayList<MNode>> descMap = rootSection.widgets.widgetsNode.descendants(scanWidgetNames)\n            // get all of the other sections by name\n            for (MNode sectionNode in descMap.get('section')) {\n                String sectionName = sectionNode.attribute(\"name\")\n                // get last matching node, is replace/override\n                ArrayList<MNode> extendNodes = extendDescendantsMap.get(\"section\")\n                Integer replaceIndex = extendNodes?.findLastIndexOf({ it.attribute(\"name\") == sectionName })\n                MNode useNode = (replaceIndex != null && replaceIndex != -1) ? extendNodes.get(replaceIndex) : sectionNode\n                sectionByName.put(sectionName, new ScreenSection(ecfi, useNode, \"${location}.section\\$${sectionName}\"))\n            }\n            for (MNode sectionNode in descMap.get('section-iterate')) {\n                String sectionName = sectionNode.attribute(\"name\")\n                ArrayList<MNode> extendNodes = extendDescendantsMap.get(\"section-iterate\")\n                Integer replaceIndex = extendNodes?.findLastIndexOf({ it.attribute(\"name\") == sectionName })\n                MNode useNode = (replaceIndex != null && replaceIndex != -1) ? extendNodes.get(replaceIndex) : sectionNode\n                sectionByName.put(sectionName, new ScreenSection(ecfi, useNode, \"${location}.section_iterate\\$${sectionName}\"))\n            }\n            for (MNode sectionNode in descMap.get('section-include')) {\n                String sectionLocation = sectionNode.attribute(\"location\")\n                String sectionName = sectionNode.attribute(\"name\")\n                boolean isDynamic = (sectionLocation != null && sectionLocation.contains('${')) || (sectionName != null && sectionName.contains('${'))\n                // if the section-include is dynamic then don't pull it now, do at runtime based on dynamic name and location\n                if (!isDynamic) pullSectionInclude(sectionNode)\n            }\n\n            // get all forms by name\n            for (MNode formNode in descMap.get(\"form-single\")) {\n                String formName = formNode.attribute(\"name\")\n                List<MNode> extendList = extendDescendantsMap.get(\"form-single\")\n                if (extendList != null) extendList = extendList.findAll({ it.attribute(\"name\") == formName })\n\n                ScreenForm newForm = new ScreenForm(ecfi, this, formNode, extendList, \"${location}.form_single\\$${formName}\")\n                if (newForm.extendsScreenLocation != null) dependsOnScreenLocations.add(newForm.extendsScreenLocation)\n                formByName.put(formName, newForm)\n            }\n            for (MNode formNode in descMap.get('form-list')) {\n                String formName = formNode.attribute(\"name\")\n                List<MNode> extendList = extendDescendantsMap.get(\"form-list\")\n                if (extendList != null) extendList = extendList.findAll({it.attribute(\"name\") == formName})\n\n                ScreenForm newForm = new ScreenForm(ecfi, this, formNode, extendList, \"${location}.form_list\\$${formName}\")\n                if (newForm.extendsScreenLocation != null) dependsOnScreenLocations.add(newForm.extendsScreenLocation)\n                formByName.put(formName, newForm)\n            }\n\n            // get all trees by name\n            for (MNode treeNode in descMap.get('tree'))\n                treeByName.put(treeNode.attribute(\"name\"), new ScreenTree(ecfi, this, treeNode, \"${location}.tree\\$${treeNode.attribute(\"name\")}\"))\n\n            // see if any subscreens-panel or subscreens-menu elements are type=tab (or empty type, defaults to tab)\n            for (MNode menuNode in descMap.get(\"subscreens-panel\")) {\n                String type = menuNode.attribute(\"type\")\n                if (type == null || type.isEmpty() || \"tab\".equals(type)) { hasTabMenu = true; break }\n            }\n            if (!hasTabMenu) for (MNode menuNode in descMap.get(\"subscreens-menu\")) {\n                String type = menuNode.attribute(\"type\")\n                if (type == null || type.isEmpty() || \"tab\".equals(type)) { hasTabMenu = true; break }\n            }\n\n            if (serverStatic == null) {\n                // if there are no elements except subscreens-panel, subscreens-active, and subscreens-menu then set serverStatic to all\n                boolean otherElements = false\n                MNode widgetsNode = rootSection.widgets.widgetsNode\n                if (!\"widgets\".equals(widgetsNode.getName())) widgetsNode = widgetsNode.first(\"widgets\")\n                for (MNode child in widgetsNode.getChildren()) {\n                    if (!screenStaticWidgetNames.contains(child.getName())) {otherElements = true; break } }\n                if (!otherElements) serverStatic = new HashSet<>(['all'])\n            }\n        }\n\n        if (logger.isTraceEnabled()) logger.trace(\"Loaded screen at [${location}] in [${(System.currentTimeMillis()-startTime)/1000}] seconds\")\n    }\n\n    void pullSectionInclude(MNode sectionIncludeNode) {\n        String location = sectionIncludeNode.attribute(\"location\")\n        String sectionName = sectionIncludeNode.attribute(\"name\")\n        boolean isDynamic = (location != null && location.contains('${')) || (sectionName != null && sectionName.contains('${'))\n        String cacheName = null\n        if (isDynamic) {\n            location = sfi.ecfi.resourceFacade.expandNoL10n(location, null)\n            sectionName = sfi.ecfi.resourceFacade.expandNoL10n(sectionName, null)\n            // get fullName for sectionByName cache before checking location for # so that matches what ScreenRenderImpl.renderSectionInclude() does\n            cacheName = location + \"#\" + sectionName\n        }\n        if (location.contains('#')) {\n            sectionName = location.substring(location.indexOf('#') + 1)\n            location = location.substring(0, location.indexOf('#'))\n        }\n        if (!isDynamic) cacheName = sectionName\n\n        ScreenDefinition includeScreen = sfi.getEcfi().screenFacade.getScreenDefinition(location)\n        ScreenSection includeSection = includeScreen?.getSection(sectionName)\n        if (includeSection == null) throw new BaseArtifactException(\"Could not find section ${sectionName} to include at location ${location}\")\n        sectionByName.put(cacheName, includeSection)\n        dependsOnScreenLocations.add(location)\n\n        Map<String, ArrayList<MNode>> descMap = includeSection.sectionNode.descendants(\n                new HashSet<String>(['section', 'section-iterate', 'section-include', 'form-single', 'form-list', 'tree']))\n\n        // see if the included section contains any SECTIONS, need to reference those here too!\n        for (MNode inclRefNode in descMap.get('section'))\n            sectionByName.put(inclRefNode.attribute(\"name\"), includeScreen.getSection(inclRefNode.attribute(\"name\")))\n        for (MNode inclRefNode in descMap.get('section-iterate'))\n            sectionByName.put(inclRefNode.attribute(\"name\"), includeScreen.getSection(inclRefNode.attribute(\"name\")))\n        // recurse for section-include\n        for (MNode inclRefNode in descMap.get('section-include')) pullSectionInclude(inclRefNode)\n\n        // see if the included section contains any FORMS or TREES, need to reference those here too!\n        for (MNode formNode in descMap.get('form-single')) {\n            ScreenForm inclForm = includeScreen.getForm(formNode.attribute(\"name\"))\n            if (inclForm.extendsScreenLocation != null) dependsOnScreenLocations.add(inclForm.extendsScreenLocation)\n            formByName.put(formNode.attribute(\"name\"), inclForm)\n        }\n        for (MNode formNode in descMap.get('form-list')) {\n            ScreenForm inclForm = includeScreen.getForm(formNode.attribute(\"name\"))\n            if (inclForm.extendsScreenLocation != null) dependsOnScreenLocations.add(inclForm.extendsScreenLocation)\n            formByName.put(formNode.attribute(\"name\"), inclForm)\n        }\n\n        for (MNode treeNode in descMap.get('tree'))\n            treeByName.put(treeNode.attribute(\"name\"), includeScreen.getTree(treeNode.attribute(\"name\")))\n    }\n\n    void populateSubscreens() {\n        // start with file/directory structure\n        String cleanLocationBase = location.substring(0, location.lastIndexOf(\".\"))\n        ResourceReference locationRef = sfi.ecfi.resourceFacade.getLocationReference(location)\n        if (logger.traceEnabled) logger.trace(\"Finding subscreens for screen at [${locationRef}]\")\n        if (locationRef.supportsAll()) {\n            String subscreensDirStr = locationRef.location\n            subscreensDirStr = subscreensDirStr.substring(0, subscreensDirStr.lastIndexOf(\".\"))\n\n            ResourceReference subscreensDirRef = sfi.ecfi.resourceFacade.getLocationReference(subscreensDirStr)\n            if (subscreensDirRef.exists && subscreensDirRef.isDirectory()) {\n                if (logger.traceEnabled) logger.trace(\"Looking for subscreens in directory [${subscreensDirRef}]\")\n                for (ResourceReference subscreenRef in subscreensDirRef.directoryEntries) {\n                    if (!subscreenRef.isFile() || !subscreenRef.location.endsWith(\".xml\")) continue\n                    MNode subscreenRoot = MNode.parse(subscreenRef)\n                    if (subscreenRoot.name == \"screen\") {\n                        String ssName = subscreenRef.getFileName()\n                        ssName = ssName.substring(0, ssName.lastIndexOf(\".\"))\n                        String cleanLocation = cleanLocationBase + \"/\" + subscreenRef.getFileName()\n                        SubscreensItem si = new SubscreensItem(ssName, cleanLocation, subscreenRoot, this)\n                        subscreensByName.put(si.name, si)\n                        if (logger.traceEnabled) logger.trace(\"Added file subscreen [${si.name}] at [${si.location}] to screen [${locationRef}]\")\n                    }\n                }\n            }\n        } else {\n            logger.info(\"Not getting subscreens by file/directory structure for screen [${location}] because it is not a location that supports directories\")\n        }\n\n        // override dir structure with subscreens.subscreens-item elements\n        if (screenNode.hasChild(\"subscreens\")) for (MNode subscreensItem in screenNode.first(\"subscreens\").children(\"subscreens-item\")) {\n            SubscreensItem si = new SubscreensItem(subscreensItem, this)\n            subscreensByName.put(si.name, si)\n            if (logger.traceEnabled) logger.trace(\"Added Screen XML defined subscreen [${si.name}] at [${si.location}] to screen [${locationRef}]\")\n        }\n\n        // override dir structure and screen.subscreens.subscreens-item elements with Moqui Conf XML screen-facade.screen.subscreens-item elements\n        MNode screenFacadeNode = sfi.ecfi.confXmlRoot.first(\"screen-facade\")\n        MNode confScreenNode = screenFacadeNode.first(\"screen\", \"location\", location)\n        if (confScreenNode != null) {\n            for (MNode subscreensItem in confScreenNode.children(\"subscreens-item\")) {\n                SubscreensItem si = new SubscreensItem(subscreensItem, this)\n                subscreensByName.put(si.name, si)\n                if (logger.traceEnabled) logger.trace(\"Added Moqui Conf XML defined subscreen [${si.name}] at [${si.location}] to screen [${locationRef}]\")\n            }\n            if (confScreenNode.attribute(\"default-subscreen\"))\n                defaultSubscreensItem = confScreenNode.attribute(\"default-subscreen\")\n        }\n\n        // override dir structure and subscreens-item elements with moqui.screen.SubscreensItem entity\n        EntityFind subscreensItemFind = sfi.ecfi.entityFacade.find(\"moqui.screen.SubscreensItem\")\n                .condition([screenLocation:location] as Map<String, Object>)\n        // NOTE: this filter should NOT be done here, causes subscreen items to be filtered by first user that renders the screen, not by current user!\n        // subscreensItemFind.condition(\"userGroupId\", EntityCondition.IN, sfi.ecfi.executionContext.user.userGroupIdSet)\n        EntityList subscreensItemList = subscreensItemFind.useCache(true).disableAuthz().list()\n        for (EntityValue subscreensItem in subscreensItemList) {\n            SubscreensItem si = new SubscreensItem(subscreensItem, this)\n            subscreensByName.put(si.name, si)\n            if (\"Y\".equals(subscreensItem.makeDefault)) defaultSubscreensItem = si.name\n            if (logger.traceEnabled) logger.trace(\"Added database subscreen [${si.name}] at [${si.location}] to screen [${locationRef}]\")\n        }\n    }\n\n    MNode getScreenNode() { return screenNode }\n    MNode getSubscreensNode() { return subscreensNode }\n    MNode getWebSettingsNode() { return webSettingsNode }\n    String getLocation() { return location }\n\n    String getDefaultSubscreensItem() { return defaultSubscreensItem }\n    ArrayList<SubscreensItem> getSubscreensNoSubPath() { return subscreensNoSubPath }\n\n    String getScreenName() { return screenName }\n    boolean isStandalone() { return standalone }\n    boolean isServerStatic(String renderMode) { return serverStatic != null && (serverStatic.contains('all') || serverStatic.contains(renderMode)) }\n\n    String getDefaultMenuName() { return getPrettyMenuName(screenNode.attribute(\"default-menu-title\"), location, sfi.ecfi) }\n    static String getPrettyMenuName(String menuName, String location, ExecutionContextFactoryImpl ecfi) {\n        if (menuName == null || menuName.isEmpty()) {\n            String filename = location.substring(location.lastIndexOf(\"/\")+1, location.length()-4)\n            StringBuilder prettyName = new StringBuilder()\n            for (String part in filename.split(\"(?=[A-Z])\")) {\n                if (prettyName) prettyName.append(\" \")\n                prettyName.append(part)\n            }\n            char firstChar = prettyName.charAt(0)\n            if (Character.isLowerCase(firstChar)) prettyName.setCharAt(0, Character.toUpperCase(firstChar))\n            menuName = prettyName.toString()\n        }\n\n        return ecfi.getEci().l10nFacade.localize(menuName)\n    }\n\n    /** Get macro template location specific to screen from marco-template elements */\n    String getMacroTemplateLocation(String renderMode) {\n        if (macroTemplateByRenderMode == null) return null\n        return (String) macroTemplateByRenderMode.get(renderMode)\n    }\n\n    Map<String, ParameterItem> getParameterMap() { return parameterByName }\n    boolean hasRequiredParameters() { return hasRequired }\n    boolean hasTabMenu() { return hasTabMenu }\n\n    XmlAction getPreActions() { return preActions }\n    XmlAction getAlwaysActions() { return alwaysActions }\n\n    boolean hasTransition(String name) {\n        for (TransitionItem curTi in transitionByName.values()) if (curTi.name == name) return true\n        return false\n    }\n\n    TransitionItem getTransitionItem(String name, String method) {\n        method = method != null ? method.toLowerCase() : \"\"\n        TransitionItem ti = (TransitionItem) transitionByName.get(name.concat(\"#\").concat(method))\n        // if no ti, try by name only which will catch transitions with \"any\" or empty method\n        if (ti == null) ti = (TransitionItem) transitionByName.get(name)\n        // still none? try each one to see if it matches as a regular expression (first one to match wins)\n        if (ti == null) for (TransitionItem curTi in transitionByName.values()) {\n            if (method != null && !method.isEmpty() && (\"any\".equals(curTi.method) || method.equals(curTi.method))) {\n                if (name.equals(curTi.name)) { ti = curTi; break }\n                if (name.matches(curTi.name)) { ti = curTi; break }\n            }\n            // logger.info(\"In getTransitionItem() transition with name [${curTi.name}] method [${curTi.method}] did not match name [${name}] method [${method}]\")\n        }\n        return ti\n    }\n\n    Collection<TransitionItem> getAllTransitions() { return transitionByName.values() }\n\n    SubscreensItem getSubscreensItem(String name) { return (SubscreensItem) subscreensByName.get(name) }\n\n    ArrayList<String> findSubscreenPath(ArrayList<String> remainingPathNameList) {\n        if (!remainingPathNameList) return null\n        String curName = remainingPathNameList.get(0)\n        SubscreensItem curSsi = getSubscreensItem(curName)\n        if (curSsi != null) {\n            if (remainingPathNameList.size() > 1) {\n                ArrayList<String> subPathNameList = new ArrayList<>(remainingPathNameList)\n                subPathNameList.remove(0)\n                try {\n                    ScreenDefinition subSd = sfi.getScreenDefinition(curSsi.getLocation())\n                    ArrayList<String> subPath = subSd.findSubscreenPath(subPathNameList)\n                    if (!subPath) return null\n                    subPath.add(0, curName)\n                    return subPath\n                } catch (Exception e) {\n                    logger.error(\"Error finding subscreens under screen at ${curSsi.getLocation()}\", BaseException.filterStackTrace(e))\n                    return null\n                }\n            } else {\n                return remainingPathNameList\n            }\n        }\n\n        // if this is a transition right under this screen use it before searching subscreens\n        if (hasTransition(curName)) return remainingPathNameList\n\n        // breadth first by looking at subscreens of each subscreen on a first pass\n        for (Map.Entry<String, SubscreensItem> entry in subscreensByName.entrySet()) {\n            ScreenDefinition subSd = null\n            try {\n                subSd = sfi.getScreenDefinition(entry.getValue().getLocation())\n            } catch (Exception e) {\n                logger.error(\"Error finding subscreens under screen ${entry.key} at ${entry.getValue().getLocation()}\", BaseException.filterStackTrace(e))\n            }\n            if (subSd == null) {\n                if (logger.isTraceEnabled()) logger.trace(\"Screen ${entry.getKey()} at ${entry.getValue().getLocation()} not found, subscreen of [${this.getLocation()}]\")\n                continue\n            }\n            SubscreensItem subSsi = subSd.getSubscreensItem(curName)\n            if (subSsi != null) {\n                if (remainingPathNameList.size() > 1) {\n                    // if there are still more path elements, recurse to find them\n                    ArrayList<String> subPathNameList = new ArrayList<>(remainingPathNameList)\n                    subPathNameList.remove(0)\n                    ScreenDefinition subSubSd = sfi.getScreenDefinition(subSsi.getLocation())\n                    ArrayList<String> subPath = subSubSd.findSubscreenPath(subPathNameList)\n                    // found a partial match, not the full thing, no match so give up\n                    if (!subPath) return null\n                    // we've found it two deep, add both names, sub name first\n                    subPath.add(0, curName)\n                    subPath.add(0, entry.getKey())\n                    return subPath\n                } else {\n                    return new ArrayList<String>([entry.getKey(), curName])\n                }\n            }\n        }\n        // not immediate child or grandchild subscreen, start recursion\n        for (Map.Entry<String, SubscreensItem> entry in subscreensByName.entrySet()) {\n            ScreenDefinition subSd = null\n            try {\n                subSd = sfi.getScreenDefinition(entry.getValue().getLocation())\n            } catch (Exception e) {\n                logger.error(\"Error finding subscreens under screen ${entry.key} at ${entry.getValue().getLocation()}\", BaseException.filterStackTrace(e))\n            }\n            if (subSd == null) {\n                if (logger.isTraceEnabled()) logger.trace(\"Screen ${entry.getKey()} at ${entry.getValue().getLocation()} not found, subscreen of [${this.getLocation()}]\")\n                continue\n            }\n            List<String> subPath = subSd.findSubscreenPath(remainingPathNameList)\n            if (subPath) {\n                subPath.add(0, entry.getKey())\n                return subPath\n            }\n        }\n\n        // is this a resource (file) under the screen?\n        ResourceReference existingFileRef = getSubContentRef(remainingPathNameList)\n        if (existingFileRef && existingFileRef.supportsExists() && existingFileRef.exists) {\n            return remainingPathNameList\n        }\n\n        /* Used mainly for transition responses where the final path element is a screen, transition, or resource with\n            no extra path elements; allowing extra path elements causes problems only solvable by first searching without\n            allowing extra path elements, then searching the full tree for all possible paths that include extra elements\n            and choosing the maximal match (highest number of original sparse path elements matching actual screens)\n        if (allowExtraPath) { return remainingPathNameList }\n        */\n\n        // nothing found, return null by default\n        return null\n    }\n\n    List<String> nestedNoReqParmLocations(String currentPath, Set<String> screensToSkip) {\n        if (!screensToSkip) screensToSkip = new HashSet<String>()\n        List<String> locList = new ArrayList<>()\n        List<SubscreensItem> ssiList = getSubscreensItemsSorted()\n        for (SubscreensItem ssi in ssiList) {\n            if (screensToSkip.contains(ssi.name)) continue\n            try {\n                ScreenDefinition subSd = sfi.getScreenDefinition(ssi.location)\n                if (!subSd.hasRequiredParameters()) {\n                    String subPath = (currentPath ? currentPath + \"/\" : '') + ssi.name\n                    // don't add current if it a has a default subscreen item\n                    if (!subSd.getDefaultSubscreensItem()) locList.add(subPath)\n                    locList.addAll(subSd.nestedNoReqParmLocations(subPath, screensToSkip))\n                }\n            } catch (Exception e) {\n                logger.error(\"Error finding no parameter screens under ${this.location} for subscreen location ${ssi.location}\", e)\n            }\n        }\n        return locList\n    }\n\n    ArrayList<SubscreensItem> getSubscreensItemsSorted() {\n        if (subscreensItemsSorted != null) return subscreensItemsSorted\n        ArrayList<SubscreensItem> newList = new ArrayList(subscreensByName.size())\n        if (subscreensByName.size() == 0) return newList\n        newList.addAll(subscreensByName.values())\n        Collections.sort(newList, new SubscreensItemComparator())\n        return subscreensItemsSorted = newList\n    }\n\n    ArrayList<SubscreensItem> getMenuSubscreensItems() {\n        ArrayList<SubscreensItem> allItems = getSubscreensItemsSorted()\n        int allItemSize = allItems.size()\n        ArrayList<SubscreensItem> filteredList = new ArrayList(allItemSize)\n\n        for (int i = 0; i < allItemSize; i++) {\n            SubscreensItem si = (SubscreensItem) allItems.get(i)\n            // check the menu include flag\n            if (!si.menuInclude) continue\n            // valid in current context? (user group, etc)\n            if (!si.isValidInCurrentContext()) continue\n            // made it through the checks? add it in...\n            filteredList.add(si)\n        }\n\n        return filteredList\n    }\n\n    ScreenSection getRootSection() { return rootSection }\n    void render(ScreenRenderImpl sri, boolean isTargetScreen) {\n        // NOTE: don't require authz if the screen doesn't require auth\n        String requireAuthentication = screenNode.attribute(\"require-authentication\")\n        ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(location,\n                ArtifactExecutionInfo.AT_XML_SCREEN, ArtifactExecutionInfo.AUTHZA_VIEW, sri.outputContentType)\n        if (\"false\".equals(screenNode.attribute(\"track-artifact-hit\"))) aei.setTrackArtifactHit(false)\n        sri.ec.artifactExecutionFacade.pushInternal(aei, isTargetScreen ?\n                (requireAuthentication == null || requireAuthentication.length() == 0 || \"true\".equals(requireAuthentication)) : false, true)\n\n        boolean loggedInAnonymous = false\n        if (\"anonymous-all\".equals(requireAuthentication)) {\n            sri.ec.artifactExecutionFacade.setAnonymousAuthorizedAll()\n            loggedInAnonymous = sri.ec.userFacade.loginAnonymousIfNoUser()\n        } else if (\"anonymous-view\".equals(requireAuthentication)) {\n            sri.ec.artifactExecutionFacade.setAnonymousAuthorizedView()\n            loggedInAnonymous = sri.ec.userFacade.loginAnonymousIfNoUser()\n        }\n\n        // logger.info(\"Rendering screen ${location}, screenNode: \\n${screenNode}\")\n\n        try {\n            rootSection.render(sri)\n        } finally {\n            sri.ec.artifactExecutionFacade.pop(aei)\n            if (loggedInAnonymous) sri.ec.userFacade.logoutAnonymousOnly()\n        }\n    }\n\n    ScreenSection getSection(String sectionName) {\n        ScreenSection ss = sectionByName.get(sectionName)\n        if (ss == null) throw new BaseArtifactException(\"Could not find section ${sectionName} in screen ${getLocation()}\")\n        return ss\n    }\n    ScreenForm getForm(String formName) {\n        ScreenForm sf = formByName.get(formName)\n        if (sf == null) throw new BaseArtifactException(\"Could not find form ${formName} in screen ${getLocation()}\")\n        return sf\n    }\n    ArrayList<ScreenForm> getAllForms() { return new ArrayList<>(formByName.values()) }\n    ScreenTree getTree(String treeName) {\n        ScreenTree st = treeByName.get(treeName)\n        if (st == null) throw new BaseArtifactException(\"Could not find tree ${treeName} in screen ${getLocation()}\")\n        return st\n    }\n\n    ResourceReference getSubContentRef(List<String> pathNameList) {\n        StringBuilder pathNameBldr = new StringBuilder()\n        // add the path elements that remain\n        for (String rp in pathNameList) pathNameBldr.append(\"/\").append(rp)\n        String pathName = pathNameBldr.toString()\n\n        ResourceReference contentRef = subContentRefByPath.get(pathName)\n        if (contentRef != null) return contentRef\n\n        ResourceReference lastScreenRef = sfi.ecfi.resourceFacade.getLocationReference(location)\n        if (lastScreenRef.supportsAll()) {\n            // NOTE: this caches internally so consider getting rid of subContentRefByPath\n            contentRef = lastScreenRef.findChildFile(pathName)\n        } else {\n            logger.info(\"Not looking for sub-content [${pathName}] under screen [${location}] because screen location does not support exists, isFile, etc\")\n        }\n\n        if (contentRef != null) subContentRefByPath.put(pathName, contentRef)\n        return contentRef\n    }\n\n    List<Map<String, Object>> getScreenDocumentInfoList() {\n        String localeString = sfi.ecfi.getEci().userFacade.getLocale().toString()\n        int localeUnderscoreIndex = localeString.indexOf('_')\n        String langString = null\n        // look for locale match, lang only match, or null\n        if (localeUnderscoreIndex > 0) langString = localeString.substring(0, localeUnderscoreIndex)\n\n        // do very simple cached query for all, then filter in iterator by locale\n        EntityList list = sfi.ecfi.entityFacade.find(\"moqui.screen.ScreenDocument\").condition(\"screenLocation\", location)\n                .orderBy(\"docIndex\").useCache(true).disableAuthz().list()\n        int listSize = list.size()\n\n        List<Map<String, Object>> outList = new ArrayList<>(listSize)\n        for (int i = 0; i < listSize; i++) {\n            EntityValue screenDoc = (EntityValue) list.get(i)\n            String docLocale = screenDoc.getNoCheckSimple(\"locale\")\n            if (docLocale != null && (!localeString.equals(docLocale) || (langString != null && !langString.equals(docLocale)))) continue\n            String title = screenDoc.getNoCheckSimple(\"docTitle\")\n            if (title == null) {\n                String loc = screenDoc.getNoCheckSimple(\"docLocation\")\n                int fnStart = loc.lastIndexOf(\"/\") + 1\n                if (fnStart == -1) fnStart = 0\n                int fnEnd = loc.indexOf(\".\", fnStart)\n                if (fnEnd == -1) fnEnd = loc.length()\n                title = loc.substring(fnStart, fnEnd)\n            }\n            outList.add([title:title, index:(Long) screenDoc.getNoCheckSimple(\"docIndex\")] as Map<String, Object>)\n        }\n        return outList\n    }\n\n    @Override\n    String toString() { return location }\n\n    @CompileStatic\n    static class ParameterItem {\n        protected String name\n        protected Class fromFieldGroovy = null\n        protected String valueString = null\n        protected Class valueGroovy = null\n        protected boolean required = false\n\n        ParameterItem(MNode parameterNode, String location, ExecutionContextFactoryImpl ecfi) {\n            this.name = parameterNode.attribute(\"name\")\n            if (parameterNode.attribute(\"required\") == \"true\") required = true\n\n            if (parameterNode.attribute(\"from\")) fromFieldGroovy = ecfi.getGroovyClassLoader().parseClass(\n                    parameterNode.attribute(\"from\"), StringUtilities.cleanStringForJavaName(\"${location}.parameter_${name}.from_field\"))\n\n            valueString = parameterNode.attribute(\"value\")\n            if (valueString != null && valueString.length() == 0) valueString = null\n            if (valueString != null && valueString.contains('${')) {\n                valueGroovy = ecfi.getGroovyClassLoader().parseClass(('\"\"\"' + parameterNode.attribute(\"value\") + '\"\"\"'),\n                        StringUtilities.cleanStringForJavaName(\"${location}.parameter_${name}.value\"))\n            }\n        }\n        String getName() { return name }\n        Object getValue(ExecutionContext ec) {\n            Object value = null\n            if (fromFieldGroovy != null) { value = InvokerHelper.createScript(fromFieldGroovy, ec.contextBinding).run() }\n            if (value == null) {\n                if (valueGroovy != null) { value = InvokerHelper.createScript(valueGroovy, ec.contextBinding).run() }\n                else { value = valueString }\n            }\n            if (value == null) value = ec.context.getByString(name)\n            if (value == null && ec.web != null) value = ec.web.parameters.get(name)\n            return value\n        }\n    }\n\n    @CompileStatic\n    static class TransitionItem {\n        protected ScreenDefinition parentScreen\n        protected MNode transitionNode\n\n        protected String name\n        protected String method\n        protected String location\n        protected XmlAction condition = null\n        protected XmlAction actions = null\n        protected XmlAction serviceActions = null\n        protected String singleServiceName = null\n\n        protected Map<String, ParameterItem> parameterByName = new HashMap<>()\n        protected List<String> pathParameterList = null\n\n        protected List<ResponseItem> conditionalResponseList = new ArrayList<ResponseItem>()\n        protected ResponseItem defaultResponse = null\n        protected ResponseItem errorResponse = null\n\n        protected boolean beginTransaction = true\n        protected boolean readOnly = false\n        protected boolean requireSessionToken = true\n\n        protected TransitionItem(ScreenDefinition parentScreen) { this.parentScreen = parentScreen }\n\n        TransitionItem(MNode transitionNode, ScreenDefinition parentScreen) {\n            this.parentScreen = parentScreen\n            this.transitionNode = transitionNode\n            name = transitionNode.attribute(\"name\")\n            method = transitionNode.attribute(\"method\") ?: \"any\"\n            location = \"${parentScreen.location}.transition\\$${StringUtilities.cleanStringForJavaName(name)}\"\n            beginTransaction = transitionNode.attribute(\"begin-transaction\") != \"false\"\n            requireSessionToken = transitionNode.attribute(\"require-session-token\") != \"false\"\n\n            ExecutionContextFactoryImpl ecfi = parentScreen.sfi.ecfi\n            // parameter\n            for (MNode parameterNode in transitionNode.children(\"parameter\"))\n                parameterByName.put(parameterNode.attribute(\"name\"), new ParameterItem(parameterNode, location, ecfi))\n            // path-parameter\n            if (transitionNode.hasChild(\"path-parameter\")) {\n                pathParameterList = new ArrayList()\n                for (MNode pathParameterNode in transitionNode.children(\"path-parameter\"))\n                    pathParameterList.add(pathParameterNode.attribute(\"name\"))\n            }\n\n            // condition\n            if (transitionNode.first(\"condition\")?.first() != null) {\n                // the script is effectively the first child of the condition element\n                condition = new XmlAction(parentScreen.sfi.ecfi, transitionNode.first(\"condition\").first(), location + \".condition\")\n            }\n            // allow both call-service and actions\n            if (transitionNode.hasChild(\"actions\")) {\n                actions = new XmlAction(parentScreen.sfi.ecfi, transitionNode.first(\"actions\"), location + \".actions\")\n            }\n            if (transitionNode.hasChild(\"service-call\")) {\n                MNode callServiceNode = transitionNode.first(\"service-call\")\n                if (!callServiceNode.attribute(\"in-map\")) callServiceNode.attributes.put(\"in-map\", \"true\")\n                if (!callServiceNode.attribute(\"out-map\")) callServiceNode.attributes.put(\"out-map\", \"context\")\n                if (!callServiceNode.attribute(\"multi\") && !\"true\".equals(callServiceNode.attribute(\"async\")))\n                    callServiceNode.attributes.put(\"multi\", \"parameter\")\n                serviceActions = new XmlAction(parentScreen.sfi.ecfi, callServiceNode, location + \".service_call\")\n                singleServiceName = callServiceNode.attribute(\"name\")\n            }\n\n            readOnly = (actions == null && serviceActions == null) || transitionNode.attribute(\"read-only\") == \"true\"\n\n            // conditional-response*\n            for (MNode condResponseNode in transitionNode.children(\"conditional-response\"))\n                conditionalResponseList.add(new ResponseItem(condResponseNode, this, parentScreen))\n            // default-response\n            defaultResponse = new ResponseItem(transitionNode.first(\"default-response\"), this, parentScreen)\n            // error-response\n            if (transitionNode.hasChild(\"error-response\"))\n                errorResponse = new ResponseItem(transitionNode.first(\"error-response\"), this, parentScreen)\n        }\n\n        String getName() { return name }\n        String getMethod() { return method }\n        String getSingleServiceName() { return singleServiceName }\n        List<String> getPathParameterList() { return pathParameterList }\n        Map<String, ParameterItem> getParameterMap() { return parameterByName }\n        boolean hasActionsOrSingleService() { return actions != null || serviceActions != null}\n        boolean getBeginTransaction() { return beginTransaction }\n        boolean isReadOnly() { return readOnly }\n        boolean getRequireSessionToken() { return requireSessionToken }\n\n        boolean checkCondition(ExecutionContextImpl ec) { return condition ? condition.checkCondition(ec) : true }\n\n        void setAllParameters(List<String> extraPathNameList, ExecutionContextImpl ec) {\n            // get the path parameters\n            if (extraPathNameList && getPathParameterList()) {\n                List<String> pathParameterList = getPathParameterList()\n                int i = 0\n                for (String extraPathName in extraPathNameList) {\n                    if (pathParameterList.size() > i) {\n                        // logger.warn(\"extraPathName ${extraPathName} i ${i} name ${pathParameterList.get(i)}\")\n                        if (ec.webImpl != null) ec.webImpl.addDeclaredPathParameter(pathParameterList.get(i), extraPathName)\n                        ec.getContext().put(pathParameterList.get(i), extraPathName)\n                        i++\n                    } else {\n                        break\n                    }\n                }\n            }\n\n            // put parameters in the context\n            if (ec.getWeb() != null) {\n                // screen parameters\n                for (ParameterItem pi in parentScreen.getParameterMap().values()) {\n                    Object value = pi.getValue(ec)\n                    if (value != null) ec.contextStack.put(pi.getName(), value)\n                }\n                // transition parameters\n                for (ParameterItem pi in parameterByName.values()) {\n                    Object value = pi.getValue(ec)\n                    if (value != null) ec.contextStack.put(pi.getName(), value)\n                }\n            }\n        }\n\n        ResponseItem run(ScreenRenderImpl sri) {\n            ExecutionContextImpl ec = sri.ec\n\n            // NOTE: if parent screen of transition does not require auth, don't require authz\n            // NOTE: use the View authz action to leave it open, ie require minimal authz; restrictions are often more\n            //    in the services/etc if/when needed, or specific transitions can have authz settings\n            String requireAuthentication = (String) parentScreen.screenNode.attribute('require-authentication')\n            ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(\"${parentScreen.location}/${name}\",\n                    ArtifactExecutionInfo.AT_XML_SCREEN_TRANS, ArtifactExecutionInfo.AUTHZA_VIEW, sri.outputContentType)\n            ec.artifactExecutionFacade.pushInternal(aei, (!requireAuthentication || \"true\".equals(requireAuthentication)), true)\n\n            boolean loggedInAnonymous = false\n            if (requireAuthentication == \"anonymous-all\") {\n                ec.artifactExecutionFacade.setAnonymousAuthorizedAll()\n                loggedInAnonymous = ec.userFacade.loginAnonymousIfNoUser()\n            } else if (requireAuthentication == \"anonymous-view\") {\n                ec.artifactExecutionFacade.setAnonymousAuthorizedView()\n                loggedInAnonymous = ec.userFacade.loginAnonymousIfNoUser()\n            }\n\n            try {\n                ScreenUrlInfo screenUrlInfo = sri.getScreenUrlInfo()\n                ScreenUrlInfo.UrlInstance screenUrlInstance = sri.getScreenUrlInstance()\n                setAllParameters(screenUrlInfo.getExtraPathNameList(), ec)\n                // for alias transitions rendered in-request put the parameters in the context\n                if (screenUrlInstance.getTransitionAliasParameters()) ec.contextStack.putAll(screenUrlInstance.getTransitionAliasParameters())\n\n\n                if (!checkCondition(ec)) {\n                    sri.ec.message.addError(ec.resource.expand('Condition failed for transition [${location}], not running actions or redirecting','',[location:location]))\n                    if (errorResponse) return errorResponse\n                    return defaultResponse\n                }\n\n                // don't push a map on the context, let the transition actions set things that will remain: sri.ec.context.push()\n                ec.contextStack.put(\"sri\", sri)\n                // logger.warn(\"Running transition ${name} context: ${ec.contextStack.toString()}\")\n                if (serviceActions != null) {\n                    // if this is an implicit entity auto service filter input for HTML like done in defined service calls by default;\n                    //     to get around define a service with a parameter that allows safe or any HTML instead of using implicit entity auto directly\n                    if (ec.serviceFacade.isEntityAutoPattern(singleServiceName)) {\n                        String entityName = ServiceDefinition.getNounFromName(singleServiceName)\n                        EntityDefinition ed = ec.entityFacade.getEntityDefinition(entityName)\n                        if (ed != null) {\n                            ArrayList<String> fieldNameList = ed.getAllFieldNames()\n                            int fieldNameListSize = fieldNameList.size()\n                            for (int i = 0; i < fieldNameListSize; i++) {\n                                String fieldName = (String) fieldNameList.get(i)\n                                Object fieldValue = ec.contextStack.getByString(fieldName)\n                                if (fieldValue instanceof CharSequence) {\n                                    String fieldString = fieldValue.toString()\n                                    if (fieldString.contains(\"<\")) {\n                                        ec.messageFacade.addValidationError(null, fieldName, singleServiceName,\n                                                ec.getL10n().localize(\"HTML not allowed including less-than (<), greater-than (>), etc symbols\"), null)\n                                    }\n                                }\n                            }\n                        }\n                    }\n                    if (!ec.messageFacade.hasError()) {\n                        serviceActions.run(ec)\n                    }\n                }\n                // run actions if any defined, even if service-call also used\n                // NOTE: prior code also required !ec.messageFacade.hasError() which doesn't allow actions to handle errors\n                if (actions != null) {\n                    actions.run(ec)\n                }\n\n                ResponseItem ri = null\n                // if there is an error-response and there are errors, we have a winner\n                if (ec.messageFacade.hasError() && errorResponse) ri = errorResponse\n\n                // check all conditional-response, if condition then return that response\n                if (ri == null) for (ResponseItem condResp in conditionalResponseList) {\n                    if (condResp.checkCondition(ec)) ri = condResp\n                }\n                // no errors, no conditionals, return default\n                if (ri == null) ri = defaultResponse\n\n                return ri\n            } finally {\n                // don't pop the context until after evaluating conditions so that data set in the actions can be used\n                // don't pop the context at all, see note above about push: sri.ec.context.pop()\n\n                // all done so pop the artifact info; don't bother making sure this is done on errors/etc like in a finally\n                // clause because if there is an error this will help us know how we got there\n                ec.artifactExecutionFacade.pop(aei)\n                if (loggedInAnonymous) ec.userFacade.logoutAnonymousOnly()\n            }\n        }\n    }\n\n    static class ActionsTransitionItem extends TransitionItem {\n        ActionsTransitionItem(ScreenDefinition parentScreen) {\n            super(parentScreen)\n            name = \"actions\"; method = \"any\"; location = \"${parentScreen.location}.transition\\$${name}\"\n            transitionNode = null; beginTransaction = true; readOnly = true; requireSessionToken = false\n            defaultResponse = new ResponseItem(new MNode(\"default-response\", [type:\"none\"]), this, parentScreen)\n        }\n\n        // NOTE: runs pre-actions too, see sri.recursiveRunTransition() call in sri.internalRender()\n        ResponseItem run(ScreenRenderImpl sri) {\n            ExecutionContextImpl ec = sri.ec\n            ContextStack context = ec.contextStack\n            context.put(\"sri\", sri)\n            WebFacade wf = ec.getWeb()\n            if (wf == null) throw new BaseArtifactException(\"Cannot run actions transition outside of a web request\")\n\n            ArrayList<String> extraPathList = sri.screenUrlInfo.extraPathNameList\n            if (extraPathList != null && extraPathList.size() > 0) {\n                String partName = (String) extraPathList.get(0)\n                // is it a form or tree?\n                ScreenForm form = parentScreen.formByName.get(partName)\n                if (form != null) {\n                    if (!form.hasDataPrep()) throw new BaseArtifactException(\"Found form ${partName} in screen ${parentScreen.getScreenName()} but it does not have its own data preparation\")\n                    ScreenForm.FormInstance formInstance = form.getFormInstance()\n                    if (formInstance.isList()) {\n                        ScreenForm.FormListRenderInfo renderInfo = formInstance.makeFormListRenderInfo()\n                        // old approach, raw data: Object listObj = renderInfo.getListObject(true)\n                        // new approach: transformed and auto values filled in based on field defs\n                        ArrayList<Map<String, Object>> listObj = sri.getFormListRowValues(renderInfo)\n\n                        HttpServletResponse response = wf.response\n                        String listName = formInstance.formNode.attribute(\"list\")\n                        if (context.get(listName.concat(\"Count\")) != null) {\n                            response.addIntHeader('X-Total-Count', context.get(listName.concat(\"Count\")) as int)\n                            response.addIntHeader('X-Page-Index', context.get(listName.concat(\"PageIndex\")) as int)\n                            response.addIntHeader('X-Page-Size', context.get(listName.concat(\"PageSize\")) as int)\n                            response.addIntHeader('X-Page-Max-Index', context.get(listName.concat(\"PageMaxIndex\")) as int)\n                            response.addIntHeader('X-Page-Range-Low', context.get(listName.concat(\"PageRangeLow\")) as int)\n                            response.addIntHeader('X-Page-Range-High', context.get(listName.concat(\"PageRangeHigh\")) as int)\n                        }\n\n                        logger.info(\"form ${partName} actions result:\\n${JsonOutput.prettyPrint(JsonOutput.toJson(listObj))}\")\n                        wf.sendJsonResponse(listObj)\n                    }\n                    // TODO: else support form-single data prep once something is added\n                } else {\n                    ScreenTree tree = parentScreen.treeByName.get(partName)\n                    if (tree != null) {\n                        tree.sendSubNodeJson()\n                    } else {\n                        throw new BaseArtifactException(\"Could not find form or tree named ${partName} in screen ${parentScreen.getScreenName()} so cannot run its actions\")\n                    }\n                }\n            } else {\n                // run actions (if there are any)\n                XmlAction actions = parentScreen.rootSection.actions\n                if (actions != null) {\n                    actions.run(ec)\n                    // use entire ec.context to get values from always-actions and pre-actions\n                    wf.sendJsonResponse(ContextJavaUtil.unwrapMap(context))\n                } else {\n                    wf.sendJsonResponse(new HashMap())\n                }\n            }\n\n            return defaultResponse\n        }\n    }\n\n    /** Special automatic transition to save results of Select Columns form for form-list with select-columns=true */\n    static class FormSelectColumnsTransitionItem extends TransitionItem {\n        FormSelectColumnsTransitionItem(ScreenDefinition parentScreen) {\n            super(parentScreen)\n            name = \"formSelectColumns\"; method = \"any\"; location = \"${parentScreen.location}.transition\\$${name}\"\n            transitionNode = null; beginTransaction = true; readOnly = false; requireSessionToken = false\n            defaultResponse = new ResponseItem(new MNode(\"default-response\", [type:\"none\"]), this, parentScreen)\n        }\n\n        ResponseItem run(ScreenRenderImpl sri) {\n            ScreenForm.saveFormConfig(sri.ec)\n            ScreenUrlInfo.UrlInstance redirectUrl = sri.buildUrl(sri.rootScreenDef, sri.screenUrlInfo.preTransitionPathNameList, \".\")\n            redirectUrl.addParameters(sri.getCurrentScreenUrl().getParameterMap()).removeParameter(\"columnsTree\")\n                    .removeParameter(\"formLocation\").removeParameter(\"ResetColumns\")\n                    .removeParameter(\"SaveColumns\").removeParameter(\"_uiType\")\n\n            if (!sri.sendJsonRedirect(redirectUrl, null)) sri.response.sendRedirect(redirectUrl.getUrlWithParams())\n            return defaultResponse\n        }\n    }\n    /** Special automatic transition to manage Saved Finds for form-list with saved-finds=true */\n    static class FormSavedFindsTransitionItem extends TransitionItem {\n        protected ResponseItem noneResponse = null\n\n        FormSavedFindsTransitionItem(ScreenDefinition parentScreen) {\n            super(parentScreen)\n            name = \"formSaveFind\"; method = \"any\"; location = \"${parentScreen.location}.transition\\$${name}\"\n            transitionNode = null; beginTransaction = true; readOnly = false; requireSessionToken = false\n            defaultResponse = new ResponseItem(new MNode(\"default-response\", [url:\".\"]), this, parentScreen)\n            noneResponse = new ResponseItem(new MNode(\"default-response\", [type:\"none\"]), this, parentScreen)\n        }\n\n        ResponseItem run(ScreenRenderImpl sri) {\n            String formListFindId = ScreenForm.processFormSavedFind(sri.ec)\n\n            if (formListFindId == null || sri.response == null) return defaultResponse\n\n            ScreenUrlInfo curUrlInfo = sri.getScreenUrlInfo()\n            ArrayList<String> curFpnl = new ArrayList<>(curUrlInfo.fullPathNameList)\n            // remove last path element, is transition name and we just want the screen this is from\n            curFpnl.remove(curFpnl.size() - 1)\n\n            ScreenUrlInfo fwdUrlInfo = ScreenUrlInfo.getScreenUrlInfo(sri, null, curFpnl, null, 0)\n            ScreenUrlInfo.UrlInstance fwdInstance = fwdUrlInfo.getInstance(sri, null)\n\n            // use only formListFindId now that ScreenRenderImpl picks it up and auto adds configured parameters:\n            // Map<String, Object> flfInfo = ScreenForm.getFormListFindInfo(formListFindId, sri.ec, null)\n            // fwdInstance.addParameters((Map<String, String>) flfInfo.findParameters)\n            fwdInstance.addParameter(\"formListFindId\", formListFindId)\n\n            if (!sri.sendJsonRedirect(fwdInstance, null)) sri.response.sendRedirect(fwdInstance.getUrlWithParams())\n            return noneResponse\n        }\n    }\n\n    /** Special automatic transition to get content of a ScreenDocument by docIndex */\n    static class ScreenDocumentTransitionItem extends TransitionItem {\n        ScreenDocumentTransitionItem(ScreenDefinition parentScreen) {\n            super(parentScreen)\n            name = \"screenDoc\"; method = \"any\"; location = \"${parentScreen.location}.transition\\$${name}\"\n            transitionNode = null; beginTransaction = false; readOnly = true; requireSessionToken = false\n            defaultResponse = new ResponseItem(new MNode(\"default-response\", [type:\"none\"]), this, parentScreen)\n        }\n\n        ResponseItem run(ScreenRenderImpl sri) {\n            ExecutionContextImpl eci = sri.ec\n            String docIndexString = eci.contextStack.getByString(\"docIndex\")\n            if (docIndexString == null || docIndexString.isEmpty()) {\n                eci.web.sendError(HttpServletResponse.SC_NOT_FOUND, \"No docIndex specified\", null)\n                return defaultResponse\n            }\n            Long docIndex = docIndexString as Long\n            EntityValue screenDocument = eci.entityFacade.find(\"moqui.screen.ScreenDocument\")\n                    .condition(\"screenLocation\", parentScreen.location).condition(\"docIndex\", docIndex)\n                    .useCache(true).disableAuthz().one()\n            if (screenDocument == null) {\n                eci.web.sendError(HttpServletResponse.SC_NOT_FOUND, \"No document found for index ${docIndex}\", null)\n                return defaultResponse\n            }\n\n            String location = screenDocument.getNoCheckSimple(\"docLocation\")\n            eci.resourceFacade.template(location, sri.response.getWriter())\n\n            return defaultResponse\n        }\n    }\n\n    @CompileStatic\n    static class ResponseItem {\n        protected TransitionItem transitionItem\n        protected ScreenDefinition parentScreen\n        protected XmlAction condition = null\n        protected Map<String, ParameterItem> parameterMap = new HashMap<>()\n\n        protected String type\n        protected String url\n        protected String urlType\n        protected Class parameterMapNameGroovy = null\n        protected boolean saveCurrentScreen\n        protected boolean saveParameters\n\n        ResponseItem(MNode responseNode, TransitionItem ti, ScreenDefinition parentScreen) {\n            this.transitionItem = ti\n            this.parentScreen = parentScreen\n            String location = \"${parentScreen.location}.transition_${ti.name}.${responseNode.name.replace(\"-\",\"_\")}\"\n            if (responseNode.first(\"condition\")?.first() != null) {\n                // the script is effectively the first child of the condition element\n                condition = new XmlAction(parentScreen.sfi.ecfi, responseNode.first(\"condition\").first(),\n                        location + \".condition\")\n            }\n\n            ExecutionContextFactoryImpl ecfi = parentScreen.sfi.ecfi\n            type = responseNode.attribute(\"type\") ?: \"url\"\n            url = responseNode.attribute(\"url\")\n            urlType = responseNode.attribute(\"url-type\") ?: \"screen-path\"\n            if (responseNode.attribute(\"parameter-map\")) parameterMapNameGroovy = ecfi.getGroovyClassLoader()\n                    .parseClass(responseNode.attribute(\"parameter-map\"), \"${location}.parameter_map\")\n            // deferred for future version: saveLastScreen = responseNode.\"@save-last-screen\" == \"true\"\n            saveCurrentScreen = responseNode.attribute(\"save-current-screen\") == \"true\"\n            saveParameters = responseNode.attribute(\"save-parameters\") == \"true\"\n\n            for (MNode parameterNode in responseNode.children(\"parameter\"))\n                parameterMap.put(parameterNode.attribute(\"name\"), new ParameterItem(parameterNode, location, ecfi))\n        }\n\n        boolean checkCondition(ExecutionContextImpl ec) { return condition ? condition.checkCondition(ec) : true }\n\n        String getType() { return type }\n        String getUrl() { return parentScreen.sfi.ecfi.resourceFacade.expandNoL10n(url, \"\") }\n        String getUrlType() { return urlType }\n        boolean getSaveCurrentScreen() { return saveCurrentScreen }\n        boolean getSaveParameters() { return saveParameters }\n\n        Map expandParameters(List<String> extraPathNameList, ExecutionContextImpl ec) {\n            transitionItem.setAllParameters(extraPathNameList, ec)\n\n            Map ep = new HashMap()\n            for (ParameterItem pi in parameterMap.values()) ep.put(pi.getName(), pi.getValue(ec))\n            if (parameterMapNameGroovy != null) {\n                Object pm = InvokerHelper.createScript(parameterMapNameGroovy, ec.getContextBinding()).run()\n                if (pm && pm instanceof Map) ep.putAll((Map) pm)\n            }\n            // logger.warn(\"========== Expanded response map to url [${url}] to: ${ep}; parameterMap=${parameterMap}; parameterMapNameGroovy=[${parameterMapNameGroovy}]\")\n            return ep\n        }\n    }\n\n    @CompileStatic\n    static class SubscreensItem {\n        protected ScreenDefinition parentScreen\n        protected String name\n        protected String location\n        protected String menuTitle\n        protected Integer menuIndex\n        protected boolean menuInclude\n        protected boolean noSubPath = false\n        protected Class disableWhenGroovy = null\n        protected String userGroupId = null\n\n        SubscreensItem(String name, String location, MNode screen, ScreenDefinition parentScreen) {\n            this.parentScreen = parentScreen\n            this.name = name\n            this.location = location\n            menuTitle = screen.attribute(\"default-menu-title\") ?: getDefaultTitle()\n            menuIndex = screen.attribute(\"default-menu-index\") ? (screen.attribute(\"default-menu-index\") as Integer) : null\n            menuInclude = (!screen.attribute(\"default-menu-include\") || screen.attribute(\"default-menu-include\") == \"true\")\n        }\n\n        SubscreensItem(MNode subscreensItem, ScreenDefinition parentScreen) {\n            this.parentScreen = parentScreen\n            name = subscreensItem.attribute(\"name\")\n            location = subscreensItem.attribute(\"location\")\n            menuTitle = subscreensItem.attribute(\"menu-title\") ?: getDefaultTitle()\n            menuIndex = subscreensItem.attribute(\"menu-index\") ? (subscreensItem.attribute(\"menu-index\") as Integer) : null\n            menuInclude = !subscreensItem.attribute(\"menu-include\") || subscreensItem.attribute(\"menu-include\") == \"true\"\n            noSubPath = subscreensItem.attribute(\"no-sub-path\") == \"true\"\n\n            if (subscreensItem.attribute(\"disable-when\")) disableWhenGroovy = parentScreen.sfi.ecfi.getGroovyClassLoader()\n                    .parseClass(subscreensItem.attribute(\"disable-when\"), \"${parentScreen.location}.subscreens_item_${name}.disable_when\")\n        }\n\n        SubscreensItem(EntityValue subscreensItem, ScreenDefinition parentScreen) {\n            this.parentScreen = parentScreen\n            name = subscreensItem.subscreenName\n            location = subscreensItem.subscreenLocation\n            menuTitle = subscreensItem.menuTitle ?: getDefaultTitle()\n            menuIndex = subscreensItem.menuIndex ? subscreensItem.menuIndex as Integer : null\n            menuInclude = subscreensItem.menuInclude == \"Y\"\n            noSubPath = subscreensItem.noSubPath == \"Y\"\n            userGroupId = subscreensItem.userGroupId\n        }\n\n        String getDefaultTitle() {\n            ExecutionContextFactoryImpl ecfi = parentScreen.sfi.ecfi\n            ResourceReference screenRr = ecfi.resourceFacade.getLocationReference(location)\n            MNode screenNode = MNode.parseRootOnly(screenRr)\n            return getPrettyMenuName(screenNode?.attribute(\"default-menu-title\"), location, ecfi)\n        }\n\n        String getName() { return name }\n        String getLocation() { return location }\n        String getMenuTitle() { return menuTitle }\n        Integer getMenuIndex() { return menuIndex }\n        boolean getMenuInclude() { return menuInclude }\n        boolean getDisable(ExecutionContext ec) {\n            if (disableWhenGroovy == null) return false\n            return InvokerHelper.createScript(disableWhenGroovy, ec.contextBinding).run() as boolean\n        }\n        String getUserGroupId() { return userGroupId }\n        boolean isValidInCurrentContext() {\n            ExecutionContextImpl eci = parentScreen.sfi.getEcfi().getEci()\n            // if the subscreens item is limited to a UserGroup make sure user is in that group\n            if (userGroupId && !(userGroupId in eci.getUser().getUserGroupIdSet())) return false\n\n            return true\n        }\n    }\n\n    @CompileStatic\n    static class SubscreensItemComparator implements Comparator<SubscreensItem> {\n        SubscreensItemComparator() { }\n        @Override\n        int compare(SubscreensItem ssi1, SubscreensItem ssi2) {\n            // order by index, null index first\n            if (ssi1.menuIndex == null && ssi2.menuIndex != null) return -1\n            if (ssi1.menuIndex != null && ssi2.menuIndex == null) return 1\n            if (ssi1.menuIndex != null && ssi2.menuIndex != null) {\n                int indexComp = ssi1.menuIndex.compareTo(ssi2.menuIndex)\n                if (indexComp != 0) return indexComp\n            }\n            // if index is the same or both null, order by localized title\n            ResourceFacade rf = ssi1.parentScreen.sfi.ecfi.resourceFacade\n            return rf.expand(ssi1.menuTitle,'',null,true).toUpperCase().compareTo(\n                   rf.expand(ssi2.menuTitle,'',null,true).toUpperCase())\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/screen/ScreenFacadeImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.screen\n\nimport freemarker.template.Template\nimport groovy.transform.CompileStatic\nimport org.moqui.BaseArtifactException\nimport org.moqui.resource.ResourceReference\nimport org.moqui.screen.ScreenFacade\nimport org.moqui.screen.ScreenRender\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.screen.ScreenDefinition.SubscreensItem\nimport org.moqui.impl.screen.ScreenDefinition.TransitionItem\nimport org.moqui.screen.ScreenTest\nimport org.moqui.util.MNode\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport javax.cache.Cache\n\n@CompileStatic\nclass ScreenFacadeImpl implements ScreenFacade {\n    protected final static Logger logger = LoggerFactory.getLogger(ScreenFacadeImpl.class)\n\n    protected final ExecutionContextFactoryImpl ecfi\n\n    protected final Cache<String, ScreenDefinition> screenLocationCache\n    protected final Cache<String, ScreenDefinition> screenLocationPermCache\n    // used by ScreenUrlInfo\n    final Cache<String, ScreenUrlInfo> screenUrlCache\n    protected final Cache<String, List<ScreenInfo>> screenInfoCache\n    protected final Cache<String, Set<String>> screenInfoRefRevCache\n    protected final Cache<String, Template> screenTemplateModeCache\n    protected final Map<String, String> mimeTypeByRenderMode = new HashMap<>()\n    protected final Map<String, Boolean> alwaysStandaloneByRenderMode = new HashMap<>()\n    protected final Map<String, Boolean> skipActionsByRenderMode = new HashMap<>()\n    protected final Cache<String, Template> screenTemplateLocationCache\n    protected final Cache<String, MNode> widgetTemplateLocationCache\n    protected final Cache<String, ArrayList<String>> screenFindPathCache\n    protected final Cache<String, MNode> dbFormNodeByIdCache\n\n    protected final Map<String, ScreenWidgetRender> screenWidgetRenderByMode = new HashMap<>()\n    protected final ScreenWidgetRender textMacroWidgetRender = new ScreenWidgetRenderFtl()\n    protected final Set<String> textOutputRenderModes = new HashSet<>()\n    protected final Set<String> allRenderModes = new HashSet<>()\n\n    protected final Map<String, Map<String, String>> themeIconByTextByTheme = new HashMap<>()\n\n    ScreenFacadeImpl(ExecutionContextFactoryImpl ecfi) {\n        this.ecfi = ecfi\n        screenLocationCache = ecfi.cacheFacade.getCache(\"screen.location\", String.class, ScreenDefinition.class)\n        screenLocationPermCache = ecfi.cacheFacade.getCache(\"screen.location.perm\", String.class, ScreenDefinition.class)\n        screenUrlCache = ecfi.cacheFacade.getCache(\"screen.url\", String.class, ScreenUrlInfo.class)\n        screenInfoCache = ecfi.cacheFacade.getCache(\"screen.info\", String.class, List.class)\n        screenInfoRefRevCache = ecfi.cacheFacade.getCache(\"screen.info.ref.rev\", String.class, Set.class)\n        screenTemplateModeCache = ecfi.cacheFacade.getCache(\"screen.template.mode\", String.class, Template.class)\n        screenTemplateLocationCache = ecfi.cacheFacade.getCache(\"screen.template.location\", String.class, Template.class)\n        widgetTemplateLocationCache = ecfi.cacheFacade.getCache(\"widget.template.location\", String.class, MNode.class)\n        screenFindPathCache = ecfi.cacheFacade.getCache(\"screen.find.path\", String.class, ArrayList.class)\n        dbFormNodeByIdCache = ecfi.cacheFacade.getCache(\"screen.form.db.node\", String.class, MNode.class)\n\n        MNode screenFacadeNode = ecfi.getConfXmlRoot().first(\"screen-facade\")\n        ArrayList<MNode> stoNodes = screenFacadeNode.children(\"screen-text-output\")\n        for (MNode stoNode in stoNodes) textOutputRenderModes.add(stoNode.attribute(\"type\"))\n\n        ArrayList<MNode> outputNodes = new ArrayList<>(stoNodes)\n        ArrayList<MNode> soutNodes = screenFacadeNode.children(\"screen-output\")\n        if (soutNodes != null && soutNodes.size() > 0) outputNodes.addAll(soutNodes)\n        for (MNode outputNode in outputNodes) {\n            String type = outputNode.attribute(\"type\")\n            allRenderModes.add(type)\n            mimeTypeByRenderMode.put(type, outputNode.attribute(\"mime-type\"))\n            alwaysStandaloneByRenderMode.put(type, outputNode.attribute(\"always-standalone\") == \"true\")\n            skipActionsByRenderMode.put(type, outputNode.attribute(\"skip-actions\") == \"true\")\n        }\n    }\n\n    ExecutionContextFactoryImpl getEcfi() { return ecfi }\n\n    void warmCache() {\n        long startTime = System.currentTimeMillis()\n        int screenCount = 0\n        for (String rootLocation in getAllRootScreenLocations()) {\n            logger.info(\"Warming cache for all screens under ${rootLocation}\")\n            ScreenDefinition rootSd = getScreenDefinition(rootLocation)\n            screenCount++\n            screenCount += warmCacheScreen(rootSd)\n        }\n        logger.info(\"Warmed screen definition cache for ${screenCount} screens in ${(System.currentTimeMillis() - startTime)/1000} seconds\")\n    }\n    protected int warmCacheScreen(ScreenDefinition sd) {\n        int screenCount = 0\n        for (SubscreensItem ssi in sd.subscreensByName.values()) {\n            try {\n                ScreenDefinition subSd = getScreenDefinition(ssi.getLocation())\n                screenCount++\n                if (subSd) screenCount += warmCacheScreen(subSd)\n            } catch (Throwable t) {\n                logger.error(\"Error loading screen at [${ssi.getLocation()}] during cache warming\", t)\n            }\n        }\n        return screenCount\n    }\n\n    List<String> getAllRootScreenLocations() {\n        List<String> allLocations = []\n        for (MNode webappNode in ecfi.confXmlRoot.first(\"webapp-list\").children(\"webapp\")) {\n            for (MNode rootScreenNode in webappNode.children(\"root-screen\")) {\n                String rootLocation = rootScreenNode.attribute(\"location\")\n                allLocations.add(rootLocation)\n            }\n        }\n        return allLocations\n    }\n\n    boolean isScreen(String location) {\n        if (location == null || location.length() == 0) return false\n        if (!location.endsWith(\".xml\")) return false\n        if (screenLocationCache.containsKey(location)) return true\n\n        try {\n            // we checked the screenLocationCache above, so now do a quick file parse to see if it is a XML file with 'screen' root\n            //     element; this is faster and more reliable when a screen is not loaded, screen doesn't have to be fully valid\n            //     which is important as with the old approach if there was an error parsing or compiling the screen it was a false\n            //     negative and the screen source would be sent in response\n            ResourceReference screenRr = ecfi.resourceFacade.getLocationReference(location)\n            MNode screenNode = MNode.parseRootOnly(screenRr)\n            return screenNode != null && \"screen\".equals(screenNode.getName())\n\n            // old approach\n            // ScreenDefinition checkSd = getScreenDefinition(location)\n            // return (checkSd != null)\n        } catch (Throwable t) {\n            // ignore the error, just checking to see if it is a screen\n            if (logger.isInfoEnabled()) logger.info(\"Error when checking to see if [${location}] is a XML Screen: ${t.toString()}\", t)\n            return false\n        }\n    }\n\n    ScreenDefinition getScreenDefinition(String location) {\n        if (location == null || location.length() == 0) return null\n        ScreenDefinition sd = (ScreenDefinition) screenLocationCache.get(location)\n        if (sd != null) return sd\n\n        return makeScreenDefinition(location)\n    }\n\n    protected synchronized ScreenDefinition makeScreenDefinition(String location) {\n        ScreenDefinition sd = (ScreenDefinition) screenLocationCache.get(location)\n        if (sd != null) return sd\n\n        ResourceReference screenRr = ecfi.resourceFacade.getLocationReference(location)\n\n        ScreenDefinition permSd = (ScreenDefinition) screenLocationPermCache.get(location)\n        if (permSd != null) {\n            // check to see if file has been modified, if we know when it was last modified\n            boolean modified = true\n            if (screenRr.supportsLastModified()) {\n                long rrLastModified = screenRr.getLastModified()\n                modified = permSd.screenLoadedTime < rrLastModified\n                // see if any screens it depends on (any extends, etc) have been modified\n                if (!modified) {\n                    for (String dependLocation in permSd.dependsOnScreenLocations) {\n                        ScreenDefinition dependSd = getScreenDefinition(dependLocation)\n                        if (dependSd.sourceLastModified == null) { modified = true; break; }\n                        if (dependSd.sourceLastModified > permSd.screenLoadedTime) {\n                            // logger.info(\"Screen ${location} depends on ${dependLocation}, modified ${dependSd.sourceLastModified} > ${permSd.screenLoadedTime}\")\n                            modified = true; break;\n                        }\n                    }\n                }\n            }\n\n            if (modified) {\n                screenLocationPermCache.remove(location)\n                logger.info(\"Reloading modified screen ${location}\")\n            } else {\n                //logger.warn(\"========= screen expired but hasn't changed so reusing: ${location}\")\n\n                // call this just in case a new screen was added, note this does slow things down just a bit, but only in dev (not in production)\n                permSd.populateSubscreens()\n\n                screenLocationCache.put(location, permSd)\n                return permSd\n            }\n        }\n\n        MNode screenNode = MNode.parse(screenRr)\n        if (screenNode == null) throw new BaseArtifactException(\"Could not find definition for screen location ${location}\")\n\n        sd = new ScreenDefinition(this, screenNode, location)\n        // logger.warn(\"========= loaded screen [${location}] supports LM ${screenRr.supportsLastModified()}, LM: ${screenRr.getLastModified()}\")\n        if (screenRr.supportsLastModified()) sd.sourceLastModified = screenRr.getLastModified()\n        screenLocationCache.put(location, sd)\n        if (screenRr.supportsLastModified()) screenLocationPermCache.put(location, sd)\n        return sd\n    }\n\n    /** NOTE: this is used in ScreenServices.xml for dynamic form stuff (FormResponse, etc) */\n    MNode getFormNode(String location) {\n        if (!location) return null\n        if (location.contains(\"#\")) {\n            String screenLocation = location.substring(0, location.indexOf(\"#\"))\n            String formName = location.substring(location.indexOf(\"#\")+1)\n            if (screenLocation == \"moqui.screen.form.DbForm\" || screenLocation == \"DbForm\") {\n                return ScreenForm.getDbFormNode(formName, ecfi)\n            } else {\n                ScreenDefinition esd = getScreenDefinition(screenLocation)\n                ScreenForm esf = esd ? esd.getForm(formName) : null\n                return esf?.getOrCreateFormNode()\n            }\n        } else {\n            throw new BaseArtifactException(\"Must use full form location (with #) to get a form node, [${location}] has no hash (#).\")\n        }\n    }\n\n    boolean isRenderModeValid(String renderMode) { return allRenderModes.contains(renderMode) }\n    boolean isRenderModeText(String renderMode) { return textOutputRenderModes.contains(renderMode) }\n    boolean isRenderModeAlwaysStandalone(String renderMode) { return alwaysStandaloneByRenderMode.get(renderMode) }\n    boolean isRenderModeSkipActions(String renderMode) { return skipActionsByRenderMode.get(renderMode) }\n    String getMimeTypeByMode(String renderMode) { return (String) mimeTypeByRenderMode.get(renderMode) }\n\n    Template getTemplateByMode(String renderMode) {\n        Template template = (Template) screenTemplateModeCache.get(renderMode)\n        if (template != null) return template\n\n        template = makeTemplateByMode(renderMode)\n        if (template == null) throw new BaseArtifactException(\"Could not find screen render template for mode [${renderMode}]\")\n        return template\n    }\n\n    protected synchronized Template makeTemplateByMode(String renderMode) {\n        Template template = (Template) screenTemplateModeCache.get(renderMode)\n        if (template != null) return template\n\n        MNode stoNode = ecfi.getConfXmlRoot().first(\"screen-facade\")\n                .first({ MNode it -> it.name == \"screen-text-output\" && it.attribute(\"type\") == renderMode })\n        String templateLocation = stoNode != null ? stoNode.attribute(\"macro-template-location\") : null\n        if (!templateLocation) throw new BaseArtifactException(\"Could not find macro-template-location for render mode (screen-text-output.@type) [${renderMode}]\")\n        // NOTE: this is a special case where we need something to call #recurse so that all includes can be straight libraries\n        String rootTemplate = \"\"\"<#include \"${templateLocation}\"/><#visit widgetsNode>\"\"\"\n\n        Template newTemplate\n        try {\n            newTemplate = new Template(\"moqui.automatic.${renderMode}\", new StringReader(rootTemplate),\n                    ecfi.resourceFacade.ftlTemplateRenderer.getFtlConfiguration())\n        } catch (Exception e) {\n            throw new BaseArtifactException(\"Error while initializing Screen Widgets template at [${templateLocation}]\", e)\n        }\n\n        screenTemplateModeCache.put(renderMode, newTemplate)\n        return newTemplate\n    }\n\n    Template getTemplateByLocation(String templateLocation) {\n        Template template = (Template) screenTemplateLocationCache.get(templateLocation)\n        if (template != null) return template\n        return makeTemplateByLocation(templateLocation)\n    }\n\n    protected synchronized Template makeTemplateByLocation(String templateLocation) {\n        Template template = (Template) screenTemplateLocationCache.get(templateLocation)\n        if (template != null) return template\n\n        // NOTE: this is a special case where we need something to call #recurse so that all includes can be straight libraries\n        String rootTemplate = \"\"\"<#include \"${templateLocation}\"/><#visit widgetsNode>\"\"\"\n\n\n        Template newTemplate\n        try {\n            // this location needs to look like a filename in the runtime directory, otherwise FTL will look for includes under the directory it looks like instead\n            String filename = templateLocation.substring(templateLocation.lastIndexOf(\"/\")+1)\n            newTemplate = new Template(filename, new StringReader(rootTemplate),\n                    ecfi.resourceFacade.ftlTemplateRenderer.getFtlConfiguration())\n        } catch (Exception e) {\n            throw new BaseArtifactException(\"Error while initializing Screen Widgets template at [${templateLocation}]\", e)\n        }\n\n        screenTemplateLocationCache.put(templateLocation, newTemplate)\n        return newTemplate\n    }\n\n    MNode getWidgetTemplatesNodeByLocation(String templateLocation) {\n        MNode templatesNode = (MNode) widgetTemplateLocationCache.get(templateLocation)\n        if (templatesNode != null) return templatesNode\n        return makeWidgetTemplatesNodeByLocation(templateLocation)\n    }\n\n    protected synchronized MNode makeWidgetTemplatesNodeByLocation(String templateLocation) {\n        MNode templatesNode = (MNode) widgetTemplateLocationCache.get(templateLocation)\n        if (templatesNode != null) return templatesNode\n\n        templatesNode = MNode.parse(templateLocation, ecfi.resourceFacade.getLocationStream(templateLocation))\n        widgetTemplateLocationCache.put(templateLocation, templatesNode)\n        return templatesNode\n    }\n\n    ScreenWidgetRender getWidgetRenderByMode(String renderMode) {\n        // first try the cache\n        ScreenWidgetRender swr = (ScreenWidgetRender) screenWidgetRenderByMode.get(renderMode)\n        if (swr != null) return swr\n        // special case for text output render modes\n        if (textOutputRenderModes.contains(renderMode)) return textMacroWidgetRender\n        // try making the ScreenWidgerRender object\n        swr = makeWidgetRenderByMode(renderMode)\n        if (swr == null) throw new BaseArtifactException(\"Could not find screen widger renderer for mode ${renderMode}\")\n        return swr\n    }\n    protected synchronized ScreenWidgetRender makeWidgetRenderByMode(String renderMode) {\n        ScreenWidgetRender swr = (ScreenWidgetRender) screenWidgetRenderByMode.get(renderMode)\n        if (swr != null) return swr\n\n        MNode stoNode = ecfi.getConfXmlRoot().first(\"screen-facade\")\n                .first({ MNode it -> it.name == \"screen-output\" && it.attribute(\"type\") == renderMode })\n        String renderClass = stoNode != null ? stoNode.attribute(\"widget-render-class\") : null\n        if (!renderClass) throw new BaseArtifactException(\"Could not find widget-render-class for render mode (screen-output.@type) ${renderMode}\")\n\n        ScreenWidgetRender newSwr\n        try {\n            Class swrClass = Thread.currentThread().getContextClassLoader().loadClass(renderClass)\n            newSwr = (ScreenWidgetRender) swrClass.newInstance()\n        } catch (Exception e) {\n            throw new BaseArtifactException(\"Error while initializing Screen Widgets render class [${renderClass}]\", e)\n        }\n\n        screenWidgetRenderByMode.put(renderMode, newSwr)\n        return newSwr\n    }\n\n    Map<String, String> getThemeIconByText(String screenThemeId) {\n        Map<String, String> themeIconByText = (Map<String, String>) themeIconByTextByTheme.get(screenThemeId)\n        if (themeIconByText == null) {\n            themeIconByText = new HashMap<>()\n            themeIconByTextByTheme.put(screenThemeId, themeIconByText)\n        }\n        return themeIconByText\n    }\n    String rootScreenFromHost(String host, String webappName) {\n        ExecutionContextFactoryImpl.WebappInfo webappInfo = ecfi.getWebappInfo(webappName)\n        MNode webappNode = webappInfo.webappNode\n        MNode wildcardHost = (MNode) null\n        for (MNode rootScreenNode in webappNode.children(\"root-screen\")) {\n            String hostAttr = rootScreenNode.attribute(\"host\")\n            if (\".*\".equals(hostAttr)) {\n                // remember wildcard host, default to it if no other matches (just in case put earlier in the list than others)\n                wildcardHost = rootScreenNode\n            } else if (host.matches(hostAttr)) {\n                return rootScreenNode.attribute(\"location\")\n            }\n        }\n        if (wildcardHost != null) return wildcardHost.attribute(\"location\")\n        throw new BaseArtifactException(\"Could not find root screen for host: ${host}\")\n    }\n\n    /** Called from ArtifactStats screen */\n    List<ScreenInfo> getScreenInfoList(String rootLocation, int levels) {\n        ScreenInfo rootInfo = new ScreenInfo(getScreenDefinition(rootLocation), null, null, 0)\n        List<ScreenInfo> infoList = []\n        infoList.add(rootInfo)\n        rootInfo.addChildrenToList(infoList, levels)\n        return infoList\n    }\n\n    class ScreenInfo implements Serializable {\n        ScreenDefinition sd\n        SubscreensItem ssi\n        ScreenInfo parentInfo\n        ScreenInfo rootInfo\n        Map<String, ScreenInfo> subscreenInfoByName = new TreeMap<String, ScreenInfo>()\n        Map<String, TransitionInfo> transitionInfoByName = new TreeMap<String, TransitionInfo>()\n        int level\n        String name\n        ArrayList<String> screenPath = new ArrayList<>()\n\n        boolean isNonPlaceholder = false\n        int subscreens = 0, allSubscreens = 0, subscreensNonPlaceholder = 0, allSubscreensNonPlaceholder = 0\n        int forms = 0, allSubscreensForms = 0\n        int trees = 0, allSubscreensTrees = 0\n        int sections = 0, allSubscreensSections = 0\n        int transitions = 0, allSubscreensTransitions = 0\n        int transitionsWithActions = 0, allSubscreensTransitionsWithActions = 0\n\n        ScreenInfo(ScreenDefinition sd, SubscreensItem ssi, ScreenInfo parentInfo, int level) {\n            this.sd = sd\n            this.ssi = ssi\n            this.parentInfo = parentInfo\n            this.level = level\n            this.name = ssi ? ssi.getName() : sd.getScreenName()\n            if (parentInfo != null) this.screenPath.addAll(parentInfo.screenPath)\n            this.screenPath.add(name)\n\n            subscreens = sd.subscreensByName.size()\n\n            forms = sd.formByName.size()\n            trees = sd.treeByName.size()\n            sections = sd.sectionByName.size()\n            transitions = sd.transitionByName.size()\n            for (TransitionItem ti in sd.transitionByName.values()) if (ti.hasActionsOrSingleService()) transitionsWithActions++\n            isNonPlaceholder = forms > 0 || sections > 0 || transitions > 4\n            // if (isNonPlaceholder) logger.info(\"Screen ${name} forms ${forms} sections ${sections} transitions ${transitions}\")\n\n            // trickle up totals\n            ScreenInfo curParent = parentInfo\n            while (curParent != null) {\n                curParent.allSubscreens += 1\n                if (isNonPlaceholder) curParent.allSubscreensNonPlaceholder += 1\n                curParent.allSubscreensForms += forms\n                curParent.allSubscreensTrees += trees\n                curParent.allSubscreensSections += sections\n                curParent.allSubscreensTransitions += transitions\n                curParent.allSubscreensTransitionsWithActions += transitionsWithActions\n                if (curParent.parentInfo == null) rootInfo = curParent\n                curParent = curParent.parentInfo\n            }\n            if (rootInfo == null) rootInfo = this\n\n            // get info for all subscreens\n            ArrayList ssItemEntryList = new ArrayList<Map.Entry<String, SubscreensItem>>(sd.subscreensByName.entrySet())\n            for (Map.Entry<String, SubscreensItem> ssEntry in ssItemEntryList) {\n                SubscreensItem curSsi = ssEntry.getValue()\n                List<String> childPath = new ArrayList(screenPath)\n                childPath.add(curSsi.getName())\n                List<ScreenInfo> curInfoList = (List<ScreenInfo>) screenInfoCache.get(screenPathToString(childPath))\n                ScreenInfo existingSi = curInfoList ? (ScreenInfo) curInfoList.get(0) : null\n                if (existingSi != null) {\n                    subscreenInfoByName.put(ssEntry.getKey(), existingSi)\n                } else {\n                    ScreenDefinition ssSd = getScreenDefinition(curSsi.getLocation())\n                    if (ssSd == null) {\n                        logger.info(\"While getting ScreenInfo screen not found for ${curSsi.getName()} at: ${curSsi.getLocation()}\")\n                        continue\n                    }\n                    try {\n                        ScreenInfo newSi = new ScreenInfo(ssSd, curSsi, this, level+1)\n                        subscreenInfoByName.put(ssEntry.getKey(), newSi)\n                    } catch (Exception e) {\n                        logger.warn(\"Error loading subscreen ${curSsi.getLocation()}\", e)\n                    }\n                }\n            }\n\n            // populate transition references\n            for (Map.Entry<String, TransitionItem> tiEntry in sd.transitionByName.entrySet()) {\n                transitionInfoByName.put(tiEntry.getKey(), new TransitionInfo(this, tiEntry.getValue()))\n            }\n\n            // now that subscreen is initialized save in list for location and path\n            List<ScreenInfo> curInfoList = (List<ScreenInfo>) screenInfoCache.get(sd.location)\n            if (curInfoList == null) {\n                curInfoList = new LinkedList<>()\n                screenInfoCache.put(sd.location, curInfoList)\n            }\n            curInfoList.add(this)\n            screenInfoCache.put(screenPathToString(screenPath), [this])\n        }\n\n        String getIndentedName() {\n            StringBuilder sb = new StringBuilder()\n            for (int i = 0; i < level; i++) sb.append(\"- \")\n            sb.append(\" \").append(name)\n            return sb.toString()\n        }\n\n        void addChildrenToList(List<ScreenInfo> infoList, int maxLevel) {\n            ArrayList ssInfoList = new ArrayList<ScreenInfo>(subscreenInfoByName.values())\n            ssInfoList.sort({ a, b -> a.ssi?.menuIndex <=> b.ssi?.menuIndex })\n            for (ScreenInfo si in ssInfoList) {\n                infoList.add(si)\n                if (maxLevel > level) si.addChildrenToList(infoList, maxLevel)\n            }\n        }\n    }\n\n    class TransitionInfo implements Serializable {\n        ScreenInfo si\n        TransitionItem ti\n        Set<String> responseScreenPathSet = new TreeSet()\n        List<String> transitionPath\n\n        TransitionInfo(ScreenInfo si, TransitionItem ti) {\n            this.si = si\n            this.ti = ti\n            transitionPath = si.screenPath\n            transitionPath.add(ti.getName())\n\n            for (ScreenDefinition.ResponseItem ri in ti.conditionalResponseList) {\n                if (ri.urlType && ri.urlType != \"transition\" && ri.urlType != \"screen\") continue\n                String expandedUrl = ri.url\n                if (expandedUrl.contains('${')) expandedUrl = ecfi.getResource().expand(expandedUrl, \"\")\n                ScreenUrlInfo sui = ScreenUrlInfo.getScreenUrlInfo(ecfi.screenFacade, si.rootInfo.sd,\n                        si.sd, si.screenPath, expandedUrl, 0)\n                if (sui.targetScreen == null) continue\n                String targetScreenPath = screenPathToString(sui.getPreTransitionPathNameList())\n                responseScreenPathSet.add(targetScreenPath)\n\n                Set<String> refSet = (Set<String>) screenInfoRefRevCache.get(targetScreenPath)\n                if (refSet == null) { refSet = new HashSet(); screenInfoRefRevCache.put(targetScreenPath, refSet) }\n                refSet.add(screenPathToString(transitionPath))\n            }\n        }\n    }\n\n    @CompileStatic\n    static String screenPathToString(List<String> screenPath) {\n        StringBuilder sb = new StringBuilder()\n        for (String screenName in screenPath) sb.append(\"/\").append(screenName)\n        return sb.toString()\n    }\n\n    @Override\n    ScreenRender makeRender() { return new ScreenRenderImpl(this) }\n\n    @Override\n    ScreenTest makeTest() { return new ScreenTestImpl(ecfi) }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/screen/ScreenForm.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.screen\n\nimport groovy.json.JsonSlurper\nimport groovy.transform.CompileStatic\nimport org.moqui.BaseArtifactException\nimport org.moqui.context.ExecutionContext\nimport org.moqui.entity.*\nimport org.moqui.impl.actions.XmlAction\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.impl.entity.*\nimport org.moqui.impl.entity.AggregationUtil.AggregateFunction\nimport org.moqui.impl.entity.AggregationUtil.AggregateField\nimport org.moqui.impl.entity.EntityJavaUtil.RelationshipInfo\nimport org.moqui.impl.screen.ScreenDefinition.TransitionItem\nimport org.moqui.impl.service.ServiceDefinition\nimport org.moqui.util.CollectionUtilities\nimport org.moqui.util.ContextStack\nimport org.moqui.util.MNode\nimport org.moqui.util.ObjectUtilities\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.math.RoundingMode\nimport java.sql.Timestamp\n\n@CompileStatic\nclass ScreenForm {\n    protected final static Logger logger = LoggerFactory.getLogger(ScreenForm.class)\n\n    protected static final Set<String> fieldAttributeNames = new HashSet<String>([\"name\", \"from\", \"entry-name\", \"hide\"])\n    protected static final Set<String> subFieldAttributeNames = new HashSet<String>([\"title\", \"tooltip\", \"red-when\",\n            \"validate-service\", \"validate-parameter\", \"validate-entity\", \"validate-field\"])\n\n    protected ExecutionContextFactoryImpl ecfi\n    protected ScreenDefinition sd\n    protected MNode internalFormNode\n    protected List<MNode> extendFormNodes = null\n    protected FormInstance internalFormInstance\n    protected String location, formName, fullFormName\n    protected boolean hasDbExtensions = false, isDynamic = false, isFormList = false\n    protected String extendsScreenLocation = null\n\n    protected MNode entityFindNode = null\n    protected XmlAction rowActions = null\n\n    ScreenForm(ExecutionContextFactoryImpl ecfi, ScreenDefinition sd, MNode baseFormNode,\n               List<MNode> extendFormNodes, String location) {\n        this.ecfi = ecfi\n        this.sd = sd\n        this.location = location\n        this.formName = baseFormNode.attribute(\"name\")\n        this.fullFormName = sd.getLocation() + \"#\" + formName\n\n        // is this a dynamic form?\n        isDynamic = (baseFormNode.attribute(\"dynamic\") == \"true\")\n        isFormList = \"form-list\".equals(baseFormNode.name)\n\n        // does this form have DbForm extensions?\n        boolean alreadyDisabled = ecfi.getExecutionContext().getArtifactExecution().disableAuthz()\n        try {\n            EntityList dbFormLookupList = ecfi.entityFacade.find(\"DbFormLookup\")\n                    .condition(\"modifyXmlScreenForm\", fullFormName).useCache(true).list()\n            if (dbFormLookupList) hasDbExtensions = true\n        } finally {\n            if (!alreadyDisabled) ecfi.getExecutionContext().getArtifactExecution().enableAuthz()\n        }\n\n        if (isDynamic) {\n            internalFormNode = baseFormNode\n            this.extendFormNodes = extendFormNodes\n        } else {\n            // setting parent to null so that this isn't found in addition to the literal form-* element\n            internalFormNode = new MNode(baseFormNode.name, null)\n            initForm(baseFormNode, internalFormNode, extendFormNodes)\n            internalFormInstance = new FormInstance(this)\n        }\n    }\n\n    boolean isDisplayOnly() {\n        ContextStack cs = ecfi.getEci().contextStack\n        return \"true\".equals(cs.getByString(\"formDisplayOnly\")) || \"true\".equals(cs.getByString(\"formDisplayOnly_${formName}\"))\n    }\n    boolean hasDataPrep() { return entityFindNode != null }\n\n    void initForm(MNode baseFormNode, MNode newFormNode, List<MNode> extendFormNodes) {\n        // if there is an extends, put that in first (everything else overrides it)\n        if (baseFormNode.attribute(\"extends\")) {\n            String extendsForm = baseFormNode.attribute(\"extends\")\n            if (isDynamic) extendsForm = ecfi.resourceFacade.expand(extendsForm, \"\")\n\n            MNode formNode\n            if (extendsForm.contains(\"#\")) {\n                String screenLocation = extendsForm.substring(0, extendsForm.indexOf(\"#\"))\n                String formName = extendsForm.substring(extendsForm.indexOf(\"#\")+1)\n                if (screenLocation == sd.getLocation()) {\n                    ScreenForm esf = sd.getForm(formName)\n                    formNode = esf?.getOrCreateFormNode()\n                } else if (\"moqui.screen.form.DbForm\".equals(screenLocation) || \"DbForm\".equals(screenLocation)) {\n                    formNode = getDbFormNode(formName, ecfi)\n                } else {\n                    ScreenDefinition esd = ecfi.screenFacade.getScreenDefinition(screenLocation)\n                    ScreenForm esf = esd ? esd.getForm(formName) : null\n                    formNode = esf?.getOrCreateFormNode()\n\n                    if (formNode != null) {\n                        // see if the included section contains any SECTIONS, need to reference those here too!\n                        Map<String, ArrayList<MNode>> descMap = formNode.descendants(new HashSet<String>(['section', 'section-iterate']))\n                        for (MNode inclRefNode in descMap.get(\"section\"))\n                            this.sd.sectionByName.put(inclRefNode.attribute(\"name\"), esd.getSection(inclRefNode.attribute(\"name\")))\n                        for (MNode inclRefNode in descMap.get(\"section-iterate\"))\n                            this.sd.sectionByName.put(inclRefNode.attribute(\"name\"), esd.getSection(inclRefNode.attribute(\"name\")))\n\n                        extendsScreenLocation = screenLocation\n                    }\n                }\n            } else {\n                ScreenForm esf = sd.getForm(extendsForm)\n                formNode = esf?.getOrCreateFormNode()\n            }\n            if (formNode == null) throw new BaseArtifactException(\"Cound not find extends form [${extendsForm}] referred to in form [${newFormNode.attribute(\"name\")}] of screen [${sd.location}]\")\n            mergeFormNodes(newFormNode, formNode, true, true)\n        }\n\n        LinkedHashMap<String, ArrayList<String>> fieldColumnInfo = \"form-list\".equals(baseFormNode.name) ? new LinkedHashMap<String, ArrayList<String>>() : null\n\n        ArrayList<MNode> childNodeList = baseFormNode.getChildren()\n        for (int cni = 0; cni < childNodeList.size(); cni++) {\n            MNode formSubNode = (MNode) childNodeList.get(cni)\n            if (formSubNode.name == \"field\") {\n                MNode nodeCopy = formSubNode.deepCopy(null)\n                expandFieldNode(newFormNode, nodeCopy)\n                mergeFieldNode(newFormNode, nodeCopy, false)\n            } else if (formSubNode.name == \"auto-fields-service\") {\n                String serviceName = formSubNode.attribute(\"service-name\")\n                ArrayList<MNode> excludeList = formSubNode.children(\"exclude\")\n                int excludeListSize = excludeList.size()\n                Set<String> excludes = excludeListSize > 0 ? new HashSet<String>() : (Set<String>) null\n                for (int i = 0; i < excludeListSize; i++) {\n                    MNode excludeNode = (MNode) excludeList.get(i)\n                    excludes.add(excludeNode.attribute(\"parameter-name\"))\n                }\n\n                if (isDynamic) {\n                    serviceName = ecfi.resourceFacade.expandNoL10n(serviceName, null)\n                    // NOTE: because this is a GString expand if value not found will evaluate to 'null'\n                    if (!serviceName || \"null\".equals(serviceName)) serviceName = ecfi.getEci().contextStack.getByString(\"formLocationExtension\")\n                }\n\n                ServiceDefinition serviceDef = ecfi.serviceFacade.getServiceDefinition(serviceName)\n                if (serviceDef != null) {\n                    addServiceFields(serviceDef, formSubNode.attribute(\"include\")?:\"in\", formSubNode.attribute(\"field-type\")?:\"edit\",\n                            excludes, newFormNode, ecfi)\n                    continue\n                }\n                if (ecfi.serviceFacade.isEntityAutoPattern(serviceName)) {\n                    EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(ServiceDefinition.getNounFromName(serviceName))\n                    if (ed != null) {\n                        addEntityFields(ed, \"all\", formSubNode.attribute(\"field-type\")?:\"edit\", null, newFormNode, fieldColumnInfo)\n                        continue\n                    }\n                }\n                throw new BaseArtifactException(\"Cound not find service [${serviceName}] or entity noun referred to in auto-fields-service of form [${newFormNode.attribute(\"name\")}] of screen [${sd.location}]\")\n            } else if (formSubNode.name == \"auto-fields-entity\") {\n                String entityName = formSubNode.attribute(\"entity-name\")\n                Boolean addAutoColumns = !\"false\".equals(formSubNode.attribute(\"auto-columns\"))\n\n                if (isDynamic) {\n                    entityName = ecfi.resourceFacade.expandNoL10n(entityName, null)\n                    // NOTE: because this is a GString expand if value not found will evaluate to 'null'\n                    if (!entityName || \"null\".equals(entityName)) entityName = ecfi.getEci().contextStack.getByString(\"formLocationExtension\")\n                }\n\n                EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(entityName)\n                if (ed != null) {\n                    ArrayList<MNode> excludeList = formSubNode.children(\"exclude\")\n                    int excludeListSize = excludeList.size()\n                    Set<String> excludes = excludeListSize > 0 ? new HashSet<String>() : (Set<String>) null\n                    for (int i = 0; i < excludeListSize; i++) {\n                        MNode excludeNode = (MNode) excludeList.get(i)\n                        excludes.add(excludeNode.attribute(\"field-name\"))\n                    }\n                    addEntityFields(ed, formSubNode.attribute(\"include\")?:\"all\",\n                            formSubNode.attribute(\"field-type\")?:\"find-display\", excludes, newFormNode, addAutoColumns ? fieldColumnInfo : null)\n                } else {\n                    throw new BaseArtifactException(\"Cound not find entity [${entityName}] referred to in auto-fields-entity of form [${newFormNode.attribute(\"name\")}] of screen [${sd.location}]\")\n                }\n            }\n        }\n\n        // merge original formNode to override any applicable settings\n        mergeFormNodes(newFormNode, baseFormNode, false, false)\n\n        // merge screen-extend forms\n        if (extendFormNodes != null) {\n            for (MNode extendFormNode in extendFormNodes) {\n                mergeFormNodes(newFormNode, extendFormNode, true, true)\n            }\n        }\n\n        // populate validate-service and validate-entity attributes if the target transition calls a single service\n        setSubFieldValidateAttrs(newFormNode, \"transition\", \"default-field\")\n        setSubFieldValidateAttrs(newFormNode, \"transition\", \"conditional-field\")\n        setSubFieldValidateAttrs(newFormNode, \"transition-first-row\", \"first-row-field\")\n        setSubFieldValidateAttrs(newFormNode, \"transition-second-row\", \"second-row-field\")\n        setSubFieldValidateAttrs(newFormNode, \"transition-last-row\", \"last-row-field\")\n\n        // check form-single.field-layout and add ONLY hidden fields that are missing\n        MNode fieldLayoutNode = newFormNode.first(\"field-layout\")\n        if (fieldLayoutNode && !fieldLayoutNode.depthFirst({ MNode it -> it.name == \"fields-not-referenced\" })) {\n            for (MNode fieldNode in newFormNode.children(\"field\")) {\n                if (!fieldLayoutNode.depthFirst({ MNode it -> it.name == \"field-ref\" && it.attribute(\"name\") == fieldNode.attribute(\"name\") })\n                        && fieldNode.depthFirst({ MNode it -> it.name == \"hidden\" }))\n                    addFieldToFieldLayout(newFormNode, fieldNode)\n            }\n        }\n\n        // for form-list auto add entity columns\n        if (fieldColumnInfo != null && fieldColumnInfo.size() > 0) {\n            for (Map.Entry<String, ArrayList<String>> curInfo in fieldColumnInfo.entrySet()) {\n                addAutoEntityColumns(newFormNode, baseFormNode, curInfo.getKey(), curInfo.getValue())\n            }\n        }\n\n        if (logger.traceEnabled) logger.trace(\"Form [${location}] resulted in expanded def: \" + newFormNode.toString())\n        // if (location.contains(\"FOO\")) logger.warn(\"======== Form [${location}] resulted in expanded def: \" + newFormNode.toString())\n\n        entityFindNode = newFormNode.first(\"entity-find\")\n        // prep row-actions\n        if (newFormNode.hasChild(\"row-actions\")) rowActions = new XmlAction(ecfi, newFormNode.first(\"row-actions\"), location + \".row_actions\")\n    }\n\n    void setSubFieldValidateAttrs(MNode newFormNode, String transitionAttribute, String subFieldNodeName) {\n        if (newFormNode.attribute(transitionAttribute)) {\n            TransitionItem ti = this.sd.getTransitionItem(newFormNode.attribute(transitionAttribute), null)\n            if (ti != null && ti.getSingleServiceName()) {\n                String singleServiceName = ti.getSingleServiceName()\n                ServiceDefinition sd = ecfi.serviceFacade.getServiceDefinition(singleServiceName)\n                if (sd != null) {\n                    ArrayList<String> inParamNames = sd.getInParameterNames()\n                    for (MNode fieldNode in newFormNode.children(\"field\")) {\n                        // if the field matches an in-parameter name and does not already have a validate-service, then set it\n                        // do it even if it has a validate-service since it might be from another form, in general we want the current service:  && !fieldNode.\"@validate-service\"\n                        if (inParamNames.contains(fieldNode.attribute(\"name\"))) {\n                            for (MNode subField in fieldNode.children(subFieldNodeName))\n                                if (!subField.attribute(\"validate-service\")) subField.attributes.put(\"validate-service\", singleServiceName)\n                        }\n                    }\n                } else if (ecfi.serviceFacade.isEntityAutoPattern(singleServiceName)) {\n                    String entityName = ServiceDefinition.getNounFromName(singleServiceName)\n                    EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(entityName)\n                    ArrayList<String> fieldNames = ed.getAllFieldNames()\n                    for (MNode fieldNode in newFormNode.children(\"field\")) {\n                        // if the field matches an in-parameter name and does not already have a validate-entity, then set it\n                        if (fieldNames.contains(fieldNode.attribute(\"name\"))) {\n                            for (MNode subField in fieldNode.children(subFieldNodeName))\n                                if (!subField.attribute(\"validate-entity\")) subField.attributes.put(\"validate-entity\", entityName)\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    String getSavedFindFullLocation() {\n        String fullLocation = location\n        if (isDynamic) {\n            String locationExtension = ecfi.getEci().contextStack.getByString(\"formLocationExtension\")\n            if (!locationExtension) {\n                // try getting service name from auto-fields-service\n                MNode autoFieldsServiceNode = internalFormNode.first(\"auto-fields-service\")\n                if (autoFieldsServiceNode != null)\n                    locationExtension = ecfi.resourceFacade.expandNoL10n(autoFieldsServiceNode.attribute(\"service-name\"), null)\n            }\n            if (!locationExtension) {\n                // try getting entity name from auto-fields-entity\n                MNode autoFieldsServiceNode = internalFormNode.first(\"auto-fields-entity\")\n                if (autoFieldsServiceNode != null)\n                    locationExtension = ecfi.resourceFacade.expandNoL10n(autoFieldsServiceNode.attribute(\"entity-name\"), null)\n            }\n            if (locationExtension) fullLocation = fullLocation + '#' + locationExtension\n        }\n        return fullLocation\n    }\n    List<Map<String, Object>> getUserFormListFinds(ExecutionContextImpl ec) {\n        EntityList flfuList = ec.entity.find(\"moqui.screen.form.FormListFindUserView\")\n                .condition(\"userId\", ec.user.userId)\n                .condition(\"formLocation\", getSavedFindFullLocation()).useCache(true).list()\n        EntityList flfugList = ec.entity.find(\"moqui.screen.form.FormListFindUserGroupView\")\n                .condition(\"userGroupId\", EntityCondition.IN, ec.user.userGroupIdSet)\n                .condition(\"formLocation\", getSavedFindFullLocation()).useCache(true).list()\n        Set<String> userOnlyFlfIdSet = new HashSet<>()\n        Set<String> formListFindIdSet = new HashSet<>()\n        for (EntityValue ev in flfuList) {\n            userOnlyFlfIdSet.add((String) ev.formListFindId)\n            formListFindIdSet.add((String) ev.formListFindId)\n        }\n        for (EntityValue ev in flfugList) formListFindIdSet.add((String) ev.formListFindId)\n\n\n        // get info for each formListFindId\n        List<Map<String, Object>> flfInfoList = new LinkedList<>()\n        for (String formListFindId in formListFindIdSet)\n            flfInfoList.add(getFormListFindInfo(formListFindId, ec, userOnlyFlfIdSet))\n\n        // sort by description\n        CollectionUtilities.orderMapList(flfInfoList, [\"description\"])\n\n        return flfInfoList\n    }\n    String getUserDefaultFormListFindId(ExecutionContextImpl ec) {\n        String userId = ec.user.userId\n        if (userId == null) return null\n        EntityValue formListFindUserDefault = ec.entityFacade.find(\"moqui.screen.form.FormListFindUserDefault\")\n                .condition(\"userId\", userId).condition(\"screenLocation\", sd?.location)\n                .disableAuthz().useCache(true).one()\n        if (formListFindUserDefault == null) return null\n        return formListFindUserDefault.get(\"formListFindId\")\n    }\n\n    List<MNode> getDbFormNodeList() {\n        if (!hasDbExtensions) return null\n\n        boolean alreadyDisabled = ecfi.getExecutionContext().getArtifactExecution().disableAuthz()\n        try {\n            // find DbForm records and merge them in as well\n            String formName = sd.getLocation() + \"#\" + internalFormNode.attribute(\"name\")\n            EntityList dbFormLookupList = this.ecfi.entityFacade.find(\"DbFormLookup\")\n                    .condition(\"userGroupId\", EntityCondition.IN, ecfi.getExecutionContext().getUser().getUserGroupIdSet())\n                    .condition(\"modifyXmlScreenForm\", formName)\n                    .useCache(true).list()\n            // logger.warn(\"TOREMOVE: looking up DbForms for form [${formName}], found: ${dbFormLookupList}\")\n\n            if (!dbFormLookupList) return null\n\n            List<MNode> formNodeList = new ArrayList<MNode>()\n            for (EntityValue dbFormLookup in dbFormLookupList) formNodeList.add(getDbFormNode(dbFormLookup.getString(\"formId\"), ecfi))\n\n            return formNodeList\n        } finally {\n            if (!alreadyDisabled) ecfi.getExecutionContext().getArtifactExecution().enableAuthz()\n        }\n    }\n\n    static MNode getDbFormNode(String formId, ExecutionContextFactoryImpl ecfi) {\n        MNode dbFormNode = (MNode) ecfi.screenFacade.dbFormNodeByIdCache.get(formId)\n\n        if (dbFormNode == null) {\n\n            boolean alreadyDisabled = ecfi.getEci().artifactExecutionFacade.disableAuthz()\n            try {\n                EntityValue dbForm = ecfi.entityFacade.fastFindOne(\"moqui.screen.form.DbForm\", true, false, formId)\n                if (dbForm == null) throw new BaseArtifactException(\"Could not find DbForm record with ID [${formId}]\")\n                dbFormNode = new MNode((dbForm.isListForm == \"Y\" ? \"form-list\" : \"form-single\"), null)\n\n                EntityList dbFormFieldList = ecfi.entityFacade.find(\"moqui.screen.form.DbFormField\").condition(\"formId\", formId)\n                        .orderBy(\"layoutSequenceNum\").useCache(true).list()\n                for (EntityValue dbFormField in dbFormFieldList) {\n                    String fieldName = (String) dbFormField.fieldName\n                    MNode newFieldNode = new MNode(\"field\", [name:fieldName])\n                    if (dbFormField.entryName) newFieldNode.attributes.put(\"from\", (String) dbFormField.entryName)\n\n                    // create the sub-field node; if DbFormField.condition use conditional-field instead of default-field\n                    MNode subFieldNode\n                    if (dbFormField.condition) {\n                        subFieldNode = newFieldNode.append(\"conditional-field\", [condition:dbFormField.condition] as Map<String, String>)\n                    } else {\n                        subFieldNode = newFieldNode.append(\"default-field\", null)\n                    }\n                    if (dbFormField.title) subFieldNode.attributes.put(\"title\", (String) dbFormField.title)\n                    if (dbFormField.tooltip) subFieldNode.attributes.put(\"tooltip\", (String) dbFormField.tooltip)\n\n                    String fieldType = dbFormField.fieldTypeEnumId\n                    if (!fieldType) throw new BaseArtifactException(\"DbFormField record with formId [${formId}] and fieldName [${fieldName}] has no fieldTypeEnumId\")\n\n                    String widgetName = fieldType.substring(6)\n                    MNode widgetNode = subFieldNode.append(widgetName, null)\n\n                    EntityList dbFormFieldAttributeList = ecfi.entityFacade.find(\"moqui.screen.form.DbFormFieldAttribute\")\n                            .condition([formId:formId, fieldName:fieldName] as Map<String, Object>).useCache(true).list()\n                    for (EntityValue dbFormFieldAttribute in dbFormFieldAttributeList) {\n                        String attributeName = dbFormFieldAttribute.attributeName\n                        if (fieldAttributeNames.contains(attributeName)) {\n                            newFieldNode.attributes.put(attributeName, (String) dbFormFieldAttribute.value)\n                        } else if (subFieldAttributeNames.contains(attributeName)) {\n                            subFieldNode.attributes.put(attributeName, (String) dbFormFieldAttribute.value)\n                        } else {\n                            widgetNode.attributes.put(attributeName, (String) dbFormFieldAttribute.value)\n                        }\n                    }\n\n                    // add option settings when applicable\n                    EntityList dbFormFieldOptionList = ecfi.entityFacade.find(\"moqui.screen.form.DbFormFieldOption\")\n                            .condition([formId:formId, fieldName:fieldName] as Map<String, Object>).useCache(true).list()\n                    EntityList dbFormFieldEntOptsList = ecfi.entityFacade.find(\"moqui.screen.form.DbFormFieldEntOpts\")\n                            .condition([formId:formId, fieldName:fieldName] as Map<String, Object>).useCache(true).list()\n                    EntityList combinedOptionList = new EntityListImpl(ecfi.entityFacade)\n                    combinedOptionList.addAll(dbFormFieldOptionList)\n                    combinedOptionList.addAll(dbFormFieldEntOptsList)\n                    combinedOptionList.orderByFields([\"sequenceNum\"])\n\n                    for (EntityValue optionValue in combinedOptionList) {\n                        if (optionValue.resolveEntityName() == \"moqui.screen.form.DbFormFieldOption\") {\n                            widgetNode.append(\"option\", [key:(String) optionValue.keyValue, text:(String) optionValue.text])\n                        } else {\n                            MNode entityOptionsNode = widgetNode.append(\"entity-options\", [text:((String) optionValue.text ?: \"\\${description}\")])\n                            MNode entityFindNode = entityOptionsNode.append(\"entity-find\", [\"entity-name\":optionValue.getString(\"entityName\")])\n\n                            EntityList dbFormFieldEntOptsCondList = ecfi.entityFacade.find(\"moqui.screen.form.DbFormFieldEntOptsCond\")\n                                    .condition([formId:formId, fieldName:fieldName, sequenceNum:optionValue.sequenceNum])\n                                    .useCache(true).list()\n                            for (EntityValue dbFormFieldEntOptsCond in dbFormFieldEntOptsCondList) {\n                                entityFindNode.append(\"econdition\", [\"field-name\":(String) dbFormFieldEntOptsCond.entityFieldName, value:(String) dbFormFieldEntOptsCond.value])\n                            }\n\n                            EntityList dbFormFieldEntOptsOrderList = ecfi.entityFacade.find(\"moqui.screen.form.DbFormFieldEntOptsOrder\")\n                                    .condition([formId:formId, fieldName:fieldName, sequenceNum:optionValue.sequenceNum])\n                                    .orderBy(\"orderSequenceNum\").useCache(true).list()\n                            for (EntityValue dbFormFieldEntOptsOrder in dbFormFieldEntOptsOrderList) {\n                                entityFindNode.append(\"order-by\", [\"field-name\":(String) dbFormFieldEntOptsOrder.entityFieldName])\n                            }\n                        }\n                    }\n\n                    // logger.warn(\"TOREMOVE Adding DbForm field [${fieldName}] widgetName [${widgetName}] at layout sequence [${dbFormField.getLong(\"layoutSequenceNum\")}], node: ${newFieldNode}\")\n                    if (dbFormField.getLong(\"layoutSequenceNum\") != null) {\n                        newFieldNode.attributes.put(\"layoutSequenceNum\", dbFormField.getString(\"layoutSequenceNum\"))\n                    }\n                    mergeFieldNode(dbFormNode, newFieldNode, false)\n                }\n\n                ecfi.screenFacade.dbFormNodeByIdCache.put(formId, dbFormNode)\n            } finally {\n                if (!alreadyDisabled) ecfi.getEci().artifactExecutionFacade.enableAuthz()\n            }\n        }\n\n        return dbFormNode\n    }\n\n    /** This is the main method for using an XML Form, the rendering is done based on the Node returned. */\n    MNode getOrCreateFormNode() {\n        // NOTE: this is cached in the ScreenRenderImpl as it may be called multiple times for a single form render\n        List<MNode> dbFormNodeList = hasDbExtensions ? getDbFormNodeList() : null\n        boolean displayOnly = isDisplayOnly()\n\n        if (isDynamic) {\n            MNode newFormNode = new MNode(internalFormNode.name, null)\n            initForm(internalFormNode, newFormNode, extendFormNodes)\n            if (dbFormNodeList != null) for (MNode dbFormNode in dbFormNodeList) mergeFormNodes(newFormNode, dbFormNode, false, true)\n            return newFormNode\n        } else if ((dbFormNodeList != null && dbFormNodeList.size() > 0) || displayOnly) {\n            MNode newFormNode = new MNode(internalFormNode.name, null)\n            // deep copy true to avoid bleed over of new fields and such\n            mergeFormNodes(newFormNode, internalFormNode, true, true)\n            // logger.warn(\"========== merging in dbFormNodeList: ${dbFormNodeList}\", new BaseException(\"getOrCreateFormNode call location\"))\n            if (dbFormNodeList != null) for (MNode dbFormNode in dbFormNodeList) mergeFormNodes(newFormNode, dbFormNode, false, true)\n\n            if (displayOnly) {\n                // change all non-display fields to simple display elements\n                for (MNode fieldNode in newFormNode.children(\"field\")) {\n                    // don't replace header form, should be just for searching: if (fieldNode.\"header-field\") fieldSubNodeToDisplay(newFormNode, fieldNode, (Node) fieldNode.\"header-field\"[0])\n                    for (MNode conditionalFieldNode in fieldNode.children(\"conditional-field\"))\n                        fieldSubNodeToDisplay(conditionalFieldNode)\n                    if (fieldNode.hasChild(\"default-field\")) fieldSubNodeToDisplay(fieldNode.first(\"default-field\"))\n                }\n            }\n\n            return newFormNode\n        } else {\n            return internalFormNode\n        }\n    }\n\n    MNode getAutoCleanedNode() {\n        MNode outNode = getOrCreateFormNode().deepCopy(null)\n        outNode.attributes.remove(\"dynamic\")\n        outNode.attributes.remove(\"multi\")\n        for (int i = 0; i < outNode.children.size(); ) {\n            MNode fn = outNode.children.get(i)\n            if (fn.attribute(\"name\") in [\"aen\", \"den\", \"lastUpdatedStamp\"]) {\n                outNode.children.remove(i)\n            } else {\n                for (MNode subFn in fn.getChildren()) {\n                    subFn.attributes.remove(\"validate-entity\")\n                    subFn.attributes.remove(\"validate-field\")\n                }\n                i++\n            }\n        }\n\n        return outNode\n    }\n\n    static Set displayOnlyIgnoreNodeNames = [\"hidden\", \"ignored\", \"label\", \"image\"] as Set\n    protected void fieldSubNodeToDisplay(MNode fieldSubNode) {\n        MNode widgetNode = fieldSubNode.children ? fieldSubNode.children.first() : null\n        if (widgetNode == null) return\n        if (widgetNode.name.contains(\"display\") || displayOnlyIgnoreNodeNames.contains(widgetNode.name)) return\n\n        if (\"reset\".equalsIgnoreCase(widgetNode.name) || \"submit\".equalsIgnoreCase(widgetNode.name)) {\n            fieldSubNode.children.remove(0)\n            return\n        }\n\n        if (\"link\".equalsIgnoreCase(widgetNode.name)) {\n            // if it goes to a transition with service-call or actions then remove it, otherwise leave it\n            String urlType = widgetNode.attribute('url-type')\n            if ((urlType == null || urlType.isEmpty() || \"transition\".equals(urlType)) &&\n                    sd.getTransitionItem(widgetNode.attribute('url'), null)?.hasActionsOrSingleService()) {\n                fieldSubNode.children.remove(0)\n            }\n            return\n        }\n\n        // otherwise change it to a display Node\n        fieldSubNode.replace(0, \"display\", null)\n        // not as good, puts it after other child Nodes: fieldSubNode.remove(widgetNode); fieldSubNode.appendNode(\"display\")\n    }\n\n    void addAutoServiceField(EntityDefinition nounEd, MNode parameterNode, String fieldType,\n                             String serviceVerb, MNode newFieldNode, MNode subFieldNode, MNode baseFormNode) {\n        // if the parameter corresponds to an entity field, we can do better with that\n        EntityDefinition fieldEd = nounEd\n        if (parameterNode.attribute(\"entity-name\")) fieldEd = ecfi.entityFacade.getEntityDefinition(parameterNode.attribute(\"entity-name\"))\n        String fieldName = parameterNode.attribute(\"field-name\") ?: parameterNode.attribute(\"name\")\n        if (fieldEd != null && fieldEd.getFieldNode(fieldName) != null) {\n            addAutoEntityField(fieldEd, fieldName, fieldType, newFieldNode, subFieldNode, baseFormNode)\n            return\n        }\n\n        // otherwise use the old approach and do what we can with the service def\n        String spType = parameterNode.attribute(\"type\") ?: \"String\"\n        String efType = fieldEd != null ? fieldEd.getFieldInfo(parameterNode.attribute(\"name\"))?.type : null\n\n        switch (fieldType) {\n            case \"edit\":\n                // lastUpdatedStamp is always hidden for edit (needed for optimistic lock)\n                if (parameterNode.attribute(\"name\") == \"lastUpdatedStamp\") {\n                    subFieldNode.append(\"hidden\", null)\n                    break\n                }\n\n                /* NOTE: used to do this but doesn't make sense for main use of this in ServiceRun/etc screens; for app\n                    forms should separates pks and use display or hidden instead of edit:\n                if (parameterNode.attribute(\"required\") == \"true\" && serviceVerb.startsWith(\"update\")) {\n                    subFieldNode.append(\"hidden\", null)\n                } else {\n                }\n                */\n                if (spType.endsWith(\"Date\") && spType != \"java.util.Date\") {\n                    subFieldNode.append(\"date-time\", [type:\"date\", format:parameterNode.attribute(\"format\")])\n                } else if (spType.endsWith(\"Time\")) {\n                    subFieldNode.append(\"date-time\", [type:\"time\", format:parameterNode.attribute(\"format\")])\n                } else if (spType.endsWith(\"Timestamp\") || spType == \"java.util.Date\") {\n                    subFieldNode.append(\"date-time\", [type:\"date-time\", format:parameterNode.attribute(\"format\")])\n                } else {\n                    if (efType == \"text-long\" || efType == \"text-very-long\") {\n                        subFieldNode.append(\"text-area\", null)\n                    } else {\n                        subFieldNode.append(\"text-line\", ['default-value':parameterNode.attribute(\"default-value\")])\n                    }\n                }\n                break\n            case \"find\":\n                if (spType.endsWith(\"Date\") && spType != \"java.util.Date\") {\n                    subFieldNode.append(\"date-find\", [type:\"date\", format:parameterNode.attribute(\"format\")])\n                } else if (spType.endsWith(\"Time\")) {\n                    subFieldNode.append(\"date-find\", [type:\"time\", format:parameterNode.attribute(\"format\")])\n                } else if (spType.endsWith(\"Timestamp\") || spType == \"java.util.Date\") {\n                    subFieldNode.append(\"date-find\", [type:\"date-time\", format:parameterNode.attribute(\"format\")])\n                } else if (spType.endsWith(\"BigDecimal\") || spType.endsWith(\"BigInteger\") || spType.endsWith(\"Long\") ||\n                        spType.endsWith(\"Integer\") || spType.endsWith(\"Double\") || spType.endsWith(\"Float\") ||\n                        spType.endsWith(\"Number\")) {\n                    subFieldNode.append(\"range-find\", null)\n                } else {\n                    subFieldNode.append(\"text-find\", null)\n                }\n                break\n            case \"display\":\n                subFieldNode.append(\"display\", [format:parameterNode.attribute(\"format\")])\n                break\n            case \"find-display\":\n                MNode headerFieldNode = newFieldNode.append(\"header-field\", null)\n                if (spType.endsWith(\"Date\") && spType != \"java.util.Date\") {\n                    headerFieldNode.append(\"date-find\", [type:\"date\", format:parameterNode.attribute(\"format\")])\n                } else if (spType.endsWith(\"Time\")) {\n                    headerFieldNode.append(\"date-find\", [type:\"time\", format:parameterNode.attribute(\"format\")])\n                } else if (spType.endsWith(\"Timestamp\") || spType == \"java.util.Date\") {\n                    headerFieldNode.append(\"date-find\", [type:\"date-time\", format:parameterNode.attribute(\"format\")])\n                } else if (spType.endsWith(\"BigDecimal\") || spType.endsWith(\"BigInteger\") || spType.endsWith(\"Long\") ||\n                        spType.endsWith(\"Integer\") || spType.endsWith(\"Double\") || spType.endsWith(\"Float\") ||\n                        spType.endsWith(\"Number\")) {\n                    headerFieldNode.append(\"range-find\", null)\n                } else {\n                    headerFieldNode.append(\"text-find\", null)\n                }\n                subFieldNode.append(\"display\", [format:parameterNode.attribute(\"format\")])\n                break\n            case \"hidden\":\n                subFieldNode.append(\"hidden\", null)\n                break\n        }\n    }\n    void addServiceFields(ServiceDefinition sd, String include, String fieldType, Set<String> excludes, MNode baseFormNode,\n                          ExecutionContextFactoryImpl ecfi) {\n        String serviceVerb = sd.verb\n        //String serviceType = sd.serviceNode.\"@type\"\n        EntityDefinition nounEd = null\n        try {\n            nounEd = ecfi.entityFacade.getEntityDefinition(sd.noun)\n        } catch (EntityException e) {\n            if (logger.isTraceEnabled()) logger.trace(\"Ignoring entity exception, may not be real entity name: ${e.toString()}\")\n        }\n\n        List<MNode> parameterNodes = []\n        if (include == \"in\" || include == \"all\") parameterNodes.addAll(sd.serviceNode.first(\"in-parameters\").children(\"parameter\"))\n        if (include == \"out\" || include == \"all\") parameterNodes.addAll(sd.serviceNode.first(\"out-parameters\").children(\"parameter\"))\n\n        for (MNode parameterNode in parameterNodes) {\n            String parameterName = parameterNode.attribute(\"name\")\n            if ((excludes != null && excludes.contains(parameterName)) || \"lastUpdatedStamp\".equals(parameterName)) continue\n            MNode newFieldNode = new MNode(\"field\", [name:parameterName])\n            MNode subFieldNode = newFieldNode.append(\"default-field\", [\"validate-service\":sd.serviceName, \"validate-parameter\":parameterName])\n            addAutoServiceField(nounEd, parameterNode, fieldType, serviceVerb, newFieldNode, subFieldNode, baseFormNode)\n            mergeFieldNode(baseFormNode, newFieldNode, false)\n        }\n    }\n\n    void addEntityFields(EntityDefinition ed, String include, String fieldType, Set<String> excludes, MNode baseFormNode, LinkedHashMap<String, ArrayList<String>> fieldColumnInfo) {\n        ArrayList<String> fieldNames = ed.getFieldNames(\"all\".equals(include) || \"pk\".equals(include), \"all\".equals(include) || \"nonpk\".equals(include))\n        int fieldNamesSize = fieldNames.size()\n\n        ArrayList<String> displayFieldNames = new ArrayList<>(fieldNamesSize)\n        for (int i = 0; i < fieldNamesSize; i++) {\n            String fieldName = (String) fieldNames.get(i)\n            if ((excludes != null && excludes.contains(fieldName)) || \"lastUpdatedStamp\".equals(fieldName)) continue\n\n            FieldInfo fi = ed.getFieldInfo(fieldName)\n            String efType = fi.type ?: \"text-long\"\n            boolean makeDefaultField = true\n            if (\"form-list\".equals(baseFormNode.name)) {\n                Boolean displayField = (Boolean) null\n                String defaultDisplay = fi.fieldNode.attribute(\"default-display\")\n                if (defaultDisplay != null && !defaultDisplay.isEmpty()) displayField = \"true\".equals(defaultDisplay)\n                if (displayField == null && efType in ['text-long', 'text-very-long', 'binary-very-long']) {\n                    // allow find by and display text-long even if not the default, but in form-list never do anything with text-very-long or binary-very-long\n                    // DEJ 20201120 changed set displayField to true instead of false so is display, change to false to not display\n                    if (\"text-long\".equals(efType)) { displayField = true } else { continue }\n                }\n                makeDefaultField = displayField == null || displayField.booleanValue()\n            }\n\n            displayFieldNames.add(fieldName)\n\n            MNode newFieldNode = new MNode(\"field\", [name:fieldName])\n            MNode subFieldNode = makeDefaultField ? newFieldNode.append(\"default-field\", [\"validate-entity\":ed.getFullEntityName(), \"validate-field\":fieldName]) : null\n\n            addAutoEntityField(ed, fieldName, fieldType, newFieldNode, subFieldNode, baseFormNode)\n\n            // logger.info(\"Adding form auto entity field [${fieldName}] of type [${efType}], fieldType [${fieldType}] serviceVerb [${serviceVerb}], node: ${newFieldNode}\")\n            mergeFieldNode(baseFormNode, newFieldNode, false)\n        }\n        // separate handling for view-entity with aliases using pq-expression\n        if (ed.isViewEntity) {\n            Map<String, MNode> pqExpressionNodeMap = ed.getPqExpressionNodeMap()\n            if (pqExpressionNodeMap != null) {\n                for (MNode pqExprNode in pqExpressionNodeMap.values()) {\n                    String defaultDisplay = pqExprNode.attribute(\"default-display\")\n                    if (!\"true\".equals(defaultDisplay)) continue\n\n                    String fieldName = pqExprNode.attribute(\"name\")\n                    MNode newFieldNode = new MNode(\"field\", [name:fieldName])\n                    MNode subFieldNode = newFieldNode.append(\"default-field\", [\"validate-entity\":ed.getFullEntityName(), \"validate-field\":fieldName])\n\n                    addAutoEntityField(ed, fieldName, \"display\", newFieldNode, subFieldNode, baseFormNode)\n                    mergeFieldNode(baseFormNode, newFieldNode, false)\n                }\n            }\n        }\n\n        if (fieldColumnInfo != null) fieldColumnInfo.put(ed.getFullEntityName(), displayFieldNames)\n\n        // logger.info(\"TOREMOVE: after addEntityFields formNode is: ${baseFormNode}\")\n    }\n\n    void addAutoEntityColumns(MNode newFormNode, MNode initFieldsFormNode, String entityName, ArrayList<String> displayFieldNames) {\n        EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(entityName)\n        // if more than 6 fields auto stack in columns\n        int displayFieldNamesSize = displayFieldNames.size()\n        int fieldsPerCol = (displayFieldNamesSize / 6.0).setScale(0, BigDecimal.ROUND_HALF_UP).intValue()\n\n        ArrayList<String> idDateFields = new ArrayList<>()\n        ArrayList<String> numberFields = new ArrayList<>()\n        ArrayList<String> shortFields = new ArrayList<>()\n        ArrayList<String> longFields = new ArrayList<>()\n\n        for (int i = 0; i < displayFieldNamesSize; i++) {\n            String fieldName = (String) displayFieldNames.get(i)\n            FieldInfo fi = ed.getFieldInfo(fieldName)\n            String efType = fi.type ?: \"text-long\"\n            if (efType.startsWith(\"id\") || efType.startsWith(\"date\") || efType.equals(\"time\")) {\n                idDateFields.add(fieldName)\n            } else if (efType.startsWith(\"number\") || efType.startsWith(\"currency\") || efType.equals(\"text-indicator\")) {\n                numberFields.add(fieldName)\n            } else if (\"text-short\".equals(efType) || \"text-medium\".equals(efType)) {\n                shortFields.add(fieldName)\n            } else {\n                longFields.add(fieldName)\n            }\n        }\n\n        ArrayList<String> sortedFields = new ArrayList<>(displayFieldNamesSize)\n        sortedFields.addAll(idDateFields); sortedFields.addAll(numberFields)\n        sortedFields.addAll(shortFields); sortedFields.addAll(longFields)\n\n        MNode columnsNode = newFormNode.first(\"columns\")\n        if (columnsNode == null) {\n            columnsNode = newFormNode.append(\"columns\", null)\n            if (initFieldsFormNode != null) {\n                ArrayList<MNode> fieldNodeList = initFieldsFormNode.children(\"field\")\n                for (int i = 0; i < fieldNodeList.size(); i++) {\n                    MNode fieldNode = (MNode) fieldNodeList.get(i)\n                    ArrayList<MNode> defCondChildList = new ArrayList(fieldNode.children(\"default-field\"))\n                    defCondChildList.addAll(fieldNode.children(\"conditional-field\"))\n                    HashSet<String> fieldsUsed = new HashSet<>()\n                    for (int dci = 0; dci < defCondChildList.size(); dci++) {\n                        MNode defCondChild = (MNode) defCondChildList.get(dci)\n                        String fieldName = fieldNode.attribute(\"name\")\n                        if (fieldsUsed.contains(fieldName)) continue\n                        if (defCondChild.children.size() == 1 && (defCondChild.hasChild(\"hidden\") || defCondChild.hasChild(\"ignored\"))) continue\n\n                        MNode columnNode = columnsNode.append(\"column\", null)\n                        columnNode.append(\"field-ref\", [name:fieldName])\n                        fieldsUsed.add(fieldName)\n                    }\n                }\n            }\n        }\n\n        ArrayList<String> curColumnList = new ArrayList<>(fieldsPerCol)\n        for (int i = 0; i < displayFieldNamesSize; i++) {\n            String fieldName = (String) sortedFields.get(i)\n            curColumnList.add(fieldName)\n\n            if (curColumnList.size() == fieldsPerCol || (i + 1 == displayFieldNamesSize)) {\n                MNode columnNode = columnsNode.append(\"column\", null)\n                for (int ci = 0; ci < curColumnList.size(); ci++) {\n                    String curFieldName = (String) curColumnList.get(ci)\n                    columnNode.append(\"field-ref\", [name:curFieldName])\n                }\n                curColumnList.clear()\n            }\n        }\n    }\n\n    void addAutoEntityField(EntityDefinition ed, String fieldName, String fieldType, MNode newFieldNode, MNode subFieldNode, MNode baseFormNode) {\n        // NOTE: in some cases this may be null\n        FieldInfo fieldInfo = ed.getFieldInfo(fieldName)\n        String efType = fieldInfo?.type ?: \"text-long\"\n\n        // to see if this should be a drop-down with data from another entity,\n        // find first relationship that has this field as the only key map and is not a many relationship\n        MNode oneRelNode = null\n        Map oneRelKeyMap = null\n        String relatedEntityName = null\n        EntityDefinition relatedEd = null\n        for (RelationshipInfo relInfo in ed.getRelationshipsInfo(false)) {\n            String relEntityName = relInfo.relatedEntityName\n            EntityDefinition relEd = relInfo.relatedEd\n            Map km = relInfo.keyMap\n            if (km.size() == 1 && km.containsKey(fieldName) && relInfo.type == \"one\" && relInfo.relNode.attribute(\"is-auto-reverse\") != \"true\") {\n                oneRelNode = relInfo.relNode\n                oneRelKeyMap = km\n                relatedEntityName = relEntityName\n                relatedEd = relEd\n                break\n            }\n        }\n        String keyField = (String) oneRelKeyMap?.keySet()?.iterator()?.next()\n        String relKeyField = (String) oneRelKeyMap?.values()?.iterator()?.next()\n        String relDefaultDescriptionField = relatedEd?.getDefaultDescriptionField()\n\n        switch (fieldType) {\n        case \"edit\":\n            // lastUpdatedStamp is always hidden for edit (needed for optimistic lock)\n            if (fieldName == \"lastUpdatedStamp\") {\n                subFieldNode.append(\"hidden\", null)\n                break\n            }\n\n            // handle header-field\n            if (baseFormNode.name == \"form-list\" && !newFieldNode.hasChild(\"header-field\"))\n                newFieldNode.append(\"header-field\", [\"show-order-by\":\"true\"])\n\n            // handle sub field (default-field)\n            if (subFieldNode == null) break\n            /* NOTE: used to do this but doesn't make sense for main use of this in ServiceRun/etc screens; for app\n                forms should separates pks and use display or hidden instead of edit:\n            List<String> pkFieldNameSet = ed.getPkFieldNames()\n            if (pkFieldNameSet.contains(fieldName) && serviceVerb == \"update\") {\n                subFieldNode.append(\"hidden\", null)\n            } else {\n            }\n            */\n            if (efType.startsWith(\"date\") || efType.startsWith(\"time\")) {\n                MNode dateTimeNode = subFieldNode.append(\"date-time\", [type:efType])\n                if (fieldName == \"fromDate\") dateTimeNode.attributes.put(\"default-value\", \"\\${ec.l10n.format(ec.user.nowTimestamp, 'yyyy-MM-dd HH:mm')}\")\n            } else if (\"text-long\".equals(efType) || \"text-very-long\".equals(efType)) {\n                subFieldNode.append(\"text-area\", null)\n            } else if (\"text-indicator\".equals(efType)) {\n                MNode dropDownNode = subFieldNode.append(\"drop-down\", [\"allow-empty\":\"true\"])\n                dropDownNode.append(\"option\", [\"key\":\"Y\"])\n                dropDownNode.append(\"option\", [\"key\":\"N\"])\n            } else if (\"binary-very-long\".equals(efType)) {\n                // would be nice to have something better for this, like a download somehow\n                subFieldNode.append(\"display\", null)\n            } else {\n                if (oneRelNode != null) {\n                    addEntityFieldDropDown(oneRelNode, subFieldNode, relatedEd, relKeyField, \"\")\n                } else {\n                    if (efType.startsWith(\"number-\") || efType.startsWith(\"currency-\")) {\n                        subFieldNode.append(\"text-line\", [size:\"10\"])\n                    } else {\n                        subFieldNode.append(\"text-line\", [size:\"30\"])\n                    }\n                }\n            }\n            break\n        case \"find\":\n            // handle header-field\n            if (baseFormNode.name == \"form-list\" && !newFieldNode.hasChild(\"header-field\"))\n                newFieldNode.append(\"header-field\", [\"show-order-by\":\"case-insensitive\"])\n            // handle sub field (default-field)\n            if (subFieldNode == null) break\n            if (efType.startsWith(\"date\") || efType.startsWith(\"time\")) {\n                subFieldNode.append(\"date-find\", [type:efType])\n            } else if (efType.startsWith(\"number-\") || efType.startsWith(\"currency-\")) {\n                subFieldNode.append(\"range-find\", null)\n            } else {\n                if (oneRelNode != null) {\n                    addEntityFieldDropDown(oneRelNode, subFieldNode, relatedEd, relKeyField, \"\")\n                } else {\n                    subFieldNode.append(\"text-find\", null)\n                }\n            }\n            break\n        case \"display\":\n            // handle header-field\n            if (baseFormNode.name == \"form-list\" && !newFieldNode.hasChild(\"header-field\"))\n                newFieldNode.append(\"header-field\", [\"show-order-by\":\"case-insensitive\"])\n            // handle sub field (default-field)\n            if (subFieldNode == null) break\n            String textStr\n            if (relDefaultDescriptionField) textStr = \"\\${\" + relDefaultDescriptionField + \" ?: ''} [\\${\" + relKeyField + \"}]\"\n            else textStr = \"[\\${\" + relKeyField + \"}]\"\n            if (oneRelNode != null) {\n                subFieldNode.append(\"display-entity\",\n                        [\"entity-name\":(oneRelNode.attribute(\"related\") ?: oneRelNode.attribute(\"related-entity-name\")), \"text\":textStr])\n            } else {\n                Map<String, String> attrs = (Map<String, String>) null\n                if (efType.equals(\"currency-amount\")) {\n                    attrs = [format:\"#,##0.00\"]\n                } else if (efType.equals(\"currency-precise\")) {\n                    attrs = [format:\"#,##0.000\"]\n                }\n                subFieldNode.append(\"display\", attrs)\n            }\n            break\n        case \"find-display\":\n            // handle header-field\n            if (baseFormNode.name == \"form-list\" && !newFieldNode.hasChild(\"header-field\"))\n                newFieldNode.append(\"header-field\", [\"show-order-by\":\"case-insensitive\"])\n            MNode headerFieldNode = newFieldNode.hasChild(\"header-field\") ?\n                newFieldNode.first(\"header-field\") : newFieldNode.append(\"header-field\", null)\n            if (\"date\".equals(efType) || \"time\".equals(efType)) {\n                headerFieldNode.append(\"date-find\", [type:efType])\n            } else if (\"date-time\".equals(efType)) {\n                headerFieldNode.append(\"date-period\", [time:\"true\"])\n            } else if (efType.startsWith(\"number-\") || efType.startsWith(\"currency-\")) {\n                headerFieldNode.append(\"range-find\", [size:'10'])\n                newFieldNode.attributes.put(\"align\", \"right\")\n                String function = fieldInfo?.fieldNode?.attribute(\"function\")\n                if (function != null && function in ['min', 'max', 'avg']) {\n                    newFieldNode.attributes.put(\"show-total\", function)\n                } else {\n                    newFieldNode.attributes.put(\"show-total\", \"sum\")\n                }\n            } else {\n                if (oneRelNode != null) {\n                    addEntityFieldDropDown(oneRelNode, headerFieldNode, relatedEd, relKeyField, \"\")\n                } else {\n                    headerFieldNode.append(\"text-find\", [size:'30', \"default-operator\":\"begins\", \"ignore-case\":\"false\"])\n                }\n            }\n            // handle sub field (default-field)\n            if (subFieldNode == null) break\n            if (oneRelNode != null) {\n                String textStr\n                if (relDefaultDescriptionField) textStr = \"\\${\" + relDefaultDescriptionField + \" ?: ''} [\\${\" + relKeyField + \"}]\"\n                else textStr = \"[\\${\" + relKeyField + \"}]\"\n                subFieldNode.append(\"display-entity\", [\"text\":textStr,\n                        \"entity-name\":(oneRelNode.attribute(\"related\") ?: oneRelNode.attribute(\"related-entity-name\"))])\n            } else {\n                Map<String, String> attrs = (Map<String, String>) null\n                if (efType.equals(\"currency-amount\")) {\n                    attrs = [format:\"#,##0.00\"]\n                } else if (efType.equals(\"currency-precise\")) {\n                    attrs = [format:\"#,##0.000\"]\n                }\n                subFieldNode.append(\"display\", attrs)\n            }\n            break\n        case \"hidden\":\n            subFieldNode.append(\"hidden\", null)\n            break\n        }\n\n        // NOTE: don't like where this is located, would be nice to have a generic way for forms to add this sort of thing\n        if (oneRelNode != null && subFieldNode != null) {\n            if (internalFormNode.attribute(\"name\") == \"UpdateMasterEntityValue\") {\n                MNode linkNode = subFieldNode.append(\"link\", [url:\"edit\",\n                        text:(\"Edit ${relatedEd.getPrettyName(null, null)} [\\${fieldValues.\" + keyField + \"}]\").toString(),\n                        condition:keyField, 'link-type':'anchor'] as Map<String, String>)\n                linkNode.append(\"parameter\", [name:\"aen\", value:relatedEntityName])\n                linkNode.append(\"parameter\", [name:relKeyField, from:\"fieldValues.${keyField}\".toString()])\n            }\n        }\n    }\n\n    protected void addEntityFieldDropDown(MNode oneRelNode, MNode subFieldNode, EntityDefinition relatedEd,\n                                          String relKeyField, String dropDownStyle) {\n        String title = oneRelNode.attribute(\"title\")\n\n        if (relatedEd == null) {\n            subFieldNode.append(\"text-line\", null)\n            return\n        }\n        String relatedEntityName = relatedEd.getFullEntityName()\n        String relDefaultDescriptionField = relatedEd.getDefaultDescriptionField()\n\n        // NOTE: combo-box not currently supported, so only show drop-down if less than 200 records\n        long recordCount\n        if (relatedEntityName == \"moqui.basic.Enumeration\") {\n            recordCount = ecfi.entityFacade.find(\"moqui.basic.Enumeration\").condition(\"enumTypeId\", title).disableAuthz().count()\n        } else if (relatedEntityName == \"moqui.basic.StatusItem\") {\n            recordCount = ecfi.entityFacade.find(\"moqui.basic.StatusItem\").condition(\"statusTypeId\", title).disableAuthz().count()\n        } else {\n            recordCount = ecfi.entityFacade.find(relatedEntityName).disableAuthz().count()\n        }\n        if (recordCount > 0 && recordCount <= 200) {\n            // FOR FUTURE: use the combo-box just in case the drop-down as a default is over-constrained\n            MNode dropDownNode = subFieldNode.append(\"drop-down\", [\"allow-empty\":\"true\", style:(dropDownStyle ?: \"\")])\n            MNode entityOptionsNode = dropDownNode.append(\"entity-options\", null)\n            MNode entityFindNode = entityOptionsNode.append(\"entity-find\",\n                    [\"entity-name\":relatedEntityName, \"offset\":\"0\", \"limit\":\"200\"])\n\n            if (relatedEntityName == \"moqui.basic.Enumeration\") {\n                // recordCount will be > 0 so we know there are records with this type\n                entityFindNode.append(\"econdition\", [\"field-name\":\"enumTypeId\", \"value\":title])\n            } else if (relatedEntityName == \"moqui.basic.StatusItem\") {\n                // recordCount will be > 0 so we know there are records with this type\n                entityFindNode.append(\"econdition\", [\"field-name\":\"statusTypeId\", \"value\":title])\n            }\n\n            if (relDefaultDescriptionField) {\n                entityOptionsNode.attributes.put(\"text\", \"\\${\" + relDefaultDescriptionField + \" ?: ''} [\\${\" + relKeyField + \"}]\")\n                entityFindNode.append(\"order-by\", [\"field-name\":relDefaultDescriptionField])\n            }\n        } else {\n            subFieldNode.append(\"text-line\", null)\n        }\n    }\n\n    protected void expandFieldNode(MNode baseFormNode, MNode fieldNode) {\n        if (fieldNode.hasChild(\"header-field\")) expandFieldSubNode(baseFormNode, fieldNode, fieldNode.first(\"header-field\"))\n        if (fieldNode.hasChild(\"first-row-field\")) expandFieldSubNode(baseFormNode, fieldNode, fieldNode.first(\"first-row-field\"))\n        if (fieldNode.hasChild(\"second-row-field\")) expandFieldSubNode(baseFormNode, fieldNode, fieldNode.first(\"second-row-field\"))\n        for (MNode conditionalFieldNode in fieldNode.children(\"conditional-field\"))\n            expandFieldSubNode(baseFormNode, fieldNode, conditionalFieldNode)\n        if (fieldNode.hasChild(\"default-field\")) expandFieldSubNode(baseFormNode, fieldNode, fieldNode.first(\"default-field\"))\n        if (fieldNode.hasChild(\"last-row-field\")) expandFieldSubNode(baseFormNode, fieldNode, fieldNode.first(\"last-row-field\"))\n    }\n\n    protected void expandFieldSubNode(MNode baseFormNode, MNode fieldNode, MNode fieldSubNode) {\n        MNode widgetNode = fieldSubNode.children ? fieldSubNode.children.get(0) : null\n        if (widgetNode == null) return\n        if (widgetNode.name == \"auto-widget-service\") {\n            fieldSubNode.children.remove(0)\n            addAutoWidgetServiceNode(baseFormNode, fieldNode, fieldSubNode, widgetNode)\n        } else if (widgetNode.name == \"auto-widget-entity\") {\n            fieldSubNode.children.remove(0)\n            addAutoWidgetEntityNode(baseFormNode, fieldNode, fieldSubNode, widgetNode)\n        } else if (widgetNode.name == \"widget-template-include\") {\n            List<MNode> setNodeList = widgetNode.children(\"set\")\n\n            String templateLocation = widgetNode.attribute(\"location\")\n            if (!templateLocation) throw new BaseArtifactException(\"widget-template-include.@location cannot be empty\")\n            if (!templateLocation.contains(\"#\")) throw new BaseArtifactException(\"widget-template-include.@location must contain a hash/pound sign to separate the file location and widget-template.@name: [${templateLocation}]\")\n            String fileLocation = templateLocation.substring(0, templateLocation.indexOf(\"#\"))\n            String widgetTemplateName = templateLocation.substring(templateLocation.indexOf(\"#\") + 1)\n\n            MNode widgetTemplatesNode = ecfi.screenFacade.getWidgetTemplatesNodeByLocation(fileLocation)\n            MNode widgetTemplateNode = widgetTemplatesNode?.first({ MNode it -> it.attribute(\"name\") == widgetTemplateName })\n            if (widgetTemplateNode == null) throw new BaseArtifactException(\"Could not find widget-template [${widgetTemplateName}] in [${fileLocation}]\")\n\n            // remove the widget-template-include node\n            fieldSubNode.children.remove(0)\n            // remove other nodes and append them back so they are after (we allow arbitrary other widget nodes as field sub-nodes)\n            List<MNode> otherNodes = []\n            otherNodes.addAll(fieldSubNode.children)\n            fieldSubNode.children.clear()\n\n            for (MNode widgetChildNode in widgetTemplateNode.children)\n                fieldSubNode.append(widgetChildNode.deepCopy(null))\n            for (MNode otherNode in otherNodes) fieldSubNode.append(otherNode)\n\n            for (MNode setNode in setNodeList) fieldSubNode.append(setNode.deepCopy(null))\n        }\n    }\n\n    protected void addAutoWidgetServiceNode(MNode baseFormNode, MNode fieldNode, MNode fieldSubNode, MNode widgetNode) {\n        String serviceName = widgetNode.attribute(\"service-name\")\n        if (isDynamic) serviceName = ecfi.resourceFacade.expand(serviceName, \"\")\n        ServiceDefinition serviceDef = ecfi.serviceFacade.getServiceDefinition(serviceName)\n        if (serviceDef != null) {\n            addAutoServiceField(serviceDef, widgetNode.attribute(\"parameter-name\") ?: fieldNode.attribute(\"name\"),\n                    widgetNode.attribute(\"field-type\") ?: \"edit\", fieldNode, fieldSubNode, baseFormNode)\n            return\n        }\n        if (serviceName.contains(\"#\")) {\n            EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(serviceName.substring(serviceName.indexOf(\"#\")+1))\n            if (ed != null) {\n                addAutoEntityField(ed, widgetNode.attribute(\"parameter-name\")?:fieldNode.attribute(\"name\"),\n                        widgetNode.attribute(\"field-type\")?:\"edit\", fieldNode, fieldSubNode, baseFormNode)\n                return\n            }\n        }\n        throw new BaseArtifactException(\"Cound not find service [${serviceName}] or entity noun referred to in auto-fields-service of form [${baseFormNode.attribute(\"name\")}] of screen [${sd.location}]\")\n    }\n    void addAutoServiceField(ServiceDefinition sd, String parameterName, String fieldType,\n                             MNode newFieldNode, MNode subFieldNode, MNode baseFormNode) {\n        EntityDefinition nounEd = null\n        try {\n            nounEd = ecfi.entityFacade.getEntityDefinition(sd.noun)\n        } catch (EntityException e) {\n            // ignore, anticipating there may be no entity def\n            if (logger.isTraceEnabled()) logger.trace(\"Ignoring entity exception, not necessarily an entity name: ${e.toString()}\")\n        }\n        MNode parameterNode = sd.serviceNode.first({ MNode it -> it.name == \"in-parameters\" && it.attribute(\"name\") == parameterName })\n\n        if (parameterNode == null) throw new BaseArtifactException(\"Cound not find parameter [${parameterName}] in service [${sd.serviceName}] referred to in auto-widget-service of form [${baseFormNode.attribute(\"name\")}] of screen [${sd.location}]\")\n        addAutoServiceField(nounEd, parameterNode, fieldType, sd.verb, newFieldNode, subFieldNode, baseFormNode)\n    }\n\n    protected void addAutoWidgetEntityNode(MNode baseFormNode, MNode fieldNode, MNode fieldSubNode, MNode widgetNode) {\n        String entityName = widgetNode.attribute(\"entity-name\")\n        if (isDynamic) entityName = ecfi.resourceFacade.expand(entityName, \"\")\n        EntityDefinition ed = null\n        try {\n            ed = ecfi.entityFacade.getEntityDefinition(entityName)\n        } catch (EntityException e) {\n            // ignore, anticipating there may be no entity def\n            if (logger.isTraceEnabled()) logger.trace(\"Ignoring entity exception, not necessarily an entity name: ${e.toString()}\")\n        }\n        if (ed == null) throw new BaseArtifactException(\"Cound not find entity [${entityName}] referred to in auto-widget-entity of form [${baseFormNode.attribute(\"name\")}] of screen [${sd.location}]\")\n        addAutoEntityField(ed, widgetNode.attribute(\"field-name\")?:fieldNode.attribute(\"name\"),\n                widgetNode.attribute(\"field-type\")?:\"find-display\", fieldNode, fieldSubNode, baseFormNode)\n    }\n\n    protected static void mergeFormNodes(MNode baseFormNode, MNode overrideFormNode, boolean deepCopy, boolean copyFields) {\n        if (overrideFormNode.attributes) baseFormNode.attributes.putAll(overrideFormNode.attributes)\n\n        if (overrideFormNode.hasChild(\"entity-find\")) {\n            int efIndex = baseFormNode.firstIndex(\"entity-find\")\n            if (efIndex >= 0) baseFormNode.replace(efIndex, overrideFormNode.first(\"entity-find\"))\n            else baseFormNode.append(overrideFormNode.first(\"entity-find\"), 0)\n        }\n        // if overrideFormNode has any row-actions add them all to the ones of the baseFormNode, ie both will run\n        if (overrideFormNode.hasChild(\"row-actions\")) {\n            if (!baseFormNode.hasChild(\"row-actions\")) baseFormNode.append(\"row-actions\", null)\n            MNode baseRowActionsNode = baseFormNode.first(\"row-actions\")\n            for (MNode actionNode in overrideFormNode.first(\"row-actions\").children) baseRowActionsNode.append(actionNode)\n        }\n        if (overrideFormNode.hasChild(\"row-selection\")) {\n            if (!baseFormNode.hasChild(\"row-selection\")) baseFormNode.append(\"row-selection\", null)\n            MNode baseRowSelNode = baseFormNode.first(\"row-selection\")\n            MNode overrideRowSelNode = overrideFormNode.first(\"row-selection\")\n            baseRowSelNode.attributes.putAll(overrideRowSelNode.attributes)\n            for (MNode actionNode in overrideRowSelNode.children) baseRowSelNode.append(actionNode)\n        }\n        if (overrideFormNode.hasChild(\"hidden-parameters\")) {\n            int hpIndex = baseFormNode.firstIndex(\"hidden-parameters\")\n            if (hpIndex >= 0) baseFormNode.replace(hpIndex, overrideFormNode.first(\"hidden-parameters\"))\n            else baseFormNode.append(overrideFormNode.first(\"hidden-parameters\"))\n        }\n\n        if (copyFields) {\n            for (MNode overrideFieldNode in overrideFormNode.children(\"field\"))\n                mergeFieldNode(baseFormNode, overrideFieldNode, deepCopy)\n        }\n\n        if (overrideFormNode.hasChild(\"field-layout\")) {\n            // just use entire override field-layout, don't try to merge\n            baseFormNode.remove(\"field-layout\")\n            baseFormNode.append(overrideFormNode.first(\"field-layout\").deepCopy(null))\n        }\n        if (overrideFormNode.hasChild(\"form-list-column\")) {\n            // if there are any form-list-column remove all from base and copy all from override\n            baseFormNode.remove(\"form-list-column\")\n            for (MNode flcNode in overrideFormNode.children(\"form-list-column\")) baseFormNode.append(flcNode.deepCopy(null))\n        }\n        if (overrideFormNode.hasChild(\"columns\")) {\n            // each columns element by @type attribute overrides corresponding type\n            for (MNode columnsNode in overrideFormNode.children(\"columns\")) {\n                String type = columnsNode.attribute(\"type\")\n                // remove base node children with matching type value\n                baseFormNode.remove({ MNode it -> \"columns\".equals(it.name) && it.attribute(\"type\") == type })\n                baseFormNode.append(columnsNode.deepCopy(null))\n            }\n        }\n    }\n\n    protected static void mergeFieldNode(MNode baseFormNode, MNode overrideFieldNode, boolean deepCopy) {\n        int baseFieldIndex = baseFormNode.firstIndex({ MNode it -> \"field\".equals(it.name) && it.attribute(\"name\") == overrideFieldNode.attribute(\"name\") })\n        if (baseFieldIndex >= 0) {\n            MNode baseFieldNode = baseFormNode.child(baseFieldIndex)\n            baseFieldNode.attributes.putAll(overrideFieldNode.attributes)\n\n            baseFieldNode.mergeSingleChild(overrideFieldNode, \"header-field\")\n            baseFieldNode.mergeSingleChild(overrideFieldNode, \"first-row-field\")\n            baseFieldNode.mergeSingleChild(overrideFieldNode, \"second-row-field\")\n            baseFieldNode.mergeChildrenByKey(overrideFieldNode, \"conditional-field\", \"condition\", null)\n            baseFieldNode.mergeSingleChild(overrideFieldNode, \"default-field\")\n            baseFieldNode.mergeSingleChild(overrideFieldNode, \"last-row-field\")\n\n            // put new node where old was\n            baseFormNode.remove(baseFieldIndex)\n            baseFormNode.append(baseFieldNode, baseFieldIndex)\n        } else {\n            baseFormNode.append(deepCopy ? overrideFieldNode.deepCopy(null) : overrideFieldNode)\n            // this is a new field... if the form has a field-layout element add a reference under that too\n            if (baseFormNode.hasChild(\"field-layout\")) addFieldToFieldLayout(baseFormNode, overrideFieldNode)\n        }\n    }\n\n    static void addFieldToFieldLayout(MNode formNode, MNode fieldNode) {\n        MNode fieldLayoutNode = formNode.first(\"field-layout\")\n        Integer layoutSequenceNum = fieldNode.attribute(\"layoutSequenceNum\") as Integer\n        if (layoutSequenceNum == null) {\n            fieldLayoutNode.append(\"field-ref\", [name:fieldNode.attribute(\"name\")])\n        } else {\n            formNode.remove(\"field-layout\")\n            MNode newFieldLayoutNode = formNode.append(\"field-layout\", fieldLayoutNode.attributes)\n            int index = 0\n            boolean addedNode = false\n            for (MNode child in fieldLayoutNode.children) {\n                if (index == layoutSequenceNum) {\n                    newFieldLayoutNode.append(\"field-ref\", [name:fieldNode.attribute(\"name\")])\n                    addedNode = true\n                }\n                newFieldLayoutNode.append(child)\n                index++\n            }\n            if (!addedNode) {\n                newFieldLayoutNode.append(\"field-ref\", [name:fieldNode.attribute(\"name\")])\n            }\n        }\n    }\n\n    static LinkedHashMap<String, String> getFieldOptions(MNode widgetNode, ExecutionContext ec) {\n        MNode fieldNode = widgetNode.parent.parent\n        LinkedHashMap<String, String> options = new LinkedHashMap<>()\n        ArrayList<MNode> widgetChildren = widgetNode.children\n        int widgetChildrenSize = widgetChildren.size()\n        for (int wci = 0; wci < widgetChildrenSize; wci++) {\n            MNode childNode = (MNode) widgetChildren.get(wci)\n            if (\"entity-options\".equals(childNode.name)) {\n                MNode entityFindNode = childNode.first(\"entity-find\")\n                EntityFind ef = ec.entity.find(entityFindNode)\n                EntityList eli = ef.list()\n\n                if (ef.shouldCache()) {\n                    // do the date filtering after the query\n                    ArrayList<MNode> dateFilterList = entityFindNode.children(\"date-filter\")\n                    int dateFilterListSize = dateFilterList.size()\n                    for (int k = 0; k < dateFilterListSize; k++) {\n                        MNode df = (MNode) dateFilterList.get(k)\n                        EntityCondition dateEc = ec.entity.conditionFactory.makeConditionDate(df.attribute(\"from-field-name\") ?: \"fromDate\",\n                                df.attribute(\"thru-field-name\") ?: \"thruDate\",\n                                (df.attribute(\"valid-date\") ? ec.resource.expression(df.attribute(\"valid-date\"), null) as Timestamp : ec.user.nowTimestamp))\n                        // logger.warn(\"TOREMOVE getFieldOptions cache=${ef.getUseCache()}, dateEc=${dateEc} list before=${eli}\")\n                        eli = eli.filterByCondition(dateEc, true)\n                    }\n                }\n\n                int eliSize = eli.size()\n                for (int i = 0; i < eliSize; i++) {\n                    EntityValue ev = (EntityValue) eli.get(i)\n                    addFieldOption(options, fieldNode, childNode, ev, ec)\n                }\n            } else if (\"list-options\".equals(childNode.name)) {\n                Object listObject = ec.resource.expression(childNode.attribute('list'), null)\n                if (listObject instanceof EntityListIterator) {\n                    EntityListIterator eli\n                    try {\n                        eli = (EntityListIterator) listObject\n                        EntityValue ev\n                        while ((ev = eli.next()) != null) addFieldOption(options, fieldNode, childNode, ev, ec)\n                    } finally {\n                        eli.close()\n                    }\n                } else {\n                    String keyAttr = childNode.attribute(\"key\")\n                    String textAttr = childNode.attribute(\"text\")\n                    for (Object listOption in listObject) {\n                        if (listOption instanceof Map) {\n                            addFieldOption(options, fieldNode, childNode, (Map) listOption, ec)\n                        } else {\n                            if (keyAttr != null || textAttr != null) {\n                                addFieldOption(options, fieldNode, childNode, [entry:listOption], ec)\n                            } else {\n                                String loString = ObjectUtilities.toPlainString(listOption)\n                                if (loString != null) options.put(loString, ec.l10n.localize(loString))\n                            }\n                        }\n                    }\n                }\n            } else if (\"option\".equals(childNode.name)) {\n                String key = childNode.attribute('key')\n                if (key != null && key.contains('${')) key = ec.resource.expandNoL10n(key, null)\n                String text = childNode.attribute('text')\n                if (text != null) text = ec.resource.expand(text, null)\n                options.put(key, text ?: ec.l10n.localize(key))\n            }\n        }\n        return options\n    }\n\n    static void addFieldOption(LinkedHashMap<String, String> options, MNode fieldNode, MNode childNode, Map listOption,\n                               ExecutionContext ec) {\n        EntityValueBase listOptionEvb = listOption instanceof EntityValueBase ? (EntityValueBase) listOption : (EntityValueBase) null\n        if (listOptionEvb != null) {\n            ec.context.push(listOptionEvb.getMap())\n        } else {\n            ec.context.push(listOption)\n        }\n        try {\n            String key = null\n            String keyAttr = childNode.attribute('key')\n            if (keyAttr != null && keyAttr.length() > 0) {\n                key = ec.resource.expandNoL10n(keyAttr, null)\n                // we just did a string expand, if it evaluates to a literal \"null\" then there was no value\n                if (key == \"null\") key = null\n            } else if (listOptionEvb != null) {\n                String keyFieldName = listOptionEvb.getEntityDefinition().getPkFieldNames().get(0)\n                if (keyFieldName != null && keyFieldName.length() > 0) key = ec.context.getByString(keyFieldName)\n            }\n            if (key == null) key = ec.context.getByString(fieldNode.attribute('name'))\n            if (key == null) return\n\n            String text = childNode.attribute('text')\n            if (text == null || text.length() == 0) {\n                if (listOptionEvb == null || listOptionEvb.getEntityDefinition().isField(\"description\")) {\n                    Object desc = listOption.get(\"description\")\n                    options.put(key, desc != null ? (String) desc : ec.l10n.localize(key))\n                } else {\n                    options.put(key, ec.l10n.localize(key))\n                }\n            } else {\n                String value = ec.resource.expand(text, null)\n                if (\"null\".equals(value)) value = ec.l10n.localize(key)\n                options.put(key, value)\n            }\n        } finally {\n            ec.context.pop()\n        }\n    }\n\n\n    // ========== FormInstance Class/etc ==========\n\n    FormInstance getFormInstance() {\n        if (isDynamic || hasDbExtensions || isDisplayOnly()) {\n            return new FormInstance(this)\n        } else {\n            if (internalFormInstance == null) internalFormInstance = new FormInstance(this)\n            return internalFormInstance\n        }\n    }\n\n    @CompileStatic\n    static class FormInstance {\n        private ScreenForm screenForm\n        private ExecutionContextFactoryImpl ecfi\n        private MNode formNode\n        private boolean isListForm = false\n        protected Set<String> serverStatic = null\n\n        private ArrayList<MNode> allFieldNodes\n        private ArrayList<String> allFieldNames\n        private Map<String, MNode> fieldNodeMap = new LinkedHashMap<>()\n\n        private boolean isUploadForm = false\n        private boolean isFormHeaderFormVal = false\n        private boolean isFormFirstRowFormVal = false\n        private boolean isFormSecondRowFormVal = false\n        private boolean isFormLastRowFormVal = false\n        private boolean hasFirstRow = false\n        private boolean hasSecondRow = false\n        private boolean hasLastRow = false\n        private ArrayList<MNode> nonReferencedFieldList = (ArrayList<MNode>) null\n        private ArrayList<MNode> hiddenFieldList = (ArrayList<MNode>) null\n        private ArrayList<String> hiddenFieldNameList = (ArrayList<String>) null\n        private Set<String> hiddenFieldNameSet = (Set<String>) null\n        private ArrayList<MNode> hiddenHeaderFieldList = (ArrayList<MNode>) null\n        private ArrayList<MNode> hiddenFirstRowFieldList = (ArrayList<MNode>) null\n        private ArrayList<MNode> hiddenSecondRowFieldList = (ArrayList<MNode>) null\n        private ArrayList<MNode> hiddenLastRowFieldList = (ArrayList<MNode>) null\n        private HashMap<String, ArrayList<ArrayList<MNode>>> formListColInfoListMap = (HashMap<String, ArrayList<ArrayList<MNode>>>) null\n        private boolean hasFieldHideAttrs = false\n\n        boolean hasAggregate = false\n        private String[] aggregateGroupFields = (String[]) null\n        private AggregateField[] aggregateFields = (AggregateField[]) null\n        private Map<String, AggregateField> aggregateFieldMap = new HashMap<>()\n        private HashMap<String, String> showTotalFields = (HashMap<String, String>) null\n        private AggregationUtil aggregationUtil = (AggregationUtil) null\n\n        FormInstance(ScreenForm screenForm) {\n            this.screenForm = screenForm\n            ecfi = screenForm.ecfi\n            formNode = screenForm.getOrCreateFormNode()\n            isListForm = \"form-list\".equals(formNode.getName())\n\n            String serverStaticStr = formNode.attribute(\"server-static\")\n            if (serverStaticStr) serverStatic = new HashSet(Arrays.asList(serverStaticStr.split(\",\")))\n            else serverStatic = screenForm.sd.serverStatic\n\n            allFieldNodes = formNode.children(\"field\")\n            int afnSize = allFieldNodes.size()\n            allFieldNames = new ArrayList<>(afnSize)\n            if (isListForm) {\n                hiddenFieldList = new ArrayList<>()\n                hiddenFieldNameList = new ArrayList<>()\n                hiddenFieldNameSet = new HashSet<>()\n                hiddenHeaderFieldList = new ArrayList<>()\n                hiddenFirstRowFieldList = new ArrayList<>()\n                hiddenSecondRowFieldList = new ArrayList<>()\n                hiddenLastRowFieldList = new ArrayList<>()\n            }\n\n            // populate fieldNodeMap, get aggregation details\n            ArrayList<String> aggregateGroupFieldList = (ArrayList<String>) null\n\n            for (int i = 0; i < afnSize; i++) {\n                MNode fieldNode = (MNode) allFieldNodes.get(i)\n                String fieldName = fieldNode.attribute(\"name\")\n                fieldNodeMap.put(fieldName, fieldNode)\n                allFieldNames.add(fieldName)\n\n                if (isListForm) {\n                    if (isListFieldHiddenWidget(fieldNode)) {\n                        hiddenFieldList.add(fieldNode)\n                        if (!hiddenFieldNameSet.contains(fieldName)) {\n                            hiddenFieldNameList.add(fieldName)\n                            hiddenFieldNameSet.add(fieldName)\n                        }\n                    }\n                    MNode headerField = fieldNode.first(\"header-field\")\n                    if (headerField != null && headerField.hasChild(\"hidden\")) hiddenHeaderFieldList.add(fieldNode)\n                    MNode firstRowField = fieldNode.first(\"first-row-field\")\n                    if (firstRowField != null && firstRowField.hasChild(\"hidden\")) hiddenFirstRowFieldList.add(fieldNode)\n                    MNode secondRowField = fieldNode.first(\"second-row-field\")\n                    if (secondRowField != null && secondRowField.hasChild(\"hidden\")) hiddenSecondRowFieldList.add(fieldNode)\n                    MNode lastRowField = fieldNode.first(\"last-row-field\")\n                    if (lastRowField != null && lastRowField.hasChild(\"hidden\")) hiddenLastRowFieldList.add(fieldNode)\n\n                    if (fieldNode.attribute(\"hide\")) hasFieldHideAttrs = true\n\n                    String showTotal = fieldNode.attribute(\"show-total\")\n                    if (\"false\".equals(showTotal)) { showTotal = null } else if (\"true\".equals(showTotal)) { showTotal = \"sum\" }\n                    if (showTotal != null && !showTotal.isEmpty()) {\n                        if (showTotalFields == null) showTotalFields = new HashMap<>()\n                        showTotalFields.put(fieldName, showTotal)\n                    }\n\n                    String aggregate = fieldNode.attribute(\"aggregate\")\n                    if (aggregate != null && !aggregate.isEmpty()) {\n                        hasAggregate = true\n\n                        boolean isGroupBy = \"group-by\".equals(aggregate)\n                        boolean isSubList = !isGroupBy && \"sub-list\".equals(aggregate)\n                        AggregateFunction af = (AggregateFunction) null\n                        if (!isGroupBy && !isSubList) {\n                            af = AggregateFunction.valueOf(aggregate.toUpperCase())\n                            if (af == null) logger.error(\"Ignoring aggregate ${aggregate} on field ${fieldName} in form ${formNode.attribute('name')}, not a valid function, group-by, or sub-list\")\n                        }\n\n                        aggregateFieldMap.put(fieldName, new AggregateField(fieldName, af, isGroupBy, isSubList, showTotal,\n                                ecfi.resourceFacade.getGroovyClass(fieldNode.attribute(\"from\"))))\n                        if (isGroupBy) {\n                            if (aggregateGroupFieldList == null) aggregateGroupFieldList = new ArrayList<>()\n                            aggregateGroupFieldList.add(fieldName)\n                        }\n                    } else {\n                        aggregateFieldMap.put(fieldName, new AggregateField(fieldName, null, false, false, showTotal,\n                                ecfi.resourceFacade.getGroovyClass(fieldNode.attribute(\"from\"))))\n                    }\n                }\n            }\n\n            // check aggregate defs\n            if (hasAggregate) {\n                if (aggregateGroupFieldList == null) {\n                    throw new BaseArtifactException(\"Form ${formNode.attribute('name')} has aggregate fields but no group-by field, must have at least one\")\n                } else {\n                    // make group fields array\n                    int groupFieldSize = aggregateGroupFieldList.size()\n                    aggregateGroupFields = new String[groupFieldSize]\n                    for (int i = 0; i < groupFieldSize; i++) aggregateGroupFields[i] = (String) aggregateGroupFieldList.get(i)\n                }\n            }\n            // make AggregateField array for all fields\n            aggregateFields = new AggregateField[afnSize]\n            for (int i = 0; i < afnSize; i++) {\n                String fieldName = (String) allFieldNames.get(i)\n                AggregateField aggField = (AggregateField) aggregateFieldMap.get(fieldName)\n                if (aggField == null) {\n                    MNode fieldNode = fieldNodeMap.get(fieldName)\n                    aggField = new AggregateField(fieldName, null, false, false, showTotalFields?.get(fieldName),\n                            ecfi.resourceFacade.getGroovyClass(fieldNode.attribute(\"from\")))\n                }\n                aggregateFields[i] = aggField\n            }\n            aggregationUtil = new AggregationUtil(formNode.attribute(\"list\"), formNode.attribute(\"list-entry\"),\n                    aggregateFields, aggregateGroupFields, screenForm.rowActions)\n\n            // determine isUploadForm and isFormHeaderFormVal\n            isUploadForm = formNode.depthFirst({ MNode it -> \"file\".equals(it.name) }).size() > 0\n            for (MNode hfNode in formNode.depthFirst({ MNode it -> \"header-field\".equals(it.name) })) {\n                if (hfNode.children.size() > 0) { isFormHeaderFormVal = true; break } }\n            // determine hasFirstRow, isFormFirstRowFormVal, hasLastRow, isFormLastRowFormVal\n            for (MNode rfNode in formNode.depthFirst({ MNode it -> \"first-row-field\".equals(it.name) })) {\n                if (rfNode.children.size() > 0) { hasFirstRow = true; break } }\n            if (hasFirstRow && formNode.attribute(\"transition-first-row\")) isFormFirstRowFormVal = true\n            for (MNode rfNode in formNode.depthFirst({ MNode it -> \"second-row-field\".equals(it.name) })) {\n                if (rfNode.children.size() > 0) { hasSecondRow = true; break } }\n            if (hasSecondRow && formNode.attribute(\"transition-second-row\")) isFormSecondRowFormVal = true\n            for (MNode rfNode in formNode.depthFirst({ MNode it -> \"last-row-field\".equals(it.name) })) {\n                if (rfNode.children.size() > 0) { hasLastRow = true; break } }\n            if (hasLastRow && formNode.attribute(\"transition-last-row\")) isFormLastRowFormVal = true\n\n            // also populate fieldsInFormListColumns\n            if (isListForm) {\n                formListColInfoListMap = new HashMap<>()\n                // iterate through columns elements and populate for each type\n                for (MNode columnsNode in formNode.children(\"columns\")) {\n                    String type = columnsNode.attribute(\"type\")\n                    if (type != null && !type.isEmpty()) formListColInfoListMap.put(type, makeFormListColumnInfo(type))\n                }\n\n                // always populate for null (default) type\n                formListColInfoListMap.put(null, makeFormListColumnInfo(null))\n            }\n        }\n\n        MNode getFormNode() { formNode }\n        MNode getRowSelectionNode() { formNode.first(\"row-selection\") }\n        MNode getFieldNode(String fieldName) { fieldNodeMap.get(fieldName) }\n        String getFormLocation() { screenForm.location }\n        String getSavedFindFullLocation() { screenForm.getSavedFindFullLocation() }\n        FormListRenderInfo makeFormListRenderInfo() { new FormListRenderInfo(this) }\n        boolean isUpload() { isUploadForm }\n        boolean isList() { isListForm }\n        boolean isServerStatic(String renderMode) { return serverStatic != null && (serverStatic.contains('all') || serverStatic.contains(renderMode)) }\n\n        MNode getFieldValidateNode(MNode subFieldNode) {\n            MNode fieldNode = subFieldNode.getParent()\n            String fieldName = fieldNode.attribute(\"name\")\n            String validateService = subFieldNode.attribute('validate-service')\n            String validateEntity = subFieldNode.attribute('validate-entity')\n            if (validateService) {\n                ServiceDefinition sd = ecfi.serviceFacade.getServiceDefinition(validateService)\n                if (sd == null) throw new BaseArtifactException(\"Invalid validate-service name [${validateService}] in field [${fieldName}] of form [${screenForm.location}]\")\n                MNode parameterNode = sd.getInParameter((String) subFieldNode.attribute('validate-parameter') ?: fieldName)\n                return parameterNode\n            } else if (validateEntity) {\n                EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(validateEntity)\n                if (ed == null) throw new BaseArtifactException(\"Invalid validate-entity name [${validateEntity}] in field [${fieldName}] of form [${screenForm.location}]\")\n                MNode efNode = ed.getFieldNode((String) subFieldNode.attribute('validate-field') ?: fieldName)\n                return efNode\n            }\n            return null\n        }\n        String getFieldValidationClasses(MNode subFieldNode) {\n            MNode validateNode = getFieldValidateNode(subFieldNode)\n            if (validateNode == null) return \"\"\n\n            Set<String> vcs = new HashSet()\n            if (validateNode.name == \"parameter\") {\n                MNode parameterNode = validateNode\n                if (parameterNode.attribute('required') == \"true\") vcs.add(\"required\")\n                if (parameterNode.hasChild(\"number-integer\")) vcs.add(\"number\")\n                if (parameterNode.hasChild(\"number-decimal\")) vcs.add(\"number\")\n                if (parameterNode.hasChild(\"text-email\")) vcs.add(\"email\")\n                if (parameterNode.hasChild(\"text-url\")) vcs.add(\"url\")\n                if (parameterNode.hasChild(\"text-digits\")) vcs.add(\"digits\")\n                if (parameterNode.hasChild(\"credit-card\")) vcs.add(\"creditcard\")\n\n                String type = parameterNode.attribute('type')\n                if (type !=null && (type.endsWith(\"BigDecimal\") || type.endsWith(\"BigInteger\") || type.endsWith(\"Long\") ||\n                        type.endsWith(\"Integer\") || type.endsWith(\"Double\") || type.endsWith(\"Float\") ||\n                        type.endsWith(\"Number\"))) vcs.add(\"number\")\n            } else if (validateNode.name == \"field\") {\n                MNode fieldNode = validateNode\n                String type = fieldNode.attribute('type')\n                if (type != null && (type.startsWith(\"number-\") || type.startsWith(\"currency-\"))) vcs.add(\"number\")\n                // bad idea, for create forms with optional PK messes it up: if (fieldNode.\"@is-pk\" == \"true\") vcs.add(\"required\")\n            }\n\n            StringBuilder sb = new StringBuilder()\n            for (String vc in vcs) { if (sb) sb.append(\" \"); sb.append(vc); }\n            return sb.toString()\n        }\n        Map getFieldValidationRegexpInfo(MNode subFieldNode) {\n            MNode validateNode = getFieldValidateNode(subFieldNode)\n            if (validateNode?.hasChild(\"matches\")) {\n                MNode matchesNode = validateNode.first(\"matches\")\n                return [regexp:matchesNode.attribute('regexp'), message:matchesNode.attribute('message')]\n            }\n            return null\n        }\n\n        static String MSG_REQUIRED = \"Please enter a value\"\n        static String MSG_NUMBER = \"Please enter a valid number\"\n        static String MSG_NUMBER_INT = \"Please enter a valid whole number\"\n        static String MSG_DIGITS = \"Please enter only numbers (digits)\"\n        static String MSG_LETTERS = \"Please enter only letters\"\n        static String MSG_EMAIL = \"Please enter a valid email address\"\n        static String MSG_URL = \"Please enter a valid URL\"\n        static String VALIDATE_NUMBER = '!value||$root.moqui.isStringNumber(value)'\n        static String VALIDATE_NUMBER_INT = '!value||$root.moqui.isStringInteger(value)'\n        ArrayList<Map<String, String>> getFieldValidationJsRules(MNode subFieldNode) {\n            MNode validateNode = getFieldValidateNode(subFieldNode)\n            if (validateNode == null) return null\n\n            ExecutionContextImpl eci = ecfi.getEci()\n            ArrayList<Map<String, String>> ruleList = new ArrayList<>(5)\n            if (validateNode.name == \"parameter\") {\n                if (\"true\".equals(validateNode.attribute('required')))\n                    ruleList.add([expr:\"!!value\", message:eci.l10nFacade.localize(MSG_REQUIRED)])\n\n                boolean foundNumber = false\n                ArrayList<MNode> children = validateNode.getChildren()\n                int childrenSize = children.size()\n                for (int i = 0; i < childrenSize; i++) {\n                    MNode child = (MNode) children.get(i)\n                    if (\"number-integer\".equals(child.getName())) {\n                        if (!foundNumber) {\n                            ruleList.add([expr:VALIDATE_NUMBER_INT, message:eci.l10nFacade.localize(MSG_NUMBER_INT)])\n                            foundNumber = true\n                        }\n                    } else if (\"number-decimal\".equals(child.getName())) {\n                        if (!foundNumber) {\n                            ruleList.add([expr:VALIDATE_NUMBER, message:eci.l10nFacade.localize(MSG_NUMBER)])\n                            foundNumber = true\n                        }\n                    } else if (\"text-digits\".equals(child.getName())) {\n                        if (!foundNumber) {\n                            ruleList.add([expr:'!value || /^\\\\d*$/.test(value)', message:eci.l10nFacade.localize(MSG_DIGITS)])\n                            foundNumber = true\n                        }\n                    } else if (\"text-letters\".equals(child.getName())) {\n                        // TODO: how to handle UTF-8 letters?\n                        ruleList.add([expr:'!value || /^[a-zA-Z]*$/.test(value)', message:eci.l10nFacade.localize(MSG_LETTERS)])\n                    } else if (\"text-email\".equals(child.getName())) {\n                        // from https://emailregex.com/ - could be looser/simpler for this purpose\n                        ruleList.add([expr:'!value || /^(([^<>()\\\\[\\\\]\\\\\\\\.,;:\\\\s@\"]+(\\\\.[^<>()\\\\[\\\\]\\\\\\\\.,;:\\\\s@\"]+)*)|(\".+\"))@((\\\\[[0-9]{1,3}\\\\.[0-9]{1,3}\\\\.[0-9]{1,3}\\\\.[0-9]{1,3}])|(([a-zA-Z\\\\-0-9]+\\\\.)+[a-zA-Z]{2,}))$/.test(value)',\n                                message:eci.l10nFacade.localize(MSG_EMAIL)])\n                    } else if (\"text-url\".equals(child.getName())) {\n                        // from https://urlregex.com/ - could be looser/simpler for this purpose\n                        ruleList.add([expr:'!value || /((([A-Za-z]{3,9}:(?:\\\\/\\\\/)?)(?:[\\\\-;:&=\\\\+\\\\$,\\\\w]+@)?[A-Za-z0-9\\\\.\\\\-]+|(?:www\\\\.|[\\\\-;:&=\\\\+\\\\$,\\\\w]+@)[A-Za-z0-9\\\\.\\\\-]+)((?:\\\\/[\\\\+~%\\\\/\\\\.\\\\w\\\\-_]*)?\\\\??(?:[\\\\-\\\\+=&;%@\\\\.\\\\w_]*)#?(?:[\\\\.\\\\!\\\\/\\\\\\\\\\\\w]*))?)/.test(value)',\n                                message:eci.l10nFacade.localize(MSG_URL)])\n                    } else if (\"matches\".equals(child.getName())) {\n                        ruleList.add([expr:'!value || /' + child.attribute(\"regexp\") + '/.test(value)',\n                                message:eci.l10nFacade.localize(child.attribute(\"message\"))])\n                    } else if (\"number-range\".equals(child.getName())) {\n                        String minStr = child.attribute(\"min\")\n                        String maxStr = child.attribute(\"max\")\n                        boolean minEquals = !\"false\".equals(child.attribute(\"min-include-equals\"))\n                        boolean maxEquals = \"true\".equals(child.attribute(\"max-include-equals\"))\n                        String message = child.attribute(\"message\")\n                        if (message == null || message.isEmpty()) {\n                            if (minStr && maxStr) message = \"Enter a number between ${minStr} and ${maxStr}\"\n                            else if (minStr) message = \"Enter a number greater than ${minStr}\"\n                            else if (maxStr) message = \"Enter a number less than ${maxStr}\"\n                        }\n                        String compareStr = \"\";\n                        if (minStr) compareStr += ' && $root.moqui.parseNumber(value) ' + (minEquals ? '>= ' : '> ') + minStr\n                        if (maxStr) compareStr += ' && $root.moqui.parseNumber(value) ' + (maxEquals ? '<= ' : '< ') + maxStr\n                        ruleList.add([expr:'!value || (!Number.isNaN($root.moqui.parseNumber(value))' + compareStr + ')', message:message])\n                    }\n                }\n\n                // TODO: val-or, val-and, val-not\n                // TODO: text-letters, time-range\n                // TODO: credit-card with types?\n\n                // fallback to type attribute for numbers\n                String type = validateNode.attribute('type')\n                if (!foundNumber && type != null) {\n                    if (type.endsWith(\"BigInteger\") || type.endsWith(\"Long\") || type.endsWith(\"Integer\")) {\n                        ruleList.add([expr:VALIDATE_NUMBER_INT, message:eci.l10nFacade.localize(MSG_NUMBER_INT)])\n                    } else if (type.endsWith(\"BigDecimal\") || type.endsWith(\"Double\") || type.endsWith(\"Float\") || type.endsWith(\"Number\")) {\n                        ruleList.add([expr:VALIDATE_NUMBER, message:eci.l10nFacade.localize(MSG_NUMBER)])\n                    }\n                }\n            } else if (validateNode.name == \"field\") {\n                String type = validateNode.attribute('type')\n                if (type != null && (type.startsWith(\"number-\") || type.startsWith(\"currency-\"))) {\n                    if (type.endsWith(\"integer\")) {\n                        ruleList.add([expr:VALIDATE_NUMBER_INT, message:eci.l10nFacade.localize(MSG_NUMBER_INT)])\n                    } else {\n                        ruleList.add([expr:VALIDATE_NUMBER, message:eci.l10nFacade.localize(MSG_NUMBER)])\n                    }\n                }\n                // bad idea, for create forms with optional PK messes it up: if (fieldNode.\"@is-pk\" == \"true\") vcs.add(\"required\")\n            }\n            return ruleList.size() > 0 ? ruleList : null\n        }\n\n        ArrayList<MNode> getFieldLayoutNonReferencedFieldList() {\n            if (nonReferencedFieldList != null) return nonReferencedFieldList\n            ArrayList<MNode> fieldList = new ArrayList<>()\n\n            if (formNode.hasChild(\"field-layout\")) for (MNode fieldNode in formNode.children(\"field\")) {\n                MNode fieldLayoutNode = formNode.first(\"field-layout\")\n                String fieldName = fieldNode.attribute(\"name\")\n                if (!fieldLayoutNode.depthFirst({ MNode it -> it.name == \"field-ref\" && it.attribute(\"name\") == fieldName }))\n                    fieldList.add(fieldNodeMap.get(fieldName))\n            }\n\n            nonReferencedFieldList = fieldList\n            return fieldList\n        }\n\n        static boolean isHeaderSubmitField(MNode fieldNode) {\n            MNode headerField = fieldNode.first(\"header-field\")\n            if (headerField == null) return false\n            return headerField.hasChild(\"submit\")\n        }\n\n        boolean isListFieldHiddenAttr(MNode fieldNode) {\n            String hideAttr = fieldNode.attribute(\"hide\")\n            if (hideAttr != null && hideAttr.length() > 0) {\n                return ecfi.getEci().resource.condition(hideAttr, \"\")\n            }\n            return false\n        }\n        static boolean isListFieldHiddenWidget(MNode fieldNode) {\n            // if default-field or any conditional-field don't have hidden or ignored elements then it's not hidden\n            MNode defaultField = fieldNode.first(\"default-field\")\n            if (defaultField != null && !defaultField.hasChild(\"hidden\") && !defaultField.hasChild(\"ignored\")) return false\n            List<MNode> condFieldList = fieldNode.children(\"conditional-field\")\n            for (MNode condField in condFieldList) if (!condField.hasChild(\"hidden\") && !condField.hasChild(\"ignored\")) return false\n            return true\n        }\n\n        ArrayList<MNode> getListHiddenFieldList() { return hiddenFieldList }\n        ArrayList<String> getListHiddenFieldNameList() { return hiddenFieldNameList }\n        Set<String> getListHiddenFieldNameSet() { return hiddenFieldNameSet }\n        boolean hasFormListColumns() { return formNode.hasChild(\"form-list-column\") || formNode.hasChild(\"columns\") }\n\n        String getUserActiveFormConfigId(ExecutionContext ec) {\n            String columnsType = ecfi.getEci().contextStack.getByString(\"_uiType\")\n            if (columnsType != null && columnsType.isEmpty()) columnsType = null\n            if (columnsType != null) {\n                // look up Enumeration record by enumCode (_uiType value) and enumTypeId to get enumId\n                String configTypeEnumId = ecfi.entityFacade.find(\"moqui.basic.Enumeration\")\n                        .condition(\"enumTypeId\", \"FormConfigType\").condition(\"enumCode\", columnsType)\n                        .useCache(true).one()?.get(\"enumId\")\n                if (configTypeEnumId != null) {\n                    EntityValue fcut = ecfi.entityFacade.fastFindOne(\"moqui.screen.form.FormConfigUserType\", true,\n                            false, screenForm.location, ec.user.userId, configTypeEnumId)\n                    if (fcut != null) return (String) fcut.getNoCheckSimple(\"formConfigId\")\n                }\n                // if a columnsType is specified and there is no matching saved FormConfig then don't default to saved general config,\n                //     defer to screen def columns config by type or screen def default columns config, so return null\n                return null\n            }\n\n            EntityValue fcu = ecfi.entityFacade.fastFindOne(\"moqui.screen.form.FormConfigUser\", true,\n                    false, screenForm.location, ec.user.userId)\n            if (fcu != null) return (String) fcu.getNoCheckSimple(\"formConfigId\")\n\n            // Maybe not do this at all and let it be a future thing where the user selects an active one from options available through groups\n            EntityList fcugvList = ecfi.entityFacade.find(\"moqui.screen.form.FormConfigUserGroupView\")\n                    .condition(\"userGroupId\", EntityCondition.IN, ec.user.userGroupIdSet)\n                    .condition(\"formLocation\", screenForm.location).useCache(true).list()\n            if (fcugvList.size() > 0) {\n                // FUTURE: somehow make a better choice than just the first? see note above too...\n                return (String) fcugvList.get(0).getNoCheckSimple(\"formConfigId\")\n            }\n\n            return null\n        }\n        EntityValue getActiveFormListFind(ExecutionContextImpl ec) {\n            String formListFindId = (String) ec.contextStack.get(\"formListFindId\")\n            if (formListFindId == null || formListFindId.isEmpty()) return null\n\n            EntityValue formListFind = ec.entityFacade.fastFindOne(\"moqui.screen.form.FormListFind\", true, false, formListFindId)\n            // see if this applies to this form-list, may be multiple on the screen\n            String fullLocation = screenForm.getSavedFindFullLocation()\n            if (formListFind != null && fullLocation != formListFind.get(\"formLocation\")) formListFind = null\n            return formListFind\n        }\n\n        ArrayList<ArrayList<MNode>> getFormListColumnInfo() {\n            ExecutionContextImpl eci = ecfi.getEci()\n            String formConfigId = (String) null\n            EntityValue activeFormListFind = getActiveFormListFind(eci)\n            if (activeFormListFind != null) formConfigId = activeFormListFind.getNoCheckSimple(\"formConfigId\")\n            if (formConfigId == null || formConfigId.isEmpty()) formConfigId = getUserActiveFormConfigId(eci)\n            if (formConfigId != null && !formConfigId.isEmpty()) {\n                // don't remember the results of this, is per-user so good only once (FormInstance is NOT per user!)\n                return makeDbFormListColumnInfo(formConfigId, eci)\n            }\n            String columnsType = ecfi.getEci().contextStack.getByString(\"_uiType\")\n            if (columnsType != null && columnsType.isEmpty()) columnsType == null\n            if (formListColInfoListMap.containsKey(columnsType)) {\n                return formListColInfoListMap.get(columnsType)\n            } else {\n                return formListColInfoListMap.get(null)\n            }\n        }\n        /** convert form-list-column elements into a list, if there are no form-list-column elements uses fields limiting\n         *    by logic about what actually gets rendered (so result can be used for display regardless of form def) */\n        private ArrayList<ArrayList<MNode>> makeFormListColumnInfo(String columnsType) {\n            ArrayList<MNode> formListColumnList = (ArrayList<MNode>) null\n\n            ArrayList<MNode> columnsNodeList = formNode.children(\"columns\")\n            int columnsNodesSize = columnsNodeList.size()\n            // look for matching columns by specified type first\n            if (columnsType != null && !columnsType.isEmpty()) {\n                for (int i = 0; i < columnsNodesSize; i++) {\n                    MNode columnsNode = (MNode) columnsNodeList.get(i)\n                    if (columnsType.equals(columnsNode.attribute(\"type\"))) {\n                        formListColumnList = columnsNode.children(\"column\")\n                        break\n                    }\n                }\n            }\n            // if nothing found (or no columnsType) look for columns with no type\n            if (formListColumnList == null) {\n                for (int i = 0; i < columnsNodesSize; i++) {\n                    MNode columnsNode = (MNode) columnsNodeList.get(i)\n                    String type = columnsNode.attribute(\"type\")\n                    if (type == null || type.isEmpty()) {\n                        formListColumnList = columnsNode.children(\"column\")\n                        break\n                    }\n                }\n            }\n\n            // default to old form-list-column elements\n            if (formListColumnList == null) formListColumnList = formNode.children(\"form-list-column\")\n\n            int flcListSize = formListColumnList != null ? formListColumnList.size() : 0\n\n            ArrayList<ArrayList<MNode>> colInfoList = new ArrayList<>()\n\n            if (flcListSize > 0) {\n                // populate fields under columns\n                for (int ci = 0; ci < flcListSize; ci++) {\n                    MNode flcNode = (MNode) formListColumnList.get(ci)\n                    ArrayList<MNode> colFieldNodes = new ArrayList<>()\n                    ArrayList<MNode> fieldRefNodes = flcNode.children(\"field-ref\")\n                    int fieldRefSize = fieldRefNodes.size()\n                    for (int fi = 0; fi < fieldRefSize; fi++) {\n                        MNode frNode = (MNode) fieldRefNodes.get(fi)\n                        String fieldName = frNode.attribute(\"name\")\n                        MNode fieldNode = (MNode) fieldNodeMap.get(fieldName)\n                        if (fieldNode == null) throw new BaseArtifactException(\"Could not find field ${fieldName} referenced in form-list-column.field-ref in form at ${screenForm.location}\")\n                        // skip hidden fields, they are handled separately\n                        if (isListFieldHiddenWidget(fieldNode)) continue\n\n                        colFieldNodes.add(fieldNode)\n                    }\n                    if (colFieldNodes.size() > 0) colInfoList.add(colFieldNodes)\n                }\n            } else {\n                // create a column for each displayed field\n                int afnSize = allFieldNodes.size()\n                for (int i = 0; i < afnSize; i++) {\n                    MNode fieldNode = (MNode) allFieldNodes.get(i)\n                    // skip hidden fields, they are handled separately\n                    if (isListFieldHiddenWidget(fieldNode)) continue\n\n                    ArrayList<MNode> singleFieldColList = new ArrayList<>()\n                    singleFieldColList.add(fieldNode)\n                    colInfoList.add(singleFieldColList)\n                }\n            }\n\n            return colInfoList\n        }\n        private ArrayList<ArrayList<MNode>> makeDbFormListColumnInfo(String formConfigId, ExecutionContextImpl eci) {\n            EntityList formConfigFieldList = ecfi.entityFacade.find(\"moqui.screen.form.FormConfigField\")\n                    .condition(\"formConfigId\", formConfigId).orderBy(\"positionIndex\").orderBy(\"positionSequence\").useCache(true).list()\n\n            // NOTE: calling code checks to see if this is not empty\n            int fcfListSize = formConfigFieldList.size()\n\n            ArrayList<ArrayList<MNode>> colInfoList = new ArrayList<>()\n            Set<String> tempFieldsInFormListColumns = new HashSet()\n\n            // populate fields under columns\n            int curColIndex = -1;\n            ArrayList<MNode> colFieldNodes = null\n            for (int ci = 0; ci < fcfListSize; ci++) {\n                EntityValue fcfValue = (EntityValue) formConfigFieldList.get(ci)\n                int columnIndex = fcfValue.getNoCheckSimple(\"positionIndex\") as int\n                if (columnIndex > curColIndex) {\n                    if (colFieldNodes != null && colFieldNodes.size() > 0) colInfoList.add(colFieldNodes)\n                    curColIndex = columnIndex\n                    colFieldNodes = new ArrayList<>()\n                }\n                String fieldName = (String) fcfValue.getNoCheckSimple(\"fieldName\")\n                MNode fieldNode = (MNode) fieldNodeMap.get(fieldName)\n                if (fieldNode == null) {\n                    //throw new BaseArtifactException(\"Could not find field ${fieldName} referenced in FormConfigField record for ID ${fcfValue.formConfigId} user ${eci.user.userId}, form at ${screenForm.location}\")\n                    logger.warn(\"Could not find field ${fieldName} referenced in FormConfigField record for ID ${fcfValue.formConfigId} user ${eci.user.userId}, form at ${screenForm.location}. removing it\")\n                    fcfValue.delete()\n                    continue\n                }\n                // skip hidden fields, they are handled separately\n                if (isListFieldHiddenWidget(fieldNode)) continue\n\n                tempFieldsInFormListColumns.add(fieldName)\n                colFieldNodes.add(fieldNode)\n            }\n            // Add the final field (if defined)\n            if (colFieldNodes != null && colFieldNodes.size() > 0) colInfoList.add(colFieldNodes)\n\n            return colInfoList\n        }\n\n        ArrayList<EntityValue> makeFormListFindFields(String formListFindId, ExecutionContext ec) {\n            ContextStack cs = ec.context\n\n            Set<String> skipSet = null\n            MNode entityFindNode = screenForm.entityFindNode\n            if (entityFindNode != null) {\n                MNode sfiNode = entityFindNode.first(\"search-form-inputs\")\n                String skipFields = sfiNode?.attribute(\"skip-fields\")\n                if (skipFields != null && !skipFields.isEmpty())\n                    skipSet = new HashSet<>(Arrays.asList(skipFields.split(\",\")).collect({ it.trim() }))\n            }\n\n            List<EntityValue> valueList = new ArrayList<>()\n            for (MNode fieldNode in allFieldNodes) {\n                // skip submit\n                if (isHeaderSubmitField(fieldNode)) continue\n\n                String fn = fieldNode.attribute(\"name\")\n                if (skipSet != null && skipSet.contains(fn)) continue\n\n                if (cs.containsKey(fn) || cs.containsKey(fn + \"_op\")) {\n                    // this will handle text-line, text-find, etc\n                    Object value = cs.get(fn)\n                    if (value != null && ObjectUtilities.isEmpty(value)) value = null\n                    String op = cs.get(fn + \"_op\") ?: \"equals\"\n                    boolean not = (cs.get(fn + \"_not\") == \"Y\" || cs.get(fn + \"_not\") == \"true\")\n                    boolean ic = (cs.get(fn + \"_ic\") == \"Y\" || cs.get(fn + \"_ic\") == \"true\")\n\n                    // for all operators other than empty skip this if there is no value\n                    if (value == null && op != \"empty\") continue\n\n                    EntityValue ev = ec.entity.makeValue(\"moqui.screen.form.FormListFindField\")\n                    ev.formListFindId = formListFindId\n                    ev.fieldName = fn\n                    ev.fieldValue = value\n                    ev.fieldOperator = op\n                    ev.fieldNot = not ? \"Y\" : \"N\"\n                    ev.fieldIgnoreCase = ic ? \"Y\" : \"N\"\n                    valueList.add(ev)\n                } else if (cs.get(fn + \"_period\")) {\n                    EntityValue ev = ec.entity.makeValue(\"moqui.screen.form.FormListFindField\")\n                    ev.formListFindId = formListFindId\n                    ev.fieldName = fn\n                    ev.fieldPeriod = cs.get(fn + \"_period\")\n                    ev.fieldPerOffset = (cs.get(fn + \"_poffset\") ?: \"0\") as Long\n                    valueList.add(ev)\n                } else {\n                    // these will handle range-find and date-find\n                    String fromValue = ObjectUtilities.toPlainString(cs.get(fn + \"_from\"))\n                    String thruValue = ObjectUtilities.toPlainString(cs.get(fn + \"_thru\"))\n                    if (fromValue || thruValue) {\n                        EntityValue ev = ec.entity.makeValue(\"moqui.screen.form.FormListFindField\")\n                        ev.formListFindId = formListFindId\n                        ev.fieldName = fn\n                        ev.fieldFrom = fromValue\n                        ev.fieldThru = thruValue\n                        valueList.add(ev)\n                    }\n                }\n            }\n            /* always look for an orderByField parameter too\n            String orderByString = cs?.get(\"orderByField\") ?: defaultOrderBy\n            if (orderByString != null && orderByString.length() > 0) {\n                ec.context.put(\"orderByField\", orderByString)\n                this.orderBy(orderByString)\n            }\n            */\n            return valueList\n        }\n    }\n    @CompileStatic\n    static class FormListRenderInfo {\n        private final FormInstance formInstance\n        private final ScreenForm screenForm\n        private ExecutionContextFactoryImpl ecfi\n        private ArrayList<ArrayList<MNode>> allColInfo\n        private ArrayList<ArrayList<MNode>> mainColInfo = (ArrayList<ArrayList<MNode>>) null\n        private ArrayList<ArrayList<MNode>> subColInfo = (ArrayList<ArrayList<MNode>>) null\n        private LinkedHashSet<String> displayedFieldSet\n\n        FormListRenderInfo(FormInstance formInstance) {\n            this.formInstance = formInstance\n            screenForm = formInstance.screenForm\n            ecfi = formInstance.ecfi\n\n            // NOTE: this can be different for each form rendering depending on user settings\n            allColInfo = formInstance.getFormListColumnInfo()\n            if (formInstance.hasFieldHideAttrs) {\n                int tempAciSize = allColInfo.size()\n                ArrayList<ArrayList<MNode>> newColInfo = new ArrayList<>(tempAciSize)\n                for (int oi = 0; oi < tempAciSize; oi++) {\n                    ArrayList<MNode> innerList = (ArrayList<MNode>) allColInfo.get(oi)\n                    if (innerList == null) continue\n                    int innerSize = innerList.size()\n                    ArrayList<MNode> newInnerList = new ArrayList<>(innerSize)\n                    for (int ii = 0; ii < innerSize; ii++) {\n                        MNode fieldNode = (MNode) innerList.get(ii)\n                        if (!formInstance.isListFieldHiddenAttr(fieldNode)) newInnerList.add(fieldNode)\n                    }\n                    if (newInnerList.size() > 0) newColInfo.add(newInnerList)\n                }\n                allColInfo = newColInfo\n            }\n\n            // make a set of fields actually displayed\n            displayedFieldSet = new LinkedHashSet<>()\n            int outerSize = allColInfo.size()\n            for (int oi = 0; oi < outerSize; oi++) {\n                ArrayList<MNode> innerList = (ArrayList<MNode>) allColInfo.get(oi)\n                if (innerList == null) { logger.warn(\"Null column field list at index ${oi} in form ${screenForm.location}\"); continue }\n                int innerSize = innerList.size()\n                for (int ii = 0; ii < innerSize; ii++) {\n                    MNode fieldNode = (MNode) innerList.get(ii)\n                    if (fieldNode != null) displayedFieldSet.add(fieldNode.attribute(\"name\"))\n                }\n            }\n\n            if (formInstance.hasAggregate) {\n                subColInfo = new ArrayList<>()\n                int flciSize = allColInfo.size()\n                mainColInfo = new ArrayList<>(flciSize)\n                for (int i = 0; i < flciSize; i++) {\n                    ArrayList<MNode> fieldList = (ArrayList<MNode>) allColInfo.get(i)\n                    ArrayList<MNode> newFieldList = new ArrayList<>()\n                    ArrayList<MNode> subFieldList = (ArrayList<MNode>) null\n                    int fieldListSize = fieldList.size()\n                    for (int fi = 0; fi < fieldListSize; fi++) {\n                        MNode fieldNode = (MNode) fieldList.get(fi)\n                        String fieldName = fieldNode.attribute(\"name\")\n                        AggregateField aggField = formInstance.aggregateFieldMap.get(fieldName)\n                        if (aggField != null && aggField.subList) {\n                            if (subFieldList == null) subFieldList = new ArrayList<>()\n                            subFieldList.add(fieldNode)\n                        } else {\n                            newFieldList.add(fieldNode)\n                        }\n                    }\n                    // if fieldList is not empty add to tempFormListColInfo\n                    if (newFieldList.size() > 0) mainColInfo.add(newFieldList)\n                    if (subFieldList != null) subColInfo.add(subFieldList)\n                }\n            }\n        }\n\n        MNode getFormNode() { return formInstance.formNode }\n        MNode getFieldNode(String fieldName) { return formInstance.fieldNodeMap.get(fieldName) }\n\n        boolean isHeaderForm() { return formInstance.isFormHeaderFormVal }\n        boolean isFirstRowForm() { return formInstance.isFormFirstRowFormVal }\n        boolean isSecondRowForm() { return formInstance.isFormSecondRowFormVal }\n        boolean isLastRowForm() { return formInstance.isFormLastRowFormVal }\n        boolean hasFirstRow() { return formInstance.hasFirstRow }\n        boolean hasSecondRow() { return formInstance.hasSecondRow }\n        boolean hasLastRow() { return formInstance.hasLastRow }\n        String getFormLocation() { return screenForm.location }\n        String getSavedFindFullLocation() { return screenForm.getSavedFindFullLocation() }\n        List<Map<String, Object>> getUserFormListFinds(ExecutionContextImpl ec) { return screenForm.getUserFormListFinds(ec) }\n        String getUserDefaultFormListFindId(ExecutionContextImpl ec) { return screenForm.getUserDefaultFormListFindId(ec) }\n\n        FormInstance getFormInstance() { return formInstance }\n        ScreenForm getScreenForm() { return screenForm }\n        ArrayList<ArrayList<MNode>> getAllColInfo() { return allColInfo }\n        ArrayList<ArrayList<MNode>> getMainColInfo() { return mainColInfo ?: allColInfo }\n        ArrayList<ArrayList<MNode>> getSubColInfo() { return subColInfo }\n        ArrayList<MNode> getListHiddenFieldList() { return formInstance.getListHiddenFieldList() }\n        ArrayList<MNode> getListHeaderHiddenFieldList() { return formInstance.hiddenHeaderFieldList }\n        ArrayList<MNode> getListFirstRowHiddenFieldList() { return formInstance.hiddenFirstRowFieldList }\n        ArrayList<MNode> getListSecondRowHiddenFieldList() { return formInstance.hiddenSecondRowFieldList }\n        ArrayList<MNode> getListLastRowHiddenFieldList() { return formInstance.hiddenLastRowFieldList }\n        LinkedHashSet<String> getDisplayedFields() { return displayedFieldSet }\n\n        ArrayList<Map<String, Object>> getListObject(boolean aggregateList) {\n            ContextStack context = ecfi.getEci().contextStack\n\n            Object listObject\n            String listName = formInstance.formNode.attribute(\"list\")\n            Set<String> includeFields = new HashSet<>(displayedFieldSet)\n            MNode entityFindNode = screenForm.entityFindNode\n            if (entityFindNode != null) {\n                EntityFindBase ef = (EntityFindBase) ecfi.entityFacade.find(entityFindNode)\n\n                // don't do this, use explicit select-field fields plus display/hidden fields: if (ef.getSelectFields() == null || ef.getSelectFields().size() == 0) {\n                // always do this even if there are some entity-find.select-field elements, support specifying some fields that are always selected\n                for (String fieldName in displayedFieldSet) ef.selectField(fieldName)\n                List<String> selFields = ef.getSelectFields()\n                // don't order by fields not in displayedFieldSet\n                ArrayList<String> orderByFields = ef.orderByFields\n                if (orderByFields != null) for (int i = 0; i < orderByFields.size(); ) {\n                    String obfString = (String) orderByFields.get(i)\n                    EntityJavaUtil.FieldOrderOptions foo = EntityJavaUtil.makeFieldOrderOptions(obfString)\n                    if (displayedFieldSet.contains(foo.fieldName) || selFields.contains(foo.fieldName)) {\n                        i++\n                    } else {\n                        orderByFields.remove(i)\n                    }\n                }\n                // always select hidden fields\n                ArrayList<String> hiddenNames = formInstance.getListHiddenFieldNameList()\n                int hiddenNamesSize = hiddenNames.size()\n                for (int i = 0; i < hiddenNamesSize; i++) {\n                    String fn = (String) hiddenNames.get(i)\n                    MNode fieldNode = formInstance.getFieldNode(fn)\n                    if (!fieldNode.hasChild(\"default-field\")) continue\n                    ef.selectField(fn)\n                    includeFields.add(fn)\n                }\n\n                // logger.warn(\"TOREMOVE form-list.entity-find: ${ef.toString()}\\ndisplayedFieldSet: ${displayedFieldSet}\")\n\n                // run the query\n                EntityList efList = ef.list()\n                // if cached do the date filter after query\n                boolean useCache = ef.shouldCache()\n                if (useCache) for (MNode df in entityFindNode.children(\"date-filter\")) {\n                    Timestamp validDate = (Timestamp) null\n                    String validDateAttr = df.attribute(\"valid-date\")\n                    if (validDateAttr != null && !validDateAttr.isEmpty()) validDate = ecfi.resourceFacade.expression(validDateAttr, \"\") as Timestamp\n                    efList.filterByDate(df.attribute(\"from-field-name\") ?: \"fromDate\", df.attribute(\"thru-field-name\") ?: \"thruDate\",\n                            validDate, \"true\".equals(df.attribute(\"ignore-if-empty\")))\n                }\n\n                // put in context for external use\n                context.put(listName, efList)\n                context.put(listName.concat(\"_xafind\"), ef)\n\n                // handle pagination, etc parameters like XML Actions entity-find\n                MNode sfiNode = entityFindNode.first(\"search-form-inputs\")\n                boolean doPaginate = sfiNode != null && !\"false\".equals(sfiNode.attribute(\"paginate\"))\n                if (doPaginate) {\n                    long count, pageSize, pageIndex\n                    if (ef.getLimit() == null) {\n                        count = efList.size()\n                        pageSize = count > 20 ? count : 20\n                        pageIndex = efList.getPageIndex()\n                    } else if (useCache) {\n                        count = efList.size()\n                        efList.filterByLimit(sfiNode.attribute(\"input-fields-map\"), true)\n                        pageSize = efList.getPageSize()\n                        pageIndex = efList.getPageIndex()\n                    } else {\n                        pageIndex = ef.pageIndex\n                        pageSize = ef.pageSize\n                        // this can be expensive, only get count if efList size is equal to pageSize (can skip if no paginate needed)\n                        if (efList.size() < pageSize) count = efList.size() + pageSize * pageIndex\n                        else count = ef.count()\n                    }\n                    long maxIndex = (new BigDecimal(count-1)).divide(new BigDecimal(pageSize), 0, RoundingMode.DOWN).longValue()\n                    long pageRangeLow = (pageIndex * pageSize) + 1\n                    long pageRangeHigh = (pageIndex * pageSize) + pageSize\n                    if (pageRangeHigh > count) pageRangeHigh = count\n                    // logger.info(\"count ${count} pageSize ${pageSize} maxIndex ${maxIndex} pageRangeLow ${pageRangeLow} pageRangeHigh ${pageRangeHigh}\")\n\n                    context.put(listName.concat(\"Count\"), count)\n                    context.put(listName.concat(\"PageIndex\"), pageIndex)\n                    context.put(listName.concat(\"PageSize\"), pageSize)\n                    context.put(listName.concat(\"PageMaxIndex\"), maxIndex)\n                    context.put(listName.concat(\"PageRangeLow\"), pageRangeLow)\n                    context.put(listName.concat(\"PageRangeHigh\"), pageRangeHigh)\n                }\n\n                listObject = efList\n            } else {\n                listObject = ecfi.resourceFacade.expression(listName, \"\")\n            }\n\n            // NOTE: always call AggregationUtil.aggregateList, passing aggregateList to tell it to do sub-lists or not\n            // this does the pre-processing for all form-list renders, handles row-actions, field.@from, etc\n            ArrayList<Map<String, Object>> aggList = formInstance.aggregationUtil.aggregateList(listObject, includeFields, aggregateList, ecfi.getEci())\n\n            // set _formListRendered and _formListResultCount so code running later on knows what happened during the screen render\n            context.getSharedMap().put(\"_formListRendered\", true)\n            int aggListSize = aggList.size()\n            Object curResultCount = context.getSharedMap().get(\"_formListResultCount\")\n            if (curResultCount instanceof Number) aggListSize += ((Number) curResultCount).intValue()\n            context.getSharedMap().put(\"_formListResultCount\", aggListSize)\n\n            return aggList\n        }\n\n        String getOrderByActualJsString(String originalOrderBy) {\n            if (originalOrderBy == null || originalOrderBy.length() == 0) return \"\";\n            // strip square braces if there are any\n            if (originalOrderBy.startsWith(\"[\")) originalOrderBy = originalOrderBy.substring(1, originalOrderBy.length() - 1)\n            originalOrderBy = originalOrderBy.replace(\" \", \"\")\n            List<String> orderByList = Arrays.asList(originalOrderBy.split(\",\"))\n            StringBuilder sb = new StringBuilder()\n            for (String obf in orderByList) {\n                if (sb.length() > 0) sb.append(\",\")\n                EntityJavaUtil.FieldOrderOptions foo = EntityJavaUtil.makeFieldOrderOptions(obf)\n                MNode curFieldNode = formInstance.fieldNodeMap.get(foo.getFieldName())\n                if (curFieldNode == null) continue\n                MNode headerFieldNode = curFieldNode.first(\"header-field\")\n                if (headerFieldNode == null) continue\n                String showOrderBy = headerFieldNode.attribute(\"show-order-by\")\n                sb.append(\"'\").append(foo.descending ? \"-\" : \"\")\n                if (\"case-insensitive\".equals(showOrderBy)) sb.append(\"^\")\n                sb.append(foo.getFieldName()).append(\"'\")\n            }\n            if (sb.length() == 0) return \"\"\n            return \"[\" + sb.toString() + \"]\"\n        }\n\n        ArrayList<MNode> getFieldsNotReferencedInFormListColumn() {\n            ArrayList<MNode> colFieldNodes = new ArrayList<>()\n            ArrayList<MNode> allFieldNodes = formInstance.allFieldNodes\n            int afnSize = allFieldNodes.size()\n            for (int i = 0; i < afnSize; i++) {\n                MNode fieldNode = (MNode) allFieldNodes.get(i)\n                // skip hidden fields, they are handled separately\n                if (formInstance.isListFieldHiddenWidget(fieldNode) ||\n                        (formInstance.hasFieldHideAttrs && formInstance.isListFieldHiddenAttr(fieldNode))) continue\n                String fieldName = fieldNode.attribute(\"name\")\n                if (!displayedFieldSet.contains(fieldName)) colFieldNodes.add(formInstance.fieldNodeMap.get(fieldName))\n            }\n\n            return colFieldNodes\n        }\n\n        ArrayList<Integer> getFormListColumnCharWidths(int originalLineWidth) {\n            int numCols = allColInfo.size()\n            ArrayList<Integer> charWidths = new ArrayList<>(numCols)\n            for (int i = 0; i < numCols; i++) charWidths.add(null)\n            if (originalLineWidth == 0) originalLineWidth = 132\n            int lineWidth = originalLineWidth\n            // leave room for 1 space between each column\n            lineWidth -= (numCols - 1)\n\n            // set fixed column widths and get a total of fixed columns, remaining characters to be split among percent width cols\n            ArrayList<BigDecimal> percentWidths = new ArrayList<>(numCols)\n            for (int i = 0; i < numCols; i++) percentWidths.add(null)\n            int fixedColsWidth = 0\n            int fixedColsCount = 0\n            for (int i = 0; i < numCols; i++) {\n                ArrayList<MNode> colNodes = (ArrayList<MNode>) allColInfo.get(i)\n                int charWidth = -1\n                BigDecimal percentWidth = null\n                for (int j = 0; j < colNodes.size(); j++) {\n                    MNode fieldNode = (MNode) colNodes.get(j)\n                    String pwAttr = fieldNode.attribute(\"print-width\")\n                    if (pwAttr == null || pwAttr.isEmpty()) continue\n                    BigDecimal curWidth = new BigDecimal(pwAttr)\n                    if (curWidth == BigDecimal.ZERO) {\n                        charWidth = 0\n                        // no separator char needed for columns not displayed so add back to lineWidth\n                        lineWidth++\n                        continue\n                    }\n                    if (\"characters\".equals(fieldNode.attribute(\"print-width-type\"))) {\n                        if (curWidth.intValue() > charWidth) charWidth = curWidth.intValue()\n                    } else {\n                        if (percentWidth == null || curWidth > percentWidth) percentWidth = curWidth\n                    }\n                }\n                if (charWidth >= 0) {\n                    if (percentWidth != null) {\n                        // if we have char and percent widths, calculate effective chars of percent width and if greater use that\n                        int percentChars = ((percentWidth / 100) * lineWidth).intValue()\n                        if (percentChars < charWidth) {\n                            charWidths.set(i, charWidth)\n                            fixedColsWidth += charWidth\n                            fixedColsCount++\n                        } else {\n                            percentWidths.set(i, percentWidth)\n                        }\n                    } else {\n                        charWidths.set(i, charWidth)\n                        fixedColsWidth += charWidth\n                        fixedColsCount++\n                    }\n                } else {\n                    if (percentWidth != null) percentWidths.set(i, percentWidth)\n                }\n            }\n\n            // now we have all fixed widths, calculate and set percent widths\n            int widthForPercentCols = lineWidth - fixedColsWidth\n            if (widthForPercentCols < 0) throw new BaseArtifactException(\"In form ${screenForm.formName} fixed width columns exceeded total line characters ${originalLineWidth} by ${-widthForPercentCols} characters\")\n            int percentColsCount = numCols - fixedColsCount\n\n            // scale column percents to 100, fill in missing\n            BigDecimal percentTotal = 0\n            for (int i = 0; i < numCols; i++) {\n                BigDecimal colPercent = (BigDecimal) percentWidths.get(i)\n                if (colPercent == null) {\n                    if (charWidths.get(i) != null) continue\n                    BigDecimal percentWidth = (1 / percentColsCount) * 100\n                    percentWidths.set(i, percentWidth)\n                    percentTotal += percentWidth\n                } else {\n                    percentTotal += colPercent\n                }\n            }\n            int percentColsUsed = 0\n            BigDecimal percentScale = 100 / percentTotal\n            for (int i = 0; i < numCols; i++) {\n                BigDecimal colPercent = (BigDecimal) percentWidths.get(i)\n                if (colPercent == null) continue\n                BigDecimal actualPercent = colPercent * percentScale\n                percentWidths.set(i, actualPercent)\n                int percentChars = ((actualPercent / 100.0) * widthForPercentCols).setScale(0, RoundingMode.HALF_EVEN).intValue()\n                charWidths.set(i, percentChars)\n                percentColsUsed += percentChars\n            }\n\n            // adjust for over/underflow\n            if (percentColsUsed != widthForPercentCols) {\n                int diffRemaining = widthForPercentCols - percentColsUsed\n                int diffPerCol = (diffRemaining / percentColsCount).setScale(0, RoundingMode.UP).intValue()\n                for (int i = 0; i < numCols; i++) {\n                    if (percentWidths.get(i) == null) continue\n                    Integer curChars = charWidths.get(i)\n                    int adjustAmount = Math.abs(diffRemaining) > Math.abs(diffPerCol) ? diffPerCol : diffRemaining\n                    int newChars = curChars + adjustAmount\n                    if (newChars > 0) {\n                        charWidths.set(i, newChars)\n                        diffRemaining -= adjustAmount\n                        if (diffRemaining == 0) break\n                    }\n                }\n            }\n\n            logger.info(\"Text mode form-list: numCols=${numCols}, percentColsUsed=${percentColsUsed}, widthForPercentCols=${widthForPercentCols}, percentColsCount=${percentColsCount}\\npercentWidths: ${percentWidths}\\ncharWidths: ${charWidths}\")\n            return charWidths\n        }\n    }\n\n    static Map<String, String> makeFormListFindParameters(String formListFindId, ExecutionContext ec) {\n        EntityList flffList = ec.entity.find(\"moqui.screen.form.FormListFindField\")\n                .condition(\"formListFindId\", formListFindId).useCache(true).disableAuthz().list()\n\n        Map<String, String> parmMap = new LinkedHashMap<>()\n        parmMap.put(\"formListFindId\", formListFindId)\n\n        int flffSize = flffList.size()\n        for (int i = 0; i < flffSize; i++) {\n            EntityValue flff = (EntityValue) flffList.get(i)\n            String fn = (String) flff.getNoCheckSimple(\"fieldName\")\n            String fieldValue = (String) flff.getNoCheckSimple(\"fieldValue\")\n            if (fieldValue != null && !fieldValue.isEmpty()) {\n                parmMap.put(fn, fieldValue)\n                String op = (String) flff.getNoCheckSimple(\"fieldOperator\")\n                if (op && !\"equals\".equals(op)) parmMap.put(fn + \"_op\", op)\n                String not = (String) flff.getNoCheckSimple(\"fieldNot\")\n                if (\"Y\".equals(not)) parmMap.put(fn + \"_not\", \"Y\")\n                String ic = (String) flff.getNoCheckSimple(\"fieldIgnoreCase\")\n                if (\"Y\".equals(ic)) parmMap.put(fn + \"_ic\", \"Y\")\n            } else if (flff.getNoCheckSimple(\"fieldPeriod\")) {\n                parmMap.put(fn + \"_period\", (String) flff.getNoCheckSimple(\"fieldPeriod\"))\n                parmMap.put(fn + \"_poffset\", flff.getNoCheckSimple(\"fieldPerOffset\") as String)\n            } else if (flff.getNoCheckSimple(\"fieldFrom\") || flff.getNoCheckSimple(\"fieldThru\")) {\n                if (flff.fieldFrom) parmMap.put(fn + \"_from\", (String) flff.getNoCheckSimple(\"fieldFrom\"))\n                if (flff.fieldThru) parmMap.put(fn + \"_thru\", (String) flff.getNoCheckSimple(\"fieldThru\"))\n            }\n        }\n        return parmMap\n    }\n\n    static EntityValue getFormListFindScreenScheduled(String formListFindId, ExecutionContextImpl ec) {\n        EntityList screenScheduledList = ec.entityFacade.find(\"moqui.screen.ScreenScheduled\")\n                .condition(\"formListFindId\", formListFindId).condition(\"userId\", ec.userFacade.userId)\n                .orderBy(\"-screenScheduledId\").useCache(true).disableAuthz().list()\n        if (screenScheduledList.size() == 0) {\n            Set<String> userGroupIdSet = ec.userFacade.getUserGroupIdSet()\n            screenScheduledList = ec.entityFacade.find(\"moqui.screen.ScreenScheduled\")\n                    .condition(\"formListFindId\", formListFindId).condition(\"userGroupId\", \"in\", userGroupIdSet)\n                    .orderBy(\"-screenScheduledId\").useCache(true).disableAuthz().list()\n        }\n\n        return screenScheduledList.getFirst()\n    }\n\n    static Map<String, Object> getFormListFindInfo(String formListFindId, ExecutionContextImpl ec, Set<String> userOnlyFlfIdSet) {\n        EntityValue formListFind = ec.entityFacade.fastFindOne(\"moqui.screen.form.FormListFind\", true, true, formListFindId)\n        Map<String, String> flfParameters = makeFormListFindParameters(formListFindId, ec)\n        flfParameters.put(\"formListFindId\", formListFindId)\n        if (formListFind.orderByField) flfParameters.put(\"orderByField\", (String) formListFind.orderByField)\n        return [description:formListFind.description, formListFind:formListFind, findParameters:flfParameters,\n                isByUserId:(userOnlyFlfIdSet?.contains(formListFindId) ? \"true\" : \"false\")]\n    }\n\n    static String processFormSavedFind(ExecutionContextImpl ec) {\n        // disable authz to allow saved finds for users with view only authz\n        ec.artifactExecutionFacade.disableAuthz()\n        try {\n            return processFormSavedFindInternal(ec)\n        } finally {\n            ec.artifactExecutionFacade.enableAuthz()\n        }\n    }\n    static String processFormSavedFindInternal(ExecutionContextImpl ec) {\n        String userId = ec.userFacade.userId\n        ContextStack cs = ec.contextStack\n\n        String formListFindId = (String) cs.getByString(\"formListFindId\")\n        EntityValue flf = formListFindId != null && !formListFindId.isEmpty() ? ec.entity.find(\"moqui.screen.form.FormListFind\")\n                .condition(\"formListFindId\", formListFindId).useCache(false).one() : null\n\n        if (cs.containsKey(\"DeleteFind\")) {\n            if (flf == null) { ec.messageFacade.addError(\"Saved find with ID ${formListFindId} not found, not deleting\"); return null }\n\n            // delete FormListFindUserDefault that reference this formListFindId for this user\n            ec.entity.find(\"moqui.screen.form.FormListFindUserDefault\").condition(\"userId\", userId)\n                    .condition(\"formListFindId\", formListFindId).deleteAll()\n\n            // delete FormListFindUser record; if there are no other FormListFindUser records or FormListFindUserGroup\n            //     records, delete the FormListFind\n            EntityValue flfu = ec.entity.find(\"moqui.screen.form.FormListFindUser\").condition(\"userId\", userId)\n                    .condition(\"formListFindId\", formListFindId).useCache(false).one()\n            // NOTE: if no FormListFindUser nothing to delete... consider removing form from all groups the user is in? best not to, affects other users especially for ALL_USERS\n            if (flfu == null) return null\n            flfu.delete()\n\n            long userCount = ec.entity.find(\"moqui.screen.form.FormListFindUser\")\n                    .condition(\"formListFindId\", formListFindId).useCache(false).count()\n            if (userCount == 0L) {\n                long groupCount = ec.entity.find(\"moqui.screen.form.FormListFindUserGroup\")\n                        .condition(\"formListFindId\", formListFindId).useCache(false).count()\n                if (groupCount == 0L) {\n                    ec.entity.find(\"moqui.screen.form.FormListFindField\")\n                            .condition(\"formListFindId\", formListFindId).deleteAll()\n                    ec.entity.find(\"moqui.screen.form.FormListFind\")\n                            .condition(\"formListFindId\", formListFindId).deleteAll()\n                }\n            }\n            return null\n        }\n\n        if (cs.containsKey(\"ScheduleFind\")) {\n            if (flf == null) { ec.messageFacade.addError(\"Saved find with ID ${formListFindId} not found, not scheduling\"); return null }\n            if (!userId) { ec.messageFacade.addError(\"No user logged in, not scheduling saved find with ID ${formListFindId}\"); return formListFindId }\n\n            String renderMode = (String) cs.getByString(\"renderMode\") ?: \"csv\"\n            String screenPath = (String) cs.getByString(\"screenPath\")\n            String cronSelected = (String) cs.getByString(\"cronSelected\")\n\n            if (!screenPath) { ec.messageFacade.addError(\"Screen Path not specified, not scheduling saved find with ID ${formListFindId}\"); return formListFindId }\n            if (!cronSelected) { ec.messageFacade.addError(\"Cron Schedule not specified, not scheduling saved find with ID ${formListFindId}\"); return formListFindId }\n\n            String emailSubject = flf.getString(\"description\") + ' ${ec.l10n.format(ec.user.nowTimestamp, null)}'\n\n            Map<String, Object> screenScheduledMap = [screenPath:screenPath, formListFindId:formListFindId, renderMode:renderMode,\n                    noResultsAbort:\"Y\", cronExpression:cronSelected, emailTemplateId:\"SCREEN_RENDER\", emailSubject:emailSubject,\n                    userId:userId] as Map<String, Object>\n            ec.serviceFacade.sync().name(\"create#moqui.screen.ScreenScheduled\").parameters(screenScheduledMap).disableAuthz().call()\n\n            ec.messageFacade.addMessage(\"Saved find scheduled to send by email\")\n\n            return formListFindId\n        }\n\n        if (cs.containsKey(\"ClearDefault\")) {\n            ec.entity.find(\"moqui.screen.form.FormListFindUserDefault\").condition(\"userId\", userId)\n                    .condition(\"formListFindId\", formListFindId).deleteAll()\n            return null\n        }\n\n        String formLocation = cs.getByString(\"formLocation\")\n        if (!formLocation) { ec.message.addError(\"No form location specified, cannot process saved find\"); return null; }\n\n        // example location: component://SimpleScreens/screen/SimpleScreens/Accounting/Reports/InvoiceAgingDetail.xml.form_list$InvoiceAgingList\n        // example location with extension: component://SimpleScreens/screen/SimpleScreens/Accounting/Reports/InvoiceAgingDetail.xml.form_list$InvoiceAgingList#123456\n        // pull out location extension if there is a '#' in the location (optional)\n        String formLocationTemp = formLocation\n        int lastHashIndex = formLocationTemp.lastIndexOf('#')\n        String locationExtension = null\n        if (lastHashIndex > 0) {\n            locationExtension = formLocationTemp.substring(lastHashIndex + 1)\n            formLocationTemp = formLocationTemp.substring(0, lastHashIndex)\n        }\n        // save this to the context for FormInstance init which for dynamic=true is called per form-instance and uses this for auto-fields-service or auto-fields-entity\n        if (locationExtension) cs.put(\"formLocationExtension\", locationExtension)\n\n        // separate formName and screenLocation\n        int lastDotIndex = formLocationTemp.lastIndexOf(\".\")\n        if (lastDotIndex < 0) { ec.message.addError(\"Form location invalid, cannot process saved find\"); return null; }\n        String screenLocation = formLocationTemp.substring(0, lastDotIndex)\n        int lastDollarIndex = formLocationTemp.lastIndexOf('$')\n        if (lastDollarIndex < 0) { ec.message.addError(\"Form location invalid, cannot process saved find\"); return null; }\n        String formName = formLocationTemp.substring(lastDollarIndex + 1)\n\n        ScreenDefinition screenDef = ec.screenFacade.getScreenDefinition(screenLocation)\n        if (screenDef == null) { ec.message.addError(\"Screen not found at ${screenLocation}, cannot process saved find\"); return null; }\n\n        // MakeDefault needs the screenLocation, do here just after validated\n        if (cs.containsKey(\"MakeDefault\")) {\n            if (flf == null) { ec.messageFacade.addError(\"Saved find with ID ${formListFindId} not found, not making default\"); return null }\n            // FUTURE: consider some sort of check to make sure associated with user or a group user is in? is it a big deal?\n\n            EntityValue curUserDefault = ec.entityFacade.find(\"moqui.screen.form.FormListFindUserDefault\")\n                    .condition(\"userId\", userId).condition(\"screenLocation\", screenLocation).one()\n            if (curUserDefault == null) {\n                ec.entityFacade.makeValue(\"moqui.screen.form.FormListFindUserDefault\").set(\"userId\", userId)\n                        .set(\"screenLocation\", screenLocation).set(\"formListFindId\", formListFindId).create()\n            } else {\n                curUserDefault.set(\"formListFindId\", formListFindId)\n                curUserDefault.update()\n            }\n\n            return null\n        }\n\n        ScreenForm screenForm = screenDef.getForm(formName)\n        if (screenForm == null) { ec.message.addError(\"Form ${formName} not found in screen at ${screenLocation}, cannot process saved find\"); return null; }\n        FormInstance formInstance = screenForm.getFormInstance()\n\n        String formConfigId = formInstance.getUserActiveFormConfigId(ec)\n        if ((formConfigId == null || formConfigId.isEmpty()) && flf != null) formConfigId = flf.formConfigId\n        EntityList formConfigFieldList = null\n        if (formConfigId != null && !formConfigId.isEmpty()) {\n            formConfigFieldList = ec.entityFacade.find(\"moqui.screen.form.FormConfigField\")\n                    .condition(\"formConfigId\", formConfigId).useCache(true).list()\n        }\n\n        // see if there is an existing FormListFind record\n        if (flf != null) {\n            // make sure the FormListFind.formLocation matches the current formLocation\n            if (!formLocation.equals(flf.getNoCheckSimple(\"formLocation\"))) {\n                ec.message.addError(\"Specified form location did not match form on Saved Find ${formListFindId}, not updating\")\n                return null\n            }\n\n            // make sure the user or group the user is in is associated with the FormListFind\n            EntityValue flfu = ec.entity.find(\"moqui.screen.form.FormListFindUser\").condition(\"userId\", userId)\n                    .condition(\"formListFindId\", formListFindId).useCache(false).one()\n            if (flfu == null) {\n                long groupCount = ec.entity.find(\"moqui.screen.form.FormListFindUserGroup\")\n                        .condition(\"userGroupId\", EntityCondition.IN, ec.user.userGroupIdSet)\n                        .condition(\"formListFindId\", formListFindId).useCache(false).count()\n                if (groupCount == 0L) {\n                    ec.message.addError(\"You are not associated with Saved Find ${formListFindId}, cannot update\")\n                    return formListFindId\n                }\n                // is associated with a group but we want to only update for a user, so treat this as if it is not based on existing\n                flf = null\n                formListFindId = null\n            }\n        }\n\n        if (flf != null) {\n            // save the FormConfig fields if needed, create a new FormConfig for the FormListFind or removing existing as needed\n            if (formConfigFieldList != null && formConfigFieldList.size() > 0) {\n                String flfFormConfigId = (String) flf.getNoCheckSimple(\"formConfigId\")\n                if (flfFormConfigId != null && !flfFormConfigId.isEmpty()) {\n                    ec.entity.find(\"moqui.screen.form.FormConfigField\").condition(\"formConfigId\", flfFormConfigId).deleteAll()\n                } else {\n                    EntityValue formConfig = ec.entity.makeValue(\"moqui.screen.form.FormConfig\").set(\"formLocation\", formLocation)\n                            .setSequencedIdPrimary().create()\n                    flfFormConfigId = (String) formConfig.getNoCheckSimple(\"formConfigId\")\n                    flf.formConfigId = flfFormConfigId\n                }\n                for (EntityValue fcf in formConfigFieldList) fcf.cloneValue().set(\"formConfigId\", flfFormConfigId).create()\n            } else {\n                // clear previous FormConfig\n                String flfFormConfigId = (String) flf.getNoCheckSimple(\"formConfigId\")\n                flf.formConfigId = null\n                if (flfFormConfigId != null && !flfFormConfigId.isEmpty())\n                    ec.entity.find(\"moqui.screen.form.FormConfigField\").condition(\"formConfigId\", flfFormConfigId).deleteAll()\n                ec.entity.find(\"moqui.screen.form.FormConfigField\").condition(\"formConfigId\", flfFormConfigId).deleteAll()\n            }\n\n            if (cs._findDescription) flf.description = cs._findDescription\n            if (cs.orderByField) flf.orderByField = cs.orderByField\n            if (flf.isModified()) flf.update()\n\n            // remove all FormListFindField records and create new ones\n            ec.entity.find(\"moqui.screen.form.FormListFindField\").condition(\"formListFindId\", formListFindId).deleteAll()\n            ArrayList<EntityValue> flffList = formInstance.makeFormListFindFields(formListFindId, ec)\n            for (EntityValue flff in flffList) flff.create()\n        } else {\n            // if there are FormConfig fields save in a new FormConfig first so we can set the formConfigId later\n            EntityValue formConfig = null\n            if (formConfigFieldList != null && formConfigFieldList.size() > 0) {\n                formConfig = ec.entity.makeValue(\"moqui.screen.form.FormConfig\").set(\"formLocation\", formLocation)\n                        .setSequencedIdPrimary().create()\n                for (EntityValue fcf in formConfigFieldList) fcf.cloneValue().set(\"formConfigId\", formConfig.formConfigId).create()\n            }\n\n            flf = ec.entity.makeValue(\"moqui.screen.form.FormListFind\")\n            flf.formLocation = formLocation\n            flf.description = cs._findDescription ?: \"${ec.user.username} - ${ec.l10n.format(ec.user.nowTimestamp, \"yyyy-MM-dd HH:mm\")}\"\n            if (cs.orderByField) flf.orderByField = cs.orderByField\n            if (formConfig != null) flf.formConfigId = formConfig.formConfigId\n            flf.setSequencedIdPrimary()\n            flf.create()\n\n            formListFindId = (String) flf.formListFindId\n\n            EntityValue flfu = ec.entity.makeValue(\"moqui.screen.form.FormListFindUser\")\n            flfu.formListFindId = formListFindId\n            flfu.userId = userId\n            flfu.create()\n\n            ArrayList<EntityValue> flffList = formInstance.makeFormListFindFields(formListFindId, ec)\n            for (EntityValue flff in flffList) flff.create()\n        }\n\n        return formListFindId\n    }\n\n    static void saveFormConfig(ExecutionContextImpl ec) {\n        String userId = ec.userFacade.userId\n        ContextStack cs = ec.contextStack\n        String formLocation = cs.get(\"formLocation\")\n        if (!formLocation) { ec.messageFacade.addError(\"No form location specified, cannot save form configuration\"); return; }\n\n        // get formConfigId\n        String formConfigId = cs.get(\"formConfigId\")\n        // get configTypeEnumId\n        String configTypeEnumId = ec.contextStack.getByString(\"configTypeEnumId\")\n        if (configTypeEnumId != null && configTypeEnumId.isEmpty()) configTypeEnumId = null\n        if (configTypeEnumId == null) {\n            String columnsType = ec.contextStack.getByString(\"_uiType\")\n            if (columnsType != null && columnsType.isEmpty()) columnsType = null\n            if (columnsType != null) {\n                // look up Enumeration record by enumCode (_uiType value) and enumTypeId to get enumId\n                configTypeEnumId = ec.entityFacade.find(\"moqui.basic.Enumeration\")\n                        .condition(\"enumTypeId\", \"FormConfigType\").condition(\"enumCode\", columnsType)\n                        .useCache(true).one()?.get(\"enumId\")\n            }\n        }\n        // logger.warn(\"formConfigId ${formConfigId} configTypeEnumId ${configTypeEnumId}\")\n\n        // see if there is an existing FormConfig record\n        if (formConfigId == null || formConfigId.isEmpty()) {\n            // if configTypeEnumId then use with FormConfigUserType, else use FormConfigUser to defer to screen def columns\n            //     config by type or screen def default columns config\n            if (configTypeEnumId != null) {\n                EntityValue fcut = ec.entityFacade.fastFindOne(\"moqui.screen.form.FormConfigUserType\", true,\n                        false, formLocation, userId, configTypeEnumId)\n                formConfigId = (String) fcut?.getNoCheckSimple(\"formConfigId\")\n            } else {\n                EntityValue fcu = ec.entity.find(\"moqui.screen.form.FormConfigUser\")\n                        .condition(\"userId\", userId).condition(\"formLocation\", formLocation).useCache(false).one()\n                formConfigId = (String) fcu?.getNoCheckSimple(\"formConfigId\")\n            }\n        }\n        String userCurrentFormConfigId = formConfigId\n\n        // if FormConfig associated with this user but no other users or groups delete its FormConfigField\n        //     records and remember its ID for create FormConfigField\n        if (formConfigId) {\n            long userCount = configTypeEnumId != null ?\n                    ec.entity.find(\"moqui.screen.form.FormConfigUserType\").condition(\"formConfigId\", formConfigId)\n                            .condition(\"configTypeEnumId\", configTypeEnumId).useCache(false).count() :\n                    ec.entity.find(\"moqui.screen.form.FormConfigUser\").condition(\"formConfigId\", formConfigId)\n                            .useCache(false).count()\n            if (userCount > 1) {\n                formConfigId = null\n            } else {\n                long groupCount = ec.entity.find(\"moqui.screen.form.FormConfigUserGroup\")\n                        .condition(\"formConfigId\", formConfigId).useCache(false).count()\n                if (groupCount > 0) formConfigId = null\n            }\n        }\n\n        // clear out existing records\n        if (formConfigId) {\n            ec.entity.find(\"moqui.screen.form.FormConfigField\").condition(\"formConfigId\", formConfigId).deleteAll()\n        }\n\n        // are we resetting columns?\n        if (cs.get(\"ResetColumns\")) {\n            if (formConfigId) {\n                // no other users on this form, and now being reset, so delete FormConfig\n                ec.entity.find(\"moqui.screen.form.FormConfigUser\").condition(\"formConfigId\", formConfigId).deleteAll()\n                ec.entity.find(\"moqui.screen.form.FormConfigUserType\").condition(\"formConfigId\", formConfigId).deleteAll()\n                ec.entity.find(\"moqui.screen.form.FormConfig\").condition(\"formConfigId\", formConfigId).deleteAll()\n            } else if (userCurrentFormConfigId) {\n                // there is a FormConfig but other users are using it, so just remove this user\n                ec.entity.find(\"moqui.screen.form.FormConfigUser\").condition(\"formConfigId\", userCurrentFormConfigId)\n                        .condition(\"userId\", userId).deleteAll()\n                ec.entity.find(\"moqui.screen.form.FormConfigUserType\").condition(\"formConfigId\", userCurrentFormConfigId)\n                        .condition(\"userId\", userId).deleteAll()\n            }\n            // to reset columns don't save new ones, just return after clearing out existing records\n            return\n        }\n\n        // if there is no FormConfig or found record is associated with other users or groups\n        //     create a new FormConfig record to use\n        if (!formConfigId) {\n            Map createResult = ec.service.sync().name(\"create#moqui.screen.form.FormConfig\")\n                    .parameters([userId:userId, formLocation:formLocation, description:\"For user ${userId}\"]).call()\n            formConfigId = createResult.formConfigId\n            if (configTypeEnumId != null) {\n                ec.service.sync().name(\"create#moqui.screen.form.FormConfigUserType\")\n                        .parameters([formConfigId:formConfigId, userId:userId, formLocation:formLocation, configTypeEnumId:configTypeEnumId]).call()\n            } else {\n                ec.service.sync().name(\"create#moqui.screen.form.FormConfigUser\")\n                        .parameters([formConfigId:formConfigId, userId:userId, formLocation:formLocation]).call()\n            }\n        }\n\n        // save changes to DB\n        String columnsTreeStr = cs.get(\"columnsTree\") as String\n        // logger.info(\"columnsTreeStr: ${columnsTreeStr}\")\n        // if columnsTree empty there were no changes\n        if (!columnsTreeStr) return\n\n        JsonSlurper slurper = new JsonSlurper()\n        List<Map> columnsTree = (List<Map>) slurper.parseText(columnsTreeStr)\n        CollectionUtilities.orderMapList(columnsTree, ['order'])\n\n        int columnIndex = 0\n        for (Map columnMap in (List<Map>) columnsTree) {\n            if (columnMap.get(\"id\") == \"hidden\") continue\n            List<Map> children = (List<Map>) columnMap.get(\"children\")\n            CollectionUtilities.orderMapList(children, ['order'])\n\n            int columnSequence = 0\n            for (Map fieldMap in (List<Map>) children) {\n                String fieldName = (String) fieldMap.get(\"id\")\n                // logger.info(\"Adding field ${fieldName} to column ${columnIndex} at sequence ${columnSequence}\")\n                ec.service.sync().name(\"create#moqui.screen.form.FormConfigField\")\n                        .parameters([formConfigId:formConfigId, fieldName:fieldName,\n                                     positionIndex:columnIndex, positionSequence:columnSequence]).call()\n                columnSequence++\n            }\n            columnIndex++\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/screen/ScreenRenderImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.screen\n\nimport freemarker.template.Template\n\nimport groovy.json.JsonOutput\nimport groovy.json.JsonSlurper\nimport groovy.transform.CompileStatic\n\nimport jakarta.servlet.http.HttpServletRequest\nimport jakarta.servlet.http.HttpServletResponse\n\nimport org.moqui.BaseArtifactException\nimport org.moqui.BaseException\nimport org.moqui.context.*\nimport org.moqui.context.MessageFacade.MessageInfo\nimport org.moqui.entity.EntityCondition.ComparisonOperator\nimport org.moqui.entity.EntityException\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityListIterator\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.context.ArtifactExecutionInfoImpl\nimport org.moqui.impl.context.ContextJavaUtil\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.impl.context.ResourceFacadeImpl\nimport org.moqui.impl.context.WebFacadeImpl\nimport org.moqui.impl.entity.EntityDefinition\nimport org.moqui.impl.entity.EntityFacadeImpl\nimport org.moqui.impl.entity.EntityValueBase\nimport org.moqui.impl.screen.ScreenDefinition.ResponseItem\nimport org.moqui.impl.screen.ScreenDefinition.SubscreensItem\nimport org.moqui.impl.screen.ScreenForm.FormInstance\nimport org.moqui.impl.screen.ScreenUrlInfo.UrlInstance\nimport org.moqui.resource.ResourceReference\nimport org.moqui.screen.ScreenRender\nimport org.moqui.screen.ScreenTest\nimport org.moqui.util.ContextStack\nimport org.moqui.util.MNode\nimport org.moqui.util.ObjectUtilities\nimport org.moqui.util.StringUtilities\nimport org.moqui.util.WebUtilities\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n@CompileStatic\nclass ScreenRenderImpl implements ScreenRender {\n    protected final static Logger logger = LoggerFactory.getLogger(ScreenRenderImpl.class)\n    protected final static boolean isTraceEnabled = logger.isTraceEnabled()\n\n    public final ScreenFacadeImpl sfi\n    public final ExecutionContextImpl ec\n    protected boolean rendering = false\n\n    protected String rootScreenLocation = (String) null\n    protected ScreenDefinition rootScreenDef = (ScreenDefinition) null\n    protected ScreenDefinition overrideActiveScreenDef = (ScreenDefinition) null\n\n    protected ArrayList<String> originalScreenPathNameList = new ArrayList<String>()\n    protected ScreenUrlInfo screenUrlInfo = (ScreenUrlInfo) null\n    protected UrlInstance screenUrlInstance = (UrlInstance) null\n    protected Map<String, ScreenUrlInfo> subscreenUrlInfos = new HashMap()\n    protected int screenPathIndex = 0\n    protected Set<String> stopRenderScreenLocations = new HashSet()\n    protected String lastStandalone = (String) null\n\n    protected String baseLinkUrl = (String) null\n    protected String servletContextPath = (String) null\n    protected String webappName = (String) null\n\n    protected String renderMode = (String) null\n    protected String characterEncoding = \"UTF-8\"\n    /** For HttpServletRequest/Response renders this will be set on the response either as this default or a value\n     * determined during render, especially for screen sub-content based on the extension of the filename. */\n    protected String outputContentType = (String) null\n\n    protected String macroTemplateLocation = (String) null\n    protected Boolean boundaryComments = (Boolean) null\n\n    protected HttpServletRequest request = (HttpServletRequest) null\n    protected HttpServletResponse response = (HttpServletResponse) null\n    protected Writer internalWriter = (Writer) null\n    protected Writer afterScreenWriter = (Writer) null\n    protected Writer scriptWriter = (Writer) null\n    protected OutputStream internalOutputStream = (OutputStream) null\n\n    protected boolean dontDoRender = false\n    protected boolean saveHistory = false\n\n    protected Map<String, FormInstance> screenFormCache = new HashMap<>()\n    protected String curThemeId = (String) null\n    protected Map<String, ArrayList<String>> curThemeValuesByType = new HashMap<>()\n\n    ScreenRenderImpl(ScreenFacadeImpl sfi) {\n        this.sfi = sfi\n        ec = sfi.ecfi.getEci()\n    }\n\n    Writer getWriter() {\n        if (internalWriter != null) return internalWriter\n        if (internalOutputStream != null) {\n            if (characterEncoding == null || characterEncoding.length() == 0) characterEncoding = \"UTF-8\"\n            internalWriter = new OutputStreamWriter(internalOutputStream, characterEncoding)\n            return internalWriter\n        }\n        if (response != null) {\n            internalWriter = response.getWriter()\n            return internalWriter\n        }\n        throw new BaseArtifactException(\"Could not render screen, no writer available\")\n    }\n\n    OutputStream getOutputStream() {\n        if (internalOutputStream != null) return internalOutputStream\n        if (response != null) {\n            internalOutputStream = response.getOutputStream()\n            return internalOutputStream\n        }\n        throw new BaseArtifactException(\"Could not render screen, no output stream available\")\n    }\n\n    ScreenUrlInfo getScreenUrlInfo() { return screenUrlInfo }\n    UrlInstance getScreenUrlInstance() { return screenUrlInstance }\n\n    @Override ScreenRender rootScreen(String rsLocation) { rootScreenLocation = rsLocation; return this }\n    ScreenRender rootScreenFromHost(String host) { return rootScreen(sfi.rootScreenFromHost(host, webappName)) }\n\n    @Override ScreenRender screenPath(List<String> screenNameList) { originalScreenPathNameList.addAll(screenNameList); return this }\n    @Override ScreenRender screenPath(String path) { screenPath(StringUtilities.pathStringToList(path, 0)); return this }\n    @Override ScreenRender lastStandalone(String ls) { lastStandalone = ls; return this }\n\n    @Override ScreenRender renderMode(String renderMode) { this.renderMode = renderMode; return this }\n    String getRenderMode() { return renderMode }\n\n    @Override ScreenRender encoding(String characterEncoding) { this.characterEncoding = characterEncoding;  return this }\n    @Override ScreenRender macroTemplate(String mtl) { this.macroTemplateLocation = mtl; return this }\n    @Override ScreenRender baseLinkUrl(String blu) { this.baseLinkUrl = blu; return this }\n    @Override ScreenRender servletContextPath(String scp) { this.servletContextPath = scp; return this }\n    @Override ScreenRender webappName(String wan) { this.webappName = wan; return this }\n    @Override ScreenRender saveHistory(boolean sh) { this.saveHistory = sh; return this }\n\n    @Override\n    void render(HttpServletRequest request, HttpServletResponse response) {\n        if (rendering) throw new IllegalStateException(\"This screen render has already been used\")\n        rendering = true\n        this.request = request\n        this.response = response\n        // NOTE: don't get the writer at this point, we don't yet know if we're writing text or binary\n        if (webappName == null || webappName.length() == 0) webappName = request.servletContext.getInitParameter(\"moqui-name\")\n        if (webappName != null && webappName.length() > 0 && (rootScreenLocation == null || rootScreenLocation.length() == 0))\n            rootScreenFromHost(request.getServerName())\n        if (originalScreenPathNameList == null || originalScreenPathNameList.size() == 0) {\n            ArrayList<String> pathList = ec.web.getPathInfoList()\n            screenPath(pathList)\n        }\n        if (servletContextPath == null || servletContextPath.isEmpty())\n            servletContextPath = request.getServletContext()?.getContextPath()\n\n        // now render\n        internalRender()\n    }\n\n    @Override\n    void render(Writer writer) {\n        if (rendering) throw new IllegalStateException(\"This screen render has already been used\")\n        rendering = true\n        internalWriter = writer\n        internalRender()\n    }\n\n    @Override\n    void render(OutputStream os) {\n        if (rendering) throw new IllegalStateException(\"This screen render has already been used\")\n        rendering = true\n        internalOutputStream = os\n        internalRender()\n    }\n\n    @Override\n    String render() {\n        if (rendering) throw new IllegalStateException(\"This screen render has already been used\")\n        rendering = true\n        internalWriter = new StringWriter()\n        internalRender()\n        return internalWriter.toString()\n    }\n\n    /** this should be called as part of a always-actions or pre-actions block to stop rendering before it starts */\n    void sendRedirectAndStopRender(String redirectUrl) {\n        if (response != null) {\n            if (servletContextPath != null && !servletContextPath.isEmpty() && redirectUrl.startsWith(\"/\"))\n                redirectUrl = servletContextPath + redirectUrl\n\n            MNode stoNode = sfi.ecfi.getConfXmlRoot().first(\"screen-facade\")\n                    .first(\"screen-text-output\", \"type\", renderMode)\n            if (stoNode != null && \"true\".equals(stoNode.attribute(\"always-standalone\"))) {\n                if (logger.isInfoEnabled()) logger.info(\"Redirecting with 205 and X-Redirect-To ${redirectUrl} instead of rendering ${this.getScreenUrlInfo().getFullPathNameList()}\")\n                response.setHeader(\"X-Redirect-To\", redirectUrl)\n                // use code 205 (Reset Content) for client router handled redirect\n                response.setStatus(HttpServletResponse.SC_RESET_CONTENT)\n            } else {\n                if (logger.isInfoEnabled()) logger.info(\"Redirecting to ${redirectUrl} instead of rendering ${this.getScreenUrlInfo().getFullPathNameList()}\")\n                // add Cache-Control: no-store header since this is often in actions after screen render has started and a Cache-Control header has been set, so replace it here\n                response.setHeader(\"Cache-Control\", \"no-cache, no-store, must-revalidate, private\")\n                response.sendRedirect(redirectUrl)\n            }\n            dontDoRender = true\n        }\n    }\n    boolean sendJsonRedirect(UrlInstance fullUrl, Long renderStartTime) {\n        if (\"json\".equals(screenUrlInfo.targetTransitionExtension) || request?.getHeader(\"Accept\")?.contains(\"application/json\")) {\n            String pathWithParams = fullUrl.getPathWithParams()\n            Map<String, Object> responseMap = getBasicResponseMap()\n            // add screen path, parameters from fullUrl\n            responseMap.put(\"screenPathList\", fullUrl.sui.fullPathNameList)\n            responseMap.put(\"screenParameters\", fullUrl.getParameterMap())\n            responseMap.put(\"screenUrl\", pathWithParams)\n            // send it\n            ec.web.sendJsonResponse(responseMap)\n            if (logger.isInfoEnabled()) logger.info(\"Transition ${screenUrlInfo.getFullPathNameList().join(\"/\")}${renderStartTime != null ? ' in ' + (System.currentTimeMillis() - renderStartTime) + 'ms' : ''}, JSON redirect to: ${pathWithParams}\")\n            return true\n        } else {\n            return false\n        }\n    }\n    boolean sendJsonRedirect(String plainUrl) {\n        if (\"json\".equals(screenUrlInfo.targetTransitionExtension) || request?.getHeader(\"Accept\")?.contains(\"application/json\")) {\n            Map<String, Object> responseMap = getBasicResponseMap()\n            // the plain URL, send as redirect URL\n            responseMap.put(\"redirectUrl\", plainUrl)\n            // send it\n            ec.web.sendJsonResponse(responseMap)\n            return true\n        } else {\n            return false\n        }\n    }\n    Map<String, Object> getBasicResponseMap() {\n        Map<String, Object> responseMap = new HashMap<>()\n        // add saveMessagesToSession, saveRequestParametersToSession/saveErrorParametersToSession data\n        // add all plain object data from session?\n        List<MessageInfo> messageInfos = ec.message.getMessageInfos()\n        int messageInfosSize = messageInfos.size()\n        if (messageInfosSize > 0) {\n            List<Map> miMapList = new ArrayList<>(messageInfosSize)\n            for (int i = 0; i < messageInfosSize; i++) {\n                MessageInfo messageInfo = (MessageInfo) messageInfos.get(i)\n                miMapList.add([message:messageInfo.message, type:messageInfo.typeString])\n            }\n            responseMap.put(\"messageInfos\", miMapList)\n        }\n        if (ec.message.getErrors().size() > 0) responseMap.put(\"errors\", ec.message.errors)\n        if (ec.message.getValidationErrors().size() > 0) {\n            List<ValidationError> valErrorList = ec.message.getValidationErrors()\n            int valErrorListSize = valErrorList.size()\n            ArrayList<Map> valErrMapList = new ArrayList<>(valErrorListSize)\n            for (int i = 0; i < valErrorListSize; i++) valErrMapList.add(valErrorList.get(i).getMap())\n            responseMap.put(\"validationErrors\", valErrMapList)\n        }\n\n        Map parms = new HashMap()\n        if (ec.web.requestParameters != null) parms.putAll(ec.web.requestParameters)\n        if (ec.web.requestAttributes != null) parms.putAll(ec.web.requestAttributes)\n        responseMap.put(\"currentParameters\", ContextJavaUtil.unwrapMap(parms))\n\n        return responseMap\n    }\n\n    protected void internalRender() {\n        // make sure this (sri) is in the context before running actions or rendering screens\n        ec.contextStack.put(\"sri\", this)\n\n        long renderStartTime = System.currentTimeMillis()\n\n        rootScreenDef = sfi.getScreenDefinition(rootScreenLocation)\n        if (rootScreenDef == null) throw new BaseArtifactException(\"Could not find root screen at location ${rootScreenLocation}\")\n\n        if (logger.traceEnabled) logger.trace(\"Rendering screen ${rootScreenLocation} with path list ${originalScreenPathNameList}\")\n        // logger.info(\"Rendering screen [${rootScreenLocation}] with path list [${originalScreenPathNameList}]\")\n\n        WebFacade web = ec.getWeb()\n        if ((lastStandalone == null || lastStandalone.isEmpty()) && web != null)\n            lastStandalone = (String) web.requestParameters.lastStandalone\n        ExecutionContextFactoryImpl.WebappInfo webappInfo = ec.ecfi.getWebappInfo(webappName)\n\n        screenUrlInfo = ScreenUrlInfo.getScreenUrlInfo(this, rootScreenDef, originalScreenPathNameList, null,\n                ScreenUrlInfo.parseLastStandalone(lastStandalone, 0))\n\n        // if the target of the url doesn't exist throw exception\n        screenUrlInfo.checkExists()\n        screenUrlInstance = screenUrlInfo.getInstance(this, false)\n\n        // if there is a formListFindId parameter see if any matching parameters are set otherwise set all configured params\n        // NOTE: needs to be done very early in screen rendering so that parameters are available for actions, etc\n        // NOTE: this should allow override of parameters along with a formListFindId while defaulting to configured ones,\n        //     but is far from ideal in detecting whether configured parms should be used\n        String formListFindId = ec.contextStack.getByString(\"formListFindId\")\n        if ((formListFindId == null || formListFindId.isEmpty()) && screenUrlInfo.targetScreen != null) {\n            // get user's default saved find if there is one\n            String userId = ec.userFacade.getUserId()\n            if (userId != null) {\n                EntityValue formListFindUserDefault = ec.entityFacade.find(\"moqui.screen.form.FormListFindUserDefault\")\n                        .condition(\"userId\", userId).condition(\"screenLocation\", screenUrlInfo.targetScreen.location)\n                        .disableAuthz().useCache(true).one()\n                if (formListFindUserDefault != null) {\n                    formListFindId = (String) formListFindUserDefault.get(\"formListFindId\")\n                    ec.contextStack.put(\"formListFindId\", formListFindId)\n                }\n            }\n        }\n        if (\"_clear\".equals(formListFindId)) {\n            formListFindId = null\n            ec.contextStack.put(\"formListFindId\", null)\n            if (web != null) web.requestParameters.put(\"formListFindId\", \"\")\n        }\n        if (formListFindId != null && !formListFindId.isEmpty()) {\n            Set<String> targetScreenParmNames = screenUrlInfo.targetScreen?.getParameterMap()?.keySet()\n            Map<String, String> flfParameters = ScreenForm.makeFormListFindParameters(formListFindId, ec)\n            boolean foundMatchingParm = false\n            for (String flfParmName in flfParameters.keySet()) {\n                if (\"formListFindId\".equals(flfParmName)) continue\n                if (targetScreenParmNames != null && targetScreenParmNames.contains(flfParmName)) continue\n                Object parmValue = ec.contextStack.getByString(flfParmName)\n                if (!ObjectUtilities.isEmpty(parmValue)) {\n                    foundMatchingParm = true\n                    break\n                }\n            }\n            if (!foundMatchingParm) {\n                EntityValue formListFind = ec.entityFacade.fastFindOne(\"moqui.screen.form.FormListFind\", true, true, formListFindId)\n                if (formListFind?.orderByField && !ec.contextStack.getByString(\"orderByField\")) ec.contextStack.put(\"orderByField\", formListFind.orderByField)\n                ec.contextStack.putAll(flfParameters)\n                // logger.warn(\"Found formListFindId and no matching parameters, orderByField [${formListFind?.orderByField}], added paramters: ${flfParameters}\")\n            }\n        }\n\n        if (web != null) {\n            // clear out the parameters used for special screen URL config\n            if (web.requestParameters.lastStandalone) web.requestParameters.lastStandalone = \"\"\n\n            // if screenUrlInfo has any parameters add them to the request (probably came from a transition acting as an alias)\n            Map<String, String> suiParameterMap = screenUrlInstance.getTransitionAliasParameters()\n            if (suiParameterMap != null) web.requestParameters.putAll(suiParameterMap)\n\n            // add URL parameters, if there were any in the URL (in path info or after ?)\n            screenUrlInstance.addParameters(web.requestParameters)\n\n            // check for pageSize parameter, if set save in current user's preference, if not look up from user pref\n            if (ec.userFacade.userId != null) {\n                String pageSize = web.requestParameters.get(\"pageSize\")\n                String userPageSize = ec.userFacade.getPreference(\"screen.user.page.size\")\n                if (pageSize != null && pageSize.isInteger()) {\n                    if (!pageSize.equals(userPageSize))\n                        ec.userFacade.setPreference(\"screen.user.page.size\", pageSize)\n                } else {\n                    if (userPageSize != null && userPageSize.isInteger()) {\n                        // don't add to parameters, just set internally: web.requestParameters.put(\"pageSize\", userPageSize)\n                        ec.contextStack.put(\"pageSize\", userPageSize)\n                    }\n                }\n            }\n        }\n\n        // check webapp settings for each screen in the path\n        ArrayList<ScreenDefinition> screenPathDefList = screenUrlInfo.screenPathDefList\n        int screenPathDefListSize = screenPathDefList.size()\n        for (int i = screenUrlInfo.renderPathDifference; i < screenPathDefListSize; i++) {\n            ScreenDefinition sd = (ScreenDefinition) screenPathDefList.get(i)\n            if (!checkWebappSettings(sd)) return\n        }\n\n        // check this here after the ScreenUrlInfo (with transition alias, etc) has already been handled\n        String localRenderMode = web != null ? web.requestParameters.renderMode : null\n        if ((renderMode == null || renderMode.length() == 0) && localRenderMode != null && localRenderMode.length() > 0)\n            renderMode = localRenderMode\n        // if no renderMode get from target screen extension in URL\n        if ((renderMode == null || renderMode.length() == 0) && screenUrlInfo.targetScreenRenderMode != null)\n            renderMode = screenUrlInfo.targetScreenRenderMode\n        // if no outputContentType but there is a renderMode get outputContentType based on renderMode\n        if ((outputContentType == null || outputContentType.length() == 0) && renderMode != null && renderMode.length() > 0) {\n            String mimeType = sfi.getMimeTypeByMode(renderMode)\n            if (mimeType != null && mimeType.length() > 0) outputContentType = mimeType\n        }\n\n        // if these aren't set yet then set to basic defaults\n        if (renderMode == null || renderMode.length() == 0) renderMode = \"html\"\n        if (characterEncoding == null || characterEncoding.length() == 0) characterEncoding = \"UTF-8\"\n        if (outputContentType == null || outputContentType.length() == 0) outputContentType = \"text/html\"\n\n\n        // before we render, set the character encoding (set the content type later, after we see if there is sub-content with a different type)\n        if (response != null) response.setCharacterEncoding(characterEncoding)\n\n        // if there is a transition run that INSTEAD of the screen to render\n        ScreenDefinition.TransitionItem targetTransition = screenUrlInstance.getTargetTransition()\n        // logger.warn(\"============ Rendering screen ${screenUrlInfo.getTargetScreen().getLocation()} transition ${screenUrlInfo.getTargetTransitionActualName()} has transition ${targetTransition != null}\")\n        if (targetTransition != null) {\n            // if this transition has actions and request was not secure or any parameters were not in the body\n            // return an error, helps prevent CSRF/XSRF attacks\n            if (request != null && targetTransition.hasActionsOrSingleService()) {\n                String queryString = request.getQueryString()\n\n                // NOTE: We decode path parameter ourselves, so use getRequestURI instead of getPathInfo\n                Map<String, Object> pathInfoParameterMap = WebUtilities.getPathInfoParameterMap(request.getRequestURI())\n                if (!targetTransition.isReadOnly() && (\n                        (!request.isSecure() && webappInfo != null && webappInfo.httpsEnabled) ||\n                        (queryString != null && queryString.length() > 0) ||\n                        (pathInfoParameterMap != null && pathInfoParameterMap.size() > 0))) {\n                    throw new BaseArtifactException(\n                        \"\"\"Cannot run screen transition with actions from non-secure request or with URL\n                        parameters for security reasons (they are not encrypted and need to be for data\n                        protection and source validation). Change the link this came from to be a\n                        form with hidden input fields instead, or declare the transition as read-only.\"\"\")\n                }\n                // require a moquiSessionToken parameter for all but get\n                if (request.getMethod().toLowerCase() != \"get\" && webappInfo != null && webappInfo.requireSessionToken &&\n                        targetTransition.getRequireSessionToken() &&\n                        !\"true\".equals(request.getAttribute(\"moqui.session.token.created\")) &&\n                        !\"true\".equals(request.getAttribute(\"moqui.request.authenticated\"))) {\n                    String passedToken = (String) ec.web.getParameters().get(\"moquiSessionToken\")\n                    if (!passedToken) passedToken = request.getHeader(\"moquiSessionToken\") ?:\n                            request.getHeader(\"SessionToken\") ?: request.getHeader(\"X-CSRF-Token\")\n\n                    String curToken = ec.web.getSessionToken()\n                    if (curToken != null && curToken.length() > 0) {\n                        if (passedToken == null || passedToken.length() == 0) {\n                            throw new AuthenticationRequiredException(\"Session token required (in X-CSRF-Token) for URL ${screenUrlInstance.url}\")\n                        } else if (!curToken.equals(passedToken)) {\n                            throw new AuthenticationRequiredException(\"Session token does not match (in X-CSRF-Token) for URL ${screenUrlInstance.url}\")\n                        }\n                    }\n                }\n            }\n\n            long startTimeNanos = System.nanoTime()\n\n            TransactionFacade transactionFacade = sfi.getEcfi().transactionFacade\n            boolean beginTransaction = targetTransition.getBeginTransaction()\n            boolean beganTransaction = beginTransaction ? transactionFacade.begin(null) : false\n            ResponseItem ri = null\n            try {\n                boolean runPreActions = targetTransition instanceof ScreenDefinition.ActionsTransitionItem\n                screenPathIndex = 0\n                ri = recursiveRunTransition(runPreActions)\n                screenPathIndex = 0\n            } catch (Throwable t) {\n                transactionFacade.rollback(beganTransaction, \"Error running transition in [${screenUrlInstance.url}]\", t)\n                throw t\n            } finally {\n                try {\n                    if (transactionFacade.isTransactionInPlace()) {\n                        if (ec.getMessage().hasError()) {\n                            transactionFacade.rollback(beganTransaction, ec.getMessage().getErrorsString(), null)\n                        } else {\n                            transactionFacade.commit(beganTransaction)\n                        }\n                    }\n                } catch (Exception e) {\n                    logger.error(\"Error ending screen transition transaction\", e)\n                }\n\n                if (!\"false\".equals(screenUrlInfo.targetScreen.screenNode.attribute(\"track-artifact-hit\"))) {\n                    String riType = ri != null ? ri.type : null\n                    sfi.ecfi.countArtifactHit(ArtifactExecutionInfo.AT_XML_SCREEN_TRANS, riType != null ? riType : \"\",\n                            targetTransition.parentScreen.getLocation() + \"#\" + targetTransition.name,\n                            (web != null ? web.requestParameters : null), renderStartTime,\n                            (System.nanoTime() - startTimeNanos)/1000000.0D, null)\n                }\n            }\n\n            if (ri == null) throw new BaseArtifactException(\"No response found for transition [${screenUrlInstance.targetTransition.name}] on screen ${screenUrlInfo.targetScreen.location}\")\n\n            WebFacadeImpl wfi = (WebFacadeImpl) null\n            if (web != null && web instanceof WebFacadeImpl) wfi = (WebFacadeImpl) web\n\n            if (ri.saveCurrentScreen && wfi != null) {\n                StringBuilder screenPath = new StringBuilder()\n                for (String pn in screenUrlInfo.fullPathNameList) screenPath.append(\"/\").append(pn)\n                ((WebFacadeImpl) web).saveScreenLastInfo(screenPath.toString(), null)\n            }\n\n            if (this.response != null && webappInfo != null) {\n                webappInfo.addHeaders(\"screen-transition\", this.response)\n            }\n\n            if (\"none\".equals(ri.type)) {\n                // for response type none also save parameters if configured to do so, and save errors if there are any\n                if (ri.saveParameters) wfi.saveRequestParametersToSession()\n                if (ec.message.hasError()) wfi.saveErrorParametersToSession()\n                if (logger.isTraceEnabled()) logger.trace(\"Transition ${screenUrlInfo.getFullPathNameList().join(\"/\")} in ${System.currentTimeMillis() - renderStartTime}ms, type none response\")\n                return\n            }\n\n            String url = ri.url != null ? ri.url : \"\"\n            String urlType = ri.urlType != null && ri.urlType.length() > 0 ? ri.urlType : \"screen-path\"\n            boolean isScreenLast = \"screen-last\".equals(ri.type)\n\n            if (wfi != null) {\n                // handle screen-last, etc\n                if (isScreenLast || \"screen-last-noparam\".equals(ri.type)) {\n                    String savedUrl = wfi.getRemoveScreenLastPath()\n                    urlType = \"screen-path\"\n                    if (savedUrl != null && savedUrl.length() > 0) {\n                        if (savedUrl.startsWith(\"http\")) urlType = \"plain\"\n                        url = savedUrl\n                        wfi.removeScreenLastParameters(isScreenLast)\n                        // logger.warn(\"going to screen-last from screen last path ${url}\")\n                    } else {\n                        // try screen history when no last was saved\n                        List<Map> historyList = wfi.getScreenHistory()\n                        Map historyMap = historyList != null && historyList.size() > 0 ? historyList.first() : (Map) null\n                        if (historyMap != null) {\n                            url = isScreenLast ? historyMap.pathWithParams : historyMap.path\n                            // logger.warn(\"going to screen-last from screen history ${url}\")\n                        } else {\n                            // if no saved URL, just go to root/default; avoid getting stuck on Login screen, etc\n                            url = \"/\"\n                            // logger.warn(\"going to screen-last no last path or history to going to root\")\n                        }\n                    }\n                }\n\n                // save messages in session before redirecting so they can be displayed on the next screen\n                wfi.saveMessagesToSession()\n                if (ri.saveParameters) wfi.saveRequestParametersToSession()\n                if (ec.message.hasError()) wfi.saveErrorParametersToSession()\n            }\n\n            // either send a redirect for the response, if possible, or just render the response now\n            if (this.response != null) {\n                if (\"plain\".equals(urlType)) {\n                    StringBuilder ps = new StringBuilder()\n                    Map<String, String> pm = (Map<String, String>) ri.expandParameters(screenUrlInfo.getExtraPathNameList(), ec)\n                    if (pm != null && pm.size() > 0) {\n                        for (Map.Entry<String, String> pme in pm.entrySet()) {\n                            if (!pme.value) continue\n                            if (ps.length() > 0) ps.append(\"&\")\n                            ps.append(URLEncoder.encode(pme.key, \"UTF-8\")).append(\"=\").append(URLEncoder.encode(pme.value, \"UTF-8\"))\n                        }\n                    }\n                    String fullUrl = url\n                    if (ps.length() > 0) {\n                        if (url.contains(\"?\")) fullUrl += \"&\" else fullUrl += \"?\"\n                        fullUrl += ps.toString()\n                    }\n                    // NOTE: even if transition extension is json still send redirect when we just have a plain url\n                    if (logger.isInfoEnabled()) logger.info(\"Transition ${screenUrlInfo.getFullPathNameList().join(\"/\")} in ${System.currentTimeMillis() - renderStartTime}ms, redirecting to plain URL: ${fullUrl}\")\n                    if (!sendJsonRedirect(fullUrl)) {\n                        response.sendRedirect(fullUrl)\n                    }\n                } else {\n                    // default is screen-path\n                    UrlInstance fullUrl = buildUrl(rootScreenDef, screenUrlInfo.preTransitionPathNameList, url)\n                    // copy through pageIndex if passed so in form-list with multiple pages we stay on same page\n                    if (web.requestParameters.containsKey(\"pageIndex\")) fullUrl.addParameter(\"pageIndex\", (String) web.parameters.get(\"pageIndex\"))\n                    // copy through orderByField if passed so in form-list with multiple pages we retain the sort order\n                    if (web.requestParameters.containsKey(\"orderByField\")) fullUrl.addParameter(\"orderByField\", (String) web.parameters.get(\"orderByField\"))\n                    fullUrl.addParameters(ri.expandParameters(screenUrlInfo.getExtraPathNameList(), ec))\n                    // if this was a screen-last and the screen has declared parameters include them in the URL\n                    Map savedParameters = wfi?.getSavedParameters()\n                    UrlInstance.copySpecialParameters(savedParameters, fullUrl.getOtherParameterMap())\n                    // screen parameters\n                    Map<String, ScreenDefinition.ParameterItem> parameterItemMap = fullUrl.sui.pathParameterItems\n                    if (isScreenLast && savedParameters != null && savedParameters.size() > 0) {\n                        if (parameterItemMap != null && parameterItemMap.size() > 0) {\n                            for (String parmName in parameterItemMap.keySet()) {\n                                if (savedParameters.get(parmName)) fullUrl.addParameter(parmName, savedParameters.get(parmName))\n                            }\n                        } else {\n                            fullUrl.addParameters(savedParameters)\n                        }\n                    }\n                    // transition parameters\n                    Map<String, ScreenDefinition.ParameterItem> transParameterItemMap = fullUrl.getTargetTransition()?.getParameterMap()\n                    if (isScreenLast && savedParameters != null && savedParameters.size() > 0 &&\n                            transParameterItemMap != null && transParameterItemMap.size() > 0) {\n                        for (String parmName in transParameterItemMap.keySet()) {\n                            if (savedParameters.get(parmName))\n                                fullUrl.addParameter(parmName, savedParameters.get(parmName))\n                        }\n                    }\n\n                    if (!sendJsonRedirect(fullUrl, renderStartTime)) {\n                        String fullUrlString = fullUrl.getUrlWithParams(screenUrlInfo.targetTransitionExtension)\n                        if (logger.isInfoEnabled()) logger.info(\"Transition ${screenUrlInfo.getFullPathNameList().join(\"/\")} in ${System.currentTimeMillis() - renderStartTime}ms, redirecting to screen path URL: ${fullUrlString}\")\n                        response.sendRedirect(fullUrlString)\n                    }\n                }\n            } else {\n                ArrayList<String> pathElements = new ArrayList<>(Arrays.asList(url.split(\"/\")))\n                if (url.startsWith(\"/\")) {\n                    this.originalScreenPathNameList = pathElements\n                } else {\n                    this.originalScreenPathNameList = new ArrayList<>(screenUrlInfo.preTransitionPathNameList)\n                    this.originalScreenPathNameList.addAll(pathElements)\n                }\n                // reset screenUrlInfo and call this again to start over with the new target\n                screenUrlInfo = (ScreenUrlInfo) null\n                internalRender()\n            }\n        } else if (screenUrlInfo.fileResourceRef != null) {\n            ResourceReference fileResourceRef = screenUrlInfo.fileResourceRef\n\n            long resourceStartTime = System.currentTimeMillis()\n            long startTimeNanos = System.nanoTime()\n\n            TemplateRenderer tr = sfi.ecfi.resourceFacade.getTemplateRendererByLocation(fileResourceRef.location)\n\n            // use the fileName to determine the content/mime type\n            String fileName = fileResourceRef.fileName\n            // strip template extension(s) to avoid problems with trying to find content types based on them\n            String fileContentType = sfi.ecfi.resourceFacade.getContentType(tr != null ? tr.stripTemplateExtension(fileName) : fileName)\n\n            boolean isBinary = tr == null && ResourceReference.isBinaryContentType(fileContentType)\n            // if (isTraceEnabled) logger.trace(\"Content type for screen sub-content filename [${fileName}] is [${fileContentType}], default [${this.outputContentType}], is binary? ${isBinary}\")\n\n            if (isBinary) {\n                if (response != null) {\n                    this.outputContentType = fileContentType\n                    response.setContentType(this.outputContentType)\n                    // static binary, tell the browser to cache it\n                    if (webappInfo != null) {\n                        webappInfo.addHeaders(\"screen-resource-binary\", response)\n                    } else {\n                        response.setHeader(\"Cache-Control\", \"max-age=86400, must-revalidate, public\")\n                    }\n\n                    InputStream is\n                    try {\n                        is = fileResourceRef.openStream()\n                        OutputStream os = response.outputStream\n                        int totalLen = ObjectUtilities.copyStream(is, os)\n\n                        if (screenUrlInfo.targetScreen.screenNode.attribute(\"track-artifact-hit\") != \"false\") {\n                            sfi.ecfi.countArtifactHit(ArtifactExecutionInfo.AT_XML_SCREEN_CONTENT, fileContentType,\n                                    fileResourceRef.location, (web != null ? web.requestParameters : null),\n                                    resourceStartTime, (System.nanoTime() - startTimeNanos)/1000000.0D, (long) totalLen)\n                        }\n                        if (isTraceEnabled) logger.trace(\"Sent binary response of length ${totalLen} from file ${fileResourceRef.location} for request to ${screenUrlInstance.url}\")\n                    } finally {\n                        if (is != null) is.close()\n                    }\n                } else {\n                    throw new BaseArtifactException(\"Tried to get binary content at ${screenUrlInfo.fileResourcePathList} under screen ${screenUrlInfo.targetScreen.location}, but there is no HTTP response available\")\n                }\n            } else if (!\"true\".equals(screenUrlInfo.targetScreen.screenNode.attribute(\"include-child-content\"))) {\n                // not a binary object (hopefully), read it and write it to the writer\n                if (fileContentType != null && fileContentType.length() > 0) this.outputContentType = fileContentType\n                if (response != null) {\n                    response.setContentType(this.outputContentType)\n                    response.setCharacterEncoding(this.characterEncoding)\n                }\n\n                if (tr != null) {\n                    // if requires a render, don't cache and make it private\n                    if (response != null) {\n                        if (webappInfo != null) {\n                            webappInfo.addHeaders(\"screen-resource-template\", response)\n                        } else {\n                            response.setHeader(\"Cache-Control\", \"no-cache, no-store, must-revalidate, private\")\n                        }\n                    }\n                    tr.render(fileResourceRef.location, writer)\n                } else {\n                    // static text, tell the browser to cache it\n                    if (response != null) {\n                        if (webappInfo != null) {\n                            webappInfo.addHeaders(\"screen-resource-text\", response)\n                        } else {\n                            response.setHeader(\"Cache-Control\", \"max-age=86400, must-revalidate, public\")\n                        }\n                    }\n                    // no renderer found, just grab the text (cached) and throw it to the writer\n                    String text = sfi.ecfi.resourceFacade.getLocationText(fileResourceRef.location, true)\n                    if (text != null && text.length() > 0) {\n                        // NOTE: String.length not correct for byte length\n                        String charset = response?.getCharacterEncoding() ?: \"UTF-8\"\n\n                        // getBytes() is pretty slow, seems to be only way to get accurate length, perhaps better without it (definitely faster)\n                        // int length = text.getBytes(charset).length\n                        // if (response != null) response.setContentLength(length)\n\n                        if (isTraceEnabled) logger.trace(\"Sending text response with ${charset} encoding from file ${fileResourceRef.location} for request to ${screenUrlInstance.url}\")\n\n                        writer.write(text)\n                        if (!\"false\".equals(screenUrlInfo.targetScreen.screenNode.attribute(\"track-artifact-hit\"))) {\n                            sfi.ecfi.countArtifactHit(ArtifactExecutionInfo.AT_XML_SCREEN_CONTENT, fileContentType,\n                                    fileResourceRef.location, (web != null ? web.requestParameters : null),\n                                    resourceStartTime, (System.nanoTime() - startTimeNanos)/1000000.0D, (long) text.length())\n                        }\n                    } else {\n                        logger.warn(\"Not sending text response from file [${fileResourceRef.location}] for request to [${screenUrlInstance.url}] because no text was found in the file.\")\n                    }\n                }\n            } else {\n                // render the root screen as normal, and when that is to the targetScreen include the content\n                doActualRender()\n            }\n        } else {\n            doActualRender()\n            if (response != null && logger.isInfoEnabled()) {\n                Map<String, Object> reqParms = web?.getRequestParameters()\n                logger.info(\"${screenUrlInfo.getFullPathNameList().join(\"/\")} ${reqParms != null && reqParms.size() > 0 ? reqParms : '[]'} in ${(System.currentTimeMillis()-renderStartTime)}ms (${response.getContentType()}) session ${request.session.id}\")\n            }\n        }\n    }\n\n    protected ResponseItem recursiveRunTransition(boolean runPreActions) {\n        ScreenDefinition sd = getActiveScreenDef()\n        // for these authz is not required, as long as something authorizes on the way to the transition, or\n        // the transition itself, it's fine\n        ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(sd.location,\n                ArtifactExecutionInfo.AT_XML_SCREEN, ArtifactExecutionInfo.AUTHZA_VIEW, null)\n        ec.artifactExecutionFacade.pushInternal(aei, false, false)\n\n        boolean loggedInAnonymous = false\n        ResponseItem ri = (ResponseItem) null\n\n        try {\n            MNode screenNode = sd.getScreenNode()\n            String requireAuthentication = screenNode.attribute(\"require-authentication\")\n            if (\"anonymous-all\".equals(requireAuthentication)) {\n                ec.artifactExecutionFacade.setAnonymousAuthorizedAll()\n                loggedInAnonymous = ec.userFacade.loginAnonymousIfNoUser()\n            } else if (\"anonymous-view\".equals(requireAuthentication)) {\n                ec.artifactExecutionFacade.setAnonymousAuthorizedView()\n                loggedInAnonymous = ec.userFacade.loginAnonymousIfNoUser()\n            }\n\n            if (sd.alwaysActions != null) sd.alwaysActions.run(ec)\n            if (runPreActions && sd.preActions != null) sd.preActions.run(ec)\n\n            if (getActiveScreenHasNext()) {\n                screenPathIndex++\n                try { ri = recursiveRunTransition(runPreActions) }\n                finally { screenPathIndex-- }\n            } else {\n                // run the transition\n                ri = screenUrlInstance.targetTransition.run(this)\n            }\n        } finally {\n            ec.artifactExecutionFacade.pop(aei)\n            if (loggedInAnonymous) ec.userFacade.logoutAnonymousOnly()\n        }\n\n        return ri\n    }\n    protected void recursiveRunActions(boolean runAlwaysActions, boolean runPreActions) {\n        ScreenDefinition sd = getActiveScreenDef()\n        boolean activeScreenHasNext = getActiveScreenHasNext()\n        // check authz first, including anonymous-* handling so that permissions and auth are in place\n        // NOTE: don't require authz if the screen doesn't require auth\n        MNode screenNode = sd.getScreenNode()\n        String requireAuthentication = screenNode.attribute(\"require-authentication\")\n        ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(sd.location,\n                ArtifactExecutionInfo.AT_XML_SCREEN, ArtifactExecutionInfo.AUTHZA_VIEW, outputContentType).setTrackArtifactHit(false)\n        ec.artifactExecutionFacade.pushInternal(aei, !activeScreenHasNext ? (!requireAuthentication || requireAuthentication == \"true\") : false, false)\n\n        boolean loggedInAnonymous = false\n        try {\n            if (requireAuthentication == \"anonymous-all\") {\n                ec.artifactExecutionFacade.setAnonymousAuthorizedAll()\n                loggedInAnonymous = ec.userFacade.loginAnonymousIfNoUser()\n            } else if (requireAuthentication == \"anonymous-view\") {\n                ec.artifactExecutionFacade.setAnonymousAuthorizedView()\n                loggedInAnonymous = ec.userFacade.loginAnonymousIfNoUser()\n            }\n\n            if (runAlwaysActions && sd.alwaysActions != null) sd.alwaysActions.run(ec)\n            if (runPreActions && sd.preActions != null) sd.preActions.run(ec)\n\n            if (activeScreenHasNext) {\n                screenPathIndex++\n                try { recursiveRunActions(runAlwaysActions, runPreActions) }\n                finally { screenPathIndex-- }\n            }\n        } finally {\n            // all done so pop the artifact info; don't bother making sure this is done on errors/etc like in a finally clause because if there is an error this will help us know how we got there\n            ec.artifactExecutionFacade.pop(aei)\n            if (loggedInAnonymous) ec.userFacade.logoutAnonymousOnly()\n        }\n    }\n\n    void doActualRender() {\n        ArrayList<ScreenDefinition> screenPathDefList = screenUrlInfo.screenPathDefList\n        int screenPathDefListSize = screenPathDefList.size()\n        ExecutionContextFactoryImpl.WebappInfo webappInfo = ec.ecfi.getWebappInfo(webappName)\n\n        boolean isServerStatic = screenUrlInfo.targetScreen.isServerStatic(renderMode)\n        // TODO: consider server caching of rendered screen, this is the place to do it\n\n        boolean beganTransaction = screenUrlInfo.beginTransaction ? sfi.ecfi.transactionFacade.begin(screenUrlInfo.transactionTimeout) : false\n        try {\n            // run always-actions for all screens in path\n            boolean hasAlwaysActions = false\n            for (int i = 0; i < screenPathDefListSize; i++) {\n                ScreenDefinition sd = (ScreenDefinition) screenPathDefList.get(i)\n                if (sd.alwaysActions != null) { hasAlwaysActions = true; break }\n            }\n            if (hasAlwaysActions) {\n                screenPathIndex = 0\n                recursiveRunActions(true, false)\n                screenPathIndex = 0\n            }\n\n            if (response != null) {\n                response.setContentType(this.outputContentType)\n                response.setCharacterEncoding(this.characterEncoding)\n                if (isServerStatic) {\n                    if (webappInfo != null) {\n                        webappInfo.addHeaders(\"screen-server-static\", response)\n                    } else {\n                        response.setHeader(\"Cache-Control\", \"max-age=86400, must-revalidate, public\")\n                    }\n                } else {\n                    if (webappInfo != null) {\n                        webappInfo.addHeaders(\"screen-render\", response)\n                    } else {\n                        // if requires a render, don't cache and make it private\n                        response.setHeader(\"Cache-Control\", \"no-cache, no-store, must-revalidate, private\")\n                        // add Content-Security-Policy by default to not allow use in iframe or allow form actions on different host\n                        // see https://content-security-policy.com/\n                        // TODO make this configurable for different screen paths? maybe a screen.web-settings attribute to exclude or add to?\n                        response.setHeader(\"Content-Security-Policy\", \"frame-ancestors 'none'; form-action 'self';\")\n                        response.setHeader(\"X-Frame-Options\", \"deny\")\n                    }\n                }\n                // if the request is secure add HSTS Strict-Transport-Security header with one leap year age (in seconds)\n                if (request.isSecure()) {\n                    if (webappInfo != null) {\n                        webappInfo.addHeaders(\"screen-secure\", response)\n                    } else {\n                        response.setHeader(\"Strict-Transport-Security\", \"max-age=31536000\")\n                    }\n                }\n\n                String filename = ec.context.saveFilename as String\n                if (filename) {\n                    String utfFilename = StringUtilities.encodeAsciiFilename(filename)\n                    response.setHeader(\"Content-Disposition\", \"attachment; filename=\\\"${filename}\\\"; filename*=utf-8''${utfFilename}\")\n                }\n            }\n\n            // for inherited permissions to work, walk the screen list before the screens to render and artifact push\n            // them, then pop after\n            ArrayList<ArtifactExecutionInfo> aeiList = null\n            if (screenUrlInfo.renderPathDifference > 0) {\n                aeiList = new ArrayList<ArtifactExecutionInfo>(screenUrlInfo.renderPathDifference)\n                for (int i = 0; i < screenUrlInfo.renderPathDifference; i++) {\n                    ScreenDefinition permSd = screenPathDefList.get(i)\n\n                    // check the subscreens item for this screen (valid in context)\n                    if (i > 0) {\n                        String curPathName = screenUrlInfo.fullPathNameList.get(i - 1) // one lower in path as it doesn't have root screen\n                        ScreenDefinition parentScreen = screenPathDefList.get(i - 1)\n                        SubscreensItem ssi = parentScreen.getSubscreensItem(curPathName)\n                        if (ssi == null) {\n                            logger.warn(\"Couldn't find SubscreenItem: parent ${parentScreen.getScreenName()}, curPathName ${curPathName}, current ${permSd.getScreenName()}\\npath list: ${screenUrlInfo.fullPathNameList}\\nscreen list: ${screenUrlInfo.screenPathDefList}\")\n                        } else {\n                            if (!ssi.isValidInCurrentContext())\n                                throw new ArtifactAuthorizationException(\"The screen ${permSd.getScreenName()} is not available\")\n                        }\n                    }\n\n                    ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(permSd.location,\n                            ArtifactExecutionInfo.AT_XML_SCREEN, ArtifactExecutionInfo.AUTHZA_VIEW, outputContentType)\n                    ec.artifactExecutionFacade.pushInternal(aei, false, false)\n                    aeiList.add(aei)\n                }\n            }\n\n            try {\n                int preActionStartIndex\n                if (screenUrlInfo.targetScreenRenderMode != null && sfi.isRenderModeAlwaysStandalone(screenUrlInfo.targetScreenRenderMode) &&\n                        screenPathDefListSize > 2) {\n                    // special case for render modes that are always standalone: run pre-actions for all screens in path except first 2 (generally webroot, apps)\n                    preActionStartIndex = 2\n                } else {\n                    // run pre-actions for just the screens that will be rendered\n                    preActionStartIndex = screenUrlInfo.renderPathDifference\n                }\n                boolean hasPreActions = false\n                for (int i = preActionStartIndex; i < screenPathDefListSize; i++) {\n                    ScreenDefinition sd = (ScreenDefinition) screenPathDefList.get(i)\n                    if (sd.preActions != null) { hasPreActions = true; break }\n                }\n                if (hasPreActions) {\n                    screenPathIndex = preActionStartIndex\n                    recursiveRunActions(false, true)\n                    screenPathIndex = 0\n                }\n\n                // if dontDoRender then quit now; this should be set during always-actions or pre-actions\n                if (dontDoRender) { return }\n\n                // we've run always and pre actions, it's now or never for required parameters so check them\n                if (!sfi.isRenderModeSkipActions(renderMode)) {\n                    for (int i = screenUrlInfo.renderPathDifference; i < screenPathDefListSize; i++) {\n                        ScreenDefinition sd = (ScreenDefinition) screenPathDefList.get(i)\n                        for (ScreenDefinition.ParameterItem pi in sd.getParameterMap().values()) {\n                            if (!pi.required) continue\n                            Object parmValue = ec.context.getByString(pi.name)\n                            if (ObjectUtilities.isEmpty(parmValue)) {\n                                ec.message.addError(ec.resource.expand(\"Required parameter missing (${pi.name})\",\"\",[pi:pi]))\n                                logger.warn(\"Tried to render screen [${sd.getLocation()}] without required parameter [${pi.name}], error message added and adding to stop list to not render\")\n                                stopRenderScreenLocations.add(sd.getLocation())\n                            }\n                        }\n                    }\n                }\n\n                // start rendering at the root section of the first screen to render\n                screenPathIndex = screenUrlInfo.renderPathDifference\n                ScreenDefinition renderStartDef = getActiveScreenDef()\n                // if there is no next screen to render then it is the target screen, otherwise it's not\n                renderStartDef.render(this, !getActiveScreenHasNext())\n\n                // if these aren't already cleared it out means they haven't been included in the output, so add them here\n                if (afterScreenWriter != null) internalWriter.write(afterScreenWriter.toString())\n                if (scriptWriter != null) {\n                    internalWriter.write(\"\\n<script>\\n\")\n                    internalWriter.write(scriptWriter.toString())\n                    internalWriter.write(\"\\n</script>\\n\")\n                }\n            } finally {\n                // pop all screens, then good to go\n                if (aeiList) for (int i = (aeiList.size() - 1); i >= 0; i--) ec.artifactExecution.pop(aeiList.get(i))\n            }\n\n            // save the screen history\n            if (saveHistory && screenUrlInfo.targetExists) {\n                WebFacade webFacade = ec.getWeb()\n                if (webFacade != null && webFacade instanceof WebFacadeImpl) ((WebFacadeImpl) webFacade).saveScreenHistory(screenUrlInstance)\n            }\n        } catch (ArtifactAuthorizationException e) {\n            throw e\n        } catch (ArtifactTarpitException e) {\n            throw e\n        } catch (Throwable t) {\n            String errMsg = \"Error rendering screen [${getActiveScreenDef().location}]\"\n            sfi.ecfi.transactionFacade.rollback(beganTransaction, errMsg, t)\n            throw new RuntimeException(errMsg, t)\n        } finally {\n            // if we began a tx commit it\n            if (beganTransaction && sfi.ecfi.transactionFacade.isTransactionInPlace()) sfi.ecfi.transactionFacade.commit()\n        }\n    }\n\n    boolean checkWebappSettings(ScreenDefinition currentSd) {\n        if (request == null) return true\n\n        MNode webSettingsNode = currentSd.webSettingsNode\n        if (webSettingsNode != null && \"false\".equals(webSettingsNode.attribute(\"allow-web-request\")))\n            throw new BaseArtifactException(\"The screen [${currentSd.location}] cannot be used in a web request (allow-web-request=false).\")\n\n        String mimeType = webSettingsNode != null ? webSettingsNode.attribute(\"mime-type\") : null\n        if (mimeType != null && mimeType.length() > 0) this.outputContentType = mimeType\n        String characterEncoding = webSettingsNode != null ? webSettingsNode.attribute(\"character-encoding\") : null\n        if (characterEncoding != null && characterEncoding.length() > 0) this.characterEncoding = characterEncoding\n\n        // if screen requires auth and there is not active user redirect to login screen, save this request\n        // if (isTraceEnabled) logger.trace(\"Checking screen [${currentSd.location}] for require-authentication, current user is [${ec.user.userId}]\")\n\n        WebFacadeImpl wfi = ec.getWebImpl()\n        String requireAuthentication = currentSd.screenNode?.attribute(\"require-authentication\")\n        String userId = ec.getUser().getUserId()\n        if ((requireAuthentication == null || requireAuthentication.length() == 0 || requireAuthentication == \"true\")\n                && (userId == null || userId.length() == 0) && !ec.userFacade.getLoggedInAnonymous()) {\n            if (logger.isInfoEnabled()) logger.info(\"Screen at location ${currentSd.location}, which is part of ${screenUrlInfo.fullPathNameList} under screen ${screenUrlInfo.fromSd.location} requires authentication but no user is currently logged in.\")\n            // save the request as a save-last to use after login\n            if (wfi != null && screenUrlInfo.fileResourceRef == null) {\n                StringBuilder screenPath = new StringBuilder()\n                for (String pn in originalScreenPathNameList) if (pn) screenPath.append(\"/\").append(pn)\n                // logger.warn(\"saving screen last: ${screenPath.toString()}\")\n                wfi.saveScreenLastInfo(screenPath.toString(), null)\n                // save messages in session before redirecting so they can be displayed on the next screen\n                wfi.saveMessagesToSession()\n            }\n\n            // find the last login path from screens in path (whether rendered or not)\n            String loginPath = \"/Login\"\n            for (ScreenDefinition sd in screenUrlInfo.screenPathDefList) {\n                String loginPathAttr = (String) sd.screenNode.attribute(\"login-path\")\n                if (loginPathAttr) loginPath = loginPathAttr\n            }\n\n            if (screenUrlInfo.lastStandalone != 0 || screenUrlInstance.getTargetTransition() != null) {\n                // just send a 401 response, should always be for data submit, content rendering, JS AJAX requests, etc\n                if (wfi != null) wfi.sendError(401, null, null)\n                else if (response != null) response.sendError(401, \"Authentication required\")\n                return false\n\n                /* TODO: remove all of this, we don't need it\n                ArrayList<String> pathElements = new ArrayList<>()\n                if (!loginPath.startsWith(\"/\")) {\n                    pathElements.addAll(screenUrlInfo.preTransitionPathNameList)\n                    pathElements.addAll(Arrays.asList(loginPath.split(\"/\")))\n                } else {\n                    pathElements.addAll(Arrays.asList(loginPath.substring(1).split(\"/\")))\n                }\n\n                // BEGIN what used to be only for requests for a json response\n                Map<String, Object> responseMap = new HashMap<>()\n                if (ec.message.getMessages().size() > 0) responseMap.put(\"messages\", ec.message.messages)\n                if (ec.message.getErrors().size() > 0) responseMap.put(\"errors\", ec.message.errors)\n                if (ec.message.getValidationErrors().size() > 0) {\n                    List<ValidationError> valErrorList = ec.message.getValidationErrors()\n                    int valErrorListSize = valErrorList.size()\n                    ArrayList<Map> valErrMapList = new ArrayList<>(valErrorListSize)\n                    for (int i = 0; i < valErrorListSize; i++) valErrMapList.add(valErrorList.get(i).getMap())\n                    responseMap.put(\"validationErrors\", valErrMapList)\n                }\n\n                Map parms = new HashMap()\n                if (ec.web.requestParameters != null) parms.putAll(ec.web.requestParameters)\n                // if (ec.web.requestAttributes != null) parms.putAll(ec.web.requestAttributes)\n                responseMap.put(\"currentParameters\", ContextJavaUtil.unwrapMap(parms))\n\n                responseMap.put(\"redirectUrl\", '/' + pathElements.join('/'))\n                // logger.warn(\"Sending JSON no authc response: ${responseMap}\")\n                ec.web.sendJsonResponse(responseMap)\n\n                // END what used to be only for requests for a json response\n                */\n\n                /* better to always send a JSON response as above instead of sometimes sending the Login screen, other that status response usually ignored anyway\n                if (\"json\".equals(screenUrlInfo.targetTransitionExtension) || request?.getHeader(\"Accept\")?.contains(\"application/json\")) {\n                } else {\n                    // respond with 401 and the login screen instead of a redirect; JS client libraries handle this much better\n                    this.originalScreenPathNameList = pathElements\n                    // reset screenUrlInfo and call this again to start over with the new target\n                    screenUrlInfo = null\n                    internalRender()\n                }\n\n                return false\n                */\n            } else {\n                // now prepare and send the redirect\n                ScreenUrlInfo suInfo = ScreenUrlInfo.getScreenUrlInfo(this, rootScreenDef, new ArrayList<String>(), loginPath, 0)\n                UrlInstance urlInstance = suInfo.getInstance(this, false)\n                response.sendRedirect(urlInstance.url)\n                return false\n            }\n        }\n\n        // if request not secure and screens requires secure redirect to https\n        ExecutionContextFactoryImpl.WebappInfo webappInfo = ec.ecfi.getWebappInfo(webappName)\n        if (!request.isSecure() && (webSettingsNode == null || webSettingsNode.attribute(\"require-encryption\") != \"false\") &&\n                webappInfo != null && webappInfo.httpsEnabled) {\n            if (logger.isInfoEnabled()) logger.info(\"Screen at location ${currentSd.location}, which is part of ${screenUrlInfo.fullPathNameList} under screen ${screenUrlInfo.fromSd.location} requires an encrypted/secure connection but the request is not secure, sending redirect to secure.\")\n            // save messages in session before redirecting so they can be displayed on the next screen\n            if (wfi != null) wfi.saveMessagesToSession()\n            // redirect to the same URL this came to\n            response.sendRedirect(screenUrlInstance.getUrlWithParams())\n            return false\n        }\n\n        return true\n    }\n\n    boolean doBoundaryComments() {\n        if (screenPathIndex == 0) return false\n        if (boundaryComments != null) return boundaryComments.booleanValue()\n        boundaryComments = \"true\".equals(sfi.ecfi.confXmlRoot.first(\"screen-facade\").attribute(\"boundary-comments\"))\n        return boundaryComments\n    }\n\n    ScreenDefinition getRootScreenDef() { return rootScreenDef }\n    ScreenDefinition getActiveScreenDef() {\n        if (overrideActiveScreenDef != null) return overrideActiveScreenDef\n        // no -1 here because the list includes the root screen\n        return (ScreenDefinition) screenUrlInfo.screenPathDefList.get(screenPathIndex)\n    }\n    ScreenDefinition getNextScreenDef() {\n        if (!getActiveScreenHasNext()) return null\n        return (ScreenDefinition) screenUrlInfo.screenPathDefList.get(screenPathIndex + 1)\n    }\n    String getActiveScreenPathName() {\n        if (screenPathIndex == 0) return \"\"\n        // subtract 1 because path name list doesn't include root screen\n        return screenUrlInfo.fullPathNameList.get(screenPathIndex - 1)\n    }\n    String getNextScreenPathName() {\n        // would subtract 1 because path name list doesn't include root screen, but we want next so use current screenPathIndex\n        return screenUrlInfo.fullPathNameList.get(screenPathIndex)\n    }\n    boolean getActiveScreenHasNext() { return (screenPathIndex + 1) < screenUrlInfo.screenPathDefList.size() }\n\n    ArrayList<String> getActiveScreenPath() {\n        // handle case where root screen is first/zero in list versus a standalone screen\n        if (screenPathIndex == 0) return new ArrayList<String>()\n        ArrayList<String> activePath = new ArrayList<>(screenUrlInfo.fullPathNameList[0..screenPathIndex-1])\n        // logger.info(\"===== activePath=${activePath}, rpd=${screenUrlInfo.renderPathDifference}, spi=${screenPathIndex}, fpi=${fullPathIndex}\\nroot: ${screenUrlInfo.rootSd.location}\\ntarget: ${screenUrlInfo.targetScreen.location}\\nfrom: ${screenUrlInfo.fromSd.location}\\nfrom path: ${screenUrlInfo.fromPathList}\")\n        return activePath\n    }\n\n    // TODO: This may not be the actual place we decided on, but due to lost work this is my best guess\n    // Get the first screen path of the parent screens with a transition specified of the currently rendered screen\n    String getScreenPathHasTransition(String transitionName) {\n        int screenPathDefListSize = screenUrlInfo.screenPathDefList.size()\n        for (int i = 0; i < screenPathDefListSize; i++) {\n            ScreenDefinition screenDef = (ScreenDefinition) screenUrlInfo.screenPathDefList.get(i)\n            if (screenDef.hasTransition(transitionName)) {\n                return '/' + screenUrlInfo.fullPathNameList.subList(0,i).join('/') + (i == 0 ? '' : '/')\n            }\n        }\n        return null\n    }\n\n    String renderSubscreen() {\n        // first see if there is another screen def in the list\n        if (!getActiveScreenHasNext()) {\n            if (screenUrlInfo.fileResourceRef != null) {\n                // NOTE: don't set this.outputContentType, when including in a screen the screen determines the type\n                sfi.ecfi.resourceFacade.template(screenUrlInfo.fileResourceRef.location, writer)\n                return \"\"\n            } else {\n                // HTML encode by default, not ideal for non-html/xml/etc output but important for XSS protection\n                return WebUtilities.encodeHtml(\"Tried to render subscreen in screen [${getActiveScreenDef()?.location}] but there is no subscreens.@default-item, and no more valid subscreen names in the screen path [${screenUrlInfo.fullPathNameList}]\".toString())\n            }\n        }\n\n        ScreenDefinition screenDef = getNextScreenDef()\n        // check the subscreens item for this screen (valid in context)\n        if (screenPathIndex > 0) {\n            String curPathName = getNextScreenPathName()\n            ScreenDefinition parentScreen = getActiveScreenDef()\n            SubscreensItem ssi = parentScreen.getSubscreensItem(curPathName)\n            if (ssi == null) {\n                logger.warn(\"Couldn't find SubscreenItem (render): parent ${parentScreen.getScreenName()}, curPathName ${curPathName}, current ${screenDef.getScreenName()}\\npath list: ${screenUrlInfo.fullPathNameList}\\nscreen list: ${screenUrlInfo.screenPathDefList}\")\n            } else {\n                if (!ssi.isValidInCurrentContext())\n                    throw new ArtifactAuthorizationException(\"The screen ${screenDef.getScreenName()} is not available\")\n            }\n        }\n\n        screenPathIndex++\n        try {\n            if (!stopRenderScreenLocations.contains(screenDef.getLocation())) {\n                writer.flush()\n                screenDef.render(this, !getActiveScreenHasNext())\n                writer.flush()\n            }\n        } catch (Throwable t) {\n            logger.error(\"Error rendering screen [${screenDef.location}]\", t)\n            // HTML encode by default, not ideal for non-html/xml/etc output but important for XSS protection\n            return WebUtilities.encodeHtml(\"Error rendering screen [${screenDef.location}]: ${t.toString()}\".toString())\n        } finally {\n            screenPathIndex--\n        }\n        // NOTE: this returns a String so that it can be used in an FTL interpolation, but it always writes to the writer\n        return \"\"\n    }\n\n    Template getTemplate() {\n        if (macroTemplateLocation != null) {\n            return sfi.getTemplateByLocation(macroTemplateLocation)\n        } else {\n            String overrideTemplateLocation = (String) null\n            // go through entire screenPathDefList so that parent screen can override template even if it isn't rendered to decorate subscreen\n            ArrayList<ScreenDefinition> screenPathDefList = screenUrlInfo.screenPathDefList\n            int screenPathDefListSize = screenPathDefList.size()\n            for (int i = 0; i < screenPathDefListSize; i++) {\n                ScreenDefinition sd = (ScreenDefinition) screenPathDefList.get(i)\n                String curLocation = sd.getMacroTemplateLocation(renderMode)\n                if (curLocation != null && curLocation.length() > 0) overrideTemplateLocation = curLocation\n            }\n            return overrideTemplateLocation != null ? sfi.getTemplateByLocation(overrideTemplateLocation) : sfi.getTemplateByMode(renderMode)\n        }\n    }\n    ScreenWidgetRender getScreenWidgetRender() {\n        ScreenWidgetRender swr = sfi.getWidgetRenderByMode(renderMode)\n        if (swr == null) throw new BaseArtifactException(\"Could not find ScreenWidgerRender implementation for render mode ${renderMode}\")\n        return swr\n    }\n\n    String renderSection(String sectionName) {\n        ScreenDefinition sd = getActiveScreenDef()\n        try {\n            ScreenSection section = sd.getSection(sectionName)\n            if (section == null) throw new BaseArtifactException(\"No section with name [${sectionName}] in screen [${sd.location}]\")\n            writer.flush()\n            section.render(this)\n            writer.flush()\n        } catch (Throwable t) {\n            BaseException.filterStackTrace(t)\n            logger.error(\"Error rendering section [${sectionName}] in screen [${sd.location}]: \" + t.toString(), t)\n            // HTML encode by default, not ideal for non-html/xml/etc output but important for XSS protection\n            return WebUtilities.encodeHtml(\"Error rendering section [${sectionName}] in screen [${sd.location}]: ${t.toString()}\".toString())\n        }\n        // NOTE: this returns a String so that it can be used in an FTL interpolation, but it always writes to the writer\n        return \"\"\n    }\n\n    MNode getSectionIncludedNode(MNode sectionIncludeNode) {\n        ScreenDefinition sd = getActiveScreenDef()\n        String sectionName = getSectionIncludeName(sectionIncludeNode)\n        ScreenSection section = sd.getSection(sectionName)\n        if (section == null) throw new BaseArtifactException(\"No section with name [${sectionName}] in screen [${sd.location}]\")\n        return section.sectionNode\n    }\n\n    String getSectionIncludeName(MNode sectionIncludeNode) {\n        String sectionLocation = sectionIncludeNode.attribute(\"location\")\n        String sectionName = sectionIncludeNode.attribute(\"name\")\n        boolean isDynamic = (sectionLocation != null && sectionLocation.contains('${')) || (sectionName != null && sectionName.contains('${'))\n        if (isDynamic) {\n            ScreenDefinition sd = getActiveScreenDef()\n            sectionLocation = sfi.ecfi.resourceFacade.expandNoL10n(sectionLocation, null)\n            sectionName = sfi.ecfi.resourceFacade.expandNoL10n(sectionName, null)\n            String cacheName = sectionLocation + \"#\" + sectionName\n            if (sd.sectionByName.get(cacheName) == null) sd.pullSectionInclude(sectionIncludeNode)\n            // logger.warn(\"sd.sectionByName ${sd.sectionByName}\")\n            return cacheName\n        } else {\n            return sectionName\n        }\n    }\n    String renderSectionInclude(MNode sectionIncludeNode) {\n        renderSection(getSectionIncludeName(sectionIncludeNode))\n    }\n\n    MNode getFormNode(String formName) {\n        FormInstance fi = getFormInstance(formName)\n        if (fi == null) return null\n        return fi.getFormNode()\n    }\n    FormInstance getFormInstance(String formName) {\n        ScreenDefinition sd = getActiveScreenDef()\n        String nodeCacheKey = sd.getLocation() + \"#\" + formName\n        // NOTE: this is cached in the context of the renderer for multiple accesses; because of form overrides may not\n        // be valid outside the scope of a single screen render\n        FormInstance formNode = screenFormCache.get(nodeCacheKey)\n        if (formNode == null) {\n            ScreenForm form = sd.getForm(formName)\n            if (!form) throw new BaseArtifactException(\"No form with name [${formName}] in screen [${sd.location}]\")\n            formNode = form.getFormInstance()\n            screenFormCache.put(nodeCacheKey, formNode)\n        }\n        return formNode\n    }\n\n    String renderIncludeScreen(String location, String shareScopeStr) {\n        boolean shareScope = shareScopeStr == \"true\"\n\n        ContextStack cs = (ContextStack) ec.context\n        ScreenDefinition oldOverrideActiveScreenDef = overrideActiveScreenDef\n        try {\n            if (!shareScope) cs.push()\n            writer.flush()\n\n            ScreenDefinition screenDef = sfi.getScreenDefinition(location)\n            if (!screenDef) throw new BaseArtifactException(\"Could not find screen at location [${location}]\")\n            overrideActiveScreenDef = screenDef\n            screenDef.render(this, false)\n\n            // this way is more literal, but has issues with relative paths and such:\n            // sfi.makeRender().rootScreen(location).renderMode(renderMode).encoding(characterEncoding)\n            //         .macroTemplate(macroTemplateLocation).render(writer)\n\n            writer.flush()\n        } catch (Throwable t) {\n            logger.error(\"Error rendering screen [${location}]\", t)\n            // HTML encode by default, not ideal for non-html/xml/etc output but important for XSS protection\n            return WebUtilities.encodeHtml(\"Error rendering screen [${location}]: ${t.toString()}\".toString())\n        } finally {\n            overrideActiveScreenDef = oldOverrideActiveScreenDef\n            if (!shareScope) cs.pop()\n        }\n\n        // NOTE: this returns a String so that it can be used in an FTL interpolation, but it always writes to the writer\n        return \"\"\n    }\n\n    /** If isTemplateStr != \"false\" then render a template using renderer based on location extension,\n     * or if no rendered found use isTemplateStr as an extension (like \"ftl\"), and if no template renderer found just write the text */\n    String renderText(String location, String isTemplateStr) {\n        boolean isTemplate = !\"false\".equals(isTemplateStr)\n\n        if (location == null || location.length() == 0 || \"null\".equals(location)) {\n            logger.warn(\"Not rendering text in screen [${getActiveScreenDef().location}], location was empty\")\n            return \"\"\n        }\n        if (isTemplate) {\n            writer.flush()\n            // NOTE: run templates with their own variable space so we can add sri, and avoid getting anything added from within\n            ContextStack cs = (ContextStack) ec.context\n            cs.push()\n            try {\n                cs.put(\"sri\", this)\n                ec.resourceFacade.template(location, writer, isTemplateStr)\n            } finally {\n                cs.pop()\n            }\n            writer.flush()\n            // NOTE: this returns a String so that it can be used in an FTL interpolation, but it always writes to the writer\n            return \"\"\n        } else {\n            return sfi.ecfi.resourceFacade.getLocationText(location, true) ?: \"\"\n        }\n    }\n\n    String appendToAfterScreenWriter(String text) {\n        if (afterScreenWriter == null) afterScreenWriter = new StringWriter()\n        afterScreenWriter.append(text)\n        // NOTE: this returns a String so that it can be used in an FTL interpolation, but it always writes to the writer\n        return \"\"\n    }\n    String getAfterScreenWriterText() {\n        String outText = afterScreenWriter == null ? \"\" : afterScreenWriter.toString()\n        afterScreenWriter = null\n        return outText\n    }\n    String appendToScriptWriter(String text) {\n        if (scriptWriter == null) scriptWriter = new StringWriter()\n        scriptWriter.append(text)\n        // NOTE: this returns a String so that it can be used in an FTL interpolation, but it always writes to the writer\n        return \"\"\n    }\n    String getScriptWriterText() {\n        String outText = scriptWriter == null ? \"\" : scriptWriter.toString()\n        scriptWriter = null\n        return outText\n    }\n\n    ScreenUrlInfo buildUrlInfo(String subscreenPathOrig) {\n        String subscreenPath = subscreenPathOrig?.contains(\"\\${\") ? ec.resource.expand(subscreenPathOrig, \"\") : subscreenPathOrig\n\n        List<String> pathList = getActiveScreenPath()\n        StringBuilder keyBuilder = new StringBuilder()\n        for (String pathElem in pathList) keyBuilder.append(pathElem).append(\"/\")\n        String key = keyBuilder.append(subscreenPath).toString()\n\n        ScreenUrlInfo csui = subscreenUrlInfos.get(key)\n        if (csui != null) {\n            // logger.warn(\"========== found cached ScreenUrlInfo ${key}\")\n            return csui\n        }  else {\n            // logger.warn(\"========== DID NOT find cached ScreenUrlInfo ${key}\")\n        }\n\n        ScreenUrlInfo sui = ScreenUrlInfo.getScreenUrlInfo(this, null, null, subscreenPath, 0)\n        subscreenUrlInfos.put(key, sui)\n        return sui\n    }\n\n    UrlInstance buildUrl(String subscreenPath) {\n        return buildUrlInfo(subscreenPath).getInstance(this, null)\n    }\n    UrlInstance buildUrl(ScreenDefinition fromSd, ArrayList<String> fromPathList, String subscreenPathOrig) {\n        String subscreenPath = subscreenPathOrig?.contains(\"\\${\") ? ec.resource.expand(subscreenPathOrig, \"\") : subscreenPathOrig\n        ScreenUrlInfo ui = ScreenUrlInfo.getScreenUrlInfo(this, fromSd, fromPathList, subscreenPath, 0)\n        return ui.getInstance(this, null)\n    }\n    UrlInstance buildUrlFromTarget(String subscreenPathOrig) {\n        String subscreenPath = subscreenPathOrig?.contains(\"\\${\") ? ec.resource.expand(subscreenPathOrig, \"\") : subscreenPathOrig\n        ScreenUrlInfo ui = ScreenUrlInfo.getScreenUrlInfo(this, screenUrlInfo.targetScreen, screenUrlInfo.preTransitionPathNameList, subscreenPath, 0)\n        return ui.getInstance(this, null)\n    }\n\n    UrlInstance makeUrlByType(String origUrl, String urlType, MNode parameterParentNode, String expandTransitionUrlString) {\n        Boolean expandTransitionUrl = expandTransitionUrlString != null ? \"true\".equals(expandTransitionUrlString) : null\n        /* TODO handle urlType=content: A content location (without the content://). URL will be one that can access that content. */\n        ScreenUrlInfo suInfo\n        String urlTypeExpanded = ec.resource.expand(urlType, \"\")\n        switch (urlTypeExpanded) {\n            // for transition we want a URL relative to the current screen, so just pass that to buildUrl\n            case \"transition\": suInfo = buildUrlInfo(origUrl); break\n            case \"screen\": suInfo = buildUrlInfo(origUrl); break\n            case \"content\": throw new BaseArtifactException(\"The url-type of content is not yet supported\"); break\n            case \"plain\":\n            default:\n                String url = ec.resource.expand(origUrl, \"\")\n                suInfo = ScreenUrlInfo.getScreenUrlInfo(this, url)\n                break\n        }\n\n        UrlInstance urli = suInfo.getInstance(this, expandTransitionUrl)\n\n        if (parameterParentNode != null) {\n            String parameterMapStr = (String) parameterParentNode.attribute(\"parameter-map\")\n            if (parameterMapStr != null && !parameterMapStr.isEmpty()) {\n                Map ctxParameterMap = (Map) ec.resource.expression(parameterMapStr, \"\")\n                if (ctxParameterMap) urli.addParameters(ctxParameterMap)\n            }\n            ArrayList<MNode> parameterNodes = parameterParentNode.children(\"parameter\")\n            int parameterNodesSize = parameterNodes.size()\n            for (int i = 0; i < parameterNodesSize; i++) {\n                MNode parameterNode = (MNode) parameterNodes.get(i)\n                String name = parameterNode.attribute(\"name\")\n                String from = parameterNode.attribute(\"from\")\n                if (from == null || from.isEmpty()) from = name\n                urli.addParameter(name, getContextValue(from, parameterNode.attribute(\"value\")))\n            }\n        }\n\n        return urli\n    }\n\n    Object getContextValue(String from, String value) {\n        if (value) {\n            return ec.resource.expand(value, getActiveScreenDef().location, (Map) ec.contextStack.get(\"_formMap\"))\n        } else if (from) {\n            return ec.resource.expression(from, getActiveScreenDef().location, (Map) ec.contextStack.get(\"_formMap\"))\n        } else {\n            return \"\"\n        }\n    }\n    String setInContext(MNode setNode) {\n        ((ResourceFacadeImpl) ec.resource).setInContext(setNode.attribute(\"field\"),\n                setNode.attribute(\"from\"), setNode.attribute(\"value\"),\n                setNode.attribute(\"default-value\"), setNode.attribute(\"type\"),\n                setNode.attribute(\"set-if-empty\"))\n        return \"\"\n    }\n    String pushContext() { ec.contextStack.push(); return \"\" }\n    String popContext() { ec.contextStack.pop(); return \"\" }\n\n    /** Call this at the beginning of a form-single or for form-list.@map-first-row and @map-last-row. Always call popContext() at the end of the form! */\n    String pushSingleFormMapContext(String mapExpr) {\n        ContextStack cs = ec.contextStack\n        Map valueMap = null\n        if (mapExpr != null && !mapExpr.isEmpty()) valueMap = (Map) ec.resourceFacade.expression(mapExpr, null)\n        if (valueMap instanceof EntityValue) valueMap = ((EntityValue) valueMap).getMap()\n        if (valueMap == null) valueMap = new HashMap()\n\n        cs.push()\n        cs.putAll(valueMap)\n        cs.put(\"_formMap\", valueMap)\n\n        return \"\"\n    }\n    Map getSingleFormMap(String mapExpr) {\n        Map valueMap = null\n        if (mapExpr != null && !mapExpr.isEmpty()) valueMap = (Map) ec.resourceFacade.expression(mapExpr, null)\n        if (valueMap instanceof EntityValue) valueMap = ((EntityValue) valueMap).getMap()\n        if (valueMap == null) valueMap = new HashMap()\n        return valueMap\n    }\n    String startFormListRow(ScreenForm.FormListRenderInfo listRenderInfo, Object listEntry, int index, boolean hasNext) {\n        ContextStack cs = ec.contextStack\n        cs.push()\n\n        if (listEntry instanceof Map) {\n            Map valueMap = (Map) listEntry\n            if (valueMap instanceof EntityValue) valueMap = ((EntityValue) valueMap).getMap()\n            cs.putAll(valueMap)\n            cs.put(\"_formMap\", valueMap)\n        } else {\n            throw new BaseArtifactException(\"Found form-list ${listRenderInfo.getFormNode().attribute('name')} list entry that is not a Map, is a ${listEntry.class.name} which should never happen after running list through list pre-processor\")\n        }\n        // NOTE: this returns an empty String so that it can be used in an FTL interpolation, but nothing is written\n        return \"\"\n    }\n    String endFormListRow() {\n        ec.contextStack.pop()\n        // NOTE: this returns an empty String so that it can be used in an FTL interpolation, but nothing is written\n        return \"\"\n    }\n    String startFormListSubRow(ScreenForm.FormListRenderInfo listRenderInfo, Object subListEntry, int index, boolean hasNext) {\n        ContextStack cs = ec.contextStack\n        cs.push()\n        MNode formNode = listRenderInfo.formNode\n        if (subListEntry instanceof Map) {\n            Map valueMap = (Map) subListEntry\n            if (valueMap instanceof EntityValue) valueMap = ((EntityValue) valueMap).getMap()\n            cs.putAll(valueMap)\n            cs.put(\"_formMap\", valueMap)\n        } else {\n            throw new BaseArtifactException(\"Found form-list ${listRenderInfo.getFormNode().attribute('name')} sub-list entry that is not a Map, is a ${subListEntry.class.name} which should never happen after running list through list pre-processor\")\n        }\n        String listStr = formNode.attribute('list')\n        cs.put(listStr + \"_sub_index\", index)\n        cs.put(listStr + \"_sub_has_next\", hasNext)\n        cs.put(listStr + \"_sub_entry\", subListEntry)\n        // NOTE: this returns an empty String so that it can be used in an FTL interpolation, but nothing is written\n        return \"\"\n    }\n    String endFormListSubRow() {\n        ec.contextStack.pop()\n        // NOTE: this returns an empty String so that it can be used in an FTL interpolation, but nothing is written\n        return \"\"\n    }\n    static String safeCloseList(Object listObject) {\n        if (listObject instanceof EntityListIterator) ((EntityListIterator) listObject).close()\n        // NOTE: this returns an empty String so that it can be used in an FTL interpolation, but nothing is written\n        return \"\"\n    }\n\n    String getFieldValueString(MNode widgetNode) {\n        MNode fieldNodeWrapper = widgetNode.parent.parent\n        String defaultValue = widgetNode.attribute(\"default-value\")\n        if (defaultValue == null) defaultValue = \"\"\n        String format = widgetNode.attribute(\"format\")\n        if (\"text\".equals(renderMode) || \"csv\".equals(renderMode)) {\n            String textFormat = widgetNode.attribute(\"text-format\")\n            if (textFormat != null && !textFormat.isEmpty()) format = textFormat\n        }\n\n        Object obj = getFieldValue(fieldNodeWrapper, defaultValue)\n        if (obj == null) return \"\"\n        if (obj instanceof CharSequence) return obj.toString()\n        String strValue = ec.l10nFacade.format(obj, format)\n        return strValue\n    }\n    String getFieldValueString(MNode fieldNodeWrapper, String defaultValue, String format) {\n        Object obj = getFieldValue(fieldNodeWrapper, defaultValue)\n        if (obj == null) return \"\"\n        if (obj instanceof String) return (String) obj\n        String strValue = ec.l10nFacade.format(obj, format)\n        return strValue\n    }\n    String getFieldValuePlainString(MNode fieldNodeWrapper, String defaultValue) {\n        // NOTE: defaultValue is handled below so that for a plain string it is not run through expand\n        Object obj = getFieldValue(fieldNodeWrapper, \"\")\n        if (ObjectUtilities.isEmpty(obj) && defaultValue != null && defaultValue.length() > 0)\n            return ec.resourceFacade.expandNoL10n(defaultValue, \"\")\n        return ObjectUtilities.toPlainString(obj)\n    }\n    String getNamedValuePlain(String fieldName, MNode formNode) {\n        Object value = null\n        if (\"form-single\".equals(formNode.name)) {\n            String mapAttr = formNode.attribute(\"map\")\n            String mapName = mapAttr != null && mapAttr.length() > 0 ? mapAttr : \"fieldValues\"\n            Map valueMap = (Map) ec.resource.expression(mapName, \"\")\n\n            if (valueMap != null) {\n                try {\n                    if (valueMap instanceof EntityValueBase) {\n                        // if it is an EntityValueImpl, only get if the fieldName is a value\n                        EntityValueBase evb = (EntityValueBase) valueMap\n                        if (evb.getEntityDefinition().isField(fieldName)) value = evb.get(fieldName)\n                    } else {\n                        value = valueMap.get(fieldName)\n                    }\n                } catch (EntityException e) {\n                    // do nothing, not necessarily an entity field\n                    if (isTraceEnabled) logger.trace(\"Ignoring entity exception for non-field: ${e.toString()}\")\n                }\n            }\n        }\n        if (value == null) value = ec.contextStack.getByString(fieldName)\n        return ObjectUtilities.toPlainString(value)\n    }\n\n    Object getFieldValue(MNode fieldNode, String defaultValue) {\n        String fieldName = fieldNode.attribute(\"name\")\n        Object value = null\n\n        MNode formNode = fieldNode.parent\n        if (\"form-single\".equals(formNode.name)) {\n            // if this is an error situation try error parameters first\n            Map<String, Object> errorParameters = ec.getWeb()?.getErrorParameters()\n            if (errorParameters != null && (errorParameters.moquiFormName == fieldNode.parent.attribute(\"name\"))) {\n                value = errorParameters.get(fieldName)\n                if (!ObjectUtilities.isEmpty(value)) return value\n            }\n\n            // NOTE: field.@from attribute is handled for form-list in pre-processing done by AggregationUtil\n            String fromAttr = fieldNode.attribute(\"from\")\n            if (fromAttr == null || fromAttr.isEmpty()) fromAttr = fieldNode.attribute(\"entry-name\")\n            if (fromAttr != null && fromAttr.length() > 0) return ec.resourceFacade.expression(fromAttr, null)\n\n            String mapAttr = formNode.attribute(\"map\")\n            String mapName = mapAttr != null && mapAttr.length() > 0 ? mapAttr : \"fieldValues\"\n            Map valueMap = (Map) ec.resourceFacade.expression(mapName, \"\")\n\n            if (valueMap != null) {\n                try {\n                    if (valueMap instanceof EntityValueBase) {\n                        // if it is an EntityValueImpl, only get if the fieldName is a value\n                        EntityValueBase evb = (EntityValueBase) valueMap\n                        if (evb.getEntityDefinition().isField(fieldName)) value = evb.get(fieldName)\n                    } else {\n                        value = valueMap.get(fieldName)\n                    }\n                } catch (EntityException e) {\n                    // do nothing, not necessarily an entity field\n                    if (isTraceEnabled) logger.trace(\"Ignoring entity exception for non-field: ${e.toString()}\")\n                }\n            }\n        }\n\n        // the value == null check here isn't necessary but is the most common case so\n        if (value == null || ObjectUtilities.isEmpty(value)) {\n            value = ec.contextStack.getByString(fieldName)\n            if (!ObjectUtilities.isEmpty(value)) return value\n        } else {\n            return value\n        }\n\n        String defaultStr = ec.resourceFacade.expandNoL10n(defaultValue, null)\n        if (defaultStr != null && defaultStr.length() > 0) return defaultStr\n        return value\n    }\n    String getFieldValueClass(MNode fieldNodeWrapper) {\n        Object fieldValue = getFieldValue(fieldNodeWrapper, null)\n        return fieldValue != null ? fieldValue.getClass().getSimpleName() : \"String\"\n    }\n\n    String getFieldEntityValue(MNode widgetNode) {\n        MNode fieldNode = widgetNode.parent.parent\n        Object fieldValue = getFieldValue(fieldNode, \"\")\n        if (fieldValue == null) return getDefaultText(widgetNode)\n        String entityName = widgetNode.attribute(\"entity-name\")\n        EntityDefinition ed = sfi.ecfi.entityFacade.getEntityDefinition(entityName)\n\n        // find the entity value\n        String keyFieldName = widgetNode.attribute(\"key-field-name\")\n        if (keyFieldName == null || keyFieldName.isEmpty()) keyFieldName = widgetNode.attribute(\"entity-key-name\")\n        if ((keyFieldName == null || keyFieldName.isEmpty()) && ed != null) keyFieldName = ed.getPkFieldNames().get(0)\n        String useCache = widgetNode.attribute(\"use-cache\") ?: widgetNode.attribute(\"entity-use-cache\") ?: \"true\"\n        EntityValue ev = ec.entity.find(entityName).condition(keyFieldName, fieldValue)\n                .useCache(useCache == \"true\").one()\n        if (ev == null) return getDefaultText(widgetNode)\n\n        String value = \"\"\n        String text = (String) widgetNode.attribute(\"text\")\n        if (text != null && text.length() > 0) {\n            // push onto the context and then expand the text\n            ec.context.push(ev.getMap())\n            try {\n                value = ec.resource.expand(text, null)\n            } finally {\n                ec.context.pop()\n            }\n        } else {\n            // get the value of the default description field for the entity\n            String defaultDescriptionField = ed.getDefaultDescriptionField()\n            if (defaultDescriptionField) value = ev.get(defaultDescriptionField)\n        }\n        return value\n    }\n    protected String getDefaultText(MNode widgetNode) {\n        String defaultText = widgetNode.attribute(\"default-text\")\n        if (defaultText != null && defaultText.length() > 0) {\n            return ec.resource.expand(defaultText, null)\n        } else {\n            return \"\"\n        }\n    }\n\n    Map<String, Object> getFormFieldValues(MNode formNode) {\n        Map<String, Object> fieldValues = new LinkedHashMap<>()\n\n        if (\"true\".equals(formNode.attribute(\"pass-through-parameters\")))\n            fieldValues.putAll(getScreenUrlInstance().getPassThroughParameterMap())\n\n        fieldValues.put(\"moquiFormName\", formNode.attribute(\"name\"))\n        String lastUpdatedString = getNamedValuePlain(\"lastUpdatedStamp\", formNode)\n        if (lastUpdatedString != null && !lastUpdatedString.isEmpty()) fieldValues.put(\"lastUpdatedStamp\", lastUpdatedString)\n\n        ArrayList<MNode> allFieldNodes = formNode.children(\"field\")\n        int afnSize = allFieldNodes.size()\n        for (int i = 0; i < afnSize; i++) {\n            MNode fieldNode = (MNode) allFieldNodes.get(i)\n            addFormFieldValue(fieldNode, fieldValues, (char) 'r')\n        }\n        return fieldValues\n    }\n\n    Map<String, Object> getFormListHeaderValues(MNode formNode) {\n        Map<String, Object> fieldValues = new LinkedHashMap<>()\n\n        // add hidden-parameters values\n        fieldValues.putAll(getFormHiddenParameters(formNode))\n\n        ArrayList<MNode> allFieldNodes = formNode.children(\"field\")\n        int afnSize = allFieldNodes.size()\n        for (int i = 0; i < afnSize; i++) {\n            MNode fieldNode = (MNode) allFieldNodes.get(i)\n            addFormFieldValue(fieldNode, fieldValues, (char) 'h')\n        }\n\n        // add orderByField\n        String orderByFieldAll = ec.contextStack.getByString(\"orderByField\")\n        if (orderByFieldAll != null && !orderByFieldAll.isEmpty()) {\n            fieldValues.put(\"orderByField\", new ArrayList(Arrays.asList(orderByFieldAll.split(\",\"))))\n        } else {\n            fieldValues.put(\"orderByField\", new ArrayList())\n        }\n\n        // formListFindId\n        String formListFindId = ec.contextStack.getByString(\"formListFindId\")\n        if (formListFindId != null && !formListFindId.isEmpty()) fieldValues.put(\"formListFindId\", formListFindId)\n        // pageSize\n        String listName = formNode.attribute(\"list\")\n        Object pageSize = ec.contextStack.getByString(listName + \"PageSize\") ?: ec.contextStack.getByString(\"pageSize\")\n        if (pageSize) fieldValues.put(\"pageSize\", pageSize.toString())\n\n        return fieldValues\n    }\n\n    ArrayList<Map<String, Object>> getFormListRowValues(ScreenForm.FormListRenderInfo renderInfo) {\n        // get row data, aggregated if needed and row-actions run\n        ArrayList<Map<String, Object>> listObject = renderInfo.getListObject(true)\n        return transformFormListRowList(renderInfo, listObject)\n    }\n    ArrayList<Map<String, Object>> transformFormListRowList(ScreenForm.FormListRenderInfo renderInfo, ArrayList<Map<String, Object>> listObject) {\n        // convert raw data to formatted strings, fill in auxiliary values, etc\n        int rowsSize = listObject.size()\n        ArrayList<Map<String, Object>> outRows = new ArrayList<>(rowsSize)\n        for (int ri = 0; ri < rowsSize; ri++) {\n            Map<String, Object> row = (Map<String, Object>) listObject.get(ri)\n            outRows.add(transformFormListRow(renderInfo, row, (char) 'r'))\n        }\n        return outRows\n    }\n    Map<String, Object> transformFormListRow(ScreenForm.FormListRenderInfo renderInfo, Map<String, Object> row, char rowType) {\n        ArrayList<MNode> fieldNodeList = renderInfo.getFormNode().children(\"field\")\n        int fieldNodeListSize = fieldNodeList.size()\n        Set<String> displayedFields = renderInfo.getDisplayedFields()\n        Set<String> hiddenFields = renderInfo.getFormInstance().getListHiddenFieldNameSet()\n        // logger.warn(\"form ${renderInfo.formNode.attribute('name')} displayed ${displayedFields} hidden ${hiddenFields}\")\n        ContextStack cs = ec.contextStack\n\n        // NOTE: not using copy constructor (new LinkedHashMap<>(row)), only want relevant output fields used for client rendering and client managed form fields\n        //  this avoids _entry, _has_next, _index auto added fields, per row service output, and much more\n        Map<String, Object> outRow = new LinkedHashMap<>()\n        for (int fni = 0; fni < fieldNodeListSize; fni++) {\n            MNode fieldNode = (MNode) fieldNodeList.get(fni)\n            String fieldName = fieldNode.attribute(\"name\")\n            // logger.warn(\"form ${renderInfo.formNode.attribute( 'name')} field ${fieldName} raw val ${row.get(fieldName)}\")\n            if (displayedFields.contains(fieldName) || hiddenFields.contains(fieldName)) {\n                // field values come from context so push current row, like SRI.startFormListRow() but slightly different approach with 2nd push to prevent potential writes\n                cs.push(row)\n                cs.push()\n                try {\n                    addFormFieldValue(fieldNode, outRow, rowType)\n                } finally {\n                    cs.pop()\n                    cs.pop()\n                }\n            }\n        }\n        // logger.warn(\"form-list row values\\norig: ${JsonOutput.prettyPrint(JsonOutput.toJson(row))}\\nout: ${JsonOutput.prettyPrint(JsonOutput.toJson(outRow))}\")\n        return outRow\n    }\n\n    // NOTE: this takes a fieldValues Map as a parameter to populate because a singe form field may have multiple values\n    void addFormFieldValue(MNode fieldNode, Map<String, Object> fieldValues, char rowType) {\n        String fieldName = fieldNode.attribute(\"name\")\n\n        MNode activeSubNode = (MNode) null\n        if (rowType == (char) 'h') {\n            activeSubNode = fieldNode.first(\"header-field\")\n        } else if (rowType == (char) 'f') {\n            activeSubNode = fieldNode.first(\"first-row-field\")\n        } else if (rowType == (char) 's') {\n            activeSubNode = fieldNode.first(\"second-row-field\")\n        } else if (rowType == (char) 'l') {\n            activeSubNode = fieldNode.first(\"last-row-field\")\n        } else {\n            ArrayList<MNode> condFieldNodeList = fieldNode.children(\"conditional-field\")\n            for (int j = 0; j < condFieldNodeList.size(); j++) {\n                MNode condFieldNode = (MNode) condFieldNodeList.get(j)\n                String condition = condFieldNode.attribute(\"condition\")\n                if (condition == null || condition.isEmpty()) {\n                    logger.warn(\"Screen ${activeScreenDef.getScreenName()} field ${fieldName} conditional-field has no condition, skipping\")\n                    continue\n                }\n                // logger.warn(\"condition ${condition}, eval: ${ec.resourceFacade.condition(condition, null)}\")\n                try {\n                    if (ec.resourceFacade.condition(condition, null)) {\n                        activeSubNode = condFieldNode\n                        // use first conditional-field with passing condition\n                        break\n                    }\n                } catch (Throwable t) {\n                    logger.warn(\"Error evaluating condition ${condition} on field ${fieldName} on screen ${this.getActiveScreenDef().getLocation()}\", t)\n                }\n            }\n            if (activeSubNode == null) activeSubNode = fieldNode.first(\"default-field\")\n        }\n        // logger.warn(\"field ${fieldName} activeSubNode ${activeSubNode?.toString()}\")\n        if (activeSubNode == null) return\n\n        ArrayList<MNode> childNodeList = activeSubNode.getChildren()\n        int childNodeListSize = childNodeList.size()\n\n        // check 'set' elements used with widget-template-include\n        ArrayList<MNode> setNodeList = new ArrayList<>(childNodeListSize)\n        for (int k = 0; k < childNodeListSize; k++) {\n            MNode widgetNode = (MNode) childNodeList.get(k)\n            if (\"set\".equals(widgetNode.getName())) setNodeList.add(widgetNode)\n        }\n        if (setNodeList.size() > 0) {\n            ec.contextStack.push()\n            for (int si = 0; si < setNodeList.size(); si++) { setInContext((MNode) setNodeList.get(si)) }\n        }\n\n        for (int k = 0; k < childNodeListSize; k++) {\n            MNode widgetNode = (MNode) childNodeList.get(k)\n            String widgetName = widgetNode.getName()\n            // set element used with widget-template-include, skip here\n            if (\"set\".equals(widgetName)) continue\n\n            String valuePlainString = getFieldValuePlainString(fieldNode, \"\")\n            if (valuePlainString == null || valuePlainString.isEmpty())\n                valuePlainString = ec.resourceFacade.expandNoL10n(widgetNode.attribute(\"no-current-selected-key\"), null)\n            if (valuePlainString != null && !valuePlainString.isEmpty() && valuePlainString.charAt(0) == ('[' as char))\n                valuePlainString = valuePlainString.substring(1, valuePlainString.length() - 1).replaceAll(\" \", \"\")\n            String[] currentValueArr = valuePlainString != null && !valuePlainString.isEmpty() ? valuePlainString.split(\",\") : null\n\n            if (\"display\".equals(widgetName)) {\n                // primary value is for hidden field only, otherwise add nothing (display only)\n                String alsoHidden = widgetNode.attribute(\"also-hidden\")\n                if (alsoHidden == null || alsoHidden.isEmpty() || \"true\".equals(alsoHidden))\n                    fieldValues.put(fieldName, valuePlainString)\n\n                // display value, reproduce logic that was in the ftl display macro\n                String fieldValue = (String) null\n                String textAttr = widgetNode.attribute(\"text\")\n                String currencyAttr = widgetNode.attribute(\"currency-unit-field\")\n                String currencyNoSymbolAttr = widgetNode.attribute(\"currency-hide-symbol\")\n                if (textAttr != null && ! textAttr.isEmpty()) {\n                    String textMapAttr = widgetNode.attribute(\"text-map\")\n                    Map textMap = (Map) null\n                    if (textMapAttr != null && !textMapAttr.isEmpty())\n                        textMap = (Map) ec.resourceFacade.expression(textMapAttr, null)\n                    if (textMap != null && textMap.size() > 0) {\n                        fieldValue = ec.resourceFacade.expand(textAttr, null, textMap)\n                    } else {\n                        fieldValue = ec.resourceFacade.expand(textAttr, null)\n                    }\n                    if (currencyAttr != null && !currencyAttr.isEmpty()) {\n                        if (currencyNoSymbolAttr == \"true\")\n                            fieldValue = ec.l10nFacade.formatCurrencyNoSymbol(fieldValue, ec.resourceFacade.expression(currencyAttr, null) as String)\n                        else\n                            fieldValue = ec.l10nFacade.formatCurrency(fieldValue, ec.resourceFacade.expression(currencyAttr, null) as String)\n                    }\n                } else if (currencyAttr != null && !currencyAttr.isEmpty()) {\n                    if (currencyNoSymbolAttr == \"true\")\n                        fieldValue = ec.l10nFacade.formatCurrencyNoSymbol(getFieldValue(fieldNode, \"\"), ec.resourceFacade.expression(currencyAttr, null) as String)\n                    else\n                    fieldValue = ec.l10nFacade.formatCurrency(getFieldValue(fieldNode, \"\"), ec.resourceFacade.expression(currencyAttr, null) as String)\n                } else {\n                    fieldValue = getFieldValueString(widgetNode)\n                }\n                fieldValues.put(fieldName + \"_display\", fieldValue)\n\n                // TODO: handle dynamic-transition attribute for initial value, and dynamic on client side too\n            } else if (\"drop-down\".equals(widgetName)) {\n                boolean allowMultiple = \"true\".equals(ec.resourceFacade.expandNoL10n(widgetNode.attribute(\"allow-multiple\"), null))\n                if (allowMultiple) {\n                    fieldValues.put(fieldName, currentValueArr != null ? new ArrayList(Arrays.asList(currentValueArr)) : null)\n                    fieldValues.put(fieldName + \"_op\", \"in\")\n                } else {\n                    fieldValues.put(fieldName, currentValueArr != null && currentValueArr.length > 0 ? currentValueArr[0] : null)\n                }\n                if (ec.resourceFacade.expandNoL10n(widgetNode.attribute(\"show-not\"), \"\") == \"true\") {\n                    fieldValues.put(fieldName + \"_not\", ec.contextStack.getByString(fieldName + \"_not\") ?: \"N\")\n                }\n            } else if (\"text-line\".equals(widgetName)) {\n                fieldValues.put(fieldName, getFieldValueString(widgetNode))\n            } else if (\"check\".equals(widgetName)) {\n                if (\"true\".equals(ec.resourceFacade.expandNoL10n(widgetNode.attribute(\"all-checked\"), null))) {\n                    // get all options and add ArrayList\n                    Set<String> fieldOptionKeys = getFieldOptions(widgetNode).keySet()\n                    fieldValues.put(fieldName, new ArrayList(fieldOptionKeys))\n                } else {\n                    if (currentValueArr == null || currentValueArr.length == 0) fieldValues.put(fieldName, new ArrayList())\n                    else fieldValues.put(fieldName, new ArrayList(Arrays.asList(currentValueArr)))\n                }\n            } else if (\"date-find\".equals(widgetName)) {\n                String type = widgetNode.attribute(\"type\")\n                String defaultFormat = \"date\".equals(type) ? \"yyyy-MM-dd\" : (\"time\".equals(type) ? \"HH:mm\" : \"yyyy-MM-dd HH:mm\")\n                String fieldValueFrom = ec.l10nFacade.format(ec.contextStack.getByString(fieldName + \"_from\") ?: widgetNode.attribute(\"default-value-from\"), defaultFormat)\n                String fieldValueThru = ec.l10nFacade.format(ec.contextStack.getByString(fieldName + \"_thru\") ?: widgetNode.attribute(\"default-value-thru\"), defaultFormat)\n                fieldValues.put(fieldName + \"_from\", fieldValueFrom)\n                fieldValues.put(fieldName + \"_thru\", fieldValueThru)\n            } else if (\"date-period\".equals(widgetName)) {\n                fieldValues.put(fieldName + \"_poffset\", ec.contextStack.getByString(fieldName + \"_poffset\"))\n                fieldValues.put(fieldName + \"_period\", ec.contextStack.getByString(fieldName + \"_period\"))\n                fieldValues.put(fieldName + \"_pdate\", ec.contextStack.getByString(fieldName + \"_pdate\"))\n                fieldValues.put(fieldName + \"_from\", ec.contextStack.getByString(fieldName + \"_from\"))\n                fieldValues.put(fieldName + \"_thru\", ec.contextStack.getByString(fieldName + \"_thru\"))\n            } else if (\"date-time\".equals(widgetName)) {\n                String type = widgetNode.attribute(\"type\")\n                String javaFormat = widgetNode.attribute(\"format\")\n                if (javaFormat == null)\n                    javaFormat = \"date\".equals(type) ? \"yyyy-MM-dd\" : (\"time\".equals(type) ? \"HH:mm\" : \"yyyy-MM-dd HH:mm\")\n                fieldValues.put(fieldName, getFieldValueString(fieldNode, widgetNode.attribute(\"default-value\"), javaFormat))\n            } else if (\"display-entity\".equals(widgetName)) {\n                // primary value is for hidden field only, otherwise add nothing (display only)\n                String alsoHidden = widgetNode.attribute(\"also-hidden\")\n                if (alsoHidden == null || alsoHidden.isEmpty() || \"true\".equals(alsoHidden))\n                    fieldValues.put(fieldName, valuePlainString)\n\n                // display value, reproduce logic that was in the ftl display macro\n                fieldValues.put(fieldName + \"_display\", getFieldEntityValue(widgetNode))\n            } else if (\"hidden\".equals(widgetName)) {\n                fieldValues.put(fieldName, getFieldValuePlainString(fieldNode, widgetNode.attribute(\"default-value\")))\n            } else if (\"file\".equals(widgetName) || \"ignored\".equals(widgetName) || \"password\".equals(widgetName)) {\n                // do nothing\n            } else if (\"radio\".equals(widgetName)) {\n                fieldValues.put(fieldName, getFieldValueString(fieldNode, widgetNode.attribute(\"no-current-selected-key\"), null))\n            } else if (\"range-find\".equals(widgetName)) {\n                fieldValues.put(fieldName + \"_from\", ec.contextStack.getByString(fieldName + \"_from\"))\n                fieldValues.put(fieldName + \"_thru\", ec.contextStack.getByString(fieldName + \"_thru\"))\n            } else if (\"text-area\".equals(widgetName)) {\n                fieldValues.put(fieldName, getFieldValueString(widgetNode))\n            } else if (\"text-find\".equals(widgetName)) {\n                fieldValues.put(fieldName, getFieldValueString(widgetNode))\n\n                String opName = fieldName + \"_op\"\n                String opValue = ec.contextStack.getByString(opName) ?: widgetNode.attribute(\"default-operator\") ?: \"contains\"\n                fieldValues.put(opName, opValue)\n\n                String notName = fieldName + \"_not\"\n                String notValue = ec.contextStack.getByString(notName)\n                fieldValues.put(notName, notValue ?: \"N\")\n\n                String icName = fieldName + \"_ic\"\n                String icAttr = widgetNode.attribute(\"ignore-case\")\n                String icValue = ec.contextStack.getByString(icName)\n                if ((icValue == null || icValue.isEmpty()) && (icAttr == null || icAttr.isEmpty() || icAttr.equals(\"true\"))) icValue = \"Y\"\n                fieldValues.put(icName, icValue ?: \"N\")\n            } else if (!\"submit\".equals(widgetName) && !\"link\".equals(widgetName)) {\n                // unknown/other type\n                fieldValues.put(fieldName, valuePlainString)\n            }\n        }\n\n        if (setNodeList.size() > 0) ec.contextStack.pop()\n    }\n\n    LinkedHashMap<String, String> getFieldOptions(MNode widgetNode) {\n        LinkedHashMap<String, String> optsMap = ScreenForm.getFieldOptions(widgetNode, ec)\n        if (optsMap.size() == 0 && widgetNode.hasChild(\"dynamic-options\")) {\n            MNode childNode = widgetNode.first(\"dynamic-options\")\n            if (!\"true\".equals(childNode.attribute(\"server-search\"))) {\n                // a bit of a hack, use ScreenTest to call the transition server-side as if it were a web request\n                String transition = childNode.attribute(\"transition\")\n                String labelField = childNode.attribute(\"label-field\") ?: \"label\"\n                String valueField = childNode.attribute(\"value-field\") ?: \"value\"\n\n                Map<String, Object> parameters = new HashMap<>()\n                boolean hasAllDepends = addNodeParameters(childNode, parameters)\n                // logger.warn(\"getFieldOptions parameters ${parameters}\")\n\n                if (hasAllDepends) {\n                    UrlInstance transUrl = buildUrl(transition)\n                    ScreenTest screenTest = ec.screen.makeTest().rootScreen(rootScreenLocation).skipJsonSerialize(true)\n                    ScreenTest.ScreenTestRender str = screenTest.render(transUrl.getPathWithParams(), parameters, null)\n\n                    Object jsonObj = str.getJsonObject()\n                    List optsList = null\n                    if (jsonObj instanceof List) {\n                        optsList = (List) jsonObj\n                    } else if (jsonObj instanceof Map) {\n                        Map jsonMap = (Map) jsonObj\n                        Object optionsObj = jsonMap.get(\"options\")\n                        if (optionsObj instanceof List) optsList = (List) optionsObj\n                    }\n                    if (optsList != null) for (Object entryObj in optsList) {\n                        if (entryObj instanceof Map) {\n                            Map entryMap = (Map) entryObj\n                            String valueObj = entryMap.get(valueField)\n                            String labelObj = entryMap.get(labelField)\n                            if (valueObj && labelObj) optsMap.put(valueObj, labelObj)\n                        }\n                    }\n\n                    /* old approach before skipJsonSerialize\n                    String output = str.getOutput()\n\n                    try {\n                        Object jsonObj = new JsonSlurper().parseText(output)\n                        List optsList = null\n                        if (jsonObj instanceof List) {\n                            optsList = (List) jsonObj\n                        } else if (jsonObj instanceof Map) {\n                            Map jsonMap = (Map) jsonObj\n                            Object optionsObj = jsonMap.get(\"options\")\n                            if (optionsObj instanceof List) optsList = (List) optionsObj\n                        }\n                        if (optsList != null) for (Object entryObj in optsList) {\n                            if (entryObj instanceof Map) {\n                                Map entryMap = (Map) entryObj\n                                String valueObj = entryMap.get(valueField)\n                                String labelObj = entryMap.get(labelField)\n                                if (valueObj && labelObj) optsMap.put(valueObj, labelObj)\n                            }\n                        }\n                    } catch (Throwable t) {\n                        logger.warn(\"Error getting field options from transition\", t)\n                    }\n                    */\n                }\n            }\n        }\n        return optsMap\n    }\n\n    /** This is messy, does a server-side/internal 'test' render so we can get the label/description for the current value\n     * from the transition written for client access. */\n    String getFieldTransitionValue(String transition, MNode parameterParentNode, String term, String labelField, boolean alwaysGet) {\n        if (!alwaysGet && (term == null || term.isEmpty())) return null\n        if (!labelField) labelField = \"label\"\n\n        Map<String, Object> parameters = new HashMap<>()\n        parameters.put(\"term\", term)\n        boolean hasAllDepends = addNodeParameters(parameterParentNode, parameters)\n        // logger.warn(\"getFieldTransitionValue parameters ${parameters}\")\n        // logger.warn(\"getFieldTransitionValue context ${ec.context.keySet()}\")\n        if (!hasAllDepends) return null\n\n        UrlInstance transUrl = buildUrl(transition)\n        ScreenTest screenTest = sfi.makeTest().rootScreen(rootScreenLocation)\n        ScreenTest.ScreenTestRender str = screenTest.render(transUrl.getPathWithParams(), parameters, null)\n        String output = str.getOutput()\n\n        String transValue = null\n        Object jsonObj = null\n        try {\n            jsonObj = new JsonSlurper().parseText(output)\n            if (jsonObj instanceof List && ((List) jsonObj).size() > 0) {\n                Object firstObj = ((List) jsonObj).get(0)\n                if (firstObj instanceof Map) {\n                    transValue = ((Map) firstObj).get(labelField)\n                } else {\n                    transValue = firstObj.toString()\n                }\n            } else if (jsonObj instanceof Map) {\n                Map jsonMap = (Map) jsonObj\n                Object optionsObj = jsonMap.get(\"options\")\n                if (optionsObj instanceof List && ((List) optionsObj).size() > 0) {\n                    Object firstObj = ((List) optionsObj).get(0)\n                    if (firstObj instanceof Map) {\n                        transValue = ((Map) firstObj).get(labelField)\n                    } else {\n                        transValue = firstObj.toString()\n                    }\n                } else {\n                    transValue = jsonMap.get(labelField)\n                }\n            } else if (jsonObj != null) {\n                transValue = jsonObj.toString()\n            }\n        } catch (Throwable t) {\n            // this happens all the time for non-JSON text response: logger.warn(\"Error getting field label from transition\", t)\n            transValue = output\n        }\n\n        // logger.warn(\"term ${term} output ${output} transValue ${transValue}\")\n        return transValue\n    }\n\n    Map<String, Object> makeFormListSingleMap(ScreenForm.FormListRenderInfo renderInfo, Map<String, Object> listEntry,\n            UrlInstance formTransitionUrl, String rowType) {\n        MNode formNode = renderInfo.getFormNode()\n        Map<String, Object> outMap = new LinkedHashMap<>()\n\n        // add url parameter map pass through parameters first, others override\n        outMap.putAll(formTransitionUrl.getParameterMap())\n        outMap.putAll(getFormHiddenParameters(formNode))\n\n        // listEntry fields before boilerplate fields below\n        Map<String, Object> row = transformFormListRow(renderInfo, listEntry, rowType.charAt(0))\n        outMap.putAll(row)\n\n        outMap.put(\"moquiFormName\", formNode.attribute(\"name\"))\n        outMap.put(\"pageIndex\", ec.contextStack.getByString(\"pageIndex\") ?: \"0\")\n        String orderByField = ec.contextStack.getByString(\"orderByField\")\n        if (orderByField) outMap.put(\"orderByField\", orderByField)\n\n        return outMap\n    }\n    Map<String, Object> makeFormListMultiMap(ScreenForm.FormListRenderInfo renderInfo,\n            ArrayList<Map<String, Object>> listObject, UrlInstance formTransitionUrl) {\n        MNode formNode = renderInfo.getFormNode()\n        Map<String, Object> outMap = new LinkedHashMap<>()\n\n        // add url parameter map pass through parameters first, others override\n        outMap.putAll(formTransitionUrl.getParameterMap())\n        outMap.putAll(getFormHiddenParameters(formNode))\n\n        // transform listObject rows to one big Map with _${rowNum} field name suffix\n        int listSize = listObject.size()\n        for (int i = 0; i < listSize; i++) {\n            Map<String, Object> listEntry = (Map<String, Object>) listObject.get(i)\n            Map<String, Object> row = transformFormListRow(renderInfo, listEntry, (char) 'r')\n            for (Map.Entry<String, Object> mapEntry in row.entrySet()) {\n                outMap.put(mapEntry.getKey() + \"_\" + i, mapEntry.getValue())\n            }\n        }\n\n        outMap.put(\"moquiFormName\", formNode.attribute(\"name\"))\n        outMap.put(\"pageIndex\", ec.contextStack.getByString(\"pageIndex\") ?: \"0\")\n        String orderByField = ec.contextStack.getByString(\"orderByField\")\n        if (orderByField) outMap.put(\"orderByField\", orderByField)\n\n        outMap.put(\"_isMulti\", \"true\")\n\n        return outMap\n    }\n\n    Map<String, String> getFormHiddenParameters(MNode formNode) {\n        Map<String, String> parmMap = new LinkedHashMap<>()\n        if (formNode == null) return parmMap\n        MNode hiddenParametersNode = formNode.first(\"hidden-parameters\")\n        if (hiddenParametersNode == null) return parmMap\n\n        Map<String, Object> objMap = new LinkedHashMap<>()\n        addNodeParameters(hiddenParametersNode, objMap)\n        for (Map.Entry<String, Object> entry in objMap.entrySet()) {\n            Object valObj = entry.getValue()\n            String valStr = ObjectUtilities.toPlainString(valObj)\n            if (valStr != null && !valStr.isEmpty()) parmMap.put(entry.getKey(), valStr)\n        }\n\n        return parmMap\n    }\n\n    boolean addNodeParameters(MNode parameterParentNode, Map<String, Object> parameters) {\n        if (parameterParentNode == null) return true\n        // get specified parameters\n        String parameterMapStr = (String) parameterParentNode.attribute(\"parameter-map\")\n        if (parameterMapStr != null && !parameterMapStr.isEmpty()) {\n            Map ctxParameterMap = (Map) ec.resource.expression(parameterMapStr, \"\")\n            if (ctxParameterMap != null) parameters.putAll(ctxParameterMap)\n        }\n        ArrayList<MNode> parameterNodes = parameterParentNode.children(\"parameter\")\n        int parameterNodesSize = parameterNodes.size()\n        for (int i = 0; i < parameterNodesSize; i++) {\n            MNode parameterNode = (MNode) parameterNodes.get(i)\n            String name = parameterNode.attribute(\"name\")\n            String from = parameterNode.attribute(\"from\")\n            if (from == null || from.isEmpty()) from = name\n            parameters.put(name, getContextValue(from, parameterNode.attribute(\"value\")))\n        }\n\n        // get current values for depends-on fields\n        boolean dependsOptional = \"true\".equals(parameterParentNode.attribute(\"depends-optional\"))\n        boolean hasAllDepends = true\n        ArrayList<MNode> doNodeList = parameterParentNode.children(\"depends-on\")\n        for (int i = 0; i < doNodeList.size(); i++) {\n            MNode doNode = (MNode) doNodeList.get(i)\n            String doField = doNode.attribute(\"field\")\n            String doParameter = doNode.attribute(\"parameter\") ?: doField\n            Object contextVal = ec.contextStack.get(doField)\n            if (ObjectUtilities.isEmpty(contextVal) && ec.contextStack.get(\"_formMap\") != null)\n                contextVal = ((Map) ec.contextStack.get(\"_formMap\")).get(doField)\n            if (ObjectUtilities.isEmpty(contextVal)) {\n                hasAllDepends = false\n            } else {\n                parameters.put(doParameter, contextVal)\n            }\n        }\n\n        return hasAllDepends || dependsOptional\n    }\n\n    boolean isInCurrentScreenPath(List<String> pathNameList) {\n        if (pathNameList.size() > screenUrlInfo.fullPathNameList.size()) return false\n        for (int i = 0; i < pathNameList.size(); i++) {\n            if (pathNameList.get(i) != screenUrlInfo.fullPathNameList.get(i)) return false\n        }\n        return true\n    }\n    boolean isActiveInCurrentMenu() {\n        List<String> currentScreenPath = screenUrlInfo ? new ArrayList(screenUrlInfo.fullPathNameList) : null\n        for (SubscreensItem ssi in getActiveScreenDef().subscreensByName.values()) {\n            if (!ssi.menuInclude) continue\n            ScreenUrlInfo urlInfo = buildUrlInfo(ssi.name)\n            if (urlInfo.getInCurrentScreenPath(currentScreenPath)) return true\n        }\n        return false\n    }\n    boolean isAnchorLink(MNode linkNode, UrlInstance urlInstance) {\n        String linkType = linkNode.attribute(\"link-type\")\n        String urlType = linkNode.attribute(\"url-type\")\n        return (\"anchor\".equals(linkType) || \"anchor-button\".equals(linkType)) || ((!linkType || \"auto\".equals(linkType)) &&\n                ((urlType && !urlType.equals(\"transition\")) || (urlInstance.isReadOnly())))\n    }\n\n    UrlInstance getCurrentScreenUrl() { return screenUrlInstance }\n    URI getBaseLinkUri() {\n        String urlString = baseLinkUrl ?: screenUrlInstance.getScreenPathUrl()\n        // logger.warn(\"=================== urlString=${urlString}, baseLinkUrl=${baseLinkUrl}\")\n        URL blu = new URL(urlString)\n        // NOTE: not including user info, query, or fragment... should consider them?\n        // NOTE: using the multi-argument constructor so it will encode stuff\n        URI baseUri = new URI(blu.getProtocol(), null, blu.getHost(), blu.getPort(), blu.getPath(), null, null)\n        return baseUri\n    }\n\n    String getCurrentThemeId() {\n        if (curThemeId != null) return curThemeId\n\n        String stteId = null\n        // loop through only screens to render and look for @screen-theme-type-enum-id, use last one found\n        ArrayList<ScreenDefinition> screenPathDefList = screenUrlInfo.screenPathDefList\n        int screenPathDefListSize = screenPathDefList.size()\n        for (int i = screenUrlInfo.renderPathDifference; i < screenPathDefListSize; i++) {\n            ScreenDefinition sd = (ScreenDefinition) screenPathDefList.get(i)\n            String stteiStr = sd.screenNode.attribute(\"screen-theme-type-enum-id\")\n            if (stteiStr != null && stteiStr.length() > 0) stteId = stteiStr\n        }\n        // if no setting default to STT_INTERNAL\n        if (stteId == null) stteId = \"STT_INTERNAL\"\n\n        EntityFacadeImpl entityFacade = sfi.ecfi.entityFacade\n        // see if there is a user setting for the theme\n        String themeId = entityFacade.fastFindOne(\"moqui.security.UserScreenTheme\", true, true, ec.userFacade.userId, stteId)?.screenThemeId\n        // if no user theme see if group a user is in has a theme\n        if (themeId == null || themeId.length() == 0) {\n            // use reverse alpha so ALL_USERS goes last...\n            List<String> userGroupIdSet = new ArrayList<String>(new TreeSet<String>(ec.user.getUserGroupIdSet())).reverse(true)\n            EntityList groupThemeList = entityFacade.find(\"moqui.security.UserGroupScreenTheme\")\n                    .condition(\"userGroupId\", \"in\", userGroupIdSet).condition(\"screenThemeTypeEnumId\", stteId)\n                    .orderBy(\"sequenceNum,-userGroupId\").useCache(true).disableAuthz().list()\n            if (groupThemeList.size() > 0) themeId = groupThemeList.first().screenThemeId\n        }\n\n        // use the Enumeration.enumCode from the type to find the theme type's default screenThemeId\n        if (themeId == null || themeId.length() == 0) {\n            EntityValue themeTypeEnum = entityFacade.fastFindOne(\"moqui.basic.Enumeration\", true, true, stteId)\n            if (themeTypeEnum?.enumCode) themeId = themeTypeEnum.enumCode\n        }\n        // theme with \"DEFAULT\" in the ID\n        if (themeId == null || themeId.length() == 0) {\n            EntityValue stv = entityFacade.find(\"moqui.screen.ScreenTheme\")\n                    .condition(\"screenThemeTypeEnumId\", stteId)\n                    .condition(\"screenThemeId\", ComparisonOperator.LIKE, \"%DEFAULT%\").disableAuthz().one()\n            if (stv) themeId = stv.screenThemeId\n        }\n\n        curThemeId = themeId ?: \"\"\n        return themeId\n    }\n\n    ArrayList<String> getThemeValues(String resourceTypeEnumId) {\n        return getThemeValues(resourceTypeEnumId, null)\n    }\n    ArrayList<String> getThemeValues(String resourceTypeEnumId, String screenThemeId) {\n        boolean currentTheme = screenThemeId == null || screenThemeId.isEmpty() || \"null\".equals(screenThemeId)\n        if (currentTheme) {\n            screenThemeId = getCurrentThemeId()\n            ArrayList<String> cachedList = (ArrayList<String>) curThemeValuesByType.get(resourceTypeEnumId)\n            if (cachedList != null) return cachedList\n        }\n\n        EntityList strList = sfi.ecfi.entityFacade.find(\"moqui.screen.ScreenThemeResource\")\n                .condition(\"screenThemeId\", screenThemeId).condition(\"resourceTypeEnumId\", resourceTypeEnumId)\n                .orderBy(\"sequenceNum\").useCache(true).disableAuthz().list()\n        int strListSize = strList.size()\n        ArrayList<String> values = new ArrayList<>(strListSize)\n        for (int i = 0; i < strListSize; i++) {\n            EntityValue str = (EntityValue) strList.get(i)\n            String resourceValue = (String) str.getNoCheckSimple(\"resourceValue\")\n            if (resourceValue != null && !resourceValue.isEmpty()) values.add(resourceValue)\n        }\n\n        if (currentTheme) curThemeValuesByType.put(resourceTypeEnumId, values)\n        return values\n    }\n    // NOTE: this is called a LOT during screen renders, for links/buttons/etc\n    String getThemeIconClass(String text) {\n        String screenThemeId = getCurrentThemeId()\n        Map<String, String> curThemeIconByText = sfi.getThemeIconByText(screenThemeId)\n        if (curThemeIconByText.containsKey(text)) return curThemeIconByText.get(text)\n\n        EntityList stiList = sfi.ecfi.entityFacade.find(\"moqui.screen.ScreenThemeIcon\")\n                .condition(\"screenThemeId\", screenThemeId).useCache(true).disableAuthz().list()\n        int stiListSize = stiList.size()\n        String iconClass = (String) null\n        for (int i = 0; i < stiListSize; i++) {\n            EntityValue sti = (EntityValue) stiList.get(i)\n            if (text.matches(sti.getString(\"textPattern\"))) {\n                iconClass = sti.getString(\"iconClass\")\n                break\n            }\n        }\n\n        curThemeIconByText.put(text, iconClass)\n        return iconClass\n    }\n\n    List<Map> getMenuData(ArrayList<String> pathNameList) {\n        if (!ec.user.userId) { ec.web.sendJsonError(401, \"Authentication required\", null); return null }\n        ScreenUrlInfo fullUrlInfo = ScreenUrlInfo.getScreenUrlInfo(this, rootScreenDef, pathNameList, null, 0)\n        if (!fullUrlInfo.targetExists) { ec.web.sendJsonError(404, \"Screen not found for path ${pathNameList}\", null); return null }\n        UrlInstance fullUrlInstance = fullUrlInfo.getInstance(this, null)\n        if (!fullUrlInstance.isPermitted()) { ec.web.sendJsonError(403, \"View not permitted for path ${pathNameList}\", null); return null }\n\n        ArrayList<String> fullPathList = fullUrlInfo.fullPathNameList\n        int fullPathSize = fullPathList.size()\n        ArrayList<String> extraPathList = fullUrlInfo.extraPathNameList\n        int extraPathSize = extraPathList != null ? extraPathList.size() : 0\n        if (extraPathSize > 0) {\n            fullPathSize -= extraPathSize\n            fullPathList = new ArrayList<String>(fullPathList.subList(0, fullPathSize))\n        }\n\n        StringBuilder currentPath = new StringBuilder()\n        List<Map> menuDataList = new LinkedList<>()\n        ScreenDefinition curScreen = rootScreenDef\n\n        // to support menu titles with values set in pre-actions: run pre-actions for all screens in path except first 2 (generally webroot, apps)\n        ec.artifactExecutionFacade.setAnonymousAuthorizedView()\n        ec.userFacade.loginAnonymousIfNoUser()\n        ArrayList<ScreenDefinition> preActionSds = new ArrayList<>(fullUrlInfo.screenPathDefList.subList(2, fullUrlInfo.screenPathDefList.size()))\n        int preActionSdSize = preActionSds.size()\n        for (int i = 0; i < preActionSdSize; i++) {\n            ScreenDefinition sd = (ScreenDefinition) preActionSds.get(i)\n            if (sd.preActions != null) {\n                try { sd.preActions.run(ec) }\n                catch (Throwable t) { logger.warn(\"Error running pre-actions in ${sd.getLocation()} while getting menu data: \" + t.toString()) }\n            }\n        }\n\n        for (int i = 0; i < (fullPathSize - 1); i++) {\n            String pathItem = (String) fullPathList.get(i)\n            String nextItem = (String) fullPathList.get(i+1)\n            currentPath.append('/').append(StringUtilities.urlEncodeIfNeeded(pathItem))\n\n            SubscreensItem curSsi = curScreen.getSubscreensItem(pathItem)\n            // already checked for exists above, path may have extra path elements beyond the screen so allow it\n            if (curSsi == null) break\n            curScreen = ec.screenFacade.getScreenDefinition(curSsi.location)\n\n            List<Map> subscreensList = new LinkedList<>()\n            ArrayList<SubscreensItem> menuItems = curScreen.getSubscreensItemsSorted()\n            int menuItemsSize = menuItems.size()\n            for (int j = 0; j < menuItemsSize; j++) {\n                SubscreensItem subscreensItem = (SubscreensItem) menuItems.get(j)\n\n                // include active subscreen even if not normally in menu\n                if (!subscreensItem.menuInclude && subscreensItem.name != nextItem) continue\n                // valid in current context? (user group, etc)\n                if (!subscreensItem.isValidInCurrentContext()) continue\n\n                String screenPath = new StringBuilder(currentPath).append('/').append(StringUtilities.urlEncodeIfNeeded(subscreensItem.name)).toString()\n                UrlInstance screenUrlInstance = buildUrl(screenPath)\n                ScreenUrlInfo sui = screenUrlInstance.sui\n                if (!screenUrlInstance.isPermitted()) continue\n                // build this subscreen's pathWithParams\n                String pathWithParams = \"/\" + sui.preTransitionPathNameList.join(\"/\")\n                Map<String, String> parmMap = screenUrlInstance.getParameterMap()\n                // check for missing required parameters\n                boolean parmMissing = false\n                for (ScreenDefinition.ParameterItem pi in sui.pathParameterItems.values()) {\n                    if (!pi.required) continue\n                    String parmValue = parmMap.get(pi.name)\n                    if (parmValue == null || parmValue.isEmpty()) { parmMissing = true; break }\n                }\n                // if there is a parameter missing skip the subscreen\n                if (parmMissing) continue\n                String parmString = screenUrlInstance.getParameterString()\n                if (!parmString.isEmpty()) pathWithParams += ('?' + parmString)\n\n                String image = sui.menuImage\n                String imageType = sui.menuImageType\n                if (image != null && !image.isEmpty() && (imageType == null || imageType.isEmpty() || \"url-screen\".equals(imageType)))\n                    image = buildUrl(image).url\n\n                boolean active = (nextItem == subscreensItem.name)\n                Map itemMap = [name:subscreensItem.name, title:ec.resource.expand(subscreensItem.menuTitle, \"\"),\n                               path:screenPath, pathWithParams:pathWithParams, image:image, imageType:imageType]\n                if (subscreensItem.menuInclude) itemMap.menuInclude = true\n                if (active) itemMap.active = true\n                if (screenUrlInstance.disableLink) itemMap.disableLink = true\n                subscreensList.add(itemMap)\n                // not needed: screenStatic:sui.targetScreen.isServerStatic(renderMode)\n            }\n\n            String curScreenPath = currentPath.toString()\n            UrlInstance curUrlInstance = buildUrl(curScreenPath)\n            String curPathWithParams = curScreenPath\n            String curParmString = curUrlInstance.getParameterString()\n            if (!curParmString.isEmpty()) curPathWithParams = curPathWithParams + '?' + curParmString\n\n            ScreenUrlInfo sui = curUrlInstance.sui\n            String image = sui.menuImage\n            String imageType = sui.menuImageType\n            if (image != null && !image.isEmpty() && (imageType == null || imageType.isEmpty() || \"url-screen\".equals(imageType)))\n                image = buildUrl(image).url\n            String menuTitle = ec.l10n.localize(curSsi.menuTitle) ?: curScreen.getDefaultMenuName()\n\n            menuDataList.add([name:pathItem, title:menuTitle, subscreens:subscreensList, path:curScreenPath,\n                    pathWithParams:curPathWithParams, hasTabMenu:curScreen.hasTabMenu(), renderModes:curScreen.renderModes, image:image, imageType:imageType])\n            // not needed: screenStatic:curScreen.isServerStatic(renderMode)\n        }\n\n        String lastPathItem = (String) fullPathList.get(fullPathSize - 1)\n        fullUrlInstance.addParameters(ec.web.getRequestParameters())\n        currentPath.append('/').append(StringUtilities.urlEncodeIfNeeded(lastPathItem))\n        String lastPath = currentPath.toString()\n        String paramString = fullUrlInstance.getParameterString()\n        if (paramString.length() > 0) currentPath.append('?').append(paramString)\n\n        String lastImage = fullUrlInfo.menuImage\n        String lastImageType = fullUrlInfo.menuImageType\n        if (lastImage != null && !lastImage.isEmpty() && (lastImageType == null || lastImageType.isEmpty() || \"url-screen\".equals(lastImageType)))\n            lastImage = buildUrl(lastImage).url\n\n        SubscreensItem lastSsi = curScreen.getSubscreensItem(lastPathItem)\n        String lastTitle = ec.l10n.localize(lastSsi?.menuTitle) ?: fullUrlInfo.targetScreen.getDefaultMenuName()\n        if (lastTitle.contains('${')) lastTitle = ec.resourceFacade.expand(lastTitle, \"\")\n        List<Map<String, Object>> screenDocList = fullUrlInfo.targetScreen.getScreenDocumentInfoList()\n\n        // look for form-list with saved find on target screen, if so look for saved finds available to user to display in menu\n        List<Map> savedFindsList = new LinkedList<>()\n        ScreenDefinition targetScreen = fullUrlInfo.getTargetScreen()\n        ArrayList<ScreenForm> formList = targetScreen.getAllForms()\n        for (int i = 0; i < formList.size(); i++) {\n            ScreenForm screenForm = (ScreenForm) formList.get(i)\n            if (screenForm.isFormList && \"true\".equals(screenForm.internalFormNode.attribute(\"saved-finds\"))) {\n                // is a saved find active (or has default)?\n                String formListFindId = ec.contextStack.getByString(\"formListFindId\")\n                if (formListFindId == null || formListFindId.isEmpty()) formListFindId = screenForm.getUserDefaultFormListFindId(ec)\n\n                // add data for saved finds\n                List<Map<String, Object>> userFlfList = screenForm.getUserFormListFinds(ec)\n                for (Map<String, Object> userFlf in userFlfList) {\n                    EntityValue formListFind = (EntityValue) userFlf.formListFind\n                    Map itemMap = [name:formListFind.formListFindId, title:formListFind.description, image:lastImage, imageType:lastImageType,\n                            path:lastPath, pathWithParams:(lastPath + \"?formListFindId=\" + formListFind.formListFindId)]\n                    if (formListFindId != null && formListFindId.equals(formListFind.formListFindId)) itemMap.active = true\n                    savedFindsList.add(itemMap)\n                }\n            }\n        }\n\n        if (extraPathList != null) {\n            int extraPathListSize = extraPathList.size()\n            for (int i = 0; i < extraPathListSize; i++) extraPathList.set(i, StringUtilities.urlEncodeIfNeeded((String) extraPathList.get(i)))\n        }\n        Map lastMap = [name:lastPathItem, title:lastTitle, path:lastPath, pathWithParams:currentPath.toString(),\n                image:lastImage, imageType:lastImageType, extraPathList:extraPathList, screenDocList:screenDocList,\n                renderModes:fullUrlInfo.targetScreen.renderModes, savedFinds:savedFindsList]\n        menuDataList.add(lastMap)\n        // not needed: screenStatic:fullUrlInfo.targetScreen.isServerStatic(renderMode)\n\n        // for (Map info in menuDataList) logger.warn(\"menu data item: ${info}\")\n        return menuDataList\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/screen/ScreenSection.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.screen\n\nimport groovy.transform.CompileStatic\nimport org.codehaus.groovy.runtime.InvokerHelper\nimport org.moqui.impl.actions.XmlAction\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.util.CollectionUtilities\nimport org.moqui.util.ContextStack\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.util.MNode\nimport org.moqui.util.WebUtilities\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n@CompileStatic\nclass ScreenSection {\n    protected final static Logger logger = LoggerFactory.getLogger(ScreenSection.class)\n\n    protected MNode sectionNode\n    protected String location\n\n    protected Class conditionClass = null\n    protected XmlAction condition = null\n    protected XmlAction actions = null\n    protected ScreenWidgets widgets = null\n    protected ScreenWidgets failWidgets = null\n\n    ScreenSection(ExecutionContextFactoryImpl ecfi, MNode sectionNode, String location) {\n        this.sectionNode = sectionNode\n        this.location = location\n\n        // prep condition attribute\n        String conditionAttr = sectionNode.attribute(\"condition\")\n        if (conditionAttr) conditionClass = ecfi.getGroovyClassLoader().parseClass(conditionAttr)\n\n        // prep condition element\n        if (sectionNode.first(\"condition\")?.first() != null) {\n            // the script is effectively the first child of the condition element\n            condition = new XmlAction(ecfi, sectionNode.first(\"condition\").first(), location + \".condition\")\n        }\n        // prep actions\n        if (sectionNode.hasChild(\"actions\")) {\n            actions = new XmlAction(ecfi, sectionNode.first(\"actions\"), location + \".actions\")\n            // if (location.contains(\"FOO\")) logger.warn(\"====== Actions for ${location}: ${actions.writeGroovyWithLines()}\")\n        }\n        // prep widgets\n        if (sectionNode.hasChild(\"widgets\")) {\n            if (sectionNode.getName() == \"screen\") {\n                MNode widgetsNode = sectionNode.first(\"widgets\")\n                MNode screenNode = new MNode(\"screen\", null, null, [widgetsNode], null)\n                widgets = new ScreenWidgets(screenNode, location + \".widgets\")\n            } else {\n                widgets = new ScreenWidgets(sectionNode.first(\"widgets\"), location + \".widgets\")\n            }\n        }\n        // prep fail-widgets\n        if (sectionNode.hasChild(\"fail-widgets\"))\n            failWidgets = new ScreenWidgets(sectionNode.first(\"fail-widgets\"), location + \".fail-widgets\")\n    }\n\n    @CompileStatic\n    void render(ScreenRenderImpl sri) {\n        ContextStack cs = sri.ec.contextStack\n        if (sectionNode.name == \"section-iterate\") {\n            String listName = sectionNode.attribute(\"list\")\n            Object list = sri.ec.resourceFacade.expression(listName, null)\n\n            // if nothing to iterate over, all done\n            if (!list) {\n                if (logger.traceEnabled) logger.trace(\"Target list [${list}] is empty, not rendering section-iterate at [${location}]\")\n                return\n            }\n\n            boolean paginate = \"true\".equals(sectionNode.attribute(\"paginate\"))\n\n            Iterator listIterator = null\n            if (paginate) {\n                cs.push()\n                if (list instanceof List) {\n                    List pageList = CollectionUtilities.paginateList((List) list, listName, cs)\n                    listIterator = pageList.iterator()\n                } else {\n                    throw new IllegalArgumentException(\"section-iterate paginate requires a List, found type ${list?.class?.name}\")\n                }\n            } else {\n                if (list instanceof Iterator) listIterator = (Iterator) list\n                else if (list instanceof Map) listIterator = ((Map) list).entrySet().iterator()\n                else if (list instanceof Iterable) listIterator = ((Iterable) list).iterator()\n            }\n\n            String sectionEntry = sectionNode.attribute(\"entry\")\n            String sectionKey = sectionNode.attribute(\"key\")\n\n            int index = 0\n            while (listIterator != null && listIterator.hasNext()) {\n                Object entry = listIterator.next()\n                cs.push()\n                try {\n                    cs.put(sectionEntry, (entry instanceof Map.Entry ? entry.getValue() : entry))\n                    if (sectionKey && entry instanceof Map.Entry) cs.put(sectionKey, entry.getKey())\n\n                    cs.put(\"sectionEntryIndex\", index)\n                    cs.put(sectionEntry + \"_index\", index)\n                    cs.put(sectionEntry + \"_has_next\", listIterator.hasNext())\n\n                    renderSingle(sri)\n                } finally {\n                    cs.pop()\n                }\n                index++\n            }\n\n            if (paginate) {\n                cs.pop()\n            }\n        } else {\n            // NOTE: don't push/pop context for normal sections, for root section want to be able to share-scope when it\n            // is included by another screen so that fields set will be in context of other screen\n            renderSingle(sri)\n        }\n    }\n\n    @CompileStatic\n    protected void renderSingle(ScreenRenderImpl sri) {\n        if (logger.traceEnabled) logger.trace(\"Begin rendering screen section at [${location}]\")\n        ExecutionContextImpl ec = sri.ec\n        boolean conditionPassed = true\n        boolean skipActions = sri.sfi.isRenderModeSkipActions(sri.renderMode)\n        if (!skipActions) {\n            if (condition != null) conditionPassed = condition.checkCondition(ec)\n            if (conditionPassed && conditionClass != null) {\n                Script script = InvokerHelper.createScript(conditionClass, ec.getContextBinding())\n                Object result = script.run()\n                conditionPassed = result as boolean\n            }\n        }\n\n        if (conditionPassed) {\n            if (!skipActions && actions != null) actions.run(ec)\n            if (widgets != null) {\n                // was there an error in the actions? don't try to render the widgets, likely to be more and more errors\n                if (ec.message.hasError()) {\n                    sri.writer.append(WebUtilities.encodeHtml(ec.message.getErrorsString()))\n                } else {\n                    // render the widgets\n                    widgets.render(sri)\n                }\n            }\n        } else {\n            if (failWidgets != null) failWidgets.render(sri)\n        }\n        if (logger.traceEnabled) logger.trace(\"End rendering screen section at [${location}]\")\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/screen/ScreenTestImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.screen\n\nimport groovy.transform.CompileStatic\nimport org.apache.shiro.subject.Subject\nimport org.moqui.BaseArtifactException\nimport org.moqui.util.ContextStack\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.screen.ScreenRender\nimport org.moqui.screen.ScreenTest\nimport org.moqui.util.MNode\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.util.concurrent.Future\n\n@CompileStatic\nclass ScreenTestImpl implements ScreenTest {\n    protected final static Logger logger = LoggerFactory.getLogger(ScreenTestImpl.class)\n\n    protected final ExecutionContextFactoryImpl ecfi\n    protected final ScreenFacadeImpl sfi\n    // see FtlTemplateRenderer.MoquiTemplateExceptionHandler, others\n    final List<String> errorStrings = [\"[Template Error\", \"FTL stack trace\", \"Could not find subscreen or transition\"]\n\n    protected String rootScreenLocation = null\n    protected ScreenDefinition rootScreenDef = null\n    protected String baseScreenPath = null\n    protected List<String> baseScreenPathList = null\n    protected ScreenDefinition baseScreenDef = null\n\n    protected String outputType = null\n    protected String characterEncoding = null\n    protected String macroTemplateLocation = null\n    protected String baseLinkUrl = null\n    protected String servletContextPath = null\n    protected String webappName = null\n    protected boolean skipJsonSerialize = false\n    protected static final String hostname = \"localhost\"\n\n    long renderCount = 0, errorCount = 0, totalChars = 0, startTime = System.currentTimeMillis()\n\n    final Map<String, Object> sessionAttributes = [:]\n\n    ScreenTestImpl(ExecutionContextFactoryImpl ecfi) {\n        this.ecfi = ecfi\n        sfi = ecfi.screenFacade\n\n        // init default webapp, root screen\n        webappName('webroot')\n    }\n\n    @Override\n    ScreenTest rootScreen(String screenLocation) {\n        rootScreenLocation = screenLocation\n        rootScreenDef = sfi.getScreenDefinition(rootScreenLocation)\n        if (rootScreenDef == null) throw new IllegalArgumentException(\"Root screen not found: ${rootScreenLocation}\")\n        baseScreenDef = rootScreenDef\n        return this\n    }\n    @Override\n    ScreenTest baseScreenPath(String screenPath) {\n        if (!rootScreenLocation) throw new BaseArtifactException(\"No rootScreen specified\")\n        baseScreenPath = screenPath\n        if (baseScreenPath.endsWith(\"/\")) baseScreenPath = baseScreenPath.substring(0, baseScreenPath.length() - 1)\n        if (baseScreenPath) {\n            baseScreenPathList = ScreenUrlInfo.parseSubScreenPath(rootScreenDef, rootScreenDef, [], baseScreenPath, null, sfi)\n            if (baseScreenPathList == null) throw new BaseArtifactException(\"Error in baseScreenPath, could find not base screen path ${baseScreenPath} under ${rootScreenDef.location}\")\n            for (String screenName in baseScreenPathList) {\n                ScreenDefinition.SubscreensItem ssi = baseScreenDef.getSubscreensItem(screenName)\n                if (ssi == null) throw new BaseArtifactException(\"Error in baseScreenPath, could not find ${screenName} under ${baseScreenDef.location}\")\n                baseScreenDef = sfi.getScreenDefinition(ssi.location)\n                if (baseScreenDef == null) throw new BaseArtifactException(\"Error in baseScreenPath, could not find screen ${screenName} at ${ssi.location}\")\n            }\n        }\n        return this\n    }\n    @Override ScreenTest renderMode(String outputType) { this.outputType = outputType; return this }\n    @Override ScreenTest encoding(String characterEncoding) { this.characterEncoding = characterEncoding; return this }\n    @Override ScreenTest macroTemplate(String macroTemplateLocation) { this.macroTemplateLocation = macroTemplateLocation; return this }\n    @Override ScreenTest baseLinkUrl(String baseLinkUrl) { this.baseLinkUrl = baseLinkUrl; return this }\n    @Override ScreenTest servletContextPath(String scp) { this.servletContextPath = scp; return this }\n    @Override ScreenTest skipJsonSerialize(boolean skip) { this.skipJsonSerialize = skip; return this }\n\n    @Override\n    ScreenTest webappName(String wan) {\n        webappName = wan\n\n        // set a default root screen based on config for \"localhost\"\n        MNode webappNode = ecfi.getWebappNode(webappName)\n        for (MNode rootScreenNode in webappNode.children(\"root-screen\")) {\n            if (hostname.matches(rootScreenNode.attribute('host'))) {\n                String rsLoc = rootScreenNode.attribute('location')\n                rootScreen(rsLoc)\n                break\n            }\n        }\n\n        return this\n    }\n\n    @Override\n    List<String> getNoRequiredParameterPaths(Set<String> screensToSkip) {\n        if (!rootScreenLocation) throw new IllegalStateException(\"No rootScreen specified\")\n\n        List<String> noReqParmLocations = baseScreenDef.nestedNoReqParmLocations(\"\", screensToSkip)\n        // logger.info(\"======= rootScreenLocation=${rootScreenLocation}\\nbaseScreenPath=${baseScreenPath}\\nbaseScreenDef: ${baseScreenDef.location}\\nnoReqParmLocations: ${noReqParmLocations}\")\n        return noReqParmLocations\n    }\n\n    @Override\n    ScreenTestRender render(String screenPath, Map<String, Object> parameters, String requestMethod) {\n        if (!rootScreenLocation) throw new IllegalArgumentException(\"No rootScreenLocation specified\")\n        return new ScreenTestRenderImpl(this, screenPath, parameters, requestMethod).render()\n    }\n    @Override\n    void renderAll(List<String> screenPathList, Map<String, Object> parameters, String requestMethod) {\n        // NOTE: using single thread for now, doesn't actually make a lot of difference in overall test run time\n        int threads = 1\n        if (threads == 1) {\n            for (String screenPath in screenPathList) {\n                ScreenTestRender str = render(screenPath, parameters, requestMethod)\n                logger.info(\"Rendered ${screenPath} in ${str.getRenderTime()}ms, ${str.output?.length()} characters\")\n            }\n        } else {\n            ExecutionContextImpl eci = ecfi.getEci()\n            ArrayList<Future> threadList = new ArrayList<Future>(threads)\n            int screenPathListSize = screenPathList.size()\n            for (int si = 0; si < screenPathListSize; si++) {\n                String screenPath = (String) screenPathList.get(si)\n                threadList.add(eci.runAsync({\n                    ScreenTestRender str = render(screenPath, parameters, requestMethod)\n                    logger.info(\"Rendered ${screenPath} in ${str.getRenderTime()}ms, ${str.output?.length()} characters\")\n                }))\n                if (threadList.size() == threads || (si + 1) == screenPathList.size()) {\n                    for (int i = 0; i < threadList.size(); i++) { ((Future) threadList.get(i)).get() }\n                    threadList.clear()\n                }\n            }\n        }\n    }\n\n    long getRenderCount() { return renderCount }\n    long getErrorCount() { return errorCount }\n    long getRenderTotalChars() { return totalChars }\n    long getStartTime() { return startTime }\n\n    @CompileStatic\n    static class ScreenTestRenderImpl implements ScreenTestRender {\n        protected final ScreenTestImpl sti\n        String screenPath = (String) null\n        Map<String, Object> parameters = [:]\n        String requestMethod = (String) null\n\n        ScreenRender screenRender = (ScreenRender) null\n        String outputString = (String) null\n        Object jsonObj = null\n        long renderTime = 0\n        Map postRenderContext = (Map) null\n        protected List<String> errorMessages = []\n\n        ScreenTestRenderImpl(ScreenTestImpl sti, String screenPath, Map<String, Object> parameters, String requestMethod) {\n            this.sti = sti\n            this.screenPath = screenPath\n            if (parameters != null) this.parameters.putAll(parameters)\n            this.requestMethod = requestMethod\n        }\n\n\n        ScreenTestRender render() {\n            // render in separate thread with an independent ExecutionContext so it doesn't muck up the current one\n            ExecutionContextFactoryImpl ecfi = sti.ecfi\n            ExecutionContextImpl localEci = ecfi.getEci()\n            String username = localEci.userFacade.getUsername()\n            Subject loginSubject = localEci.userFacade.getCurrentSubject()\n            boolean authzDisabled = localEci.artifactExecutionFacade.getAuthzDisabled()\n            ScreenTestRenderImpl stri = this\n            Throwable threadThrown = null\n\n            Thread newThread = new Thread(\"ScreenTestRender\") {\n                @Override void run() {\n                    try {\n                        ExecutionContextImpl threadEci = ecfi.getEci()\n                        if (loginSubject != null) threadEci.userFacade.internalLoginSubject(loginSubject)\n                        else if (username != null && !username.isEmpty()) threadEci.userFacade.internalLoginUser(username)\n                        if (authzDisabled) threadEci.artifactExecutionFacade.disableAuthz()\n                        // as this is used for server-side transition calls don't do tarpit checks\n                        threadEci.artifactExecutionFacade.disableTarpit()\n                        renderInternal(threadEci, stri)\n                        threadEci.destroy()\n                    } catch (Throwable t) {\n                        threadThrown = t\n                    }\n                }\n            }\n            newThread.start()\n            newThread.join()\n            if (threadThrown != null) throw threadThrown\n            return this\n        }\n        private static void renderInternal(ExecutionContextImpl eci, ScreenTestRenderImpl stri) {\n            ScreenTestImpl sti = stri.sti\n            long startTime = System.currentTimeMillis()\n\n            // parse the screenPath\n            ArrayList<String> screenPathList = ScreenUrlInfo.parseSubScreenPath(sti.rootScreenDef, sti.baseScreenDef,\n                    sti.baseScreenPathList, stri.screenPath, stri.parameters, sti.sfi)\n            if (screenPathList == null) throw new BaseArtifactException(\"Could not find screen path ${stri.screenPath} under base screen ${sti.baseScreenDef.location}\")\n\n            // push the context\n            ContextStack cs = eci.getContext()\n            cs.push()\n            // create the WebFacadeStub\n            WebFacadeStub wfs = new WebFacadeStub(sti.ecfi, stri.parameters, sti.sessionAttributes, stri.requestMethod)\n            // set stub on eci, will also put parameters in the context\n            eci.setWebFacade(wfs)\n            // make the ScreenRender\n            ScreenRender screenRender = sti.sfi.makeRender()\n            stri.screenRender = screenRender\n            // pass through various settings\n            if (sti.rootScreenLocation != null && sti.rootScreenLocation.length() > 0) screenRender.rootScreen(sti.rootScreenLocation)\n            if (sti.outputType != null && sti.outputType.length() > 0) screenRender.renderMode(sti.outputType)\n            if (sti.characterEncoding != null && sti.characterEncoding.length() > 0) screenRender.encoding(sti.characterEncoding)\n            if (sti.macroTemplateLocation != null && sti.macroTemplateLocation.length() > 0) screenRender.macroTemplate(sti.macroTemplateLocation)\n            if (sti.baseLinkUrl != null && sti.baseLinkUrl.length() > 0) screenRender.baseLinkUrl(sti.baseLinkUrl)\n            if (sti.servletContextPath != null && sti.servletContextPath.length() > 0) screenRender.servletContextPath(sti.servletContextPath)\n            screenRender.webappName(sti.webappName)\n            if (sti.skipJsonSerialize) wfs.skipJsonSerialize = true\n\n            // set the screenPath\n            screenRender.screenPath(screenPathList)\n\n            // do the render\n            try {\n                screenRender.render(wfs.httpServletRequest, wfs.httpServletResponse)\n                // get the response text from the WebFacadeStub\n                stri.outputString = wfs.getResponseText()\n                stri.jsonObj = wfs.getResponseJsonObj()\n            } catch (Throwable t) {\n                String errMsg = \"Exception in render of ${stri.screenPath}: ${t.toString()}\"\n                logger.warn(errMsg, t)\n                stri.errorMessages.add(errMsg)\n                sti.errorCount++\n            }\n            // calc renderTime\n            stri.renderTime = System.currentTimeMillis() - startTime\n\n            // pop the context stack, get rid of var space\n            stri.postRenderContext = cs.pop()\n\n            // check, pass through, error messages\n            if (eci.message.hasError()) {\n                stri.errorMessages.addAll(eci.message.getErrors())\n                eci.message.clearErrors()\n                StringBuilder sb = new StringBuilder(\"Error messages from ${stri.screenPath}: \")\n                for (String errorMessage in stri.errorMessages) sb.append(\"\\n\").append(errorMessage)\n                logger.warn(sb.toString())\n                sti.errorCount += stri.errorMessages.size()\n            }\n\n            // check for error strings in output\n            if (stri.outputString != null) for (String errorStr in sti.errorStrings) if (stri.outputString.contains(errorStr)) {\n                String errMsg = \"Found error [${errorStr}] in output from ${stri.screenPath}\"\n                stri.errorMessages.add(errMsg)\n                sti.errorCount++\n                logger.warn(errMsg)\n            }\n\n            // update stats\n            sti.renderCount++\n            if (stri.outputString != null) sti.totalChars += stri.outputString.length()\n        }\n\n        @Override ScreenRender getScreenRender() { return screenRender }\n        @Override String getOutput() { return outputString }\n        @Override Object getJsonObject() { return jsonObj }\n        @Override long getRenderTime() { return renderTime }\n        @Override Map getPostRenderContext() { return postRenderContext }\n        @Override List<String> getErrorMessages() { return errorMessages }\n\n        @Override\n        boolean assertContains(String text) {\n            if (!outputString) return false\n            return outputString.contains(text)\n        }\n        @Override\n        boolean assertNotContains(String text) {\n            if (!outputString) return true\n            return !outputString.contains(text)\n        }\n        @Override\n        boolean assertRegex(String regex) {\n            if (!outputString) return false\n            return outputString.matches(regex)\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/screen/ScreenTree.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.screen\n\nimport groovy.transform.CompileStatic\nimport org.moqui.util.ContextStack\nimport org.moqui.impl.actions.XmlAction\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.util.MNode\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n@CompileStatic\nclass ScreenTree {\n    protected final static Logger logger = LoggerFactory.getLogger(ScreenTree.class)\n\n    protected ExecutionContextFactoryImpl ecfi\n    protected ScreenDefinition sd\n    protected MNode treeNode\n    protected String location\n\n    // protected Map<String, ScreenDefinition.ParameterItem> parameterByName = [:]\n    protected Map<String, TreeNode> nodeByName = [:]\n    protected List<TreeSubNode> subNodeList = []\n\n    ScreenTree(ExecutionContextFactoryImpl ecfi, ScreenDefinition sd, MNode treeNode, String location) {\n        this.ecfi = ecfi\n        this.sd = sd\n        this.treeNode = treeNode\n        this.location = location\n\n        // prep tree-node\n        for (MNode treeNodeNode in treeNode.children(\"tree-node\"))\n            nodeByName.put(treeNodeNode.attribute(\"name\"), new TreeNode(this, treeNodeNode, location + \".node.\" + treeNodeNode.attribute(\"name\")))\n\n        // prep tree-sub-node\n        for (MNode treeSubNodeNode in treeNode.children(\"tree-sub-node\"))\n            subNodeList.add(new TreeSubNode(this, treeSubNodeNode, location + \".subnode.\" + treeSubNodeNode.attribute(\"node-name\")))\n    }\n\n    void sendSubNodeJson() {\n        // NOTE: This method is very specific to jstree\n\n        ExecutionContextImpl eci = ecfi.getEci()\n        ContextStack cs = eci.getContext()\n\n        // logger.warn(\"========= treeNodeId = ${cs.get(\"treeNodeId\")}\")\n        // if this is the root node get the main tree sub-nodes, otherwise find the node and use its sub-nodes\n        List<TreeSubNode> currentSubNodeList = null\n        if (cs.get(\"treeNodeId\") == \"#\") {\n            currentSubNodeList = subNodeList\n        } else {\n            // logger.warn(\"======== treeNodeName = ${cs.get(\"treeNodeName\")}\")\n            if (cs.get(\"treeNodeName\")) currentSubNodeList = nodeByName.get(cs.get(\"treeNodeName\"))?.subNodeList\n            if (currentSubNodeList == null) {\n                // if no treeNodeName passed through just use the first defined node, though this shouldn't happen\n                logger.warn(\"No treeNodeName passed in request for child nodes for node [${cs.get(\"treeNodeId\")}] in tree [${this.location}], using first node in tree definition.\")\n                currentSubNodeList = nodeByName.values().first().subNodeList\n            }\n        }\n\n        List outputNodeList = getChildNodes(currentSubNodeList, eci, cs, true)\n\n        // logger.warn(\"========= outputNodeList = ${outputNodeList}\")\n        eci.getWeb().sendJsonResponse(outputNodeList)\n    }\n\n    List<Map> getChildNodes(List<TreeSubNode> currentSubNodeList, ExecutionContextImpl eci, ContextStack cs, boolean recurse) {\n        List<Map> outputNodeList = []\n\n        for (TreeSubNode tsn in currentSubNodeList) {\n            // check condition\n            if (tsn.condition != null && !tsn.condition.checkCondition(eci)) continue\n            // run actions\n            if (tsn.actions != null) tsn.actions.run(eci)\n\n            TreeNode tn = nodeByName.get(tsn.treeSubNodeNode.attribute(\"node-name\"))\n\n            // iterate over the list and add a response node for each entry\n            String nodeListName = tsn.treeSubNodeNode.attribute(\"list\") ?: \"nodeList\"\n            List nodeList = (List) eci.getResource().expression(nodeListName, \"\")\n            // logger.warn(\"======= nodeList named [${nodeListName}]: ${nodeList}\")\n            Iterator i = nodeList?.iterator()\n            int index = 0\n            while (i?.hasNext()) {\n                Object nodeListEntry = i.next()\n\n                cs.push()\n                try {\n                    cs.put(\"nodeList_entry\", nodeListEntry)\n                    cs.put(\"nodeList_index\", index)\n                    cs.put(\"nodeList_has_next\", i.hasNext())\n\n                    // check condition\n                    if (tn.condition != null && !tn.condition.checkCondition(eci)) continue\n                    // run actions\n                    if (tn.actions != null) tn.actions.run(eci)\n\n                    MNode showNode = tn.linkNode != null ? tn.linkNode : tn.labelNode\n                    String id = eci.getResource().expand((String) showNode.attribute(\"id\"), tn.location + \".id\")\n                    String text = eci.getResource().expand((String) showNode.attribute(\"text\"), tn.location + \".text\")\n                    Map aAttrMap = (Map) null\n                    if (tn.linkNode != null) {\n                        ScreenUrlInfo.UrlInstance urlInstance = ((ScreenRenderImpl) cs.get(\"sri\")).makeUrlByType((String) tn.linkNode.attribute(\"url\"),\n                                (String) tn.linkNode.attribute(\"url-type\") ?: \"transition\",\n                                tn.linkNode, (String) tn.linkNode.attribute(\"expand-transition-url\") ?: \"true\")\n\n                        boolean noParam = tn.linkNode.attribute(\"url-noparam\") == \"true\"\n                        String urlText = noParam ? urlInstance.getPath() : urlInstance.getPathWithParams()\n                        String hrefText = urlText\n                        String loadId = tn.linkNode.attribute(\"dynamic-load-id\")\n                        if (loadId) {\n                            // NOTE: the void(0) is needed for Firefox and other browsers that render the result of the JS expression\n                            hrefText = \"javascript:{\\$('#${loadId}').load('${urlText}'); void(0);}\"\n                        }\n                        aAttrMap = [href:hrefText, loadId:loadId, urlText:urlText]\n                    }\n\n                    boolean isOpen = ((String) cs.get(\"treeOpenPath\"))?.startsWith(id)\n\n                    // now get children to check if has some, and if in treeOpenPath include them\n                    List<Map> childNodeList = null\n                    if (recurse) {\n                        cs.push()\n                        try {\n                            cs.put(\"treeNodeId\", id)\n                            childNodeList = getChildNodes(tn.subNodeList, eci, cs, isOpen)\n                        } finally {\n                            cs.pop()\n                        }\n                    }\n\n                    // NOTE: passing href as either URL or JS to load (for static rendering with jstree), plus plain loadId and urlText for more dynamic stuff\n                    Map<String, Object> subNodeMap = [id:id, text:text,\n                            li_attr:[\"treeNodeName\":tn.treeNodeNode.attribute(\"name\")]] as Map<String, Object>\n                    if (aAttrMap != null) subNodeMap.a_attr = aAttrMap\n                    if (isOpen) {\n                        subNodeMap.state = [opened:true, selected:(cs.get(\"treeOpenPath\") == id)] as Map<String, Object>\n                        subNodeMap.children = childNodeList\n                    } else {\n                        subNodeMap.children = childNodeList as boolean\n                    }\n                    outputNodeList.add(subNodeMap)\n                    /* structure of JSON object from jstree docs:\n                        {\n                          id          : \"string\" // will be autogenerated if omitted\n                          text        : \"string\" // node text\n                          icon        : \"string\" // string for custom\n                          state       : {\n                            opened    : boolean  // is the node open\n                            disabled  : boolean  // is the node disabled\n                            selected  : boolean  // is the node selected\n                          },\n                          children    : []  // array of strings or objects\n                          li_attr     : {}  // attributes for the generated LI node\n                          a_attr      : {}  // attributes for the generated A node\n                        }\n                     */\n                } finally {\n                    cs.pop()\n                }\n            }\n        }\n\n        // logger.warn(\"========= outputNodeList: ${outputNodeList}\")\n        return outputNodeList\n    }\n\n    static class TreeNode {\n        protected ScreenTree screenTree\n        protected MNode treeNodeNode\n        protected String location\n\n        protected XmlAction condition = null\n        protected XmlAction actions = null\n        protected MNode linkNode = null\n        protected MNode labelNode = null\n        protected List<TreeSubNode> subNodeList = []\n\n        TreeNode(ScreenTree screenTree, MNode treeNodeNode, String location) {\n            this.screenTree = screenTree\n            this.treeNodeNode = treeNodeNode\n            this.location = location\n            this.linkNode = treeNodeNode.first(\"link\")\n            this.labelNode = treeNodeNode.first(\"label\")\n\n            // prep condition\n            if (treeNodeNode.hasChild(\"condition\") && treeNodeNode.first(\"condition\").children) {\n                // the script is effectively the first child of the condition element\n                condition = new XmlAction(screenTree.ecfi, treeNodeNode.first(\"condition\").children[0], location + \".condition\")\n            }\n            // prep actions\n            if (treeNodeNode.hasChild(\"actions\")) actions = new XmlAction(screenTree.ecfi, treeNodeNode.first(\"actions\"), location + \".actions\")\n\n            // prep tree-sub-node\n            for (MNode treeSubNodeNode in treeNodeNode.children(\"tree-sub-node\"))\n                subNodeList.add(new TreeSubNode(screenTree, treeSubNodeNode, location + \".subnode.\" + treeSubNodeNode.attribute(\"node-name\")))\n        }\n    }\n\n    static class TreeSubNode {\n        protected ScreenTree screenTree\n        protected MNode treeSubNodeNode\n        protected String location\n\n        protected XmlAction condition = null\n        protected XmlAction actions = null\n\n        TreeSubNode(ScreenTree screenTree, MNode treeSubNodeNode, String location) {\n            this.screenTree = screenTree\n            this.treeSubNodeNode = treeSubNodeNode\n            this.location = location\n\n            // prep condition\n            if (treeSubNodeNode.hasChild(\"condition\") && treeSubNodeNode.first(\"condition\").children) {\n                // the script is effectively the first child of the condition element\n                condition = new XmlAction(screenTree.ecfi, treeSubNodeNode.first(\"condition\").children[0], location + \".condition\")\n            }\n            // prep actions\n            if (treeSubNodeNode.hasChild(\"actions\")) actions =\n                    new XmlAction(screenTree.ecfi, treeSubNodeNode.first(\"actions\"), location + \".actions\")\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/screen/ScreenUrlInfo.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.screen\n\nimport groovy.transform.CompileStatic\nimport org.moqui.BaseArtifactException\nimport org.moqui.BaseException\nimport org.moqui.context.ArtifactExecutionInfo\nimport org.moqui.context.ArtifactExecutionInfo.AuthzAction\nimport org.moqui.context.ExecutionContext\nimport org.moqui.resource.ResourceReference\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.context.ArtifactExecutionInfoImpl\nimport org.moqui.impl.context.ArtifactExecutionFacadeImpl\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.impl.context.WebFacadeImpl\nimport org.moqui.impl.entity.EntityDefinition\nimport org.moqui.impl.screen.ScreenDefinition.ParameterItem\nimport org.moqui.impl.screen.ScreenDefinition.SubscreensItem\nimport org.moqui.impl.screen.ScreenDefinition.TransitionItem\nimport org.moqui.impl.service.ServiceDefinition\nimport org.moqui.impl.webapp.ScreenResourceNotFoundException\nimport org.moqui.util.MNode\nimport org.moqui.util.ObjectUtilities\nimport org.moqui.util.StringUtilities\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport jakarta.servlet.http.HttpServletRequest\n\nimport javax.cache.Cache\n\n@CompileStatic\nclass ScreenUrlInfo {\n    protected final static Logger logger = LoggerFactory.getLogger(ScreenUrlInfo.class)\n\n    // ExecutionContext ec\n    ExecutionContextFactoryImpl ecfi\n    ScreenFacadeImpl sfi\n    ScreenDefinition rootSd\n    String plainUrl = (String) null\n\n    ScreenDefinition fromSd = (ScreenDefinition) null\n    ArrayList<String> fromPathList = (ArrayList<String>) null\n    String fromScreenPath = (String) null\n\n    Map<String, String> pathParameterMap = new HashMap()\n    boolean requireEncryption = false\n    // boolean hasActions = false\n    // boolean disableLink = false\n    boolean alwaysUseFullPath = false\n    boolean beginTransaction = false\n    Integer transactionTimeout = null\n\n    String menuImage = (String) null\n    String menuImageType = (String) null\n\n    /** The full path name list for the URL, including extraPathNameList */\n    ArrayList<String> fullPathNameList = (ArrayList<String>) null\n\n    /** The minimal path name list for the URL, basically without following the defaults */\n    ArrayList<String> minimalPathNameList = (ArrayList<String>) null\n\n    /** Everything in the path after the screen or transition, may be used to pass additional info */\n    ArrayList<String> extraPathNameList = (ArrayList<String>) null\n\n    /** The path for a file resource (template or static), relative to the targetScreen.location */\n    ArrayList<String> fileResourcePathList = (ArrayList<String>) null\n    /** If the full path led to a file resource that is verified to exist, the URL goes here; the URL for access on the\n     * server, the client will get the resource from the url field as normal */\n    ResourceReference fileResourceRef = (ResourceReference) null\n    String fileResourceContentType = (String) null\n\n    /** All screens found in the path list */\n    ArrayList<ScreenDefinition> screenPathDefList = new ArrayList<ScreenDefinition>()\n    int renderPathDifference = 0\n    /** positive lastStandalone means how many to include from the end back, negative how many path elements to skip from the beginning */\n    int lastStandalone = 0\n\n    HashMap<String, ParameterItem> pathParameterItems = new HashMap<>()\n\n    /** The last screen found in the path list */\n    ScreenDefinition targetScreen = (ScreenDefinition) null\n    String targetScreenRenderMode = (String) null\n    String targetTransitionActualName = (String) null\n    String targetTransitionExtension = (String) null\n    ArrayList<String> preTransitionPathNameList = new ArrayList<String>()\n\n    boolean reusable = true\n    boolean targetExists = true\n    ScreenDefinition notExistsLastSd = (ScreenDefinition) null\n    String notExistsLastName = (String) null\n    String notExistsNextLoc = (String) null\n\n    protected ScreenUrlInfo() { }\n\n    /** Stub mode for ScreenUrlInfo, represent a plain URL and not a screen URL */\n    static ScreenUrlInfo getScreenUrlInfo(ScreenRenderImpl sri, String url) {\n        Cache<String, ScreenUrlInfo> screenUrlCache = sri.sfi.screenUrlCache\n        ScreenUrlInfo cached = (ScreenUrlInfo) screenUrlCache.get(url)\n        if (cached != null) return cached\n\n        ScreenUrlInfo newSui = new ScreenUrlInfo(sri, url)\n        screenUrlCache.put(url, newSui)\n        return newSui\n    }\n    static ScreenUrlInfo getScreenUrlInfo(ScreenFacadeImpl sfi, String url) {\n        Cache<String, ScreenUrlInfo> screenUrlCache = sfi.screenUrlCache\n        ScreenUrlInfo cached = (ScreenUrlInfo) screenUrlCache.get(url)\n        if (cached != null) return cached\n\n        ScreenUrlInfo newSui = new ScreenUrlInfo(sfi, url)\n        screenUrlCache.put(url, newSui)\n        return newSui\n    }\n\n    static ScreenUrlInfo getScreenUrlInfo(ScreenFacadeImpl sfi, ScreenDefinition rootSd, ScreenDefinition fromScreenDef,\n                                          ArrayList<String> fpnl, String subscreenPath, int lastStandalone) {\n        // see if a plain URL was treated as a subscreen path\n        if (subscreenPath != null && (subscreenPath.startsWith(\"https:\") || subscreenPath.startsWith(\"http:\")))\n            return getScreenUrlInfo(sfi, subscreenPath)\n\n        Cache<String, ScreenUrlInfo> screenUrlCache = sfi.screenUrlCache\n        String cacheKey = makeCacheKey(rootSd, fromScreenDef, fpnl, subscreenPath, lastStandalone)\n        ScreenUrlInfo cached = (ScreenUrlInfo) screenUrlCache.get(cacheKey)\n        if (cached != null) return cached\n\n        ScreenUrlInfo newSui = new ScreenUrlInfo(sfi, rootSd, fromScreenDef, fpnl, subscreenPath, lastStandalone)\n        screenUrlCache.put(cacheKey, newSui)\n        return newSui\n    }\n\n    static ScreenUrlInfo getScreenUrlInfo(ScreenRenderImpl sri, ScreenDefinition fromScreenDef, ArrayList<String> fpnl,\n                                          String subscreenPath, int lastStandalone) {\n        // see if a plain URL was treated as a subscreen path\n        if (subscreenPath != null && (subscreenPath.startsWith(\"https:\") || subscreenPath.startsWith(\"http:\")))\n            return getScreenUrlInfo(sri, subscreenPath)\n\n        ScreenDefinition rootSd = sri.getRootScreenDef()\n        ScreenDefinition fromSd = fromScreenDef\n        ArrayList<String> fromPathList = fpnl\n        if (fromSd == null) fromSd = sri.getActiveScreenDef()\n        if (fromPathList == null) fromPathList = sri.getActiveScreenPath()\n\n        Cache<String, ScreenUrlInfo> screenUrlCache = sri.sfi.screenUrlCache\n        String cacheKey = makeCacheKey(rootSd, fromSd, fromPathList, subscreenPath, lastStandalone)\n        ScreenUrlInfo cached = (ScreenUrlInfo) screenUrlCache.get(cacheKey)\n        if (cached != null) return cached\n\n        ScreenUrlInfo newSui = new ScreenUrlInfo(sri.sfi, rootSd, fromSd, fromPathList, subscreenPath, lastStandalone)\n        if (newSui.reusable) screenUrlCache.put(cacheKey, newSui)\n        return newSui\n    }\n\n    static ScreenUrlInfo getScreenUrlInfo(ScreenFacadeImpl sfi, HttpServletRequest request) {\n        String webappName = request.servletContext.getInitParameter(\"moqui-name\")\n        String rootScreenLocation = sfi.rootScreenFromHost(request.getServerName(), webappName)\n        ScreenDefinition rootScreenDef = sfi.getScreenDefinition(rootScreenLocation)\n        if (rootScreenDef == null) throw new BaseArtifactException(\"Could not find root screen at location ${rootScreenLocation}\")\n\n        ArrayList<String> screenPath = WebFacadeImpl.getPathInfoList(request)\n        return getScreenUrlInfo(sfi, rootScreenDef, rootScreenDef, screenPath, null, 0)\n\n    }\n\n    final static char slashChar = (char) '/'\n    static String makeCacheKey(ScreenDefinition rootSd, ScreenDefinition fromScreenDef, ArrayList<String> fpnl,\n                               String subscreenPath, int lastStandalone) {\n        StringBuilder sb = new StringBuilder()\n        // shouldn't be too many root screens, so the screen name (filename) should be sufficiently unique and much shorter\n        sb.append(rootSd.getScreenName()).append(\":\")\n        if (fromScreenDef != null) sb.append(fromScreenDef.getScreenName()).append(\":\")\n        boolean hasSsp = subscreenPath != null && subscreenPath.length() > 0\n        boolean skipFpnl = hasSsp && subscreenPath.charAt(0) == slashChar\n        // NOTE: we will get more cache hits (less cache redundancy) if we combine with fpnl and use cleanupPathNameList,\n        //     but is it worth it? no, let there be redundant cache entries for the same screen path, will be faster\n        if (!skipFpnl && fpnl != null) {\n            int fpnlSize = fpnl.size()\n            for (int i = 0; i < fpnlSize; i++) {\n                String fpn = (String) fpnl.get(i)\n                sb.append('/').append(fpn)\n            }\n        }\n        if (hasSsp) sb.append(subscreenPath)\n        sb.append(\":\").append(lastStandalone)\n\n        // logger.warn(\"======= makeCacheKey subscreenPath=${subscreenPath}, fpnl=${fpnl}\\n key=${sb}\")\n        return sb.toString()\n    }\n\n    /** Stub mode for ScreenUrlInfo, represent a plain URL and not a screen URL */\n    ScreenUrlInfo(ScreenRenderImpl sri, String url) {\n        this.sfi = sri.sfi\n        this.ecfi = sfi.ecfi\n        this.rootSd = sri.getRootScreenDef()\n        this.plainUrl = url\n    }\n    ScreenUrlInfo(ScreenFacadeImpl sfi, String url) {\n        this.sfi = sfi\n        this.ecfi = sfi.ecfi\n        this.plainUrl = url\n    }\n\n    ScreenUrlInfo(ScreenFacadeImpl sfi, ScreenDefinition rootSd, ScreenDefinition fromScreenDef,\n                  ArrayList<String> fpnl, String subscreenPath, int lastStandalone) {\n        this.sfi = sfi\n        this.ecfi = sfi.getEcfi()\n        this.rootSd = rootSd\n        fromSd = fromScreenDef\n        fromPathList = fpnl\n        fromScreenPath = subscreenPath ?: \"\"\n        this.lastStandalone = lastStandalone\n\n        initUrl()\n    }\n\n    UrlInstance getInstance(ScreenRenderImpl sri, Boolean expandAliasTransition) {\n        return new UrlInstance(this, sri, expandAliasTransition)\n    }\n\n    boolean getInCurrentScreenPath(List<String> currentPathNameList) {\n        // if currentPathNameList (was from sri.screenUrlInfo) is null it is because this object is not yet set to it, so set this to true as it \"is\" the current screen path\n        if (currentPathNameList == null) return true\n        if (minimalPathNameList == null) return false\n        if (minimalPathNameList.size() > currentPathNameList.size()) return false\n        for (int i = 0; i < minimalPathNameList.size(); i++) {\n            if (minimalPathNameList.get(i) != currentPathNameList.get(i)) return false\n        }\n        return true\n    }\n\n    ScreenDefinition getParentScreen() {\n        if (screenPathDefList.size() > 1) {\n            return screenPathDefList.get(screenPathDefList.size() - 2)\n        } else {\n            return null\n        }\n    }\n\n    boolean isPermitted(ExecutionContext ec, TransitionItem transitionItem) {\n        return isPermitted(ec, transitionItem, ArtifactExecutionInfo.AUTHZA_VIEW)\n    }\n    boolean isPermitted(ExecutionContext ec, TransitionItem transitionItem, AuthzAction actionEnum) {\n        ArtifactExecutionFacadeImpl aefi = (ArtifactExecutionFacadeImpl) ec.getArtifactExecution()\n        String userId = ec.getUser().getUserId()\n\n        // if a user is permitted to view a certain location once in a render/ec they can safely be always allowed to, so cache it\n        // add the username to the key just in case user changes during an EC instance\n        String permittedCacheKey = (String) null\n        if (fullPathNameList != null) {\n            String keyUserId = userId != null ? userId : '_anonymous'\n            permittedCacheKey = keyUserId.concat(fullPathNameList.toString())\n            Boolean cachedPermitted = (Boolean) aefi.screenPermittedCache.get(permittedCacheKey)\n            if (cachedPermitted != null) return cachedPermitted.booleanValue()\n        } else {\n            // logger.warn(\"======== Not caching isPermitted, username=${username}, fullPathNameList=${fullPathNameList}\")\n        }\n\n        ArrayDeque<ArtifactExecutionInfoImpl> artifactExecutionInfoStack = new ArrayDeque<ArtifactExecutionInfoImpl>()\n\n        int screenPathDefListSize = screenPathDefList.size()\n        boolean allowedByScreenDefinitionView = false\n        boolean allowedByScreenDefinitionAll = false\n        boolean allowedByScreenDefinition = false\n        for (int i = 0; i < screenPathDefListSize; i++) {\n            AuthzAction curActionEnum = (i == (screenPathDefListSize - 1)) ? actionEnum : ArtifactExecutionInfo.AUTHZA_VIEW\n            ScreenDefinition screenDef = (ScreenDefinition) screenPathDefList.get(i)\n            ArtifactExecutionInfoImpl aeii = new ArtifactExecutionInfoImpl(screenDef.getLocation(),\n                    ArtifactExecutionInfo.AT_XML_SCREEN, curActionEnum, null)\n\n            ArtifactExecutionInfoImpl lastAeii = (ArtifactExecutionInfoImpl) artifactExecutionInfoStack.peekFirst()\n\n            // logger.warn(\"TOREMOVE checking screen for user ${username} - ${aeii}\")\n\n            boolean isLast = ((i + 1) == screenPathDefListSize)\n            MNode screenNode = screenDef.getScreenNode()\n\n            String requireAuthentication = screenNode.attribute('require-authentication')\n            allowedByScreenDefinitionView = \"anonymous-view\".equals(requireAuthentication)\n            allowedByScreenDefinitionAll = \"anonymous-all\".equals(requireAuthentication)\n            if (actionEnum == ArtifactExecutionInfo.AUTHZA_VIEW) {\n                allowedByScreenDefinition = allowedByScreenDefinition || allowedByScreenDefinitionView || allowedByScreenDefinitionAll\n            } else if (actionEnum == ArtifactExecutionInfo.AUTHZA_ALL)\n                allowedByScreenDefinition = allowedByScreenDefinition || allowedByScreenDefinitionAll\n            if (!aefi.isPermitted(aeii, lastAeii,\n                    isLast ? (!requireAuthentication || \"true\".equals(requireAuthentication)) : false, false, false, artifactExecutionInfoStack)) {\n                //logger.warn(\"TOREMOVE user ${userId} is NOT allowed to view screen at path ${this.fullPathNameList} because of screen at ${screenDef.location}\")\n                if (permittedCacheKey != null) aefi.screenPermittedCache.put(permittedCacheKey, false)\n                return false\n            }\n\n            artifactExecutionInfoStack.addFirst(aeii)\n        }\n\n        // see if the transition is permitted\n        if (!allowedByScreenDefinition && transitionItem != null) {\n            ScreenDefinition lastScreenDef = (ScreenDefinition) screenPathDefList.get(screenPathDefList.size() - 1)\n            ArtifactExecutionInfoImpl aeii = new ArtifactExecutionInfoImpl(\"${lastScreenDef.location}/${transitionItem.name}\",\n                    ArtifactExecutionInfo.AT_XML_SCREEN_TRANS, ArtifactExecutionInfo.AUTHZA_VIEW, null)\n            ArtifactExecutionInfoImpl lastAeii = (ArtifactExecutionInfoImpl) artifactExecutionInfoStack.peekFirst()\n            if (!aefi.isPermitted(aeii, lastAeii, true, false, false, artifactExecutionInfoStack)) {\n                // logger.warn(\"TOREMOVE user ${username} is NOT allowed to view screen at path ${this.fullPathNameList} because of screen at ${screenDef.location}\")\n                if (permittedCacheKey != null) aefi.screenPermittedCache.put(permittedCacheKey, false)\n                return false\n            }\n        }\n\n        // if there is a transition with a single service go a little further and see if we have permission to call it\n        String serviceName = transitionItem?.singleServiceName\n        if (transitionItem != null && !transitionItem.isReadOnly() && serviceName != null && !serviceName.isEmpty()) {\n            ServiceDefinition sd = sfi.ecfi.serviceFacade.getServiceDefinition(serviceName)\n            ArtifactExecutionInfo.AuthzAction authzAction\n            if (sd != null) authzAction = sd.authzAction\n            if (authzAction == null) authzAction = ServiceDefinition.verbAuthzActionEnumMap.get(ServiceDefinition.getVerbFromName(serviceName))\n            if (authzAction == null) authzAction = ArtifactExecutionInfo.AUTHZA_ALL\n\n            boolean allowedByServiceDefinition = false\n            if (authzAction == ArtifactExecutionInfo.AUTHZA_VIEW) {\n                allowedByServiceDefinition = allowedByScreenDefinitionView || (sd != null && (\"anonymous-view\".equals(sd.authenticate) || \"anonymous-all\".equals(sd.authenticate)))\n            } else if (authzAction in [ArtifactExecutionInfo.AUTHZA_ALL, ArtifactExecutionInfo.AUTHZA_CREATE, ArtifactExecutionInfo.AUTHZA_UPDATE, ArtifactExecutionInfo.AUTHZA_DELETE]) {\n                allowedByServiceDefinition = allowedByScreenDefinitionAll || (sd != null && \"anonymous-all\".equals(sd.authenticate))\n            }\n            ArtifactExecutionInfoImpl aeii = new ArtifactExecutionInfoImpl(serviceName, ArtifactExecutionInfo.AT_SERVICE, authzAction, null)\n\n            ArtifactExecutionInfoImpl lastAeii = (ArtifactExecutionInfoImpl) artifactExecutionInfoStack.peekFirst()\n            if (!aefi.isPermitted(aeii, lastAeii, !allowedByServiceDefinition, false, false, null)) {\n                // logger.warn(\"TOREMOVE user ${username} is NOT allowed to run transition at path ${this.fullPathNameList} because of screen at ${screenDef.location}\")\n                if (permittedCacheKey != null) aefi.screenPermittedCache.put(permittedCacheKey, false)\n                return false\n            }\n\n            artifactExecutionInfoStack.addFirst(aeii)\n        }\n\n        // logger.warn(\"TOREMOVE user ${username} IS allowed to view screen at path ${this.fullPathNameList}\")\n        if (permittedCacheKey != null) aefi.screenPermittedCache.put(permittedCacheKey, true)\n        return true\n    }\n\n    String getBaseUrl(ScreenRenderImpl sri) {\n        // support the stub mode for ScreenUrlInfo, representing a plain URL and not a screen URL\n        if (plainUrl != null && plainUrl.length() > 0) return plainUrl\n\n        if (sri == null) return \"\"\n        String baseUrl\n        if (sri.baseLinkUrl != null && sri.baseLinkUrl.length() > 0) {\n            baseUrl = sri.baseLinkUrl\n            if (baseUrl && baseUrl.charAt(baseUrl.length()-1) == (char) '/') baseUrl = baseUrl.substring(0, baseUrl.length()-1)\n        } else {\n            if (sri.webappName == null || sri.webappName.length() == 0)\n                throw new BaseArtifactException(\"No webappName specified, cannot get base URL for screen location ${sri.rootScreenLocation}\")\n            baseUrl = WebFacadeImpl.getWebappRootUrl(sri.webappName, sri.servletContextPath, true,\n                    this.requireEncryption, sri.ec)\n        }\n        return baseUrl\n    }\n\n    String getUrlWithBase(String baseUrl) {\n        if (!targetExists) {\n            logger.warn(\"Tried to get URL for screen path ${fullPathNameList} that does not exist under ${rootSd.location}, returning hash\")\n            return \"#\"\n        }\n        StringBuilder urlBuilder = new StringBuilder(baseUrl)\n        if (fullPathNameList != null) {\n            int listSize = fullPathNameList.size()\n            for (int i = 0; i < listSize; i++) {\n                String pathName = fullPathNameList.get(i)\n                urlBuilder.append('/').append(StringUtilities.urlEncodeIfNeeded(pathName))\n            }\n        }\n        return urlBuilder.toString()\n    }\n\n    String getMinimalPathUrlWithBase(String baseUrl) {\n        if (!targetExists) {\n            logger.warn(\"Tried to get URL for screen path ${fullPathNameList} that does not exist under ${rootSd.location}, returning hash\")\n            return \"#\"\n        }\n        StringBuilder urlBuilder = new StringBuilder(baseUrl)\n        if (alwaysUseFullPath) {\n            // really get the full path instead of minimal\n            if (fullPathNameList != null) {\n                int listSize = fullPathNameList.size()\n                for (int i = 0; i < listSize; i++) {\n                    String pathName = fullPathNameList.get(i)\n                    urlBuilder.append('/').append(StringUtilities.urlEncodeIfNeeded(pathName))\n                }\n            }\n        } else {\n            if (minimalPathNameList != null) {\n                int listSize = minimalPathNameList.size()\n                for (int i = 0; i < listSize; i++) {\n                    String pathName = minimalPathNameList.get(i)\n                    urlBuilder.append('/').append(StringUtilities.urlEncodeIfNeeded(pathName))\n                }\n            }\n        }\n        return urlBuilder.toString()\n    }\n\n    String getScreenPathUrlWithBase(String baseUrl) {\n        if (!targetExists) {\n            logger.warn(\"Tried to get URL for screen path ${fullPathNameList} that does not exist under ${rootSd.location}, returning hash\")\n            return \"#\"\n        }\n        StringBuilder urlBuilder = new StringBuilder(baseUrl)\n        if (preTransitionPathNameList) for (String pathName in preTransitionPathNameList) urlBuilder.append('/').append(pathName)\n        return urlBuilder.toString()\n    }\n\n    ArrayList<String> getPreTransitionPathNameList() { return preTransitionPathNameList }\n    ArrayList<String> getExtraPathNameList() { return extraPathNameList }\n\n    ScreenUrlInfo addParameter(Object name, Object value) {\n        if (!name || value == null) return this\n        pathParameterMap.put(name as String, ObjectUtilities.toPlainString(value))\n        return this\n    }\n    ScreenUrlInfo addParameters(Map manualParameters) {\n        if (!manualParameters) return this\n        for (Map.Entry mpEntry in manualParameters.entrySet()) {\n            pathParameterMap.put(mpEntry.getKey() as String, ObjectUtilities.toPlainString(mpEntry.getValue()))\n        }\n        return this\n    }\n    Map getPathParameterMap() { return pathParameterMap }\n\n    void initUrl() {\n        // TODO: use this in all calling code (expand url before creating/caching so that we have the full/unique one)\n        // support string expansion if there is a \"${\"\n        // if (fromScreenPath.contains('${')) fromScreenPath = ec.getResource().expand(fromScreenPath, \"\")\n\n        ArrayList<ScreenDefinition> screenRenderDefList = new ArrayList<ScreenDefinition>()\n\n        ArrayList<String> subScreenPath = parseSubScreenPath(rootSd, fromSd, fromPathList, fromScreenPath, pathParameterMap, sfi)\n        if (subScreenPath == null) {\n            targetExists = false\n            return\n        }\n        // logger.info(\"initUrl BEFORE fromPathList=${fromPathList}, fromScreenPath=${fromScreenPath}, subScreenPath=${subScreenPath}\")\n        boolean fromPathSlash = fromScreenPath.startsWith(\"/\")\n        if (fromPathSlash && fromScreenPath.startsWith(\"//\")) {\n            // find the screen by name\n            fromSd = rootSd\n            fromPathList = subScreenPath\n            fullPathNameList = subScreenPath\n        } else {\n            if (fromPathSlash) {\n                fromSd = rootSd\n                fromPathList = new ArrayList<String>()\n            }\n\n            fullPathNameList = subScreenPath\n        }\n        // logger.info(\"initUrl fromScreenPath=${fromScreenPath}, fromPathList=${fromPathList}, fullPathNameList=${fullPathNameList}\")\n\n        // encrypt is the default loop through screens if all are not secure/etc use http setting, otherwise https\n        requireEncryption = !\"false\".equals(rootSd?.webSettingsNode?.attribute(\"require-encryption\"))\n        if (\"true\".equals(rootSd?.screenNode?.attribute('begin-transaction'))) beginTransaction = true\n        String txTimeoutAttr = rootSd?.screenNode?.attribute(\"transaction-timeout\")\n        if (txTimeoutAttr) transactionTimeout = Integer.parseInt(txTimeoutAttr)\n\n        // start the render lists with the root SD\n        screenRenderDefList.add(rootSd)\n        screenPathDefList.add(rootSd)\n\n        // loop through path for various things: check validity, see if we can do a transition short-cut and go right\n        //     to its response url, etc\n        ScreenDefinition lastSd = rootSd\n        extraPathNameList = new ArrayList<String>(fullPathNameList)\n        for (int i = 0; i < fullPathNameList.size(); i++) {\n            String pathName = (String) fullPathNameList.get(i)\n            String rmExtension = (String) null\n            String pathNamePreDot = (String) null\n            int dotIndex = pathName.indexOf('.')\n            if (dotIndex > 0) {\n                // is there an extension with a render-mode added to the screen name?\n                String curExtension = pathName.substring(dotIndex + 1)\n                if (sfi.isRenderModeValid(curExtension)) {\n                    rmExtension = curExtension\n                    pathNamePreDot = pathName.substring(0, dotIndex)\n                }\n            }\n\n            // This section is for no-sub-path support, allowing screen override or extend on same path with wrapping by no-sub-path screen\n            // check getSubscreensNoSubPath() for subscreens item, transition, resource ref\n            // add subscreen to screenRenderDefList and screenPathDefList, also add to fullPathNameList\n            ArrayList<SubscreensItem> subscreensNoSubPath = lastSd.getSubscreensNoSubPath()\n            if (subscreensNoSubPath != null) {\n                int subscreensNoSubPathSize = subscreensNoSubPath.size()\n                for (int sni = 0; sni < subscreensNoSubPathSize; sni++) {\n                    SubscreensItem noSubPathSi = (SubscreensItem) subscreensNoSubPath.get(sni)\n                    String noSubPathLoc = noSubPathSi.getLocation()\n                    ScreenDefinition noSubPathSd = (ScreenDefinition) null\n                    try {\n                        noSubPathSd = sfi.getScreenDefinition(noSubPathLoc)\n                    } catch (Exception e) {\n                        logger.error(\"Error loading no sub-path screen under path ${pathName} at ${noSubPathLoc}\", BaseException.filterStackTrace(e))\n                    }\n                    if (noSubPathSd == null) continue\n\n                    boolean foundChild = false\n                    // look for subscreen, transition\n                    SubscreensItem subSi = noSubPathSd.getSubscreensItem(pathName)\n                    if ((subSi != null && sfi.isScreen(subSi.getLocation())) || noSubPathSd.hasTransition(pathName)) foundChild = true\n                    // is this a file under the screen?\n                    if (!foundChild) {\n                        ResourceReference existingFileRef = noSubPathSd.getSubContentRef(extraPathNameList)\n                        if (existingFileRef != null && existingFileRef.getExists() && !existingFileRef.isDirectory() &&\n                                !sfi.isScreen(existingFileRef.getLocation())) foundChild = true\n                    }\n                    // if pathNamePreDot not null see if matches subscreen or transition\n                    if (!foundChild && pathNamePreDot != null) {\n                        // is there an extension with a render-mode added to the screen name?\n                        subSi = noSubPathSd.getSubscreensItem(pathNamePreDot)\n                        if ((subSi != null && sfi.isScreen(subSi.getLocation())) || noSubPathSd.hasTransition(pathNamePreDot)) foundChild = true\n                    }\n\n                    if (foundChild) {\n                        // if standalone, clear out screenRenderDefList before adding this to it\n                        if (noSubPathSd.isStandalone()) {\n                            renderPathDifference += screenRenderDefList.size()\n                            screenRenderDefList.clear()\n                        } else {\n                            while (this.lastStandalone < 0 && -lastStandalone > renderPathDifference && screenRenderDefList.size() > 0) {\n                                renderPathDifference++\n                                screenRenderDefList.remove(0)\n                            }\n                        }\n\n                        screenRenderDefList.add(noSubPathSd)\n                        screenPathDefList.add(noSubPathSd)\n                        fullPathNameList.add(i, noSubPathSi.name)\n                        i++\n                        lastSd = noSubPathSd\n                        break\n                    }\n                }\n            }\n\n            SubscreensItem curSi = lastSd.getSubscreensItem(pathName)\n\n            if (curSi == null || !sfi.isScreen(curSi.getLocation())) {\n                // handle case where last one may be a transition name, and not a subscreen name\n                if (lastSd.hasTransition(pathName)) {\n                    // extra path elements always allowed after transitions for parameters, but we don't want the transition name on it\n                    extraPathNameList.remove(0)\n                    targetTransitionActualName = pathName\n                    // break out; a transition means we're at the end\n                    break\n                }\n\n                // is this a file under the screen?\n                ResourceReference existingFileRef = lastSd.getSubContentRef(extraPathNameList)\n                if (existingFileRef != null && existingFileRef.getExists() && !existingFileRef.isDirectory() &&\n                        !sfi.isScreen(existingFileRef.getLocation())) {\n                    // exclude screen files, don't want to treat them as resources and let them be downloaded\n                    fileResourceRef = existingFileRef\n                    break\n                }\n\n                if (pathNamePreDot != null) {\n                    // is there an extension with a render-mode added to the screen name?\n                    curSi = lastSd.getSubscreensItem(pathNamePreDot)\n                    if (curSi != null && sfi.isScreen(curSi.getLocation())) {\n                        targetScreenRenderMode = rmExtension\n                        if (sfi.isRenderModeAlwaysStandalone(rmExtension)) lastStandalone = 1\n                        fullPathNameList.set(i, pathNamePreDot)\n                        pathName = pathNamePreDot\n                    }\n\n                    // is there an extension beyond a transition name?\n                    if (curSi == null && lastSd.hasTransition(pathNamePreDot)) {\n                        // extra path elements always allowed after transitions for parameters, but we don't want the transition name on it\n                        extraPathNameList.remove(0)\n                        targetTransitionActualName = pathNamePreDot\n                        targetTransitionExtension = rmExtension\n                        // break out; a transition means we're at the end\n                        break\n                    }\n                }\n\n                // next SubscreenItem still not found?\n                if (curSi == null) {\n                    // call it good if extra path is allowed\n                    if (lastSd.allowExtraPath) break\n\n                    targetExists = false\n                    notExistsLastSd = lastSd\n                    notExistsLastName = extraPathNameList ? extraPathNameList.last() : (fullPathNameList ? fullPathNameList.last() : null)\n                    return\n                    // throw new ScreenResourceNotFoundException(fromSd, fullPathNameList, lastSd, extraPathNameList?.last(), null, new Exception(\"Screen sub-content not found here\"))\n                }\n            }\n\n            String nextLoc = curSi.getLocation()\n            ScreenDefinition curSd = (ScreenDefinition) null\n            try {\n                curSd = sfi.getScreenDefinition(nextLoc)\n            } catch (Exception e) {\n                logger.error(\"Error loading screen with path name ${pathName} at ${nextLoc}\", BaseException.filterStackTrace(e))\n            }\n            if (curSd == null) {\n                targetExists = false\n                notExistsLastSd = lastSd\n                notExistsLastName = pathName\n                notExistsNextLoc = nextLoc\n                return\n                // throw new ScreenResourceNotFoundException(fromSd, fullPathNameList, lastSd, pathName, nextLoc, new Exception(\"Screen subscreen or transition not found here\"))\n            }\n\n            if (curSd.webSettingsNode?.attribute('require-encryption') != \"false\") this.requireEncryption = true\n            if (curSd.screenNode?.attribute('begin-transaction') == \"true\") this.beginTransaction = true\n            String curTxTimeoutAttr = curSd.screenNode?.attribute(\"transaction-timeout\")\n            if (curTxTimeoutAttr) {\n                Integer curTransactionTimeout = Integer.parseInt(curTxTimeoutAttr)\n                if (transactionTimeout == null || curTransactionTimeout > transactionTimeout)\n                    transactionTimeout = curTransactionTimeout\n            }\n            if (curSd.getSubscreensNode()?.attribute('always-use-full-path') == \"true\") alwaysUseFullPath = true\n\n            for (ParameterItem pi in curSd.getParameterMap().values())\n                if (!pathParameterItems.containsKey(pi.name)) pathParameterItems.put(pi.name, pi)\n\n            // if standalone, clear out screenRenderDefList before adding this to it\n            if (curSd.isStandalone()) {\n                renderPathDifference += screenRenderDefList.size()\n                screenRenderDefList.clear()\n            } else {\n                while (this.lastStandalone < 0 && -lastStandalone > renderPathDifference && screenRenderDefList.size() > 0) {\n                    renderPathDifference++\n                    screenRenderDefList.remove(0)\n                }\n            }\n            screenRenderDefList.add(curSd)\n            screenPathDefList.add(curSd)\n            lastSd = curSd\n            // add this to the list of path names to use for transition redirect\n            preTransitionPathNameList.add(pathName)\n\n            // made it all the way to here so this was a screen\n            extraPathNameList.remove(0)\n        }\n\n        // save the path so far for minimal URLs\n        minimalPathNameList = new ArrayList<String>(fullPathNameList)\n\n        // beyond the last screenPathName, see if there are any screen.default-item values (keep following until none found)\n        int defaultSubScreenCount = 0\n        // NOTE: don't look for defaults if we have a target screen with a render mode, means we want to render that screen\n        while (targetScreenRenderMode == null && targetTransitionActualName == null && fileResourceRef == null && lastSd.getDefaultSubscreensItem()) {\n            if (lastSd.getSubscreensNode()?.attribute('always-use-full-path') == \"true\") alwaysUseFullPath = true\n            // logger.warn(\"TOREMOVE lastSd ${minimalPathNameList} subscreens: ${lastSd.screenNode?.subscreens}, alwaysUseFullPath=${alwaysUseFullPath}, from ${lastSd.screenNode.\"subscreens\"?.\"@always-use-full-path\"?.getAt(0)}, subscreenName=${subscreenName}\")\n\n            // determine the subscreen name\n            String subscreenName = null\n\n            // check SubscreensDefault records\n            EntityList subscreensDefaultList = ecfi.entity.find(\"moqui.screen.SubscreensDefault\")\n                    .condition(\"screenLocation\", lastSd.location).useCache(true).disableAuthz().list()\n            for (int i = 0; i < subscreensDefaultList.size(); i++) {\n                EntityValue subscreensDefault = subscreensDefaultList.get(i)\n                String condStr = (String) subscreensDefault.conditionExpression\n                if (condStr && !ecfi.getResource().condition(condStr, \"SubscreensDefault_condition\")) continue\n                subscreenName = subscreensDefault.subscreenName\n            }\n\n            // if any conditional-default.@condition eval to true, use that conditional-default.@item instead\n            List<MNode> condDefaultList = lastSd.getSubscreensNode()?.children(\"conditional-default\")\n            if (condDefaultList != null && condDefaultList.size() > 0) for (MNode conditionalDefaultNode in condDefaultList) {\n                String condStr = conditionalDefaultNode.attribute('condition')\n                if (!condStr) continue\n                if (ecfi.getResource().condition(condStr, null)) {\n                    subscreenName = conditionalDefaultNode.attribute('item')\n                    break\n                }\n            }\n\n            // whether we got a hit or not there are conditional defaults for this path, so can't reuse this instance\n            if ((subscreensDefaultList != null && subscreensDefaultList.size() > 0) ||\n                    (condDefaultList != null && condDefaultList.size() > 0)) reusable = false\n\n            if (subscreenName == null || subscreenName.isEmpty()) subscreenName = lastSd.getDefaultSubscreensItem()\n\n            String nextLoc = lastSd.getSubscreensItem(subscreenName)?.location\n            if (nextLoc == null || nextLoc.isEmpty()) {\n                // handle case where last one may be a transition name, and not a subscreen name\n                if (lastSd.hasTransition(subscreenName)) {\n                    targetTransitionActualName = subscreenName\n                    fullPathNameList.add(subscreenName)\n                    break\n                }\n\n                // is this a file under the screen?\n                ResourceReference existingFileRef = lastSd.getSubContentRef([subscreenName])\n                if (existingFileRef && existingFileRef.supportsExists() && existingFileRef.exists) {\n                    fileResourceRef = existingFileRef\n                    fullPathNameList.add(subscreenName)\n                    break\n                }\n\n                targetExists = false\n                return\n                // throw new ScreenResourceNotFoundException(fromSd, fullPathNameList, lastSd, subscreenName, null, new Exception(\"Screen subscreen or transition not found here\"))\n            }\n            ScreenDefinition curSd = sfi.getScreenDefinition(nextLoc)\n            if (curSd == null) {\n                targetExists = false\n                return\n                // throw new ScreenResourceNotFoundException(fromSd, fullPathNameList, lastSd, subscreenName, nextLoc, new Exception(\"Screen subscreen or transition not found here\"))\n            }\n\n            if (curSd.webSettingsNode?.attribute('require-encryption') != \"false\") this.requireEncryption = true\n            if (curSd.screenNode?.attribute('begin-transaction') == \"true\") this.beginTransaction = true\n            String curTxTimeoutAttr = curSd.screenNode?.attribute(\"transaction-timeout\")\n            if (curTxTimeoutAttr) {\n                Integer curTransactionTimeout = Integer.parseInt(curTxTimeoutAttr)\n                if (transactionTimeout == null || curTransactionTimeout > transactionTimeout)\n                    transactionTimeout = curTransactionTimeout\n            }\n\n            // if standalone, clear out screenRenderDefList before adding this to it\n            if (curSd.isStandalone()) {\n                renderPathDifference += screenRenderDefList.size()\n                screenRenderDefList.clear()\n            } else {\n                while (this.lastStandalone < 0 && -lastStandalone > renderPathDifference && screenRenderDefList.size() > 0) {\n                    renderPathDifference++\n                    screenRenderDefList.remove(0)\n                }\n            }\n\n            screenRenderDefList.add(curSd)\n            screenPathDefList.add(curSd)\n            lastSd = curSd\n\n            // for use in URL writing and such add the subscreenName we found to the main path name list\n            fullPathNameList.add(subscreenName)\n            // add this to the list of path names to use for transition redirect, just in case a default is a transition\n            preTransitionPathNameList.add(subscreenName)\n\n            defaultSubScreenCount++\n        }\n\n        this.targetScreen = lastSd\n\n        // remove all but lastStandalone items from screenRenderDefList\n        if (lastStandalone > 0) while (screenRenderDefList.size() > lastStandalone) {\n            renderPathDifference++\n            screenRenderDefList.remove(0)\n        }\n\n        // screenRenderDefList now in place, look for menu-image and menu-image-type of last in list\n        int renderListSize = screenRenderDefList.size()\n        int defaultSubScreenLimit = renderListSize - defaultSubScreenCount - 1\n        for (int i = 0; i < renderListSize; i++) {\n            // only use explicit path to find icon, don't want default subscreens overriding it\n            ScreenDefinition curSd = screenRenderDefList.get(i)\n            String curMenuImage = curSd.getScreenNode().attribute(\"menu-image\")\n            if (curMenuImage) {\n                menuImage = curMenuImage\n                menuImageType = curSd.getScreenNode().attribute(\"menu-image-type\") ?: 'url-screen'\n            }\n            if (i >= defaultSubScreenLimit && menuImage) break\n        }\n    }\n\n    void checkExists() {\n        if (!targetExists) throw new ScreenResourceNotFoundException(fromSd, fullPathNameList, notExistsLastSd, notExistsLastName,\n                notExistsNextLoc, new Exception(\"Screen, transition, or resource not found here\"))\n    }\n\n    @Override\n    String toString() {\n        // return ONLY the url built from the inputs; that is the most basic possible value\n        return this.getUrlWithBase(getBaseUrl(null))\n    }\n\n    ScreenUrlInfo cloneUrlInfo() {\n        ScreenUrlInfo sui = new ScreenUrlInfo()\n        this.copyUrlInfoInto(sui)\n        return sui\n    }\n\n    void copyUrlInfoInto(ScreenUrlInfo sui) {\n        sui.sfi = this.sfi\n        sui.rootSd = this.rootSd\n        sui.fromSd = this.fromSd\n        sui.fromPathList = this.fromPathList != null ? new ArrayList<String>(this.fromPathList) : null\n        sui.fromScreenPath = this.fromScreenPath\n        sui.pathParameterMap = this.pathParameterMap != null ? new HashMap(this.pathParameterMap) : null\n        sui.requireEncryption = this.requireEncryption\n        sui.beginTransaction = this.beginTransaction\n        sui.transactionTimeout = this.transactionTimeout\n        sui.fullPathNameList = this.fullPathNameList != null ? new ArrayList<String>(this.fullPathNameList) : null\n        sui.minimalPathNameList = this.minimalPathNameList != null ? new ArrayList<String>(this.minimalPathNameList) : null\n        sui.fileResourcePathList = this.fileResourcePathList != null ? new ArrayList<String>(this.fileResourcePathList) : null\n        sui.fileResourceRef = this.fileResourceRef\n        sui.fileResourceContentType = this.fileResourceContentType\n        sui.screenPathDefList = this.screenPathDefList != null ? new ArrayList<ScreenDefinition>(this.screenPathDefList) : null\n        sui.renderPathDifference = this.renderPathDifference\n        sui.lastStandalone = this.lastStandalone\n        sui.targetScreen = this.targetScreen\n        sui.targetScreenRenderMode = this.targetScreenRenderMode\n        sui.targetTransitionActualName = this.targetTransitionActualName\n        sui.targetTransitionExtension = this.targetTransitionExtension\n        sui.preTransitionPathNameList = this.preTransitionPathNameList!=null ? new ArrayList<String>(this.preTransitionPathNameList) : null\n    }\n\n    static ArrayList<String> parseSubScreenPath(ScreenDefinition rootSd, ScreenDefinition fromSd, List<String> fromPathList,\n                                                String screenPath, Map inlineParameters, ScreenFacadeImpl sfi) {\n        if (screenPath == null) screenPath = \"\"\n        // at very beginning look up ScreenPathAlias to see if this should be replaced; allows various flexible uses of this including global placeholders\n        boolean startsWithSlash = screenPath.startsWith(\"/\")\n        String aliasPath = screenPath\n        if (!startsWithSlash && fromPathList != null && fromPathList.size() > 0) {\n            StringBuilder newPath = new StringBuilder()\n            int fplSize = fromPathList.size()\n            for (int i = 0; i < fplSize; i++) newPath.append('/').append(fromPathList.get(i))\n            if (!screenPath.isEmpty()) newPath.append('/').append(screenPath)\n            aliasPath = newPath.toString()\n        }\n        // logger.warn(\"Looking for path alias with screenPath ${screenPath} fromPathList ${fromPathList} aliasPath ${aliasPath}\")\n        EntityList screenPathAliasList = sfi.ecfi.entityFacade.find(\"moqui.screen.ScreenPathAlias\")\n                .condition(\"aliasPath\", aliasPath).disableAuthz().useCache(true).list()\n        // logger.warn(\"Looking for path alias with aliasPath ${aliasPath} screenPathAliasList ${screenPathAliasList}\")\n        // keep this as light weight as possible, only filter and sort if needed\n        if (screenPathAliasList.size() > 0) {\n            screenPathAliasList = screenPathAliasList.cloneList().filterByDate(\"fromDate\", \"thruDate\", null)\n            int spaListSize = screenPathAliasList.size()\n            if (spaListSize > 0) {\n                if (spaListSize > 1) screenPathAliasList.orderByFields([\"-fromDate\"])\n                String newScreenPath = screenPathAliasList.get(0).getNoCheckSimple(\"screenPath\")\n                if (newScreenPath != null && !newScreenPath.isEmpty()) {\n                    screenPath = newScreenPath\n                }\n            }\n        }\n\n        // NOTE: this is somewhat tricky because screenPath may be encoded or not, may come from internal string or from browser URL string\n\n        // if there are any ?... parameters parse them off and remove them from the string\n        int indexOfQuestionMark = screenPath.lastIndexOf(\"?\")\n\n        // BAD idea: common to have at least '.' characters in URL parameters and such\n        // for wiki pages and other odd filenames try to handle a '?' in the filename, ie don't consider parameter separator if\n        //     there is a '/' or '.' after it or if it is the end of the string; doesn't handle all cases, may not be possible to\n        // if (indexOfQuestionMark > 0 && (indexOfQuestionMark == screenPath.length() - 1 || screenPath.indexOf(\"/\", indexOfQuestionMark) > 0 || screenPath.indexOf(\".\", indexOfQuestionMark) > 0)) { indexOfQuestionMark = -1 }\n        // logger.warn(\"indexOfQuestionMark ${indexOfQuestionMark} screenPath ${screenPath}\")\n\n        if (indexOfQuestionMark > 0) {\n            String pathParmString = screenPath.substring(indexOfQuestionMark + 1)\n            if (inlineParameters != null && pathParmString.length() > 0) {\n                List<String> nameValuePairs = pathParmString.replaceAll(\"&amp;\", \"&\").split(\"&\") as List\n                for (String nameValuePair in nameValuePairs) {\n                    String[] nameValue = nameValuePair.substring(0).split(\"=\")\n                    if (nameValue.length == 2) inlineParameters.put(nameValue[0], URLDecoder.decode(nameValue[1], \"UTF-8\"))\n                }\n            }\n\n            screenPath = screenPath.substring(0, indexOfQuestionMark)\n        }\n\n        startsWithSlash = screenPath.startsWith(\"/\")\n        if (startsWithSlash && screenPath.startsWith(\"//\")) {\n            // find the screen by name\n            String trimmedFromPath = screenPath.substring(2)\n            ArrayList<String> originalPathNameList = new ArrayList<String>(Arrays.asList(trimmedFromPath.split(\"/\")))\n            originalPathNameList = cleanupPathNameList(originalPathNameList, inlineParameters)\n\n            if (sfi.screenFindPathCache.containsKey(screenPath)) {\n                ArrayList<String> cachedPathList = (ArrayList<String>) sfi.screenFindPathCache.get(screenPath)\n                if (cachedPathList != null && cachedPathList.size() > 0) {\n                    return cachedPathList\n                } else {\n                    return null\n                    // throw new ScreenResourceNotFoundException(fromSd, originalPathNameList, fromSd, screenPath, null, new Exception(\"Could not find screen, transition or content matching path\"))\n                }\n            } else {\n                ArrayList<String> expandedPathNameList = rootSd.findSubscreenPath(originalPathNameList)\n                sfi.screenFindPathCache.put(screenPath, expandedPathNameList)\n                if (expandedPathNameList) {\n                    return expandedPathNameList\n                } else {\n                    return null\n                    // throw new ScreenResourceNotFoundException(fromSd, originalPathNameList, fromSd, screenPath, null, new Exception(\"Could not find screen, transition or content matching path\"))\n                }\n            }\n        } else {\n            if (startsWithSlash) fromPathList = (List<String>) null\n\n            ArrayList<String> tempPathNameList = new ArrayList<String>()\n            if (fromPathList != null) tempPathNameList.addAll(fromPathList)\n            tempPathNameList.addAll(Arrays.asList(screenPath.split(\"/\")))\n            return cleanupPathNameList(tempPathNameList, inlineParameters)\n        }\n    }\n\n    static ArrayList<String> cleanupPathNameList(ArrayList<String> inputPathNameList, Map inlineParameters) {\n        // filter the list: remove empty, remove \".\", remove \"..\" and previous\n        int inputPathNameListSize = inputPathNameList.size()\n        ArrayList<String> cleanList = new ArrayList<String>(inputPathNameListSize)\n        for (int i = 0; i < inputPathNameListSize; i++) {\n            String pathName = (String) inputPathNameList.get(i)\n            if (pathName == null || pathName.length() == 0) continue\n            if (\".\".equals(pathName)) continue\n            // .. means go up a level, ie drop the last in the list\n            if (\"..\".equals(pathName)) {\n                int cleanListSize = cleanList.size()\n                if (cleanListSize > 0) cleanList.remove(cleanListSize - 1)\n                continue\n            }\n            // if it has a tilde it is a parameter, so skip it but remember it\n            if (pathName.startsWith(\"~\")) {\n                if (inlineParameters != null) {\n                    String[] nameValue = pathName.substring(1).split(\"=\")\n                    if (nameValue.length == 2) inlineParameters.put(nameValue[0], URLDecoder.decode(nameValue[1], \"UTF-8\"))\n                }\n                continue\n            }\n\n            // the original approach, not needed as already decoded: cleanList.add(URLDecoder.decode(pathName, \"UTF-8\"))\n            // the 2nd pass approach, now not needed as ScreenRenderImpl.render(request, response) uses URLDecoder for each path segment: cleanList.add(pathName.replace(plusChar, spaceChar))\n            cleanList.add(pathName)\n        }\n        return cleanList\n    }\n\n    static int parseLastStandalone(String lastStandalone, int defLs) {\n        if (lastStandalone == null || lastStandalone.length() == 0) return defLs\n        if (lastStandalone.startsWith(\"t\")) return 1\n        if (lastStandalone.startsWith(\"f\")) return 0\n        try {\n            return Integer.parseInt(lastStandalone)\n        } catch (Exception e) {\n            if (logger.isTraceEnabled()) logger.trace(\"Error parsing lastStandalone value ${lastStandalone}, default to 0 for no lastStandalone\")\n            return 0\n        }\n    }\n\n    @CompileStatic\n    static class UrlInstance {\n        ScreenUrlInfo sui\n        ScreenRenderImpl sri\n        ExecutionContextImpl ec\n        Boolean expandAliasTransition\n\n        /** If a transition is specified, the target transition within the targetScreen */\n        TransitionItem curTargetTransition = (TransitionItem) null\n\n        Map<String, String> otherParameterMap = new HashMap<String, String>()\n        Map transitionAliasParameters = (Map) null\n        Map<String, String> allParameterMap = (Map<String, String>) null\n\n        UrlInstance(ScreenUrlInfo sui, ScreenRenderImpl sri, Boolean expandAliasTransition) {\n            this.sui = sui\n            this.sri = sri\n            ec = sri.ec\n\n            this.expandAliasTransition = expandAliasTransition\n            if (expandAliasTransition != null && expandAliasTransition.booleanValue()) expandTransitionAliasUrl()\n\n            // logger.warn(\"======= Creating UrlInstance ${sui.getFullPathNameList()} - ${sui.targetScreen.getLocation()} - ${sui.getTargetTransitionActualName()}\")\n        }\n\n        String getRequestMethod() { return ec.web != null ? ec.web.request.method : \"\" }\n        TransitionItem getTargetTransition() {\n            if (curTargetTransition == null && sui.targetScreen != null && sui.targetTransitionActualName != null)\n                curTargetTransition = sui.targetScreen.getTransitionItem(sui.targetTransitionActualName, getRequestMethod())\n            return curTargetTransition\n        }\n        boolean getHasActions() { getTargetTransition() != null && (getTargetTransition().actions != null || getTargetTransition().serviceActions != null) }\n        boolean isReadOnly() { getTargetTransition() == null || getTargetTransition().isReadOnly() }\n        boolean getDisableLink() { return !sui.targetExists || (getTargetTransition() != null && !getTargetTransition().checkCondition(ec)) || !isPermitted() }\n        boolean isPermitted() { return sui.isPermitted(ec, getTargetTransition()) }\n        boolean getInCurrentScreenPath() {\n            List<String> currentPathNameList = new ArrayList<String>(sri.screenUrlInfo.fullPathNameList)\n            return sui.getInCurrentScreenPath(currentPathNameList)\n        }\n        boolean isScreenUrl() {\n            if (getTargetTransition() != null && curTargetTransition.defaultResponse != null &&\n                    (\"plain\".equals(curTargetTransition.defaultResponse.urlType) || \"none\".equals(curTargetTransition.defaultResponse.type) ||\n                            curTargetTransition.defaultResponse.parameterMap.containsKey(\"renderMode\"))) return false\n            return sui.targetScreen != null\n        }\n\n        void expandTransitionAliasUrl() {\n            TransitionItem ti = getTargetTransition()\n            if (ti == null) return\n\n            // Screen Transition as a URL Alias:\n            // if fromScreenPath is a transition, and that transition has no condition,\n            // service/actions or conditional-response then use the default-response.url instead\n            // of the name (if type is screen-path or empty, url-type is url or empty)\n            if (ti.condition == null && !ti.hasActionsOrSingleService() && !ti.conditionalResponseList &&\n                    ti.defaultResponse != null && \"url\".equals(ti.defaultResponse.type) &&\n                    \"screen-path\".equals(ti.defaultResponse.urlType) && ec.web != null) {\n\n                transitionAliasParameters = ti.defaultResponse.expandParameters(sui.getExtraPathNameList(), ec)\n\n                // create a ScreenUrlInfo, then copy its info into this\n                String expandedUrl = ti.defaultResponse.url\n                if (expandedUrl.contains('${')) expandedUrl = ec.resourceFacade.expand(expandedUrl, \"\")\n                ScreenUrlInfo aliasUrlInfo = getScreenUrlInfo(sri.sfi, sui.rootSd, sui.fromSd,\n                        sui.preTransitionPathNameList, expandedUrl, parseLastStandalone((String) transitionAliasParameters.lastStandalone, sui.lastStandalone))\n\n                // logger.warn(\"Made transition alias: ${aliasUrlInfo.toString()}\")\n                sui = aliasUrlInfo\n                curTargetTransition = (TransitionItem) null\n            }\n        }\n        Map getTransitionAliasParameters() { return transitionAliasParameters }\n\n        String getPath() { return sui.getUrlWithBase(\"\") }\n        String getPathWithParams() {\n            String ps = getParameterString()\n            String path = getPath()\n            if (ps.length() > 0) path = path.concat(\"?\").concat(ps)\n            return path\n        }\n        // now redundant with getPath() but left in place for backward compatibility\n        String getScreenPath() { return sui.getUrlWithBase(\"\") }\n\n        String getUrl() { return sui.getUrlWithBase(sui.getBaseUrl(sri)) }\n        String getUrlWithParams() {\n            String ps = getParameterString()\n            String url = getUrl()\n            if (ps.length() > 0) url = url.concat(\"?\").concat(ps)\n            return url\n        }\n        String getUrlWithParams(String extension) {\n            String ps = getParameterString()\n            String url = getUrl()\n            if (extension != null && !extension.isEmpty()) url = url.concat(\".\").concat(extension)\n            if (ps.length() > 0) url = url.concat(\"?\").concat(ps)\n            return url\n        }\n\n        String getMinimalPathUrl() { return sui.getMinimalPathUrlWithBase(sui.getBaseUrl(sri)) }\n        String getMinimalPathUrlWithParams() {\n            String ps = getParameterString()\n            String url = getMinimalPathUrl()\n            if (ps != null && ps.length() > 0) url = url.concat(\"?\").concat(ps)\n            return url\n        }\n\n        String getScreenOnlyPath() { return sui.getScreenPathUrlWithBase(\"\") }\n        String getScreenPathUrl() { return sui.getScreenPathUrlWithBase(sui.getBaseUrl(sri)) }\n\n        Map<String, String> getParameterMap() {\n            if (allParameterMap != null) return allParameterMap\n\n            allParameterMap = new HashMap<>()\n            // get default parameters for the screens in the path\n\n            for (ParameterItem pi in (Collection<ParameterItem>) sui.pathParameterItems.values()) {\n                Object value = pi.getValue(ec)\n                String valueStr = ObjectUtilities.toPlainString(value)\n                if (valueStr != null && valueStr.length() > 0) allParameterMap.put(pi.name, valueStr)\n            }\n\n            TransitionItem targetTrans = getTargetTransition()\n            if (targetTrans != null) {\n                Map<String, ParameterItem> transParameterMap = targetTrans.getParameterMap()\n                for (ParameterItem pi in (Collection<ParameterItem>) transParameterMap.values()) {\n                    Object value = pi.getValue(ec)\n                    String valueStr = ObjectUtilities.toPlainString(value)\n                    if (valueStr != null && valueStr.length() > 0) allParameterMap.put(pi.name, valueStr)\n                }\n                String targetServiceName = targetTransition.getSingleServiceName()\n                if (targetServiceName != null && targetServiceName.length() > 0) {\n                    ServiceDefinition sd = ec.serviceFacade.getServiceDefinition(targetServiceName)\n                    Map<String, Object> csMap = ec.contextStack.getCombinedMap()\n                    Map<String, Object> wfParameters = ec.getWeb()?.getParameters()\n                    if (sd != null) {\n                        ArrayList<String> inParameterNames = sd.getInParameterNames()\n                        int inParameterNamesSize = inParameterNames.size()\n                        for (int i = 0; i < inParameterNamesSize; i++) {\n                            String pn = (String) inParameterNames.get(i)\n                            Object value = csMap.get(pn)\n                            if (ObjectUtilities.isEmpty(value) && wfParameters != null)\n                                value = wfParameters.get(pn)\n                            String valueStr = ObjectUtilities.toPlainString(value)\n                            if (valueStr != null && valueStr.length() > 0) allParameterMap.put(pn, valueStr)\n                        }\n                    } else if (targetServiceName.contains(\"#\")) {\n                        // service name but no service def, see if it is an entity op and if so try the pk fields\n                        String verb = targetServiceName.substring(0, targetServiceName.indexOf(\"#\"))\n                        if (verb == \"create\" || verb == \"update\" || verb == \"delete\" || verb == \"store\") {\n                            String en = targetServiceName.substring(targetServiceName.indexOf(\"#\") + 1)\n                            EntityDefinition ed = ec.entityFacade.getEntityDefinition(en)\n                            if (ed != null) {\n                                for (String fn in ed.getPkFieldNames()) {\n                                    Object value = csMap.get(fn)\n                                    if (ObjectUtilities.isEmpty(value) && wfParameters != null)\n                                        value = wfParameters.get(fn)\n                                    String valueStr = ObjectUtilities.toPlainString(value)\n                                    if (valueStr != null && valueStr.length() > 0) allParameterMap.put(fn, valueStr)\n                                }\n                            }\n                        }\n                    }\n                }\n            }\n            // add all of the parameters specified inline in the screen path or added after\n            if (sui.pathParameterMap != null) allParameterMap.putAll(sui.pathParameterMap)\n            // add transition parameters, for alias transitions\n            if (transitionAliasParameters != null) allParameterMap.putAll(transitionAliasParameters)\n            // add all parameters added to the instance after\n            allParameterMap.putAll(otherParameterMap)\n\n            // logger.info(\"TOREMOVE Getting parameterMap [${pm}] for targetScreen [${targetScreen.location}]\")\n            return allParameterMap\n        }\n\n        String getParameterString() {\n            StringBuilder ps = new StringBuilder()\n            Map<String, String> pm = getParameterMap()\n            for (Map.Entry<String, String> pme in pm.entrySet()) {\n                if (!pme.value) continue\n                if (pme.key == \"moquiSessionToken\") continue\n                if (ps.length() > 0) ps.append(\"&\")\n                ps.append(StringUtilities.urlEncodeIfNeeded(pme.key)).append(\"=\").append(StringUtilities.urlEncodeIfNeeded(pme.value))\n            }\n            return ps.toString()\n        }\n        String getParameterPathString() {\n            StringBuilder ps = new StringBuilder()\n            Map<String, String> pm = getParameterMap()\n            for (Map.Entry<String, String> pme in pm.entrySet()) {\n                if (!pme.getValue()) continue\n                ps.append(\"/~\")\n                ps.append(StringUtilities.urlEncodeIfNeeded(pme.getKey())).append(\"=\").append(StringUtilities.urlEncodeIfNeeded(pme.getValue()))\n            }\n            return ps.toString()\n        }\n\n        UrlInstance addParameter(Object nameObj, Object value) {\n            String name = nameObj.toString()\n            if (name == null || name.length() == 0 || value == null) return this\n            String parmValue = ObjectUtilities.toPlainString(value)\n            otherParameterMap.put(name, parmValue)\n            if (allParameterMap != null) allParameterMap.put(name, parmValue)\n            return this\n        }\n        UrlInstance addParameters(Map manualParameters) {\n            if (manualParameters == null || manualParameters.size() == 0) return this\n            for (Map.Entry mpEntry in manualParameters.entrySet()) {\n                String parmKey = mpEntry.getKey().toString()\n                // just in case a ContextStack with the context entry used is passed\n                if (\"context\".equals(parmKey)) continue\n                String parmValue = ObjectUtilities.toPlainString(mpEntry.getValue())\n                otherParameterMap.put(parmKey, parmValue)\n                if (allParameterMap != null) allParameterMap.put(parmKey, parmValue)\n            }\n            return this\n        }\n        UrlInstance removeParameter(Object nameObj) {\n            String name = nameObj.toString()\n            if (name == null || name.length() == 0) return this\n            otherParameterMap.remove(name)\n            // make sure allParameterMap is populated first\n            if (allParameterMap == null) getParameterMap()\n            allParameterMap.remove(name)\n            return this\n        }\n        Map getOtherParameterMap() { return otherParameterMap }\n\n        UrlInstance passThroughSpecialParameters() {\n            copySpecialParameters(ec.context, otherParameterMap)\n            return this\n        }\n        static void copySpecialParameters(Map fromMap, Map toMap) {\n            if (!fromMap || toMap == null) return\n            for (String fieldName in fromMap.keySet()) {\n                if (fieldName.startsWith(\"formDisplayOnly\")) toMap.put(fieldName, (String) fromMap.get(fieldName))\n            }\n            if (fromMap.containsKey(\"pageNoLimit\")) toMap.put(\"pageNoLimit\", (String) fromMap.get(\"pageNoLimit\"))\n            if (fromMap.containsKey(\"lastStandalone\")) toMap.put(\"lastStandalone\", (String) fromMap.get(\"lastStandalone\"))\n            if (fromMap.containsKey(\"renderMode\")) toMap.put(\"renderMode\", (String) fromMap.get(\"renderMode\"))\n        }\n        Map<String, String> getPassThroughParameterMap() {\n            Map<String, String> paramMap = new HashMap<>(getParameterMap())\n            paramMap.remove(\"moquiFormName\")\n            paramMap.remove(\"moquiSessionToken\")\n            paramMap.remove(\"lastStandalone\")\n            paramMap.remove(\"formListFindId\")\n            paramMap.remove(\"moquiRequestStartTime\")\n            paramMap.remove(\"webrootTT\")\n            logger.warn(\"pass through params for ${getUrl()}: ${paramMap}\")\n            return paramMap\n        }\n        UrlInstance addPassThroughParameters(UrlInstance sourceUrlInstance) {\n            if (sourceUrlInstance == null) return null\n            addParameters(sourceUrlInstance.getPassThroughParameterMap())\n            return this\n        }\n\n        UrlInstance cloneUrlInstance() {\n            UrlInstance ui = new UrlInstance(sui, sri, expandAliasTransition)\n            ui.curTargetTransition = curTargetTransition\n            if (otherParameterMap) ui.otherParameterMap = new HashMap<String, String>(otherParameterMap)\n            if (transitionAliasParameters) ui.transitionAliasParameters = new HashMap(transitionAliasParameters)\n            return ui\n        }\n\n        @Override\n        String toString() {\n            // return ONLY the url built from the inputs; that is the most basic possible value\n            return this.getUrl()\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/screen/ScreenWidgetRender.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.screen;\n\npublic interface ScreenWidgetRender {\n    void render(ScreenWidgets widgets, ScreenRenderImpl sri);\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/screen/ScreenWidgetRenderFtl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.screen\n\nimport groovy.transform.CompileStatic\nimport org.moqui.util.ContextStack\n\n@CompileStatic\nclass ScreenWidgetRenderFtl implements ScreenWidgetRender {\n\n    ScreenWidgetRenderFtl() { }\n\n    @Override\n    void render(ScreenWidgets widgets, ScreenRenderImpl sri) {\n        ContextStack cs = sri.ec.contextStack\n        cs.push()\n        try {\n            cs.sri = sri\n            cs.widgetsNode = widgets.getWidgetsNode()\n\n            sri.template.createProcessingEnvironment(cs, sri.writer).process()\n        } finally {\n            cs.pop()\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/screen/ScreenWidgets.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.screen\n\nimport groovy.transform.CompileStatic\nimport org.moqui.util.MNode\nimport org.slf4j.LoggerFactory\nimport org.slf4j.Logger\n\n@CompileStatic\nclass ScreenWidgets {\n    protected final static Logger logger = LoggerFactory.getLogger(ScreenWidgets.class)\n\n    protected MNode widgetsNode\n    protected String location\n\n    ScreenWidgets(MNode widgetsNode, String location) {\n        this.widgetsNode = widgetsNode\n        this.location = location\n    }\n\n    MNode getWidgetsNode() { return widgetsNode }\n    String getLocation() { return location }\n\n    void render(ScreenRenderImpl sri) {\n        ScreenWidgetRender swr = sri.getScreenWidgetRender()\n        swr.render(this, sri)\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/screen/WebFacadeStub.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.screen\n\nimport groovy.transform.CompileStatic\n\nimport org.moqui.impl.context.ContextJavaUtil\nimport org.moqui.util.ContextStack\nimport org.moqui.context.ValidationError\nimport org.moqui.context.WebFacade\nimport org.moqui.context.MessageFacade.MessageInfo\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.impl.context.WebFacadeImpl\nimport org.moqui.impl.service.RestApi\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport jakarta.servlet.AsyncContext\nimport jakarta.servlet.DispatcherType\nimport jakarta.servlet.Filter\nimport jakarta.servlet.FilterRegistration\nimport jakarta.servlet.RequestDispatcher\nimport jakarta.servlet.Servlet\nimport jakarta.servlet.ServletConnection\nimport jakarta.servlet.ServletContext\nimport jakarta.servlet.ServletException\nimport jakarta.servlet.ServletInputStream\nimport jakarta.servlet.ServletOutputStream\nimport jakarta.servlet.ServletRegistration\nimport jakarta.servlet.ServletRequest\nimport jakarta.servlet.ServletResponse\nimport jakarta.servlet.SessionCookieConfig\nimport jakarta.servlet.SessionTrackingMode\nimport jakarta.servlet.descriptor.JspConfigDescriptor\nimport jakarta.servlet.http.Cookie\nimport jakarta.servlet.http.HttpServletRequest\nimport jakarta.servlet.http.HttpServletResponse\nimport jakarta.servlet.http.HttpSession\nimport jakarta.servlet.http.HttpUpgradeHandler\nimport jakarta.servlet.http.Part\n\nimport java.security.Principal\n\n/** A test stub for the WebFacade interface, used in ScreenTestImpl */\n@CompileStatic\nclass WebFacadeStub implements WebFacade {\n    protected final static Logger logger = LoggerFactory.getLogger(WebFacadeStub.class)\n\n    ExecutionContextFactoryImpl ecfi\n    ContextStack parameters = (ContextStack) null\n    Map<String, Object> requestParameters = [:]\n    Map<String, Object> sessionAttributes = [:]\n    String requestMethod = \"get\"\n    boolean skipJsonSerialize = false\n\n    protected HttpSessionStub httpSession\n    protected HttpServletRequestStub httpServletRequest\n    protected ServletContextStub servletContext\n    protected HttpServletResponseStub httpServletResponse\n\n    protected StringWriter responseWriter = new StringWriter()\n    protected PrintWriter responsePrintWriter = new PrintWriter(responseWriter)\n    protected Object responseJsonObj = null\n\n    WebFacadeStub(ExecutionContextFactoryImpl ecfi, Map<String, Object> requestParameters,\n                  Map<String, Object> sessionAttributes, String requestMethod) {\n        this.ecfi = ecfi\n        if (requestParameters != null) this.requestParameters.putAll(requestParameters)\n        if (sessionAttributes != null) this.sessionAttributes = sessionAttributes\n        if (requestMethod != null) this.requestMethod = requestMethod\n\n        servletContext = new ServletContextStub(this)\n        httpSession = new HttpSessionStub(this)\n        httpServletRequest = new HttpServletRequestStub(this)\n        httpServletResponse = new HttpServletResponseStub(this)\n    }\n\n    String getResponseText() { responseWriter.flush(); return responseWriter.toString() }\n    Object getResponseJsonObj() { return responseJsonObj }\n    HttpServletResponseStub getHttpServletResponseStub() { return httpServletResponse }\n    String getRequestDetails() { return \"Stub\" }\n\n    @Override String getRequestUrl() { return \"TestRequestUrl\" }\n\n    @Override Map<String, Object> getParameters() {\n        // only create when requested, then keep for additional requests\n        if (parameters != null) return parameters\n\n        ContextStack cs = new ContextStack()\n        cs.push(sessionAttributes)\n        cs.push(requestParameters)\n        parameters = cs\n        return parameters\n    }\n\n    @Override HttpServletRequest getRequest() { return httpServletRequest }\n    @Override Map<String, Object> getRequestAttributes() { return requestParameters }\n    @Override Map<String, Object> getRequestParameters() { return requestParameters }\n    @Override Map<String, Object> getSecureRequestParameters() { return requestParameters }\n\n    @Override String getHostName(boolean withPort) { return withPort ? \"localhost:443\" : \"localhost\" }\n\n    @Override String getPathInfo() { return httpServletRequest.getPathInfo() }\n    @Override ArrayList<String> getPathInfoList() { return WebFacadeImpl.getPathInfoList(request) }\n    @Override String getRequestBodyText() { return null }\n    @Override String getResourceDistinctValue() { return ecfi.initStartHex }\n\n    @Override HttpServletResponse getResponse() { return httpServletResponse }\n    @Override HttpSession getSession() { return httpSession }\n    @Override Map<String, Object> getSessionAttributes() { return sessionAttributes }\n    @Override String getSessionToken() { return \"TestSessionToken\" }\n    @Override ServletContext getServletContext() { return servletContext }\n    @Override Map<String, Object> getApplicationAttributes() { return sessionAttributes }\n\n    @Override String getWebappRootUrl(boolean requireFullUrl, Boolean useEncryption) {\n        return useEncryption ? \"https://localhost\" : \"http://localhost\"\n    }\n\n    @Override Map<String, Object> getErrorParameters() { return null }\n    @Override List<MessageInfo> getSavedMessages() { return null }\n    @Override List<MessageInfo> getSavedPublicMessages() { return null }\n    @Override List<String> getSavedErrors() { return null }\n    @Override List<ValidationError> getSavedValidationErrors() { return null }\n    @Override List<ValidationError> getFieldValidationErrors(String fieldName) { return null }\n\n    @Override List<Map> getScreenHistory() { return (List<Map>) sessionAttributes.get(\"moqui.screen.history\") ?: new ArrayList<Map>() }\n\n    @Override void sendJsonResponse(Object responseObj) {\n        if (skipJsonSerialize) {\n            responseJsonObj = responseObj\n        } else {\n            WebFacadeImpl.sendJsonResponseInternal(responseObj, ecfi.getEci(), httpServletRequest, httpServletResponse, requestAttributes)\n        }\n        /*\n        String jsonStr\n        if (responseObj instanceof CharSequence) {\n            jsonStr = responseObj.toString()\n        } else if (responseObj != null) {\n            JsonBuilder jb = new JsonBuilder()\n            if (responseObj instanceof Map) {\n                jb.call((Map) responseObj)\n            } else if (responseObj instanceof List) {\n                jb.call((List) responseObj)\n            } else {\n                jb.call((Object) responseObj)\n            }\n            jsonStr = jb.toPrettyString()\n        } else {\n            jsonStr = \"\"\n        }\n        responseWriter.append(jsonStr)\n        logger.info(\"WebFacadeStub sendJsonResponse ${jsonStr.length()} chars\")\n        */\n    }\n    @Override\n    void sendJsonError(int statusCode, String message, Throwable origThrowable) {\n        WebFacadeImpl.sendJsonErrorInternal(statusCode, message, origThrowable, response)\n    }\n\n    @Override void sendTextResponse(String text) { sendTextResponse(text, \"text/plain\", null) }\n    @Override void sendTextResponse(String text, String contentType, String filename) {\n        WebFacadeImpl.sendTextResponseInternal(text, contentType, filename, ecfi.getEci(), httpServletRequest, httpServletResponse, requestAttributes)\n        // responseWriter.append(text)\n        // logger.info(\"WebFacadeStub sendTextResponse (${text.length()} chars, content type ${contentType}, filename: ${filename})\")\n    }\n\n    @Override void sendResourceResponse(String location) { sendResourceResponse(location, false) }\n    @Override void sendResourceResponse(String location, boolean inline) {\n        WebFacadeImpl.sendResourceResponseInternal(location, inline, ecfi.getEci(), httpServletResponse)\n        /*\n        ResourceReference rr = ecfi.getResource().getLocationReference(location)\n        if (rr == null) throw new IllegalArgumentException(\"Resource not found at: ${location}\")\n        String rrText = rr.getText()\n        responseWriter.append(rrText)\n        logger.info(\"WebFacadeStub sendResourceResponse ${rrText.length()} chars, location: ${location}\")\n        */\n    }\n    @Override void sendError(int errorCode, String message, Throwable origThrowable) { response.sendError(errorCode, message) }\n\n    @Override void handleJsonRpcServiceCall() { throw new IllegalArgumentException(\"WebFacadeStub handleJsonRpcServiceCall not supported\") }\n    @Override void handleEntityRestCall(List<String> extraPathNameList, boolean masterNameInPath) {\n        throw new IllegalArgumentException(\"WebFacadeStub handleEntityRestCall not supported\") }\n\n    @Override\n    void handleServiceRestCall(List<String> extraPathNameList) {\n        long startTime = System.currentTimeMillis()\n        ExecutionContextImpl eci = ecfi.getEci()\n\n        eci.contextStack.push(getParameters())\n        RestApi.RestResult restResult = eci.serviceFacade.restApi.run(extraPathNameList, eci)\n        eci.contextStack.pop()\n\n        response.addIntHeader('X-Run-Time-ms', (System.currentTimeMillis() - startTime) as int)\n        restResult.setHeaders(response)\n\n        sendJsonResponse(restResult.responseObj)\n    }\n\n    @Override void handleSystemMessage(List<String> extraPathNameList) {\n        throw new IllegalArgumentException(\"WebFacadeStub handleSystemMessage not supported\") }\n\n    static class HttpServletRequestStub implements HttpServletRequest {\n        WebFacadeStub wfs\n\n        HttpServletRequestStub(WebFacadeStub wfs) {\n            this.wfs = wfs\n        }\n\n        String getRequestId() { return null }\n        String getProtocolRequestId() { return null }\n        ServletConnection getServletConnection() { return null }\n        String getAuthType() { return null }\n        Cookie[] getCookies() { return new Cookie[0] }\n        long getDateHeader(String s) { return System.currentTimeMillis() }\n        String getHeader(String s) { return null }\n        Enumeration getHeaders(String s) { return null }\n        Enumeration getHeaderNames() { return null }\n        int getIntHeader(String s) { return 0 }\n\n        @Override String getMethod() { return wfs.requestMethod }\n\n        @Override\n        String getPathInfo() {\n            // TODO\n            return null\n        }\n\n        @Override\n        String getPathTranslated() {\n            // TODO\n            return null\n        }\n\n        @Override\n        String getContextPath() {\n            // TODO\n            return null\n        }\n\n        String getQueryString() { return null }\n        String getRemoteUser() { return null }\n        boolean isUserInRole(String s) { return false }\n        Principal getUserPrincipal() { return null }\n        String getRequestedSessionId() { return null }\n\n        @Override\n        String getRequestURI() {\n            // TODO\n            return null\n        }\n\n        @Override\n        StringBuffer getRequestURL() {\n            // TODO\n            return null\n        }\n\n        @Override String getServletPath() { return \"\" }\n        @Override HttpSession getSession(boolean b) { return wfs.httpSession }\n        @Override HttpSession getSession() { return wfs.httpSession }\n\n        @Override boolean isRequestedSessionIdValid() { return true }\n        @Override boolean isRequestedSessionIdFromCookie() { return false }\n        @Override boolean isRequestedSessionIdFromURL() { return false }\n\n        @Override Object getAttribute(String s) { return wfs.requestParameters.get(s) }\n        @Override Enumeration getAttributeNames() { return wfs.requestParameters.keySet() as Enumeration }\n\n        @Override String getCharacterEncoding() { return \"UTF-8\" }\n        @Override void setCharacterEncoding(String s) throws UnsupportedEncodingException { }\n        @Override int getContentLength() { return 0 }\n        @Override String getContentType() { return null }\n        @Override ServletInputStream getInputStream() throws IOException { return null }\n\n        @Override String getParameter(String s) { return wfs.requestParameters.get(s) as String }\n        @Override Enumeration getParameterNames() {\n            return new Enumeration() {\n                Iterator i = wfs.requestParameters.keySet().iterator()\n                boolean hasMoreElements() { return i.hasNext() }\n                Object nextElement() { return i.next() }\n            }\n        }\n        @Override String[] getParameterValues(String s) {\n            Object valObj = wfs.requestParameters.get(s)\n            if (valObj != null) {\n                String[] retVal = new String[1]\n                retVal[0] = valObj as String\n                return retVal\n            } else {\n                return null\n            }\n        }\n        @Override Map getParameterMap() { return wfs.requestParameters }\n\n        @Override String getProtocol() { return \"HTTP/1.1\" }\n        @Override String getScheme() { return \"https\" }\n        @Override String getServerName() { return \"localhost\" }\n        @Override int getServerPort() { return 443 }\n\n        @Override BufferedReader getReader() throws IOException { return null }\n        @Override String getRemoteAddr() { return \"TestRemoteAddr\" }\n        @Override String getRemoteHost() { return \"TestRemoteHost\" }\n\n        @Override void setAttribute(String s, Object o) { wfs.requestParameters.put(s, o) }\n        @Override void removeAttribute(String s) { wfs.requestParameters.remove(s) }\n\n        @Override Locale getLocale() { return Locale.ENGLISH }\n        @Override Enumeration getLocales() { return null }\n\n        @Override boolean isSecure() { return true }\n\n        @Override RequestDispatcher getRequestDispatcher(String s) { return null }\n\n        @Override int getRemotePort() { return 0 }\n        @Override String getLocalName() { return \"TestLocalName\" }\n        @Override String getLocalAddr() { return \"TestLocalAddr\" }\n        @Override int getLocalPort() { return 443 }\n\n        // ========== New methods for Servlet 3.1 ==========\n        @Override String changeSessionId() { throw new UnsupportedOperationException() }\n        @Override boolean authenticate(HttpServletResponse response) throws IOException, ServletException { throw new UnsupportedOperationException() }\n        @Override void login(String username, String password) throws ServletException { throw new UnsupportedOperationException() }\n        @Override void logout() throws ServletException { throw new UnsupportedOperationException() }\n        @Override Collection<Part> getParts() throws IOException, ServletException { throw new UnsupportedOperationException() }\n        @Override Part getPart(String name) throws IOException, ServletException { throw new UnsupportedOperationException() }\n        @Override def <T extends HttpUpgradeHandler> T upgrade(Class<T> handlerClass) throws IOException, ServletException { return null }\n        @Override long getContentLengthLong() { return 0 }\n        @Override ServletContext getServletContext() { return wfs.servletContext }\n        @Override AsyncContext startAsync() throws IllegalStateException { throw new UnsupportedOperationException(\"startAsync not supported\") }\n        @Override AsyncContext startAsync(ServletRequest servletRequest, ServletResponse servletResponse) throws IllegalStateException { return null }\n        @Override boolean isAsyncStarted() { return false }\n        @Override boolean isAsyncSupported() { return false }\n        @Override AsyncContext getAsyncContext() { throw new UnsupportedOperationException(\"getAsyncContext not supported\") }\n        @Override DispatcherType getDispatcherType() { throw new UnsupportedOperationException(\"getDispatcherType not supported\") }\n    }\n\n    static class HttpSessionStub implements HttpSession {\n        WebFacadeStub wfs\n        HttpSessionStub(WebFacadeStub wfs) { this.wfs = wfs }\n\n        long getCreationTime() { return System.currentTimeMillis() }\n        String getId() { return \"TestSessionId\" }\n        long getLastAccessedTime() { return System.currentTimeMillis() }\n        ServletContext getServletContext() { return wfs.servletContext }\n        void setMaxInactiveInterval(int i) { }\n        int getMaxInactiveInterval() { return 0 }\n\n        @Override Object getAttribute(String s) { return wfs.sessionAttributes.get(s) }\n        @Override Enumeration getAttributeNames() {\n            return new Enumeration() {\n                Iterator i = wfs.sessionAttributes.keySet().iterator()\n                boolean hasMoreElements() { return i.hasNext() }\n                Object nextElement() { return i.next() }\n            }\n        }\n        @Override void setAttribute(String s, Object o) { wfs.sessionAttributes.put(s, o) }\n        @Override void removeAttribute(String s) { wfs.sessionAttributes.remove(s) }\n\n        void invalidate() { }\n        boolean isNew() { return false }\n    }\n\n    static class ServletContextStub implements ServletContext {\n        WebFacadeStub wfs\n        ServletContextStub(WebFacadeStub wfs) { this.wfs = wfs }\n\n        ServletContext getContext(String s) { return this }\n        String getContextPath() { return \"\" }\n        int getMajorVersion() { return 3 }\n        int getMinorVersion() { return 0 }\n        String getMimeType(String s) { return null }\n        Set getResourcePaths(String s) { return new HashSet() }\n        URL getResource(String s) throws MalformedURLException { return null }\n        InputStream getResourceAsStream(String s) { return null }\n        RequestDispatcher getRequestDispatcher(String s) { return null }\n        RequestDispatcher getNamedDispatcher(String s) { return null }\n        Servlet getServlet(String s) throws ServletException { return null }\n        Enumeration getServlets() { return null }\n        Enumeration getServletNames() { return null }\n        void log(String s) { }\n        void log(Exception e, String s) { }\n        void log(String s, Throwable throwable) { }\n        String getRealPath(String s) { return null }\n        String getServerInfo() { return \"Web Facade Stub/1.0\" }\n\n        @Override String getInitParameter(String s) { return s == \"moqui-name\" ? \"webroot\" : null }\n        @Override Enumeration getInitParameterNames() {\n            return new Enumeration() {\n                Iterator i = ['moqui-name'].iterator()\n                boolean hasMoreElements() { return i.hasNext() }\n                Object nextElement() { return i.next() }\n            }\n        }\n        @Override Object getAttribute(String s) { return wfs.sessionAttributes.get(s) }\n        @Override Enumeration getAttributeNames() {\n            return new Enumeration() {\n                Iterator i = wfs.sessionAttributes.keySet().iterator()\n                boolean hasMoreElements() { return i.hasNext() }\n                Object nextElement() { return i.next() }\n            }\n        }\n        @Override void setAttribute(String s, Object o) { wfs.sessionAttributes.put(s, o) }\n        @Override void removeAttribute(String s) { wfs.sessionAttributes.remove(s) }\n        @Override String getServletContextName() { return \"Moqui Root Webapp\" }\n\n        // ========== New methods for Servlet 3.1 and 4.0 ==========\n        @Override int getEffectiveMajorVersion() { return 4 }\n        @Override int getEffectiveMinorVersion() { return 0 }\n        @Override boolean setInitParameter(String name, String value) { return false }\n        @Override ServletRegistration.Dynamic addServlet(String servletName, String className) { throw new UnsupportedOperationException() }\n        @Override ServletRegistration.Dynamic addServlet(String servletName, Servlet servlet) { throw new UnsupportedOperationException() }\n        @Override ServletRegistration.Dynamic addServlet(String servletName, Class<? extends Servlet> servletClass) { throw new UnsupportedOperationException() }\n        @Override ServletRegistration.Dynamic addJspFile(String servletName, String jspFile) { throw new UnsupportedOperationException() }\n        @Override def <T extends Servlet> T createServlet(Class<T> clazz) throws ServletException { throw new UnsupportedOperationException() }\n        @Override ServletRegistration getServletRegistration(String servletName) { throw new UnsupportedOperationException() }\n        @Override Map<String, ? extends ServletRegistration> getServletRegistrations() { throw new UnsupportedOperationException() }\n        @Override FilterRegistration.Dynamic addFilter(String filterName, String className) { throw new UnsupportedOperationException() }\n        @Override FilterRegistration.Dynamic addFilter(String filterName, Filter filter) { throw new UnsupportedOperationException() }\n        @Override FilterRegistration.Dynamic addFilter(String filterName, Class<? extends Filter> filterClass) { throw new UnsupportedOperationException() }\n        @Override def <T extends Filter> T createFilter(Class<T> clazz) throws ServletException { throw new UnsupportedOperationException() }\n        @Override FilterRegistration getFilterRegistration(String filterName) { throw new UnsupportedOperationException() }\n        @Override Map<String, ? extends FilterRegistration> getFilterRegistrations() { throw new UnsupportedOperationException() }\n        @Override SessionCookieConfig getSessionCookieConfig() { throw new UnsupportedOperationException() }\n        @Override void setSessionTrackingModes(Set<SessionTrackingMode> sessionTrackingModes) { throw new UnsupportedOperationException() }\n        @Override Set<SessionTrackingMode> getDefaultSessionTrackingModes() { throw new UnsupportedOperationException() }\n        @Override Set<SessionTrackingMode> getEffectiveSessionTrackingModes() { throw new UnsupportedOperationException() }\n        @Override void addListener(String className) { throw new UnsupportedOperationException() }\n        @Override def <T extends EventListener> void addListener(T t) { throw new UnsupportedOperationException() }\n        @Override void addListener(Class<? extends EventListener> listenerClass) { throw new UnsupportedOperationException() }\n        @Override def <T extends EventListener> T createListener(Class<T> clazz) throws ServletException { throw new UnsupportedOperationException() }\n        @Override JspConfigDescriptor getJspConfigDescriptor() { throw new UnsupportedOperationException() }\n        @Override ClassLoader getClassLoader() { throw new UnsupportedOperationException() }\n        @Override void declareRoles(String... roleNames) { throw new UnsupportedOperationException() }\n        @Override String getVirtualServerName() { throw new UnsupportedOperationException() }\n        @Override int getSessionTimeout() { return 30 }\n        @Override void setSessionTimeout(int sessionTimeout) { }\n        @Override String getRequestCharacterEncoding() { return \"UTF-8\" }\n        @Override void setRequestCharacterEncoding(String encoding) { throw new UnsupportedOperationException() }\n        @Override String getResponseCharacterEncoding() { return \"UTF-8\" }\n        @Override void setResponseCharacterEncoding(String encoding) { throw new UnsupportedOperationException() }\n    }\n\n    static class HttpServletResponseStub implements HttpServletResponse {\n        WebFacadeStub wfs\n\n        String characterEncoding = null\n        int contentLength = 0\n        String contentType = null\n        Locale locale = Locale.default\n        int status = SC_OK\n        Map<String, Object> headers = [:]\n\n        HttpServletResponseStub(WebFacadeStub wfs) { this.wfs = wfs }\n\n        @Override void addCookie(Cookie cookie) { }\n\n        @Override boolean containsHeader(String s) { return headers.containsKey(s) }\n\n        @Override String encodeURL(String s) { return null }\n        @Override String encodeRedirectURL(String s) { return null }\n\n        @Override void sendError(int i, String s) throws IOException {\n            status = i\n            if (s != null) wfs.responseWriter.append(s)\n        }\n        @Override void sendError(int i) throws IOException { status = i }\n        @Override void sendRedirect(String s, int i, boolean b) { logger.info(\"HttpServletResponseStub sendRedirect to: ${s}\") }\n\n        @Override void setDateHeader(String s, long l) { headers.put(s, l) }\n        @Override void addDateHeader(String s, long l) { headers.put(s, l) }\n        @Override void setHeader(String s, String s1) { headers.put(s, s1) }\n        @Override void addHeader(String s, String s1) { headers.put(s, s1) }\n        @Override void setIntHeader(String s, int i) { headers.put(s, i) }\n        @Override void addIntHeader(String s, int i) { headers.put(s, i) }\n\n        @Override String getCharacterEncoding() { return characterEncoding }\n        @Override String getContentType() { return contentType }\n\n        @Override ServletOutputStream getOutputStream() throws IOException {\n            throw new UnsupportedOperationException(\"Using WebFacadeStub getOutputStream is not supported\") }\n        @Override PrintWriter getWriter() throws IOException { return wfs.responsePrintWriter }\n\n        @Override void setBufferSize(int i) { }\n        @Override int getBufferSize() { return wfs.responseWriter.getBuffer().length() }\n\n        @Override void flushBuffer() throws IOException { wfs.responseWriter.flush() }\n        @Override void resetBuffer() { wfs.responseWriter = new StringWriter() }\n        @Override boolean isCommitted() { return false }\n        @Override void reset() { resetBuffer(); status = SC_OK; headers.clear() }\n        @Override void setLocale(Locale locale) { this.locale = locale }\n        @Override Locale getLocale() { return locale }\n\n        // ========== New methods for Servlet 3.1 ==========\n        @Override String getHeader(String name) { return headers.get(name) as String }\n        @Override Collection<String> getHeaders(String name) { return [headers.get(name) as String] }\n        @Override Collection<String> getHeaderNames() { return headers.keySet() }\n        @Override void setContentLengthLong(long len) { }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/EmailEcaRule.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.service\n\nimport groovy.transform.CompileStatic\nimport org.apache.commons.io.IOUtils\nimport org.moqui.impl.actions.XmlAction\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.util.MNode\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport jakarta.mail.*\nimport jakarta.mail.internet.MimeMessage\nimport java.sql.Timestamp\n\n@CompileStatic\nclass EmailEcaRule {\n    protected final static Logger logger = LoggerFactory.getLogger(EmailEcaRule.class)\n\n    protected MNode emecaNode\n    protected String location\n\n    protected XmlAction condition = null\n    protected XmlAction actions = null\n\n    EmailEcaRule(ExecutionContextFactoryImpl ecfi, MNode emecaNode, String location) {\n        this.emecaNode = emecaNode\n        this.location = location\n\n        // prep condition\n        if (emecaNode.hasChild(\"condition\") && emecaNode.first(\"condition\").children) {\n            // the script is effectively the first child of the condition element\n            condition = new XmlAction(ecfi, emecaNode.first(\"condition\").children.get(0), location + \".condition\")\n        }\n        // prep actions\n        if (emecaNode.hasChild(\"actions\")) {\n            actions = new XmlAction(ecfi, emecaNode.first(\"actions\"), location + \".actions\")\n        }\n    }\n\n    // Node getEmecaNode() { return emecaNode }\n\n    void runIfMatches(MimeMessage message, String emailServerId, ExecutionContextImpl ec) {\n\n        try {\n            ec.context.push()\n\n            ec.context.put(\"emailServerId\", emailServerId)\n            ec.context.put(\"message\", message)\n\n            Map<String, Object> fields = [:]\n            ec.context.put(\"fields\", fields)\n\n            List<String> toList = []\n            for (Address addr in message.getRecipients(MimeMessage.RecipientType.TO)) toList.add(addr.toString())\n            fields.put(\"toList\", toList)\n\n            List<String> ccList = []\n            for (Address addr in message.getRecipients(MimeMessage.RecipientType.CC)) ccList.add(addr.toString())\n            fields.put(\"ccList\", ccList)\n\n            List<String> bccList = []\n            for (Address addr in message.getRecipients(MimeMessage.RecipientType.BCC)) bccList.add(addr.toString())\n            fields.put(\"bccList\", bccList)\n\n            fields.put(\"from\", message.getFrom() ? message.getFrom()[0] : null)\n            fields.put(\"subject\", message.getSubject())\n            fields.put(\"sentDate\", message.getSentDate() ? new Timestamp(message.getSentDate().getTime()) : null)\n            fields.put(\"receivedDate\", message.getReceivedDate() ? new Timestamp(message.getReceivedDate().getTime()) : null)\n\n            ec.context.put(\"bodyPartList\", makeBodyPartList(message))\n\n            Map<String, Object> headers = [:]\n            ec.context.put(\"headers\", headers)\n            Enumeration<Header> allHeaders = message.getAllHeaders()\n            while (allHeaders.hasMoreElements()) {\n                Header header = allHeaders.nextElement()\n                String headerName = header.name.toLowerCase()\n                if (headers.get(headerName)) {\n                    Object hi = headers.get(headerName)\n                    if (hi instanceof List) { hi.add(header.value) }\n                    else { headers.put(headerName, [hi, header.value]) }\n                } else {\n                    headers.put(headerName, header.value)\n                }\n            }\n\n            Map<String, Boolean> flags = [:]\n            ec.context.put(\"flags\", flags)\n            flags.answered = message.isSet(Flags.Flag.ANSWERED)\n            flags.deleted = message.isSet(Flags.Flag.DELETED)\n            flags.draft = message.isSet(Flags.Flag.DRAFT)\n            flags.flagged = message.isSet(Flags.Flag.FLAGGED)\n            flags.recent = message.isSet(Flags.Flag.RECENT)\n            flags.seen = message.isSet(Flags.Flag.SEEN)\n\n            // run the condition and if passes run the actions\n            boolean conditionPassed = true\n            if (condition) conditionPassed = condition.checkCondition(ec)\n            // logger.info(\"======== EMECA ${emecaNode.attribute(\"rule-name\")} conditionPassed? ${conditionPassed} fields:\\n${fields}\\nflags: ${flags}\\nheaders: ${headers}\")\n            if (conditionPassed) {\n                if (actions) actions.run(ec)\n            }\n        } finally {\n            ec.context.pop()\n        }\n    }\n\n    static List<Map> makeBodyPartList(Part part) {\n        List<Map> bodyPartList = []\n        Object content = part.getContent()\n        Map bpMap = [contentType:part.getContentType(), filename:part.getFileName(), disposition:part.getDisposition()?.toLowerCase()]\n        if (content instanceof CharSequence) {\n            bpMap.contentText = content.toString()\n            bodyPartList.add(bpMap)\n        } else if (content instanceof Multipart) {\n            Multipart mpContent = (Multipart) content\n            int count = mpContent.getCount()\n            for (int i = 0; i < count; i++) {\n                BodyPart bp = mpContent.getBodyPart(i)\n                bodyPartList.addAll(makeBodyPartList(bp))\n            }\n        } else if (content instanceof InputStream) {\n            InputStream is = (InputStream) content\n            bpMap.contentBytes = IOUtils.toByteArray(is)\n            bodyPartList.add(bpMap)\n        }\n        return bodyPartList\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/ParameterInfo.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.service;\n\nimport org.jsoup.Jsoup;\nimport org.jsoup.nodes.Document;\nimport org.jsoup.safety.Safelist;\nimport org.moqui.impl.context.ContextJavaUtil;\nimport org.moqui.util.MClassLoader;\nimport org.moqui.impl.context.ExecutionContextImpl;\nimport org.moqui.util.MNode;\nimport org.moqui.util.ObjectUtilities;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.math.BigDecimal;\nimport java.text.MessageFormat;\nimport java.util.*;\n\n/** This is a dumb data holder class for framework internal use only; in Java for efficiency as it is used a LOT */\npublic class ParameterInfo {\n    protected final static Logger logger = LoggerFactory.getLogger(ParameterInfo.class);\n\n    public enum ParameterAllowHtml { ANY, SAFE, NONE }\n    public enum ParameterType { STRING, INTEGER, LONG, FLOAT, DOUBLE, BIG_DECIMAL, BIG_INTEGER, TIME, DATE, TIMESTAMP, LIST, SET, MAP }\n    public static Map<String, ParameterType> typeEnumByString = new HashMap<>();\n    static {\n        typeEnumByString.put(\"String\", ParameterType.STRING); typeEnumByString.put(\"java.lang.String\", ParameterType.STRING);\n        typeEnumByString.put(\"Integer\", ParameterType.INTEGER); typeEnumByString.put(\"java.lang.Integer\", ParameterType.INTEGER);\n        typeEnumByString.put(\"Long\", ParameterType.LONG); typeEnumByString.put(\"java.lang.Long\", ParameterType.LONG);\n        typeEnumByString.put(\"Float\", ParameterType.FLOAT); typeEnumByString.put(\"java.lang.Float\", ParameterType.FLOAT);\n        typeEnumByString.put(\"Double\", ParameterType.DOUBLE); typeEnumByString.put(\"java.lang.Double\", ParameterType.DOUBLE);\n        typeEnumByString.put(\"BigDecimal\", ParameterType.BIG_DECIMAL); typeEnumByString.put(\"java.math.BigDecimal\", ParameterType.BIG_DECIMAL);\n        typeEnumByString.put(\"BigInteger\", ParameterType.BIG_INTEGER); typeEnumByString.put(\"java.math.BigInteger\", ParameterType.BIG_INTEGER);\n\n        typeEnumByString.put(\"Time\", ParameterType.TIME); typeEnumByString.put(\"java.sql.Time\", ParameterType.TIME);\n        typeEnumByString.put(\"Date\", ParameterType.DATE); typeEnumByString.put(\"java.sql.Date\", ParameterType.DATE);\n        typeEnumByString.put(\"Timestamp\", ParameterType.TIMESTAMP); typeEnumByString.put(\"java.sql.Timestamp\", ParameterType.TIMESTAMP);\n        typeEnumByString.put(\"Collection\", ParameterType.LIST); typeEnumByString.put(\"java.util.Collection\", ParameterType.LIST);\n        typeEnumByString.put(\"List\", ParameterType.LIST); typeEnumByString.put(\"java.util.List\", ParameterType.LIST);\n        typeEnumByString.put(\"Set\", ParameterType.SET); typeEnumByString.put(\"java.util.Set\", ParameterType.SET);\n        typeEnumByString.put(\"Map\", ParameterType.MAP); typeEnumByString.put(\"java.util.Map\", ParameterType.MAP);\n    }\n\n    public final ServiceDefinition sd;\n    public final String serviceName;\n    public final MNode parameterNode;\n    public final String name, type, format;\n    public final ParameterType parmType;\n    public final Class<?> parmClass;\n\n    public final String entityName, fieldName;\n    public final String defaultStr, defaultValue;\n    public final boolean defaultValueNeedsExpand;\n    public final boolean hasDefault;\n    public final boolean thisOrChildHasDefault;\n    public final boolean required;\n    public final boolean disabled;\n    public final ParameterAllowHtml allowHtml;\n    public final boolean allowSafe;\n\n    public final ParameterInfo[] childParameterInfoArray;\n\n    public final ArrayList<MNode> validationNodeList;\n\n    public ParameterInfo(ServiceDefinition sd, MNode parameterNode) {\n        this.sd = sd;\n        this.parameterNode = parameterNode;\n        serviceName = sd.serviceName;\n\n        name = parameterNode.attribute(\"name\");\n        String typeAttr = parameterNode.attribute(\"type\");\n        type = typeAttr == null || typeAttr.isEmpty() ? \"String\" : typeAttr;\n        parmType = typeEnumByString.get(type);\n        parmClass = MClassLoader.getCommonClass(type);\n\n        format = parameterNode.attribute(\"format\");\n        entityName = parameterNode.attribute(\"entity-name\");\n        fieldName = parameterNode.attribute(\"field-name\");\n\n        String defaultTmp = parameterNode.attribute(\"default\");\n        if (defaultTmp != null && defaultTmp.isEmpty()) defaultTmp = null;\n        defaultStr = defaultTmp;\n        String defaultValTmp = parameterNode.attribute(\"default-value\");\n        if (defaultValTmp != null && defaultValTmp.isEmpty()) defaultValTmp = null;\n        defaultValue = defaultValTmp;\n        hasDefault = defaultStr != null || defaultValue != null;\n        defaultValueNeedsExpand = defaultValue != null && defaultValue.contains(\"${\");\n\n        required = \"true\".equals(parameterNode.attribute(\"required\"));\n        disabled = \"disabled\".equals(parameterNode.attribute(\"required\"));\n\n        String allowHtmlStr = parameterNode.attribute(\"allow-html\");\n        if (\"any\".equals(allowHtmlStr)) allowHtml = ParameterAllowHtml.ANY;\n        else if (\"safe\".equals(allowHtmlStr)) allowHtml = ParameterAllowHtml.SAFE;\n        else allowHtml = ParameterAllowHtml.NONE;\n        allowSafe = ParameterAllowHtml.SAFE == allowHtml;\n\n        Map<String, ParameterInfo> childParameterInfoMap = new HashMap<>();\n        ArrayList<String> parmNameList = new ArrayList<>();\n        for (MNode childParmNode: parameterNode.children(\"parameter\")) {\n            String name = childParmNode.attribute(\"name\");\n            childParameterInfoMap.put(name, new ParameterInfo(sd, childParmNode));\n            parmNameList.add(name);\n        }\n        int parmNameListSize = parmNameList.size();\n        boolean childHasDefault = false;\n        if (parmNameListSize > 0) {\n            childParameterInfoArray = new ParameterInfo[parmNameListSize];\n            for (int i = 0; i < parmNameListSize; i++) {\n                String parmName = parmNameList.get(i);\n                ParameterInfo pi = childParameterInfoMap.get(parmName);\n                childParameterInfoArray[i] = pi;\n                if (pi.thisOrChildHasDefault) childHasDefault = true;\n            }\n        } else {\n            childParameterInfoArray = null;\n        }\n        thisOrChildHasDefault = hasDefault || childHasDefault;\n\n        ArrayList<MNode> tempValidationNodeList = new ArrayList<>();\n        for (MNode child: parameterNode.getChildren()) {\n            if (\"description\".equals(child.getName()) || \"parameter\".equals(child.getName())) continue;\n            tempValidationNodeList.add(child);\n        }\n        if (tempValidationNodeList.size() > 0) {\n            validationNodeList = tempValidationNodeList;\n        } else {\n            validationNodeList = null;\n        }\n    }\n\n    /** Currently used only in ServiceDefinition.checkParameterMap() */\n    Object convertType(String namePrefix, Object parameterValue, boolean isString, ExecutionContextImpl eci) {\n        // no need to check for null, only called with parameterValue not empty\n        // if (parameterValue == null) return null;\n        // no need to check for type match, only called when types don't match\n        // if (ObjectUtilities.isInstanceOf(parameterValue, type)) {\n\n        // do type conversion if possible\n        Object converted = null;\n        boolean isEmptyString = isString && ((CharSequence) parameterValue).length() == 0;\n        if (parmType != null && isString && !isEmptyString) {\n            String valueStr = parameterValue.toString().trim();\n            // try some String to XYZ specific conversions for parsing with format, locale, etc\n            switch (parmType) {\n                case INTEGER:\n                case LONG:\n                case FLOAT:\n                case DOUBLE:\n                case BIG_DECIMAL:\n                case BIG_INTEGER:\n                    BigDecimal bdVal = eci.l10nFacade.parseNumber(valueStr, format);\n                    if (bdVal == null) {\n                        eci.messageFacade.addValidationError(null, namePrefix + name, serviceName,\n                                MessageFormat.format(eci.getL10n().localize(\"Value entered ({0}) could not be converted to a {1}{2,choice,0#|1# using format [}{3}{2,choice,0#|1#]}\"),valueStr,type,(format != null ? 1 : 0),(format == null ? \"\" : format)), null);\n                    } else {\n                        switch (parmType) {\n                            case INTEGER: converted = bdVal.intValue(); break;\n                            case LONG: converted = bdVal.longValue(); break;\n                            case FLOAT: converted = bdVal.floatValue(); break;\n                            case DOUBLE: converted = bdVal.doubleValue(); break;\n                            case BIG_INTEGER: converted = bdVal.toBigInteger(); break;\n                            default: converted = bdVal;\n                        }\n                    }\n                    break;\n                case TIME:\n                    converted = eci.l10nFacade.parseTime(valueStr, format);\n                    if (converted == null) eci.messageFacade.addValidationError(null, namePrefix + name,\n                            serviceName, MessageFormat.format(eci.getL10n().localize(\"Value entered ({0}) could not be converted to a {1}{2,choice,0#|1# using format [}{3}{2,choice,0#|1#]}\"),valueStr,type,(format != null ? 1 : 0),(format == null ? \"\" : format)), null);\n                    break;\n                case DATE:\n                    converted = eci.l10nFacade.parseDate(valueStr, format);\n                    if (converted == null) eci.messageFacade.addValidationError(null, namePrefix + name,\n                            serviceName, MessageFormat.format(eci.getL10n().localize(\"Value entered ({0}) could not be converted to a {1}{2,choice,0#|1# using format [}{3}{2,choice,0#|1#]}\"),valueStr,type,(format != null ? 1 : 0),(format == null ? \"\" : format)), null);\n                    break;\n                case TIMESTAMP:\n                    converted = eci.l10nFacade.parseTimestamp(valueStr, format);\n                    if (converted == null) eci.messageFacade.addValidationError(null, namePrefix + name,\n                            serviceName, MessageFormat.format(eci.getL10n().localize(\"Value entered ({0}) could not be converted to a {1}{2,choice,0#|1# using format [}{3}{2,choice,0#|1#]}\"),valueStr,type,(format != null ? 1 : 0),(format == null ? \"\" : format)), null);\n                    break;\n                case LIST:\n                    // strip off square braces\n                    if (valueStr.charAt(0) == '[' && valueStr.charAt(valueStr.length()-1) == ']')\n                        valueStr = valueStr.substring(1, valueStr.length()-1);\n                    // split by comma or just create a list with the single string\n                    if (valueStr.contains(\",\")) {\n                        converted = Arrays.asList(valueStr.split(\",\"));\n                    } else {\n                        List<String> newList = new ArrayList<>();\n                        newList.add(valueStr);\n                        converted = newList;\n                    }\n                    break;\n                case SET:\n                    // strip off square braces\n                    if (valueStr.charAt(0) == '[' && valueStr.charAt(valueStr.length()-1) == ']')\n                        valueStr = valueStr.substring(1, valueStr.length()-1);\n                    // split by comma or just create a list with the single string\n                    if (valueStr.contains(\",\")) {\n                        converted = new HashSet<>(Arrays.asList(valueStr.split(\",\")));\n                    } else {\n                        Set<String> newSet = new LinkedHashSet<>();\n                        newSet.add(valueStr);\n                        converted = newSet;\n                    }\n                    break;\n                case MAP:\n                    if (valueStr.startsWith(\"{\")) {\n                        try {\n                            converted = ContextJavaUtil.jacksonMapper.readValue(valueStr, Map.class);\n                        } catch (Exception e) {\n                            eci.messageFacade.addValidationError(null, namePrefix + name, serviceName,\n                                    \"Could not convert JSON to Map\", e);\n                        }\n                    }\n                    break;\n            }\n        }\n\n        // fallback to a really simple type conversion\n        // TODO: how to detect conversion failed to add validation error?\n        if (converted == null && !isEmptyString) converted = ObjectUtilities.basicConvert(parameterValue, type);\n\n        return converted;\n    }\n\n    Object validateParameterHtml(String namePrefix, Object parameterValue, boolean isString, ExecutionContextImpl eci) {\n        // check for none/safe/any HTML\n        if (isString) {\n            return canonicalizeAndCheckHtml(sd, namePrefix, (String) parameterValue, eci);\n        } else {\n            Collection<?> lst = (Collection<?>) parameterValue;\n            ArrayList<Object> lstClone = new ArrayList<>(lst);\n            int lstSize = lstClone.size();\n            for (int i = 0; i < lstSize; i++) {\n                Object obj = lstClone.get(i);\n                if (obj instanceof CharSequence) {\n                    String htmlChecked = canonicalizeAndCheckHtml(sd, namePrefix, obj.toString(), eci);\n                    lstClone.set(i, htmlChecked != null ? htmlChecked : obj);\n                } else {\n                    lstClone.set(i, obj);\n                }\n            }\n            return lstClone;\n        }\n    }\n\n    public static Document.OutputSettings outputSettings = new Document.OutputSettings().charset(\"UTF-8\").prettyPrint(true).indentAmount(4);\n    private String canonicalizeAndCheckHtml(ServiceDefinition sd, String namePrefix, String parameterValue, ExecutionContextImpl eci) {\n        // NOTE DEJ20161114 Jsoup.clean() does not have a way to tell us if anything was filtered, so to avoid reformatting other\n        //     text this method now only calls Jsoup if a '<' is found\n        // int indexOfEscape = -1;\n        int indexOfLessThan = -1;\n        int valueLength = parameterValue.length();\n        for (int i = 0; i < valueLength; i++) {\n            char curChar = parameterValue.charAt(i);\n            /* if (curChar == '%' || curChar == '&') {\n                indexOfEscape = i;\n                if (indexOfLessThan >= 0) break;\n            } else */\n            if (curChar == '<') {\n                indexOfLessThan = i;\n                break;\n                // if (indexOfEscape >= 0) break;\n            }\n        }\n        // if (indexOfEscape < 0 && indexOfLessThan < 0) return null;\n\n        if (indexOfLessThan >= 0) {\n            if (allowSafe) {\n                return Jsoup.clean(parameterValue, \"\", Safelist.relaxed(), outputSettings);\n            } else {\n                // check for \"<\"; this will protect against HTML/JavaScript injection\n                eci.getMessage().addValidationError(null, namePrefix + name, sd.serviceName, eci.getL10n().localize(\"HTML not allowed including less-than (<), greater-than (>), etc symbols\"), null);\n            }\n        }\n\n        // nothing changed, return null\n        return null;\n    }\n    /*\n    Old OWASP HTML Sanitizer code (removed because heavy, depends on Guava):\n\n    in framework/build.gradle:\n    // OWASP Java HTML Sanitizer\n    compile 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20160924.1' // New BSD & Apache 2.0\n\n    SafeHtmlChangeListener changes = new SafeHtmlChangeListener(eci, sd);\n    String cleanHtml = EbayPolicyExample.POLICY_DEFINITION.sanitize(parameterValue, changes, namePrefix.concat(name));\n    List<String> cleanChanges = changes.getMessages();\n    // use message instead of error, accept cleaned up HTML\n    if (cleanChanges.size() > 0) {\n        for (String cleanChange: cleanChanges) eci.getMessage().addMessage(cleanChange);\n        logger.info(\"Service parameter safe HTML messages for \" + sd.serviceName + \".\" + name + \": \" + cleanChanges);\n        return cleanHtml;\n    } else {\n        // nothing changed, return null\n        return null;\n    }\n\n    private static class SafeHtmlChangeListener implements HtmlChangeListener<String> {\n        private ExecutionContextImpl eci;\n        private ServiceDefinition sd;\n        private List<String> messages = new LinkedList<>();\n        SafeHtmlChangeListener(ExecutionContextImpl eci, ServiceDefinition sd) { this.eci = eci; this.sd = sd; }\n        List<String> getMessages() { return messages; }\n        @SuppressWarnings(\"NullableProblems\")\n        @Override\n        public void discardedTag(@Nullable String context, String elementName) {\n            messages.add(MessageFormat.format(eci.getL10n().localize(\"Removed HTML element {0} from field {1} in service {2}\"),\n                    elementName, context, sd.serviceName));\n        }\n        @SuppressWarnings(\"NullableProblems\")\n        @Override\n        public void discardedAttributes(@Nullable String context, String tagName, String... attributeNames) {\n            for (String attrName: attributeNames)\n                messages.add(MessageFormat.format(eci.getL10n().localize(\"Removed attribute {0} from HTML element {1} from field {2} in service {3}\"),\n                        attrName, tagName, context, sd.serviceName));\n        }\n    }\n    */\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/RestApi.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.service\n\nimport groovy.transform.CompileStatic\nimport org.moqui.BaseException\nimport org.moqui.context.ArtifactExecutionInfo\nimport org.moqui.context.AuthenticationRequiredException\nimport org.moqui.context.ExecutionContext\nimport org.moqui.entity.EntityValue\nimport org.moqui.resource.ResourceReference\nimport org.moqui.entity.EntityFind\nimport org.moqui.impl.context.ArtifactExecutionInfoImpl\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.impl.context.UserFacadeImpl\nimport org.moqui.impl.entity.EntityDefinition\nimport org.moqui.impl.entity.FieldInfo\nimport org.moqui.impl.util.RestSchemaUtil\nimport org.moqui.jcache.MCache\nimport org.moqui.util.CollectionUtilities\nimport org.moqui.util.MNode\nimport org.moqui.util.SystemBinding\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport jakarta.servlet.http.HttpServletRequest\nimport jakarta.servlet.http.HttpServletResponse\n\nimport javax.cache.Cache\nimport java.math.RoundingMode\n\n@CompileStatic\nclass RestApi {\n    protected final static Logger logger = LoggerFactory.getLogger(RestApi.class)\n\n    @SuppressWarnings(\"GrFinalVariableAccess\") protected final ExecutionContextFactoryImpl ecfi\n    @SuppressWarnings(\"GrFinalVariableAccess\") final MCache<String, ResourceNode> rootResourceCache\n\n    RestApi(ExecutionContextFactoryImpl ecfi) {\n        this.ecfi = ecfi\n        rootResourceCache = ecfi.cacheFacade.getLocalCache(\"service.rest.api\")\n        loadRootResourceNode(null)\n    }\n\n    ResourceNode getRootResourceNode(String name) {\n        ResourceNode resourceNode = rootResourceCache.get(name)\n        if (resourceNode != null) return resourceNode\n\n        loadRootResourceNode(name)\n        resourceNode = rootResourceCache.get(name)\n        if (resourceNode != null) return resourceNode\n\n        throw new ResourceNotFoundException(\"Service REST API Root resource not found with name ${name}\")\n    }\n\n    synchronized void loadRootResourceNode(String name) {\n        if (name != null) {\n            ResourceNode resourceNode = rootResourceCache.get(name)\n            if (resourceNode != null) return\n        }\n\n        long startTime = System.currentTimeMillis()\n        // find *.rest.xml files in component/service directories, put in rootResourceMap\n        for (String location in this.ecfi.getComponentBaseLocations().values()) {\n            ResourceReference serviceDirRr = this.ecfi.resourceFacade.getLocationReference(location + \"/service\")\n            if (serviceDirRr.supportsAll()) {\n                // if for some weird reason this isn't a directory, skip it\n                if (!serviceDirRr.isDirectory()) continue\n                for (ResourceReference rr in serviceDirRr.directoryEntries) {\n                    if (!rr.fileName.endsWith(\".rest.xml\")) continue\n                    MNode rootNode = MNode.parse(rr)\n                    if (name == null || name.equals(rootNode.attribute(\"name\"))) {\n                        ResourceNode rn = new ResourceNode(rootNode, null, ecfi)\n                        rootResourceCache.put(rn.name, rn)\n                        logger.info(\"Loaded REST API from ${rr.getFileName()} (${rn.childPaths} paths, ${rn.childMethods} methods)\")\n                        // logger.info(rn.toString())\n                    }\n                }\n            } else {\n                logger.warn(\"Can't load REST APIs from component at [${serviceDirRr.location}] because it doesn't support exists/directory/etc\")\n            }\n        }\n        logger.info(\"Loaded REST API files, ${rootResourceCache.size()} roots, in ${System.currentTimeMillis() - startTime}ms\")\n    }\n\n    /** Used in tools dashboard screen */\n    List<ResourceNode> getFreshRootResources() {\n        loadRootResourceNode(null)\n        List<ResourceNode> rootList = new ArrayList<>()\n        for (Cache.Entry<String, ResourceNode> entry in rootResourceCache.getEntryList()) rootList.add(entry.getValue())\n        return rootList\n    }\n\n    RestResult run(List<String> pathList, ExecutionContextImpl ec) {\n        if (pathList == null || pathList.size() == 0) throw new ResourceNotFoundException(\"Cannot run REST service with no path\")\n        String firstPath = pathList[0]\n        ResourceNode resourceNode = getRootResourceNode(firstPath)\n        return resourceNode.visit(pathList, 0, ec)\n    }\n\n    Map<String, Object> getRamlMap(String rootResourceName, String linkPrefix) {\n        ResourceNode resourceNode = getRootResourceNode(rootResourceName)\n\n        Map<String, Object> typesMap = new TreeMap<String, Object>()\n\n        Map<String, Object> rootMap = [title:(resourceNode.displayName ?: rootResourceName + ' REST API'),\n                                       version:(resourceNode.version ?: '1.0'), baseUri:linkPrefix,\n                                       mediaType:'application/json', types:typesMap] as Map<String, Object>\n        Map<String, Object> headers = ['X-Total-Count':[type:'integer', description:\"Count of all results (not just current page)\"],\n                                       'X-Page-Index':[type:'integer', description:\"Index of current page\"],\n                                       'X-Page-Size':[type:'integer', description:\"Number of results per page\"],\n                                       'X-Page-Max-Index':[type:'integer', description:\"Highest page index given page size and count of results\"],\n                                       'X-Page-Range-Low':[type:'integer', description:\"Index of first result in page\"],\n                                       'X-Page-Range-High':[type:'integer', description:\"Index of last result in page\"]] as Map<String, Object>\n        rootMap.put('traits', [[paged:[queryParameters:RestSchemaUtil.ramlPaginationParameters, headers:headers]],\n            [service:[responses:[401:[description:\"Authentication required\"], 403:[description:\"Access Forbidden (no authz)\"],\n                                 429:[description:\"Too Many Requests (tarpit)\"], 500:[description:\"General Error\"]]]],\n            [entity:[responses:[401:[description:\"Authentication required\"], 403:[description:\"Access Forbidden (no authz)\"],\n                                404:[description:\"Value Not Found\"], 429:[description:\"Too Many Requests (tarpit)\"],\n                                500:[description:\"General Error\"]]]]\n        ])\n\n        Map<String, Object> childrenMap = resourceNode.getRamlChildrenMap(typesMap)\n        rootMap.put('/' + rootResourceName, childrenMap)\n\n        return rootMap\n    }\n\n    Map<String, Object> getSwaggerMap(List<String> rootPathList, List<String> schemes, String hostName, String basePath) {\n        // TODO: support generate for all roots with empty path\n        if (!rootPathList) throw new ResourceNotFoundException(\"No resource path specified\")\n        String rootResourceName = rootPathList[0]\n        ResourceNode resourceNode = getRootResourceNode(rootResourceName)\n\n        StringBuilder fullBasePath = new StringBuilder(basePath)\n        for (String rootPath in rootPathList) fullBasePath.append('/').append(rootPath)\n        Map<String, Map> paths = [:]\n        // NOTE: using LinkedHashMap though TreeMap would be nice as saw odd behavior where TreeMap.put() did nothing\n        Map<String, Map> definitions = new LinkedHashMap<String, Map>()\n        Map<String, Object> swaggerMap = [swagger:'2.0',\n            info:[title:(resourceNode.displayName ?: \"Service REST API (${fullBasePath})\"),\n                  version:(resourceNode.version ?: '1.0'), description:(resourceNode.description ?: '')],\n            host:hostName, basePath:fullBasePath.toString(), schemes:schemes,\n            securityDefinitions:[basicAuth:[type:'basic', description:'HTTP Basic Authentication'],\n                api_key:[type:\"apiKey\", name:\"api_key\", in:\"header\", description:'HTTP Header api_key']],\n            consumes:['application/json', 'multipart/form-data'], produces:['application/json'],\n        ]\n\n        // add tags for 2nd level resources\n        if (rootPathList.size() >= 1) {\n            List<Map> tags = []\n            for (ResourceNode childResource in resourceNode.getResourceMap().values())\n                tags.add([name:childResource.name, description:(childResource.description ?: childResource.name)])\n            swaggerMap.put(\"tags\", tags)\n        }\n\n        swaggerMap.put(\"paths\", paths)\n        swaggerMap.put(\"definitions\", definitions)\n\n        resourceNode.addToSwaggerMap(swaggerMap, rootPathList)\n\n        int methodsCount = 0\n        for (Map rsMap in paths.values()) methodsCount += rsMap.size()\n        logger.info(\"Generated Swagger for ${rootPathList}; ${paths.size()} (${resourceNode.childPaths}) paths with ${methodsCount} (${resourceNode.childMethods}) methods, ${definitions.size()} definitions\")\n\n        return swaggerMap\n    }\n\n    static abstract class MethodHandler {\n        ExecutionContextFactoryImpl ecfi\n        String method\n        PathNode pathNode\n        String requireAuthentication\n        MethodHandler(MNode methodNode, PathNode pathNode, ExecutionContextFactoryImpl ecfi) {\n            this.ecfi = ecfi\n            method = methodNode.attribute(\"type\")\n            this.pathNode = pathNode\n            requireAuthentication = methodNode.attribute(\"require-authentication\") ?: pathNode.requireAuthentication ?: \"true\"\n        }\n        abstract RestResult run(List<String> pathList, ExecutionContext ec)\n        abstract void addToSwaggerMap(Map<String, Object> swaggerMap, Map<String, Map<String, Object>> resourceMap)\n        abstract Map<String, Object> getRamlMap(Map<String, Object> typesMap)\n        abstract void toString(int level, StringBuilder sb)\n    }\n\n    protected static final Map<String, String> objectTypeJsonMap = [\n            Integer:\"integer\", Long:\"integer\", Short:\"integer\", Float:\"number\", Double:\"number\",\n            BigDecimal:\"number\", BigInteger:\"integer\", Boolean:\"boolean\", List:\"array\", Set:\"array\", Collection:\"array\",\n            Map:\"object\", EntityValue:\"object\", EntityList:\"array\" ]\n    static String getJsonType(String javaType) {\n        if (!javaType) return \"string\"\n        if (javaType.contains(\".\")) javaType = javaType.substring(javaType.lastIndexOf(\".\") + 1)\n        return objectTypeJsonMap.get(javaType) ?: \"string\"\n    }\n    protected static final Map<String, String> objectJsonFormatMap = [\n            Integer:\"int32\", Long:\"int64\", Short:\"int32\", Float:\"float\", Double:\"double\",\n            BigDecimal:\"\", BigInteger:\"int64\", Date:\"date\", Timestamp:\"date-time\",\n            Boolean:\"\", List:\"\", Set:\"\", Collection:\"\", Map:\"\" ]\n    static String getJsonFormat(String javaType) {\n        if (!javaType) return \"\"\n        if (javaType.contains(\".\")) javaType = javaType.substring(javaType.lastIndexOf(\".\") + 1)\n        return objectJsonFormatMap.get(javaType) ?: \"\"\n    }\n\n    protected static final Map<String, String> objectTypeRamlMap = [\n            Integer:\"integer\", Long:\"integer\", Short:\"integer\", Float:\"number\", Double:\"number\",\n            BigDecimal:\"number\", BigInteger:\"integer\", Boolean:\"boolean\", List:\"array\", Set:\"array\", Collection:\"array\",\n            Map:\"object\", EntityValue:\"object\", EntityList:\"array\" ]\n    static String getRamlType(String javaType) {\n        if (!javaType) return \"string\"\n        if (javaType.contains(\".\")) javaType = javaType.substring(javaType.lastIndexOf(\".\") + 1)\n        return objectTypeRamlMap.get(javaType) ?: \"string\"\n    }\n\n    static class MethodService extends MethodHandler {\n        String serviceName\n        MethodService(MNode methodNode, MNode serviceNode, PathNode pathNode, ExecutionContextFactoryImpl ecfi) {\n            super(methodNode, pathNode, ecfi)\n            serviceName = serviceNode.attribute(\"name\")\n        }\n        RestResult run(List<String> pathList, ExecutionContext ec) {\n            if ((requireAuthentication == null || requireAuthentication.length() == 0 || \"true\".equals(requireAuthentication)) &&\n                    !ec.getUser().getUsername()) {\n                throw new AuthenticationRequiredException(\"User must be logged in to call service ${serviceName}\")\n            }\n\n            boolean loggedInAnonymous = false\n            if (\"anonymous-all\".equals(requireAuthentication)) {\n                ec.artifactExecution.setAnonymousAuthorizedAll()\n                loggedInAnonymous = ec.getUser().loginAnonymousIfNoUser()\n            } else if (\"anonymous-view\".equals(requireAuthentication)) {\n                ec.artifactExecution.setAnonymousAuthorizedView()\n                loggedInAnonymous = ec.getUser().loginAnonymousIfNoUser()\n            }\n\n            try {\n                Map result = ec.getService().sync().name(serviceName).parameters(ec.context).call()\n                ServiceDefinition.nestedRemoveNullsFromResultMap(result)\n                return new RestResult(result, null)\n            } finally {\n                if (loggedInAnonymous) ((UserFacadeImpl) ec.getUser()).logoutAnonymousOnly()\n            }\n        }\n\n        void addToSwaggerMap(Map<String, Object> swaggerMap, Map<String, Map<String, Object>> resourceMap) {\n            ServiceDefinition sd = ecfi.serviceFacade.getServiceDefinition(serviceName)\n            if (sd == null) throw new IllegalArgumentException(\"Service ${serviceName} not found\")\n            MNode serviceNode = sd.serviceNode\n            Map definitionsMap = (Map) swaggerMap.definitions\n\n            // add parameters, including path parameters\n            List<Map> parameters = []\n            Set<String> remainingInParmNames = new LinkedHashSet<String>(sd.getInParameterNames())\n            for (String pathParm in pathNode.pathParameters) {\n                MNode parmNode = sd.getInParameter(pathParm)\n                if (parmNode == null) throw new IllegalArgumentException(\"No in parameter found for path parameter ${pathParm} in service ${sd.serviceName}\")\n                parameters.add([name:pathParm, in:'path', required:true, type:getJsonType((String) parmNode?.attribute('type')),\n                                description:parmNode.first(\"description\")?.text])\n                remainingInParmNames.remove(pathParm)\n            }\n            if (remainingInParmNames) {\n                if (method in ['post', 'put', 'patch']) {\n                    parameters.add([name:'body', in:'body', required:true, schema:['$ref':\"#/definitions/${sd.serviceNameNoHash}.In\".toString()]])\n                    // add a definition for service in parameters\n                    definitionsMap.put(\"${sd.serviceNameNoHash}.In\".toString(), RestSchemaUtil.getJsonSchemaMapIn(sd))\n                } else {\n                    for (String parmName in remainingInParmNames) {\n                        MNode parmNode = sd.getInParameter(parmName)\n                        String javaType = parmNode.attribute(\"type\")\n                        String jsonType = getJsonType(javaType)\n                        // these are query parameters because method doesn't support body, so skip objects and arrays\n                        //   (in many services they are not needed, pre-lookup sorts of objects; use post or something if needed)\n                        if (jsonType == 'object' || jsonType == 'array') continue\n                        Map<String, Object> propMap = [name:parmName, in:'query', required:false,\n                                type:jsonType, format:getJsonFormat(javaType),\n                                description:parmNode.first(\"description\")?.text] as Map<String, Object>\n                        parameters.add(propMap)\n                        RestSchemaUtil.addParameterEnums(sd, parmNode, propMap)\n                    }\n\n                }\n            }\n\n            // add responses\n            Map responses = [\"401\":[description:\"Authentication required\"], \"403\":[description:\"Access Forbidden (no authz)\"],\n                             \"429\":[description:\"Too Many Requests (tarpit)\"], \"500\":[description:\"General Error\"]] as Map<String, Object>\n            if (sd.getOutParameterNames().size() > 0) {\n                responses.put(\"200\", [description:'Success', schema:['$ref':\"#/definitions/${sd.serviceNameNoHash}.Out\".toString()]])\n                definitionsMap.put(\"${sd.serviceNameNoHash}.Out\".toString(), RestSchemaUtil.getJsonSchemaMapOut(sd))\n            }\n\n            Map curMap = new LinkedHashMap<String, Object>()\n            if (swaggerMap.tags && pathNode.fullPathList.size() > 1) curMap.put(\"tags\", [pathNode.fullPathList[1]])\n            curMap.putAll([summary:(serviceNode.attribute(\"displayName\") ?: \"${sd.verb} ${sd.noun}\".toString()),\n                           description:serviceNode.first(\"description\")?.text,\n                           security:[[basicAuth:[]], [api_key:[]]], parameters:parameters, responses:responses])\n            resourceMap.put(method, curMap)\n        }\n\n        Map<String, Object> getRamlMap(Map<String, Object> typesMap) {\n            ServiceDefinition sd = ecfi.serviceFacade.getServiceDefinition(serviceName)\n            if (sd == null) throw new IllegalArgumentException(\"Service ${serviceName} not found\")\n            MNode serviceNode = sd.serviceNode\n\n            Map<String, Object> ramlMap =  [is:['service'],\n                    displayName:(serviceNode.attribute(\"displayName\") ?: \"${sd.verb} ${sd.noun}\".toString())] as Map<String, Object>\n\n            // add parameters, including path parameters\n            Set<String> remainingInParmNames = new LinkedHashSet<String>(sd.getInParameterNames())\n            for (String pathParm in pathNode.pathParameters) remainingInParmNames.remove(pathParm)\n            if (remainingInParmNames) {\n                ramlMap.put(\"body\", ['application/json': [type:\"${sd.serviceName}.In\".toString()]])\n                // add a definition for service in parameters\n                typesMap.put(\"${sd.serviceName}.In\".toString(), RestSchemaUtil.getRamlMapIn(sd))\n            }\n\n            if (sd.getOutParameterNames().size() > 0) {\n                ramlMap.put(\"responses\", [200:[body:['application/json': [type:\"${sd.serviceName}.Out\".toString()]]]])\n                typesMap.put(\"${sd.serviceName}.Out\".toString(), RestSchemaUtil.getRamlMapOut(sd))\n            }\n\n            return ramlMap\n        }\n\n        void toString(int level, StringBuilder sb) {\n            for (int i=0; i < (level * 4); i++) sb.append(\" \")\n            sb.append(method).append(\": service - \").append(serviceName).append(\"\\n\")\n        }\n    }\n\n    static class MethodEntity extends MethodHandler {\n        String entityName, masterName, operation\n        MethodEntity(MNode methodNode, MNode entityNode, PathNode pathNode, ExecutionContextFactoryImpl ecfi) {\n            super(methodNode, pathNode, ecfi)\n            entityName = entityNode.attribute(\"name\")\n            masterName = entityNode.attribute(\"masterName\")\n            operation = entityNode.attribute(\"operation\")\n        }\n        RestResult run(List<String> pathList, ExecutionContext ec) {\n            // for entity ops authc always required\n            if ((requireAuthentication == null || requireAuthentication.length() == 0 || \"true\".equals(requireAuthentication)) &&\n                    !ec.getUser().getUsername()) {\n                throw new AuthenticationRequiredException(\"User must be logged in for operaton ${operation} on entity ${entityName}\")\n            }\n\n            boolean loggedInAnonymous = false\n            if (\"anonymous-all\".equals(requireAuthentication)) {\n                ec.artifactExecution.setAnonymousAuthorizedAll()\n                loggedInAnonymous = ec.getUser().loginAnonymousIfNoUser()\n            } else if (\"anonymous-view\".equals(requireAuthentication)) {\n                ec.artifactExecution.setAnonymousAuthorizedView()\n                loggedInAnonymous = ec.getUser().loginAnonymousIfNoUser()\n            }\n\n            try {\n                if (operation == 'one') {\n                    EntityFind ef = ec.entity.find(entityName).searchFormMap(ec.context, null, null, null, false)\n                    if (masterName) {\n                        return new RestResult(ef.oneMaster(masterName), null)\n                    } else {\n                        EntityValue val = ef.one()\n                        return new RestResult(val != null ? CollectionUtilities.removeNullsFromMap(val.getMap()) : null, null)\n                    }\n                } else if (operation == 'list') {\n                    EntityFind ef = ec.entity.find(entityName).searchFormMap(ec.context, null, null, null, false)\n                    // we don't want to go overboard with these requests, never do an unlimited find, if no limit use 100\n                    if (!ef.getLimit() && !\"true\".equals(ec.context.get(\"pageNoLimit\"))) ef.limit(100)\n\n                    int count = ef.count() as int\n                    int pageIndex = ef.getPageIndex()\n                    int pageSize = ef.getPageSize()\n                    int pageMaxIndex = ((count - 1) as BigDecimal).divide(pageSize as BigDecimal, 0, RoundingMode.DOWN).intValue()\n                    int pageRangeLow = pageIndex * pageSize + 1\n                    int pageRangeHigh = (pageIndex * pageSize) + pageSize\n                    if (pageRangeHigh > count) pageRangeHigh = count\n                    Map<String, Object> headers = ['X-Total-Count':count, 'X-Page-Index':pageIndex, 'X-Page-Size':pageSize,\n                        'X-Page-Max-Index':pageMaxIndex, 'X-Page-Range-Low':pageRangeLow, 'X-Page-Range-High':pageRangeHigh] as Map<String, Object>\n\n                    if (masterName) {\n                        return new RestResult(ef.listMaster(masterName), headers)\n                    } else {\n                        return new RestResult(ef.list().getValueMapList(), headers)\n                    }\n                } else if (operation == 'count') {\n                    EntityFind ef = ec.entity.find(entityName).searchFormMap(ec.context, null, null, null, false)\n                    long count = ef.count()\n                    Map<String, Object> headers = ['X-Total-Count':count] as Map<String, Object>\n                    return new RestResult([count:count], headers)\n                } else if (operation in ['create', 'update', 'store', 'delete']) {\n                    Map result = ec.getService().sync().name(operation, entityName).parameters(ec.context).call()\n                    return new RestResult(result, null)\n                } else {\n                    throw new IllegalArgumentException(\"Entity operation ${operation} not supported, must be one of: one, list, count, create, update, store, delete\")\n                }\n            } finally {\n                if (loggedInAnonymous) ((UserFacadeImpl) ec.getUser()).logoutAnonymousOnly()\n            }\n        }\n\n        void addToSwaggerMap(Map<String, Object> swaggerMap, Map<String, Map<String, Object>> resourceMap) {\n            EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(entityName)\n            if (ed == null) throw new IllegalArgumentException(\"Entity ${entityName} not found\")\n            // Node entityNode = ed.getEntityNode()\n\n            Map definitionsMap = ((Map) swaggerMap.definitions)\n            String refDefName = ed.getShortOrFullEntityName()\n            if (masterName) refDefName = refDefName + \".\" + masterName\n            String refDefNamePk = refDefName + \".PK\"\n\n            // add path parameters\n            List<Map> parameters = []\n            ArrayList<String> remainingPkFields = new ArrayList<String>(ed.getPkFieldNames())\n            for (String pathParm in pathNode.pathParameters) {\n                FieldInfo fi = ed.getFieldInfo(pathParm)\n                if (fi == null) throw new IllegalArgumentException(\"No field found for path parameter ${pathParm} in entity ${ed.getFullEntityName()}\")\n                parameters.add([name:pathParm, in:'path', required:true, type:(RestSchemaUtil.fieldTypeJsonMap.get(fi.type) ?: \"string\"),\n                                description:fi.fieldNode.first(\"description\")?.text])\n                remainingPkFields.remove(pathParm)\n            }\n\n            // add responses\n            Map responses = [\"401\":[description:\"Authentication required\"], \"403\":[description:\"Access Forbidden (no authz)\"],\n                             \"404\":[description:\"Value Not Found\"], \"429\":[description:\"Too Many Requests (tarpit)\"],\n                             \"500\":[description:\"General Error\"]] as Map<String, Object>\n\n            boolean addEntityDef = true\n            boolean addPkDef = false\n            if (operation  == 'one') {\n                if (remainingPkFields) {\n                    for (String fieldName in remainingPkFields) {\n                        FieldInfo fi = ed.getFieldInfo(fieldName)\n                        Map<String, Object> fieldMap = [name:fieldName, in:'query', required:false,\n                                type:(RestSchemaUtil.fieldTypeJsonMap.get(fi.type) ?: \"string\"),\n                                format:(RestSchemaUtil.fieldTypeJsonFormatMap.get(fi.type) ?: \"\"),\n                                description:fi.fieldNode.first(\"description\")?.text] as Map<String, Object>\n                        parameters.add(fieldMap)\n                        List enumList = RestSchemaUtil.getFieldEnums(ed, fi)\n                        if (enumList) fieldMap.put('enum', enumList)\n                    }\n                }\n                responses.put(\"200\", [description:'Success', schema:['$ref':\"#/definitions/${refDefName}\".toString()]])\n            } else if (operation == 'list') {\n                parameters.addAll(RestSchemaUtil.swaggerPaginationParameters)\n                for (String fieldName in ed.getAllFieldNames()) {\n                    if (fieldName in pathNode.pathParameters) continue\n                    FieldInfo fi = ed.getFieldInfo(fieldName)\n                    parameters.add([name:fieldName, in:'query', required:false,\n                                        type:(RestSchemaUtil.fieldTypeJsonMap.get(fi.type) ?: \"string\"),\n                                        format:(RestSchemaUtil.fieldTypeJsonFormatMap.get(fi.type) ?: \"\"),\n                                        description:fi.fieldNode.first(\"description\")?.text])\n                }\n                // parameters.add([name:'body', in:'body', required:false, schema:[allOf:[['$ref':'#/definitions/paginationParameters'], ['$ref':\"#/definitions/${refDefName}\"]]]])\n                responses.put(\"200\", [description:'Success', schema:[type:\"array\", items:['$ref':\"#/definitions/${refDefName}\".toString()]]])\n            } else if (operation == 'count') {\n                parameters.add([name:'body', in:'body', required:false, schema:['$ref':\"#/definitions/${refDefName}\".toString()]])\n                responses.put(\"200\", [description:'Success', schema:RestSchemaUtil.jsonCountParameters])\n            } else if (operation in ['create', 'update', 'store']) {\n                parameters.add([name:'body', in:'body', required:false, schema:['$ref':\"#/definitions/${refDefName}\".toString()]])\n                responses.put(\"200\", [description:'Success', schema:['$ref':\"#/definitions/${refDefNamePk}\".toString()]])\n                addPkDef = true\n            } else if (operation == 'delete') {\n                addEntityDef = false\n                if (remainingPkFields) {\n                    parameters.add([name:'body', in:'body', required:false, schema:['$ref':\"#/definitions/${refDefNamePk}\".toString()]])\n                    addPkDef = true\n                }\n            }\n\n            Map curMap = new LinkedHashMap<String, Object>()\n            String summary = \"${operation} ${ed.entityInfo.internalEntityName}\"\n            if (masterName) summary = summary + \" (master: \" + masterName + \")\"\n            if (swaggerMap.tags && pathNode.fullPathList.size() > 1) curMap.put(\"tags\", [pathNode.fullPathList[1]])\n            curMap.putAll([summary:summary, description:ed.getEntityNode().first(\"description\")?.text,\n                           security:[[basicAuth:[]], [api_key:[]]], parameters:parameters, responses:responses])\n            resourceMap.put(method, curMap)\n\n            // add a definition for entity fields\n            if (addEntityDef) definitionsMap.put(refDefName, RestSchemaUtil.getJsonSchema(ed, false, false, definitionsMap, null, null, null, false, masterName, null))\n            if (addPkDef) definitionsMap.put(refDefNamePk, RestSchemaUtil.getJsonSchema(ed, true, false, null, null, null, null, false, masterName, null))\n        }\n\n        Map<String, Object> getRamlMap(Map<String, Object> typesMap) {\n            Map<String, Object> ramlMap = null\n\n            EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(entityName)\n            if (ed == null) throw new IllegalArgumentException(\"Entity ${entityName} not found\")\n\n            String refDefName = ed.getShortOrFullEntityName()\n            if (masterName) refDefName = refDefName + \".\" + masterName\n\n            String prettyName = ed.getPrettyName(null, null)\n\n            // add path parameters\n            ArrayList<String> remainingPkFields = new ArrayList<String>(ed.getPkFieldNames())\n            for (String pathParm in pathNode.pathParameters) {\n                remainingPkFields.remove(pathParm)\n            }\n            Map pkQpMap = [:]\n            for (int i = 0; i < remainingPkFields.size(); i++) {\n                FieldInfo fi = ed.getFieldInfo(remainingPkFields.get(i))\n                pkQpMap.put(fi.name, RestSchemaUtil.getRamlFieldMap(ed, fi))\n            }\n            Map allQpMap = [:]\n            ArrayList<String> allFields = ed.getAllFieldNames()\n            for (int i = 0; i < allFields.size(); i++) {\n                FieldInfo fi = ed.getFieldInfo(allFields.get(i))\n                allQpMap.put(fi.name, RestSchemaUtil.getRamlFieldMap(ed, fi))\n            }\n\n            boolean addEntityDef = true\n            if (operation  == 'one') {\n                ramlMap = [is:['entity'], displayName:\"Get single ${prettyName}\".toString()] as Map<String, Object>\n                if (pkQpMap) ramlMap.put('queryParameters', pkQpMap)\n                ramlMap.put(\"responses\", [200:[body:['application/json': [type:refDefName]]]])\n            } else if (operation == 'list') {\n                // TODO: add pagination headers\n                ramlMap = [is:['paged', 'entity'], displayName:\"Get list of ${prettyName}\".toString(), body:['application/json': [type:refDefName]]]\n                ramlMap.put(\"responses\", [200:[body:['application/json': [type:\"array\", items:refDefName]]]])\n            } else if (operation == 'count') {\n                ramlMap = [is:['entity'], displayName:\"Count ${prettyName}\".toString(), body:['application/json': [type:refDefName]]] as Map<String, Object>\n                ramlMap.put(\"responses\", [200:[body:['application/json': RestSchemaUtil.jsonCountParameters]]])\n            } else if (operation  == 'create') {\n                ramlMap = [is:['entity'], displayName:\"Create ${prettyName}\".toString(), body:['application/json': [type:refDefName]]] as Map<String, Object>\n                if (pkQpMap) ramlMap.put(\"responses\", [200:[body:['application/json': [type:'object', properties:pkQpMap]]]])\n            } else if (operation == 'update') {\n                ramlMap = [is:['entity'], displayName:\"Update ${prettyName}\".toString(), body:['application/json': [type:refDefName]]] as Map<String, Object>\n            } else if (operation == 'store') {\n                ramlMap = [is:['entity'], displayName:\"Create or Update ${prettyName}\".toString(), body:['application/json': [type:refDefName]]] as Map<String, Object>\n                if (pkQpMap) ramlMap.put(\"responses\", [200:[body:['application/json': [type:'object', properties:pkQpMap]]]])\n            } else if (operation == 'delete') {\n                ramlMap = [is:['entity'], displayName:\"Delete ${prettyName}\".toString()] as Map<String, Object>\n                if (pkQpMap) ramlMap.put('queryParameters', pkQpMap)\n                addEntityDef = false\n            }\n            if (addEntityDef) RestSchemaUtil.getRamlTypeMap(ed, false, typesMap, masterName, null)\n\n            return ramlMap\n        }\n\n        void toString(int level, StringBuilder sb) {\n            for (int i=0; i < (level * 4); i++) sb.append(\" \")\n            sb.append(method).append(\": entity - \").append(operation).append(\" - \").append(entityName)\n            if (masterName) sb.append(\" (master: \").append(masterName).append(\")\")\n            sb.append(\"\\n\")\n        }\n    }\n\n    static abstract class PathNode {\n        ExecutionContextFactoryImpl ecfi\n\n        String displayName, description, version\n\n        Map<String, MethodHandler> methodMap = [:]\n        IdNode idNode = null\n        Map<String, ResourceNode> resourceMap = [:]\n\n        String name\n        String requireAuthentication\n        PathNode parent\n        List<String> fullPathList = []\n        Set<String> pathParameters = new LinkedHashSet<String>()\n\n        int childPaths = 0\n        int childMethods = 0\n\n        PathNode(MNode node, PathNode parent, ExecutionContextFactoryImpl ecfi, boolean isId) {\n            this.ecfi = ecfi\n            this.parent = parent\n\n            displayName = node.attribute(\"displayName\")\n            description = node.attribute(\"description\")\n            version = node.attribute(\"version\")\n            if (version && version.contains('${')) version = SystemBinding.expand(version)\n\n            if (parent != null) this.pathParameters.addAll(parent.pathParameters)\n            name = node.attribute(\"name\")\n            if (parent != null) fullPathList.addAll(parent.fullPathList)\n            fullPathList.add(isId ? \"{${name}}\".toString() : name)\n            if (isId) pathParameters.add(name)\n            requireAuthentication = node.attribute(\"require-authentication\") ?: parent?.requireAuthentication ?: \"true\"\n\n            for (MNode childNode in node.children) {\n                if (childNode.name == \"method\") {\n                    String method = childNode.attribute(\"type\")\n\n                    MNode methodNode = childNode.children[0]\n                    if (methodNode.name == \"service\") {\n                        methodMap.put(method, new MethodService(childNode, methodNode, this, ecfi))\n                    } else if (methodNode.name == \"entity\") {\n                        methodMap.put(method, new MethodEntity(childNode, methodNode, this, ecfi))\n                    }\n                } else if (childNode.name == \"resource\") {\n                    ResourceNode resourceNode = new ResourceNode(childNode, this, ecfi)\n                    resourceMap.put(resourceNode.name, resourceNode)\n                } else if (childNode.name == \"id\") {\n                    idNode = new IdNode(childNode, this, ecfi)\n                }\n            }\n\n            childMethods += methodMap.size()\n            for (ResourceNode rn in resourceMap.values()) {\n                childPaths++\n                childPaths += rn.childPaths\n                childMethods += rn.childMethods\n            }\n            if (idNode != null) {\n                childPaths++\n                childPaths += idNode.childPaths\n                childMethods += idNode.childMethods\n            }\n        }\n\n        RestResult runByMethod(List<String> pathList, ExecutionContext ec) {\n            String method = getCurrentMethod(ec)\n            MethodHandler mh = (MethodHandler) methodMap.get(method)\n            if (mh == null) throw new MethodNotSupportedException(\"Method ${method} not supported at ${pathList}\")\n            return mh.run(pathList, ec)\n        }\n        private String getCurrentMethod(ExecutionContext ec) {\n            HttpServletRequest request = ec.web.getRequest()\n            String method = request.getMethod().toLowerCase()\n            if (\"post\".equals(method)) {\n                String ovdMethod = request.getHeader(\"X-HTTP-Method-Override\")\n                if (ovdMethod != null && !ovdMethod.isEmpty()) method = ovdMethod.toLowerCase()\n            }\n            return method\n        }\n\n        RestResult visitChildOrRun(List<String> pathList, int pathIndex, ExecutionContextImpl ec) {\n            // more in path? visit the next, otherwise run by request method\n            int nextPathIndex = pathIndex + 1\n            boolean moreInPath = pathList.size() > nextPathIndex\n\n            // push onto artifact stack, check authz\n            String curPath = getFullPathName([])\n            ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(curPath, ArtifactExecutionInfo.AT_REST_PATH, getActionFromMethod(ec), null)\n            // for now don't track/count artifact hits for REST path\n            aei.setTrackArtifactHit(false)\n            // NOTE: consider setting parameters on aei, but don't like setting entire context, currently used for entity/service calls\n            ec.artifactExecutionFacade.pushInternal(aei, !moreInPath ?\n                    (requireAuthentication == null || requireAuthentication.length() == 0 || \"true\".equals(requireAuthentication)) : false, true)\n\n            boolean loggedInAnonymous = false\n            if (\"anonymous-all\".equals(requireAuthentication)) {\n                ec.artifactExecutionFacade.setAnonymousAuthorizedAll()\n                loggedInAnonymous = ec.userFacade.loginAnonymousIfNoUser()\n            } else if (\"anonymous-view\".equals(requireAuthentication)) {\n                ec.artifactExecutionFacade.setAnonymousAuthorizedView()\n                loggedInAnonymous = ec.userFacade.loginAnonymousIfNoUser()\n            }\n\n            try {\n                if (moreInPath) {\n                    String nextPath = pathList[nextPathIndex]\n                    // first try resources\n                    ResourceNode rn = resourceMap.get(nextPath)\n                    if (rn != null) {\n                        return rn.visit(pathList, nextPathIndex, ec)\n                    } else if (idNode != null) {\n                        // no resource? if there is an idNode treat as ID\n                        return idNode.visit(pathList, nextPathIndex, ec)\n                    } else {\n                        // not a resource and no idNode, is a bad path\n                        throw new ResourceNotFoundException(\"Resource ${nextPath} not valid, index ${pathIndex} in path ${pathList}; resources available are ${resourceMap.keySet()}\")\n                    }\n                } else {\n                    // if there is a child id node and it has allow-extra-path=true then try using it, allow id with extra path to have no extra path\n                    if (idNode != null && idNode.allowExtraPath && methodMap.get(getCurrentMethod(ec)) == null) {\n                        return idNode.visit(pathList, nextPathIndex, ec)\n                    }\n                    return runByMethod(pathList, ec)\n                }\n            } finally {\n                ec.artifactExecutionFacade.pop(aei)\n                if (loggedInAnonymous) ec.userFacade.logoutAnonymousOnly()\n            }\n        }\n\n        void addToSwaggerMap(Map<String, Object> swaggerMap, List<String> rootPathList) {\n            // see if we are in the root path specified\n            int curIndex = fullPathList.size() - 1\n            if (curIndex < rootPathList.size() && fullPathList[curIndex] != rootPathList[curIndex]) return\n\n            // if we have method handlers add this, otherwise just do children\n            if (rootPathList.size() - 1 <= curIndex && methodMap) {\n                String curPath = getFullPathName(rootPathList)\n\n                Map<String, Map<String, Object>> rsMap = [:]\n                for (MethodHandler mh in methodMap.values()) mh.addToSwaggerMap(swaggerMap, rsMap)\n\n                ((Map) swaggerMap.paths).put(curPath ?: '/', rsMap)\n            }\n            // add the id node if there is one\n            if (idNode != null) idNode.addToSwaggerMap(swaggerMap, rootPathList)\n            // add any resource nodes there might be\n            for (ResourceNode rn in resourceMap.values()) rn.addToSwaggerMap(swaggerMap, rootPathList)\n        }\n\n        String getFullPathName(List<String> rootPathList) {\n            StringBuilder curPath = new StringBuilder()\n            for (int i = rootPathList.size(); i < fullPathList.size(); i++) {\n                String pathItem = fullPathList.get(i)\n                curPath.append('/').append(pathItem)\n            }\n            return curPath.toString()\n        }\n        static final Map<String, ArtifactExecutionInfo.AuthzAction> actionByMethodMap = [get:ArtifactExecutionInfo.AUTHZA_VIEW,\n                patch:ArtifactExecutionInfo.AUTHZA_UPDATE, put:ArtifactExecutionInfo.AUTHZA_UPDATE,\n                post:ArtifactExecutionInfo.AUTHZA_CREATE, delete:ArtifactExecutionInfo.AUTHZA_DELETE,\n                options:ArtifactExecutionInfo.AUTHZA_VIEW, head:ArtifactExecutionInfo.AUTHZA_VIEW]\n        static ArtifactExecutionInfo.AuthzAction getActionFromMethod(ExecutionContext ec) {\n            String method = ec.web.getRequest().getMethod().toLowerCase()\n            return actionByMethodMap.get(method)\n        }\n\n        Map getRamlChildrenMap(Map<String, Object> typesMap) {\n            Map<String, Object> childrenMap = [:]\n\n            // add displayName, description\n            if (displayName) childrenMap.put('displayName', displayName)\n            if (description) childrenMap.put('description', description)\n\n            // if we have method handlers add this, otherwise just do children\n            if (methodMap) for (MethodHandler mh in methodMap.values()) childrenMap.put(mh.method, mh.getRamlMap(typesMap))\n            // add the id node if there is one\n            if (idNode != null) childrenMap.put('/{' + idNode.name + '}', idNode.getRamlChildrenMap(typesMap))\n            // add any resource nodes there might be\n            for (ResourceNode rn in resourceMap.values()) childrenMap.put('/' + rn.name, rn.getRamlChildrenMap(typesMap))\n\n            return childrenMap\n        }\n\n        void toStringChildren(int level, StringBuilder sb) {\n            for (MethodHandler mh in methodMap.values()) mh.toString(level + 1, sb)\n            for (ResourceNode rn in resourceMap.values()) rn.toString(level + 1, sb)\n            if (idNode != null) idNode.toString(level + 1, sb)\n        }\n\n        abstract Object visit(List<String> pathList, int pathIndex, ExecutionContextImpl ec)\n    }\n    static class ResourceNode extends PathNode {\n        ResourceNode(MNode node, PathNode parent, ExecutionContextFactoryImpl ecfi) {\n            super(node, parent, ecfi, false)\n        }\n        RestResult visit(List<String> pathList, int pathIndex, ExecutionContextImpl ec) {\n            // logger.info(\"Visit resource ${name}\")\n            // visit child or run here\n            return visitChildOrRun(pathList, pathIndex, ec)\n        }\n        String toString() {\n            StringBuilder sb = new StringBuilder()\n            toString(0, sb)\n            return sb.toString()\n        }\n        void toString(int level, StringBuilder sb) {\n            for (int i=0; i < (level * 4); i++) sb.append(\" \")\n            sb.append(\"/\").append(name)\n            if (displayName) sb.append(\" - \").append(displayName)\n            sb.append(\"\\n\")\n            toStringChildren(level, sb)\n        }\n    }\n    static class IdNode extends PathNode {\n        private boolean allowExtraPath = false\n        IdNode(MNode node, PathNode parent, ExecutionContextFactoryImpl ecfi) {\n            super(node, parent, ecfi, true)\n            allowExtraPath = \"true\".equals(node.attribute(\"allow-extra-path\"))\n        }\n        RestResult visit(List<String> pathList, int pathIndex, ExecutionContextImpl ec) {\n            // logger.info(\"Visit id ${name}\")\n            // set ID value in context\n            if (allowExtraPath) {\n                // handle allow-extra-path, make a List of this path element plus all after it\n                // path elements remaining to include in this list, including the current element\n                int elementsRemaining = pathList.size() - pathIndex\n                ArrayList<String> pathElements = new ArrayList<>()\n                // note that this may do nothing if pathIndex = pathList.size() (ie no extra path elements)\n                for (int i = pathIndex; i < pathList.size(); i++) pathElements.add(pathList.get(i))\n                ec.context.put(name, pathElements)\n                // add elementsRemaining - 1 (for the current element) to advance pathIndex to the end\n                pathIndex += (elementsRemaining - 1)\n            } else {\n                ec.context.put(name, pathList.get(pathIndex))\n            }\n            // visit child or run here\n            return visitChildOrRun(pathList, pathIndex, ec)\n        }\n        void toString(int level, StringBuilder sb) {\n            for (int i=0; i < (level * 4); i++) sb.append(\" \")\n            sb.append(\"/{\").append(name).append(\"}\\n\")\n            toStringChildren(level, sb)\n        }\n    }\n\n    static class RestResult {\n        Object responseObj\n        Map<String, Object> headers = [:]\n        RestResult(Object responseObj, Map<String, Object> headers) {\n            this.responseObj = responseObj\n            if (headers) this.headers.putAll(headers)\n        }\n        void setHeaders(HttpServletResponse response) {\n            for (Map.Entry<String, Object> entry in headers) {\n                Object value = entry.value\n                if (value == null) continue\n                if (value instanceof Integer) {\n                    response.setIntHeader(entry.key, (int) value)\n                } else if (value instanceof Date) {\n                    response.setDateHeader(entry.key, ((Date) value).getTime())\n                } else {\n                    response.setHeader(entry.key, value.toString())\n                }\n            }\n        }\n    }\n\n    static class ResourceNotFoundException extends BaseException {\n        ResourceNotFoundException(String str) { super(str) }\n        // ResourceNotFoundException(String str, Throwable nested) { super(str, nested) }\n    }\n    static class MethodNotSupportedException extends BaseException {\n        MethodNotSupportedException(String str) { super(str) }\n        // MethodNotSupportedException(String str, Throwable nested) { super(str, nested) }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/ScheduledJobRunner.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.service\n\nimport com.cronutils.descriptor.CronDescriptor\nimport com.cronutils.model.Cron\nimport com.cronutils.model.CronType\nimport com.cronutils.model.definition.CronDefinition\nimport com.cronutils.model.definition.CronDefinitionBuilder\nimport com.cronutils.model.time.ExecutionTime\nimport com.cronutils.parser.CronParser\nimport groovy.transform.CompileStatic\nimport org.moqui.entity.EntityCondition\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.impl.entity.EntityFacadeImpl\nimport org.moqui.util.MNode\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.sql.Timestamp\nimport java.time.Instant\nimport java.time.ZoneId\nimport java.time.ZonedDateTime\nimport java.util.concurrent.ThreadPoolExecutor\n\n/**\n * Runs scheduled jobs as defined in ServiceJob records with a cronExpression. Cron expression uses Quartz flavored syntax.\n *\n * Uses cron-utils for cron processing, see:\n *     https://github.com/jmrozanec/cron-utils\n * For a Quartz cron reference see:\n *     http://www.quartz-scheduler.org/documentation/quartz-2.x/tutorials/crontrigger.html\n *     https://www.quartz-scheduler.org/api/2.2.1/org/quartz/CronExpression.html\n *\n * Handy cron strings: [0 0 2 * * ?] every night at 2:00 am, [0 0/15 * * * ?] every 15 minutes, [0 0/2 * * * ?] every 2 minutes\n */\n@CompileStatic\nclass ScheduledJobRunner implements Runnable {\n    private final static Logger logger = LoggerFactory.getLogger(ScheduledJobRunner.class)\n    private final ExecutionContextFactoryImpl ecfi\n\n    private final static CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ)\n    private final static CronParser parser = new CronParser(cronDefinition)\n    private final static Map<String, Cron> cronByExpression = new HashMap<>()\n    private long lastExecuteTime = 0\n    private int jobQueueMax = 0, executeCount = 0, totalJobsRun = 0, lastJobsActive = 0, lastJobsPaused = 0\n\n    ScheduledJobRunner(ExecutionContextFactoryImpl ecfi) {\n        this.ecfi = ecfi\n\n        MNode serviceFacadeNode = ecfi.confXmlRoot.first(\"service-facade\")\n        jobQueueMax = (serviceFacadeNode.attribute(\"job-queue-max\") ?: \"0\") as int\n    }\n\n    // NOTE: these are called in the service job screens\n    long getLastExecuteTime() { lastExecuteTime }\n    int getExecuteCount() { executeCount }\n    int getTotalJobsRun() { totalJobsRun }\n    int getLastJobsActive() { lastJobsActive }\n    int getLastJobsPaused() { lastJobsPaused }\n\n    @Override\n    synchronized void run() {\n        try {\n            runInternal()\n        } catch (Throwable t) {\n            logger.error(\"Uncaught Throwable in ScheduledJobRunner, catching and suppressing to avoid removal from scheduler\", t)\n        }\n    }\n    void runInternal() {\n        ZonedDateTime now = ZonedDateTime.now()\n        long nowMillis = now.toInstant().toEpochMilli()\n        Timestamp nowTimestamp = new Timestamp(nowMillis)\n        int jobsRun = 0, jobsActive = 0, jobsPaused = 0, jobsReadyNotRun = 0\n\n        // Get ExecutionContext, just for disable authz\n        ExecutionContextImpl eci = ecfi.getEci()\n        eci.artifactExecution.disableAuthz()\n        EntityFacadeImpl efi = ecfi.entityFacade\n        ThreadPoolExecutor jobWorkerPool = ecfi.serviceFacade.jobWorkerPool\n        try {\n            // make sure no transaction is in place, shouldn't be any so try to commit if there is one\n            if (ecfi.transactionFacade.isTransactionInPlace()) {\n                logger.error(\"Found transaction in place in ScheduledJobRunner thread ${Thread.currentThread().getName()}, trying to commit\")\n                try {\n                    ecfi.transactionFacade.destroyAllInThread()\n                } catch (Exception e) {\n                    logger.error(\" Commit of in-place transaction failed for ScheduledJobRunner thread ${Thread.currentThread().getName()}\", e)\n                }\n            }\n\n            // look at jobWorkerPool to see how many jobs we can run: (jobQueueMax + poolMax) - (active + queueSize)\n            int jobSlots = jobQueueMax + jobWorkerPool.getMaximumPoolSize()\n            int jobsRunning = jobWorkerPool.getActiveCount() + jobWorkerPool.queue.size()\n            int jobSlotsAvailable = jobSlots - jobsRunning\n            // if we can't handle any more jobs\n            if (jobSlotsAvailable <= 0) {\n                logger.info(\"ScheduledJobRunner doing nothing, already ${jobsRunning} of ${jobSlots} jobs running\")\n            }\n\n            // find scheduled jobs\n            EntityList serviceJobList = efi.find(\"moqui.service.job.ServiceJob\").useCache(false)\n                    .condition(\"cronExpression\", EntityCondition.ComparisonOperator.NOT_EQUAL, null)\n                    .orderBy(\"priority\").orderBy(\"jobName\").list()\n            serviceJobList.filterByDate(\"fromDate\", \"thruDate\", nowTimestamp)\n            int serviceJobListSize = serviceJobList.size()\n            for (int i = 0; i < serviceJobListSize; i++) {\n                EntityValue serviceJob = (EntityValue) serviceJobList.get(i)\n                String jobName = (String) serviceJob.jobName\n                // a job is ACTIVE if the paused field is null or 'N', so skip for any other value for paused (Y, T, whatever)\n                if (serviceJob.paused != null && !\"N\".equals(serviceJob.paused)) {\n                    jobsPaused++\n                    continue\n                }\n                if (serviceJob.repeatCount != null) {\n                    long repeatCount = ((Long) serviceJob.repeatCount).longValue()\n                    long runCount = efi.find(\"moqui.service.job.ServiceJobRun\").condition(\"jobName\", jobName).useCache(false).count()\n                    if (runCount >= repeatCount) {\n                        // pause the job and set thruDate for faster future filtering\n                        ecfi.service.sync().name(\"update\", \"moqui.service.job.ServiceJob\")\n                                .parameters([jobName: jobName, paused:'Y', thruDate:nowTimestamp] as Map<String, Object>)\n                                .disableAuthz().call()\n                        continue\n                    }\n                }\n                jobsActive++\n\n                String jobRunId\n                EntityValue serviceJobRun\n                EntityValue serviceJobRunLock\n                Timestamp lastRunTime\n                // get a lock, see if another instance is running the job\n                // now we need to run in a transaction; note that this is running in a executor service thread, no tx should ever be in place\n                boolean beganTransaction = ecfi.transaction.begin(null)\n                try {\n                    serviceJobRunLock = efi.find(\"moqui.service.job.ServiceJobRunLock\")\n                            .condition(\"jobName\", jobName).forUpdate(true).one()\n                    lastRunTime = (Timestamp) serviceJobRunLock?.lastRunTime\n                    ZonedDateTime lastRunDt = (lastRunTime != (Timestamp) null) ?\n                            ZonedDateTime.ofInstant(Instant.ofEpochMilli(lastRunTime.getTime()), now.getZone()) : null\n                    if (serviceJobRunLock != null && serviceJobRunLock.jobRunId != null && lastRunDt != null) {\n                        // for failure with no lock reset: run recovery, based on expireLockTime (default to 1440 minutes)\n                        Long expireLockTime = (Long) serviceJob.expireLockTime\n                        if (expireLockTime == null) expireLockTime = 1440L\n                        ZonedDateTime lockCheckTime = now.minusMinutes(expireLockTime.intValue())\n                        if (lastRunDt.isBefore(lockCheckTime)) {\n                            // recover failed job without lock reset, run it if schedule says to\n                            logger.warn(\"Lock expired: found lock for job ${jobName} from ${lastRunDt}, more than ${expireLockTime} minutes old, ignoring lock\")\n                            serviceJobRunLock.set(\"jobRunId\", null).update()\n                        } else {\n                            // normal lock, skip this job\n                            logger.info(\"Lock found for job ${jobName} from ${lastRunDt} run ID ${serviceJobRunLock.jobRunId}, not running\")\n                            continue\n                        }\n                    }\n\n                    // calculate time it should have run last\n                    String cronExpression = (String) serviceJob.getNoCheckSimple(\"cronExpression\")\n                    ExecutionTime executionTime = getExecutionTime(cronExpression)\n                    ZonedDateTime lastSchedule = executionTime.lastExecution(now).get()\n                    if (lastSchedule != null && lastRunDt != null) {\n                        // if the time it should have run last is before the time it ran last don't run it\n                        if (lastSchedule.isBefore(lastRunDt)) continue\n                    }\n\n                    // if the last run had an error check the minRetryTime, don't run if hasn't been long enough\n                    EntityValue lastJobRun = efi.find(\"moqui.service.job.ServiceJobRun\").condition(\"jobName\", jobName)\n                            .orderBy(\"-startTime\").limit(1).useCache(false).list().getFirst()\n                    if (lastJobRun != null && \"Y\".equals(lastJobRun.hasError)) {\n                        Timestamp lastErrorTime = (Timestamp) lastJobRun.endTime ?: (Timestamp) lastJobRun.startTime\n                        if (lastErrorTime != (Timestamp) null) {\n                            ZonedDateTime lastErrorDt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(lastErrorTime.getTime()), now.getZone())\n                            Long minRetryTime = (Long) serviceJob.minRetryTime ?: 5L\n                            ZonedDateTime retryCheckTime = now.minusMinutes(minRetryTime.intValue())\n                            // if last error time after retry check time don't run the job\n                            if (lastErrorDt.isAfter(retryCheckTime)) {\n                                logger.info(\"Not retrying job ${jobName} after error, before ${minRetryTime} min retry minutes (error run at ${lastErrorDt})\")\n                                continue\n                            }\n                        }\n                    }\n\n                    // if no more job slots available continue, don't break because we want to loop through all to get jobsPaused, jobsActive, etc\n                    if (jobSlotsAvailable <= 0) {\n                        jobsReadyNotRun++\n                        continue\n                    }\n\n                    // create a job run and lock it\n                    serviceJobRun = efi.makeValue(\"moqui.service.job.ServiceJobRun\")\n                            .set(\"jobName\", jobName).setSequencedIdPrimary().create()\n                    jobRunId = (String) serviceJobRun.getNoCheckSimple(\"jobRunId\")\n\n                    if (serviceJobRunLock == null) {\n                        serviceJobRunLock = efi.makeValue(\"moqui.service.job.ServiceJobRunLock\").set(\"jobName\", jobName)\n                                .set(\"jobRunId\", jobRunId).set(\"lastRunTime\", nowTimestamp).create()\n                    } else {\n                        serviceJobRunLock.set(\"jobRunId\", jobRunId).set(\"lastRunTime\", nowTimestamp).update()\n                    }\n\n                    logger.info(\"Running job ${jobName} run ${jobRunId} (last run ${lastRunTime}, schedule ${lastSchedule})\")\n                } catch (Throwable t) {\n                    String errMsg = \"Error getting and checking service job run lock\"\n                    ecfi.transaction.rollback(beganTransaction, errMsg, t)\n                    logger.error(errMsg, t)\n                    continue\n                } finally {\n                    ecfi.transaction.commit(beganTransaction)\n                }\n\n                jobsRun++\n                jobSlotsAvailable--\n                if (jobSlotsAvailable <= 0) {\n                    logger.info(\"ScheduledJobRunner out of job slots after running ${jobsRun} jobs, ${jobSlots} jobs running, evaluated ${i} of ${serviceJobListSize} ServiceJob records\")\n                }\n\n                // at this point jobRunId and serviceJobRunLock should not be null\n                ServiceCallJobImpl serviceCallJob = new ServiceCallJobImpl(jobName, ecfi.serviceFacade)\n                // use the job run we created\n                serviceCallJob.withJobRunId(jobRunId)\n                serviceCallJob.withLastRunTime(lastRunTime)\n                // clear the lock when finished\n                serviceCallJob.clearLock()\n                // always run locally to use service job's worker pool and keep queue of pending jobs in the database\n                serviceCallJob.localOnly(true)\n                // run it, will run async\n                try {\n                    serviceCallJob.run()\n                } catch (Throwable t) {\n                    logger.error(\"Error running scheduled job ${jobName}\", t)\n                    ecfi.transactionFacade.runUseOrBegin(null, \"Error clearing lock and saving error on scheduled job run error\", {\n                        serviceJobRunLock.set(\"jobRunId\", null).update()\n                        serviceJobRun.set(\"hasError\", \"Y\").set(\"errors\", t.toString()).set(\"startTime\", nowTimestamp)\n                                .set(\"endTime\", nowTimestamp).update()\n                    })\n                }\n\n                // end of for loop\n            }\n        } catch (Throwable t) {\n            logger.error(\"Uncaught error in scheduled job runner\", t)\n        } finally {\n            // no need, we're destroying the eci: if (!authzDisabled) eci.artifactExecution.enableAuthz()\n            eci.destroy()\n        }\n\n        // update job runner stats\n        lastExecuteTime = nowMillis\n        executeCount++\n        totalJobsRun += jobsRun\n        lastJobsActive = jobsActive\n        lastJobsPaused = jobsPaused\n\n        int jobSlots = jobQueueMax + jobWorkerPool.getMaximumPoolSize()\n        int jobsRunning = jobWorkerPool.getActiveCount() + jobWorkerPool.queue.size()\n\n        if (jobsRun > 0 || logger.isTraceEnabled()) {\n            String infoStr = \"Ran ${jobsRun} Service Jobs starting ${now} - active: ${jobsActive}, paused: ${jobsPaused}; on this server using ${jobsRunning} of ${jobSlots} job slots\"\n            if (jobsReadyNotRun > 0) infoStr += \", ${jobsReadyNotRun} jobs ready but not run (insufficient job slots)\"\n            logger.info(infoStr)\n        }\n    }\n\n    static Cron getCron(String cronExpression) {\n        Cron cachedCron = cronByExpression.get(cronExpression)\n        if (cachedCron != null) return cachedCron\n\n        Cron cron = parser.parse(cronExpression)\n        cronByExpression.put(cronExpression, cron)\n\n        return cron\n    }\n\n    static ExecutionTime getExecutionTime(String cronExpression) { return ExecutionTime.forCron(getCron(cronExpression)) }\n\n    /** Use to determine if it is time to run again, if returns true then run and if false don't run.\n     * See if lastRun is before last scheduled run time based on cronExpression and nowTimestamp (defaults to current date/time) */\n    static boolean isLastRunBeforeLastSchedule(String cronExpression, Timestamp lastRun, String description, Timestamp nowTimestamp) {\n        try {\n            if (lastRun == (Timestamp) null) return true\n            ZonedDateTime now = nowTimestamp != (Timestamp) null ?\n                    ZonedDateTime.ofInstant(Instant.ofEpochMilli(nowTimestamp.getTime()), ZoneId.systemDefault()) :\n                    ZonedDateTime.now()\n            def lastRunDt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(lastRun.getTime()), now.getZone())\n\n            ExecutionTime executionTime = getExecutionTime(cronExpression)\n            ZonedDateTime lastSchedule = executionTime.lastExecution(now).get()\n\n            if (lastSchedule == null) return false\n            if (lastRunDt == null) return true\n\n            return lastRunDt.isBefore(lastSchedule)\n        } catch (Throwable t) {\n            logger.error(\"Error processing Cron Expression ${cronExpression} and Last Run ${lastRun} for ${description}, skipping\", t)\n            return false\n        }\n    }\n\n    static String getCronDescription(String cronExpression, Locale locale, boolean handleInvalid) {\n        if (cronExpression == null || cronExpression.isEmpty()) return null\n        if (locale == null) locale = Locale.US\n        try {\n            return CronDescriptor.instance(locale).describe(getCron(cronExpression))\n        } catch (Exception e) {\n            if (handleInvalid) {\n                return \"Invalid cron '${cronExpression}': ${e.message}\"\n            } else {\n                throw e\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/ServiceCallAsyncImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.service\n\nimport groovy.transform.CompileStatic\nimport org.moqui.Moqui\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.service.ServiceCallAsync\nimport org.moqui.service.ServiceException\nimport org.moqui.impl.context.ExecutionContextImpl\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.util.concurrent.Callable\nimport java.util.concurrent.Future\n\n@CompileStatic\nclass ServiceCallAsyncImpl extends ServiceCallImpl implements ServiceCallAsync {\n    protected final static Logger logger = LoggerFactory.getLogger(ServiceCallAsyncImpl.class)\n\n    protected boolean distribute = false\n\n    ServiceCallAsyncImpl(ServiceFacadeImpl sfi) {\n        super(sfi)\n    }\n\n    @Override\n    ServiceCallAsync name(String serviceName) { serviceNameInternal(serviceName); return this }\n    @Override\n    ServiceCallAsync name(String v, String n) { serviceNameInternal(null, v, n); return this }\n    @Override\n    ServiceCallAsync name(String p, String v, String n) { serviceNameInternal(p, v, n); return this }\n\n    @Override\n    ServiceCallAsync parameters(Map<String, Object> map) { parameters.putAll(map); return this }\n    @Override\n    ServiceCallAsync parameter(String name, Object value) { parameters.put(name, value); return this }\n\n    @Override\n    ServiceCallAsync distribute(boolean dist) { this.distribute = dist; return this }\n\n    @Override\n    void call() {\n        ExecutionContextFactoryImpl ecfi = sfi.ecfi\n        ExecutionContextImpl eci = ecfi.getEci()\n        validateCall(eci)\n\n        AsyncServiceRunnable runnable = new AsyncServiceRunnable(eci, serviceName, parameters)\n        if (distribute && sfi.distributedExecutorService != null) {\n            sfi.distributedExecutorService.execute(runnable)\n        } else {\n            ecfi.workerPool.execute(runnable)\n        }\n    }\n\n    @Override\n    Future<Map<String, Object>> callFuture() throws ServiceException {\n        ExecutionContextFactoryImpl ecfi = sfi.ecfi\n        ExecutionContextImpl eci = ecfi.getEci()\n        validateCall(eci)\n\n        AsyncServiceCallable callable = new AsyncServiceCallable(eci, serviceName, parameters)\n        if (distribute && sfi.distributedExecutorService != null) {\n            return sfi.distributedExecutorService.submit(callable)\n        } else {\n            return ecfi.workerPool.submit(callable)\n        }\n    }\n\n    @Override\n    Runnable getRunnable() {\n        return new AsyncServiceRunnable(sfi.ecfi.getEci(), serviceName, parameters)\n    }\n\n    @Override\n    Callable<Map<String, Object>> getCallable() {\n        return new AsyncServiceCallable(sfi.ecfi.getEci(), serviceName, parameters)\n    }\n\n    static class AsyncServiceInfo implements Externalizable {\n        transient ExecutionContextFactoryImpl ecfiLocal\n        String threadUsername\n        String serviceName\n        Map<String, Object> parameters\n\n        AsyncServiceInfo() { }\n        AsyncServiceInfo(ExecutionContextImpl eci, String serviceName, Map<String, Object> parameters) {\n            ecfiLocal = eci.ecfi\n            threadUsername = eci.userFacade.username\n            this.serviceName = serviceName\n            this.parameters = new HashMap<>(parameters)\n        }\n        AsyncServiceInfo(ExecutionContextFactoryImpl ecfi, String username, String serviceName, Map<String, Object> parameters) {\n            ecfiLocal = ecfi\n            threadUsername = username\n            this.serviceName = serviceName\n            this.parameters = new HashMap<>(parameters)\n        }\n\n        @Override\n        void writeExternal(ObjectOutput out) throws IOException {\n            out.writeObject(threadUsername) // might be null\n            out.writeUTF(serviceName) // never null\n            out.writeObject(parameters)\n        }\n\n        @Override\n        void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException {\n            threadUsername = (String) objectInput.readObject()\n            serviceName = objectInput.readUTF()\n            parameters = (Map<String, Object>) objectInput.readObject()\n        }\n\n        ExecutionContextFactoryImpl getEcfi() {\n            if (ecfiLocal == null) ecfiLocal = (ExecutionContextFactoryImpl) Moqui.getExecutionContextFactory()\n            return ecfiLocal\n        }\n\n        Map<String, Object> runInternal() throws Exception {\n            return runInternal(null, false)\n        }\n        Map<String, Object> runInternal(Map<String, Object> parameters, boolean skipEcCheck) throws Exception {\n            ExecutionContextImpl threadEci = (ExecutionContextImpl) null\n            try {\n                // check for active Transaction\n                if (getEcfi().transactionFacade.isTransactionInPlace()) {\n                    logger.error(\"In ServiceCallAsync service ${serviceName} a transaction is in place for thread ${Thread.currentThread().getName()}, trying to commit\")\n                    try {\n                        getEcfi().transactionFacade.destroyAllInThread()\n                    } catch (Exception e) {\n                        logger.error(\"ServiceCallAsync commit in place transaction failed for thread ${Thread.currentThread().getName()}\", e)\n                    }\n                }\n                // check for active ExecutionContext\n                if (!skipEcCheck) {\n                    ExecutionContextImpl activeEc = getEcfi().activeContext.get()\n                    if (activeEc != null) {\n                        logger.error(\"In ServiceCallAsync service ${serviceName} there is already an ExecutionContext for user ${activeEc.user.username} (from ${activeEc.forThreadId}:${activeEc.forThreadName}) in this thread ${Thread.currentThread().id}:${Thread.currentThread().name}, destroying\")\n                        try {\n                            activeEc.destroy()\n                        } catch (Throwable t) {\n                            logger.error(\"Error destroying ExecutionContext already in place in ServiceCallAsync in thread ${Thread.currentThread().id}:${Thread.currentThread().name}\", t)\n                        }\n                    }\n                }\n\n                threadEci = getEcfi().getEci()\n                if (threadUsername != null && threadUsername.length() > 0) {\n                    threadEci.userFacade.internalLoginUser(threadUsername, false)\n                } else {\n                    threadEci.userFacade.loginAnonymousIfNoUser()\n                }\n\n                Map<String, Object> parmsToUse = this.parameters\n                if (parameters != null) {\n                    parmsToUse = new HashMap<>(this.parameters)\n                    parmsToUse.putAll(parameters)\n                }\n\n                // NOTE: authz is disabled because authz is checked before queueing\n                Map<String, Object> result = threadEci.serviceFacade.sync().name(serviceName).parameters(parmsToUse).disableAuthz().call()\n                return result\n            } catch (Throwable t) {\n                logger.error(\"Error in async service\", t)\n                throw t\n            } finally {\n                if (threadEci != null) threadEci.destroy()\n            }\n        }\n    }\n\n    static class AsyncServiceRunnable extends AsyncServiceInfo implements Runnable, Externalizable {\n        AsyncServiceRunnable() { super() }\n        AsyncServiceRunnable(ExecutionContextImpl eci, String serviceName, Map<String, Object> parameters) {\n            super(eci, serviceName, parameters)\n        }\n        @Override void run() { runInternal() }\n    }\n\n\n    static class AsyncServiceCallable extends AsyncServiceInfo implements Callable<Map<String, Object>>, Externalizable {\n        AsyncServiceCallable() { super() }\n        AsyncServiceCallable(ExecutionContextImpl eci, String serviceName, Map<String, Object> parameters) {\n            super(eci, serviceName, parameters)\n        }\n        @Override Map<String, Object> call() throws Exception { return runInternal() }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/ServiceCallImpl.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.service;\n\nimport org.moqui.context.ArtifactExecutionInfo;\nimport org.moqui.impl.context.ArtifactExecutionInfoImpl;\nimport org.moqui.impl.context.ExecutionContextImpl;\nimport org.moqui.service.ServiceCall;\nimport org.moqui.service.ServiceException;\n\nimport java.util.HashMap;\nimport java.util.Map;\n\npublic class ServiceCallImpl implements ServiceCall {\n    protected final ServiceFacadeImpl sfi;\n    protected String path = null;\n    protected String verb = null;\n    protected String noun = null;\n    protected ServiceDefinition sd = null;\n    protected boolean noSd = false;\n    protected String serviceName = null;\n    protected String serviceNameNoHash = null;\n    protected Map<String, Object> parameters = new HashMap<>();\n\n    public ServiceCallImpl(ServiceFacadeImpl sfi) { this.sfi = sfi; }\n\n    protected void serviceNameInternal(String serviceName) {\n        if (serviceName == null || serviceName.isEmpty()) throw new ServiceException(\"Service name cannot be empty\");\n        sd = sfi.getServiceDefinition(serviceName);\n        if (sd != null) {\n            path = sd.verb;\n            verb = sd.verb;\n            noun = sd.verb;\n            this.serviceName = sd.serviceName;\n            serviceNameNoHash = sd.serviceNameNoHash;\n        } else {\n            path = ServiceDefinition.getPathFromName(serviceName);\n            verb = ServiceDefinition.getVerbFromName(serviceName);\n            noun = ServiceDefinition.getNounFromName(serviceName);\n            // if the service is not found must be an entity auto, but if there is a path then error\n            if (path == null || path.isEmpty()) {\n                noSd = true;\n            } else {\n                throw new ServiceException(\"Service not found with name \" + serviceName);\n            }\n            this.serviceName = serviceName;\n            serviceNameNoHash = serviceName.replace(\"#\", \"\");\n        }\n    }\n\n    protected void serviceNameInternal(String path, String verb, String noun) {\n        if (path == null || path.isEmpty()) {\n            noSd = true;\n        } else {\n            this.path = path;\n        }\n        this.verb = verb;\n        this.noun = noun;\n        StringBuilder sb = new StringBuilder();\n        if (!noSd) sb.append(path).append('.');\n        sb.append(verb);\n        if (noun != null && !noun.isEmpty()) sb.append('#').append(noun);\n        serviceName = sb.toString();\n        if (noSd) {\n            serviceNameNoHash = serviceName.replace(\"#\", \"\");\n        } else {\n            sd = sfi.getServiceDefinition(serviceName);\n            if (sd == null) throw new ServiceException(\"Service not found with name \" + serviceName + \" (path: \" + path + \", verb: \" + verb + \", noun: \" + noun + \")\");\n            serviceNameNoHash = sd.serviceNameNoHash;\n        }\n    }\n\n    @Override\n    public String getServiceName() { return serviceName; }\n\n    @Override\n    public Map<String, Object> getCurrentParameters() {\n        return parameters;\n    }\n\n    public ServiceDefinition getServiceDefinition() {\n        // this should now never happen, sd now always set on name set\n        // if (sd == null && !noSd) sd = sfi.getServiceDefinition(serviceName);\n        return sd;\n    }\n\n    public boolean isEntityAutoPattern() {\n        return noSd;\n        // return sfi.isEntityAutoPattern(path, verb, noun);\n    }\n\n    public void validateCall(ExecutionContextImpl eci) {\n        // Before scheduling the service check a few basic things so they show up sooner than later:\n        ServiceDefinition sd = sfi.getServiceDefinition(getServiceName());\n        if (sd == null && !isEntityAutoPattern())\n            throw new ServiceException(\"Could not find service with name [\" + getServiceName() + \"]\");\n\n        if (sd != null) {\n            String serviceType = sd.serviceType;\n            if (serviceType == null || serviceType.isEmpty()) serviceType = \"inline\";\n            if (\"interface\".equals(serviceType)) throw new ServiceException(\"Cannot run interface service [\" + getServiceName() + \"]\");\n            ServiceRunner sr = sfi.getServiceRunner(serviceType);\n            if (sr == null) throw new ServiceException(\"Could not find service runner for type [\" + serviceType + \"] for service [\" + getServiceName() + \"]\");\n            // validation\n            parameters = sd.convertValidateCleanParameters(parameters, eci);\n            // if error(s) in parameters, return now with no results\n            if (eci.getMessage().hasError()) return;\n        }\n\n\n        // always do an authz before scheduling the job\n        ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(getServiceName(), ArtifactExecutionInfo.AT_SERVICE, ServiceDefinition.getVerbAuthzActionEnum(verb), null);\n        aei.setTrackArtifactHit(false);\n        eci.artifactExecutionFacade.pushInternal(aei, (sd != null && \"true\".equals(sd.authenticate)), true);\n        // pop immediately, just did the push to to an authz\n        eci.artifactExecutionFacade.pop(aei);\n\n        parameters.put(\"authUsername\", eci.userFacade.getUsername());\n\n        // logger.warn(\"=========== async call ${serviceName}, parameters: ${parameters}\")\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/ServiceCallJobImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.service\n\nimport groovy.json.JsonOutput\nimport groovy.transform.CompileStatic\nimport org.moqui.BaseArtifactException\nimport org.moqui.Moqui\nimport org.moqui.context.NotificationMessage\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.service.ServiceCallJob\nimport org.moqui.service.ServiceCallSync\nimport org.moqui.service.ServiceException\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.sql.Timestamp\nimport java.util.concurrent.Callable\nimport java.util.concurrent.ExecutionException\nimport java.util.concurrent.Future\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.TimeoutException\n\n@CompileStatic\nclass ServiceCallJobImpl extends ServiceCallImpl implements ServiceCallJob {\n    protected final static Logger logger = LoggerFactory.getLogger(ServiceCallJobImpl.class)\n\n    private String jobName\n    private EntityValue serviceJob\n    private Future<Map<String, Object>> runFuture = (Future) null\n    private String withJobRunId = (String) null\n    private Timestamp lastRunTime = (Timestamp) null\n    private boolean clearLock = false\n    private boolean localOnly = false\n\n    ServiceCallJobImpl(String jobName, ServiceFacadeImpl sfi) {\n        super(sfi)\n        ExecutionContextImpl eci = sfi.ecfi.getEci()\n\n        // get ServiceJob, make sure exists\n        this.jobName = jobName\n        serviceJob = eci.entityFacade.fastFindOne(\"moqui.service.job.ServiceJob\", true, true, jobName)\n        if (serviceJob == null) throw new BaseArtifactException(\"No ServiceJob record found for jobName ${jobName}\")\n\n        // set ServiceJobParameter values\n        EntityList serviceJobParameters = eci.entity.find(\"moqui.service.job.ServiceJobParameter\")\n                .condition(\"jobName\", jobName).useCache(true).disableAuthz().list()\n        for (EntityValue serviceJobParameter in serviceJobParameters)\n            parameters.put((String) serviceJobParameter.parameterName, serviceJobParameter.parameterValue)\n\n        // set the serviceName so rest of ServiceCallImpl works\n        serviceNameInternal((String) serviceJob.serviceName)\n    }\n\n    @Override ServiceCallJob parameters(Map<String, Object> map) { parameters.putAll(map); return this }\n    @Override ServiceCallJob parameter(String name, Object value) { parameters.put(name, value); return this }\n    @Override ServiceCallJob localOnly(boolean local) { localOnly = local; return this }\n\n    ServiceCallJobImpl withJobRunId(String jobRunId) { withJobRunId = jobRunId; return this }\n    ServiceCallJobImpl withLastRunTime(Timestamp lastRunTime) { this.lastRunTime = lastRunTime; return this }\n    ServiceCallJobImpl clearLock() { clearLock = true; return this }\n\n    @Override\n    String run() throws ServiceException {\n        ExecutionContextFactoryImpl ecfi = sfi.ecfi\n        ExecutionContextImpl eci = ecfi.getEci()\n        validateCall(eci)\n\n        String jobRunId\n        if (withJobRunId == null) {\n            // create the ServiceJobRun record\n            String parametersString = JsonOutput.toJson(parameters)\n            Map jobRunResult = ecfi.service.sync().name(\"create\", \"moqui.service.job.ServiceJobRun\")\n                    .parameters([jobName:jobName, userId:eci.user.userId, parameters:parametersString] as Map<String, Object>)\n                    .disableAuthz().requireNewTransaction(true).call()\n            jobRunId = jobRunResult.jobRunId\n        } else {\n            jobRunId = withJobRunId\n        }\n\n        // run it\n        ServiceJobCallable callable = new ServiceJobCallable(eci, serviceJob, jobRunId, lastRunTime, clearLock, parameters)\n        if (sfi.distributedExecutorService == null || localOnly || \"Y\".equals(serviceJob.localOnly)) {\n            runFuture = sfi.jobWorkerPool.submit(callable)\n        } else {\n            runFuture = sfi.distributedExecutorService.submit(callable)\n        }\n\n        return jobRunId\n    }\n\n    @Override\n    boolean cancel(boolean mayInterruptIfRunning) {\n        if (runFuture == null) throw new IllegalStateException(\"Must call run() before using Future interface methods\")\n        return runFuture.cancel(mayInterruptIfRunning)\n    }\n    @Override\n    boolean isCancelled() {\n        if (runFuture == null) throw new IllegalStateException(\"Must call run() before using Future interface methods\")\n        return runFuture.isCancelled()\n    }\n    @Override\n    boolean isDone() {\n        if (runFuture == null) throw new IllegalStateException(\"Must call run() before using Future interface methods\")\n        return runFuture.isDone()\n    }\n    @Override\n    Map<String, Object> get() throws InterruptedException, ExecutionException {\n        if (runFuture == null) throw new IllegalStateException(\"Must call run() before using Future interface methods\")\n        return runFuture.get()\n    }\n    @Override\n    Map<String, Object> get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException {\n        if (runFuture == null) throw new IllegalStateException(\"Must call run() before using Future interface methods\")\n        return runFuture.get(timeout, unit)\n    }\n\n    static class ServiceJobCallable implements Callable<Map<String, Object>>, Externalizable {\n        transient ExecutionContextFactoryImpl ecfi\n        String threadUsername, currentUserId\n        String jobName, jobDescription, serviceName, topic, jobRunId\n        Map<String, Object> parameters\n        Timestamp lastRunTime = (Timestamp) null\n        boolean clearLock\n        int transactionTimeout\n\n        // default constructor for deserialization only!\n        ServiceJobCallable() { }\n\n        ServiceJobCallable(ExecutionContextImpl eci, Map<String, Object> serviceJob, String jobRunId, Timestamp lastRunTime,\n                           boolean clearLock, Map<String, Object> parameters) {\n            ecfi = eci.ecfi\n            threadUsername = eci.userFacade.username\n            currentUserId = eci.userFacade.userId\n            jobName = (String) serviceJob.jobName\n            jobDescription = (String) serviceJob.description\n            serviceName = (String) serviceJob.serviceName\n            topic = (String) serviceJob.topic\n            transactionTimeout = (serviceJob.transactionTimeout ?: 1800) as int\n            this.jobRunId = jobRunId\n            this.lastRunTime = lastRunTime\n            this.clearLock = clearLock\n            this.parameters = new HashMap<>(parameters)\n        }\n\n        @Override\n        void writeExternal(ObjectOutput out) throws IOException {\n            out.writeObject(threadUsername) // might be null\n            out.writeObject(currentUserId) // might be null\n            out.writeUTF(jobName) // never null\n            out.writeObject(jobDescription) // might be null\n            out.writeUTF(serviceName) // never null\n            out.writeObject(topic) // might be null\n            out.writeUTF(jobRunId) // never null\n            out.writeObject(lastRunTime) // might be null\n            out.writeBoolean(clearLock)\n            out.writeInt(transactionTimeout)\n            out.writeObject(parameters)\n        }\n        @Override\n        void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException {\n            threadUsername = (String) objectInput.readObject()\n            currentUserId = (String) objectInput.readObject()\n            jobName = objectInput.readUTF()\n            jobDescription = objectInput.readObject()\n            serviceName = objectInput.readUTF()\n            topic = (String) objectInput.readObject()\n            jobRunId = objectInput.readUTF()\n            lastRunTime = (Timestamp) objectInput.readObject()\n            clearLock = objectInput.readBoolean()\n            transactionTimeout = objectInput.readInt()\n            parameters = (Map<String, Object>) objectInput.readObject()\n        }\n\n        ExecutionContextFactoryImpl getEcfi() {\n            if (ecfi == null) ecfi = (ExecutionContextFactoryImpl) Moqui.getExecutionContextFactory()\n            return ecfi\n        }\n\n        @Override\n        Map<String, Object> call() throws Exception {\n            ExecutionContextImpl threadEci = (ExecutionContextImpl) null\n            try {\n                ExecutionContextFactoryImpl ecfi = getEcfi()\n                if (ecfi == null) {\n                    String errMsg = \"ExecutionContextFactory not initialized, cannot call service job ${jobName} with run ID ${jobRunId}\"\n                    logger.error(errMsg)\n                    throw new IllegalStateException(errMsg)\n                }\n\n                // check for active Transaction\n                if (ecfi.transactionFacade.isTransactionInPlace()) {\n                    logger.error(\"In ServiceCallJob ${jobName} service ${serviceName} a transaction is in place for thread ${Thread.currentThread().getName()}, trying to commit\")\n                    try {\n                        ecfi.transactionFacade.destroyAllInThread()\n                    } catch (Exception e) {\n                        logger.error(\"ServiceCallJob commit in place transaction failed for thread ${Thread.currentThread().getName()}\", e)\n                    }\n                }\n                // check for active ExecutionContext\n                ExecutionContextImpl activeEc = ecfi.activeContext.get()\n                if (activeEc != null) {\n                    logger.error(\"In ServiceCallJob ${jobName} service ${serviceName} there is already an ExecutionContext for user ${activeEc.user.username} (from ${activeEc.forThreadId}:${activeEc.forThreadName}) in this thread ${Thread.currentThread().id}:${Thread.currentThread().name}, destroying\")\n                    try {\n                        activeEc.destroy()\n                    } catch (Throwable t) {\n                        logger.error(\"Error destroying ExecutionContext already in place in ServiceCallJob in thread ${Thread.currentThread().id}:${Thread.currentThread().name}\", t)\n                    }\n                }\n\n                // get a fresh ExecutionContext\n                threadEci = ecfi.getEci()\n                if (threadUsername != null && threadUsername.length() > 0)\n                    threadEci.userFacade.internalLoginUser(threadUsername, false)\n\n                // set hostAddress, hostName, runThread, startTime on ServiceJobRun\n                InetAddress localHost = ecfi.getLocalhostAddress()\n                // NOTE: no need to run async or separate thread, is in separate TX because no wrapping TX for these service calls\n                ecfi.serviceFacade.sync().name(\"update\", \"moqui.service.job.ServiceJobRun\")\n                        .parameters([jobRunId:jobRunId, hostAddress:(localHost?.getHostAddress() ?: '127.0.0.1'),\n                            hostName:(localHost?.getHostName() ?: 'localhost'), runThread:Thread.currentThread().getName(),\n                            startTime:threadEci.user.nowTimestamp] as Map<String, Object>)\n                        .disableAuthz().call()\n\n                if (lastRunTime != (Object) null) parameters.put(\"lastRunTime\", lastRunTime)\n\n                // NOTE: authz is disabled because authz is checked before queueing\n                Map<String, Object> results = new HashMap<>()\n                try {\n                    results = ecfi.serviceFacade.sync().name(serviceName).parameters(parameters)\n                            .transactionTimeout(transactionTimeout).disableAuthz().call()\n                } catch (Throwable t) {\n                    logger.error(\"Error in service job call\", t)\n                    threadEci.messageFacade.addError(t.toString())\n                }\n\n                // set endTime, results, messages, errors on ServiceJobRun\n                String resultString = (String) null\n                if (results != null) {\n                    if (results.containsKey(null)) {\n                        logger.warn(\"Service Job ${jobName} results has a null key with value ${results.get(null)}, removing\")\n                        results.remove(null)\n                    }\n                    try {\n                        resultString = JsonOutput.toJson(results)\n                    } catch (Exception e) {\n                        logger.warn(\"Error writing JSON for Service Job ${jobName} results: ${e.toString()}\\n${results}\")\n                    }\n                }\n\n                boolean hasError = threadEci.messageFacade.hasError()\n                String messages = threadEci.messageFacade.getMessagesString()\n                if (messages != null && messages.length() > 4000) messages = messages.substring(0, 4000)\n                String errors = hasError ? threadEci.messageFacade.getErrorsString() : null\n                if (errors != null && errors.length() > 4000) errors = errors.substring(0, 4000)\n                Timestamp nowTimestamp = threadEci.userFacade.nowTimestamp\n\n                // before calling other services clear out errors or they won't run\n                if (hasError) threadEci.messageFacade.clearErrors()\n\n                // clear the ServiceJobRunLock if there is one\n                if (clearLock) {\n                    ServiceCallSync scs = ecfi.serviceFacade.sync().name(\"update\", \"moqui.service.job.ServiceJobRunLock\")\n                            .parameter(\"jobName\", jobName).parameter(\"jobRunId\", null)\n                            .disableAuthz()\n                    // if there was an error set lastRunTime to previous\n                    if (hasError) scs.parameter(\"lastRunTime\", lastRunTime)\n                    scs.call()\n                }\n\n                // NOTE: no need to run async or separate thread, is in separate TX because no wrapping TX for these service calls\n                ecfi.serviceFacade.sync().name(\"update\", \"moqui.service.job.ServiceJobRun\")\n                        .parameters([jobRunId:jobRunId, endTime:nowTimestamp, results:resultString,\n                            messages:messages, hasError:(hasError ? 'Y' : 'N'), errors:errors] as Map<String, Object>)\n                        .disableAuthz().call()\n\n                // notifications\n                Map<String, Object> msgMap = (Map<String, Object>) null\n                EntityList serviceJobUsers = (EntityList) null\n                if (topic || hasError) {\n                    msgMap = new HashMap<>()\n                    msgMap.put(\"serviceCallRun\", [jobName:jobName, description:jobDescription, jobRunId:jobRunId,\n                          endTime:nowTimestamp, messages:messages, hasError:hasError, errors:errors])\n                    msgMap.put(\"parameters\", parameters)\n                    msgMap.put(\"results\", results)\n\n                    serviceJobUsers = threadEci.entityFacade.find(\"moqui.service.job.ServiceJobUser\")\n                            .condition(\"jobName\", jobName).useCache(true).disableAuthz().list()\n                }\n\n                // if topic send NotificationMessage\n                if (topic) {\n                    NotificationMessage nm = threadEci.makeNotificationMessage().topic(topic)\n                    nm.message(msgMap)\n\n                    if (currentUserId) nm.userId(currentUserId)\n                    for (EntityValue serviceJobUser in serviceJobUsers)\n                        if (serviceJobUser.receiveNotifications != 'N') nm.userId((String) serviceJobUser.userId)\n\n                    nm.type(hasError ? NotificationMessage.danger : NotificationMessage.success)\n                    nm.send()\n                }\n\n                // if hasError send general error notification\n                if (hasError) {\n                    NotificationMessage nm = threadEci.makeNotificationMessage().topic(\"ServiceJobError\")\n                            .type(NotificationMessage.danger)\n                            .title('''Job Error ${serviceCallRun.jobName?:''} [${serviceCallRun.jobRunId?:''}] ${serviceCallRun.errors?:'N/A'}''')\n                            .message(msgMap)\n\n                    if (currentUserId) nm.userId(currentUserId)\n                    for (EntityValue serviceJobUser in serviceJobUsers)\n                        if (serviceJobUser.receiveNotifications != 'N') nm.userId((String) serviceJobUser.userId)\n\n                    nm.send()\n                }\n\n                return results\n            } catch (Throwable t) {\n                logger.error(\"Error in service job handling\", t)\n                // better to not throw? seems to cause issue with scheduler: throw t\n                return null\n            } finally {\n                if (threadEci != null) threadEci.destroy()\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/ServiceCallSpecialImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.service\n\nimport groovy.transform.CompileStatic\nimport org.moqui.BaseArtifactException\nimport org.moqui.service.ServiceException\n\nimport jakarta.transaction.Status\nimport jakarta.transaction.Synchronization\nimport jakarta.transaction.Transaction\nimport jakarta.transaction.TransactionManager\nimport javax.transaction.xa.XAException\n\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.service.ServiceCallSpecial\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n@CompileStatic\nclass ServiceCallSpecialImpl extends ServiceCallImpl implements ServiceCallSpecial {\n\n    ServiceCallSpecialImpl(ServiceFacadeImpl sfi) {\n        super(sfi)\n    }\n\n    @Override\n    ServiceCallSpecial name(String serviceName) { serviceNameInternal(serviceName); return this }\n    @Override\n    ServiceCallSpecial name(String v, String n) { serviceNameInternal(null, v, n); return this }\n    @Override\n    ServiceCallSpecial name(String p, String v, String n) { serviceNameInternal(p, v, n); return this }\n\n    @Override\n    ServiceCallSpecial parameters(Map<String, Object> map) { parameters.putAll(map); return this }\n    @Override\n    ServiceCallSpecial parameter(String name, Object value) { parameters.put(name, value); return this }\n\n    @Override\n    void registerOnCommit() {\n        if (getServiceDefinition() == null && !isEntityAutoPattern()) throw new ServiceException(\"Could not find service with name [${getServiceName()}]\")\n\n        ServiceSynchronization sxr = new ServiceSynchronization(this, sfi.ecfi, true)\n        sxr.enlist()\n    }\n\n    @Override\n    void registerOnRollback() {\n        if (getServiceDefinition() == null && !isEntityAutoPattern()) throw new ServiceException(\"Could not find service with name [${getServiceName()}]\")\n\n        ServiceSynchronization sxr = new ServiceSynchronization(this, sfi.ecfi, false)\n        sxr.enlist()\n    }\n\n    static class ServiceSynchronization implements Synchronization {\n        protected final static Logger logger = LoggerFactory.getLogger(ServiceSynchronization.class)\n\n        protected ExecutionContextFactoryImpl ecfi\n        protected String serviceName\n        protected Map<String, Object> parameters\n        protected boolean runOnCommit\n\n        protected Transaction tx = null\n\n        ServiceSynchronization(ServiceCallSpecialImpl scsi, ExecutionContextFactoryImpl ecfi, boolean runOnCommit) {\n            this.ecfi = ecfi\n            this.serviceName = scsi.getServiceName()\n            this.parameters = new HashMap(scsi.parameters)\n            this.runOnCommit = runOnCommit\n        }\n\n        void enlist() {\n            TransactionManager tm = ecfi.transactionFacade.getTransactionManager()\n            if (tm == null && tm.getStatus() != Status.STATUS_ACTIVE) throw new XAException(\"Cannot enlist: no transaction manager or transaction not active\")\n\n            Transaction tx = tm.getTransaction();\n            if (tx == null) throw new XAException(XAException.XAER_NOTA)\n\n            this.tx = tx\n            tx.registerSynchronization(this)\n        }\n\n        @Override\n        void beforeCompletion() { }\n\n        @Override\n        void afterCompletion(int status) {\n            if (status == Status.STATUS_COMMITTED) {\n                if (runOnCommit) ecfi.serviceFacade.async().name(this.serviceName).parameters(this.parameters).call()\n            } else {\n                if (!runOnCommit) ecfi.serviceFacade.async().name(this.serviceName).parameters(this.parameters).call()\n            }\n        }\n\n        /* Old XAResource (and Thread) approach:\n\n        protected Xid xid = null\n        protected Integer timeout = null\n        protected boolean active = false\n        protected boolean suspended = false\n\n        @Override\n        void start(Xid xid, int flag) throws XAException {\n            if (this.active) {\n                if (this.xid != null && this.xid.equals(xid)) {\n                    throw new XAException(XAException.XAER_DUPID);\n                } else {\n                    throw new XAException(XAException.XAER_PROTO);\n                }\n            }\n            if (this.xid != null && !this.xid.equals(xid)) throw new XAException(XAException.XAER_NOTA)\n\n            this.active = true\n            this.suspended = false\n            this.xid = xid\n\n            // start a thread with this object to do something on timeout\n            this.setName(\"ServiceSynchronizationThread\")\n            this.setDaemon(true)\n            this.start()\n        }\n\n        @Override\n        void end(Xid xid, int flag) throws XAException {\n            if (this.xid == null || !this.xid.equals(xid)) throw new XAException(XAException.XAER_NOTA)\n            if (flag == TMSUSPEND) {\n                if (!this.active) throw new XAException(XAException.XAER_PROTO)\n                this.suspended = true\n            }\n            if (flag == TMSUCCESS || flag == TMFAIL) {\n                // allow a success/fail end if TX is suspended without a resume flagged start first\n                if (!this.active && !this.suspended) throw new XAException(XAException.XAER_PROTO)\n            }\n            this.active = false\n        }\n\n        @Override\n        void forget(Xid xid) throws XAException {\n            if (this.xid == null || !this.xid.equals(xid)) throw new XAException(XAException.XAER_NOTA)\n            this.xid = null\n            if (active) logger.warn(\"forget() called without end()\")\n        }\n\n        @Override\n        int prepare(Xid xid) throws XAException {\n            if (this.xid == null || !this.xid.equals(xid)) throw new XAException(XAException.XAER_NOTA)\n            return XA_OK\n        }\n\n        @Override\n        Xid[] recover(int flag) throws XAException { return this.xid != null ? [this.xid] : [] }\n        @Override\n        boolean isSameRM(XAResource xaResource) throws XAException { return xaResource == this }\n        @Override\n        int getTransactionTimeout() throws XAException { return this.timeout == null ? 0 : this.timeout }\n        @Override\n        boolean setTransactionTimeout(int seconds) throws XAException {\n            this.timeout = (seconds == 0 ? null : seconds)\n            return true\n        }\n\n        @Override\n        void commit(Xid xid, boolean onePhase) throws XAException {\n            if (this.active) logger.warn(\"commit() called without end()\")\n            if (this.xid == null || !this.xid.equals(xid)) throw new XAException(XAException.XAER_NOTA)\n\n            if (runOnCommit) ecfi.serviceFacade.async().name(this.serviceName).parameters(this.parameters).call()\n\n            this.xid = null\n            this.active = false\n        }\n\n        @Override\n        void rollback(Xid xid) throws XAException {\n            if (this.active) logger.warn(\"rollback() called without end()\")\n            if (this.xid == null || !this.xid.equals(xid)) throw new XAException(XAException.XAER_NOTA)\n\n            if (!runOnCommit) ecfi.serviceFacade.async().name(this.serviceName).parameters(this.parameters).call()\n\n            this.xid = null\n            this.active = false\n        }\n\n        @Override\n        void run() {\n            try {\n                if (timeout != null) {\n                    // sleep until the transaction times out\n                    sleep(timeout.intValue() * 1000)\n\n                    if (active) {\n                        String statusString = ecfi.transactionFacade.getStatusString()\n                        logger.warn(\"Transaction timeout [${timeout}] status [${statusString}] xid [${this.xid}], service [${serviceName}] did NOT run\")\n\n                        // NOTE: what to do, if anything, when the we timeout and the service hasn't been run?\n                    }\n                }\n            } catch (InterruptedException e) {\n                logger.warn(\"Service Call Special Interrupted\", e)\n            } catch (Throwable t) {\n                logger.warn(\"Service Call Special Error\", t)\n            }\n        }\n        */\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/ServiceCallSyncImpl.java",
    "content": "package org.moqui.impl.service;\n\nimport groovy.lang.Closure;\nimport org.moqui.BaseException;\nimport org.moqui.context.*;\nimport org.moqui.entity.EntityValue;\nimport org.moqui.impl.context.*;\nimport org.moqui.impl.entity.EntityDefinition;\nimport org.moqui.impl.entity.EntitySqlException;\nimport org.moqui.impl.service.runner.EntityAutoServiceRunner;\nimport org.moqui.service.ServiceCallSync;\nimport org.moqui.service.ServiceException;\nimport org.moqui.util.ObjectUtilities;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport jakarta.transaction.Status;\nimport java.sql.Timestamp;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.Map;\nimport java.util.concurrent.atomic.AtomicBoolean;\n\npublic class ServiceCallSyncImpl extends ServiceCallImpl implements ServiceCallSync {\n    private static final Logger logger = LoggerFactory.getLogger(ServiceCallSyncImpl.class);\n    private static final boolean traceEnabled = logger.isTraceEnabled();\n\n    private boolean ignoreTransaction = false;\n    private boolean requireNewTransaction = false;\n    private Boolean useTransactionCache = null;\n    private Integer transactionTimeout = null;\n    private boolean ignorePreviousError = false;\n    private boolean softValidate = false;\n    private boolean multi = false;\n    private boolean rememberParameters = true;\n    protected boolean disableAuthz = false;\n\n    public ServiceCallSyncImpl(ServiceFacadeImpl sfi) { super(sfi); }\n\n    @Override public ServiceCallSync name(String serviceName) { serviceNameInternal(serviceName); return this; }\n    @Override public ServiceCallSync name(String v, String n) { serviceNameInternal(null, v, n); return this; }\n    @Override public ServiceCallSync name(String p, String v, String n) { serviceNameInternal(p, v, n); return this; }\n\n    @Override public ServiceCallSync parameters(Map<String, ?> map) { if (map != null) parameters.putAll(map); return this; }\n    @Override public ServiceCallSync parameter(String name, Object value) { parameters.put(name, value); return this; }\n\n    @Override public ServiceCallSync ignoreTransaction(boolean it) { this.ignoreTransaction = it; return this; }\n    @Override public ServiceCallSync requireNewTransaction(boolean rnt) { this.requireNewTransaction = rnt; return this; }\n    @Override public ServiceCallSync useTransactionCache(boolean utc) { this.useTransactionCache = utc; return this; }\n    @Override public ServiceCallSync transactionTimeout(int timeout) { this.transactionTimeout = timeout; return this; }\n\n    @Override public ServiceCallSync ignorePreviousError(boolean ipe) { this.ignorePreviousError = ipe; return this; }\n    @Override public ServiceCallSync softValidate(boolean sv) { this.softValidate = sv; return this; }\n    @Override public ServiceCallSync multi(boolean mlt) { this.multi = mlt; return this; }\n    @Override public ServiceCallSync disableAuthz() { disableAuthz = true; return this; }\n    @Override public ServiceCallSync noRememberParameters() { rememberParameters = false; return this; }\n\n    @Override\n    public Map<String, Object> call() {\n        ExecutionContextFactoryImpl ecfi = sfi.ecfi;\n        ExecutionContextImpl eci = ecfi.getEci();\n\n        boolean enableAuthz = disableAuthz && !eci.artifactExecutionFacade.disableAuthz();\n        try {\n            if (multi) {\n                ArrayList<String> inParameterNames = null;\n                if (sd != null) {\n                    inParameterNames = sd.getInParameterNames();\n                } else if (isEntityAutoPattern()) {\n                    EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(noun);\n                    if (ed != null) inParameterNames = ed.getAllFieldNames();\n                }\n\n                int inParameterNamesSize = inParameterNames != null ? inParameterNames.size() : 0;\n                // run all service calls in a single transaction for multi form submits, ie all succeed or fail together\n                boolean beganTransaction = eci.transactionFacade.begin(null);\n                try {\n                    Map<String, Object> result = new HashMap<>();\n                    for (int i = 0; ; i++) {\n                        Map<String, Object> currentParms = new HashMap<>();\n                        for (int paramIndex = 0; paramIndex < inParameterNamesSize; paramIndex++) {\n                            String ipn = inParameterNames.get(paramIndex);\n                            String key = ipn + \"_\" + i;\n                            if (parameters.containsKey(key)) currentParms.put(ipn, parameters.get(key));\n                        }\n\n                        // if the map stayed empty we have no parms, so we're done\n                        if (currentParms.size() == 0) break;\n\n                        if ((\"true\".equals(parameters.get(\"_useRowSubmit\")) || \"true\".equals(parameters.get(\"_useRowSubmit_\" + i)))\n                                && !\"true\".equals(parameters.get(\"_rowSubmit_\" + i))) continue;\n\n                        // now that we have checked the per-row parameters, add in others available\n                        for (int paramIndex = 0; paramIndex < inParameterNamesSize; paramIndex++) {\n                            String ipn = inParameterNames.get(paramIndex);\n                            if (!ObjectUtilities.isEmpty(currentParms.get(ipn))) continue;\n                            if (!ObjectUtilities.isEmpty(parameters.get(ipn))) {\n                                currentParms.put(ipn, parameters.get(ipn));\n                            } else if (!ObjectUtilities.isEmpty(result.get(ipn))) {\n                                currentParms.put(ipn, result.get(ipn));\n                            }\n                        }\n\n                        // call the service\n                        Map<String, Object> singleResult = callSingle(currentParms, sd, eci);\n                        if (singleResult != null) result.putAll(singleResult);\n                        // ... and break if there are any errors\n                        if (eci.messageFacade.hasError()) break;\n                    }\n\n                    return result;\n                } catch (Throwable t) {\n                    eci.transactionFacade.rollback(beganTransaction, \"Uncaught error running service \" + serviceName + \" in multi mode\", t);\n                    throw t;\n                } finally {\n                    if (eci.transactionFacade.isTransactionInPlace()) {\n                        if (eci.messageFacade.hasError()) {\n                            eci.transactionFacade.rollback(beganTransaction, \"Error message found running service \" + serviceName + \" in multi mode\", null);\n                        } else {\n                            eci.transactionFacade.commit(beganTransaction);\n                        }\n                    }\n                }\n            } else {\n                return callSingle(parameters, sd, eci);\n            }\n        } finally {\n            if (enableAuthz) eci.artifactExecutionFacade.enableAuthz();\n        }\n    }\n\n    private Map<String, Object> callSingle(Map<String, Object> currentParameters, ServiceDefinition sd, final ExecutionContextImpl eci) {\n        if (ignorePreviousError) eci.messageFacade.pushErrors();\n        // NOTE: checking this here because service won't generally run after input validation, etc anyway\n        if (eci.messageFacade.hasError()) {\n            logger.warn(\"Found error(s) before service \" + serviceName + \", so not running service. Errors: \" + eci.messageFacade.getErrorsString());\n            return null;\n        }\n\n        TransactionFacadeImpl tf = eci.transactionFacade;\n        int transactionStatus = tf.getStatus();\n        if (!requireNewTransaction && transactionStatus == Status.STATUS_MARKED_ROLLBACK) {\n            logger.warn(\"Transaction marked for rollback, not running service \" + serviceName + \". Errors: [\" + eci.messageFacade.getErrorsString() + \"] Artifact stack: \" + eci.artifactExecutionFacade.getStackNameString());\n            if (ignorePreviousError) {\n                eci.messageFacade.popErrors();\n            } else if (!eci.messageFacade.hasError()) {\n                eci.messageFacade.addError(\"Transaction marked for rollback, not running service \" + serviceName);\n            }\n            return null;\n        }\n\n        if (traceEnabled) logger.trace(\"Calling service \" + serviceName + \" initial input: \" + currentParameters);\n\n        // get these before cleaning up the parameters otherwise will be removed\n        String username = null;\n        String password = null;\n        if (currentParameters.containsKey(\"authUsername\")) {\n            username = (String) currentParameters.get(\"authUsername\");\n            password = (String) currentParameters.get(\"authPassword\");\n        } else if (currentParameters.containsKey(\"authUserAccount\")) {\n            Map authUserAccount = (Map) currentParameters.get(\"authUserAccount\");\n            username = (String) authUserAccount.get(\"username\");\n            if (username == null || username.isEmpty()) username = (String) currentParameters.get(\"authUsername\");\n            password = (String) authUserAccount.get(\"currentPassword\");\n            if (password == null || password.isEmpty()) password = (String) currentParameters.get(\"authPassword\");\n        }\n\n        final String serviceType = sd != null ? sd.serviceType : \"entity-implicit\";\n        ArrayList<ServiceEcaRule> secaRules = sfi.secaRules(serviceNameNoHash);\n        boolean hasSecaRules = secaRules != null && secaRules.size() > 0;\n\n        // in-parameter validation\n        if (hasSecaRules) ServiceFacadeImpl.runSecaRules(serviceNameNoHash, currentParameters, null, \"pre-validate\", secaRules, eci);\n        if (sd != null) {\n            if (softValidate) eci.messageFacade.pushErrors();\n            currentParameters = sd.convertValidateCleanParameters(currentParameters, eci);\n            if (softValidate) {\n                if (eci.messageFacade.hasError()) {\n                    eci.messageFacade.moveErrorsToDangerMessages();\n                    eci.messageFacade.popErrors();\n                    return null;\n                }\n                eci.messageFacade.popErrors();\n            }\n        }\n        // if error(s) in parameters, return now with no results\n        if (eci.messageFacade.hasError()) {\n            StringBuilder errMsg = new StringBuilder(\"Found error(s) when validating input parameters for service \" + serviceName + \", so not running service. Errors: \" + eci.messageFacade.getErrorsString() + \"; the artifact stack is:\\n\");\n            for (ArtifactExecutionInfo stackItem : eci.artifactExecutionFacade.getStack()) {\n                errMsg.append(stackItem.toString()).append(\"\\n\");\n            }\n\n            logger.warn(errMsg.toString());\n            if (ignorePreviousError) eci.messageFacade.popErrors();\n            return null;\n        }\n\n        boolean userLoggedIn = false;\n\n        // always try to login the user if parameters are specified\n        if (username != null && password != null && username.length() > 0 && password.length() > 0) {\n            userLoggedIn = eci.getUser().loginUser(username, password);\n            // if user was not logged in we should already have an error message in place so just return\n            if (!userLoggedIn) return null;\n        }\n\n        if (sd != null && \"true\".equals(sd.authenticate) && eci.userFacade.getUsername() == null && !eci.userFacade.getLoggedInAnonymous()) {\n            if (ignorePreviousError) eci.messageFacade.popErrors();\n            throw new AuthenticationRequiredException(\"User must be logged in to call service \" + serviceName);\n        }\n\n        if (sd == null) {\n            if (sfi.isEntityAutoPattern(path, verb, noun)) {\n                try {\n                    return runImplicitEntityAuto(currentParameters, secaRules, eci);\n                } finally {\n                    if (ignorePreviousError) eci.messageFacade.popErrors();\n                }\n            } else {\n                logger.info(\"No service with name \" + serviceName + \", isEntityAutoPattern=\" + isEntityAutoPattern() +\n                        \", path=\" + path + \", verb=\" + verb + \", noun=\" + noun + \", noun is entity? \" + eci.getEntityFacade().isEntityDefined(noun));\n                if (ignorePreviousError) eci.messageFacade.popErrors();\n                throw new ServiceException(\"Could not find service with name \" + serviceName);\n            }\n        }\n\n        if (\"interface\".equals(serviceType)) {\n            if (ignorePreviousError) eci.messageFacade.popErrors();\n            throw new ServiceException(\"Service \" + serviceName + \" is an interface and cannot be run\");\n        }\n\n        ServiceRunner serviceRunner = sd.serviceRunner;\n        if (serviceRunner == null) {\n            if (ignorePreviousError) eci.messageFacade.popErrors();\n            throw new ServiceException(\"Could not find service runner for type \" + serviceType + \" for service \" + serviceName);\n        }\n\n        // pre authentication and authorization SECA rules\n        if (hasSecaRules) ServiceFacadeImpl.runSecaRules(serviceNameNoHash, currentParameters, null, \"pre-auth\", secaRules, eci);\n\n        // push service call artifact execution, checks authz too\n        // NOTE: don't require authz if the service def doesn't authenticate\n        // NOTE: if no sd then requiresAuthz is false, ie let the authz get handled at the entity level (but still put\n        //     the service on the stack)\n        ArtifactExecutionInfo.AuthzAction authzAction = sd != null ? sd.authzAction : ServiceDefinition.verbAuthzActionEnumMap.get(verb);\n        if (authzAction == null) authzAction = ArtifactExecutionInfo.AUTHZA_ALL;\n        ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(serviceName, ArtifactExecutionInfo.AT_SERVICE, authzAction, serviceType);\n        if (rememberParameters && !sd.noRememberParameters) aei.setParameters(currentParameters);\n        eci.artifactExecutionFacade.pushInternal(aei, (sd != null && \"true\".equals(sd.authenticate)), true);\n\n        // if error in auth or for other reasons, return now with no results\n        if (eci.messageFacade.hasError()) {\n            eci.artifactExecutionFacade.pop(aei);\n            if (ignorePreviousError) eci.messageFacade.popErrors();\n            logger.warn(\"Found error(s) when checking authc for service \" + serviceName + \", so not running service. Errors: \" +\n                    eci.messageFacade.getErrorsString() + \"; the artifact stack is:\\n \" + eci.getArtifactExecution().getStack());\n            return null;\n        }\n\n        // must be done after the artifact execution push so that AEII object to set anonymous authorized is in place\n        boolean loggedInAnonymous = false;\n        if (sd != null && \"anonymous-all\".equals(sd.authenticate)) {\n            eci.artifactExecutionFacade.setAnonymousAuthorizedAll();\n            loggedInAnonymous = eci.userFacade.loginAnonymousIfNoUser();\n        } else if (sd != null && \"anonymous-view\".equals(sd.authenticate)) {\n            eci.artifactExecutionFacade.setAnonymousAuthorizedView();\n            loggedInAnonymous = eci.userFacade.loginAnonymousIfNoUser();\n        }\n\n        // handle sd.serviceNode.\"@semaphore\"; do this BEFORE local transaction created, etc so waiting for this doesn't cause TX timeout\n        if (sd.hasSemaphore) {\n            try {\n                checkAddSemaphore(eci, currentParameters, true);\n            } catch (Throwable t) {\n                eci.artifactExecutionFacade.pop(aei);\n                throw t;\n            }\n        }\n\n        // start with the settings for the default: use-or-begin\n        boolean pauseResumeIfNeeded = false;\n        boolean beginTransactionIfNeeded = true;\n        if (ignoreTransaction || sd.txIgnore) beginTransactionIfNeeded = false;\n        if (requireNewTransaction || sd.txForceNew) pauseResumeIfNeeded = true;\n\n        boolean suspendedTransaction = false;\n        Map<String, Object> result = new HashMap<>();\n        try {\n            if (pauseResumeIfNeeded && transactionStatus != Status.STATUS_NO_TRANSACTION) {\n                suspendedTransaction = tf.suspend();\n                transactionStatus = tf.getStatus();\n            }\n            boolean beganTransaction = false;\n            if (beginTransactionIfNeeded && transactionStatus != Status.STATUS_ACTIVE) {\n                // logger.warn(\"Service \" + serviceName + \" begin TX timeout \" + transactionTimeout + \" SD txTimeout \" + sd.txTimeout);\n                beganTransaction = tf.begin(transactionTimeout != null ? transactionTimeout : sd.txTimeout);\n                transactionStatus = tf.getStatus();\n            }\n            if (sd.noTxCache) {\n                tf.flushAndDisableTransactionCache();\n            } else {\n                if (useTransactionCache != null ? useTransactionCache : sd.txUseCache) tf.initTransactionCache(false);\n                // alternative to use read only TX cache by default, not functional yet: tf.initTransactionCache(!(useTransactionCache != null ? useTransactionCache : sd.txUseCache));\n            }\n\n            try {\n                if (hasSecaRules) ServiceFacadeImpl.runSecaRules(serviceNameNoHash, currentParameters, null, \"pre-service\", secaRules, eci);\n                if (traceEnabled) logger.trace(\"Calling service \" + serviceName + \" pre-call input: \" + currentParameters);\n\n                // if error(s) in pre-service or anything else before actual run then return now with no results\n                if (eci.messageFacade.hasError()) {\n                    StringBuilder errMsg = new StringBuilder(\"Found error(s) before running service \" + serviceName + \" so not running. Errors: \" + eci.messageFacade.getErrorsString() + \"; the artifact stack is:\\n\");\n                    for (ArtifactExecutionInfo stackItem : eci.artifactExecutionFacade.getStack())\n                        errMsg.append(stackItem.toString()).append(\"\\n\");\n                    logger.warn(errMsg.toString());\n                    if (ignorePreviousError) eci.messageFacade.popErrors();\n                    return null;\n                }\n\n                try {\n                    // run the service through the ServiceRunner\n                    result = serviceRunner.runService(sd, currentParameters);\n                } finally {\n                    if (hasSecaRules) sfi.registerTxSecaRules(serviceNameNoHash, currentParameters, result, secaRules);\n                }\n                // logger.warn(\"Called \" + serviceName + \" has error message \" + eci.messageFacade.hasError() + \" began TX \" + beganTransaction + \" TX status \" + tf.getStatusString());\n\n                // post-service SECA rules\n                if (hasSecaRules) ServiceFacadeImpl.runSecaRules(serviceNameNoHash, currentParameters, result, \"post-service\", secaRules, eci);\n                // registered callbacks, no Throwable\n                sfi.callRegisteredCallbacks(serviceName, currentParameters, result);\n                // if we got any errors added to the message list in the service, rollback for that too\n                if (eci.messageFacade.hasError()) {\n                    tf.rollback(beganTransaction, \"Error running service \" + serviceName + \" (message): \" + eci.messageFacade.getErrorsString(), null);\n                    transactionStatus = tf.getStatus();\n                }\n\n                if (traceEnabled) logger.trace(\"Calling service \" + serviceName + \" result: \" + result);\n            } catch (ArtifactAuthorizationException e) {\n                // this is a local call, pass certain exceptions through\n                throw e;\n            } catch (Throwable t) {\n                BaseException.filterStackTrace(t);\n                // registered callbacks with Throwable\n                sfi.callRegisteredCallbacksThrowable(serviceName, currentParameters, t);\n                // rollback the transaction\n                tf.rollback(beganTransaction, \"Error running service \" + serviceName + \" (Throwable)\", t);\n                transactionStatus = tf.getStatus();\n                logger.warn(\"Error running service \" + serviceName + \" (Throwable) Artifact stack: \" + eci.artifactExecutionFacade.getStackNameString(), t);\n                // add all exception messages to the error messages list\n                eci.messageFacade.addError(t.getMessage());\n                Throwable parent = t.getCause();\n                while (parent != null) {\n                    eci.messageFacade.addError(parent.getMessage());\n                    parent = parent.getCause();\n                }\n            } finally {\n                try {\n                    if (beganTransaction) {\n                        transactionStatus = tf.getStatus();\n                        if (transactionStatus == Status.STATUS_ACTIVE) {\n                            tf.commit();\n                        } else if (transactionStatus == Status.STATUS_MARKED_ROLLBACK) {\n                            if (!eci.messageFacade.hasError())\n                                eci.messageFacade.addError(\"Cannot commit transaction for service \" + serviceName + \", marked rollback-only\");\n                            // will rollback based on marked rollback only\n                            tf.commit();\n                        }\n                        /* most likely in this case is no transaction in place, already rolled back above, do nothing:\n                        else {\n                            logger.warn(\"In call to service \" + serviceName + \" transaction not Active or Marked Rollback-Only (\" + tf.getStatusString() + \"), doing commit to make sure TX closed\");\n                            tf.commit();\n                        }\n                        */\n                    }\n                } catch (Throwable t) {\n                    logger.warn(\"Error committing transaction for service \" + serviceName, t);\n                    // add all exception messages to the error messages list\n                    eci.messageFacade.addError(t.getMessage());\n                    Throwable parent = t.getCause();\n                    while (parent != null) {\n                        eci.messageFacade.addError(parent.getMessage());\n                        parent = parent.getCause();\n                    }\n\n                }\n\n                if (hasSecaRules) ServiceFacadeImpl.runSecaRules(serviceNameNoHash, currentParameters, result, \"post-commit\", secaRules, eci);\n            }\n\n            return result;\n        } finally {\n            // clear the semaphore\n            if (sd.hasSemaphore) clearSemaphore(eci, currentParameters);\n\n            try {\n                if (suspendedTransaction) tf.resume();\n            } catch (Throwable t) {\n                logger.error(\"Error resuming parent transaction after call to service \" + serviceName, t);\n            }\n\n            try {\n                if (userLoggedIn) eci.userFacade.logoutLocal();\n            } catch (Throwable t) {\n                logger.error(\"Error logging out user after call to service \" + serviceName, t);\n            }\n\n            if (loggedInAnonymous) eci.userFacade.logoutAnonymousOnly();\n\n            // all done so pop the artifact info\n            eci.artifactExecutionFacade.pop(aei);\n            // restore error messages if needed\n            if (ignorePreviousError) eci.messageFacade.popErrors();\n\n            if (traceEnabled) logger.trace(\"Finished call to service \" + serviceName +\n                    (eci.messageFacade.hasError() ? \" with \" + (eci.messageFacade.getErrors().size() +\n                            eci.messageFacade.getValidationErrors().size()) + \" error messages\" : \", was successful\"));\n        }\n\n    }\n\n    @SuppressWarnings(\"unused\")\n    private void clearSemaphore(final ExecutionContextImpl eci, Map<String, Object> currentParameters) {\n        final String semaphoreName = sd.semaphoreName != null && !sd.semaphoreName.isEmpty() ? sd.semaphoreName : serviceName;\n        String semParameter = sd.semaphoreParameter;\n        String parameterValue;\n        if (semParameter == null || semParameter.isEmpty()) {\n            parameterValue = \"_NA_\";\n        } else {\n            Object parmObj = currentParameters.get(semParameter);\n            parameterValue = parmObj != null ? parmObj.toString() : \"_NULL_\";\n        }\n\n        eci.transactionFacade.runRequireNew(null, \"Error in clear service semaphore\", new Closure<EntityValue>(this, this) {\n            EntityValue doCall(Object it) {\n                boolean authzDisabled = eci.artifactExecutionFacade.disableAuthz();\n                try {\n                    return eci.getEntity().makeValue(\"moqui.service.semaphore.ServiceParameterSemaphore\")\n                            .set(\"serviceName\", semaphoreName).set(\"parameterValue\", parameterValue)\n                            .set(\"lockThread\", null).set(\"lockTime\", null).update();\n                } finally {\n                    if (!authzDisabled) eci.artifactExecutionFacade.enableAuthz();\n                }\n            }\n            public EntityValue doCall() { return doCall(null); }\n        });\n    }\n\n    /* A good test case is the place#Order service which is used in the AssetReservationMultipleThreads.groovy tests:\n        conflicting lock:\n            <service verb=\"place\" noun=\"Order\" semaphore=\"wait\" semaphore-name=\"TestOrder\">\n        segemented lock (bad in practice, good test with transacitonal ID):\n            <service verb=\"place\" noun=\"Order\" semaphore=\"wait\" semaphore-name=\"TestOrder\" semaphore-parameter=\"orderId\">\n     */\n    @SuppressWarnings(\"unused\")\n    private void checkAddSemaphore(final ExecutionContextImpl eci, Map<String, Object> currentParameters, boolean allowRetry) {\n        final String semaphore = sd.semaphore;\n        final String semaphoreName = sd.semaphoreName != null && !sd.semaphoreName.isEmpty() ? sd.semaphoreName : serviceName;\n        String semaphoreParameter = sd.semaphoreParameter;\n        final String parameterValue;\n        if (semaphoreParameter == null || semaphoreParameter.isEmpty()) {\n            parameterValue = \"_NA_\";\n        } else {\n            Object parmObj = currentParameters.get(semaphoreParameter);\n            parameterValue = parmObj != null ? parmObj.toString() : \"_NULL_\";\n        }\n\n        final long semaphoreIgnoreMillis = sd.semaphoreIgnoreMillis;\n        final long semaphoreSleepTime = sd.semaphoreSleepTime;\n        final long semaphoreTimeoutTime = sd.semaphoreTimeoutTime;\n        final int txTimeout = Math.toIntExact(sd.semaphoreTimeoutTime / 1000) * 2;\n\n        // NOTE: get Thread name outside runRequireNew otherwise will always be RequireNewTx\n        final String lockThreadName = Thread.currentThread().getName();\n        // support a single wait/retry on error creating semaphore record\n        AtomicBoolean retrySemaphore = new AtomicBoolean(false);\n\n        eci.transactionFacade.runRequireNew(txTimeout, \"Error in check/add service semaphore\", new Closure<EntityValue>(this, this) {\n            EntityValue doCall(Object it) {\n                boolean authzDisabled = eci.artifactExecutionFacade.disableAuthz();\n                try {\n                    final long startTime = System.currentTimeMillis();\n\n                    // look up semaphore, note that is no forUpdate, we want to loop wait below instead of doing a database lock wait\n                    EntityValue serviceSemaphore = eci.getEntity().find(\"moqui.service.semaphore.ServiceParameterSemaphore\")\n                            .condition(\"serviceName\", semaphoreName).condition(\"parameterValue\", parameterValue).useCache(false).one();\n                    // if there is an active semaphore but lockTime is too old reset and ignore it\n                    if (serviceSemaphore != null && (serviceSemaphore.getNoCheckSimple(\"lockThread\") != null || serviceSemaphore.getNoCheckSimple(\"lockTime\") != null)) {\n                        Timestamp lockTime = serviceSemaphore.getTimestamp(\"lockTime\");\n                        if (startTime > (lockTime.getTime() + semaphoreIgnoreMillis)) {\n                            serviceSemaphore.set(\"lockThread\", null).set(\"lockTime\", null).update();\n                        }\n                    }\n\n                    if (serviceSemaphore != null && (serviceSemaphore.getNoCheckSimple(\"lockThread\") != null || serviceSemaphore.getNoCheckSimple(\"lockTime\") != null)) {\n                        if (\"fail\".equals(semaphore)) {\n                            throw new ServiceException(\"An instance of service semaphore \" + semaphoreName + \" with parameter value \" +\n                                    \"[\" + parameterValue + \"] is already running (thread [\" + serviceSemaphore.get(\"lockThread\") +\n                                    \"], locked at \" + serviceSemaphore.get(\"lockTime\") + \") and it is setup to fail on semaphore conflict.\");\n                        } else {\n                            boolean semaphoreCleared = false;\n                            while (System.currentTimeMillis() < (startTime + semaphoreTimeoutTime)) {\n                                // sleep, watch for interrupt\n                                try { Thread.sleep(semaphoreSleepTime); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }\n                                // get updated semaphore and see if it has been cleared\n                                serviceSemaphore = eci.getEntity().find(\"moqui.service.semaphore.ServiceParameterSemaphore\")\n                                        .condition(\"serviceName\", semaphoreName).condition(\"parameterValue\", parameterValue).useCache(false).one();\n                                if (serviceSemaphore == null || (serviceSemaphore.getNoCheckSimple(\"lockThread\") == null && serviceSemaphore.getNoCheckSimple(\"lockTime\") == null)) {\n                                    semaphoreCleared = true;\n                                    break;\n                                }\n                            }\n                            if (!semaphoreCleared) {\n                                throw new ServiceException(\"An instance of service semaphore \" + semaphoreName + \" with parameter value [\" +\n                                        parameterValue + \"] is already running (thread [\" + serviceSemaphore.get(\"lockThread\") +\n                                        \"], locked at \" + serviceSemaphore.get(\"lockTime\") + \") and it is setup to wait on semaphore conflict, but the semaphore did not clear in \" +\n                                        (semaphoreTimeoutTime / 1000) + \" seconds.\");\n                            }\n                        }\n                    }\n\n                    // if we got to here the semaphore didn't exist or has cleared, so update existing or create new\n                    // do a for-update find now to make sure we own the record if one exists\n                    serviceSemaphore = eci.getEntity().find(\"moqui.service.semaphore.ServiceParameterSemaphore\")\n                            .condition(\"serviceName\", semaphoreName).condition(\"parameterValue\", parameterValue)\n                            .useCache(false).forUpdate(true).one();\n\n                    final Timestamp lockTime = new Timestamp(System.currentTimeMillis());\n                    if (serviceSemaphore != null) {\n                        return serviceSemaphore.set(\"lockThread\", lockThreadName).set(\"lockTime\", lockTime).update();\n                    } else {\n                        try {\n                            return eci.getEntity().makeValue(\"moqui.service.semaphore.ServiceParameterSemaphore\")\n                                    .set(\"serviceName\", semaphoreName).set(\"parameterValue\", parameterValue)\n                                    .set(\"lockThread\", lockThreadName).set(\"lockTime\", lockTime).create();\n                        } catch (EntitySqlException e) {\n                            if (\"23505\".equals(e.getSQLState())) {\n                                logger.warn(\"Record exists error creating semaphore \" + semaphoreName + \" parameter \" + parameterValue + \", retrying: \" + e.toString());\n                                retrySemaphore.set(true);\n                                return null;\n                            } else {\n                                throw new ServiceException(\"Error creating semaphore \" + semaphoreName + \" with parameter value [\" + parameterValue + \"]\", e);\n                            }\n                        }\n                    }\n                } finally {\n                    if (!authzDisabled) eci.artifactExecutionFacade.enableAuthz();\n                }\n            }\n            public EntityValue doCall() { return doCall(null); }\n        });\n\n        if (allowRetry && retrySemaphore.get()) {\n            checkAddSemaphore(eci, currentParameters, false);\n        }\n    }\n\n    private Map<String, Object> runImplicitEntityAuto(Map<String, Object> currentParameters, ArrayList<ServiceEcaRule> secaRules, ExecutionContextImpl eci) {\n        // NOTE: no authentication, assume not required for this; security settings can override this and require\n        //     permissions, which will require authentication\n        // done in calling method: sfi.runSecaRules(serviceName, currentParameters, null, \"pre-auth\")\n\n        boolean hasSecaRules = secaRules != null && secaRules.size() > 0;\n        if (hasSecaRules)\n            ServiceFacadeImpl.runSecaRules(serviceNameNoHash, currentParameters, null, \"pre-validate\", secaRules, eci);\n\n        // start with the settings for the default: use-or-begin\n        boolean pauseResumeIfNeeded = false;\n        boolean beginTransactionIfNeeded = true;\n        if (ignoreTransaction) beginTransactionIfNeeded = false;\n        if (requireNewTransaction) pauseResumeIfNeeded = true;\n\n        TransactionFacadeImpl tf = eci.transactionFacade;\n        boolean suspendedTransaction = false;\n        Map<String, Object> result = new HashMap<>();\n        try {\n            if (pauseResumeIfNeeded && tf.isTransactionInPlace()) suspendedTransaction = tf.suspend();\n            boolean beganTransaction = beginTransactionIfNeeded && tf.begin(null);\n\n            if (useTransactionCache != null && useTransactionCache) tf.initTransactionCache(false);\n            // alternative to use read only TX cache by default, not functional yet: tf.initTransactionCache(useTransactionCache == null || !useTransactionCache);\n\n            try {\n                if (hasSecaRules) ServiceFacadeImpl.runSecaRules(serviceNameNoHash, currentParameters, null, \"pre-service\", secaRules, eci);\n\n                // if error(s) in pre-service or anything else before actual run then return now with no results\n                if (eci.messageFacade.hasError()) {\n                    StringBuilder errMsg = new StringBuilder(\"Found error(s) before running service \" + serviceName + \" so not running. Errors: \" + eci.messageFacade.getErrorsString() + \"; the artifact stack is:\\n\");\n                    for (ArtifactExecutionInfo stackItem : eci.artifactExecutionFacade.getStack())\n                        errMsg.append(stackItem.toString()).append(\"\\n\");\n                    logger.warn(errMsg.toString());\n                    if (ignorePreviousError) eci.messageFacade.popErrors();\n                    return null;\n                }\n\n                try {\n                    EntityDefinition ed = eci.getEntityFacade().getEntityDefinition(noun);\n                    if (\"create\".equals(verb)) {\n                        EntityAutoServiceRunner.createEntity(eci, ed, currentParameters, result, null);\n                    } else if (\"update\".equals(verb)) {\n                        EntityAutoServiceRunner.updateEntity(eci, ed, currentParameters, result, null, null);\n                    } else if (\"delete\".equals(verb)) {\n                        EntityAutoServiceRunner.deleteEntity(eci, ed, currentParameters);\n                    } else if (\"store\".equals(verb)) {\n                        EntityAutoServiceRunner.storeEntity(eci, ed, currentParameters, result, null);\n                    }\n\n                    // NOTE: no need to throw exception for other verbs, checked in advance when looking for valid service name by entity auto pattern\n                } finally {\n                    if (hasSecaRules) sfi.registerTxSecaRules(serviceNameNoHash, currentParameters, result, secaRules);\n                }\n\n                if (hasSecaRules) ServiceFacadeImpl.runSecaRules(serviceNameNoHash, currentParameters, result, \"post-service\", secaRules, eci);\n            } catch (ArtifactAuthorizationException e) {\n                tf.rollback(beganTransaction, \"Authorization error running service \" + serviceName, e);\n                // this is a local call, pass certain exceptions through\n                throw e;\n            } catch (Throwable t) {\n                logger.error(\"Error running service \" + serviceName, t);\n                tf.rollback(beganTransaction, \"Error running service \" + serviceName + \" (Throwable)\", t);\n                // add all exception messages to the error messages list\n                eci.messageFacade.addError(t.getMessage());\n                Throwable parent = t.getCause();\n                while (parent != null) {\n                    eci.messageFacade.addError(parent.getMessage());\n                    parent = parent.getCause();\n                }\n            } finally {\n                try {\n                    if (beganTransaction && tf.isTransactionActive()) tf.commit();\n                } catch (Throwable t) {\n                    logger.warn(\"Error committing transaction for entity-auto service \" + serviceName, t);\n                    // add all exception messages to the error messages list\n                    eci.messageFacade.addError(t.getMessage());\n                    Throwable parent = t.getCause();\n                    while (parent != null) {\n                        eci.messageFacade.addError(parent.getMessage());\n                        parent = parent.getCause();\n                    }\n                }\n\n                if (hasSecaRules) ServiceFacadeImpl.runSecaRules(serviceNameNoHash, currentParameters, result, \"post-commit\", secaRules, eci);\n            }\n        } finally {\n            if (suspendedTransaction) tf.resume();\n        }\n\n        return result;\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/ServiceDefinition.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.service;\n\nimport org.apache.commons.validator.routines.CreditCardValidator;\nimport org.apache.commons.validator.routines.EmailValidator;\nimport org.apache.commons.validator.routines.UrlValidator;\nimport org.codehaus.groovy.runtime.DefaultGroovyMethods;\n\nimport org.moqui.context.ArtifactExecutionInfo;\nimport org.moqui.entity.EntityList;\nimport org.moqui.entity.EntityValue;\nimport org.moqui.impl.actions.XmlAction;\nimport org.moqui.impl.context.ExecutionContextImpl;\nimport org.moqui.impl.entity.EntityDefinition;\nimport org.moqui.service.ServiceException;\nimport org.moqui.util.CollectionUtilities;\nimport org.moqui.util.MNode;\nimport org.moqui.util.ObjectUtilities;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.math.BigDecimal;\nimport java.math.BigInteger;\nimport java.util.*;\n\npublic class ServiceDefinition {\n    protected static final Logger logger = LoggerFactory.getLogger(ServiceDefinition.class);\n    private static final EmailValidator emailValidator = EmailValidator.getInstance();\n    private static final UrlValidator urlValidator = new UrlValidator(UrlValidator.ALLOW_ALL_SCHEMES);\n\n    public final ServiceFacadeImpl sfi;\n    public final MNode serviceNode;\n\n    private final LinkedHashMap<String, ParameterInfo> inParameterInfoMap = new LinkedHashMap<>();\n    private final ParameterInfo[] inParameterInfoArray;\n    // private final boolean inParameterHasDefault;\n    private final LinkedHashMap<String, ParameterInfo> outParameterInfoMap = new LinkedHashMap<>();\n    public final ArrayList<String> inParameterNameList = new ArrayList<>();\n    public final ArrayList<String> outParameterNameList = new ArrayList<>();\n    public final String[] outParameterNameArray;\n\n    public final String path;\n    public final String verb;\n    public final String noun;\n    public final String serviceName;\n    public final String serviceNameNoHash;\n\n    public final String location;\n    public final String method;\n    public final XmlAction xmlAction;\n\n    public final String authenticate;\n    public final ArtifactExecutionInfo.AuthzAction authzAction;\n    public final String serviceType;\n    public final ServiceRunner serviceRunner;\n    public final boolean txIgnore;\n    public final boolean txForceNew;\n    public final boolean txUseCache;\n    public final boolean noTxCache;\n    public final Integer txTimeout;\n    public final boolean validate;\n    public final boolean allowRemote;\n    public final boolean noRememberParameters;\n\n    public final boolean hasSemaphore;\n    public final String semaphore, semaphoreName, semaphoreParameter;\n    public final long semaphoreIgnoreMillis, semaphoreSleepTime, semaphoreTimeoutTime;\n\n    public ServiceDefinition(ServiceFacadeImpl sfi, String path, MNode sn) {\n        this.sfi = sfi;\n        this.serviceNode = sn.deepCopy(null);\n        this.path = path;\n        this.verb = serviceNode.attribute(\"verb\");\n        this.noun = serviceNode.attribute(\"noun\");\n\n        serviceName = makeServiceName(path, verb, noun);\n        serviceNameNoHash = makeServiceNameNoHash(path, verb, noun);\n        location = serviceNode.attribute(\"location\");\n        method = serviceNode.attribute(\"method\");\n\n        ArtifactExecutionInfo.AuthzAction tempAction = null;\n        String authzActionAttr = serviceNode.attribute(\"authz-action\");\n        if (authzActionAttr != null && !authzActionAttr.isEmpty()) tempAction = ArtifactExecutionInfo.authzActionByName.get(authzActionAttr);\n        if (tempAction == null) tempAction = verbAuthzActionEnumMap.get(verb);\n        if (tempAction == null) tempAction = ArtifactExecutionInfo.AUTHZA_ALL;\n        authzAction = tempAction;\n\n        MNode inParameters = new MNode(\"in-parameters\", null);\n        MNode outParameters = new MNode(\"out-parameters\", null);\n\n        boolean noRememberParmsTemp = \"true\".equals(serviceNode.attribute(\"no-remember-parameters\"));\n\n        // handle implements elements\n        if (serviceNode.hasChild(\"implements\")) for (MNode implementsNode : serviceNode.children(\"implements\")) {\n            final String implServiceName = implementsNode.attribute(\"service\");\n            String implRequired = implementsNode.attribute(\"required\");// no default here, only used if has a value\n            if (implRequired != null && implRequired.isEmpty()) implRequired = null;\n            ServiceDefinition sd = sfi.getServiceDefinition(implServiceName);\n            if (sd == null) throw new ServiceException(\"Service \" + implServiceName +\n                    \" not found, specified in service.implements in service \" + serviceName);\n\n            // while most attributes aren't passed through do pass through no-remember-parameters if true\n            if (sd.noRememberParameters) noRememberParmsTemp = true;\n\n            // these are the first params to be set, so just deep copy them over\n            MNode implInParms = sd.serviceNode.first(\"in-parameters\");\n            if (implInParms != null && implInParms.hasChild(\"parameter\")) {\n                for (MNode parameter : implInParms.children(\"parameter\")) {\n                    MNode newParameter = parameter.deepCopy(null);\n                    if (implRequired != null) newParameter.getAttributes().put(\"required\", implRequired);\n                    inParameters.append(newParameter);\n                }\n            }\n\n            MNode implOutParms = sd.serviceNode.first(\"out-parameters\");\n            if (implOutParms != null && implOutParms.hasChild(\"parameter\")) {\n                for (MNode parameter : implOutParms.children(\"parameter\")) {\n                    MNode newParameter = parameter.deepCopy(null);\n                    if (implRequired != null) newParameter.getAttributes().put(\"required\", implRequired);\n                    outParameters.append(newParameter);\n                }\n            }\n        }\n\n        noRememberParameters = noRememberParmsTemp;\n\n        // expand auto-parameters and merge parameter in in-parameters and out-parameters\n        // if noun is a valid entity name set it on parameters with valid field names on it\n        EntityDefinition ed = null;\n        if (sfi.ecfi.entityFacade.isEntityDefined(this.noun))\n            ed = sfi.ecfi.entityFacade.getEntityDefinition(this.noun);\n        if (serviceNode.hasChild(\"in-parameters\")) {\n            for (MNode paramNode : serviceNode.first(\"in-parameters\").getChildren()) {\n                if (\"auto-parameters\".equals(paramNode.getName())) {\n                    mergeAutoParameters(inParameters, paramNode);\n                } else if (paramNode.getName().equals(\"parameter\")) {\n                    mergeParameter(inParameters, paramNode, ed);\n                }\n            }\n        }\n\n        if (serviceNode.hasChild(\"out-parameters\")) {\n            for (MNode paramNode : serviceNode.first(\"out-parameters\").getChildren()) {\n                if (\"auto-parameters\".equals(paramNode.getName())) {\n                    mergeAutoParameters(outParameters, paramNode);\n                } else if (\"parameter\".equals(paramNode.getName())) {\n                    mergeParameter(outParameters, paramNode, ed);\n                }\n            }\n        }\n\n        // replace the in-parameters and out-parameters Nodes for the service\n        if (serviceNode.hasChild(\"in-parameters\")) serviceNode.remove(\"in-parameters\");\n        serviceNode.append(inParameters);\n        if (serviceNode.hasChild(\"out-parameters\")) serviceNode.remove(\"out-parameters\");\n        serviceNode.append(outParameters);\n\n        if (logger.isTraceEnabled()) logger.trace(\"After merge for service \" + serviceName + \" node is:\\n\" + serviceNode.toString());\n\n        // if this is an inline service, get that now\n        if (serviceNode.hasChild(\"actions\")) {\n            xmlAction = new XmlAction(sfi.ecfi, serviceNode.first(\"actions\"), serviceName);\n        } else {\n            xmlAction = null;\n        }\n\n        final String authenticateAttr = serviceNode.attribute(\"authenticate\");\n        authenticate = authenticateAttr != null && !authenticateAttr.isEmpty() ? authenticateAttr : \"true\";\n        final String typeAttr = serviceNode.attribute(\"type\");\n        serviceType = typeAttr != null && !typeAttr.isEmpty() ? typeAttr : \"inline\";\n        serviceRunner = sfi.getServiceRunner(serviceType);\n\n        String transactionAttr = serviceNode.attribute(\"transaction\");\n        txIgnore = \"ignore\".equals(transactionAttr);\n        txForceNew = \"force-new\".equals(transactionAttr) || \"force-cache\".equals(transactionAttr);\n        txUseCache = \"cache\".equals(transactionAttr) || \"force-cache\".equals(transactionAttr);\n        noTxCache = \"true\".equals(serviceNode.attribute(\"no-tx-cache\"));\n        String txTimeoutAttr = serviceNode.attribute(\"transaction-timeout\");\n        if (txTimeoutAttr != null && !txTimeoutAttr.isEmpty()) {\n            txTimeout = Integer.valueOf(txTimeoutAttr);\n        } else {\n            txTimeout = null;\n        }\n\n        semaphore = serviceNode.attribute(\"semaphore\");\n        semaphoreName = serviceNode.attribute(\"semaphore-name\");\n        hasSemaphore = semaphore != null && semaphore.length() > 0 && !\"none\".equals(semaphore);\n        semaphoreParameter = serviceNode.attribute(\"semaphore-parameter\");\n        String ignoreAttr = serviceNode.attribute(\"semaphore-ignore\");\n        if (ignoreAttr == null || ignoreAttr.isEmpty()) ignoreAttr = \"3600\";\n        semaphoreIgnoreMillis = Long.parseLong(ignoreAttr) * 1000;\n        String sleepAttr = serviceNode.attribute(\"semaphore-sleep\");\n        if (sleepAttr == null || sleepAttr.isEmpty()) sleepAttr = \"5\";\n        semaphoreSleepTime = Long.parseLong(sleepAttr) * 1000;\n        String timeoutAttr = serviceNode.attribute(\"semaphore-timeout\");\n        if (timeoutAttr == null || timeoutAttr.isEmpty()) timeoutAttr = \"120\";\n        semaphoreTimeoutTime = Long.parseLong(timeoutAttr) * 1000;\n\n        // validate defaults to true\n        validate = !\"false\".equals(serviceNode.attribute(\"validate\"));\n        allowRemote = \"true\".equals(serviceNode.attribute(\"allow-remote\"));\n\n        MNode inParametersNode = serviceNode.first(\"in-parameters\");\n        MNode outParametersNode = serviceNode.first(\"out-parameters\");\n\n        if (inParametersNode != null) for (MNode parameter : inParametersNode.children(\"parameter\")) {\n            String parameterName = parameter.attribute(\"name\");\n            inParameterInfoMap.put(parameterName, new ParameterInfo(this, parameter));\n            inParameterNameList.add(parameterName);\n        }\n        int inParameterNameListSize = inParameterNameList.size();\n        inParameterInfoArray = new ParameterInfo[inParameterNameListSize];\n        // boolean tempHasDefault = false;\n        for (int i = 0; i < inParameterNameListSize; i++) {\n            String parmName = inParameterNameList.get(i);\n            ParameterInfo pi = inParameterInfoMap.get(parmName);\n            inParameterInfoArray[i] = pi;\n            // if (pi.thisOrChildHasDefault) tempHasDefault = true;\n        }\n        // inParameterHasDefault = tempHasDefault;\n\n        if (outParametersNode != null) for (MNode parameter : outParametersNode.children(\"parameter\")) {\n            String parameterName = parameter.attribute(\"name\");\n            outParameterInfoMap.put(parameterName, new ParameterInfo(this, parameter));\n            outParameterNameList.add(parameterName);\n        }\n        outParameterNameArray = new String[outParameterNameList.size()];\n        outParameterNameList.toArray(outParameterNameArray);\n    }\n\n    private void mergeAutoParameters(MNode parametersNode, MNode autoParameters) {\n        String entityName = autoParameters.attribute(\"entity-name\");\n        if (entityName == null || entityName.isEmpty()) entityName = noun;\n        if (entityName == null || entityName.isEmpty()) throw new ServiceException(\"Error in auto-parameters in service \" +\n                serviceName + \", no auto-parameters.@entity-name and no service.@noun for a default\");\n        EntityDefinition ed = sfi.ecfi.entityFacade.getEntityDefinition(entityName);\n        if (ed == null) throw new ServiceException(\"Error in auto-parameters in service \" + serviceName + \", the entity-name or noun [\" + entityName + \"] is not a valid entity name\");\n\n        Set<String> fieldsToExclude = new HashSet<>();\n        for (MNode excludeNode : autoParameters.children(\"exclude\")) {\n            fieldsToExclude.add(excludeNode.attribute(\"field-name\"));\n        }\n\n\n        String includeStr = autoParameters.attribute(\"include\");\n        if (includeStr == null || includeStr.isEmpty()) includeStr = \"all\";\n        String requiredStr = autoParameters.attribute(\"required\");\n        if (requiredStr == null || requiredStr.isEmpty()) requiredStr = \"false\";\n        String allowHtmlStr = autoParameters.attribute(\"allow-html\");\n        if (allowHtmlStr == null || allowHtmlStr.isEmpty()) allowHtmlStr = \"none\";\n        for (String fieldName : ed.getFieldNames(\"all\".equals(includeStr) || \"pk\".equals(includeStr), \"all\".equals(includeStr) || \"nonpk\".equals(includeStr))) {\n            if (fieldsToExclude.contains(fieldName)) continue;\n\n            String javaType = sfi.ecfi.entityFacade.getFieldJavaType(ed.getFieldInfo(fieldName).type, ed);\n            Map<String, String> map = new LinkedHashMap<>(5);\n            map.put(\"type\", javaType);\n            map.put(\"required\", requiredStr);\n            map.put(\"allow-html\", allowHtmlStr);\n            map.put(\"entity-name\", ed.fullEntityName);\n            map.put(\"field-name\", fieldName);\n            mergeParameter(parametersNode, fieldName, map);\n        }\n    }\n\n    private void mergeParameter(MNode parametersNode, MNode overrideParameterNode, EntityDefinition ed) {\n        MNode baseParameterNode = mergeParameter(parametersNode, overrideParameterNode.attribute(\"name\"), overrideParameterNode.getAttributes());\n        // merge description, ParameterValidations\n        for (MNode childNode : overrideParameterNode.getChildren()) {\n            if (\"description\".equals(childNode.getName())) {\n                if (baseParameterNode.hasChild(childNode.getName())) baseParameterNode.remove(childNode.getName());\n            }\n\n            if (\"auto-parameters\".equals(childNode.getName())) {\n                mergeAutoParameters(baseParameterNode, childNode);\n            } else if (\"parameter\".equals(childNode.getName())) {\n                mergeParameter(baseParameterNode, childNode, ed);\n            } else {\n                // is a validation, just add it in, or the original has been removed so add the new one\n                baseParameterNode.append(childNode);\n            }\n\n        }\n\n        String entityNameAttr = baseParameterNode.attribute(\"entity-name\");\n        if (entityNameAttr != null && !entityNameAttr.isEmpty()) {\n            String fieldNameAttr = baseParameterNode.attribute(\"field-name\");\n            if (fieldNameAttr == null || fieldNameAttr.isEmpty())\n                baseParameterNode.getAttributes().put(\"field-name\", baseParameterNode.attribute(\"name\"));\n        } else if (ed != null && ed.isField(baseParameterNode.attribute(\"name\"))) {\n            baseParameterNode.getAttributes().put(\"entity-name\", ed.fullEntityName);\n            baseParameterNode.getAttributes().put(\"field-name\", baseParameterNode.attribute(\"name\"));\n        }\n\n    }\n\n    private static MNode mergeParameter(MNode parametersNode, final String parameterName, Map<String, String> attributeMap) {\n        MNode baseParameterNode = parametersNode.first(\"parameter\", \"name\", parameterName);\n        if (baseParameterNode == null) {\n            Map<String, String> map = new HashMap<>(1); map.put(\"name\", parameterName);\n            baseParameterNode = parametersNode.append(\"parameter\", map);\n        }\n        baseParameterNode.getAttributes().putAll(attributeMap);\n        return baseParameterNode;\n    }\n\n    public static String makeServiceName(String path, String verb, String noun) {\n        return (path != null && !path.isEmpty() ? path + \".\" : \"\") + verb + (noun != null && !noun.isEmpty() ? \"#\" + noun : \"\");\n    }\n\n    public static String makeServiceNameNoHash(String path, String verb, String noun) {\n        return (path != null && !path.isEmpty() ? path + \".\" : \"\") + verb + (noun != null ? noun : \"\");\n    }\n\n    public static String getPathFromName(String serviceName) {\n        String p = serviceName;\n        // do hash first since a noun following hash may have dots in it\n        int hashIndex = p.indexOf('#');\n        if (hashIndex > 0) p = p.substring(0, hashIndex);\n        int lastDotIndex = p.lastIndexOf('.');\n        if (lastDotIndex <= 0) return null;\n        return p.substring(0, lastDotIndex);\n    }\n\n    public static String getVerbFromName(String serviceName) {\n        String v = serviceName;\n        // do hash first since a noun following hash may have dots in it\n        int hashIndex = v.indexOf('#');\n        if (hashIndex > 0) v = v.substring(0, hashIndex);\n        int lastDotIndex = v.lastIndexOf('.');\n        if (lastDotIndex > 0) v = v.substring(lastDotIndex + 1);\n        return v;\n    }\n\n    public static String getNounFromName(String serviceName) {\n        int hashIndex = serviceName.lastIndexOf('#');\n        if (hashIndex < 0) return null;\n        return serviceName.substring(hashIndex + 1);\n    }\n\n    public static ArtifactExecutionInfo.AuthzAction getVerbAuthzActionEnum(String theVerb) {\n        // default to require the \"All\" authz action, and for special verbs default to something more appropriate\n        ArtifactExecutionInfo.AuthzAction authzAction = verbAuthzActionEnumMap.get(theVerb);\n        if (authzAction == null) authzAction = ArtifactExecutionInfo.AUTHZA_ALL;\n        return authzAction;\n    }\n\n    public MNode getInParameter(String name) {\n        ParameterInfo pi = inParameterInfoMap.get(name);\n        if (pi == null) return null;\n        return pi.parameterNode;\n    }\n\n    public ArrayList<String> getInParameterNames() {\n        return inParameterNameList;\n    }\n\n    public MNode getOutParameter(String name) {\n        ParameterInfo pi = outParameterInfoMap.get(name);\n        if (pi == null) return null;\n        return pi.parameterNode;\n    }\n\n    public ArrayList<String> getOutParameterNames() {\n        return outParameterNameList;\n    }\n\n    public Map<String, Object>  convertValidateCleanParameters(Map<String, Object> parameters, ExecutionContextImpl eci) {\n        // logger.warn(\"BEFORE ${serviceName} convertValidateCleanParameters: ${parameters.toString()}\")\n\n        // checkParameterMap(\"\", parameters, parameters, inParameterInfoMap, eci);\n        return nestedParameterClean(\"\", parameters, inParameterInfoArray, eci);\n\n        // logger.warn(\"AFTER ${serviceName} convertValidateCleanParameters: ${parameters.toString()}\")\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private Map<String, Object> nestedParameterClean(String namePrefix, Map<String, Object> parameters,\n                                      ParameterInfo[] parameterInfoArray, ExecutionContextImpl eci) {\n        // a copy of the parameters Map to retain unknown entries to add at the end when NOT validating (pass through unknown parameters)\n        HashMap<String, Object> parametersCopy = validate ? null : new HashMap<>(parameters);\n        // the new Map that will be populated and returned\n        HashMap<String, Object> newMap = new HashMap<>();\n\n        for (int i = 0; i < parameterInfoArray.length; i++) {\n            ParameterInfo parameterInfo = parameterInfoArray[i];\n            String parameterName = parameterInfo.name;\n\n            boolean hasParameter = parameters.containsKey(parameterName);\n            Object parameterValue = hasParameter ? parameters.get(parameterName) : null;\n            if (hasParameter && parametersCopy != null) parametersCopy.remove(parameterName);\n\n            boolean parameterIsEmpty;\n            boolean isString = false;\n            boolean isCollection = false;\n            boolean isMap = false;\n            Class parameterClass = null;\n            if (parameterValue != null) {\n                if (parameterValue instanceof CharSequence) {\n                    String stringValue = parameterValue.toString();\n                    parameterValue = stringValue;\n                    isString = true;\n                    parameterClass = String.class;\n                    parameterIsEmpty = stringValue.isEmpty();\n                } else {\n                    parameterClass = parameterValue.getClass();\n                    if (parameterValue instanceof Map) {\n                        isMap = true;\n                        parameterIsEmpty = ((Map) parameterValue).isEmpty();\n                    } else if (parameterValue instanceof Collection) {\n                        isCollection = true;\n                        parameterIsEmpty = ((Collection) parameterValue).isEmpty();\n                    } else {\n                        parameterIsEmpty = false;\n                    }\n                }\n            } else {\n                parameterIsEmpty = true;\n            }\n\n            // set the default if applicable\n            if (parameterIsEmpty) {\n                if (parameterInfo.hasDefault) {\n                    // TODO: consider doing this as a second pass so newMap has all parameters\n                    if (parameterInfo.defaultStr != null) {\n                        Map<String, Object> combinedMap = new HashMap<>(parameters);\n                        combinedMap.putAll(newMap);\n                        parameterValue = eci.resourceFacade.expression(parameterInfo.defaultStr, null, combinedMap);\n                        if (parameterValue != null) {\n                            hasParameter = true;\n                            isString = false;\n                            isCollection = false;\n                            isMap = false;\n                            if (parameterValue instanceof CharSequence) {\n                                String stringValue = parameterValue.toString();\n                                parameterValue = stringValue;\n                                isString = true;\n                                parameterClass = String.class;\n                                parameterIsEmpty = stringValue.isEmpty();\n                            } else {\n                                parameterClass = parameterValue.getClass();\n                                if (parameterValue instanceof Map) {\n                                    isMap = true;\n                                    parameterIsEmpty = ((Map) parameterValue).isEmpty();\n                                } else if (parameterValue instanceof Collection) {\n                                    isCollection = true;\n                                    parameterIsEmpty = ((Collection) parameterValue).isEmpty();\n                                } else {\n                                    parameterIsEmpty = false;\n                                }\n                            }\n                        }\n                    } else if (parameterInfo.defaultValue != null) {\n                        String stringValue;\n                        if (parameterInfo.defaultValueNeedsExpand) {\n                            Map<String, Object> combinedMap = new HashMap<>(parameters);\n                            combinedMap.putAll(newMap);\n                            stringValue = eci.resourceFacade.expand(parameterInfo.defaultValue, null, combinedMap, false);\n                        } else {\n                            stringValue = parameterInfo.defaultValue;\n                        }\n                        hasParameter = true;\n                        parameterValue = stringValue;\n                        isString = true;\n                        parameterClass = String.class;\n                        parameterIsEmpty = stringValue.isEmpty();\n                    }\n                } else {\n                    // if empty but not null and types don't match set to null instead of trying to convert\n                    if (parameterValue != null) {\n                        boolean typeMatches;\n                        if (parameterInfo.parmClass != null) {\n                            typeMatches = parameterClass == parameterInfo.parmClass || parameterInfo.parmClass.isInstance(parameterValue);\n                        } else {\n                            typeMatches = ObjectUtilities.isInstanceOf(parameterValue, parameterInfo.type);\n                        }\n                        if (!typeMatches) parameterValue = null;\n                    }\n                }\n                // if required and still empty (nothing from default), complain\n                if (parameterIsEmpty && validate && parameterInfo.required)\n                    eci.messageFacade.addValidationError(null, namePrefix + parameterName, serviceName, eci.getL10n().localize(\"Field cannot be empty\"), null);\n            }\n            // NOTE: not else because parameterIsEmpty may be changed\n            if (!parameterIsEmpty) {\n                boolean typeMatches;\n                if (parameterInfo.parmClass != null) {\n                    typeMatches = parameterClass == parameterInfo.parmClass || parameterInfo.parmClass.isInstance(parameterValue);\n                } else {\n                    typeMatches = ObjectUtilities.isInstanceOf(parameterValue, parameterInfo.type);\n                }\n                if (!typeMatches) {\n                    // convert type, at this point parameterValue is not empty and doesn't match parameter type\n                    parameterValue = parameterInfo.convertType(namePrefix, parameterValue, isString, eci);\n                    isString = false;\n                    isCollection = false;\n                    isMap = false;\n                    if (parameterValue instanceof CharSequence) {\n                        parameterValue = parameterValue.toString();\n                        isString = true;\n                    } else if (parameterValue instanceof Map) {\n                            isMap = true;\n                    } else if (parameterValue instanceof Collection) {\n                        isCollection = true;\n                    }\n                }\n\n                if (validate) {\n                    if ((isString || isCollection) && ParameterInfo.ParameterAllowHtml.ANY != parameterInfo.allowHtml) {\n                        Object htmlValidated = parameterInfo.validateParameterHtml(namePrefix, parameterValue, isString, eci);\n                        // put the final parameterValue back into the parameters Map\n                        if (htmlValidated != null) {\n                            parameterValue = htmlValidated;\n                        }\n                    }\n\n                    // check against validation sub-elements (do this after the convert so we can deal with objects when needed)\n                    if (parameterInfo.validationNodeList != null) {\n                        int valListSize = parameterInfo.validationNodeList.size();\n                        for (int valIdx = 0; valIdx < valListSize; valIdx++) {\n                            MNode valNode = parameterInfo.validationNodeList.get(valIdx);\n                            // NOTE don't break on fail, we want to get a list of all failures for the user to see\n                            try {\n                                // validateParameterSingle calls eci.message.addValidationError as needed so nothing else to do here\n                                validateParameterSingle(valNode, parameterName, parameterValue, eci);\n                            } catch (Throwable t) {\n                                logger.error(\"Error in validation\", t);\n                                Map<String, Object> map = new HashMap<>(3);\n                                map.put(\"parameterValue\", parameterValue); map.put(\"valNode\", valNode); map.put(\"t\", t);\n                                eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand(\"Value entered failed ${valNode.name} validation: ${t.message}\", \"\", map), null);\n                            }\n                        }\n                    }\n                }\n                if (isMap && parameterInfo.childParameterInfoArray != null && parameterInfo.childParameterInfoArray.length > 0) {\n                    parameterValue = nestedParameterClean(namePrefix + parameterName + \".\",\n                            (Map<String, Object>) parameterValue, parameterInfo.childParameterInfoArray, eci);\n                }\n            }\n\n            if (hasParameter) newMap.put(parameterName, parameterValue);\n        }\n\n        // if we are not validating and there are parameters remaining, add them to the newMap\n        if (!validate && parametersCopy != null && parametersCopy.size() > 0) {\n            newMap.putAll(parametersCopy);\n        }\n\n        return newMap;\n    }\n\n    private boolean validateParameterSingle(MNode valNode, String parameterName, Object pv, ExecutionContextImpl eci) {\n        // should never be null (caller checks) but check just in case\n        if (pv == null) return true;\n\n        String validateName = valNode.getName();\n        if (\"val-or\".equals(validateName)) {\n            boolean anyPass = false;\n            for (MNode child : valNode.getChildren()) if (validateParameterSingle(child, parameterName, pv, eci)) anyPass = true;\n            return anyPass;\n        } else if (\"val-and\".equals(validateName)) {\n            boolean allPass = true;\n            for (MNode child : valNode.getChildren()) if (!validateParameterSingle(child, parameterName, pv, eci)) allPass = false;\n            return allPass;\n        } else if (\"val-not\".equals(validateName)) {\n            boolean allPass = true;\n            for (MNode child : valNode.getChildren()) if (!validateParameterSingle(child, parameterName, pv, eci)) allPass = false;\n            return !allPass;\n        } else if (\"matches\".equals(validateName)) {\n            if (!(pv instanceof CharSequence)) {\n                Map<String, Object> map = new HashMap<>(1); map.put(\"pv\", pv);\n                eci.getMessage().addValidationError(null, parameterName, serviceName,\n                        eci.getResource().expand(\"Value entered (${pv}) is not a string, cannot do matches validation.\", \"\", map), null);\n                return false;\n            }\n\n            String pvString = pv.toString();\n            String regexp = valNode.attribute(\"regexp\");\n            if (regexp != null && !regexp.isEmpty() && !pvString.matches(regexp)) {\n                // a message attribute should always be there, but just in case we'll have a default\n                final String message = valNode.attribute(\"message\");\n                Map<String, Object> map = new HashMap<>(2); map.put(\"pv\", pv); map.put(\"regexp\", regexp);\n                eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand(message != null && !message.isEmpty() ? message : \"Value entered (${pv}) did not match expression: ${regexp}\", \"\", map), null);\n                return false;\n            }\n\n            return true;\n        } else if (\"number-range\".equals(validateName)) {\n            BigDecimal bdVal = new BigDecimal(pv.toString());\n            String message = valNode.attribute(\"message\");\n\n            String minStr = valNode.attribute(\"min\");\n            if (minStr != null && !minStr.isEmpty()) {\n                BigDecimal min = new BigDecimal(minStr);\n                if (\"false\".equals(valNode.attribute(\"min-include-equals\"))) {\n                    if (bdVal.compareTo(min) <= 0) {\n                        Map<String, Object> map = new HashMap<>(2); map.put(\"pv\", pv); map.put(\"min\", min);\n                        if (message == null || message.isEmpty()) message = \"Value entered (${pv}) is less than or equal to ${min}, must be greater than.\";\n                        eci.getMessage().addValidationError(null, parameterName, serviceName,\n                                eci.getResource().expand(message, \"\", map), null);\n                        return false;\n                    }\n                } else {\n                    if (bdVal.compareTo(min) < 0) {\n                        Map<String, Object> map = new HashMap<>(2); map.put(\"pv\", pv); map.put(\"min\", min);\n                        if (message == null || message.isEmpty()) message = \"Value entered (${pv}) is less than ${min} and must be greater than or equal to.\";\n                        eci.getMessage().addValidationError(null, parameterName, serviceName,\n                                eci.getResource().expand(message, \"\", map), null);\n                        return false;\n                    }\n                }\n            }\n\n            String maxStr = valNode.attribute(\"max\");\n            if (maxStr != null && !maxStr.isEmpty()) {\n                BigDecimal max = new BigDecimal(maxStr);\n                if (\"true\".equals(valNode.attribute(\"max-include-equals\"))) {\n                    if (bdVal.compareTo(max) > 0) {\n                        Map<String, Object> map = new HashMap<>(2); map.put(\"pv\", pv); map.put(\"max\", max);\n                        if (message == null || message.isEmpty()) message = \"Value entered (${pv}) is greater than ${max} and must be less than or equal to.\";\n                        eci.getMessage().addValidationError(null, parameterName, serviceName,\n                                eci.getResource().expand(message, \"\", map), null);\n                        return false;\n                    }\n\n                } else {\n                    if (bdVal.compareTo(max) >= 0) {\n                        Map<String, Object> map = new HashMap<>(2); map.put(\"pv\", pv); map.put(\"max\", max);\n                        if (message == null || message.isEmpty()) message = \"Value entered (${pv}) is greater than or equal to ${max} and must be less than.\";\n                        eci.getMessage().addValidationError(null, parameterName, serviceName,\n                                eci.getResource().expand(message, \"\", map), null);\n                        return false;\n                    }\n                }\n            }\n\n            return true;\n        } else if (\"number-integer\".equals(validateName)) {\n            try {\n                new BigInteger(pv.toString());\n            } catch (NumberFormatException e) {\n                if (logger.isTraceEnabled())\n                    logger.trace(\"Adding error message for NumberFormatException for BigInteger parse: \" + e.toString());\n                Map<String, Object> map = new HashMap<>(1); map.put(\"pv\", pv);\n                eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand(\"Value [${pv}] is not a whole (integer) number.\", \"\", map), null);\n                return false;\n            }\n\n            return true;\n        } else if (\"number-decimal\".equals(validateName)) {\n            try {\n                new BigDecimal(pv.toString());\n            } catch (NumberFormatException e) {\n                if (logger.isTraceEnabled())\n                    logger.trace(\"Adding error message for NumberFormatException for BigDecimal parse: \" + e.toString());\n                Map<String, Object> map = new HashMap<>(1);\n                map.put(\"pv\", pv);\n                eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand(\"Value [${pv}] is not a decimal number.\", \"\", map), null);\n                return false;\n            }\n\n            return true;\n        } else if (\"text-length\".equals(validateName)) {\n            String str = pv.toString();\n            String minStr = valNode.attribute(\"min\");\n            if (minStr != null && !minStr.isEmpty()) {\n                int min = Integer.parseInt(minStr);\n                if (str.length() < min) {\n                    Map<String, Object> map = new HashMap<>(3); map.put(\"pv\", pv); map.put(\"str\", str); map.put(\"minStr\", minStr);\n                    eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand(\"Value entered (${pv}), length ${str.length()}, is shorter than ${minStr} characters.\", \"\", map), null);\n                    return false;\n                }\n\n            }\n\n            String maxStr = valNode.attribute(\"max\");\n            if (maxStr != null && !maxStr.isEmpty()) {\n                int max = Integer.parseInt(maxStr);\n                if (str.length() > max) {\n                    Map<String, Object> map = new HashMap<>(3); map.put(\"pv\", pv); map.put(\"str\", str); map.put(\"maxStr\", maxStr);\n                    eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand(\"Value entered (${pv}), length ${str.length()}, is longer than ${maxStr} characters.\", \"\", map), null);\n                    return false;\n                }\n            }\n\n            return true;\n        } else if (\"text-email\".equals(validateName)) {\n            String str = pv.toString();\n            if (!emailValidator.isValid(str)) {\n                Map<String, Object> map = new HashMap<>(1); map.put(\"str\", str);\n                eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand(\"Value entered (${str}) is not a valid email address.\", \"\", map), null);\n                return false;\n            }\n\n            return true;\n        } else if (\"text-url\".equals(validateName)) {\n            String str = pv.toString();\n            if (!urlValidator.isValid(str)) {\n                Map<String, Object> map = new HashMap<>(1); map.put(\"str\", str);\n                eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand(\"Value entered (${str}) is not a valid URL.\", \"\", map), null);\n                return false;\n            }\n\n            return true;\n        } else if (\"text-letters\".equals(validateName)) {\n            String str = pv.toString();\n            for (char c : str.toCharArray()) {\n                if (!Character.isLetter(c)) {\n                    Map<String, Object> map = new HashMap<>(1); map.put(\"str\", str);\n                    eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand(\"Value entered (${str}) must have only letters.\", \"\", map), null);\n                    return false;\n                }\n            }\n\n            return true;\n        } else if (\"text-digits\".equals(validateName)) {\n            String str = pv.toString();\n            for (char c : str.toCharArray()) {\n                if (!Character.isDigit(c)) {\n                    Map<String, Object> map = new HashMap<>(1); map.put(\"str\", str);\n                    eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand(\"Value [${str}] must have only digits.\", \"\", map), null);\n                    return false;\n                }\n            }\n\n            return true;\n        } else if (\"time-range\".equals(validateName)) {\n            Calendar cal;\n            String format = valNode.attribute(\"format\");\n            if (pv instanceof CharSequence) {\n                cal = eci.getL10n().parseDateTime(pv.toString(), format);\n            } else {\n                // try letting groovy convert it\n                cal = Calendar.getInstance();\n                // TODO: not sure if this will work: ((pv as java.util.Date).getTime())\n                cal.setTimeInMillis((DefaultGroovyMethods.asType(pv, Date.class)).getTime());\n            }\n\n            String after = valNode.attribute(\"after\");\n            if (after != null && !after.isEmpty()) {\n                // handle after date/time/date-time depending on type of parameter, support \"now\" too\n                Calendar compareCal;\n                if (\"now\".equals(after)) {\n                    compareCal = eci.getL10n().parseDateTime(eci.getL10n().format(eci.getUser().getNowTimestamp(), format), format);\n                } else {\n                    compareCal = eci.getL10n().parseDateTime(after, format);\n                }\n                if (cal != null && cal.compareTo(compareCal) < 0) {\n                    Map<String, Object> map = new HashMap<>(2); map.put(\"pv\", pv); map.put(\"after\", after);\n                    eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand(\"Value entered (${pv}) is before ${after}.\", \"\", map), null);\n                    return false;\n                }\n            }\n\n            String before = valNode.attribute(\"before\");\n            if (before != null && !before.isEmpty()) {\n                // handle after date/time/date-time depending on type of parameter, support \"now\" too\n                Calendar compareCal;\n                if (\"now\".equals(before)) {\n                    compareCal = eci.getL10n().parseDateTime(eci.getL10n().format(eci.getUser().getNowTimestamp(), format), format);\n                } else {\n                    compareCal = eci.getL10n().parseDateTime(before, format);\n                }\n                if (cal != null && cal.compareTo(compareCal) > 0) {\n                    Map<String, Object> map = new HashMap<>(1); map.put(\"pv\", pv);\n                    eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand(\"Value entered (${pv}) is after ${before}.\", \"\", map), null);\n                    return false;\n                }\n            }\n\n            return true;\n        } else if (\"credit-card\".equals(validateName)) {\n            long creditCardTypes = 0;\n            String types = valNode.attribute(\"types\");\n            if (types != null && !types.isEmpty()) {\n                for (String cts : types.split(\",\")) creditCardTypes += creditCardTypeMap.get(cts.trim());\n            } else {\n                creditCardTypes = allCreditCards;\n            }\n\n            CreditCardValidator ccv = new CreditCardValidator(creditCardTypes);\n            String str = pv.toString();\n            if (!ccv.isValid(str)) {\n                Map<String, Object> map = new HashMap<>(1); map.put(\"str\", str);\n                eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand(\"Value entered is not a valid credit card number.\", \"\", map), null);\n                return false;\n            }\n\n            return true;\n        }\n        // shouldn't get here, but just in case\n        return true;\n    }\n\n    private static final HashMap<String, Long> creditCardTypeMap;\n    static {\n        HashMap<String, Long> map = new HashMap<>(5);\n        map.put(\"visa\", CreditCardValidator.VISA);\n        map.put(\"mastercard\", CreditCardValidator.MASTERCARD);\n        map.put(\"amex\", CreditCardValidator.AMEX);\n        map.put(\"discover\", CreditCardValidator.DISCOVER);\n        map.put(\"dinersclub\", CreditCardValidator.DINERS);\n        creditCardTypeMap = map;\n    }\n    private static final long allCreditCards = CreditCardValidator.VISA + CreditCardValidator.MASTERCARD +\n            CreditCardValidator.AMEX + CreditCardValidator.DISCOVER + CreditCardValidator.DINERS;\n\n    public static final HashMap<String, ArtifactExecutionInfo.AuthzAction> verbAuthzActionEnumMap;\n    static {\n        HashMap<String, ArtifactExecutionInfo.AuthzAction> map = new HashMap<>(6);\n        map.put(\"create\", ArtifactExecutionInfo.AUTHZA_CREATE);\n        map.put(\"update\", ArtifactExecutionInfo.AUTHZA_UPDATE);\n        map.put(\"store\", ArtifactExecutionInfo.AUTHZA_UPDATE);\n        map.put(\"delete\", ArtifactExecutionInfo.AUTHZA_DELETE);\n        map.put(\"view\", ArtifactExecutionInfo.AUTHZA_VIEW);\n        map.put(\"find\", ArtifactExecutionInfo.AUTHZA_VIEW);\n        map.put(\"get\", ArtifactExecutionInfo.AUTHZA_VIEW);\n        map.put(\"search\", ArtifactExecutionInfo.AUTHZA_VIEW);\n        verbAuthzActionEnumMap = map;\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    public static void nestedRemoveNullsFromResultMap(Map<String, Object> result) {\n        if (result == null) return;\n        Iterator<Map.Entry<String, Object>> iter = result.entrySet().iterator();\n        while (iter.hasNext()) {\n            Map.Entry<String, Object> entry = iter.next();\n            Object value = entry.getValue();\n            if (value == null) { iter.remove(); continue; }\n            if (value instanceof EntityValue) {\n                entry.setValue(CollectionUtilities.removeNullsFromMap(((EntityValue) value).getMap()));\n            } else if (value instanceof EntityList) {\n                entry.setValue(((EntityList) value).getValueMapList());\n            } else if (value instanceof Collection) {\n                boolean foundEv = false;\n                Collection valCol = (Collection) value;\n                for (Object colEntry : valCol) {\n                    if (colEntry instanceof EntityValue) {\n                        foundEv = true;\n                    } else if (colEntry instanceof Map) {\n                        CollectionUtilities.removeNullsFromMap((Map) colEntry);\n                    } else {\n                        break;\n                    }\n                }\n                if (foundEv) {\n                    ArrayList newCol = new ArrayList(valCol.size());\n                    for (Object colEntry : valCol) {\n                        if (colEntry instanceof EntityValue) {\n                            newCol.add(CollectionUtilities.removeNullsFromMap(((EntityValue) colEntry).getMap()));\n                        } else {\n                            newCol.add(colEntry);\n                        }\n                    }\n                    entry.setValue(newCol);\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/ServiceEcaRule.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.service\n\nimport groovy.transform.CompileStatic\nimport org.moqui.impl.actions.XmlAction\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.util.MNode\nimport org.moqui.util.StringUtilities\n\nimport jakarta.transaction.Status\nimport jakarta.transaction.Synchronization\nimport jakarta.transaction.Transaction\nimport jakarta.transaction.TransactionManager\nimport javax.transaction.xa.XAException\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n@CompileStatic\nclass ServiceEcaRule {\n    protected final static Logger logger = LoggerFactory.getLogger(ServiceEcaRule.class)\n\n    protected final MNode secaNode\n    public final String location, serviceName, serviceNameNoHash, when\n    public final int priority\n    protected final boolean nameIsPattern, runOnError\n\n    protected final XmlAction condition\n    protected final XmlAction actions\n\n    ServiceEcaRule(ExecutionContextFactoryImpl ecfi, MNode secaNode, String location) {\n        this.secaNode = secaNode\n        this.location = location\n        serviceName = secaNode.attribute(\"service\")\n        serviceNameNoHash = serviceName.replace(\"#\", \"\")\n        when = secaNode.attribute(\"when\")\n        nameIsPattern = secaNode.attribute(\"name-is-pattern\") == \"true\"\n        runOnError = secaNode.attribute(\"run-on-error\") == \"true\"\n        priority = (secaNode.attribute(\"priority\") ?: \"5\") as int\n\n        // prep condition\n        if (secaNode.hasChild(\"condition\") && secaNode.first(\"condition\").children) {\n            // the script is effectively the first child of the condition element\n            condition = new XmlAction(ecfi, secaNode.first(\"condition\").children.get(0), location + \".condition\")\n        } else {\n            condition = (XmlAction) null\n        }\n        // prep actions\n        if (secaNode.hasChild(\"actions\")) {\n            String actionsLocation = null\n            String secaId = secaNode.attribute(\"id\")\n            if (secaId != null && !secaId.isEmpty()) actionsLocation = \"seca.\" + secaId + \".\" + StringUtilities.getRandomString(8) + \".actions\"\n            actions = new XmlAction(ecfi, secaNode.first(\"actions\"), actionsLocation)\n        } else {\n            actions = (XmlAction) null\n        }\n    }\n\n    void runIfMatches(String serviceName, Map<String, Object> parameters, Map<String, Object> results, String when, ExecutionContextImpl ec) {\n        // see if we match this event and should run\n        if (!nameIsPattern && !serviceNameNoHash.equals(serviceName)) return\n        if (nameIsPattern && !serviceName.matches(this.serviceNameNoHash)) return\n        if (!this.when.equals(when)) return\n        if (!runOnError && ec.getMessage().hasError()) return\n\n        standaloneRun(parameters, results, ec)\n    }\n\n    void standaloneRun(Map<String, Object> parameters, Map<String, Object> results, ExecutionContextImpl ec) {\n        try {\n            ec.context.push()\n            ec.context.putAll(parameters)\n            ec.context.put(\"parameters\", parameters)\n            if (results != null) {\n                ec.context.putAll(results)\n                ec.context.put(\"results\", results)\n            }\n\n            // run the condition and if passes run the actions\n            boolean conditionPassed = true\n            if (condition) conditionPassed = condition.checkCondition(ec)\n            if (conditionPassed) {\n                if (actions) actions.run(ec)\n            }\n        } finally {\n            ec.context.pop()\n        }\n    }\n\n    void registerTx(String serviceName, Map<String, Object> parameters, Map<String, Object> results, ExecutionContextFactoryImpl ecfi) {\n        if (!this.serviceNameNoHash.equals(serviceName)) return\n        def sxr = new SecaSynchronization(this, parameters, results, ecfi)\n        sxr.enlist()\n    }\n\n    @Override\n    String toString() { return secaNode.toString() }\n\n    static class SecaSynchronization implements Synchronization {\n        protected final static Logger logger = LoggerFactory.getLogger(SecaSynchronization.class)\n\n        protected ExecutionContextFactoryImpl ecfi\n        protected ServiceEcaRule sec\n        protected Map<String, Object> parameters\n        protected Map<String, Object> results\n\n        protected Transaction tx = null\n\n        SecaSynchronization(ServiceEcaRule sec, Map<String, Object> parameters, Map<String, Object> results, ExecutionContextFactoryImpl ecfi) {\n            this.ecfi = ecfi\n            this.sec = sec\n            this.parameters = new HashMap(parameters)\n            this.results = new HashMap(results)\n        }\n\n        void enlist() {\n            TransactionManager tm = ecfi.transactionFacade.getTransactionManager()\n            if (tm == null || tm.getStatus() != Status.STATUS_ACTIVE) throw new XAException(\"Cannot enlist: no transaction manager or transaction not active\")\n\n            Transaction tx = tm.getTransaction()\n            if (tx == null) throw new XAException(XAException.XAER_NOTA)\n\n            this.tx = tx\n            tx.registerSynchronization(this)\n        }\n\n        @Override\n        void beforeCompletion() { }\n\n        @Override\n        void afterCompletion(int status) {\n            if (status == Status.STATUS_COMMITTED) {\n                if (\"tx-commit\".equals(sec.when)) runInThreadAndTx()\n            } else {\n                if (\"tx-rollback\".equals(sec.when)) runInThreadAndTx()\n            }\n        }\n\n        void runInThreadAndTx() {\n            ExecutionContextImpl.ThreadPoolRunnable runnable = new ExecutionContextImpl.ThreadPoolRunnable(ecfi, {\n                boolean beganTransaction = ecfi.transactionFacade.begin(null)\n                try {\n                    sec.standaloneRun(parameters, results, ecfi.getEci())\n                } catch (Throwable t) {\n                    logger.error(\"Error running Service TX ECA rule\", t)\n                    ecfi.transactionFacade.rollback(beganTransaction, \"Error running Service TX ECA rule\", t)\n                } finally {\n                    if (beganTransaction && ecfi.transactionFacade.isTransactionInPlace())\n                        ecfi.transactionFacade.commit()\n                }\n            })\n            ecfi.workerPool.submit(runnable)\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/ServiceFacadeImpl.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.service\n\nimport groovy.transform.CompileStatic\nimport org.moqui.Moqui\nimport org.moqui.impl.context.ArtifactExecutionInfoImpl\nimport org.moqui.impl.context.ArtifactExecutionInfoImpl.ArtifactTypeStats\nimport org.moqui.impl.context.ContextJavaUtil\nimport org.moqui.impl.context.ContextJavaUtil.CustomScheduledExecutor\nimport org.moqui.resource.ResourceReference\nimport org.moqui.context.ToolFactory\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.resource.ClasspathResourceReference\nimport org.moqui.impl.service.runner.EntityAutoServiceRunner\nimport org.moqui.impl.service.runner.RemoteJsonRpcServiceRunner\nimport org.moqui.service.*\nimport org.moqui.util.CollectionUtilities\nimport org.moqui.util.MNode\nimport org.moqui.util.ObjectUtilities\nimport org.moqui.util.RestClient\nimport org.moqui.util.StringUtilities\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport jakarta.mail.internet.MimeMessage\n\nimport javax.cache.Cache\nimport java.sql.Timestamp\nimport java.util.concurrent.*\nimport java.util.concurrent.atomic.AtomicInteger\nimport java.util.concurrent.locks.ReentrantLock\n\n@CompileStatic\nclass ServiceFacadeImpl implements ServiceFacade {\n    protected final static Logger logger = LoggerFactory.getLogger(ServiceFacadeImpl.class)\n\n    public final ExecutionContextFactoryImpl ecfi\n\n    protected final Cache<String, ServiceDefinition> serviceLocationCache\n    protected final ReentrantLock locationLoadLock = new ReentrantLock()\n\n    protected Map<String, ArrayList<ServiceEcaRule>> secaRulesByServiceName = new HashMap<>()\n    protected final List<EmailEcaRule> emecaRuleList = new ArrayList<>()\n    public final RestApi restApi\n\n    protected final Map<String, ServiceRunner> serviceRunners = new HashMap<>()\n\n    private ScheduledJobRunner jobRunner = null\n    public final ThreadPoolExecutor jobWorkerPool\n    private LoadRunner loadRunner = null\n\n    /** Distributed ExecutorService for async services, etc */\n    protected ExecutorService distributedExecutorService = null\n\n    protected final ConcurrentMap<String, List<ServiceCallback>> callbackRegistry = new ConcurrentHashMap<>()\n\n    ServiceFacadeImpl(ExecutionContextFactoryImpl ecfi) {\n        this.ecfi = ecfi\n        serviceLocationCache = ecfi.cacheFacade.getCache(\"service.location\", String.class, ServiceDefinition.class)\n\n        MNode serviceFacadeNode = ecfi.confXmlRoot.first(\"service-facade\")\n        serviceFacadeNode.setSystemExpandAttributes(true)\n        // load service runners from configuration\n        for (MNode serviceType in serviceFacadeNode.children(\"service-type\")) {\n            ServiceRunner sr = (ServiceRunner) Thread.currentThread().getContextClassLoader()\n                    .loadClass(serviceType.attribute(\"runner-class\")).newInstance()\n            serviceRunners.put(serviceType.attribute(\"name\"), sr.init(this))\n        }\n\n        // load REST API\n        restApi = new RestApi(ecfi)\n\n        jobWorkerPool = makeWorkerPool()\n    }\n\n    private ThreadPoolExecutor makeWorkerPool() {\n        MNode serviceFacadeNode = ecfi.confXmlRoot.first(\"service-facade\")\n\n        int jobQueueMax = (serviceFacadeNode.attribute(\"job-queue-max\") ?: \"0\") as int\n        int coreSize = (serviceFacadeNode.attribute(\"job-pool-core\") ?: \"2\") as int\n        int maxSize = (serviceFacadeNode.attribute(\"job-pool-max\") ?: \"8\") as int\n        int availableProcessorsSize = Runtime.getRuntime().availableProcessors() * 2\n        if (availableProcessorsSize > maxSize) {\n            logger.info(\"Setting Service Job worker pool size to ${availableProcessorsSize} based on available processors * 2\")\n            maxSize = availableProcessorsSize\n        }\n        long aliveTime = (serviceFacadeNode.attribute(\"worker-pool-alive\") ?: \"120\") as long\n\n        logger.info(\"Initializing Service Job ThreadPoolExecutor: queue limit ${jobQueueMax}, pool-core ${coreSize}, pool-max ${maxSize}, pool-alive ${aliveTime}s\")\n        // make the actual queue at least maxSize to allow for stuffing the queue to get it to add threads to the pool\n        BlockingQueue<Runnable> workQueue = new LinkedBlockingQueue<>(jobQueueMax < maxSize ? maxSize : jobQueueMax)\n        return new ContextJavaUtil.WorkerThreadPoolExecutor(ecfi, coreSize, maxSize, aliveTime, TimeUnit.SECONDS,\n                workQueue, new ContextJavaUtil.JobThreadFactory())\n    }\n\n    void postFacadeInit() {\n        // load Service ECA rules\n        loadSecaRulesAll()\n        // load Email ECA rules\n        loadEmecaRulesAll()\n\n        MNode serviceFacadeNode = ecfi.confXmlRoot.first(\"service-facade\")\n\n        // get distributed ExecutorService\n        String distEsFactoryName = serviceFacadeNode.attribute(\"distributed-factory\")\n        if (distEsFactoryName) {\n            logger.info(\"Getting Async Distributed Service ExecutorService (using ToolFactory ${distEsFactoryName})\")\n            ToolFactory<ExecutorService> esToolFactory = ecfi.getToolFactory(distEsFactoryName)\n            if (esToolFactory == null) {\n                logger.warn(\"Could not find ExecutorService ToolFactory with name ${distEsFactoryName}, distributed async service calls will be run local only\")\n                distributedExecutorService = null\n            } else {\n                distributedExecutorService = esToolFactory.getInstance()\n            }\n        } else {\n            logger.info(\"No distributed-factory specified, distributed async service calls will be run local only\")\n            distributedExecutorService = null\n        }\n\n        // setup service job runner\n        long jobRunnerRate = (serviceFacadeNode.attribute(\"scheduled-job-check-time\") ?: \"60\") as long\n        if (jobRunnerRate > 0L) {\n            // wait before first run to make sure all is loaded and we're past an initial activity burst\n            long initialDelay = 120L\n            logger.info(\"Starting Scheduled Service Job Runner, checking for jobs every ${jobRunnerRate} seconds after a ${initialDelay} second initial delay\")\n            jobRunner = new ScheduledJobRunner(ecfi)\n            ecfi.scheduleAtFixedRate(jobRunner, initialDelay, jobRunnerRate)\n        } else {\n            logger.warn(\"Not starting Scheduled Service Job Runner (config:${jobRunnerRate})\")\n            jobRunner = null\n        }\n\n    }\n\n    void setDistributedExecutorService(ExecutorService executorService) {\n        logger.info(\"Setting DistributedExecutorService to ${executorService.class.name}, was ${this.distributedExecutorService?.class?.name}\")\n        this.distributedExecutorService = executorService\n    }\n\n    void warmCache()  {\n        logger.info(\"Warming cache for all service definitions\")\n        long startTime = System.currentTimeMillis()\n        Set<String> serviceNames = getKnownServiceNames()\n        for (String serviceName in serviceNames) {\n            try { getServiceDefinition(serviceName) }\n            catch (Throwable t) { logger.warn(\"Error warming service cache: ${t.toString()}\") }\n        }\n        logger.info(\"Warmed service definition cache for ${serviceNames.size()} services in ${System.currentTimeMillis() - startTime}ms\")\n    }\n\n    void destroy() {\n        // destroy all service runners\n        for (ServiceRunner sr in serviceRunners.values()) sr.destroy()\n    }\n\n    ServiceRunner getServiceRunner(String type) { serviceRunners.get(type) }\n    // NOTE: this is used in the ServiceJobList screen\n    ScheduledJobRunner getJobRunner() { jobRunner }\n\n    boolean isServiceDefined(String serviceName) {\n        ServiceDefinition sd = getServiceDefinition(serviceName)\n        if (sd != null) return true\n\n        String path = ServiceDefinition.getPathFromName(serviceName)\n        String verb = ServiceDefinition.getVerbFromName(serviceName)\n        String noun = ServiceDefinition.getNounFromName(serviceName)\n        return isEntityAutoPattern(path, verb, noun)\n    }\n\n    boolean isEntityAutoPattern(String serviceName) {\n        return isEntityAutoPattern(ServiceDefinition.getPathFromName(serviceName), ServiceDefinition.getVerbFromName(serviceName),\n                ServiceDefinition.getNounFromName(serviceName))\n    }\n\n    boolean isEntityAutoPattern(String path, String verb, String noun) {\n        // if no path, verb is create|update|delete and noun is a valid entity name, do an implicit entity-auto\n        return (path == null || path.isEmpty()) && EntityAutoServiceRunner.verbSet.contains(verb) &&\n                ecfi.entityFacade.isEntityDefined(noun)\n    }\n\n    ServiceDefinition getServiceDefinition(String serviceName) {\n        if (serviceName == null) return null\n        ServiceDefinition sd = (ServiceDefinition) serviceLocationCache.get(serviceName)\n        if (sd != null) return sd\n\n\n        // now try some acrobatics to find the service, these take longer to run hence trying to avoid\n        String path = ServiceDefinition.getPathFromName(serviceName)\n        String verb = ServiceDefinition.getVerbFromName(serviceName)\n        String noun = ServiceDefinition.getNounFromName(serviceName)\n        // logger.warn(\"Getting service definition for [${serviceName}], path=[${path}] verb=[${verb}] noun=[${noun}]\")\n\n        String cacheKey = makeCacheKey(path, verb, noun)\n        boolean cacheKeySame = serviceName.equals(cacheKey)\n        if (!cacheKeySame) {\n            sd = (ServiceDefinition) serviceLocationCache.get(cacheKey)\n            if (sd != null) return sd\n        }\n\n        // at this point sd is null (from serviceName and cacheKey), so if contains key we know the service doesn't exist; do in lock to avoid reload issues\n        locationLoadLock.lock()\n        try {\n            if (serviceLocationCache.containsKey(serviceName)) return (ServiceDefinition) serviceLocationCache.get(serviceName)\n            if (!cacheKeySame && serviceLocationCache.containsKey(cacheKey)) return (ServiceDefinition) serviceLocationCache.get(cacheKey)\n        } finally {\n            locationLoadLock.unlock()\n        }\n\n        return makeServiceDefinition(serviceName, path, verb, noun)\n    }\n\n    protected ServiceDefinition makeServiceDefinition(String origServiceName, String path, String verb, String noun) {\n        locationLoadLock.lock()\n        try {\n            String cacheKey = makeCacheKey(path, verb, noun)\n            if (serviceLocationCache.containsKey(cacheKey)) {\n                // NOTE: this could be null if it's a known non-existing service\n                return (ServiceDefinition) serviceLocationCache.get(cacheKey)\n            }\n\n            MNode serviceNode = findServiceNode(path, verb, noun)\n            if (serviceNode == null) {\n                // NOTE: don't throw an exception for service not found (this is where we know there is no def), let service caller handle that\n                // Put null in the cache to remember the non-existing service\n                serviceLocationCache.put(cacheKey, null)\n                if (!origServiceName.equals(cacheKey)) serviceLocationCache.put(origServiceName, null)\n                return null\n            }\n\n            ServiceDefinition sd = new ServiceDefinition(this, path, serviceNode)\n            serviceLocationCache.put(cacheKey, sd)\n            if (!origServiceName.equals(cacheKey)) serviceLocationCache.put(origServiceName, sd)\n            return sd\n        } finally {\n            locationLoadLock.unlock()\n        }\n    }\n\n    protected static String makeCacheKey(String path, String verb, String noun) {\n        // use a consistent format as the key in the cache, keeping in mind that the verb and noun may be merged in the serviceName passed in\n        // no # here so that it doesn't matter if the caller used one or not\n        return (path != null && !path.isEmpty() ? path + '.' : '') + verb + (noun != null ? noun : '')\n    }\n\n    protected MNode findServiceNode(String path, String verb, String noun) {\n        if (path == null || path.isEmpty()) return null\n\n        // make a file location from the path\n        String partialLocation = path.replace('.', '/') + '.xml'\n        String servicePathLocation = 'service/' + partialLocation\n\n        MNode serviceNode = (MNode) null\n        ResourceReference foundRr = (ResourceReference) null\n\n        // search for the service def XML file in the classpath LAST (allow components to override, same as in entity defs)\n        ResourceReference serviceComponentRr = new ClasspathResourceReference().init(servicePathLocation)\n        if (serviceComponentRr.supportsExists() && serviceComponentRr.exists) {\n            serviceNode = findServiceNode(serviceComponentRr, verb, noun)\n            if (serviceNode != null) foundRr == serviceComponentRr\n        }\n\n        // search for the service def XML file in the components\n        for (String location in this.ecfi.getComponentBaseLocations().values()) {\n            // logger.warn(\"Finding service node for location=[${location}], servicePathLocation=[${servicePathLocation}]\")\n            serviceComponentRr = this.ecfi.resourceFacade.getLocationReference(location + \"/\" + servicePathLocation)\n            if (serviceComponentRr.supportsExists()) {\n                if (serviceComponentRr.exists) {\n                    MNode tempNode = findServiceNode(serviceComponentRr, verb, noun)\n                    if (tempNode != null) {\n                        if (foundRr != null) logger.info(\"Found service ${verb}#${noun} at ${serviceComponentRr.location} which overrides service at ${foundRr.location}\")\n                        serviceNode = tempNode\n                        foundRr = serviceComponentRr\n                    }\n                }\n            } else {\n                // only way to see if it is a valid location is to try opening the stream, so no extra conditions here\n                MNode tempNode = findServiceNode(serviceComponentRr, verb, noun)\n                if (tempNode != null) {\n                    if (foundRr != null) logger.info(\"Found service ${verb}#${noun} at ${serviceComponentRr.location} which overrides service at ${foundRr.location}\")\n                    serviceNode = tempNode\n                    foundRr = serviceComponentRr\n                }\n            }\n            // NOTE: don't quit on finding first, allow later components to override earlier: if (serviceNode != null) break\n        }\n\n        if (serviceNode == null) logger.warn(\"Service ${path}.${verb}#${noun} not found; used relative location [${servicePathLocation}]\")\n\n        return serviceNode\n    }\n\n    protected MNode findServiceNode(ResourceReference serviceComponentRr, String verb, String noun) {\n        if (serviceComponentRr == null || (serviceComponentRr.supportsExists() && !serviceComponentRr.exists)) return null\n\n        MNode serviceRoot = MNode.parse(serviceComponentRr)\n        MNode serviceNode\n        if (noun) {\n            // only accept the separated names\n            serviceNode = serviceRoot.first({ MNode it -> (\"service\".equals(it.name) || \"service-include\".equals(it.name)) &&\n                    it.attribute(\"verb\") == verb && it.attribute(\"noun\") == noun })\n        } else {\n            // we just have a verb, this should work if the noun field is empty, or if noun + verb makes up the verb passed in\n            serviceNode = serviceRoot.first({ MNode it -> (\"service\".equals(it.name) || \"service-include\".equals(it.name)) &&\n                    (it.attribute(\"verb\") + (it.attribute(\"noun\") ?: \"\")) == verb })\n        }\n\n        // if we found a service-include look up the referenced service node\n        if (serviceNode != null && \"service-include\".equals(serviceNode.name)) {\n            String includeLocation = serviceNode.attribute(\"location\")\n            if (includeLocation == null || includeLocation.isEmpty()) {\n                logger.error(\"Ignoring service-include with no location for verb ${verb} noun ${noun} in ${serviceComponentRr.location}\")\n                return null\n            }\n\n            ResourceReference includeRr = ecfi.resourceFacade.getLocationReference(includeLocation)\n            // logger.warn(\"includeLocation: ${includeLocation}\\nincludeRr: ${includeRr}\")\n            return findServiceNode(includeRr, verb, noun)\n        }\n\n        return serviceNode\n    }\n\n    Set<String> getKnownServiceNames() {\n        Set<String> sns = new TreeSet<String>()\n\n        // search declared service-file elements in Moqui Conf XML\n        for (MNode serviceFile in ecfi.confXmlRoot.first(\"service-facade\").children(\"service-file\")) {\n            String location = serviceFile.attribute(\"location\")\n            ResourceReference entryRr = ecfi.resourceFacade.getLocationReference(location)\n            findServicesInFile(\"classpath://service\", entryRr, sns)\n        }\n\n        // search for service def XML files in the components\n        for (String location in this.ecfi.getComponentBaseLocations().values()) {\n            //String location = \"component://${componentName}/service\"\n            ResourceReference serviceRr = this.ecfi.resourceFacade.getLocationReference(location + \"/service\")\n            if (serviceRr.supportsExists() && serviceRr.exists && serviceRr.supportsDirectory()) {\n                findServicesInDir(serviceRr.location, serviceRr, sns)\n            }\n        }\n\n        // TODO: how to search for service def XML files in the classpath? perhaps keep a list of service files that\n        //     have been found on the classpath so we at least have those?\n\n        return sns\n    }\n\n    List<Map> getAllServiceInfo(int levels) {\n        Map<String, Map> serviceInfoMap = [:]\n        for (String serviceName in getKnownServiceNames()) {\n            int lastDotIndex = 0\n            for (int i = 0; i < levels; i++) lastDotIndex = serviceName.indexOf(\".\", lastDotIndex+1)\n            String name = lastDotIndex == -1 ? serviceName : serviceName.substring(0, lastDotIndex)\n            Map curInfo = serviceInfoMap.get(name)\n            if (curInfo) {\n                CollectionUtilities.addToBigDecimalInMap(\"services\", 1.0, curInfo)\n            } else {\n                serviceInfoMap.put(name, [name:name, services:1])\n            }\n        }\n        TreeSet<String> nameSet = new TreeSet(serviceInfoMap.keySet())\n        List<Map> serviceInfoList = []\n        for (String name in nameSet) serviceInfoList.add(serviceInfoMap.get(name))\n        return serviceInfoList\n    }\n\n    protected void findServicesInDir(String baseLocation, ResourceReference dir, Set<String> sns) {\n        // logger.warn(\"Finding services in [${dir.location}]\")\n        for (ResourceReference entryRr in dir.directoryEntries) {\n            if (entryRr.directory) {\n                findServicesInDir(baseLocation, entryRr, sns)\n            } else if (entryRr.fileName.endsWith(\".xml\")) {\n                // logger.warn(\"Finding services in [${entryRr.location}], baseLocation=[${baseLocation}]\")\n                if (entryRr.fileName.endsWith(\".secas.xml\") || entryRr.fileName.endsWith(\".emecas.xml\") ||\n                        entryRr.fileName.endsWith(\".rest.xml\")) continue\n                findServicesInFile(baseLocation, entryRr, sns)\n            }\n        }\n    }\n    protected void findServicesInFile(String baseLocation, ResourceReference entryRr, Set<String> sns) {\n        MNode serviceRoot = MNode.parse(entryRr)\n        if ((serviceRoot.getName()) in [\"secas\", \"emecas\", \"resource\"]) return\n        if (serviceRoot.getName() != \"services\") {\n            logger.info(\"While finding service ignoring XML file [${entryRr.location}] in a services directory because the root element is ${serviceRoot.name} and not services\")\n            return\n        }\n\n        // get the service file location without the .xml and without everything up to the \"service\" directory\n        String location = entryRr.location.substring(0, entryRr.location.lastIndexOf(\".\"))\n        if (location.startsWith(baseLocation)) location = location.substring(baseLocation.length())\n        if (location.charAt(0) == '/' as char) location = location.substring(1)\n        location = location.replace('/', '.')\n\n        for (MNode serviceNode in serviceRoot.children) {\n            String nodeName = serviceNode.name\n            if (!\"service\".equals(nodeName) && !\"service-include\".equals(nodeName)) continue\n            sns.add(location + \".\" + serviceNode.attribute(\"verb\") +\n                    (serviceNode.attribute(\"noun\") ? \"#\" + serviceNode.attribute(\"noun\") : \"\"))\n        }\n    }\n\n    void loadSecaRulesAll() {\n        int numLoaded = 0\n        int numFiles = 0\n        HashMap<String, ServiceEcaRule> ruleByIdMap = new HashMap<>()\n        LinkedList<ServiceEcaRule> ruleNoIdList = new LinkedList<>()\n        // search for the service def XML file in the components\n        for (String location in this.ecfi.getComponentBaseLocations().values()) {\n            ResourceReference serviceDirRr = this.ecfi.resourceFacade.getLocationReference(location + \"/service\")\n            if (serviceDirRr.supportsAll()) {\n                // if for some weird reason this isn't a directory, skip it\n                if (!serviceDirRr.isDirectory()) continue\n                for (ResourceReference rr in serviceDirRr.directoryEntries) {\n                    if (!rr.fileName.endsWith(\".secas.xml\")) continue\n                    numLoaded += loadSecaRulesFile(rr, ruleByIdMap, ruleNoIdList)\n                    numFiles++\n                }\n            } else {\n                logger.warn(\"Can't load SECA rules from component at [${serviceDirRr.location}] because it doesn't support exists/directory/etc\")\n            }\n        }\n        if (logger.infoEnabled) logger.info(\"Loaded ${numLoaded} Service ECA rules from ${numFiles} .secas.xml files, ${ruleNoIdList.size()} rules have no id, ${ruleNoIdList.size() + ruleByIdMap.size()} SECA rules active\")\n\n        Map<String, ArrayList<ServiceEcaRule>> ruleMap = new HashMap<>()\n        ruleNoIdList.addAll(ruleByIdMap.values())\n        for (ServiceEcaRule ecaRule in ruleNoIdList) {\n\n            // find all matching services if the name is a pattern, otherwise just add the service name to the list\n            boolean nameIsPattern = ecaRule.nameIsPattern\n            List<String> serviceNameList = new ArrayList<>()\n            if (nameIsPattern) {\n                String serviceNamePattern = ecaRule.serviceName\n                for (String ksn : knownServiceNames) {\n                    if (ksn.matches(serviceNamePattern)) {\n                        serviceNameList.add(ksn)\n                    }\n                }\n            } else {\n                serviceNameList.add(ecaRule.serviceName)\n            }\n\n            // add each of the services in the list to the rule map\n            for (String serviceName in serviceNameList) {\n                // remove the hash if there is one to more consistently match the service name\n                serviceName = StringUtilities.removeChar(serviceName, (char) '#')\n                ArrayList<ServiceEcaRule> lst = ruleMap.get(serviceName)\n                if (lst == null) {\n                    lst = new ArrayList<>()\n                    ruleMap.put(serviceName, lst)\n                }\n                // insert by priority\n                int insertIdx = 0\n                for (int i = 0; i < lst.size(); i++) {\n                    ServiceEcaRule lstSer = (ServiceEcaRule) lst.get(i)\n                    if (lstSer.priority <= ecaRule.priority) { insertIdx++ } else { break }\n                }\n                lst.add(insertIdx, ecaRule)\n            }\n        }\n\n        // replace entire SECA rules Map in one operation\n        secaRulesByServiceName = ruleMap\n    }\n    protected int loadSecaRulesFile(ResourceReference rr, HashMap<String, ServiceEcaRule> ruleByIdMap, LinkedList<ServiceEcaRule> ruleNoIdList) {\n        MNode serviceRoot = MNode.parse(rr)\n        int numLoaded = 0\n        for (MNode secaNode in serviceRoot.children(\"seca\")) {\n            // a service name is valid if it is not a pattern and represents a defined service or if it is a pattern and\n            // matches at least one of the known service names\n            String serviceName = secaNode.attribute(\"service\")\n            boolean nameIsPattern = secaNode.attribute(\"name-is-pattern\") == \"true\"\n            boolean serviceDefined = false\n            if (nameIsPattern) {\n                for (String ksn : knownServiceNames) {\n                    serviceDefined = ksn.matches(serviceName)\n                    if (serviceDefined) break\n                }\n            } else {\n                serviceDefined = isServiceDefined(serviceName)\n            }\n            if (!serviceDefined) {\n                logger.warn(\"Invalid service name ${serviceName} found in SECA file ${rr.location}, skipping\")\n                continue\n            }\n\n            ServiceEcaRule ecaRule = new ServiceEcaRule(ecfi, secaNode, rr.location)\n            String ruleId = secaNode.attribute(\"id\")\n            if (ruleId != null && !ruleId.isEmpty()) ruleByIdMap.put(ruleId, ecaRule)\n            else ruleNoIdList.add(ecaRule)\n\n            numLoaded++\n        }\n        if (logger.isTraceEnabled()) logger.trace(\"Loaded ${numLoaded} Service ECA rules from [${rr.location}]\")\n        return numLoaded\n    }\n\n    ArrayList<ServiceEcaRule> secaRules(String serviceName) {\n        // NOTE: no need to remove the hash, ServiceCallSyncImpl now passes a service name with no hash\n        return (ArrayList<ServiceEcaRule>) secaRulesByServiceName.get(serviceName)\n    }\n    static void runSecaRules(String serviceName, Map<String, Object> parameters, Map<String, Object> results, String when,\n                      ArrayList<ServiceEcaRule> lst, ExecutionContextImpl eci) {\n        int lstSize = lst.size()\n        for (int i = 0; i < lstSize; i++) {\n            ServiceEcaRule ser = (ServiceEcaRule) lst.get(i)\n            ser.runIfMatches(serviceName, parameters, results, when, eci)\n        }\n    }\n    void registerTxSecaRules(String serviceName, Map<String, Object> parameters, Map<String, Object> results, ArrayList<ServiceEcaRule> lst) {\n        int lstSize = lst.size()\n        for (int i = 0; i < lstSize; i++) {\n            ServiceEcaRule ser = (ServiceEcaRule) lst.get(i)\n            if (ser.when.startsWith(\"tx-\")) ser.registerTx(serviceName, parameters, results, ecfi)\n        }\n    }\n\n    int getSecaRuleCount() {\n        int count = 0\n        for (List ruleList in secaRulesByServiceName.values()) count += ruleList.size()\n        return count\n    }\n\n\n    protected void loadEmecaRulesAll() {\n        if (emecaRuleList.size() > 0) emecaRuleList.clear()\n\n        // search for the service def XML file in the components\n        for (String location in this.ecfi.getComponentBaseLocations().values()) {\n            ResourceReference serviceDirRr = this.ecfi.resourceFacade.getLocationReference(location + \"/service\")\n            if (serviceDirRr.supportsAll()) {\n                // if for some weird reason this isn't a directory, skip it\n                if (!serviceDirRr.isDirectory()) continue\n                for (ResourceReference rr in serviceDirRr.directoryEntries) {\n                    if (!rr.fileName.endsWith(\".emecas.xml\")) continue\n                    loadEmecaRulesFile(rr)\n                }\n            } else {\n                logger.warn(\"Can't load Email ECA rules from component at [${serviceDirRr.location}] because it doesn't support exists/directory/etc\")\n            }\n        }\n    }\n    protected void loadEmecaRulesFile(ResourceReference rr) {\n        MNode emecasRoot = MNode.parse(rr)\n        int numLoaded = 0\n        for (MNode emecaNode in emecasRoot.children(\"emeca\")) {\n            EmailEcaRule eer = new EmailEcaRule(ecfi, emecaNode, rr.location)\n            emecaRuleList.add(eer)\n            numLoaded++\n        }\n        if (logger.infoEnabled) logger.info(\"Loaded [${numLoaded}] Email ECA rules from [${rr.location}]\")\n    }\n\n    void runEmecaRules(MimeMessage message, String emailServerId) {\n        ExecutionContextImpl eci = ecfi.getEci()\n        for (EmailEcaRule eer in emecaRuleList) eer.runIfMatches(message, emailServerId, eci)\n    }\n\n    @Override\n    ServiceCallSync sync() { return new ServiceCallSyncImpl(this) }\n    @Override\n    ServiceCallAsync async() { return new ServiceCallAsyncImpl(this) }\n    @Override\n    ServiceCallJob job(String jobName) { return new ServiceCallJobImpl(jobName, this) }\n\n    @Override\n    ServiceCallSpecial special() { return new ServiceCallSpecialImpl(this) }\n\n    @Override\n    Map<String, Object> callJsonRpc(String location, String method, Map<String, Object> parameters) {\n        return RemoteJsonRpcServiceRunner.runJsonService(null, location, method, parameters, ecfi.getExecutionContext())\n    }\n\n    @Override\n    RestClient rest() { return new RestClient() }\n\n    @Override\n    void registerCallback(String serviceName, ServiceCallback serviceCallback) {\n        List<ServiceCallback> callbackList = callbackRegistry.get(serviceName)\n        if (callbackList == null) {\n            callbackList = new CopyOnWriteArrayList()\n            callbackRegistry.putIfAbsent(serviceName, callbackList)\n            callbackList = callbackRegistry.get(serviceName)\n        }\n        callbackList.add(serviceCallback)\n    }\n\n    void callRegisteredCallbacks(String serviceName, Map<String, Object> context, Map<String, Object> result) {\n        List<ServiceCallback> callbackList = callbackRegistry.get(serviceName)\n        if (callbackList != null && callbackList.size() > 0)\n            for (ServiceCallback scb in callbackList) scb.receiveEvent(context, result)\n    }\n\n    void callRegisteredCallbacksThrowable(String serviceName, Map<String, Object> context, Throwable t) {\n        List<ServiceCallback> callbackList = callbackRegistry.get(serviceName)\n        if (callbackList != null && callbackList.size() > 0)\n            for (ServiceCallback scb in callbackList) scb.receiveEvent(context, t)\n    }\n\n    // ==========================\n    // Service LoadRunner Classes\n    // ==========================\n\n    synchronized LoadRunner getLoadRunner() {\n        if (loadRunner == null) loadRunner = new LoadRunner(ecfi)\n        return loadRunner\n    }\n\n    static class LoadRunnerServiceRunnable implements Runnable, Externalizable {\n        volatile ExecutionContextFactoryImpl ecfi\n        volatile LoadRunner loadRunner\n        String serviceName, parametersExpr\n\n        LoadRunnerServiceRunnable() {\n            // init the other objects that can't be serialized\n            ecfi = (ExecutionContextFactoryImpl) Moqui.getExecutionContextFactory()\n            loadRunner = ecfi.serviceFacade.getLoadRunner()\n        }\n        LoadRunnerServiceRunnable(String serviceName, String parametersExpr, LoadRunner loadRunner) {\n            this.loadRunner = loadRunner\n            this.ecfi = loadRunner.ecfi\n            this.serviceName = serviceName\n            this.parametersExpr = parametersExpr\n        }\n\n        @Override\n        void writeExternal(ObjectOutput out) throws IOException {\n            out.writeUTF(serviceName) // never null\n            out.writeObject(parametersExpr) // may be null\n        }\n\n        @Override\n        void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException {\n            serviceName = objectInput.readUTF()\n            parametersExpr = objectInput.readObject()\n        }\n\n        @Override void run() {\n            try {\n                runInternal()\n            } catch (Throwable t) {\n                logger.error(\"Error in LoadRunner service run\", t)\n            }\n        }\n        void runInternal() {\n            // check for active Transaction\n            if (getEcfi().transactionFacade.isTransactionInPlace()) {\n                logger.error(\"In LoadRunner service ${serviceName} a transaction is in place for thread ${Thread.currentThread().getName()}, trying to commit\")\n                try {\n                    getEcfi().transactionFacade.destroyAllInThread()\n                } catch (Exception e) {\n                    logger.error(\"LoadRunner commit in place transaction failed for thread ${Thread.currentThread().getName()}\", e)\n                }\n            }\n            // check for active ExecutionContext\n            ExecutionContextImpl activeEc = getEcfi().activeContext.get()\n            if (activeEc != null) {\n                logger.error(\"In LoadRunner service ${serviceName} there is already an ExecutionContext for user ${activeEc.user.username} (from ${activeEc.forThreadId}:${activeEc.forThreadName}) in this thread ${Thread.currentThread().id}:${Thread.currentThread().name}, destroying\")\n                try {\n                    activeEc.destroy()\n                } catch (Throwable t) {\n                    logger.error(\"Error destroying LoadRunner already in place in ServiceCallAsync in thread ${Thread.currentThread().id}:${Thread.currentThread().name}\", t)\n                }\n            }\n\n            LoadRunnerServiceInfo serviceInfo = loadRunner.getServiceInfo(serviceName, parametersExpr)\n            if (serviceInfo == null) {\n                logger.error(\"Service Info not found for ${serviceName} ${parametersExpr}, not running\")\n                return\n            }\n            String parametersExpr = serviceInfo.parametersExpr\n            Map<String, Object> parameters = [index:loadRunner.execIndex.getAndIncrement()] as Map<String, Object>\n            if (parametersExpr != null && !parametersExpr.isEmpty()) {\n                try {\n                    Map<String, Object> exprMap = (Map<String, Object>) loadRunner.ecfi.getEci().resourceFacade\n                            .expression(parametersExpr, null, parameters)\n                    if (exprMap != null) parameters.putAll(exprMap)\n                } catch (Throwable t) {\n                    logger.error(\"Error in Service LoadRunner parameter expression: ${parametersExpr}\", t)\n                }\n            }\n\n            // before starting, and tracking the startTime, do a small random delay for variation in run times\n            if (serviceInfo.runDelayVaryMs != 0)\n                Thread.sleep(ThreadLocalRandom.current().nextInt(serviceInfo.runDelayVaryMs))\n\n            long startTime = System.currentTimeMillis()\n            ExecutionContextImpl threadEci = ecfi.getEci()\n            try {\n                // always login anonymous, disable authz below\n                threadEci.userFacade.loginAnonymousIfNoUser()\n\n                // run the service\n                try {\n                    serviceInfo.lastResult = threadEci.serviceFacade.sync().name(serviceName)\n                            .parameters(parameters).disableAuthz().call()\n                } catch (Throwable t) {\n                    // logged elsewhere, just count and swallow\n                    serviceInfo.errorCount++\n                }\n\n                // count the run and accumulate stats\n                serviceInfo.countRun(loadRunner, startTime, System.currentTimeMillis(),\n                        threadEci.artifactExecutionFacade.getArtifactTypeStats())\n            } finally {\n                if (threadEci != null) threadEci.destroy()\n            }\n        }\n    }\n    static class LoadRunnerServiceStats {\n        long lastRunTime = 0, beginTime = 0, totalTime = 0, totalSquaredTime = 0, minTime = Long.MAX_VALUE, maxTime = 0\n        int runCount = 0, errorCount = 0\n        ArtifactTypeStats artifactTypeStats = new ArtifactTypeStats()\n        Map getMap() {\n            Map newMap = [lastRunTime:lastRunTime, beginTime:beginTime, totalTime:totalTime,\n                    totalSquaredTime:totalSquaredTime, minTime:minTime, maxTime:maxTime, runCount:runCount, errorCount:errorCount] as Map<String, Object>\n            newMap.put(\"artifactTypeStats\", ObjectUtilities.objectToMap(artifactTypeStats))\n            return newMap\n        }\n    }\n    static class LoadRunnerServiceInfo extends LoadRunnerServiceStats {\n        String serviceName, parametersExpr\n        int targetThreads, runDelayMs, runDelayVaryMs, rampDelayMs, timeBinLength, timeBinsKeep\n        AtomicInteger currentThreads = new AtomicInteger(0)\n\n        Map lastResult = null\n        ConcurrentLinkedDeque<LoadRunnerServiceStats> timeBinList = new ConcurrentLinkedDeque<>()\n        ArrayList<ScheduledFuture> runFutures = new ArrayList<>()\n        ScheduledFuture rampFuture = null\n\n        LoadRunnerServiceInfo(String serviceName, String parametersExpr, int targetThreads,\n                int runDelayMs, int runDelayVaryMs, int rampDelayMs, int timeBinLength, int timeBinsKeep) {\n            this.serviceName = serviceName; this.parametersExpr = parametersExpr\n            this.targetThreads = targetThreads\n            this.runDelayMs = runDelayMs; this.runDelayVaryMs = runDelayVaryMs; this.rampDelayMs = rampDelayMs\n            this.timeBinLength = timeBinLength; this.timeBinsKeep = timeBinsKeep\n        }\n\n        void countRun(LoadRunner loadRunner, long startTime, long endTime, ArtifactTypeStats stats) {\n            long runTime = endTime - startTime\n            // logger.info(\"count run ${serviceName} ${runTime} ${Thread.currentThread().name}\")\n            LoadRunnerServiceStats curBin = null\n\n            // find the current time bin in a semaphore locked section, the rest is increments and can be run multithreaded\n            loadRunner.mutateLock.lock()\n            // logger.info(\"count run after lock ${serviceName} ${runTime} ${Thread.currentThread().name}\")\n            try {\n                if (beginTime == 0) beginTime = startTime\n\n                curBin = timeBinList.isEmpty() ? null : timeBinList.getLast()\n                // create and add a new bin if there are none or if this hit is after the bin's end time (need to advance the bin)\n                if (curBin == null || curBin.beginTime + timeBinLength < startTime) {\n                    curBin = new LoadRunnerServiceStats()\n                    curBin.beginTime = startTime\n                    timeBinList.add(curBin)\n                    if (timeBinList.size() > timeBinsKeep) {\n                        LoadRunnerServiceStats removeBin = timeBinList.removeFirst()\n                        // logger.info(\"Removed time bin starting ${new Timestamp(removeBin.beginTime)} count ${removeBin.runCount}\")\n                    }\n                }\n\n                // some exceptions, these are multiple operations and need to be in the locked section to be accurate, maybe don't need to be...\n                if (runTime < this.minTime) this.minTime = runTime\n                if (runTime > this.maxTime) this.maxTime = runTime\n                if (runTime < curBin.minTime) curBin.minTime = runTime\n                if (runTime > curBin.maxTime) curBin.maxTime = runTime\n            } finally {\n                loadRunner.mutateLock.unlock()\n                // logger.info(\"count run after unlock ${serviceName} ${runTime} ${Thread.currentThread().name}\")\n                // loadRunner.logFutures()\n            }\n\n            // for all runs\n            this.runCount++\n            this.lastRunTime = endTime\n            this.totalTime += runTime\n            this.totalSquaredTime += runTime * runTime\n            this.artifactTypeStats.add(stats)\n\n            // same thing for just this bin\n            curBin.runCount++\n            curBin.lastRunTime = endTime\n            curBin.totalTime += runTime\n            curBin.totalSquaredTime += runTime * runTime\n            curBin.artifactTypeStats.add(stats)\n        }\n\n        void addThread(LoadRunner loadRunner) {\n            LoadRunnerServiceRunnable runnable = new LoadRunnerServiceRunnable(serviceName, parametersExpr, loadRunner)\n            // NOTE: use scheduleWithFixedDelay so delay is wait between terminate of one and start of another, better for both short and long running test runs\n            ScheduledFuture future = loadRunner.scheduledExecutor.scheduleWithFixedDelay(runnable, 1, runDelayMs, TimeUnit.MILLISECONDS)\n            // logger.info(\"Added run thread runDelayMs ${runDelayMs} ${runDelayMs?.class?.name} done ${future.done} ${future.toString()}\")\n            runFutures.add(future)\n        }\n        void addRampThread(LoadRunner loadRunner) {\n            if (rampFuture != null && !rampFuture)\n            beginTime = System.currentTimeMillis()\n            LoadRunnerRamperRunnable runnable = new LoadRunnerRamperRunnable(loadRunner, this)\n            // NOTE: use scheduleAtFixedRate so one is added each delay period regardless of how long it takes (generally not long)\n            rampFuture = loadRunner.scheduledExecutor.scheduleAtFixedRate(runnable, 1, rampDelayMs, TimeUnit.MILLISECONDS)\n        }\n        void resetStats() {\n            lastRunTime = 0; beginTime = 0; totalTime = 0; totalSquaredTime = 0; minTime = Long.MAX_VALUE; maxTime = 0\n            runCount = 0; errorCount = 0\n            artifactTypeStats = new ArtifactTypeStats()\n            lastResult = null\n            timeBinList = new ConcurrentLinkedDeque<>()\n        }\n    }\n    static class LoadRunnerRamperRunnable implements Runnable {\n        LoadRunner loadRunner\n        LoadRunnerServiceInfo serviceInfo\n        LoadRunnerRamperRunnable(LoadRunner loadRunner, LoadRunnerServiceInfo serviceInfo) {\n            this.loadRunner = loadRunner\n            this.serviceInfo = serviceInfo\n        }\n        @Override void run() {\n            if (serviceInfo.currentThreads < serviceInfo.targetThreads) {\n                serviceInfo.addThread(loadRunner)\n                // may not actually need AtomicInteger here, but for ramp down will need a list of ScheduledFuture objects and might be useful there\n                serviceInfo.currentThreads.incrementAndGet()\n            }\n            // TODO add delayed ramp-down, useful for some performance behavior patterns but usually redundant with delayed ramp up to look for elbows in the response time over time\n        }\n    }\n    static class LoadRunnerThreadFactory implements ThreadFactory {\n        private final ThreadGroup workerGroup = new ThreadGroup(\"LoadRunner\")\n        private final AtomicInteger threadNumber = new AtomicInteger(1)\n        Thread newThread(Runnable r) { return new Thread(workerGroup, r, \"LoadRunner-\" + threadNumber.getAndIncrement()) }\n    }\n\n    static class LoadRunner {\n        ExecutionContextFactoryImpl ecfi\n        CustomScheduledExecutor scheduledExecutor = null\n        ArrayList<LoadRunnerServiceInfo> serviceInfos = new ArrayList<>()\n        Integer corePoolSize = 4, maxPoolSize = null\n        AtomicInteger execIndex = new AtomicInteger(1)\n        ReentrantLock mutateLock = new ReentrantLock()\n\n        LoadRunner(ExecutionContextFactoryImpl ecfi) {\n            this.ecfi = ecfi\n        }\n\n        LoadRunnerServiceInfo getServiceInfo(String serviceName, String parametersExpr) {\n            for (int i = 0; i < serviceInfos.size(); i++) {\n                LoadRunnerServiceInfo curInfo = (LoadRunnerServiceInfo) serviceInfos.get(i)\n                if (curInfo.serviceName == serviceName && curInfo.parametersExpr == parametersExpr)\n                    return curInfo\n            }\n            return null\n        }\n        void setServiceInfo(String serviceName, String parametersExpr, int targetThreads, int runDelayMs,\n                int runDelayVaryMs, int rampDelayMs, int timeBinLength, int timeBinsKeep) {\n            mutateLock.lock()\n            try {\n                LoadRunnerServiceInfo serviceInfo = getServiceInfo(serviceName, parametersExpr)\n                if (serviceInfo == null) {\n                    serviceInfo = new LoadRunnerServiceInfo(serviceName, parametersExpr, targetThreads,\n                            runDelayMs, runDelayVaryMs, rampDelayMs, timeBinLength, timeBinsKeep)\n\n                    serviceInfos.add(serviceInfo)\n\n                    if (scheduledExecutor != null) {\n                        // begin() already called, get this started\n                        serviceInfo.addRampThread(this)\n                    }\n                } else {\n                    serviceInfo.targetThreads = targetThreads\n                    serviceInfo.runDelayMs = runDelayMs\n                    serviceInfo.rampDelayMs = rampDelayMs\n                    serviceInfo.timeBinLength = timeBinLength\n                    serviceInfo.timeBinsKeep = timeBinsKeep\n                }\n            } finally {\n                mutateLock.unlock()\n            }\n        }\n        void begin() {\n            mutateLock.lock()\n            try {\n                if (scheduledExecutor == null) {\n                    // restart index\n                    execIndex = new AtomicInteger(1)\n\n                    for (int i = 0; i < serviceInfos.size(); i++) {\n                        LoadRunnerServiceInfo curInfo = (LoadRunnerServiceInfo) serviceInfos.get(i)\n                        // clear out stats before start\n                        curInfo.currentThreads = new AtomicInteger(0)\n                        curInfo.resetStats()\n                    }\n\n                    scheduledExecutor = new CustomScheduledExecutor(corePoolSize, new LoadRunnerThreadFactory())\n                    if (maxPoolSize == null) maxPoolSize = Runtime.getRuntime().availableProcessors() * 4\n                    scheduledExecutor.setMaximumPoolSize(maxPoolSize)\n\n                    for (int i = 0; i < serviceInfos.size(); i++) {\n                        LoadRunnerServiceInfo curInfo = (LoadRunnerServiceInfo) serviceInfos.get(i)\n                        // get the ramp thread started\n                        curInfo.addRampThread(this)\n                    }\n                }\n            } finally {\n                mutateLock.unlock()\n            }\n        }\n        void stopNow() {\n            mutateLock.lock()\n            try {\n                if (scheduledExecutor != null) {\n                    logger.info(\"Shutting down LoadRunner ScheduledExecutorService now\")\n                    scheduledExecutor.shutdownNow()\n                    scheduledExecutor = null\n                }\n            } finally {\n                mutateLock.unlock()\n            }\n        }\n        void stopWait() {\n            mutateLock.lock()\n            try {\n                if (scheduledExecutor != null) {\n                    logger.info(\"Shutting down LoadRunner ScheduledExecutorService\")\n                    scheduledExecutor.shutdown()\n                }\n\n                if (scheduledExecutor != null) {\n                    scheduledExecutor.awaitTermination(30, TimeUnit.SECONDS)\n                    if (scheduledExecutor.isTerminated()) logger.info(\"LoadRunner Scheduled executor shut down and terminated\")\n                    else logger.warn(\"LoadRunner Scheduled executor NOT YET terminated, waited 30 seconds\")\n                    scheduledExecutor = null\n                }\n            } finally {\n                mutateLock.unlock()\n            }\n        }\n\n        void logFutures() {\n            for (int si = 0; si < serviceInfos.size(); si++) {\n                LoadRunnerServiceInfo serviceInfo = serviceInfos.get(si)\n                logger.info(\"LoadRunner RAMP Future done ${serviceInfo.rampFuture?.done} canceled ${serviceInfo.rampFuture?.cancelled} ${serviceInfo.rampFuture?.toString()}\")\n                for (int i = 0; i < serviceInfo.runFutures.size(); i++) {\n                    ScheduledFuture future = serviceInfo.runFutures.get(i)\n                    logger.info(\"LoadRunner RUN Future done ${future.done} canceled ${future.cancelled} ${future.toString()}\")\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/ServiceJsonRpcDispatcher.groovy",
    "content": "package org.moqui.impl.service\n\nimport groovy.transform.CompileStatic\n\n/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\nimport jakarta.servlet.http.HttpServletRequest\nimport jakarta.servlet.http.HttpServletResponse\n\nimport org.moqui.context.ArtifactAuthorizationException\nimport org.moqui.impl.context.ExecutionContextImpl\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n/* NOTE: see JSON-RPC2 specs at: http://www.jsonrpc.org/specification */\n\n@CompileStatic\npublic class ServiceJsonRpcDispatcher {\n    protected final static Logger logger = LoggerFactory.getLogger(ServiceJsonRpcDispatcher.class)\n\n    final static int PARSE_ERROR = -32700 // Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text.\n    final static int INVALID_REQUEST = -32600 // The JSON sent is not a valid Request object.\n    final static int METHOD_NOT_FOUND = -32601 // The method does not exist / is not available.\n    final static int INVALID_PARAMS = -32602 // Invalid method parameter(s).\n    final static int INTERNAL_ERROR = -32603 // Internal JSON-RPC error.\n\n    private final ExecutionContextImpl eci\n\n    public ServiceJsonRpcDispatcher(ExecutionContextImpl eci) {\n        this.eci = eci\n    }\n\n    public void dispatch() {\n        Map callMap = eci.web.getRequestParameters()\n        if (callMap._requestBodyJsonList) {\n            List callList = (List) callMap._requestBodyJsonList\n            List<Map> jsonRespList = []\n            for (Object callSingleObj in callList) {\n                if (callSingleObj instanceof Map) {\n                    Map callSingleMap = (Map) callSingleObj\n                    jsonRespList.add(callSingle(callSingleMap.method as String, callSingleMap.params, callSingleMap.id ?: null))\n                } else {\n                    jsonRespList.add(callSingle(null, callSingleObj, null))\n                }\n            }\n        } else {\n            // logger.info(\"========= JSON-RPC request with map: ${callMap}\")\n            Map jsonResp = callSingle(callMap.method as String, callMap.params, callMap.id ?: null)\n            eci.getWeb().sendJsonResponse(jsonResp)\n        }\n    }\n\n    protected Map callSingle(String method, Object paramsObj, Object id) {\n        // logger.warn(\"========= JSON-RPC call method=[${method}], id=[${id}], params=${paramsObj}\")\n\n        String errorMessage = null\n        Integer errorCode = null\n        ServiceDefinition sd = method ? eci.serviceFacade.getServiceDefinition(method) : null\n        if (eci.web.getRequestParameters()._requestBodyJsonParseError) {\n            errorMessage = eci.web.getRequestParameters()._requestBodyJsonParseError\n            errorCode = PARSE_ERROR\n        } else if (!method) {\n            errorMessage = \"No method specified\"\n            errorCode = INVALID_REQUEST\n        } else if (sd == null) {\n            errorMessage = \"Unknown service [${method}]\"\n            errorCode = METHOD_NOT_FOUND\n        } else if (!(paramsObj instanceof Map)) {\n            // We expect named parameters (JSON object)\n            errorMessage = \"Parameters must be named parameters (JSON object, Java Map), got type [${paramsObj.class.getName()}]\"\n            errorCode = INVALID_PARAMS\n        } else if (!sd.allowRemote) {\n            errorMessage = \"Service [${sd.serviceName}] does not allow remote calls\"\n            errorCode = METHOD_NOT_FOUND\n        }\n\n        Map result = null\n        if (errorMessage == null) {\n            try {\n                result = eci.service.sync().name(sd.serviceName).parameters((Map) paramsObj).call()\n                if (eci.getMessage().hasError()) {\n                    logger.warn(\"Got errors in JSON-RPC call to service [${sd.serviceName}]: ${eci.message.errorsString}\")\n                    errorMessage = eci.message.errorsString\n                    // could use whatever code here as long as it is not -32768 to -32000, this was chosen somewhat arbitrarily\n                    errorCode = HttpServletResponse.SC_INTERNAL_SERVER_ERROR\n                }\n            } catch (ArtifactAuthorizationException e) {\n                logger.error(\"Authz error calling service ${sd.serviceName} from JSON-RPC request: ${e.toString()}\", e)\n                errorMessage = e.getMessage()\n                // could use whatever code here as long as it is not -32768 to -32000, this was chosen somewhat arbitrarily\n                errorCode = HttpServletResponse.SC_FORBIDDEN\n            } catch (Exception e) {\n                logger.error(\"Error calling service ${sd.serviceName} from JSON-RPC request: ${e.toString()}\", e)\n                errorMessage = e.getMessage()\n                // could use whatever code here as long as it is not -32768 to -32000, this was chosen somewhat arbitrarily\n                errorCode = HttpServletResponse.SC_INTERNAL_SERVER_ERROR\n            }\n        }\n\n        if (errorMessage == null) {\n            return [jsonrpc:\"2.0\", id:id, result:result]\n        } else {\n            logger.warn(\"Responding with JSON-RPC error code [${errorCode}]: ${errorMessage}\")\n            return [jsonrpc:\"2.0\", id:id, error:[code:errorCode, message:errorMessage]]\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/ServiceRunner.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.service\n\nimport org.moqui.service.ServiceException\n\ninterface ServiceRunner {\n    ServiceRunner init(ServiceFacadeImpl sfi);\n    Map<String, Object> runService(ServiceDefinition sd, Map<String, Object> parameters) throws ServiceException;\n    void destroy();\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/runner/EntityAutoServiceRunner.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.service.runner\n\nimport groovy.transform.CompileStatic\nimport org.moqui.BaseException\nimport org.moqui.context.ExecutionContext\nimport org.moqui.context.ExecutionContextFactory\nimport org.moqui.entity.EntityException\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityValue\nimport org.moqui.entity.EntityValueNotFoundException\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.impl.entity.EntityDefinition\nimport org.moqui.impl.entity.EntityFacadeImpl\nimport org.moqui.impl.entity.EntityJavaUtil.RelationshipInfo\nimport org.moqui.impl.entity.EntityValueBase\nimport org.moqui.impl.entity.FieldInfo\nimport org.moqui.impl.service.ServiceDefinition\nimport org.moqui.impl.service.ServiceFacadeImpl\nimport org.moqui.impl.service.ServiceRunner\nimport org.moqui.service.ServiceException\nimport org.moqui.util.ObjectUtilities\nimport org.moqui.util.StringUtilities\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.sql.Timestamp\n\n@CompileStatic\nclass EntityAutoServiceRunner implements ServiceRunner {\n    protected final static Logger logger = LoggerFactory.getLogger(EntityAutoServiceRunner.class)\n\n    final static Set<String> verbSet = new HashSet(['create', 'update', 'delete', 'store'])\n    final static Set<String> otherFieldsToSkip = new HashSet(['ec', '_entity', 'authUsername', 'authPassword'])\n\n    private ServiceFacadeImpl sfi = null\n    private ExecutionContextFactoryImpl ecfi = null\n\n    EntityAutoServiceRunner() {}\n\n    @Override ServiceRunner init(ServiceFacadeImpl sfi) { this.sfi = sfi; ecfi = sfi.ecfi; return this }\n    @Override void destroy() { }\n\n    // TODO: add update-expire and delete-expire entity-auto service verbs for entities with from/thru dates\n    // TODO: add find (using search input parameters) and find-one (using literal PK, or as many PK fields as are passed on) entity-auto verbs\n    Map<String, Object> runService(ServiceDefinition sd, Map<String, Object> parameters) {\n        // check the verb and noun\n        if (sd.verb == null || !verbSet.contains(sd.verb))\n            throw new ServiceException(\"In service ${sd.serviceName} the verb must be one of ${verbSet} for entity-auto type services.\")\n        if (sd.noun == null || sd.noun.isEmpty()) throw new ServiceException(\"In service ${sd.serviceName} you must specify a noun for entity-auto service calls\")\n\n        ExecutionContextImpl eci = ecfi.getEci()\n        EntityDefinition ed = eci.entityFacade.getEntityDefinition(sd.noun)\n        if (ed == null) throw new ServiceException(\"In service ${sd.serviceName} the specified noun ${sd.noun} is not a valid entity name\")\n\n        Map<String, Object> result = new HashMap()\n\n        try {\n            boolean allPksInOnly = true\n            for (String pkFieldName in ed.getPkFieldNames()) {\n                if (!sd.getInParameter(pkFieldName) || sd.getOutParameter(pkFieldName)) { allPksInOnly = false; break }\n            }\n\n            if (\"create\".equals(sd.verb)) {\n                createEntity(eci, ed, parameters, result, sd.getOutParameterNames())\n            } else if (\"update\".equals(sd.verb)) {\n                /* <auto-attributes include=\"pk\" mode=\"IN\" optional=\"false\"/> */\n                if (!allPksInOnly) throw new ServiceException(\"In entity-auto type service ${sd.serviceName} with update noun, not all pk fields have the mode IN\")\n                updateEntity(eci, ed, parameters, result, sd.getOutParameterNames(), null)\n            } else if (\"delete\".equals(sd.verb)) {\n                /* <auto-attributes include=\"pk\" mode=\"IN\" optional=\"false\"/> */\n                if (!allPksInOnly) throw new ServiceException(\"In entity-auto type service ${sd.serviceName} with delete noun, not all pk fields have the mode IN\")\n                deleteEntity(eci, ed, parameters)\n            } else if (\"store\".equals(sd.verb)) {\n                storeEntity(eci, ed, parameters, result, sd.getOutParameterNames())\n            } else if (\"update-expire\".equals(sd.verb)) {\n                // TODO\n            } else if (\"delete-expire\".equals(sd.verb)) {\n                // TODO\n            } else if (\"find\".equals(sd.verb)) {\n                // TODO\n            } else if (\"find-one\".equals(sd.verb)) {\n                // TODO\n            }\n        } catch (BaseException e) {\n            throw new ServiceException(\"Error doing entity-auto operation for entity [${ed.fullEntityName}] in service [${sd.serviceName}]\", e)\n        }\n\n        return result\n    }\n\n    protected static void checkFromDate(EntityDefinition ed, Map<String, Object> parameters,\n                              Map<String, Object> result, ExecutionContextFactoryImpl ecfi) {\n        List<String> pkFieldNames = ed.getPkFieldNames()\n\n        // always make fromDate optional, whether or not part of the pk; do this before the allPksIn check\n        if (pkFieldNames.contains(\"fromDate\") && parameters.get(\"fromDate\") == null) {\n            Timestamp fromDate = ecfi.getExecutionContext().getUser().getNowTimestamp()\n            parameters.put(\"fromDate\", fromDate)\n            result.put(\"fromDate\", fromDate)\n            // logger.info(\"Set fromDate field to default [${parameters.fromDate}]\")\n        }\n    }\n\n    protected static boolean checkAllPkFields(EntityDefinition ed, Map<String, Object> parameters, Map<String, Object> tempResult,\n                                    EntityValue newEntityValue, ArrayList<String> outParamNames) {\n        FieldInfo[] pkFieldInfos = ed.entityInfo.pkFieldInfoArray\n\n        // see if all PK fields were passed in\n        boolean allPksIn = true\n        int pkSize = pkFieldInfos.length\n        ArrayList<String> missingPkFields = (ArrayList<String>) null\n        for (int i = 0; i < pkSize; i++) {\n            FieldInfo fieldInfo = (FieldInfo) pkFieldInfos[i]\n            Object pkValue = parameters.get(fieldInfo.name)\n            if (ObjectUtilities.isEmpty(pkValue) && (fieldInfo.defaultStr == null || fieldInfo.defaultStr.isEmpty())) {\n                allPksIn = false\n                if (missingPkFields == null) missingPkFields = new ArrayList<>()\n                missingPkFields.add(fieldInfo.name)\n            }\n        }\n        boolean isSinglePk = pkSize == 1\n        boolean isDoublePk = pkSize == 2\n\n        // logger.info(\"======= checkAllPkFields for ${ed.getEntityName()} allPksIn=${allPksIn}, isSinglePk=${isSinglePk}, isDoublePk=${isDoublePk}; parameters: ${parameters}\")\n\n        if (isSinglePk) {\n            /* **** primary sequenced primary key **** */\n            /* **** primary sequenced key with optional override passed in **** */\n            FieldInfo singlePkField = pkFieldInfos[0]\n\n            Object pkValue = parameters.get(singlePkField.name)\n            if (!ObjectUtilities.isEmpty(pkValue)) {\n                // convert from String if parameter type is String, PK field type may not be\n                if (pkValue instanceof CharSequence) newEntityValue.setString(singlePkField.name, pkValue.toString())\n                else newEntityValue.set(singlePkField.name, pkValue)\n            } else {\n                // if it has a default value don't sequence the PK\n                if (singlePkField.defaultStr == null || singlePkField.defaultStr.isEmpty()) {\n                    newEntityValue.setSequencedIdPrimary()\n                    pkValue = newEntityValue.getNoCheckSimple(singlePkField.name)\n                }\n            }\n            if (outParamNames == null || outParamNames.size() == 0 || outParamNames.contains(singlePkField.name))\n                tempResult.put(singlePkField.name, pkValue)\n        } else if (isDoublePk && !allPksIn) {\n            /* **** secondary sequenced primary key **** */\n            // don't do it this way, currently only supports second pk fields: String doublePkSecondaryName = parameters.get(pkFieldNames.get(0)) ? pkFieldNames.get(1) : pkFieldNames.get(0)\n            FieldInfo doublePkSecondary = pkFieldInfos[1]\n            newEntityValue.setFields(parameters, true, null, true)\n            // if it has a default value don't sequence the PK\n            if (doublePkSecondary.defaultStr == null || doublePkSecondary.defaultStr.isEmpty()) {\n                newEntityValue.setSequencedIdSecondary()\n                if (outParamNames == null || outParamNames.size() == 0 || outParamNames.contains(doublePkSecondary.name))\n                    tempResult.put(doublePkSecondary.name, newEntityValue.getNoCheckSimple(doublePkSecondary.name))\n            }\n        } else if (allPksIn) {\n            /* **** plain specified primary key **** */\n            newEntityValue.setFields(parameters, true, null, true)\n        } else {\n            logger.error(\"Entity [${ed.fullEntityName}] auto create pk fields ${ed.getPkFieldNames()} incomplete: ${parameters}\" +\n                    \"\\nCould not find a valid combination of primary key settings to do a create operation; options include: \" +\n                    \"1. a single entity primary-key field for primary auto-sequencing with or without matching in-parameter, and with or without matching out-parameter for the possibly sequenced value, \" +\n                    \"2. a 2-part entity primary-key with one part passed in as an in-parameter (existing primary pk value) and with or without the other part defined as an out-parameter (the secodnary pk to sub-sequence), \" +\n                    \"3. all entity pk fields are passed into the service\")\n            if (missingPkFields.size() == 1) {\n                throw new ServiceException(\"Required field ${StringUtilities.camelCaseToPretty(missingPkFields.get(0))} is missing, cannot create ${StringUtilities.camelCaseToPretty(ed.entityName)}\")\n            } else {\n                throw new ServiceException(\"Required fields ${missingPkFields.collect({ StringUtilities.camelCaseToPretty(it) }).join(', ')} are missing, cannot create ${StringUtilities.camelCaseToPretty(ed.entityName)}\")\n            }\n        }\n\n        // logger.info(\"In auto createEntity allPksIn [${allPksIn}] isSinglePk [${isSinglePk}] isDoublePk [${isDoublePk}] newEntityValue final [${newEntityValue}]\")\n\n        return allPksIn\n    }\n\n    static void createEntity(ExecutionContextImpl eci, EntityDefinition ed, Map<String, Object> parameters,\n                             Map<String, Object> result, ArrayList<String> outParamNames) {\n        createRecursive(eci.ecfi, eci.entityFacade, ed, parameters, result, outParamNames, null)\n    }\n\n    static void createRecursive(ExecutionContextFactoryImpl ecfi, EntityFacadeImpl efi, EntityDefinition ed, Map<String, Object> parameters,\n                                Map<String, Object> result, ArrayList<String> outParamNames, Map<String, Object> parentPks) {\n        EntityValue newEntityValue = ed.makeEntityValue()\n\n        // add in all of the main entity's primary key fields, this is necessary for auto-generated, and to\n        //     allow them to be left out of related records\n        if (parentPks != null) {\n            for (Map.Entry<String, Object> entry in parentPks.entrySet())\n                if (!parameters.containsKey(entry.key)) parameters.put(entry.key, entry.value)\n        }\n\n        checkFromDate(ed, parameters, result, ecfi)\n\n        Map<String, Object> tempResult = [:]\n        checkAllPkFields(ed, parameters, tempResult, newEntityValue, outParamNames)\n\n        newEntityValue.setFields(parameters, true, null, false)\n        try {\n            newEntityValue.create()\n        } catch (Exception e) {\n            if (e.getMessage().contains(\"primary key\")) {\n                long[] bank = (long[]) efi.entitySequenceBankCache.get(ed.getFullEntityName())\n                EntityValue svi = efi.find(\"moqui.entity.SequenceValueItem\").condition(\"seqName\", ed.getFullEntityName())\n                        .useCache(false).disableAuthz().one()\n                logger.warn(\"Got PK violation, current bank is ${bank}, PK is ${newEntityValue.getPrimaryKeys()}, current SequenceValueItem: ${svi}\")\n            }\n            throw e\n        }\n\n        // NOTE: keep a separate Map of parent PK values to pass down, can't just be current record's PK fields because\n        //     we allow other entities to be nested, and they may have nested records that depend ANY ancestor's PKs\n        // this returns a clone or new Map, so we'll modify it freely\n        Map<String, Object> sharedPkMap = newEntityValue.getPrimaryKeys()\n        if (parentPks != null) {\n            for (Map.Entry<String, Object> entry in parentPks.entrySet())\n                if (!sharedPkMap.containsKey(entry.key)) sharedPkMap.put(entry.key, entry.value)\n        }\n\n        // if a PK field has a @default get it and return it\n        ArrayList<String> pkFieldNames = ed.getPkFieldNames()\n        int size = pkFieldNames.size()\n        for (int i = 0; i < size; i++) {\n            String pkName = (String) pkFieldNames.get(i)\n            FieldInfo pkInfo = ed.getFieldInfo(pkName)\n            if (pkInfo.defaultStr != null && !pkInfo.defaultStr.isEmpty()) {\n                tempResult.put(pkName, newEntityValue.getNoCheckSimple(pkName))\n            }\n        }\n\n        // check parameters Map for relationships and other entities\n        Map nonFieldEntries = ed.entityInfo.cloneMapRemoveFields(parameters, null)\n        for (Map.Entry entry in nonFieldEntries.entrySet()) {\n            Object relParmObj = entry.getValue()\n            if (relParmObj == null) continue\n            // if the entry is not a Map or List ignore it, we're only looking for those\n            if (!(relParmObj instanceof Map) && !(relParmObj instanceof List)) continue\n\n            String entryName = (String) entry.getKey()\n            if (parentPks != null && parentPks.containsKey(entryName)) continue\n            if (otherFieldsToSkip.contains(entryName)) continue\n\n            EntityDefinition subEd = null\n            Map<String, Object> pkMap = null\n            RelationshipInfo relInfo = ed.getRelationshipInfo(entryName)\n            if (relInfo != null) {\n                if (!relInfo.mutable) {\n                    if (logger.isTraceEnabled()) logger.trace(\"In create entity auto service found key [${entryName}] which is a non-mutable relationship of [${ed.getFullEntityName()}], skipping\")\n                    continue\n                }\n                subEd = relInfo.relatedEd\n                // this is a relationship so add mapped key fields to the parentPks if any field names are different\n                pkMap = new HashMap<>(sharedPkMap)\n                pkMap.putAll(relInfo.getTargetParameterMap(sharedPkMap))\n            } else if (efi.isEntityDefined(entryName)) {\n                subEd = efi.getEntityDefinition(entryName)\n                pkMap = sharedPkMap\n            }\n            if (subEd == null) {\n                // this happens a lot, extra stuff passed to the service call, so be quiet unless trace is on\n                if (logger.isTraceEnabled()) logger.trace(\"In create entity auto service found key [${entryName}] which is not a field or relationship of [${ed.getFullEntityName()}] and is not a defined entity\")\n                continue\n            }\n\n            boolean isEntityValue = relParmObj instanceof EntityValue\n            if (relParmObj instanceof Map && !isEntityValue) {\n                Map<String, Object> relResults = new HashMap<String, Object>()\n                createRecursive(ecfi, efi, subEd, (Map) relParmObj, relResults, null, pkMap)\n                tempResult.put(entryName, relResults)\n            } else if (relParmObj instanceof List) {\n                List relResultList = []\n                for (Object relParmEntry in relParmObj) {\n                    Map<String, Object> relResults = new HashMap<String, Object>()\n                    if (relParmEntry instanceof Map) {\n                        createRecursive(ecfi, efi, subEd, (Map) relParmEntry, relResults, null, pkMap)\n                    } else {\n                        logger.warn(\"In entity auto create for entity ${ed.getFullEntityName()} found list for sub-object ${entryName} with a non-Map entry: ${relParmEntry}\")\n                    }\n                    relResultList.add(relResults)\n                }\n                tempResult.put(entryName, relResultList)\n            } else {\n                if (isEntityValue) {\n                    if (logger.isTraceEnabled()) logger.trace(\"In entity auto create for entity ${ed.getFullEntityName()} found sub-object ${entryName} which is not a Map or List: ${relParmObj}\")\n                } else {\n                    logger.warn(\"In entity auto create for entity ${ed.getFullEntityName()} found sub-object ${entryName} which is not a Map or List: ${relParmObj}\")\n                }\n            }\n        }\n\n        result.putAll(tempResult)\n    }\n\n    /** Does a create if record does not exist, or update if it does. */\n    static void storeEntity(ExecutionContextImpl eci, EntityDefinition ed, Map<String, Object> parameters,\n                                   Map<String, Object> result, ArrayList<String> outParamNames) {\n        storeRecursive(eci.ecfi, eci.getEntityFacade(), ed, parameters, result, outParamNames, null)\n    }\n\n    static void storeRecursive(ExecutionContextFactoryImpl ecfi, EntityFacadeImpl efi, EntityDefinition ed, Map<String, Object> parameters,\n                               Map<String, Object> result, ArrayList<String> outParamNames, Map<String, Object> parentPks) {\n        EntityValue newEntityValue = efi.makeValue(ed.getFullEntityName())\n\n        // add in all of the main entity's primary key fields, this is necessary for auto-generated, and to\n        //     allow them to be left out of related records\n        if (parentPks != null) {\n            for (Map.Entry<String, Object> entry in parentPks.entrySet())\n                if (!parameters.containsKey(entry.key)) parameters.put(entry.key, entry.value)\n        }\n\n        checkFromDate(ed, parameters, result, ecfi)\n\n        Map<String, Object> tempResult = [:]\n        boolean allPksIn = checkAllPkFields(ed, parameters, tempResult, newEntityValue, outParamNames)\n        if (result != null) result.putAll(tempResult)\n\n        if (!allPksIn) {\n            // we had to fill some stuff in, so do a create\n            newEntityValue.setFields(parameters, true, null, false)\n            newEntityValue.create()\n            storeRelated(ecfi, efi, (EntityValueBase) newEntityValue, parameters, result, parentPks)\n            return\n        }\n\n        EntityValue lookedUpValue = null\n        if (parameters.containsKey(\"statusId\") && ed.isField(\"statusId\")) {\n            // do the actual query so we'll have the current statusId\n            lookedUpValue = efi.find(ed.fullEntityName)\n                    .condition(newEntityValue).useCache(false).one()\n            if (lookedUpValue != null) {\n                checkStatus(ed, parameters, result, outParamNames, lookedUpValue, efi)\n            } else {\n                // no lookedUpValue at this point? doesn't exist so create\n                newEntityValue.setFields(parameters, true, null, false)\n                newEntityValue.create()\n                storeRelated(ecfi, efi, (EntityValueBase) newEntityValue, parameters, result, parentPks)\n                return\n            }\n        }\n\n        if (lookedUpValue == null) lookedUpValue = newEntityValue\n        lookedUpValue.setFields(parameters, true, null, false)\n        // logger.info(\"In auto updateEntity lookedUpValue final [${lookedUpValue}] for parameters [${parameters}]\")\n        lookedUpValue.createOrUpdate()\n\n        storeRelated(ecfi, efi, (EntityValueBase) lookedUpValue, parameters, result, parentPks)\n    }\n\n    static void storeRelated(ExecutionContextFactoryImpl ecfi, EntityFacadeImpl efi, EntityValueBase parentValue,\n                             Map<String, Object> parameters, Map<String, Object> result, Map<String, Object> parentPks) {\n        EntityDefinition ed = parentValue.getEntityDefinition()\n\n        // NOTE: keep a separate Map of parent PK values to pass down, can't just be current record's PK fields because\n        //     we allow other entities to be nested, and they may have nested records that depend ANY ancestor's PKs\n        // this returns a clone or new Map, so we'll modify it freely\n        Map<String, Object> sharedPkMap = parentValue.getPrimaryKeys()\n        if (parentPks != null) {\n            for (Map.Entry<String, Object> entry in parentPks.entrySet())\n                if (!sharedPkMap.containsKey(entry.key)) sharedPkMap.put(entry.key, entry.value)\n        }\n\n        Map nonFieldEntries = ed.entityInfo.cloneMapRemoveFields(parameters, null)\n        if (nonFieldEntries.size() > 0) for (Map.Entry entry in nonFieldEntries.entrySet()) {\n            Object relParmObj = entry.getValue()\n            if (relParmObj == null) continue\n            // if the entry is not a Map or List ignore it, we're only looking for those\n            if (!(relParmObj instanceof Map) && !(relParmObj instanceof List)) continue\n\n            String entryName = (String) entry.getKey()\n            if (parentPks != null && parentPks.containsKey(entryName)) continue\n            if (otherFieldsToSkip.contains(entryName)) continue\n\n            EntityDefinition subEd = null\n            Map<String, Object> pkMap = null\n            RelationshipInfo relInfo = ed.getRelationshipInfo(entryName)\n            if (relInfo != null) {\n                if (!relInfo.mutable) {\n                    if (logger.isTraceEnabled()) logger.trace(\"In store entity auto service found key [${entryName}] which is a non-mutable relationship of [${ed.getFullEntityName()}], skipping\")\n                    continue\n                }\n                subEd = relInfo.relatedEd\n\n                // this is a relationship so add mapped key fields to the parentPks if any field names are different\n                pkMap = new HashMap<>(sharedPkMap)\n                pkMap.putAll(relInfo.getTargetParameterMap(sharedPkMap))\n            } else if (efi.isEntityDefined(entryName)) {\n                subEd = efi.getEntityDefinition(entryName)\n                pkMap = sharedPkMap\n            }\n            if (subEd == null) {\n                // this happens a lot, extra stuff passed to the service call, so be quiet unless trace is on\n                if (logger.isTraceEnabled()) logger.trace(\"In store entity auto service found key [${entryName}] which is not a field or relationship of [${ed.getFullEntityName()}] and is not a defined entity\")\n                continue\n            }\n\n            boolean isEntityValue = relParmObj instanceof EntityValue\n            if (relParmObj instanceof Map && !isEntityValue) {\n                Map<String, Object> relResults = new HashMap<String, Object>()\n                storeRecursive(ecfi, efi, subEd, (Map) relParmObj, relResults, null, pkMap)\n                result.put(entryName, relResults)\n            } else if (relParmObj instanceof List) {\n                List relResultList = []\n                for (Object relParmEntry in relParmObj) {\n                    Map<String, Object> relResults = new HashMap<String, Object>()\n                    if (relParmEntry instanceof Map) {\n                        storeRecursive(ecfi, efi, subEd, (Map) relParmEntry, relResults, null, pkMap)\n                    } else {\n                        logger.warn(\"In entity auto create for entity ${ed.getFullEntityName()} found list for sub-object ${entryName} with a non-Map entry: ${relParmEntry}\")\n                    }\n                    relResultList.add(relResults)\n                }\n                result.put(entryName, relResultList)\n            } else {\n                if (isEntityValue) {\n                    if (logger.isTraceEnabled()) logger.trace(\"In entity auto store for entity ${ed.getFullEntityName()} found sub-object ${entryName} which is not a Map or List: ${relParmObj}\")\n                } else {\n                    logger.warn(\"In entity auto store for entity ${ed.getFullEntityName()} found sub-object ${entryName} which is not a Map or List: ${relParmObj}\")\n                }\n            }\n        }\n    }\n\n    /* This should only be called if statusId is a field of the entity and lookedUpValue != null */\n    protected static void checkStatus(EntityDefinition ed, Map<String, Object> parameters, Map<String, Object> result,\n                                      ArrayList<String> outParamNames, EntityValue lookedUpValue, EntityFacadeImpl efi) {\n        if (!parameters.containsKey(\"statusId\")) return\n\n        // populate the oldStatusId out if there is a service parameter for it, and before we do the set non-pk fields\n        if (outParamNames == null || outParamNames.size() == 0 || outParamNames.contains(\"oldStatusId\")) {\n            result.put(\"oldStatusId\", lookedUpValue.getNoCheckSimple(\"statusId\"))\n        }\n        if (outParamNames == null || outParamNames.size() == 0 || outParamNames.contains(\"statusChanged\")) {\n            result.put(\"statusChanged\", !(lookedUpValue.getNoCheckSimple(\"statusId\") == parameters.get(\"statusId\")))\n            // logger.warn(\"========= oldStatusId=${result.oldStatusId}, statusChanged=${result.statusChanged}, lookedUpValue.statusId=${lookedUpValue.statusId}, parameters.statusId=${parameters.statusId}, lookedUpValue=${lookedUpValue}\")\n        }\n\n        // do the StatusValidChange check\n        String parameterStatusId = (String) parameters.get(\"statusId\")\n        if (parameterStatusId) {\n            String lookedUpStatusId = (String) lookedUpValue.getNoCheckSimple(\"statusId\")\n            if (lookedUpStatusId && !parameterStatusId.equals(lookedUpStatusId)) {\n                ExecutionContext eci = efi.ecfi.getEci()\n\n                // there was an old status, and in this call we are trying to change it, so do the StatusFlowTransition check\n                // NOTE that we are using a cached list from a common pattern so it should generally be there instead of a count that wouldn't\n                EntityList statusFlowTransitionList = efi.find(\"moqui.basic.StatusFlowTransition\")\n                        .condition(\"statusId\", lookedUpStatusId).condition(\"toStatusId\", parameterStatusId).useCache(true).list()\n                // check userPermissionId for each\n                int statusFlowTransitionListSize = statusFlowTransitionList.size()\n                int validTransitionCount = 0\n                List<String> transitionCheckMessages = new LinkedList<String>()\n                for (int i = 0; i < statusFlowTransitionListSize; i++) {\n                    EntityValue statusFlowTransition = (EntityValue) statusFlowTransitionList.get(i)\n                    // NOTE: could check the old conditionExpression field here as well but there are issues with context definition (check here, in screens, etc), may have limited use anyway, may be better to remove\n                    String userPermissionId = (String) statusFlowTransition.getNoCheckSimple(\"userPermissionId\")\n                    if (userPermissionId == null || userPermissionId.isEmpty()) {\n                        validTransitionCount++\n                    } else {\n                        if (eci.userFacade.hasPermission(userPermissionId)) {\n                            validTransitionCount++\n                        } else {\n                            transitionCheckMessages.add(\"User ${eci.userFacade.username} (${eci.userFacade.userId}) does not have permission ${userPermissionId} to change status in flow ${statusFlowTransition.statusFlowId} from ${lookedUpStatusId} to ${parameterStatusId} for ${ed.getFullEntityName()} ${lookedUpValue.getPrimaryKeys()}\".toString())\n                        }\n                    }\n                }\n                if (validTransitionCount == 0) {\n                    // uh-oh, no valid change...\n                    EntityValue lookedUpStatus = efi.find(\"moqui.basic.StatusItem\")\n                            .condition(\"statusId\", lookedUpStatusId).useCache(true).one()\n                    EntityValue parameterStatus = efi.find(\"moqui.basic.StatusItem\")\n                            .condition(\"statusId\", parameterStatusId).useCache(true).one()\n                    logger.warn(\"Status transition not allowed from ${lookedUpStatusId} to ${parameterStatusId} on entity ${ed.fullEntityName} with PK ${lookedUpValue.getPrimaryKeys()}\\n${transitionCheckMessages.join('\\n')}\")\n                    throw new ServiceException(eci.resource.expand('StatusFlowTransitionNotFoundTemplate', \"\",\n                            [fullEntityName:eci.l10n.localize(ed.fullEntityName + '##EntityName'),\n                                lookedUpStatusId:lookedUpStatusId, parameterStatusId:parameterStatusId,\n                                lookedUpStatusName:lookedUpStatus?.getNoCheckSimple(\"description\"),\n                                parameterStatusName:parameterStatus?.getNoCheckSimple(\"description\")]))\n                }\n            }\n        }\n\n        // NOTE: nothing here to maintain the status history, that should be done with a custom service called by SECA rule or with audit log on field\n    }\n\n    static void updateEntity(ExecutionContextImpl eci, EntityDefinition ed, Map<String, Object> parameters,\n                             Map<String, Object> result, ArrayList<String> outParamNames, EntityValue preLookedUpValue) {\n        ExecutionContextFactoryImpl ecfi = eci.ecfi\n        EntityFacadeImpl efi = eci.getEntityFacade()\n\n        EntityValue lookedUpValue = preLookedUpValue ?: efi.makeValue(ed.getFullEntityName()).setFields(parameters, true, null, true)\n        // this is much slower, and we don't need to do the query: sfi.getEcfi().getEntityFacade().find(ed.entityName).condition(parameters).useCache(false).one()\n        if (lookedUpValue == null) throw new EntityValueNotFoundException(\"In entity-auto update service for entity [${ed.fullEntityName}] value not found, cannot update; using parameters [${parameters}]\")\n\n        if (parameters.containsKey(\"statusId\") && ed.isField(\"statusId\")) {\n            // do the actual query so we'll have the current statusId\n            Map<String, Object> pkParms = ed.getPrimaryKeys(parameters)\n            lookedUpValue = preLookedUpValue ?: efi.find(ed.getFullEntityName()).condition(pkParms).useCache(false).one()\n            if (lookedUpValue == null) throw new EntityValueNotFoundException(\"In entity-auto update service for entity [${ed.fullEntityName}] value not found, cannot update; using parameters [${parameters}]\")\n\n            checkStatus(ed, parameters, result, outParamNames, lookedUpValue, efi)\n        }\n\n        lookedUpValue.setFields(parameters, true, null, false)\n        // logger.info(\"In auto updateEntity lookedUpValue final [${((EntityValueBase) lookedUpValue).getValueMap()}] for parameters [${parameters}]\")\n        lookedUpValue.update()\n\n        storeRelated(ecfi, efi, (EntityValueBase) lookedUpValue, parameters, result, null)\n    }\n\n    static void deleteEntity(ExecutionContextImpl eci, EntityDefinition ed, Map<String, Object> parameters) {\n        if (!ed.containsPrimaryKey(parameters)) throw new EntityException(\"Must specify all primary key fields to delete, can use wildcard of '*' in one or more PK fields to delete multiple records\")\n\n        Map<String, Object> newParms = new HashMap<>(parameters)\n        boolean hasWildcard = false\n        ArrayList<String> fieldNameList = ed.getPkFieldNames()\n        int size = fieldNameList.size()\n        for (int i = 0; i < size; i++) {\n            String fieldName = (String) fieldNameList.get(i)\n            if (\"*\".equals(newParms.get(fieldName))) {\n                hasWildcard = true\n                newParms.remove(fieldName)\n            }\n        }\n        if (hasWildcard) {\n            // long deleted =\n            eci.entityFacade.find(ed.fullEntityName).condition(newParms).deleteAll()\n            // logger.info(\"Deleted ${deleted} ${ed.fullEntityName} records with PK wildcard: ${parameters}\")\n        } else {\n            EntityValue ev = eci.entityFacade.makeValue(ed.fullEntityName).setFields(parameters, true, null, true)\n            ev.delete()\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/runner/InlineServiceRunner.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.service.runner;\n\nimport org.moqui.impl.context.ExecutionContextFactoryImpl;\nimport org.moqui.impl.context.ExecutionContextImpl;\nimport org.moqui.impl.service.ServiceDefinition;\nimport org.moqui.impl.service.ServiceFacadeImpl;\nimport org.moqui.impl.service.ServiceRunner;\nimport org.moqui.service.ServiceException;\nimport org.moqui.util.ContextStack;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.HashMap;\nimport java.util.Map;\n\npublic class InlineServiceRunner implements ServiceRunner {\n    protected static final Logger logger = LoggerFactory.getLogger(InlineServiceRunner.class);\n    private ExecutionContextFactoryImpl ecfi = null;\n\n    public InlineServiceRunner() { }\n\n    @Override\n    public ServiceRunner init(ServiceFacadeImpl sfi) {\n        ecfi = sfi.ecfi;\n        return this;\n    }\n\n    @Override\n    @SuppressWarnings(\"unchecked\")\n    public Map<String, Object> runService(ServiceDefinition sd, Map<String, Object> parameters) {\n        if (sd.xmlAction == null) throw new ServiceException(\"Service\" + sd.serviceName + \" run inline but has no actions\");\n        ExecutionContextImpl ec = ecfi.getEci();\n        ContextStack cs = ec.contextStack;\n\n        // push the entire context to isolate the context for the service call\n        cs.pushContext();\n        try {\n            // add the parameters to this service call; copy instead of pushing, faster with newer ContextStack\n            cs.putAll(parameters);\n            // we have an empty context so add the ec\n            cs.put(\"ec\", ec);\n            // add a convenience Map to explicitly put results in\n            Map<String, Object> autoResult = new HashMap<>();\n            cs.put(\"result\", autoResult);\n\n            Object result = sd.xmlAction.run(ec);\n\n            if (result instanceof Map) {\n                return (Map<String, Object>) result;\n            } else {\n                ScriptServiceRunner.combineResults(sd, autoResult, cs.getCombinedMap());\n                return autoResult;\n            }\n        /* ServiceCallSyncImpl logs this anyway, no point logging it here: } catch (Throwable t) { logger.error(\"Error running inline XML Actions in service [${sd.serviceName}]: \", t); throw t */\n        } finally {\n            // pop the entire context to get back to where we were before isolating the context with pushContext\n            cs.popContext();\n        }\n    }\n\n    @Override\n    public void destroy() { }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/runner/JavaServiceRunner.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.service.runner\n\nimport groovy.transform.CompileStatic\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.util.ObjectUtilities\n\nimport java.lang.reflect.Method\nimport java.lang.reflect.Modifier\nimport java.lang.reflect.InvocationTargetException\n\nimport org.moqui.context.ExecutionContext\nimport org.moqui.impl.service.ServiceDefinition\nimport org.moqui.impl.service.ServiceFacadeImpl\nimport org.moqui.service.ServiceException\nimport org.moqui.impl.service.ServiceRunner\nimport org.moqui.util.ContextStack\n\n@CompileStatic\npublic class JavaServiceRunner implements ServiceRunner {\n\n    private ServiceFacadeImpl sfi = null\n    private ExecutionContextFactoryImpl ecfi = null\n\n    JavaServiceRunner() {}\n\n    public ServiceRunner init(ServiceFacadeImpl sfi) {\n        this.sfi = sfi\n        ecfi = sfi.ecfi\n        return this\n    }\n\n    public Map<String, Object> runService(ServiceDefinition sd, Map<String, Object> parameters) {\n        if (!sd.location || !sd.method) throw new ServiceException(\"Service [\" + sd.serviceName + \"] is missing location and/or method attributes and they are required for running a java service.\")\n\n        ExecutionContextImpl ec = ecfi.getEci()\n        ContextStack cs = ec.contextStack\n        Map<String, Object> result = (Map<String, Object>) null\n\n        // push the entire context to isolate the context for the service call\n        cs.pushContext()\n        try {\n            // we have an empty context so add the ec\n            cs.put(\"ec\", ec)\n            // now add the parameters to this service call; copy instead of pushing, faster with newer ContextStack\n            cs.putAll(parameters)\n\n            Class c = (Class) ObjectUtilities.getClass(sd.location)\n            if (c == null) c = Thread.currentThread().getContextClassLoader().loadClass(sd.location)\n\n            Method m = c.getMethod(sd.method, ExecutionContext.class)\n            if (Modifier.isStatic(m.getModifiers())) {\n                result = (Map<String, Object>) m.invoke(null, ec)\n            } else {\n                result = (Map<String, Object>) m.invoke(c.newInstance(), ec)\n            }\n        } catch (ClassNotFoundException e) {\n            throw new ServiceException(\"Could not find class for java service [${sd.serviceName}]\", e)\n        } catch (NoSuchMethodException e) {\n            throw new ServiceException(\"Java Service [${sd.serviceName}] specified method [${sd.method}] that does not exist in class [${sd.location}]\", e)\n        } catch (SecurityException e) {\n            throw new ServiceException(\"Access denied in service [${sd.serviceName}]\", e)\n        } catch (IllegalAccessException e) {\n            throw new ServiceException(\"Method not accessible in service [${sd.serviceName}]\", e)\n        } catch (IllegalArgumentException e) {\n            throw new ServiceException(\"Invalid parameter match in service [${sd.serviceName}]\", e)\n        } catch (NullPointerException e) {\n            throw new ServiceException(\"Null pointer in service [${sd.serviceName}]\", e)\n        } catch (ExceptionInInitializerError e) {\n            throw new ServiceException(\"Initialization failed for service [${sd.serviceName}]\", e)\n        } catch (InvocationTargetException e) {\n            throw new ServiceException(\"Java method for service [${sd.serviceName}] threw an exception\", e.getTargetException())\n        } catch (Throwable t) {\n            throw new ServiceException(\"Error or unknown exception in service [${sd.serviceName}]\", t)\n        } finally {\n            // pop the entire context to get back to where we were before isolating the context with pushContext\n            cs.popContext()\n        }\n\n        return result\n    }\n\n    public void destroy() { }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/runner/RemoteJsonRpcServiceRunner.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.service.runner\n\nimport groovy.json.JsonBuilder\nimport groovy.json.JsonSlurper\nimport groovy.transform.CompileStatic\nimport org.moqui.context.ExecutionContext\nimport org.moqui.util.WebUtilities\nimport org.moqui.impl.service.ServiceDefinition\nimport org.moqui.impl.service.ServiceFacadeImpl\nimport org.moqui.impl.service.ServiceRunner\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n@CompileStatic\npublic class RemoteJsonRpcServiceRunner implements ServiceRunner {\n    protected final static Logger logger = LoggerFactory.getLogger(RemoteJsonRpcServiceRunner.class)\n\n    protected ServiceFacadeImpl sfi = null\n\n    RemoteJsonRpcServiceRunner() {}\n\n    public ServiceRunner init(ServiceFacadeImpl sfi) { this.sfi = sfi; return this }\n\n    public Map<String, Object> runService(ServiceDefinition sd, Map<String, Object> parameters) {\n        ExecutionContext ec = sfi.ecfi.getExecutionContext()\n\n        String location = sd.location\n        String method = sd.method\n        if (!location) throw new IllegalArgumentException(\"Cannot call remote service [${sd.serviceName}] because it has no location specified.\")\n        if (!method) throw new IllegalArgumentException(\"Cannot call remote service [${sd.serviceName}] because it has no method specified.\")\n\n        return runJsonService(sd.serviceNameNoHash, location, method, parameters, ec)\n    }\n\n    static Map<String, Object> runJsonService(String serviceName, String location, String method,\n                                              Map<String, Object> parameters, ExecutionContext ec) {\n        Map jsonRequestMap = [jsonrpc:\"2.0\", id:1, method:method, params:parameters]\n        JsonBuilder jb = new JsonBuilder()\n        jb.call(jsonRequestMap)\n        String jsonRequest = jb.toString()\n\n        // logger.warn(\"======== JSON-RPC remote service request to location [${location}]: ${jsonRequest}\")\n\n        String jsonResponse = WebUtilities.simpleHttpStringRequest(location, jsonRequest, \"application/json\")\n\n        // logger.info(\"JSON-RPC remote service [${sd.getServiceName()}] request: ${httpPost.getRequestLine()}, ${httpPost.getAllHeaders()}, ${httpPost.getEntity().contentLength} bytes\")\n        // logger.warn(\"======== JSON-RPC remote service request entity [length:${httpPost.getEntity().contentLength}]: ${EntityUtils.toString(httpPost.getEntity())}\")\n\n        // logger.warn(\"======== JSON-RPC remote service response from location [${location}]: ${jsonResponse}\")\n\n        // parse and return the results\n        JsonSlurper slurper = new JsonSlurper()\n        Object jsonObj\n        try {\n            // logger.warn(\"========== JSON-RPC response: ${jsonResponse}\")\n            jsonObj = slurper.parseText(jsonResponse)\n        } catch (Throwable t) {\n            String errMsg = ec.resource.expand('Error parsing JSON-RPC response for service [${serviceName ?: method}]: ${t.toString()}','',[serviceName:serviceName, method:method, t:t])\n            logger.error(errMsg, t)\n            ec.message.addError(errMsg)\n            return null\n        }\n\n        if (jsonObj instanceof Map) {\n            Map responseMap = (Map) jsonObj\n            if (responseMap.error) {\n                logger.error(\"JSON-RPC service [${serviceName ?: method}] returned an error: ${responseMap.error}\")\n                ec.message.addError((String) ((Map) responseMap.error)?.message ?: ec.resource.expand('JSON-RPC error with no message, code [${responseMap.error?.code}]','',[responseMap:responseMap]))\n                return null\n            } else {\n                Object jr = responseMap.result\n                if (jr instanceof Map) {\n                    return (Map) jr\n                } else {\n                    return [response:jr]\n                }\n            }\n        } else {\n            String errMsg = ec.resource.expand('JSON-RPC response was not a object/Map for service [${serviceName ?: method}]: ${jsonObj}','',[serviceName:serviceName,method:method,jsonObj:jsonObj])\n            logger.error(errMsg)\n            ec.message.addError(errMsg)\n            return null\n        }\n    }\n\n    public void destroy() { }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/runner/RemoteRestServiceRunner.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.service.runner\n\nimport groovy.transform.CompileStatic\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.impl.service.ServiceDefinition\nimport org.moqui.impl.service.ServiceFacadeImpl\nimport org.moqui.impl.service.ServiceRunner\nimport org.moqui.util.RestClient\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n@CompileStatic\nclass RemoteRestServiceRunner implements ServiceRunner {\n    protected final static Logger logger = LoggerFactory.getLogger(RemoteRestServiceRunner.class)\n\n    protected ServiceFacadeImpl sfi = null\n\n    RemoteRestServiceRunner() {}\n\n    ServiceRunner init(ServiceFacadeImpl sfi) { this.sfi = sfi; return this }\n\n    Map<String, Object> runService(ServiceDefinition sd, Map<String, Object> parameters) {\n        ExecutionContextImpl eci = sfi.ecfi.getEci()\n\n        String location = sd.location\n        if (!location) throw new IllegalArgumentException(\"Location required to call remote service ${sd.serviceName}\")\n        String method = sd.method\n        if (method == null || method.isEmpty()) {\n            // default to verb IFF it is a valid method, otherwise default to POST\n            if (RestClient.METHOD_SET.contains(sd.verb.toUpperCase())) method = sd.verb\n            else method = \"POST\"\n        }\n\n        RestClient rc = eci.serviceFacade.rest().method(method)\n\n        if (location.contains('${')) {\n            // TODO: consider somehow removing parameters used in location from the parameters Map,\n            //     thinking of something like a ContextStack feature to watch for field names (keys) used,\n            //     and then remove those from parameters Map\n            location = eci.resourceFacade.expand(location, null, parameters, false)\n        }\n\n        if (RestClient.GET.is(rc.getMethod())) {\n            String parmsStr = RestClient.parametersMapToString(parameters)\n            if (parmsStr != null && !parmsStr.isEmpty()) location = location + \"?\" + parmsStr\n            rc.uri(location)\n        } else {\n            rc.uri(location)\n            // NOTE: another option for parameters might be addBodyParameters(parameters), but a JSON body in the request is more common except for GET\n            if (parameters != null && !parameters.isEmpty()) rc.jsonObject(parameters)\n        }\n        // logger.warn(\"remote-rest service call to ${rc.getUriString()}\")\n\n        // TODO/FUTURE: other options for remote authentication with headers/etc? a big limitation here, needs to be in parameters for now\n\n        RestClient.RestResponse response = rc.call()\n\n        if (response.statusCode < 200 || response.statusCode >= 300) {\n            logger.warn(\"Remote REST service \" + sd.serviceName + \" error \" + response.statusCode + \" (\" + response.reasonPhrase + \") in response to \" + rc.method + \" to \" + rc.uriString + \", response text:\\n\" + response.text())\n            eci.messageFacade.addError(\"Remote service error ${response.statusCode}: ${response.reasonPhrase}\")\n            return null\n        }\n\n        Object responseObj = response.jsonObject()\n        if (responseObj instanceof Map) return (Map) responseObj\n        else return [response:responseObj]\n    }\n\n    void destroy() { }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/service/runner/ScriptServiceRunner.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.service.runner;\n\nimport groovy.transform.CompileStatic;\nimport org.moqui.impl.context.ExecutionContextFactoryImpl;\nimport org.moqui.impl.context.ExecutionContextImpl;\nimport org.moqui.impl.service.ServiceDefinition;\nimport org.moqui.impl.service.ServiceFacadeImpl;\nimport org.moqui.impl.service.ServiceRunner;\nimport org.moqui.util.ContextStack;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.util.HashMap;\nimport java.util.Map;\n\n@CompileStatic\npublic class ScriptServiceRunner implements ServiceRunner {\n    protected static final Logger logger = LoggerFactory.getLogger(ScriptServiceRunner.class);\n    private ExecutionContextFactoryImpl ecfi = null;\n\n    public ScriptServiceRunner() { }\n\n    @Override\n    public ServiceRunner init(ServiceFacadeImpl sfi) {\n        ecfi = sfi.ecfi;\n        return this;\n    }\n\n    @Override\n    @SuppressWarnings(\"unchecked\")\n    public Map<String, Object> runService(ServiceDefinition sd, Map<String, Object> parameters) {\n        ExecutionContextImpl ec = ecfi.getEci();\n        ContextStack cs = ec.contextStack;\n\n        // push the entire context to isolate the context for the service call\n        cs.pushContext();\n        try {\n            // now add the parameters to this service call; copy instead of pushing, faster with newer ContextStack\n            cs.putAll(parameters);\n            // we have an empty context so add the ec\n            cs.put(\"ec\", ec);\n            // add a convenience Map to explicitly put results in\n            Map<String, Object> autoResult = new HashMap<>();\n            cs.put(\"result\", autoResult);\n\n            Object result = ec.getResource().script(sd.location, sd.method);\n\n            if (result instanceof Map) {\n                return (Map<String, Object>) result;\n            } else {\n                combineResults(sd, autoResult, cs.getCombinedMap());\n                return autoResult;\n            }\n        } finally {\n            // pop the entire context to get back to where we were before isolating the context with pushContext\n            cs.popContext();\n        }\n    }\n    static void combineResults(ServiceDefinition sd, Map<String, Object> autoResult, Map<String, Object> csMap) {\n        // if there are fields in ec.context that match out-parameters but that aren't in the result, set them\n        boolean autoResultUsed = autoResult.size() > 0;\n        String[] outParameterNames = sd.outParameterNameArray;\n        int outParameterNamesSize = outParameterNames.length;\n        for (int i = 0; i < outParameterNamesSize; i++) {\n            String outParameterName = outParameterNames[i];\n            Object outValue = csMap.get(outParameterName);\n            if ((!autoResultUsed || !autoResult.containsKey(outParameterName)) && outValue != null)\n                autoResult.put(outParameterName, outValue);\n        }\n    }\n\n    @Override\n    public void destroy() { }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/tools/H2ServerToolFactory.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.tools\n\nimport groovy.transform.CompileStatic\nimport org.h2.tools.Server\nimport org.moqui.context.ExecutionContextFactory\nimport org.moqui.context.ToolFactory\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.util.MNode\nimport org.moqui.util.SystemBinding\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.lang.reflect.Field\n\n\n/** Initializes H2 Database server if any datasource is configured to use H2. */\n@CompileStatic\nclass H2ServerToolFactory implements ToolFactory<Server> {\n    protected final static Logger logger = LoggerFactory.getLogger(H2ServerToolFactory.class)\n\n    protected ExecutionContextFactoryImpl ecfi = null\n\n    // for the embedded H2 server to allow remote access, used to stop server on destroy\n    protected Server h2Server = null\n\n    /** Default empty constructor */\n    H2ServerToolFactory() { }\n\n    @Override\n    void init(ExecutionContextFactory ecf) {\n        this.ecfi = (ExecutionContextFactoryImpl) ecf\n\n        for (MNode datasourceNode in ecfi.getConfXmlRoot().first(\"entity-facade\").children(\"datasource\")) {\n            String dbConfName = datasourceNode.attribute(\"database-conf-name\")\n            if (!\"h2\".equals(dbConfName)) continue\n\n            String argsString = datasourceNode.attribute(\"start-server-args\")\n            if (argsString == null || argsString.isEmpty()) {\n                MNode dbNode = ecfi.confXmlRoot.first(\"database-list\")\n                        .first({ MNode it -> \"database\".equals(it.name) && \"h2\".equals(it.attribute(\"name\")) })\n                argsString = dbNode.attribute(\"default-start-server-args\")\n            }\n            if (argsString) {\n                String[] args = argsString.split(\" \")\n                for (int i = 0; i < args.length; i++) while (args[i].contains('${')) args[i] = SystemBinding.expand(args[i])\n                try {\n                    h2Server = Server.createTcpServer(args).start();\n                    logger.info(\"Started H2 remote server on port ${h2Server.getPort()} status: ${h2Server.getStatus()}\")\n                    logger.info(\"H2 args: ${args}\")\n                    // only start one server\n                    break\n                } catch (Throwable t) {\n                    logger.warn(\"Error starting H2 server (may already be running): ${t.toString()}\")\n                }\n            }\n        }\n    }\n\n    @Override\n    Server getInstance(Object... parameters) {\n        if (h2Server == null) throw new IllegalStateException(\"H2ServerToolFactory not initialized\")\n        return h2Server\n    }\n\n    @Override\n    void postFacadeDestroy() {\n        // NOTE: using shutdown() instead of stop() so it shuts down the DB and stops the TCP server\n        if (h2Server != null) {\n            h2Server.shutdown()\n            System.out.println(\"Shut down H2 Server\")\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/tools/JCSCacheToolFactory.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.tools\n\nimport groovy.transform.CompileStatic\nimport org.moqui.context.ExecutionContextFactory\nimport org.moqui.context.ToolFactory\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport javax.cache.CacheManager\nimport javax.cache.Caching\nimport javax.cache.spi.CachingProvider\n\n/** A factory for getting a JCS CacheManager; this has no compile time dependency on Commons JCS, just add the jar files\n * Current artifact: org.apache.commons:commons-jcs-jcache:2.0-beta-1\n */\n@CompileStatic\nclass JCSCacheToolFactory implements ToolFactory<CacheManager> {\n    protected final static Logger logger = LoggerFactory.getLogger(JCSCacheToolFactory.class)\n    final static String TOOL_NAME = \"JCSCache\"\n\n    protected ExecutionContextFactory ecf = null\n\n    protected CacheManager cacheManager = null\n\n    /** Default empty constructor */\n    JCSCacheToolFactory() { }\n\n    @Override\n    String getName() { return TOOL_NAME }\n    @Override\n    void init(ExecutionContextFactory ecf) { }\n    @Override\n    void preFacadeInit(ExecutionContextFactory ecf) {\n        this.ecf = ecf\n        // always use the server caching provider, the client one always goes over a network interface and is slow\n        ClassLoader cl = Thread.currentThread().getContextClassLoader()\n        CachingProvider providerInternal = Caching.getCachingProvider(\"org.apache.commons.jcs.jcache.JCSCachingProvider\", cl)\n        URL cmUrl = cl.getResource(\"cache.ccf\")\n        logger.info(\"JCS config URI: ${cmUrl}\")\n        cacheManager = providerInternal.getCacheManager(cmUrl.toURI(), cl)\n        logger.info(\"Initialized JCS CacheManager\")\n    }\n\n    @Override\n    CacheManager getInstance(Object... parameters) {\n        if (cacheManager == null) throw new IllegalStateException(\"JCSCacheToolFactory not initialized\")\n        return cacheManager\n    }\n\n    @Override\n    void destroy() {\n        // do nothing?\n    }\n\n    ExecutionContextFactory getEcf() { return ecf }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/tools/JackrabbitRunToolFactory.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.tools\n\nimport groovy.transform.CompileStatic\nimport org.moqui.context.ExecutionContextFactory\nimport org.moqui.context.ToolFactory\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n/** ToolFactory to initialize Apache Jackrabbit and get a java.lang.Process for the Jackrabbit instance */\n@CompileStatic\nclass JackrabbitRunToolFactory implements ToolFactory<Process> {\n    protected final static Logger logger = LoggerFactory.getLogger(JackrabbitRunToolFactory.class)\n    final static String TOOL_NAME = \"JackrabbitRun\"\n\n    protected ExecutionContextFactory ecf = null\n\n    /** Jackrabbit Process */\n    protected Process jackrabbitProcess = null\n\n    /** Default empty constructor */\n    JackrabbitRunToolFactory() { }\n\n    @Override\n    String getName() { return TOOL_NAME }\n    @Override\n    void init(ExecutionContextFactory ecf) {\n    }\n    @Override\n    void preFacadeInit(ExecutionContextFactory ecf) {\n        this.ecf = ecf\n\n        logger.info(\"Initializing Jackrabbit\")\n        Properties jackrabbitProperties = new Properties()\n        URL jackrabbitProps = this.class.getClassLoader().getResource(\"jackrabbit_moqui.properties\")\n        if (jackrabbitProps != null) {\n            InputStream is = jackrabbitProps.openStream(); jackrabbitProperties.load(is); is.close();\n        }\n\n        String jackrabbitWorkingDir = System.getProperty(\"moqui.jackrabbit_working_dir\")\n        if (!jackrabbitWorkingDir) jackrabbitWorkingDir = jackrabbitProperties.getProperty(\"moqui.jackrabbit_working_dir\")\n        if (!jackrabbitWorkingDir) jackrabbitWorkingDir = \"jackrabbit\"\n\n        String jackrabbitJar = System.getProperty(\"moqui.jackrabbit_jar\")\n        if (!jackrabbitJar) jackrabbitJar = jackrabbitProperties.getProperty(\"moqui.jackrabbit_jar\")\n        if (!jackrabbitJar) throw new IllegalArgumentException(\n                \"No moqui.jackrabbit_jar property found in jackrabbit_moqui.ini or in a system property (with: -Dmoqui.jackrabbit_jar=... on the command line)\")\n        String jackrabbitJarFullPath = ecf.runtimePath + \"/\" + jackrabbitWorkingDir + \"/\" + jackrabbitJar\n\n        String jackrabbitConfFile = System.getProperty(\"moqui.jackrabbit_configuration_file\")\n        if (!jackrabbitConfFile)\n            jackrabbitConfFile = jackrabbitProperties.getProperty(\"moqui.jackrabbit_configuration_file\")\n        if (!jackrabbitConfFile) jackrabbitConfFile = \"repository.xml\"\n        String jackrabbitConfFileFullPath = ecf.runtimePath + \"/\" + jackrabbitWorkingDir + \"/\" + jackrabbitConfFile\n\n        String jackrabbitPort = System.getProperty(\"moqui.jackrabbit_port\")\n        if (!jackrabbitPort)\n            jackrabbitPort = jackrabbitProperties.getProperty(\"moqui.jackrabbit_port\")\n        if (!jackrabbitPort) jackrabbitPort = \"8081\"\n\n        logger.info(\"Starting Jackrabbit\")\n\n        ProcessBuilder pb = new ProcessBuilder(\"java\", \"-jar\", jackrabbitJarFullPath, \"-p\", jackrabbitPort, \"-c\", jackrabbitConfFileFullPath)\n        pb.directory(new File(ecf.runtimePath + \"/\" + jackrabbitWorkingDir))\n        jackrabbitProcess = pb.start();\n\n        while(!hostAvailabilityCheck(\"localhost\", jackrabbitPort.toInteger())) {\n            sleep(500)\n        }\n    }\n\n    @Override\n    Process getInstance(Object... parameters) {\n        if (jackrabbitProcess == null) throw new IllegalStateException(\"JackrabbitRunToolFactory not initialized\")\n        return jackrabbitProcess\n    }\n\n    @Override\n    void destroy() {\n        // Stop Jackrabbit process\n        if (jackrabbitProcess != null) try {\n            jackrabbitProcess.destroy()\n            logger.info(\"Jackrabbit process destroyed\")\n        } catch (Throwable t) { logger.error(\"Error in JackRabbit process destroy\", t) }\n    }\n\n    ExecutionContextFactory getEcf() { return ecf }\n\n    private static boolean hostAvailabilityCheck(String hostname, int port) {\n        Socket s = null\n        try {\n            s = new Socket(hostname, port)\n            return true\n        } catch (IOException e ) {\n            /* ignore */\n        } finally {\n            if (s != null) try { s.close() } catch (Exception e) {}\n        }\n        return false\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/tools/MCacheToolFactory.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.tools;\n\nimport org.moqui.context.ExecutionContextFactory;\nimport org.moqui.context.ToolFactory;\nimport org.moqui.jcache.MCacheManager;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport javax.cache.CacheManager;\n\n/** A factory for getting a MCacheManager */\npublic class MCacheToolFactory implements ToolFactory<CacheManager> {\n    protected final static Logger logger = LoggerFactory.getLogger(MCacheToolFactory.class);\n    public final static String TOOL_NAME = \"MCache\";\n\n    protected ExecutionContextFactory ecf = null;\n\n    private MCacheManager cacheManager = null;\n\n    /** Default empty constructor */\n    public MCacheToolFactory() { }\n\n    @Override\n    public String getName() { return TOOL_NAME; }\n    @Override\n    public void init(ExecutionContextFactory ecf) { }\n    @Override\n    public void preFacadeInit(ExecutionContextFactory ecf) {\n        this.ecf = ecf;\n        cacheManager = MCacheManager.getMCacheManager();\n    }\n\n    @Override\n    public CacheManager getInstance(Object... parameters) {\n        if (cacheManager == null) throw new IllegalStateException(\"MCacheToolFactory not initialized\");\n        return cacheManager;\n    }\n\n    @Override\n    public void destroy() { cacheManager.close(); }\n\n    ExecutionContextFactory getEcf() { return ecf; }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/tools/SubEthaSmtpToolFactory.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.tools\n\nimport groovy.transform.CompileStatic\nimport org.moqui.context.ExecutionContextFactory\nimport org.moqui.context.ToolFactory\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.service.EmailEcaRule\nimport org.moqui.impl.util.MoquiShiroRealm\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\nimport org.subethamail.smtp.MessageContext\nimport org.subethamail.smtp.MessageHandler\nimport org.subethamail.smtp.MessageHandlerFactory\nimport org.subethamail.smtp.RejectException\nimport org.subethamail.smtp.TooMuchDataException\nimport org.subethamail.smtp.auth.EasyAuthenticationHandlerFactory\nimport org.subethamail.smtp.auth.LoginFailedException\nimport org.subethamail.smtp.auth.UsernamePasswordValidator\nimport org.subethamail.smtp.server.SMTPServer\n\nimport jakarta.mail.Session\nimport jakarta.mail.internet.MimeMessage\n\n/**\n * ToolFactory to initialize SubEtha SMTP server and provide access to an instance of org.subethamail.smtp.server.SMTPServer\n *\n * Includes static class EmecaMessageHandler that will generate Email ECA events for messages received.\n *\n * See the MOQUI_LOCAL EmailServer record in seed data for SMTP server parameters.\n */\n@CompileStatic\nclass SubEthaSmtpToolFactory implements ToolFactory<SMTPServer> {\n    protected final static Logger logger = LoggerFactory.getLogger(SubEthaSmtpToolFactory.class)\n    final static String TOOL_NAME = \"SubEthaSmtp\"\n    final static String EMAIL_SERVER_ID = \"MOQUI_LOCAL\"\n\n    protected ExecutionContextFactoryImpl ecfi = null\n    protected SMTPServer smtpServer = null\n    protected EmecaMessageHandlerFactory messageHandlerFactory = null\n    protected EasyAuthenticationHandlerFactory authHandlerFactory = null\n    protected Session session = Session.getInstance(System.getProperties())\n\n\n    /** Default empty constructor */\n    SubEthaSmtpToolFactory() { }\n\n    @Override String getName() { return TOOL_NAME }\n    @Override\n    void init(ExecutionContextFactory ecf) {\n        ecfi = (ExecutionContextFactoryImpl) ecf\n\n        EntityValue emailServer = ecf.entity.find(\"moqui.basic.email.EmailServer\").condition(\"emailServerId\", EMAIL_SERVER_ID)\n                .useCache(true).disableAuthz().one()\n\n        if (emailServer == null) {\n            logger.error(\"Not starting SubEtha SMTP server, could not find ${EMAIL_SERVER_ID} EmailServer record\")\n            return\n        }\n        int port = emailServer.smtpPort as int\n\n        messageHandlerFactory = new EmecaMessageHandlerFactory(this)\n        authHandlerFactory = new EasyAuthenticationHandlerFactory(new MoquiUsernamePasswordValidator(ecfi))\n\n        def serverBuilder = SMTPServer\n                .port(port)\n                .messageHandlerFactory(messageHandlerFactory)\n                .authenticationHandlerFactory(authHandlerFactory)\n        if (emailServer.smtpStartTls == \"Y\") {\n            serverBuilder = serverBuilder.enableTLS()\n        }\n        smtpServer = serverBuilder.build()\n        smtpServer.start()\n    }\n    @Override void preFacadeInit(ExecutionContextFactory ecf) { }\n\n    @Override\n    SMTPServer getInstance(Object... parameters) {\n        if (smtpServer == null) throw new IllegalStateException(\"SubEthaSmtpToolFactory not initialized\")\n        return smtpServer\n    }\n\n    @Override\n    void destroy() {\n        if (smtpServer != null) try {\n            smtpServer.stop()\n            logger.info(\"SubEtha SMTP server stopped\")\n        } catch (Throwable t) { logger.error(\"Error in SubEtha SMTP server stop\", t) }\n    }\n\n    static class EmecaMessageHandlerFactory implements MessageHandlerFactory {\n        final SubEthaSmtpToolFactory toolFactory\n        EmecaMessageHandlerFactory(SubEthaSmtpToolFactory toolFactory) { this.toolFactory = toolFactory }\n        @Override MessageHandler create(MessageContext ctx) { return new EmecaMessageHandler(ctx, toolFactory) }\n    }\n\n    static class EmecaMessageHandler implements MessageHandler {\n        final MessageContext ctx\n        final SubEthaSmtpToolFactory toolFactory\n\n        private String from = (String) null\n        private List<String> recipientList = new LinkedList<>()\n        private MimeMessage mimeMessage = (MimeMessage) null\n\n        EmecaMessageHandler(MessageContext ctx, SubEthaSmtpToolFactory toolFactory) { this.ctx = ctx; this.toolFactory = toolFactory; }\n\n        @Override void from(String from) throws RejectException { this.from = from }\n        @Override void recipient(String recipient) throws RejectException { recipientList.add(recipient) }\n        @Override\n        String data(InputStream data) throws RejectException, TooMuchDataException, IOException {\n            // TODO: ever reject? perhaps of the from or no recipient addresses match a valid UserAccount.username?\n            mimeMessage = new MimeMessage(toolFactory.session, data)\n            return null\n        }\n\n        @Override\n        void done() {\n            // run EMECA rules\n            toolFactory.ecfi.serviceFacade.runEmecaRules(mimeMessage, EMAIL_SERVER_ID)\n            // always save EmailMessage record? better to let an EMECA rule do it...\n            // logger.warn(\"Got email: ${mimeMessage.getSubject()} from ${from} recipients ${recipientList}\\n${EmailEcaRule.makeBodyPartList(mimeMessage)}\")\n        }\n    }\n\n    static class MoquiUsernamePasswordValidator implements UsernamePasswordValidator {\n        final ExecutionContextFactoryImpl ecf\n        MoquiUsernamePasswordValidator(ExecutionContextFactoryImpl ecf) { this.ecf = ecf }\n        @Override\n        void login(String username, String password, MessageContext messageContext) throws LoginFailedException {\n            EntityValue emailServer = ecf.entity.find(\"moqui.basic.email.EmailServer\").condition(\"emailServerId\", EMAIL_SERVER_ID)\n                    .useCache(true).disableAuthz().one()\n            if (emailServer.mailUsername == username) {\n                if (emailServer.mailPassword != password) throw new LoginFailedException(\"Password incorrect for email root user\")\n            } else {\n                if (!MoquiShiroRealm.checkCredentials(username, password, ecf))\n                    throw new LoginFailedException(ecf.resource.expand('Username ${username} and/or password incorrect','',[username:username]))\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/util/EdiHandler.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.util\n\nimport groovy.transform.CompileStatic\nimport org.moqui.context.ExecutionContext\nimport org.moqui.util.CollectionUtilities\nimport org.moqui.util.ObjectUtilities\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.util.regex.Pattern\n\n@CompileStatic\nclass EdiHandler {\n    protected final static Logger logger = LoggerFactory.getLogger(EdiHandler.class)\n\n    protected ExecutionContext ec\n\n    Character segmentTerminator = null\n    Character elementSeparator = null\n    Character componentDelimiter = null\n    char escapeCharacter = '?'\n    Character segmentSuffix = '\\n'\n\n    protected List<Map<String, Object>> envelope = null\n    protected List<Map<String, Object>> body = null\n    protected String bodyRootId = null\n    protected Set<String> knownSegmentIds = new HashSet<>()\n    // FUTURE: load Bots record defs to validate input/output messages: Map<String, List> recordDefs\n\n    protected List<SegmentError> segmentErrors = null\n\n    EdiHandler(ExecutionContext ec) { this.ec = ec }\n\n    EdiHandler setChars(Character segmentTerminator, Character elementSeparator, Character componentDelimiter, Character escapeCharacter) {\n        this.segmentTerminator = segmentTerminator ?: ('~' as Character)\n        this.elementSeparator = elementSeparator ?: ('*' as Character)\n        this.componentDelimiter = componentDelimiter ?: (':' as Character)\n        this.escapeCharacter = (escapeCharacter ?: '?') as char\n        return this\n    }\n\n    // NOTE: common X12 componentDelimiter seems to include ':', '^', '<', '@', etc...\n    EdiHandler setX12DefaultChars() { setChars('~' as char, '*' as char, ':' as char, '?' as char); return this }\n    EdiHandler setITradeDefaultChars() { setChars('~' as char, '*' as char, '@' as char, '?' as char); return this }\n    EdiHandler setEdifactDefaultChars() { setChars('\\'' as char, '+' as char, ':' as char, '?' as char); return this }\n\n    /** Run a Groovy script at location to get the nested List/Map file envelope structure (for X12: ISA, GS, and ST\n     * segments). The QUERIES and SUBTRANSLATION entries can be removed, will be ignored.\n     *\n     * These are based on Bots Grammars (http://sourceforge.net/projects/bots/files/grammars/), converted from Python to\n     * Groovy List/Map syntax (search/replace '{' to '[' and '}' to ']'), only include the structure List (script should\n     * evaluate to or return just the structure List).\n     */\n    EdiHandler loadEnvelope(String location) {\n        envelope = (List<Map<String, Object>>) ec.resource.script(location, null)\n        extractSegmentIds(envelope)\n        return this\n    }\n\n    /** Run a Groovy script at location to get the nested List/Map file structure. The segment(s) in the top-level List\n     * should be referenced in the envelope structure, ie this structure will be used under the envelope structure.\n     *\n     * These are based on Bots Grammars (http://sourceforge.net/projects/bots/files/grammars/), converted from Python to\n     * Groovy List/Map syntax (search/replace '{' to '[' and '}' to ']'), only include the structure List (script should\n     * evaluate to or return just the structure List).\n     */\n    EdiHandler loadBody(String location) {\n        body = (List<Map<String, Object>>) ec.resource.script(location, null)\n        extractSegmentIds(body)\n        bodyRootId = body[0].ID\n        return this\n    }\n\n    protected void extractSegmentIds(List<Map<String, Object>> defList) {\n        for (Map<String, Object> defMap in defList) {\n            knownSegmentIds.add((String) defMap.ID)\n            if (defMap.LEVEL) extractSegmentIds((List<Map<String, Object>>) defMap.LEVEL)\n        }\n    }\n\n    /** Parse EDI text and return a Map containing a \"elements\" entry with a List of element values (each may be a String\n     * or List<String>) and an entry for each child segment where the key is the segment ID (generally 2 or 3 characters)\n     * and the value is a List<Map> where each Map has this same Map structure.\n     *\n     * If no definition is found for a segment, all text the segment (in original form) is put in the \"originalList\"\n     * (of type List<String>) entry. This is used for partial parsing (envelope only) and then completing the parse with\n     * the body structure loaded.\n     */\n    Map<String, List<Object>> parseText(String ediText) {\n        if (envelope == null) throw new IllegalArgumentException(\"Cannot parse EDI text, envelope must be loaded\")\n        if (!ediText) throw new IllegalArgumentException(\"No EDI text passed\")\n\n        segmentErrors = []\n        determineSeparators(ediText)\n\n        List<String> allSegmentStringList = Arrays.asList(ediText.split(getSegmentRegex()))\n        if (allSegmentStringList.size() < 2) throw new IllegalArgumentException(\"No segments found in EDI text, using segment terminator [${segmentTerminator}]\")\n\n        Map<String, List<Object>> rootMap = [:]\n        parseSegments(allSegmentStringList, 0, rootMap, envelope)\n        return rootMap\n    }\n    List<SegmentError> getSegmentErrors() { return segmentErrors }\n\n    /** Generate EDI text from the same Map/List structure created from the parse. */\n    String generateText(Map<String, List<Object>> rootMap) {\n        if (segmentTerminator == null) throw new IllegalArgumentException(\"No segment terminator specified\")\n        if (elementSeparator == null) throw new IllegalArgumentException(\"No element separator specified\")\n        if (componentDelimiter == null) throw new IllegalArgumentException(\"No component delimiter specified\")\n\n        StringBuilder sb = new StringBuilder()\n        generateSegment(rootMap, sb)\n        return sb.toString()\n    }\n\n\n    // X12 ISA segment is fixed width, pad fields to width of each element\n    Map<String, List<Integer>> segmentElementSizes = [ISA:[3, 2, 10, 2, 10, 2, 15, 2, 15, 6, 4, 1, 5, 9, 1, 1, 1]]\n    char paddingChar = '\\u00a0'\n    Set<String> noEscapeSegments = new HashSet<>(['ISA', 'UNA'])\n    protected void generateSegment(Map<String, List<Object>> segmentMap, StringBuilder sb) {\n        if (segmentMap.elements) {\n            List<Object> elements = segmentMap.elements\n            String segmentId = elements[0]\n            List<Integer> elementSizes = segmentElementSizes.get(segmentId)\n            boolean noEscape = noEscapeSegments.contains(segmentId)\n\n            // all segments should have elements, but root Map will not\n            for (int i = 0; i < elements.size(); i++) {\n                Object element = elements[i]\n                Integer elementSize = elementSizes ? elementSizes[i] : null\n                if (element instanceof List) {\n                    // composite element, add each component with component delimiter\n                    Iterator compIter = element.iterator()\n                    while (compIter.hasNext()) {\n                        Object curComp = compIter.next()\n                        if (curComp != null) sb.append(escape(ObjectUtilities.toPlainString(curComp)))\n                        if (compIter.hasNext()) sb.append(componentDelimiter)\n                    }\n                } else {\n                    String elementString = ObjectUtilities.toPlainString(element)\n                    if (!noEscape) elementString = escape(elementString)\n                    sb.append(elementString)\n                    if (elementSize != null) {\n                        int curSize = elementString.size()\n                        while (curSize < elementSize) { sb.append(paddingChar); curSize++ }\n                    }\n                }\n                // append the element separator, if there is another element\n                if (i < (elements.size() - 1)) sb.append(elementSeparator)\n            }\n            // append segment terminator\n            sb.append(segmentTerminator)\n            // if there is a segment suffix append that\n            if (segmentSuffix) sb.append(segmentSuffix)\n        }\n\n        // generate child segments\n        for (Map.Entry<String, List<Object>> entry in segmentMap.entrySet()) {\n            if (!(entry.value instanceof List)) throw new IllegalArgumentException(\"Entry value is not a list: ${entry}\")\n            if (entry.key == \"elements\") continue\n            if (entry.key == \"originalList\") {\n                // also support output of literal child segments from originalList (full segment string except terminator)\n                for (Object original in entry.value) sb.append(original).append(segmentTerminator)\n            } else {\n                // is a child segment\n                for (Object childObj in entry.value) {\n                    if (childObj instanceof Map) {\n                        generateSegment((Map<String, List<Object>>) childObj, sb)\n                    } else {\n                        // should ALWAYS be a Map at this level, if not blow up\n                        throw new Exception(\"Expected Map for segment, got: ${childObj}\")\n                    }\n                }\n            }\n        }\n    }\n\n    protected void determineSeparators(String ediText) {\n        // auto-detect segment/element/component chars (only if not set)\n        // useful reference, see: https://mohsinkalam.wordpress.com/delimiters/\n        if (ediText.startsWith(\"ISA\")) {\n            // X12 message\n            if (segmentTerminator == null) segmentTerminator = ediText.charAt(105) as Character\n            if (elementSeparator == null) elementSeparator = ediText.charAt(3) as Character\n            if (componentDelimiter == null) componentDelimiter = ediText.charAt(104) as Character\n        } else if (ediText.startsWith(\"UNA\")) {\n            // EDIFACT message\n            if (segmentTerminator == null) segmentTerminator = ediText.charAt(8) as Character\n            if (elementSeparator == null) elementSeparator = ediText.charAt(4) as Character\n            if (componentDelimiter == null) componentDelimiter = ediText.charAt(3) as Character\n        }\n\n        if (segmentTerminator == null) throw new IllegalArgumentException(\"No segment terminator specified or automatically determined\")\n        if (elementSeparator == null) throw new IllegalArgumentException(\"No element separator specified or automatically determined\")\n        if (componentDelimiter == null) throw new IllegalArgumentException(\"No component delimiter specified or automatically determined\")\n    }\n\n    /** Internal recursive method for parsing segments */\n    protected int parseSegments(List<String> allSegmentStringList, int segmentIndex, Map<String, List<Object>> currentSegment,\n                                List<Map<String, Object>> levelDefList) {\n        while (segmentIndex < allSegmentStringList.size()) {\n            String segmentString = allSegmentStringList.get(segmentIndex).trim()\n            String segmentId = getSegmentId(segmentString)\n            if (segmentId == null) {\n                // this shouldn't generally happen, but may if there is a terminating character at the end of the message (after the last segment separator)\n                logger.info(\"No ID found for segment: ${segmentString}\")\n                segmentIndex++\n                continue\n            }\n            Map<String, Object> curDefMap = levelDefList.find({ it.ID == segmentId })\n            if (curDefMap != null) {\n                // NOTE: incremented in parseSegment, returns next segment to process\n                segmentIndex = parseSegment(allSegmentStringList, segmentIndex, currentSegment, curDefMap)\n            } else if (!knownSegmentIds.contains(segmentId)) {\n                if (body) {\n                    // TODO: improve this to handle multiple positions, somehow keep track of last tx set start segment (in X12 is ST; is first segment in body)\n                    int positionInTxSet = segmentIndex - 2\n                    segmentErrors.add(new SegmentError(SegmentErrorType.NOT_DEFINED_IN_TX_SET, segmentIndex,\n                            positionInTxSet, segmentId, segmentString))\n                    segmentIndex++\n                } else {\n                    // skip the segment; this is necessary to support partial parsing with envelope only\n                    segmentIndex++\n                    // save the string in originalList\n                    List<Object> originalList = currentSegment.originalList\n                    if (originalList == null) {\n                        originalList = new ArrayList<>()\n                        currentSegment.originalList = originalList\n                    }\n                    originalList.add(segmentString)\n                }\n            } else {\n                // if segmentId is not in the current levelDefList, return to check against parent\n                return segmentIndex\n            }\n        }\n        // this will only happen for the root segment, the final child (trailer segment)\n        return segmentIndex\n    }\n\n    protected int parseSegment(List<String> allSegmentStringList, int segmentIndex, Map<String, List<Object>> currentSegment,\n                               Map<String, Object> curDefMap) {\n        String segmentString = allSegmentStringList.get(segmentIndex).trim()\n        ArrayList<Object> elements = getSegmentElements(segmentString)\n\n        String segmentId = elements[0]\n        // if segmentId is in the current levelDefList add as child to current segment, increment index, recurse\n        Map<String, List<Object>> newSegment = [elements:elements] as Map<String, List>\n        CollectionUtilities.addToListInMap(segmentId, newSegment, currentSegment)\n\n        int nextSegmentIndex = segmentIndex + 1\n        // current segment has children (ie LEVEL entry)? then recurse otherwise just return to handle siblings/parents\n        List<Map<String, Object>> curDefLevel = (List<Map<String, Object>>) curDefMap.LEVEL\n        if (!curDefLevel && body && curDefMap.ID == bodyRootId) {\n            // switch from envelope to body\n            curDefLevel = (List<Map<String, Object>>) body[0].LEVEL\n        }\n        if (curDefLevel) {\n            return parseSegments(allSegmentStringList, nextSegmentIndex, newSegment, curDefLevel)\n        } else {\n            return nextSegmentIndex\n        }\n    }\n\n    protected String getSegmentId(String segmentString) {\n        int separatorIndex = segmentString.indexOf(elementSeparator as String)\n        if (separatorIndex > 0) {\n            return segmentString.substring(0, separatorIndex)\n        } else if (segmentString.size() <= 3) {\n            return segmentString\n        } else {\n            return null\n        }\n    }\n    protected ArrayList<Object> getSegmentElements(String segmentString) {\n        List<String> originalElementList = Arrays.asList(segmentString.split(getElementRegex()))\n        // split composite elements to components List, unescape elements\n        ArrayList<Object> elements = new ArrayList<>(originalElementList.size())\n        for (String originalElement in originalElementList) {\n            // change non-breaking white space to regular space before trim\n            originalElement = originalElement.replaceAll(\"\\\\u00a0\", \" \")\n            originalElement = originalElement.trim()\n            if (originalElement.length() >= 3 && originalElement.contains(componentDelimiter as String)) {\n                String[] componentArray = originalElement.split(getComponentRegex())\n                if (componentArray.length == 1) {\n                    elements.add(unescape(componentArray[0]))\n                } else {\n                    ArrayList<String> components = new ArrayList<>(componentArray.length)\n                    for (String component in componentArray) components.add(unescape(component.trim()))\n                    elements.add(components)\n                }\n            } else {\n                elements.add(unescape(originalElement))\n            }\n        }\n\n        return elements\n    }\n\n    // regex strings have a non-capturing lookahead for the escape character (ie only separate if not escaped)\n    protected String getSegmentRegex() { return \"(?<!${Pattern.quote(escapeCharacter as String)})${Pattern.quote(segmentTerminator as String)}\".toString() }\n    protected String getElementRegex() { return \"(?<!${Pattern.quote(escapeCharacter as String)})${Pattern.quote(elementSeparator as String)}\".toString() }\n    protected String getComponentRegex() { return \"(?<!${Pattern.quote(escapeCharacter as String)})${Pattern.quote(componentDelimiter as String)}\".toString() }\n\n    List<String> splitMessage(String rootHeaderId, String rootTrailerId, String ediText) {\n        determineSeparators(ediText)\n\n        List<String> splitStringList = []\n        List<String> allSegmentStringList = Arrays.asList(ediText.split(getSegmentRegex()))\n\n        ArrayList<String> curSplitList = null\n        for (int i = 0; i < allSegmentStringList.size(); i++) {\n            String segmentString = allSegmentStringList.get(i).trim()\n            String segId = getSegmentId(segmentString)\n\n            if (rootHeaderId && segId == rootHeaderId && curSplitList) {\n                // hit a header without a footer, save what we have so far and start a new split\n                splitStringList.add(combineSegments(curSplitList))\n                curSplitList = new ArrayList<>()\n                curSplitList.add(segmentString)\n            } else if (rootTrailerId && segId == rootTrailerId) {\n                // hit a trailer, add it to the current split, save the split, clear the current split\n                if (curSplitList == null) curSplitList = new ArrayList<>()\n                curSplitList.add(segmentString)\n                splitStringList.add(combineSegments(curSplitList))\n                curSplitList = null\n            } else {\n                if (curSplitList == null) curSplitList = new ArrayList<>()\n                curSplitList.add(segmentString)\n            }\n        }\n\n        return splitStringList\n    }\n    String combineSegments(ArrayList<String> segmentStringList) {\n        StringBuilder sb = new StringBuilder()\n        for (int i = 0; i < segmentStringList.size(); i++) {\n            sb.append(segmentStringList.get(i)).append(segmentTerminator)\n            if (segmentSuffix) sb.append(segmentSuffix)\n        }\n        return sb.toString()\n    }\n\n    int countSegments(Map<String, List<Object>> ediMap) {\n        int count = 0\n        if (ediMap.size() <= 1) return 0\n        for (Map.Entry<String, List<Object>> entry in ediMap) {\n            if (entry.key == 'elements') continue\n            for (Object itemObj in entry.value) {\n                if (itemObj instanceof Map) {\n                    Map<String, List<Object>> itemMap = (Map<String, List<Object>>) itemObj\n                    if (itemMap.size() > 0) count++\n                    if (itemMap.size() > 1) count += countSegments(itemMap)\n                }\n            }\n        }\n        return count\n    }\n\n    protected String escape(String original) {\n        if (!original) return \"\"\n        StringBuilder builder = new StringBuilder()\n        for (int i = 0; i < original.length(); i++) {\n            char c = original.charAt(i)\n            if (needsEscape(c)) builder.append(escapeCharacter)\n            builder.append(c)\n        }\n        return builder.toString()\n    }\n    protected boolean needsEscape(char c) {\n        return (c == componentDelimiter || c == elementSeparator || c == escapeCharacter || c == segmentTerminator)\n    }\n    protected String unescape(String original) {\n        StringBuilder builder = new StringBuilder()\n        for (int i = 0; i < original.length(); i++) {\n            char c = original.charAt(i)\n            if (c == escapeCharacter) {\n                // skip it and append the next character (next char might be escape character to don't just skip)\n                i++\n                builder.append(original.charAt(i))\n            } else {\n                builder.append(c)\n            }\n        }\n        return builder.toString()\n    }\n\n    static enum SegmentErrorType { UNRECOGNIZED_SEGMENT_ID, UNEXPECTED, MANDATORY_MISSING, LOOP_OVER_MAX, EXCEEDS_MAXIMUM_USE,\n            NOT_DEFINED_IN_TX_SET, NOT_IN_SEQUENCE, ELEMENT_ERRORS }\n    /* X12 AK304 Element Error Codes\n        1 Unrecognized segment ID\n        2 Unexpected segment\n        3 Mandatory segment missing\n        4 Loop Occurs Over Maximum Times\n        5 Segment Exceeds Maximum Use\n        6 Segment Not in Defined Transaction Set\n        7 Segment Not in Proper Sequence\n        8 Segment Has Data Element Errors\n     */\n    static Map<SegmentErrorType, String> segmentErrorX12Codes = [\n            (SegmentErrorType.UNRECOGNIZED_SEGMENT_ID):'1', (SegmentErrorType.UNEXPECTED):'2',\n            (SegmentErrorType.MANDATORY_MISSING):'3', (SegmentErrorType.LOOP_OVER_MAX):'4',\n            (SegmentErrorType.EXCEEDS_MAXIMUM_USE):'5', (SegmentErrorType.NOT_DEFINED_IN_TX_SET):'6',\n            (SegmentErrorType.NOT_IN_SEQUENCE):'7',(SegmentErrorType.ELEMENT_ERRORS):'8']\n\n    static enum ElementErrorType { MANDATORY_MISSING, CONDITIONAL_REQUIRED_MISSING, TOO_MANY, TOO_SHORT, TOO_LONG,\n            INVALID_CHAR, INVALID_CODE, INVALID_DATE, INVALID_TIME, EXCLUSION_VIOLATED }\n    /* X12 AK403 Element Error Codes\n        1 Mandatory data element missing\n        2 Conditional required data element missing.\n        3 Too many data elements.\n        4 Data element too short.\n        5 Data element too long.\n        6 Invalid character in data element.\n        7 Invalid code value.\n        8 Invalid Date\n        9 Invalid Time\n        10 Exclusion Condition Violated\n     */\n    static Map<ElementErrorType, String> elementErrorX12Codes = [\n            (ElementErrorType.MANDATORY_MISSING):'1', (ElementErrorType.CONDITIONAL_REQUIRED_MISSING):'2',\n            (ElementErrorType.TOO_MANY):'3', (ElementErrorType.TOO_SHORT):'4',\n            (ElementErrorType.TOO_LONG):'5', (ElementErrorType.INVALID_CHAR):'6',\n            (ElementErrorType.INVALID_CODE):'7', (ElementErrorType.INVALID_DATE):'8',\n            (ElementErrorType.INVALID_TIME):'9', (ElementErrorType.EXCLUSION_VIOLATED):'10']\n\n    static class SegmentError {\n        SegmentErrorType errorType\n        int segmentIndex\n        int positionInTxSet\n        String segmentId\n        String segmentText\n        List<ElementError> elementErrors = []\n        SegmentError(SegmentErrorType errorType, int segmentIndex, int positionInTxSet, String segmentId, String segmentText) {\n            this.errorType=errorType; this.segmentIndex = segmentIndex; this.positionInTxSet = positionInTxSet\n            this.segmentId = segmentId; this.segmentText = segmentText\n        }\n        /** NOTE: used in mantle EdiServices.produce#X12FunctionalAck */\n        Map<String, List> makeAk3() {\n            Map<String, List> AK3 = [:]\n            AK3.elements = ['AK3', segmentId, positionInTxSet as String, '', segmentErrorX12Codes.get(errorType)]\n            if (elementErrors) {\n                List<Object> ak4List = []\n                AK3.AK4 = ak4List\n                for (ElementError elementError in elementErrors) ak4List.add(elementError.makeAk4())\n            }\n            return AK3\n        }\n    }\n    static class ElementError {\n        ElementErrorType errorType\n        int elementPosition\n        Integer compositePosition\n        String elementText\n        ElementError(ElementErrorType errorType, int elementPosition, Integer compositePosition, String elementText) {\n            this.errorType = errorType; this.elementPosition = elementPosition\n            this.compositePosition = compositePosition; this.elementText = elementText\n        }\n        Map<String, List<Object>> makeAk4() {\n            Object position = elementPosition as String\n            if (compositePosition) position = [position, compositePosition as String]\n            List<Object> elements = [\n                'AK4',\n                position,\n                elementErrorX12Codes.get(errorType),\n                elementText\n            ]\n            return [elements: elements]\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/util/ElFinderConnector.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.util\n\nimport org.apache.commons.fileupload2.core.FileItem\nimport org.moqui.context.ExecutionContext\nimport org.moqui.resource.ResourceReference\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n/** Used by the org.moqui.impl.ElFinderServices.run#Command service. */\nclass ElFinderConnector {\n    protected final static Logger logger = LoggerFactory.getLogger(ElFinderConnector.class)\n\n    ExecutionContext ec\n    String volumeId\n    String resourceRoot\n\n    ElFinderConnector(ExecutionContext ec, String resourceRoot, String volumeId) {\n        this.ec = ec\n        this.resourceRoot = resourceRoot\n        this.volumeId = volumeId\n    }\n\n    String hash(String str) {\n        String hashed = str.bytes.encodeBase64().toString()\n        hashed = hashed.replace(\"=\", \"\")\n        hashed = hashed.replace(\"+\", \"-\")\n        hashed = hashed.replace(\"/\", \"_\")\n        hashed = volumeId + hashed\n        return hashed\n    }\n\n    static String unhash(String hashed) {\n        if (!hashed) return \"\"\n        // NOTE: assumes a volume ID prefix with 3 characters\n        hashed = hashed.substring(3)\n        hashed = hashed.replace(\".\", \"=\")\n        hashed = hashed.replace(\"-\", \"+\")\n        hashed = hashed.replace(\"_\", \"/\")\n        return new String(hashed.decodeBase64())\n    }\n\n    String getLocation(String hashed) {\n        if (hashed) {\n            String unhashedPath = unhash(hashed)\n            if (unhashedPath == \"/\" || unhashedPath == \"root\") return resourceRoot\n            if (unhashedPath.startsWith(\"/\")) unhashedPath = unhashedPath.substring(1)\n            return resourceRoot + (resourceRoot.endsWith(\"/\") ? \"\" : \"/\") + unhashedPath\n        }\n        return resourceRoot\n    }\n\n    String getPathRelativeToRoot(String location) {\n        String path = location.trim()\n        if (!location.startsWith(resourceRoot)) {\n            logger.warn(\"Location [${location}] does not start resourceRoot [${resourceRoot}]! Returning full location as relative path to root\")\n            return location\n        }\n        path = path.substring(resourceRoot.length())\n        if (path.endsWith(\"/\")) path = path.substring(0, path.length() - 1)\n        if (path.startsWith(\"/\")) path = path.substring(1)\n        if (path == \"\") return \"root\"\n        return path\n    }\n\n    boolean isRoot(String location) { return getPathRelativeToRoot(location) == \"root\" }\n\n    Map getLocationInfo(String location) { return getResourceInfo(ec.resource.getLocationReference(location)) }\n\n    Map getResourceInfo(ResourceReference ref) {\n        Map info = [:]\n        boolean curRoot = isRoot(ref.getLocation())\n        info.name = curRoot ? (resourceRoot.endsWith(\"/\") ? resourceRoot.substring(0, resourceRoot.length() - 1) : resourceRoot) : ref.getFileName()\n        String location = ref.getLocation()\n        String relativePath = getPathRelativeToRoot(location)\n        info.hash = hash(relativePath)\n\n        if (curRoot) {\n            info.volumeid = volumeId\n        } else {\n            String parentPath = relativePath.contains(\"/\") ? relativePath.substring(0, relativePath.lastIndexOf(\"/\")) : \"root\"\n            // logger.warn(\"======= phash: location=${location}, relativePath=${relativePath}, parentPath=${parentPath}\")\n            info.phash = hash(parentPath)\n        }\n        info.mime = curRoot || ref.isDirectory() ? \"directory\" : ref.getContentType()\n        if (ref.supportsLastModified()) info.ts = ref.getLastModified()\n        if (ref.supportsSize()) info.size = ref.getSize()\n        info.dirs = hasChildDirectories(ref) ? 1 : 0\n        info.read = 1\n        info.write = ref.supportsWrite() ? 1 : 0\n        info.locked = 0\n\n        return info\n    }\n\n    static boolean hasChildDirectories(ResourceReference ref) {\n        if (!ref.isDirectory()) return false\n        List<ResourceReference> childList = ref.getDirectoryEntries()\n        for (ResourceReference child in childList) if (child.isDirectory()) return true\n        return false\n    }\n\n    List<Map> getFiles(String target, boolean tree) {\n        List<Map> files = []\n        ResourceReference currentRef = ec.resource.getLocationReference(getLocation(target))\n        if (currentRef.isDirectory()) files.add(getResourceInfo(currentRef))\n\n        if (tree) files.addAll(getTree(resourceRoot, 0))\n\n        for (ResourceReference childRef in currentRef.getDirectoryEntries()) {\n            Map resourceInfo = getResourceInfo(childRef)\n            if (!files.contains(resourceInfo)) files.add(resourceInfo)\n        }\n        return files\n    }\n\n\n    List<Map> getTree(String location, int deep) { return getTree(ec.resource.getLocationReference(location), deep) }\n    List<Map> getTree(ResourceReference ref, int deep) {\n        List<Map> dirs = []\n        for (ResourceReference child in ref.getDirectoryEntries()) {\n            if (child.isDirectory()) {\n                Map info = getResourceInfo(child)\n                dirs.add(info)\n                if (deep > 0) dirs.addAll(getTree(child, deep - 1))\n            }\n        }\n        return dirs\n    }\n\n    List<Map> getParents(String location) { return getParents(ec.resource.getLocationReference(location)) }\n    List<Map> getParents(ResourceReference ref) {\n        List<Map> tree = []\n        ResourceReference dir = ref\n        while (!isRoot(dir.getLocation())) {\n            ResourceReference parent = dir.getParent()\n            if (parent == null) {\n                logger.warn(\"Got null parent for [${dir.getLocation()}], starting location [${ref.getLocation()}]\")\n                break\n            }\n            dir = parent\n            tree.add(0, getResourceInfo(dir))\n            getTree(dir, 0).each { if (!tree.contains(it)) tree.add(it) }\n        }\n        return tree ?: [getResourceInfo(ref)]\n    }\n\n    Map getOptions(String target) {\n        Map options = [seperator:\"/\", path:getLocation(target)]\n        // if we ever have a direct URL to get a file: options.url = \"http://localhost/files/...\"\n        options.disabled = [ 'tmb', 'size', 'dim', 'duplicate', 'paste', 'archive', 'extract', 'search', 'resize', 'netmount' ]\n        return options\n    }\n\n    List delete(String location) {\n        List<String> deleted = []\n        ResourceReference ref = ec.resource.getLocationReference(location)\n        if (!ref.isDirectory()) if (ref.delete()) deleted.add(hash(getPathRelativeToRoot(location)))\n        else deleted.addAll(deleteDir(ref))\n        return deleted\n    }\n\n    List deleteDir(ResourceReference dir) {\n        List deleted = []\n        for (ResourceReference child in dir.getDirectoryEntries()) {\n            if (child.isDirectory()) {\n                deleted.addAll(deleteDir(child))\n            } else {\n                if (child.delete()) deleted.add(hash(getPathRelativeToRoot(child.getLocation())))\n            }\n        }\n        if (dir.delete()) deleted.add(hash(getPathRelativeToRoot(dir.getLocation())))\n        return deleted\n    }\n\n    void runCommand() {\n        String cmd = ec.context.cmd\n        String target = ec.context.target\n        Map otherParameters = (Map) ec.context.otherParameters\n\n        Map responseMap = [:]\n        ec.context.responseMap = responseMap\n\n        if (cmd == \"file\") {\n            ec.context.fileLocation = getLocation(target)\n            ec.context.fileInline = otherParameters.download != \"1\"\n        } else if (cmd == \"open\") {\n            boolean init = otherParameters.init == \"1\"\n            boolean tree = otherParameters.tree == \"1\"\n            if (init) {\n                responseMap.api = \"2.0\"\n                responseMap.netDrivers = []\n                if (!target) target = hash(\"root\")\n            }\n\n            if (!target) {\n                responseMap.clear()\n                responseMap.error = \"File not found\"\n                return\n            }\n\n            // TODO: make this a setting somewhere? leave out altogether?\n            responseMap.uplMaxSize = \"32M\"\n\n            responseMap.cwd = getLocationInfo(getLocation(target))\n            responseMap.files = getFiles(target, tree)\n            responseMap.options = getOptions(target)\n        } else if (cmd == \"tree\") {\n            if (!target) { responseMap.clear(); responseMap.error = \"errOpen\"; return }\n\n            String location = getLocation(target)\n            List<Map> tree = [getLocationInfo(location)]\n            tree.addAll(getTree(location, 0))\n            responseMap.tree = tree\n        } else if (cmd == \"parents\") {\n            // if (!target) { responseMap.clear(); responseMap.error = \"errOpen\"; return }\n            responseMap.tree = getParents(getLocation(target))\n        } else if (cmd == \"ls\") {\n            if (!target) { responseMap.clear(); responseMap.error = \"errOpen\"; return }\n            List<String> fileList = []\n            ResourceReference curDir = ec.resource.getLocationReference(getLocation(target))\n            for (ResourceReference child in curDir.getDirectoryEntries()) fileList.add(child.getFileName())\n            responseMap.list = fileList\n        } else if (cmd == \"mkdir\") {\n            String name = otherParameters.name\n            if (!target) { responseMap.clear(); responseMap.error = \"errOpen\"; return }\n            if (!name) { responseMap.clear(); responseMap.error = \"No name specified for new directory\"; return }\n            String curLocation = getLocation(target)\n            ResourceReference curDir = ec.resource.getLocationReference(curLocation)\n            if (!curDir.supportsWrite()) { responseMap.clear(); responseMap.error = \"Resource does not support write\"; return }\n            ResourceReference newRef  = curDir.makeDirectory(name)\n            responseMap.added = [getResourceInfo(newRef)]\n        } else if (cmd == \"mkfile\") {\n            String name = otherParameters.name\n            if (!target) { responseMap.clear(); responseMap.error = \"errOpen\"; return }\n            if (!name) { responseMap.clear(); responseMap.error = \"No name specified for new file\"; return }\n            String curLocation = getLocation(target)\n            ResourceReference curDir = ec.resource.getLocationReference(curLocation)\n            if (!curDir.supportsWrite()) { responseMap.clear(); responseMap.error = \"Resource does not support write\"; return }\n            ResourceReference newRef  = curDir.makeFile(name)\n            responseMap.added = [getResourceInfo(newRef)]\n        } else if (cmd == \"rm\") {\n            Object targetsObj = otherParameters.targets\n            if (!targetsObj) targetsObj = otherParameters.'targets[]'\n            List<String> targets = targetsObj instanceof List ? targetsObj : [targetsObj as String]\n            List<String> removed = []\n            for (String curTarget in targets) {\n                String rmLocation = getLocation(curTarget)\n                logger.info(\"ElFinder rm ${rmLocation}\")\n                removed.addAll(delete(rmLocation))\n            }\n            responseMap.removed = removed\n        } else if (cmd == \"rename\") {\n            String name = otherParameters.name\n            if (!target) { responseMap.clear(); responseMap.error = \"errOpen\"; return }\n            if (!name) { responseMap.clear(); responseMap.error = \"No name specified for new directory\"; return }\n\n            String location = getLocation(target)\n            String newLocation = location.substring(0, location.lastIndexOf(\"/\") + 1) + name\n\n            ResourceReference curRef = ec.resource.getLocationReference(location)\n            curRef.move(newLocation)\n\n            responseMap.added = [getLocationInfo(newLocation)]\n            responseMap.removed = [target]\n        } else if (cmd == \"upload\") {\n            if (!target) { responseMap.clear(); responseMap.error = \"errOpen\"; return }\n            String location = getLocation(target)\n            // logger.info(\"ElFinder upload to ${location}, _fileUploadList: ${otherParameters._fileUploadList}\")\n            List<Map> added = []\n            for (FileItem item in otherParameters._fileUploadList) {\n                logger.info(\"ElFinder upload ${item.getName()} to ${location}\")\n                ResourceReference newRef = ec.resource.getLocationReference(\"${location}/${item.getName()}\")\n                newRef.putStream(item.getInputStream())\n                added.add(getResourceInfo(newRef))\n            }\n            responseMap.added = added\n        } else if (cmd == \"get\") {\n            String location = getLocation(target)\n            ResourceReference curRef = ec.resource.getLocationReference(location)\n            responseMap.content = curRef.getText()\n        } else if (cmd == \"put\") {\n            String content = otherParameters.content\n            String location = getLocation(target)\n            ResourceReference curRef = ec.resource.getLocationReference(location)\n            curRef.putText(content)\n            responseMap.changed = [getResourceInfo(curRef)]\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/util/ElasticSearchLogger.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.util\n\nimport groovy.transform.CompileStatic\nimport org.apache.logging.log4j.Level\nimport org.apache.logging.log4j.core.LogEvent\nimport org.apache.logging.log4j.util.ReadOnlyStringMap\nimport org.moqui.BaseArtifactException\nimport org.moqui.context.ArtifactExecutionInfo\nimport org.moqui.impl.context.ElasticFacadeImpl\nimport org.moqui.context.LogEventSubscriber\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.util.concurrent.ConcurrentLinkedQueue\nimport java.util.concurrent.TimeUnit\nimport java.util.concurrent.atomic.AtomicBoolean\n\n/** */\n@CompileStatic\nclass ElasticSearchLogger {\n    protected final static Logger logger = LoggerFactory.getLogger(ElasticFacadeImpl.class)\n\n    // TODO: make INDEX_NAME configurable somehow\n    final static String INDEX_NAME = \"moqui_logs\"\n    final static String DOC_TYPE = \"LogMessage\"\n    final static int QUEUE_LIMIT = 16384\n\n    private ElasticFacadeImpl.ElasticClientImpl elasticClient = null\n    protected ExecutionContextFactoryImpl ecfi = null\n    protected ElasticSearchSubscriber subscriber = null\n\n    private boolean initialized = false\n    private boolean disabled = false\n    final ConcurrentLinkedQueue<Map> logMessageQueue = new ConcurrentLinkedQueue<>()\n    final AtomicBoolean flushRunning = new AtomicBoolean(false)\n\n    ElasticSearchLogger(ElasticFacadeImpl.ElasticClientImpl elasticClient, ExecutionContextFactoryImpl ecfi) {\n        this.elasticClient = elasticClient\n        this.ecfi = ecfi\n        if (ecfi.getToolFactory(\"ElasticSearchLogger\") != null) {\n            // used to check: elasticClient.esVersionUnder7\n            // logger.warn(\"ElasticClient ${elasticClient.clusterName} has version under 7.0, not starting ElasticSearchLogger\")\n            logger.warn(\"Found 'ElasticSearchLogger' ToolFactory from moqui-elasticsearch, not starting embedded ElasticSearchLogger\")\n        } else {\n            init()\n        }\n    }\n    void init() {\n        // check for index exists, create with mapping for log doc if not\n        try {\n            boolean hasIndex = elasticClient.indexExists(INDEX_NAME)\n            if (!hasIndex) elasticClient.createIndex(INDEX_NAME, DOC_TYPE, docMapping, (String) null)\n        } catch (Exception e) {\n            logger.error(\"Error checking and creating ${INDEX_NAME} ES index, not starting ElasticSearchLogger\", e)\n            return\n        }\n\n        LogMessageQueueFlush lmqf = new LogMessageQueueFlush(this)\n        // running every 3 seconds (was originally 1), might be good to have configurable as a higher value better for less busy servers, lower for busier\n        ecfi.scheduleAtFixedRate(lmqf, 10, 3)\n\n        subscriber = new ElasticSearchSubscriber(this)\n        ecfi.registerLogEventSubscriber(subscriber)\n\n        initialized = true\n    }\n\n    void destroy() { disabled = true }\n\n    boolean isInitialized() { return initialized }\n\n    static class ElasticSearchSubscriber implements LogEventSubscriber {\n        private final ElasticSearchLogger esLogger\n        private final InetAddress localAddr = InetAddress.getLocalHost()\n\n        ElasticSearchSubscriber(ElasticSearchLogger esLogger) { this.esLogger = esLogger }\n\n        @Override\n        void process(LogEvent event) {\n            if (esLogger.disabled) return\n            // NOTE: levels configurable in log4j2.xml but always exclude these\n            if (Level.DEBUG.is(event.level) || Level.TRACE.is(event.level)) return\n            // if too many messages in queue start ignoring, likely means ElasticSearch not responding or not fast enough\n            if (esLogger.logMessageQueue.size() >= QUEUE_LIMIT) return\n\n            Map<String, Object> msgMap = ['@timestamp':event.timeMillis, level:event.level.toString(), thread_name:event.threadName,\n                    thread_id:event.threadId, thread_priority:event.threadPriority, logger_name:event.loggerName,\n                    message:event.message?.formattedMessage, source_host:localAddr.hostName] as Map<String, Object>\n            ReadOnlyStringMap contextData = event.contextData\n            if (contextData != null && contextData.size() > 0) {\n                Map<String, String> mdcMap = new HashMap<>(contextData.toMap())\n                String userId = mdcMap.get(\"moqui_userId\")\n                if (userId != null) { msgMap.put(\"user_id\", userId); mdcMap.remove(\"moqui_userId\") }\n                String visitorId = mdcMap.get(\"moqui_visitorId\")\n                if (visitorId != null) { msgMap.put(\"visitor_id\", visitorId); mdcMap.remove(\"moqui_visitorId\") }\n                if (mdcMap.size() > 0) msgMap.put(\"mdc\", mdcMap)\n                // System.out.println(\"Cur user ${userId} ${visitorId}\")\n            }\n            Throwable thrown = event.thrown\n            if (thrown != null) msgMap.put(\"thrown\", makeThrowableMap(thrown))\n\n            esLogger.logMessageQueue.add(msgMap)\n        }\n        static Map makeThrowableMap(Throwable thrown) {\n            StackTraceElement[] stArray = thrown.stackTrace\n            List<String> stList = []\n            for (int i = 0; i < stArray.length; i++) {\n                StackTraceElement ste = (StackTraceElement) stArray[i]\n                stList.add(\"${ste.className}.${ste.methodName}(${ste.fileName}:${ste.lineNumber})\".toString())\n            }\n            Map<String, Object> thrownMap = [name:thrown.class.name, message:thrown.message,\n                    localizedMessage:thrown.localizedMessage, stackTrace:stList] as Map<String, Object>\n            if (thrown instanceof BaseArtifactException) {\n                BaseArtifactException bae = (BaseArtifactException) thrown\n                Deque<ArtifactExecutionInfo> aeiList = bae.getArtifactStack()\n                if (aeiList != null && aeiList.size() > 0) thrownMap.put(\"artifactStack\", aeiList.collect({ it.toBasicString() }))\n            }\n            Throwable cause = thrown.cause\n            if (cause != null) thrownMap.put(\"cause\", makeThrowableMap(cause))\n            Throwable[] supArray = thrown.suppressed\n            if (supArray != null && supArray.length > 0) {\n                List<Map> supList = []\n                for (int i = 0; i < supArray.length; i++) {\n                    Throwable sup = supArray[i]\n                    supList.add(makeThrowableMap(sup))\n                }\n                thrownMap.put(\"suppressed\", supList)\n            }\n            return thrownMap\n        }\n    }\n\n    static class LogMessageQueueFlush implements Runnable {\n        final static int maxCreates = 50\n        final static int sameTsMaxCreates = 100\n        final ElasticSearchLogger esLogger\n\n        LogMessageQueueFlush(ElasticSearchLogger esLogger) { this.esLogger = esLogger }\n\n        @Override void run() {\n            // if flag not false (expect param) return now, wait for next scheduled run\n            if (!esLogger.flushRunning.compareAndSet(false, true)) return\n\n            try {\n                while (esLogger.logMessageQueue.size() > 0) { flushQueue() }\n            } finally {\n                esLogger.flushRunning.set(false)\n            }\n        }\n        void flushQueue() {\n            final ConcurrentLinkedQueue<Map> queue = esLogger.logMessageQueue\n            ArrayList<Map> createList = new ArrayList<>(maxCreates)\n            int createCount = 0\n            long lastTimestamp = 0\n            int sameTsCount = 0\n            while (createCount < sameTsMaxCreates) {\n                Map message = queue.poll()\n                if (message == null) break\n                // add 1ms to timestamp if same as last so in search messages are in a better order; on busy servers this will require filtering by thread_id\n                boolean sameTs = false\n                try {\n                    long timestamp = message.get(\"@timestamp\") as long\n                    if (timestamp == lastTimestamp) {\n                        sameTsCount++\n                        timestamp += sameTsCount\n                        message.put(\"@timestamp\", timestamp)\n                        sameTs = true\n                    } else {\n                        lastTimestamp = timestamp\n                        sameTsCount = 0\n                    }\n                } catch (Throwable t) {\n                    System.out.println(\"Error checking subsequent timestamp in ES log message: \" + t.toString())\n                }\n                // increment the count and add the message\n                createCount++\n                createList.add(message)\n                if (!sameTs && createCount >= maxCreates) break\n            }\n            int retryCount = 5\n            while (retryCount > 0) {\n                int createListSize = createList.size()\n                if (createListSize == 0) break\n                try {\n                    // long startTime = System.currentTimeMillis()\n                    try {\n                        esLogger.elasticClient.bulkIndex(INDEX_NAME, DOC_TYPE, null, createList, false)\n                    } catch (Exception e) {\n                        System.out.println(\"Error logging to ElasticSearch: ${e.toString()}\")\n                    }\n                    // System.out.println(\"Indexed ${createListSize} ElasticSearch log messages in ${System.currentTimeMillis() - startTime}ms\")\n                    break\n                } catch (Throwable t) {\n                    System.out.println(\"Error indexing ElasticSearch log messages, retrying (${retryCount}): ${t.toString()}\")\n                    retryCount--\n                }\n            }\n        }\n    }\n\n    final static Map docMapping = [properties:\n            ['@timestamp':[type:'date', format:'epoch_millis'], level:[type:'keyword'], thread_name:[type:'keyword'],\n                    thread_id:[type:'long'], thread_priority:[type:'long'], user_id:[type:'keyword'], visitor_id:[type:'keyword'],\n                    logger_name:[type:'text'], name:[type:'text'], message:[type:'text'], mdc:[type:'object'],\n                    thrown:[type:'object', properties:[name:[type:'text'], message:[type:'text'], localizedMessage:[type:'text'],\n                            stackTrace:[type:'text'], artifactStack:[type:'text'],\n                            suppressed:[type:'object', properties:[name:[type:'text'], message:[type:'text'], localizedMessage:[type:'text'],\n                                    commonElementCount:[type:'long'], stackTrace:[type:'text']]],\n                            cause:[type:'object', properties:[name:[type:'text'], message:[type:'text'], localizedMessage:[type:'text'],\n                                    commonElementCount:[type:'long'], stackTrace:[type:'text'], artifactStack:[type:'text']]]\n            ]]\n    ]]\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/util/JdbcExtractor.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.util;\n\nimport org.moqui.BaseException;\nimport org.moqui.etl.SimpleEtl;\nimport org.moqui.impl.context.ExecutionContextImpl;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport javax.sql.XAConnection;\nimport java.sql.Connection;\nimport java.sql.ResultSet;\nimport java.sql.ResultSetMetaData;\nimport java.sql.Statement;\nimport java.util.HashMap;\nimport java.util.Map;\n\npublic class JdbcExtractor implements SimpleEtl.Extractor {\n    protected final static Logger logger = LoggerFactory.getLogger(JdbcExtractor.class);\n\n    SimpleEtl etl = null;\n    private ExecutionContextImpl eci;\n    private String recordType, selectSql;\n    private Map<String, String> confMap;\n\n    public JdbcExtractor(ExecutionContextImpl eci) { this.eci = eci; }\n\n    public JdbcExtractor setSqlInfo(String recordType, String selectSql) {\n        this.recordType = recordType;\n        this.selectSql = selectSql;\n        return this;\n    }\n    public JdbcExtractor setDbInfo(String dbType, String host, String port, String database, String user, String password) {\n        confMap = new HashMap<>();\n        confMap.put(\"entity_ds_db_conf\", dbType);\n        confMap.put(\"entity_ds_host\", host);\n        confMap.put(\"entity_ds_port\", port);\n        confMap.put(\"entity_ds_database\", database);\n        confMap.put(\"entity_ds_user\", user);\n        confMap.put(\"entity_ds_password\", password);\n        return this;\n    }\n\n    public String getRecordType() { return recordType; }\n\n    @Override\n    public void extract(SimpleEtl etl) throws Exception {\n        this.etl = etl;\n\n        XAConnection xacon = null;\n        Connection con = null;\n        Statement stmt = null;\n        ResultSet rs = null;\n        try {\n            xacon = eci.getEntityFacade().getConfConnection(confMap);\n            con = xacon.getConnection();\n            stmt = con.createStatement();\n            rs = stmt.executeQuery(selectSql);\n            ResultSetMetaData rsmd = rs.getMetaData();\n            int columnCount = rsmd.getColumnCount();\n            String[] columnNames = new String[columnCount];\n            for (int i = 1; i <= columnCount; i++) columnNames[i-1] = rsmd.getColumnName(i);\n\n            while (rs.next()) {\n                SimpleEtl.SimpleEntry curEntry = new SimpleEtl.SimpleEntry(recordType, new HashMap<>());\n                for (int i = 1; i <= columnCount; i++) curEntry.values.put(columnNames[i-1], rs.getObject(i));\n\n                try {\n                    etl.processEntry(curEntry);\n                } catch (SimpleEtl.StopException e) {\n                    logger.warn(\"Got StopException\", e);\n                    break;\n                }\n            }\n        } catch (Exception e) {\n            throw new BaseException(\"Error in SQL query \" + selectSql, e);\n        } finally {\n            if (rs != null) rs.close();\n            if (stmt != null) stmt.close();\n            if (con != null) con.close();\n            if (xacon != null) xacon.close();\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/util/MoquiShiroRealm.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.util\n\nimport groovy.transform.CompileStatic\nimport org.apache.shiro.authc.*\nimport org.apache.shiro.authc.credential.CredentialsMatcher\nimport org.apache.shiro.authz.Authorizer\nimport org.apache.shiro.authz.Permission\nimport org.apache.shiro.authz.UnauthorizedException\nimport org.apache.shiro.realm.Realm\nimport org.apache.shiro.subject.PrincipalCollection\nimport org.apache.shiro.lang.util.SimpleByteSource\nimport org.moqui.BaseArtifactException\nimport org.moqui.Moqui\nimport org.moqui.context.PasswordChangeRequiredException\nimport org.moqui.context.SecondFactorRequiredException\nimport org.moqui.entity.EntityCondition\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.context.ArtifactExecutionFacadeImpl\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.impl.context.UserFacadeImpl\nimport org.moqui.util.MNode\nimport org.moqui.util.WebUtilities\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.sql.Timestamp\n\n@CompileStatic\nclass MoquiShiroRealm implements Realm, Authorizer {\n    protected final static Logger logger = LoggerFactory.getLogger(MoquiShiroRealm.class)\n\n    protected ExecutionContextFactoryImpl ecfi\n    protected String realmName = \"moquiRealm\"\n\n    protected Class<? extends AuthenticationToken> authenticationTokenClass = UsernamePasswordToken.class\n\n    MoquiShiroRealm() {\n        // with this sort of init we may only be able to get ecfi through static reference\n        this.ecfi = (ExecutionContextFactoryImpl) Moqui.executionContextFactory\n    }\n\n    MoquiShiroRealm(ExecutionContextFactoryImpl ecfi) {\n        this.ecfi = ecfi\n    }\n\n    void setName(String n) { realmName = n }\n\n    @Override\n    String getName() { return realmName }\n\n    //Class getAuthenticationTokenClass() { return authenticationTokenClass }\n    //void setAuthenticationTokenClass(Class<? extends AuthenticationToken> atc) { authenticationTokenClass = atc }\n\n    @Override\n    boolean supports(AuthenticationToken token) {\n        return token != null && authenticationTokenClass.isAssignableFrom(token.getClass())\n    }\n\n    static EntityValue loginPrePassword(ExecutionContextImpl eci, String username) {\n        EntityValue newUserAccount = eci.entity.find(\"moqui.security.UserAccount\").condition(\"username\", username)\n                .useCache(true).disableAuthz().one()\n\n        if (newUserAccount == null) {\n            // case-insensitive lookup by username\n            EntityCondition usernameCond = eci.entityFacade.getConditionFactory()\n                    .makeCondition(\"username\", EntityCondition.ComparisonOperator.EQUALS, username).ignoreCase()\n            newUserAccount = eci.entity.find(\"moqui.security.UserAccount\").condition(usernameCond).disableAuthz().one()\n        }\n        if (newUserAccount == null) {\n            // look at emailAddress if used instead, with case-insensitive lookup\n            EntityCondition emailAddressCond = eci.entityFacade.getConditionFactory()\n                    .makeCondition(\"emailAddress\", EntityCondition.ComparisonOperator.EQUALS, username).ignoreCase()\n            newUserAccount = eci.entity.find(\"moqui.security.UserAccount\").condition(emailAddressCond).disableAuthz().one()\n        }\n\n        // no account found?\n        if (newUserAccount == null) throw new UnknownAccountException(eci.resource.expand('No account found for username ${username}','',[username:username]))\n\n        // check for disabled account before checking password (otherwise even after disable could determine if\n        //    password is correct or not\n        if (\"Y\".equals(newUserAccount.getNoCheckSimple(\"disabled\"))) {\n            if (newUserAccount.getNoCheckSimple(\"disabledDateTime\") != null) {\n                // account temporarily disabled (probably due to excessive attempts\n                Integer disabledMinutes = eci.ecfi.confXmlRoot.first(\"user-facade\").first(\"login\").attribute(\"disable-minutes\") as Integer ?: 30I\n                Timestamp reEnableTime = new Timestamp(newUserAccount.getTimestamp(\"disabledDateTime\").getTime() + (disabledMinutes.intValue()*60I*1000I))\n                if (reEnableTime > eci.user.nowTimestamp) {\n                    // only blow up if the re-enable time is not passed\n                    eci.service.sync().name(\"org.moqui.impl.UserServices.increment#UserAccountFailedLogins\")\n                            .parameter(\"userId\", newUserAccount.userId).requireNewTransaction(true).call()\n                    throw new ExcessiveAttemptsException(eci.resource.expand('Authenticate failed for user ${newUserAccount.username} because account is disabled and will not be re-enabled until ${reEnableTime} [DISTMP].',\n                            '', [newUserAccount:newUserAccount, reEnableTime:reEnableTime]))\n                }\n            } else {\n                // account permanently disabled\n                eci.service.sync().name(\"org.moqui.impl.UserServices.increment#UserAccountFailedLogins\")\n                        .parameters((Map<String, Object>) [userId:newUserAccount.userId]).requireNewTransaction(true).call()\n                throw new DisabledAccountException(eci.resource.expand('Authenticate failed for user ${newUserAccount.username} because account is disabled and is not schedule to be automatically re-enabled [DISPRM].',\n                        '', [newUserAccount:newUserAccount]))\n            }\n        }\n\n        Timestamp terminateDate = (Timestamp) newUserAccount.getNoCheckSimple(\"terminateDate\")\n        if (terminateDate != (Timestamp) null && System.currentTimeMillis() > terminateDate.getTime()) {\n            throw new DisabledAccountException(eci.resource.expand('User account ${newUserAccount.username} was terminated at ${ec.l10n.format(newUserAccount.terminateDate, null)} [TERM].',\n                    '', [newUserAccount:newUserAccount]))\n        }\n\n        return newUserAccount\n    }\n\n    static void loginPostPassword(ExecutionContextImpl eci, EntityValue newUserAccount, AuthenticationToken token) {\n        // the password did match, but check a few additional things\n        String userId = newUserAccount.getNoCheckSimple(\"userId\")\n\n        // check for require password change\n        if (\"Y\".equals(newUserAccount.getNoCheckSimple(\"requirePasswordChange\"))) {\n            // NOTE: don't call incrementUserAccountFailedLogins here (don't need compounding reasons to stop access)\n            throw new PasswordChangeRequiredException(eci.resource.expand('Authenticate failed for user [${newUserAccount.username}] because account requires password change [PWDCHG].','',[newUserAccount:newUserAccount]))\n        }\n        // check time since password was last changed, if it has been too long (user-facade.password.@change-weeks default 12) then fail\n        if (newUserAccount.getNoCheckSimple(\"passwordSetDate\") != null) {\n            int changeWeeks = (eci.ecfi.confXmlRoot.first(\"user-facade\").first(\"password\").attribute(\"change-weeks\") ?: 12) as int\n            if (changeWeeks > 0) {\n                int wksSinceChange = ((eci.user.nowTimestamp.time - newUserAccount.getTimestamp(\"passwordSetDate\").time) / (7*24*60*60*1000)).intValue()\n                if (wksSinceChange > changeWeeks) {\n                    // NOTE: don't call incrementUserAccountFailedLogins here (don't need compounding reasons to stop access)\n                    throw new ExpiredCredentialsException(eci.resource.expand('Authenticate failed for user ${newUserAccount.username} because password was changed ${wksSinceChange} weeks ago and must be changed every ${changeWeeks} weeks [PWDTIM].',\n                            '', [newUserAccount:newUserAccount, wksSinceChange:wksSinceChange, changeWeeks:changeWeeks]))\n                }\n            }\n        }\n        // check if the user requires an additional authentication factor step\n        // do this after checking for require password change and expired password for better user experience\n        if (!(token instanceof ForceLoginToken)) {\n            boolean secondReqd = eci.ecfi.serviceFacade.sync().name(\"org.moqui.impl.UserServices.get#UserAuthcFactorRequired\")\n                    .parameter(\"userId\", userId).disableAuthz().call()?.secondFactorRequired ?: false\n            // if the user requires authentication, throw a SecondFactorRequiredException so that UserFacadeImpl.groovy can catch the error and perform the appropriate action.\n            if (secondReqd) {\n                throw new SecondFactorRequiredException(eci.ecfi.resource.expand('Authentication code required for user ${username}',\n                        '',[username:newUserAccount.getNoCheckSimple(\"username\")]))\n            }\n        }\n\n        // check ipAllowed if on UserAccount or any UserGroup a member of\n        String clientIp = eci.userFacade.getClientIp()\n        if (clientIp == null || clientIp.isEmpty()) {\n            if (eci.web != null) logger.warn(\"Web login with no client IP for userId ${newUserAccount.userId}, not checking ipAllowed\")\n        } else {\n            if (clientIp.contains(\":\")) {\n                logger.warn(\"Web login with IPv6 client IP ${clientIp} for userId ${newUserAccount.userId}, not checking ipAllowed\")\n            } else {\n                ArrayList<String> ipAllowedList = new ArrayList<>()\n                String uaIpAllowed = newUserAccount.getNoCheckSimple(\"ipAllowed\")\n                if (uaIpAllowed != null && !uaIpAllowed.isEmpty()) ipAllowedList.add(uaIpAllowed)\n\n                EntityList ugmList = eci.entityFacade.find(\"moqui.security.UserGroupMember\")\n                        .condition(\"userId\", newUserAccount.getNoCheckSimple(\"userId\"))\n                        .disableAuthz().useCache(true).list()\n                        .filterByDate(null, null, eci.userFacade.nowTimestamp)\n                ArrayList<String> userGroupIdList = new ArrayList<>()\n                for (EntityValue ugm in ugmList) userGroupIdList.add((String) ugm.get(\"userGroupId\"))\n                userGroupIdList.add(\"ALL_USERS\")\n                EntityList ugList = eci.entityFacade.find(\"moqui.security.UserGroup\")\n                        .condition(\"ipAllowed\", EntityCondition.IS_NOT_NULL, null)\n                        .condition(\"userGroupId\", EntityCondition.IN, userGroupIdList).disableAuthz().useCache(false).list()\n                for (EntityValue ug in ugList) ipAllowedList.add((String) ug.getNoCheckSimple(\"ipAllowed\"))\n\n                int ipAllowedListSize = ipAllowedList.size()\n                if (ipAllowedListSize > 0) {\n                    boolean anyMatches = false\n                    for (int i = 0; i < ipAllowedListSize; i++) {\n                        String pattern = (String) ipAllowedList.get(i)\n                        if (WebUtilities.ip4Matches(pattern, clientIp)) {\n                            anyMatches = true\n                            break\n                        }\n                    }\n                    if (!anyMatches) throw new AccountException(\n                            eci.resource.expand('Authenticate failed for user ${newUserAccount.username} because client IP ${clientIp} is not in allowed list for user or group.',\n                            '', [newUserAccount:newUserAccount, clientIp:clientIp]))\n                }\n            }\n        }\n\n        // no more auth failures? record the various account state updates, hasLoggedOut=N\n        if (newUserAccount.getNoCheckSimple(\"successiveFailedLogins\") || \"Y\".equals(newUserAccount.getNoCheckSimple(\"disabled\")) ||\n                newUserAccount.getNoCheckSimple(\"disabledDateTime\") != null || \"Y\".equals(newUserAccount.getNoCheckSimple(\"hasLoggedOut\"))) {\n            try {\n                eci.service.sync().name(\"update\", \"moqui.security.UserAccount\")\n                        .parameters([userId:newUserAccount.userId, successiveFailedLogins:0, disabled:\"N\", disabledDateTime:null, hasLoggedOut:\"N\"])\n                        .disableAuthz().call()\n            } catch (Exception e) {\n                logger.warn(\"Error resetting UserAccount login status\", e)\n            }\n        }\n\n        // update visit if no user in visit yet\n        String visitId = eci.userFacade.getVisitId()\n        EntityValue visit = eci.entityFacade.find(\"moqui.server.Visit\").condition(\"visitId\", visitId).disableAuthz().one()\n        if (visit != null) {\n            if (!visit.getNoCheckSimple(\"userId\")) {\n                eci.service.sync().name(\"update\", \"moqui.server.Visit\").parameter(\"visitId\", visit.visitId)\n                        .parameter(\"userId\", newUserAccount.userId).disableAuthz().call()\n            }\n            if (!visit.getNoCheckSimple(\"clientIpCountryGeoId\") && !visit.getNoCheckSimple(\"clientIpTimeZone\")) {\n                MNode ssNode = eci.ecfi.confXmlRoot.first(\"server-stats\")\n                if (ssNode.attribute(\"visit-ip-info-on-login\") != \"false\") {\n                    eci.service.async().name(\"org.moqui.impl.ServerServices.get#VisitClientIpData\")\n                            .parameter(\"visitId\", visit.visitId).call()\n                }\n            }\n        }\n    }\n\n    static void loginSaveHistory(ExecutionContextImpl eci, String userId, String passwordUsed, boolean successful) {\n        // track the UserLoginHistory, whether the above succeeded or failed (ie even if an exception was thrown)\n        if (!eci.getSkipStats()) {\n            MNode loginNode = eci.ecfi.confXmlRoot.first(\"user-facade\").first(\"login\")\n            if (userId != null && loginNode.attribute(\"history-store\") != \"false\") {\n                Timestamp fromDate = eci.getUser().getNowTimestamp()\n                // look for login history in the last minute, if any found don't create UserLoginHistory\n                Timestamp recentDate = new Timestamp(fromDate.getTime() - 60000)\n\n                Map<String, Object> ulhContext = [userId:userId, fromDate:fromDate,\n                        visitId:eci.user.visitId, successfulLogin:(successful?\"Y\":\"N\")] as Map<String, Object>\n                if (!successful && loginNode.attribute(\"history-incorrect-password\") != \"false\") ulhContext.passwordUsed = passwordUsed\n\n                eci.runInWorkerThread({\n                    try {\n                        long recentUlh = eci.entity.find(\"moqui.security.UserLoginHistory\").condition(\"userId\", userId)\n                                .condition(\"fromDate\", EntityCondition.GREATER_THAN, recentDate).disableAuthz().count()\n                        if (recentUlh == 0) {\n                            eci.ecfi.serviceFacade.sync().name(\"create\", \"moqui.security.UserLoginHistory\")\n                                    .parameters(ulhContext).disableAuthz().call()\n                        } else {\n                            if (logger.isDebugEnabled()) logger.debug(\"Not creating UserLoginHistory, found existing record for userId ${userId} and more recent than ${recentDate}\")\n                        }\n                    } catch (Exception ee) {\n                        // this blows up sometimes on MySQL, may in other cases, and is only so important so log a warning but don't rethrow\n                        logger.warn(\"UserLoginHistory create failed: ${ee.toString()}\")\n                    }\n                })\n            }\n        }\n    }\n\n    @Override\n    AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {\n        ExecutionContextImpl eci = ecfi.getEci()\n        String username = token.principal as String\n        String userId = null\n        boolean successful = false\n        boolean isForceLogin = token instanceof ForceLoginToken\n\n        SaltedAuthenticationInfo info = null\n        try {\n            EntityValue newUserAccount = loginPrePassword(eci, username)\n            userId = newUserAccount.getString(\"userId\")\n\n            // create the salted SimpleAuthenticationInfo object\n            String salt = (newUserAccount.passwordSalt ?: '') as String\n            SimpleByteSource saltBs = new SimpleByteSource(salt)\n            info = new SimpleAuthenticationInfo(username, newUserAccount.currentPassword, saltBs, realmName)\n            if (!isForceLogin) {\n                // check the password (credentials for this case)\n                CredentialsMatcher cm = ecfi.getCredentialsMatcher((String) newUserAccount.passwordHashType, \"Y\".equals(newUserAccount.passwordBase64))\n                if (!cm.doCredentialsMatch(token, info)) {\n                    // if failed on password, increment in new transaction to make sure it sticks\n                    ecfi.serviceFacade.sync().name(\"org.moqui.impl.UserServices.increment#UserAccountFailedLogins\")\n                            .parameters((Map<String, Object>) [userId:newUserAccount.userId]).requireNewTransaction(true).call()\n                    throw new IncorrectCredentialsException(ecfi.resource.expand('Password incorrect for username ${username}','',[username:username]))\n                }\n            }\n\n            // credentials matched\n            loginPostPassword(eci, newUserAccount, token)\n\n            // at this point the user is successfully authenticated\n            successful = true\n        } finally {\n            boolean saveHistory = true\n            if (isForceLogin) {\n                ForceLoginToken flt = (ForceLoginToken) token\n                saveHistory = flt.saveHistory\n            }\n            if (saveHistory) loginSaveHistory(eci, userId, token.credentials as String, successful)\n        }\n\n        return info\n    }\n\n    static boolean checkCredentials(String username, String password, ExecutionContextFactoryImpl ecfi) {\n        EntityValue newUserAccount = ecfi.entity.find(\"moqui.security.UserAccount\").condition(\"username\", username)\n                .useCache(true).disableAuthz().one()\n\n        String salt = (newUserAccount.passwordSalt ?: '') as String\n        SimpleByteSource saltBs = new SimpleByteSource(salt)\n        SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(username, newUserAccount.currentPassword, saltBs, \"moquiRealm\")\n\n        CredentialsMatcher cm = ecfi.getCredentialsMatcher((String) newUserAccount.passwordHashType, \"Y\".equals(newUserAccount.passwordBase64))\n        UsernamePasswordToken token = new UsernamePasswordToken(username, password)\n        return cm.doCredentialsMatch(token, info)\n    }\n\n    static class ForceLoginToken extends UsernamePasswordToken {\n        boolean saveHistory = true\n        ForceLoginToken(final String username, final boolean rememberMe) {\n            super (username, 'force', rememberMe)\n        }\n        ForceLoginToken(final String username, final boolean rememberMe, final boolean saveHistory) {\n            super (username, 'force', rememberMe)\n            this.saveHistory = saveHistory\n        }\n    }\n\n    // ========== Authorization Methods ==========\n\n    /**\n     * @param principalCollection The principal (user)\n     * @param resourceAccess Formatted as: \"${typeEnumId}:${actionEnumId}:${name}\"\n     * @return boolean true if principal is permitted to access the resource, false otherwise.\n     */\n    boolean isPermitted(PrincipalCollection principalCollection, String resourceAccess) {\n        // String username = (String) principalCollection.primaryPrincipal\n        // TODO: if we want to support other users than the current need to look them up here\n        return ArtifactExecutionFacadeImpl.isPermitted(resourceAccess, ecfi.getEci())\n    }\n\n    boolean[] isPermitted(PrincipalCollection principalCollection, String... resourceAccesses) {\n        boolean[] resultArray = new boolean[resourceAccesses.size()]\n        int i = 0\n        for (String resourceAccess in resourceAccesses) {\n            resultArray[i] = this.isPermitted(principalCollection, resourceAccess)\n            i++\n        }\n        return resultArray\n    }\n\n    boolean isPermittedAll(PrincipalCollection principalCollection, String... resourceAccesses) {\n        for (String resourceAccess in resourceAccesses)\n            if (!this.isPermitted(principalCollection, resourceAccess)) return false\n        return true\n    }\n\n    boolean isPermitted(PrincipalCollection principalCollection, Permission permission) {\n        throw new BaseArtifactException(\"Authorization of Permission through Shiro not yet supported\")\n    }\n\n    boolean[] isPermitted(PrincipalCollection principalCollection, List<Permission> permissions) {\n        throw new BaseArtifactException(\"Authorization of Permission through Shiro not yet supported\")\n    }\n\n    boolean isPermittedAll(PrincipalCollection principalCollection, Collection<Permission> permissions) {\n        throw new BaseArtifactException(\"Authorization of Permission through Shiro not yet supported\")\n    }\n\n    void checkPermission(PrincipalCollection principalCollection, Permission permission) {\n        // TODO how to handle the permission interface?\n        // see: http://www.jarvana.com/jarvana/view/org/apache/shiro/shiro-core/1.1.0/shiro-core-1.1.0-javadoc.jar!/org/apache/shiro/authz/Permission.html\n        // also look at DomainPermission, can extend for Moqui artifacts\n        // this.checkPermission(principalCollection, permission.?)\n        throw new BaseArtifactException(\"Authorization of Permission through Shiro not yet supported\")\n    }\n\n    void checkPermission(PrincipalCollection principalCollection, String permission) {\n        String username = (String) principalCollection.primaryPrincipal\n        if (UserFacadeImpl.hasPermission(username, permission, null, ecfi.getEci())) {\n            throw new UnauthorizedException(ecfi.resource.expand('User ${username} does not have permission ${permission}','',[username:username,permission:permission]))\n        }\n    }\n\n    void checkPermissions(PrincipalCollection principalCollection, String... strings) {\n        for (String permission in strings) checkPermission(principalCollection, permission)\n    }\n\n    void checkPermissions(PrincipalCollection principalCollection, Collection<Permission> permissions) {\n        for (Permission permission in permissions) checkPermission(principalCollection, permission)\n    }\n\n    boolean hasRole(PrincipalCollection principalCollection, String roleName) {\n        String username = (String) principalCollection.primaryPrincipal\n        return UserFacadeImpl.isInGroup(username, roleName, null, ecfi.getEci())\n    }\n\n    boolean[] hasRoles(PrincipalCollection principalCollection, List<String> roleNames) {\n        boolean[] resultArray = new boolean[roleNames.size()]\n        int i = 0\n        for (String roleName in roleNames) { resultArray[i] = this.hasRole(principalCollection, roleName); i++ }\n        return resultArray\n    }\n\n    boolean hasAllRoles(PrincipalCollection principalCollection, Collection<String> roleNames) {\n        for (String roleName in roleNames) { if (!this.hasRole(principalCollection, roleName)) return false }\n        return true\n    }\n\n    void checkRole(PrincipalCollection principalCollection, String roleName) {\n        if (!this.hasRole(principalCollection, roleName))\n            throw new UnauthorizedException(ecfi.resource.expand('User ${principalCollection.primaryPrincipal} is not in role ${roleName}','',[principalCollection:principalCollection,roleName:roleName]))\n    }\n\n    void checkRoles(PrincipalCollection principalCollection, Collection<String> roleNames) {\n        for (String roleName in roleNames) {\n            if (!this.hasRole(principalCollection, roleName))\n                throw new UnauthorizedException(ecfi.resource.expand('User ${principalCollection.primaryPrincipal} is not in role ${roleName}','',[principalCollection:principalCollection,roleName:roleName]))\n        }\n    }\n\n    void checkRoles(PrincipalCollection principalCollection, String... roleNames) {\n        for (String roleName in roleNames) {\n            if (!this.hasRole(principalCollection, roleName))\n                throw new UnauthorizedException(ecfi.resource.expand('User ${principalCollection.primaryPrincipal} is not in role ${roleName}','',[principalCollection:principalCollection,roleName:roleName]))\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/util/RestSchemaUtil.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.util\n\nimport groovy.json.JsonBuilder\nimport groovy.transform.CompileStatic\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityNotFoundException\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.impl.context.WebFacadeImpl\nimport org.moqui.impl.entity.EntityDefinition\nimport org.moqui.impl.entity.EntityDefinition.MasterDefinition\nimport org.moqui.impl.entity.EntityDefinition.MasterDetail\nimport org.moqui.impl.entity.EntityFacadeImpl\nimport org.moqui.impl.entity.EntityJavaUtil.RelationshipInfo\nimport org.moqui.impl.entity.FieldInfo\nimport org.moqui.impl.service.RestApi\nimport org.moqui.impl.service.ServiceDefinition\nimport org.moqui.service.ServiceException\nimport org.moqui.util.MNode\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\nimport org.yaml.snakeyaml.DumperOptions\nimport org.yaml.snakeyaml.Yaml\n\nimport jakarta.servlet.http.HttpServletResponse\n\n@CompileStatic\nclass RestSchemaUtil {\n    protected final static Logger logger = LoggerFactory.getLogger(RestSchemaUtil.class)\n\n    static final Map<String, String> fieldTypeJsonMap = [\n            \"id\":\"string\", \"id-long\":\"string\", \"text-indicator\":\"string\", \"text-short\":\"string\", \"text-medium\":\"string\",\n            \"text-intermediate\":\"string\", \"text-long\":\"string\", \"text-very-long\":\"string\", \"date-time\":\"string\", \"time\":\"string\",\n            \"date\":\"string\", \"number-integer\":\"number\", \"number-float\":\"number\",\n            \"number-decimal\":\"number\", \"currency-amount\":\"number\", \"currency-precise\":\"number\",\n            \"binary-very-long\":\"string\" ] // NOTE: binary-very-long may need hyper-schema stuff\n    static final Map<String, String> fieldTypeJsonFormatMap = [\n            \"date-time\":\"date-time\", \"date\":\"date\", \"number-integer\":\"int64\", \"number-float\":\"double\",\n            \"number-decimal\":\"\", \"currency-amount\":\"\", \"currency-precise\":\"\", \"binary-very-long\":\"\" ]\n\n    static final Map jsonPaginationProperties =\n            [pageIndex:[type:'number', format:'int32', description:'Page number to return, starting with zero'],\n             pageSize:[type:'number', format:'int32', description:'Number of records per page (default 100)'],\n             orderByField:[type:'string', description:'Field name to order by (or comma separated names)'],\n             pageNoLimit:[type:'string', description:'If true don\\'t limit page size (no pagination)'],\n             dependentLevels:[type:'number', format:'int32', description:'Levels of dependent child records to include']\n            ]\n    static final Map jsonPaginationParameters = [type:'object', properties: jsonPaginationProperties]\n    static final Map jsonCountParameters = [type:'object', properties: [count:[type:'number', format:'int64', description:'Count of results']]]\n    static final List<Map> swaggerPaginationParameters =\n            [[name:'pageIndex', in:'query', required:false, type:'number', format:'int32', description:'Page number to return, starting with zero'],\n             [name:'pageSize', in:'query', required:false, type:'number', format:'int32', description:'Number of records per page (default 100)'],\n             [name:'orderByField', in:'query', required:false, type:'string', description:'Field name to order by (or comma separated names)'],\n             [name:'pageNoLimit', in:'query', required:false, type:'string', description:'If true don\\'t limit page size (no pagination)'],\n             [name:'dependentLevels', in:'query', required:false, type:'number', format:'int32', description:'Levels of dependent child records to include']\n            ] as List<Map>\n\n\n    static final Map ramlPaginationParameters = [\n             pageIndex:[type:'number', description:'Page number to return, starting with zero'],\n             pageSize:[type:'number', default:100, description:'Number of records per page (default 100)'],\n             orderByField:[type:'string', description:'Field name to order by (or comma separated names)'],\n             pageNoLimit:[type:'string', description:'If true don\\'t limit page size (no pagination)'],\n             dependentLevels:[type:'number', description:'Levels of dependent child records to include']\n            ]\n    static final Map<String, String> fieldTypeRamlMap = [\n            \"id\":\"string\", \"id-long\":\"string\", \"text-indicator\":\"string\", \"text-short\":\"string\", \"text-medium\":\"string\",\n            \"text-intermediate\":\"string\", \"text-long\":\"string\", \"text-very-long\":\"string\", \"date-time\":\"date\", \"time\":\"string\",\n            \"date\":\"string\", \"number-integer\":\"integer\", \"number-float\":\"number\",\n            \"number-decimal\":\"number\", \"currency-amount\":\"number\", \"currency-precise\":\"number\",\n            \"binary-very-long\":\"string\" ] // NOTE: binary-very-long may need hyper-schema stuff\n\n    // ===============================================\n    // ========== Entity Definition Methods ==========\n    // ===============================================\n\n    static List<String> getFieldEnums(EntityDefinition ed, FieldInfo fi) {\n        // populate enum values for Enumeration and StatusItem\n        // find first relationship that has this field as the only key map and is not a many relationship\n        RelationshipInfo oneRelInfo = null\n        List<RelationshipInfo> allRelInfoList = ed.getRelationshipsInfo(false)\n        for (RelationshipInfo relInfo in allRelInfoList) {\n            Map km = relInfo.keyMap\n            if (km.size() == 1 && km.containsKey(fi.name) && relInfo.type == \"one\" && relInfo.relNode.attribute(\"is-auto-reverse\") != \"true\") {\n                oneRelInfo = relInfo\n                break;\n            }\n        }\n        if (oneRelInfo != null && oneRelInfo.title != null) {\n            if (oneRelInfo.relatedEd.getFullEntityName() == 'moqui.basic.Enumeration') {\n                EntityList enumList = ed.efi.find(\"moqui.basic.Enumeration\").condition(\"enumTypeId\", oneRelInfo.title)\n                        .orderBy(\"sequenceNum,enumId\").disableAuthz().list()\n                if (enumList) {\n                    List<String> enumIdList = []\n                    for (EntityValue ev in enumList) enumIdList.add((String) ev.enumId)\n                    return enumIdList\n                }\n            } else if (oneRelInfo.relatedEd.getFullEntityName() == 'moqui.basic.StatusItem') {\n                EntityList statusList = ed.efi.find(\"moqui.basic.StatusItem\").condition(\"statusTypeId\", oneRelInfo.title)\n                        .orderBy(\"sequenceNum,statusId\").disableAuthz().list()\n                if (statusList) {\n                    List<String> statusIdList = []\n                    for (EntityValue ev in statusList) statusIdList.add((String) ev.statusId)\n                    return statusIdList\n                }\n            }\n        }\n        return null\n    }\n\n    static Map getJsonSchema(EntityDefinition ed, boolean pkOnly, boolean standalone, Map<String, Object> definitionsMap, String schemaUri, String linkPrefix,\n                      String schemaLinkPrefix, boolean nestRelationships, String masterName, MasterDetail masterDetail) {\n        String name = ed.getShortOrFullEntityName()\n        String prettyName = ed.getPrettyName(null, null)\n        String refName = name\n        if (masterName) {\n            refName = \"${name}.${masterName}\".toString()\n            prettyName = \"${prettyName} (Master: ${masterName})\".toString()\n        }\n        if (pkOnly) {\n            name = name + \".PK\"\n            refName = refName + \".PK\"\n        }\n\n        Map<String, Object> properties = [:]\n        properties.put('_entity', [type:'string', default:name])\n        // NOTE: Swagger validation doesn't like the id field, was: id:refName\n        Map<String, Object> schema = [title:prettyName, type:'object', properties:properties] as Map<String, Object>\n\n        // add all fields\n        ArrayList<String> allFields = pkOnly ? ed.getPkFieldNames() : ed.getAllFieldNames()\n        for (int i = 0; i < allFields.size(); i++) {\n            FieldInfo fi = ed.getFieldInfo(allFields.get(i))\n            Map<String, Object> propMap = [:]\n            propMap.put('type', fieldTypeJsonMap.get(fi.type))\n            String format = fieldTypeJsonFormatMap.get(fi.type)\n            if (format) propMap.put('format', format)\n            properties.put(fi.name, propMap)\n\n            List enumList = getFieldEnums(ed, fi)\n            if (enumList) propMap.put('enum', enumList)\n        }\n\n\n        // put current schema in Map before nesting for relationships, avoid infinite recursion with entity rel loops\n        if (standalone && definitionsMap == null) {\n            definitionsMap = [:]\n            definitionsMap.put('paginationParameters', jsonPaginationParameters)\n        }\n        if (definitionsMap != null && !definitionsMap.containsKey(refName)) definitionsMap.put(refName, schema)\n\n        if (!pkOnly && (masterName || masterDetail != null)) {\n            // add only relationships from master definition or detail\n            List<MasterDetail> detailList\n            if (masterName) {\n                MasterDefinition masterDef = ed.getMasterDefinition(masterName)\n                if (masterDef == null) throw new IllegalArgumentException(\"Master name ${masterName} not valid for entity ${ed.getFullEntityName()}\")\n                detailList = masterDef.detailList\n            } else {\n                detailList = masterDetail.getDetailList()\n            }\n            for (MasterDetail childMasterDetail in detailList) {\n                RelationshipInfo relInfo = childMasterDetail.relInfo\n                String relationshipName = relInfo.relationshipName\n                String entryName = relInfo.shortAlias ?: relationshipName\n                String relatedRefName = relInfo.relatedEd.getShortOrFullEntityName()\n                if (pkOnly) relatedRefName = relatedRefName + \".PK\"\n\n                // recurse, let it put itself in the definitionsMap\n                // linkPrefix and schemaLinkPrefix are null so that no links are added for master dependents\n                if (definitionsMap != null && !definitionsMap.containsKey(relatedRefName))\n                    getJsonSchema(relInfo.relatedEd, pkOnly, false, definitionsMap, schemaUri, null, null, false, null, childMasterDetail)\n\n                if (relInfo.type == \"many\") {\n                    properties.put(entryName, [type:'array', items:['$ref':('#/definitions/' + relatedRefName)]])\n                } else {\n                    properties.put(entryName, ['$ref':('#/definitions/' + relatedRefName)])\n                }\n            }\n        } else if (!pkOnly && nestRelationships) {\n            // add all relationships, nest\n            List<RelationshipInfo> relInfoList = ed.getRelationshipsInfo(true)\n            for (RelationshipInfo relInfo in relInfoList) {\n                String relationshipName = relInfo.relationshipName\n                String entryName = relInfo.shortAlias ?: relationshipName\n                String relatedRefName = relInfo.relatedEd.getShortOrFullEntityName()\n                if (pkOnly) relatedRefName = relatedRefName + \".PK\"\n\n                // recurse, let it put itself in the definitionsMap\n                if (definitionsMap != null && !definitionsMap.containsKey(relatedRefName))\n                    getJsonSchema(relInfo.relatedEd, pkOnly, false, definitionsMap, schemaUri, linkPrefix, schemaLinkPrefix, nestRelationships, null, null)\n\n                if (relInfo.type == \"many\") {\n                    properties.put(entryName, [type:'array', items:['$ref':('#/definitions/' + relatedRefName)]])\n                } else {\n                    properties.put(entryName, ['$ref':('#/definitions/' + relatedRefName)])\n                }\n            }\n        }\n\n        // add links (for Entity REST API)\n        if (linkPrefix || schemaLinkPrefix) {\n            List<String> pkNameList = ed.getPkFieldNames()\n            StringBuilder idSb = new StringBuilder()\n            for (String pkName in pkNameList) idSb.append('/{').append(pkName).append('}')\n            String idString = idSb.toString()\n\n            List<Map> linkList\n            if (linkPrefix) {\n                linkList = [\n                    [rel:'self', method:'GET', href:\"${linkPrefix}/${refName}${idString}\", title:\"Get single ${prettyName}\",\n                        targetSchema:['$ref':\"#/definitions/${name}\"]],\n                    [rel:'instances', method:'GET', href:\"${linkPrefix}/${refName}\", title:\"Get list of ${prettyName}\",\n                        schema:[allOf:[['$ref':'#/definitions/paginationParameters'], ['$ref':\"#/definitions/${name}\"]]],\n                        targetSchema:[type:'array', items:['$ref':\"#/definitions/${name}\"]]],\n                    [rel:'create', method:'POST', href:\"${linkPrefix}/${refName}\", title:\"Create ${prettyName}\",\n                        schema:['$ref':\"#/definitions/${name}\"]],\n                    [rel:'update', method:'PATCH', href:\"${linkPrefix}/${refName}${idString}\", title:\"Update ${prettyName}\",\n                        schema:['$ref':\"#/definitions/${name}\"]],\n                    [rel:'store', method:'PUT', href:\"${linkPrefix}/${refName}${idString}\", title:\"Create or Update ${prettyName}\",\n                        schema:['$ref':\"#/definitions/${name}\"]],\n                    [rel:'destroy', method:'DELETE', href:\"${linkPrefix}/${refName}${idString}\", title:\"Delete ${prettyName}\",\n                        schema:['$ref':\"#/definitions/${name}\"]]\n                ] as List<Map>\n            } else {\n                linkList = []\n            }\n            if (schemaLinkPrefix) linkList.add([rel:'describedBy', method:'GET', href:\"${schemaLinkPrefix}/${refName}\", title:\"Get schema for ${prettyName}\"])\n\n            schema.put('links', linkList)\n        }\n\n        if (standalone) {\n            return ['$schema':'http://json-schema.org/draft-04/hyper-schema#', id:\"${schemaUri}/${refName}\",\n                    '$ref':\"#/definitions/${name}\", definitions:definitionsMap]\n        } else {\n            return schema\n        }\n    }\n\n    static Map<String, Object> getRamlFieldMap(EntityDefinition ed, FieldInfo fi) {\n        Map<String, Object> propMap = [:]\n        String description = fi.fieldNode.first(\"description\")?.text\n        if (description) propMap.put(\"description\", description)\n        propMap.put('type', fieldTypeRamlMap.get(fi.type))\n\n        List enumList = getFieldEnums(ed, fi)\n        if (enumList) propMap.put('enum', enumList)\n        return propMap\n    }\n\n    static Map<String, Object> getRamlTypeMap(EntityDefinition ed, boolean pkOnly, Map<String, Object> typesMap,\n                                              String masterName, MasterDetail masterDetail) {\n        String name = ed.getShortOrFullEntityName()\n        String prettyName = ed.getPrettyName(null, null)\n        String refName = name\n        if (masterName) {\n            refName = \"${name}.${masterName}\"\n            prettyName = prettyName + \" (Master: ${masterName})\"\n        }\n\n        Map properties = [:]\n        Map<String, Object> typeMap = [displayName:prettyName, type:'object', properties:properties] as Map<String, Object>\n\n        if (typesMap != null && !typesMap.containsKey(name)) typesMap.put(refName, typeMap)\n\n        // add field properties\n        ArrayList<String> allFields = pkOnly ? ed.getPkFieldNames() : ed.getAllFieldNames()\n        for (int i = 0; i < allFields.size(); i++) {\n            FieldInfo fi = ed.getFieldInfo(allFields.get(i))\n            properties.put(fi.name, getRamlFieldMap(ed, fi))\n        }\n\n        // for master add related properties\n        if (!pkOnly && (masterName || masterDetail != null)) {\n            // add only relationships from master definition or detail\n            List<MasterDetail> detailList\n            if (masterName) {\n                MasterDefinition masterDef = ed.getMasterDefinition(masterName)\n                if (masterDef == null) throw new IllegalArgumentException(\"Master name ${masterName} not valid for entity ${ed.getFullEntityName()}\")\n                detailList = masterDef.detailList\n            } else {\n                detailList = masterDetail.getDetailList()\n            }\n            for (MasterDetail childMasterDetail in detailList) {\n                RelationshipInfo relInfo = childMasterDetail.relInfo\n                String relationshipName = relInfo.relationshipName\n                String entryName = relInfo.shortAlias ?: relationshipName\n                String relatedRefName = relInfo.relatedEd.getShortOrFullEntityName()\n\n                // recurse, let it put itself in the definitionsMap\n                if (typesMap != null && !typesMap.containsKey(relatedRefName))\n                    getRamlTypeMap(relInfo.relatedEd, pkOnly, typesMap, null, childMasterDetail)\n\n                if (relInfo.type == \"many\") {\n                    // properties.put(entryName, [type:'array', items:relatedRefName])\n                    properties.put(entryName, [type:(relatedRefName + '[]')])\n                } else {\n                    properties.put(entryName, [type:relatedRefName])\n                }\n            }\n        }\n\n        return typeMap\n    }\n\n    static Map getRamlApi(EntityDefinition ed, String masterName) {\n        String name = ed.getShortOrFullEntityName()\n        if (masterName) name = \"${name}/${masterName}\"\n        String prettyName = ed.getPrettyName(null, null)\n\n        Map<String, Object> ramlMap = [:]\n\n        // setup field info\n        Map qpMap = [:]\n        ArrayList<String> allFields = ed.getAllFieldNames()\n        for (int i = 0; i < allFields.size(); i++) {\n            FieldInfo fi = ed.getFieldInfo(allFields.get(i))\n            qpMap.put(fi.name, getRamlFieldMap(ed, fi))\n        }\n\n        // get list\n        // TODO: make body array of schema\n        ramlMap.put('get', [is:['paged'], description:\"Get list of ${prettyName}\".toString(), queryParameters:qpMap,\n                            responses:[200:[body:['application/json': [schema:name]]]]])\n        // create\n        ramlMap.put('post', [description:\"Create ${prettyName}\".toString(), body:['application/json': [schema:name]]])\n\n        // under IDs for single record operations\n        List<String> pkNameList = ed.getPkFieldNames()\n        Map<String, Object> recordMap = ramlMap\n        for (String pkName in pkNameList) {\n            Map<String, Object> childMap = [:]\n            recordMap.put('/{' + pkName + '}', childMap)\n            recordMap = childMap\n        }\n\n        // get single\n        recordMap.put('get', [description:\"Get single ${prettyName}\".toString(),\n                            responses:[200:[body:['application/json': [schema:name]]]]])\n        // update\n        recordMap.put('patch', [description:\"Update ${prettyName}\".toString(), body:['application/json': [schema:name]]])\n        // store\n        recordMap.put('put', [description:\"Create or Update ${prettyName}\".toString(), body:['application/json': [schema:name]]])\n        // delete\n        recordMap.put('delete', [description:\"Delete ${prettyName}\".toString()])\n\n        return ramlMap\n    }\n\n    static void addToSwaggerMap(EntityDefinition ed, Map<String, Object> swaggerMap, String masterName) {\n        Map definitionsMap = ((Map) swaggerMap.definitions)\n        String refDefName = ed.getShortOrFullEntityName()\n        if (masterName) refDefName = refDefName + \".\" + masterName\n        String refDefNamePk = refDefName + \".PK\"\n\n        String entityDescription = ed.getEntityNode().first(\"description\")?.text\n\n        // add responses\n        Map responses = [\"401\":[description:\"Authentication required\"], \"403\":[description:\"Access Forbidden (no authz)\"],\n                         \"404\":[description:\"Value Not Found\"], \"429\":[description:\"Too Many Requests (tarpit)\"],\n                         \"500\":[description:\"General Error\"]]\n\n        // entity path (no ID)\n        String entityPath = \"/\" + (ed.getShortOrFullEntityName())\n        if (masterName) entityPath = entityPath + \"/\" + masterName\n        Map<String, Map<String, Object>> entityResourceMap = [:]\n        ((Map) swaggerMap.paths).put(entityPath, entityResourceMap)\n\n        // get - list\n        List<Map> listParameters = []\n        listParameters.addAll(swaggerPaginationParameters)\n        for (String fieldName in ed.getAllFieldNames()) {\n            FieldInfo fi = ed.getFieldInfo(fieldName)\n            listParameters.add([name:fieldName, in:'query', required:false, type:(fieldTypeJsonMap.get(fi.type) ?: \"string\"),\n                                format:(fieldTypeJsonFormatMap.get(fi.type) ?: \"\"),\n                                description:fi.fieldNode.first(\"description\")?.text])\n        }\n        Map listResponses = [\"200\":[description:'Success', schema:[type:\"array\", items:['$ref':\"#/definitions/${refDefName}\".toString()]]]] as Map<String, Object>\n        listResponses.putAll(responses)\n        entityResourceMap.put(\"get\", [summary:(\"Get ${ed.getFullEntityName()}\".toString()), description:entityDescription,\n                parameters:listParameters, security:[[basicAuth:[]]], responses:listResponses])\n\n        // post - create\n        Map createResponses = [\"200\":[description:'Success', schema:['$ref':\"#/definitions/${refDefNamePk}\".toString()]]] as Map<String, Object>\n        createResponses.putAll(responses)\n        entityResourceMap.put(\"post\", [summary:(\"Create ${ed.getFullEntityName()}\".toString()), description:entityDescription,\n                parameters:[name:'body', in:'body', required:true, schema:['$ref':\"#/definitions/${refDefName}\".toString()]],\n                security:[[basicAuth:[]]], responses:createResponses])\n\n        // entity plus ID path\n        StringBuilder entityIdPathSb = new StringBuilder(entityPath)\n        List<Map> parameters = []\n        for (String pkName in ed.getPkFieldNames()) {\n            entityIdPathSb.append(\"/{\").append(pkName).append(\"}\")\n\n            FieldInfo fi = ed.getFieldInfo(pkName)\n            parameters.add([name:pkName, in:'path', required:true, type:(fieldTypeJsonMap.get(fi.type) ?: \"string\"),\n                            description:fi.fieldNode.first(\"description\")?.text])\n        }\n        String entityIdPath = entityIdPathSb.toString()\n        Map<String, Map<String, Object>> entityIdResourceMap = [:]\n        ((Map) swaggerMap.paths).put(entityIdPath, entityIdResourceMap)\n\n        // under id: get - one\n        Map oneResponses = [\"200\":[name:'body', in:'body', required:false, schema:['$ref':\"#/definitions/${refDefName}\".toString()]]] as Map<String, Object>\n        oneResponses.putAll(responses)\n        entityIdResourceMap.put(\"get\", [summary:(\"Create ${ed.getFullEntityName()}\".toString()),\n                description:entityDescription, security:[[basicAuth:[]], [api_key:[]]], parameters:parameters, responses:oneResponses])\n\n        // under id: patch - update\n        List<Map> updateParameters = new LinkedList<Map>(parameters)\n        updateParameters.add([name:'body', in:'body', required:false, schema:['$ref':\"#/definitions/${refDefName}\".toString()]])\n        entityIdResourceMap.put(\"patch\", [summary:(\"Update ${ed.getFullEntityName()}\".toString()),\n                description:entityDescription, security:[[basicAuth:[]], [api_key:[]]], parameters:updateParameters, responses:responses])\n\n        // under id: put - store\n        entityIdResourceMap.put(\"put\", [summary:(\"Create or Update ${ed.getFullEntityName()}\".toString()),\n                description:entityDescription, security:[[basicAuth:[]], [api_key:[]]], parameters:updateParameters, responses:responses])\n\n        // under id: delete - delete\n        entityIdResourceMap.put(\"delete\", [summary:(\"Delete ${ed.getFullEntityName()}\".toString()),\n                description:entityDescription, security:[[basicAuth:[]], [api_key:[]]], parameters:parameters, responses:responses])\n\n        // add a definition for entity fields\n        definitionsMap.put(refDefName, getJsonSchema(ed, false, false, definitionsMap, null, null, null, false, masterName, null))\n        definitionsMap.put(refDefNamePk, getJsonSchema(ed, true, false, null, null, null, null, false, masterName, null))\n    }\n\n    // ================================================\n    // ========== Service Definition Methods ==========\n    // ================================================\n\n    static Map<String, Object> getJsonSchemaMapIn(ServiceDefinition sd) {\n        // add a definition for service in parameters\n        List<String> requiredParms = []\n        Map<String, Object> properties = [:]\n        Map<String, Object> defMap = [type:'object', properties:properties] as Map<String, Object>\n        for (String parmName in sd.getInParameterNames()) {\n            MNode parmNode = sd.getInParameter(parmName)\n            if (parmNode.attribute(\"required\") == \"true\") requiredParms.add(parmName)\n            properties.put(parmName, getJsonSchemaPropMap(sd, parmNode))\n        }\n        if (requiredParms) defMap.put(\"required\", requiredParms)\n        return defMap\n    }\n    static Map<String, Object> getJsonSchemaMapOut(ServiceDefinition sd) {\n        List<String> requiredParms = []\n        Map<String, Object> properties = [:]\n        Map<String, Object> defMap = [type:'object', properties:properties] as Map<String, Object>\n        for (String parmName in sd.getOutParameterNames()) {\n            MNode parmNode = sd.getOutParameter(parmName)\n            if (parmNode.attribute(\"required\") == \"true\") requiredParms.add(parmName)\n            properties.put(parmName, getJsonSchemaPropMap(sd, parmNode))\n        }\n        if (requiredParms) defMap.put(\"required\", requiredParms)\n        return defMap\n    }\n    static protected Map<String, Object> getJsonSchemaPropMap(ServiceDefinition sd, MNode parmNode) {\n        String objectType = (String) parmNode?.attribute('type')\n        String jsonType = RestApi.getJsonType(objectType)\n        Map<String, Object> propMap = [type:jsonType] as Map<String, Object>\n        String format = RestApi.getJsonFormat(objectType)\n        if (format) propMap.put(\"format\", format)\n        String description = parmNode.first(\"description\")?.text\n        if (description) propMap.put(\"description\", description)\n        if (parmNode.attribute(\"default-value\")) propMap.put(\"default\", (String) parmNode.attribute(\"default-value\"))\n        if (parmNode.attribute(\"default\")) propMap.put(\"default\", \"{${parmNode.attribute(\"default\")}}\".toString())\n\n        List<MNode> childList = parmNode.children(\"parameter\")\n        if (jsonType == 'array') {\n            if (childList) {\n                propMap.put(\"items\", getJsonSchemaPropMap(sd, childList[0]))\n            } else {\n                logger.warn(\"Parameter ${parmNode.attribute('name')} of service ${sd.serviceName} is an array type but has no child parameter (should have one, name ignored), may cause error in Swagger, etc\")\n            }\n        } else if (jsonType == 'object') {\n            if (childList) {\n                Map properties = [:]\n                propMap.put(\"properties\", properties)\n                for (MNode childNode in childList) {\n                    properties.put(childNode.attribute(\"name\"), getJsonSchemaPropMap(sd, childNode))\n                }\n            } else {\n                // Swagger UI is okay with empty maps (works, just less detail), so don't warn about this\n                // logger.warn(\"Parameter ${parmNode.attribute('name')} of service ${getServiceName()} is an object type but has no child parameters, may cause error in Swagger, etc\")\n            }\n        } else {\n            addParameterEnums(sd, parmNode, propMap)\n        }\n\n        return propMap\n    }\n\n    static void addParameterEnums(ServiceDefinition sd, MNode parmNode, Map<String, Object> propMap) {\n        String entityName = parmNode.attribute(\"entity-name\")\n        String fieldName = parmNode.attribute(\"field-name\")\n        if (entityName && fieldName) {\n            EntityDefinition ed = sd.sfi.ecfi.entityFacade.getEntityDefinition(entityName)\n            if (ed == null) throw new ServiceException(\"Entity ${entityName} not found, from parameter ${parmNode.attribute('name')} of service ${sd.serviceName}\")\n            FieldInfo fi = ed.getFieldInfo(fieldName)\n            if (fi == null) throw new ServiceException(\"Field ${fieldName} not found for entity ${entityName}, from parameter ${parmNode.attribute('name')} of service ${sd.serviceName}\")\n            List enumList = getFieldEnums(ed, fi)\n            if (enumList) propMap.put('enum', enumList)\n        }\n    }\n\n    static Map<String, Object> getRamlMapIn(ServiceDefinition sd) {\n        Map<String, Object> properties = [:]\n        Map<String, Object> defMap = [type:'object', properties:properties] as Map<String, Object>\n        for (String parmName in sd.getInParameterNames()) {\n            MNode parmNode = sd.getInParameter(parmName)\n            properties.put(parmName, getRamlPropMap(parmNode))\n        }\n        return defMap\n    }\n    static Map<String, Object> getRamlMapOut(ServiceDefinition sd) {\n        Map<String, Object> properties = [:]\n        Map<String, Object> defMap = [type:'object', properties:properties] as Map<String, Object>\n        for (String parmName in sd.getOutParameterNames()) {\n            MNode parmNode = sd.getOutParameter(parmName)\n            properties.put(parmName, getRamlPropMap(parmNode))\n        }\n        return defMap\n    }\n    protected static Map<String, Object> getRamlPropMap(MNode parmNode) {\n        String objectType = parmNode?.attribute('type')\n        String ramlType = RestApi.getRamlType(objectType)\n        Map<String, Object> propMap = [type:ramlType] as Map<String, Object>\n        String description = parmNode.first(\"description\")?.text\n        if (description) propMap.put(\"description\", description)\n        if (parmNode.attribute(\"required\") == \"true\") propMap.put(\"required\", true)\n        if (parmNode.attribute(\"default-value\")) propMap.put(\"default\", (String) parmNode.attribute(\"default-value\"))\n        if (parmNode.attribute(\"default\")) propMap.put(\"default\", \"{${parmNode.attribute(\"default\")}}\".toString())\n\n        List<MNode> childList = parmNode.children(\"parameter\")\n        if (childList) {\n            if (ramlType == 'array') {\n                propMap.put(\"items\", getRamlPropMap(childList[0]))\n            } else if (ramlType == 'object') {\n                Map properties = [:]\n                propMap.put(\"properties\", properties)\n                for (MNode childNode in childList) {\n                    properties.put(childNode.attribute(\"name\"), getRamlPropMap(childNode))\n                }\n            }\n        }\n\n        return propMap\n    }\n\n    // ================================================\n    // ========== Web Request Schema Methods ==========\n    // ================================================\n\n    static void handleEntityRestSchema(ExecutionContextImpl eci, List<String> extraPathNameList, String schemaUri, String linkPrefix,\n                                       String schemaLinkPrefix, boolean getMaster) {\n        // make sure a user is logged in, screen/etc that calls will generally be configured to not require auth\n        if (!eci.getUser().getUsername()) {\n            // if there was a login error there will be a MessageFacade error message\n            String errorMessage = eci.message.errorsString\n            if (!errorMessage) errorMessage = \"Authentication required for entity REST schema\"\n            eci.webImpl.sendJsonError(HttpServletResponse.SC_UNAUTHORIZED, errorMessage, null)\n            return\n        }\n\n        EntityFacadeImpl efi = eci.entityFacade\n\n        if (extraPathNameList.size() == 0) {\n            List allRefList = []\n            Map<String, Object> definitionsMap = [:]\n            definitionsMap.put('paginationParameters', jsonPaginationParameters)\n            Map rootMap = ['$schema':'http://json-schema.org/draft-04/hyper-schema#', title:'Moqui Entity REST API',\n                    anyOf:allRefList, definitions:definitionsMap]\n            if (schemaUri) rootMap.put('id', schemaUri)\n\n            Set<String> entityNameSet\n            if (getMaster) {\n                // if getMaster and no entity name in path, just get entities with master definitions\n                entityNameSet = efi.getAllEntityNamesWithMaster()\n            } else {\n                entityNameSet = efi.getAllNonViewEntityNames()\n            }\n            for (String entityName in entityNameSet) {\n                EntityDefinition ed = efi.getEntityDefinition(entityName)\n                String refName = ed.getShortOrFullEntityName()\n                if (getMaster) {\n                    Map<String, MasterDefinition> masterDefMap = ed.getMasterDefinitionMap()\n                    Map entityPathMap = [:]\n                    for (String masterName in masterDefMap.keySet()) {\n                        allRefList.add(['$ref':\"#/definitions/${refName}/${masterName}\"])\n\n                        Map schema = getJsonSchema(ed, false, false, definitionsMap, schemaUri, linkPrefix, schemaLinkPrefix, false, masterName, null)\n                        entityPathMap.put(masterName, schema)\n                    }\n                    definitionsMap.put(refName, entityPathMap)\n                } else {\n                    allRefList.add(['$ref':\"#/definitions/${refName}\"])\n\n                    Map schema = getJsonSchema(ed, false, false, null, schemaUri, linkPrefix, schemaLinkPrefix, true, null, null)\n                    definitionsMap.put(refName, schema)\n                }\n            }\n\n            JsonBuilder jb = new JsonBuilder()\n            jb.call(rootMap)\n            String jsonStr = jb.toPrettyString()\n\n            eci.webImpl.sendTextResponse(jsonStr, \"application/schema+json\", \"MoquiEntities.schema.json\")\n        } else {\n            String entityName = extraPathNameList.get(0)\n            if (entityName.endsWith(\".json\")) entityName = entityName.substring(0, entityName.length() - 5)\n\n            String masterName = null\n            if (extraPathNameList.size() > 1) {\n                masterName = extraPathNameList.get(1)\n                if (masterName.endsWith(\".json\")) masterName = masterName.substring(0, masterName.length() - 5)\n            }\n            if (getMaster && !masterName) masterName = \"default\"\n\n            try {\n                EntityDefinition ed = efi.getEntityDefinition(entityName)\n                if (ed == null) {\n                    eci.webImpl.sendJsonError(HttpServletResponse.SC_BAD_REQUEST, \"No entity found with name or alias [${entityName}]\", null)\n                    return\n                }\n\n                Map schema = getJsonSchema(ed, false, true, null, schemaUri, linkPrefix, schemaLinkPrefix, !getMaster, masterName, null)\n                // TODO: support array wrapper (different URL? suffix?) with [type:'array', items:schema]\n\n                // sendJsonResponse(schema)\n                JsonBuilder jb = new JsonBuilder()\n                jb.call(schema)\n                String jsonStr = jb.toPrettyString()\n\n                eci.webImpl.sendTextResponse(jsonStr, \"application/schema+json\", \"${entityName}.schema.json\")\n            } catch (EntityNotFoundException e) {\n                if (logger.isTraceEnabled()) logger.trace(\"In entity REST schema entity not found: \" + e.toString())\n                eci.webImpl.sendJsonError(HttpServletResponse.SC_BAD_REQUEST, \"No entity found with name or alias [${entityName}]\", null)\n            }\n        }\n    }\n\n    static void handleEntityRestRaml(ExecutionContextImpl eci, List<String> extraPathNameList, String linkPrefix, String schemaLinkPrefix, boolean getMaster) {\n        // make sure a user is logged in, screen/etc that calls will generally be configured to not require auth\n        if (!eci.getUser().getUsername()) {\n            // if there was a login error there will be a MessageFacade error message\n            String errorMessage = eci.message.errorsString\n            if (!errorMessage) errorMessage = \"Authentication required for entity REST schema\"\n            eci.webImpl.sendJsonError(HttpServletResponse.SC_UNAUTHORIZED, errorMessage, null)\n            return\n        }\n\n        EntityFacadeImpl efi = eci.entityFacade\n\n        List<Map> schemasList = []\n        Map<String, Object> rootMap = [title:'Moqui Entity REST API', version:eci.factory.moquiVersion, baseUri:linkPrefix,\n                                       mediaType:'application/json', schemas:schemasList] as Map<String, Object>\n        rootMap.put('traits', [[paged:[queryParameters:ramlPaginationParameters]]])\n\n        Set<String> entityNameSet\n        String masterName = null\n        if (extraPathNameList.size() > 0) {\n            String entityName = extraPathNameList.get(0)\n            if (entityName.endsWith(\".raml\")) entityName = entityName.substring(0, entityName.length() - 5)\n\n            if (extraPathNameList.size() > 1) {\n                masterName = extraPathNameList.get(1)\n                if (masterName.endsWith(\".raml\")) masterName = masterName.substring(0, masterName.length() - 5)\n            }\n\n            entityNameSet = new TreeSet<String>()\n            entityNameSet.add(entityName)\n        } else if (getMaster) {\n            // if getMaster and no entity name in path, just get entities with master definitions\n            entityNameSet = efi.getAllEntityNamesWithMaster()\n        } else {\n            entityNameSet = efi.getAllNonViewEntityNames()\n        }\n        for (String entityName in entityNameSet) {\n            EntityDefinition ed = efi.getEntityDefinition(entityName)\n            String refName = ed.getShortOrFullEntityName()\n            if (getMaster) {\n                Set<String> masterNameSet = new LinkedHashSet<String>()\n                if (masterName) {\n                    masterNameSet.add(masterName)\n                } else {\n                    Map<String, MasterDefinition> masterDefMap = ed.getMasterDefinitionMap()\n                    masterNameSet.addAll(masterDefMap.keySet())\n                }\n                Map entityPathMap = [:]\n                for (String curMasterName in masterNameSet) {\n                    schemasList.add([(\"${refName}/${curMasterName}\".toString()):\"!include ${schemaLinkPrefix}/${refName}/${curMasterName}.json\".toString()])\n\n                    Map ramlApi = getRamlApi(ed, masterName)\n                    entityPathMap.put(\"/\" + curMasterName, ramlApi)\n                }\n                rootMap.put(\"/\" + refName, entityPathMap)\n            } else {\n                schemasList.add([(refName):\"!include ${schemaLinkPrefix}/${refName}.json\".toString()])\n\n                Map ramlApi = getRamlApi(ed, null)\n                rootMap.put('/' + refName, ramlApi)\n            }\n        }\n\n        DumperOptions options = new DumperOptions()\n        options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK)\n        // default: options.setDefaultScalarStyle(DumperOptions.ScalarStyle.PLAIN)\n        options.setPrettyFlow(true)\n        Yaml yaml = new Yaml(options)\n        String yamlString = yaml.dump(rootMap)\n        // add beginning line \"#%RAML 0.8\", more efficient way to do this?\n        yamlString = \"#%RAML 0.8\\n\" + yamlString\n\n        eci.webImpl.sendTextResponse(yamlString, \"application/raml+yaml\", \"MoquiEntities.raml\")\n    }\n\n    static void handleEntityRestSwagger(ExecutionContextImpl eci, List<String> extraPathNameList, String basePath, boolean getMaster) {\n        if (extraPathNameList.size() == 0) {\n            eci.webImpl.sendJsonError(HttpServletResponse.SC_BAD_REQUEST, \"No entity name specified in path (for all entities use 'all')\", null)\n            return\n        }\n\n        EntityFacadeImpl efi = eci.entityFacade\n\n        String entityName = extraPathNameList.get(0)\n        String outputType = \"application/json\"\n        if (entityName.endsWith(\".yaml\")) outputType = \"application/yaml\"\n        if (entityName.endsWith(\".json\") || entityName.endsWith(\".yaml\"))\n            entityName = entityName.substring(0, entityName.length() - 5)\n        if (entityName == 'all') entityName = null\n\n        String masterName = null\n        if (extraPathNameList.size() > 1) {\n            masterName = extraPathNameList.get(1)\n            if (masterName.endsWith(\".json\") || masterName.endsWith(\".yaml\"))\n                masterName = masterName.substring(0, masterName.length() - 5)\n        }\n\n        String filename = entityName ?: \"Entities\"\n        if (masterName) filename = filename + \".\" + masterName\n\n        eci.webImpl.response.setHeader(\"Access-Control-Allow-Origin\", \"*\")\n        eci.webImpl.response.setHeader(\"Access-Control-Allow-Methods\", \"GET, POST, DELETE, PUT, PATCH, OPTIONS\")\n        eci.webImpl.response.setHeader(\"Access-Control-Allow-Headers\", \"Content-Type, api_key, Authorization\")\n\n        String fullHost = WebFacadeImpl.makeWebappHost(eci.webImpl.webappMoquiName, eci, eci.webImpl, true)\n        String scheme = fullHost.substring(0, fullHost.indexOf(\"://\"))\n        String hostName = fullHost.substring(fullHost.indexOf(\"://\") + 3)\n        Map definitionsMap = new TreeMap()\n        Map<String, Object> swaggerMap = [swagger:'2.0',\n            info:[title:(\"${filename} REST API\"), version:eci.factory.moquiVersion], host:hostName, basePath:basePath,\n            schemes:[scheme], consumes:['application/json', 'multipart/form-data'], produces:['application/json'],\n            securityDefinitions:[basicAuth:[type:'basic', description:'HTTP Basic Authentication'],\n                api_key:[type:\"apiKey\", name:\"api_key\", in:\"header\", description:'HTTP Header api_key']],\n            paths:[:], definitions:definitionsMap\n        ]\n\n        Set<String> entityNameSet\n        if (entityName) {\n            entityNameSet = new TreeSet<String>()\n            entityNameSet.add(entityName)\n        } else if (getMaster) {\n            // if getMaster and no entity name in path, just get entities with master definitions\n            entityNameSet = efi.getAllEntityNamesWithMaster()\n        } else {\n            entityNameSet = efi.getAllNonViewEntityNames()\n        }\n\n        for (String curEntityName in entityNameSet) {\n            EntityDefinition ed = efi.getEntityDefinition(curEntityName)\n            if (getMaster) {\n                Set<String> masterNameSet = new LinkedHashSet<String>()\n                if (masterName) {\n                    masterNameSet.add(masterName)\n                } else {\n                    Map<String, MasterDefinition> masterDefMap = ed.getMasterDefinitionMap()\n                    masterNameSet.addAll(masterDefMap.keySet())\n                }\n                for (String curMasterName in masterNameSet) {\n                    addToSwaggerMap(ed, swaggerMap, curMasterName)\n                }\n            } else {\n                addToSwaggerMap(ed, swaggerMap, null)\n            }\n        }\n\n        if (outputType == \"application/json\") {\n            JsonBuilder jb = new JsonBuilder()\n            jb.call(swaggerMap)\n            String jsonStr = jb.toPrettyString()\n            eci.webImpl.sendTextResponse(jsonStr, \"application/json\", \"${filename}.swagger.json\")\n        } else if (outputType == \"application/yaml\") {\n            DumperOptions options = new DumperOptions()\n            options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK)\n            // default: options.setDefaultScalarStyle(DumperOptions.ScalarStyle.PLAIN)\n            options.setPrettyFlow(true)\n            Yaml yaml = new Yaml(options)\n            String yamlString = yaml.dump(swaggerMap)\n\n            eci.webImpl.sendTextResponse(yamlString, \"application/yaml\", \"${filename}.swagger.yaml\")\n        } else {\n            eci.webImpl.sendJsonError(HttpServletResponse.SC_BAD_REQUEST, \"Output type ${outputType} not supported\", null)\n        }\n    }\n\n    static void handleServiceRestSwagger(ExecutionContextImpl eci, List<String> extraPathNameList, String basePath) {\n        if (extraPathNameList.size() == 0) {\n            eci.webImpl.sendJsonError(HttpServletResponse.SC_BAD_REQUEST, \"No root resource name specified in path\", null)\n            return\n        }\n\n        String outputType = \"application/json\"\n        List<String> rootPathList = []\n        StringBuilder filenameBase = new StringBuilder()\n        for (String pathName in extraPathNameList) {\n            if (pathName.endsWith(\".yaml\")) outputType = \"application/yaml\"\n            if (pathName.endsWith(\".json\") || pathName.endsWith(\".yaml\"))\n                pathName = pathName.substring(0, pathName.length() - 5)\n            rootPathList.add(pathName)\n            filenameBase.append(pathName).append('.')\n        }\n\n        eci.webImpl.response.setHeader(\"Access-Control-Allow-Origin\", \"*\")\n        eci.webImpl.response.setHeader(\"Access-Control-Allow-Methods\", \"GET, POST, DELETE, PUT, PATCH, OPTIONS\")\n        eci.webImpl.response.setHeader(\"Access-Control-Allow-Headers\", \"Content-Type, api_key, Authorization\")\n\n        String fullHost = WebFacadeImpl.makeWebappHost(eci.webImpl.webappMoquiName, eci, eci.webImpl, true)\n        String scheme = fullHost.substring(0, fullHost.indexOf(\"://\"))\n        String hostName = fullHost.substring(fullHost.indexOf(\"://\") + 3)\n        Map swaggerMap = eci.serviceFacade.restApi.getSwaggerMap(rootPathList, [scheme], hostName, basePath)\n        if (outputType == \"application/json\") {\n            JsonBuilder jb = new JsonBuilder()\n            jb.call(swaggerMap)\n            String jsonStr = jb.toPrettyString()\n            eci.webImpl.sendTextResponse(jsonStr, \"application/json\", \"${filenameBase}swagger.json\")\n        } else if (outputType == \"application/yaml\") {\n            DumperOptions options = new DumperOptions()\n            options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK)\n            // default: options.setDefaultScalarStyle(DumperOptions.ScalarStyle.PLAIN)\n            options.setPrettyFlow(true)\n            Yaml yaml = new Yaml(options)\n            String yamlString = yaml.dump(swaggerMap)\n\n            eci.webImpl.sendTextResponse(yamlString, \"application/yaml\", \"${filenameBase}swagger.yaml\")\n        } else {\n            eci.webImpl.sendJsonError(HttpServletResponse.SC_BAD_REQUEST, \"Output type ${outputType} not supported\", null)\n        }\n    }\n\n    static void handleServiceRestRaml(ExecutionContextImpl eci, List<String> extraPathNameList, String linkPrefix) {\n        if (extraPathNameList.size() == 0) {\n            eci.webImpl.sendJsonError(HttpServletResponse.SC_BAD_REQUEST, \"No root resource name specified in path\", null)\n            return\n        }\n        String rootResourceName = extraPathNameList.get(0)\n        if (rootResourceName.endsWith(\".raml\")) rootResourceName = rootResourceName.substring(0, rootResourceName.length() - 5)\n\n        Map swaggerMap = eci.serviceFacade.restApi.getRamlMap(rootResourceName, linkPrefix)\n        DumperOptions options = new DumperOptions()\n        options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK)\n        // default: options.setDefaultScalarStyle(DumperOptions.ScalarStyle.PLAIN)\n        options.setPrettyFlow(true)\n        Yaml yaml = new Yaml(options)\n        String yamlString = yaml.dump(swaggerMap)\n        // add beginning line \"#%RAML 1.0\", more efficient way to do this?\n        yamlString = \"#%RAML 1.0\\n\" + yamlString\n\n        eci.webImpl.sendTextResponse(yamlString, \"application/raml+yaml\", \"${rootResourceName}.raml\")\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/util/SimpleSgmlReader.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.util\n\nimport groovy.transform.CompileStatic\nimport org.apache.commons.validator.routines.CalendarValidator\nimport org.moqui.util.StringUtilities\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.sql.Timestamp\n\n/** A Simple SGML parser. Doesn't validate, doesn't support attributes, and has some hacks for easier OFX V1 parsing with\n * the colon separated header. */\n@CompileStatic\nclass SimpleSgmlReader {\n    protected final static Logger logger = LoggerFactory.getLogger(SimpleSgmlReader.class)\n\n    final static CalendarValidator calendarValidator = new CalendarValidator()\n\n    protected Map<String, String> headerMap = null\n    protected Node rootNode = null\n\n    private String sgml\n    private int length = 0\n    private int pos = 0\n\n    SimpleSgmlReader(String sgml) {\n        this.sgml = sgml\n        if (!sgml) return\n        this.length = sgml.length()\n\n        // parse the header key/value pairs (one per line, colon separated)\n        int firstLt = sgml.indexOf(lt)\n        if (firstLt > 0) {\n            String headerStr = sgml.substring(0, firstLt).trim()\n            headerMap = [:]\n            if (headerStr) {\n                for (String line in headerStr.split('\\\\s')) {\n                    if (!line) continue\n                    int colonIndex = line.indexOf(':')\n                    if (colonIndex > 0 && line.length() > (colonIndex + 1)) {\n                        String key = line.substring(0, colonIndex).trim()\n                        String value = line.substring(colonIndex + 1).trim()\n                        headerMap.put(key, value)\n                    }\n                }\n            }\n        }\n\n        pos = firstLt\n        rootNode = startRoot()\n    }\n\n    Map<String, String> getHeader() { return headerMap }\n    Node getRoot() { return rootNode }\n\n    static final int slash = ('/' as char) as int\n    static final int lt = ('<' as char) as int\n    static final int gt = ('>' as char) as int\n    protected Node startRoot() {\n        // move pos to first char of element name\n        pos++\n        // find close of tag (>)\n        int gtIndex = sgml.indexOf(gt, pos)\n        if (gtIndex == -1) return null\n        String nodeName = sgml.substring(pos, gtIndex).trim()\n        Node rootNode = new Node(null, nodeName)\n        pos = gtIndex + 1\n\n        // assume root Node has child nodes (not value)\n        int nextLtIndex = sgml.indexOf(lt, pos)\n        if (nextLtIndex == -1) return null\n        pos = nextLtIndex\n        handleChildren(rootNode)\n\n        return rootNode\n    }\n    protected void handleChildren(Node parent) {\n        // child elements\n        while (pos < length && sgml.charAt(pos) == lt && sgml.charAt(pos + 1) != slash) {\n            startNode(parent)\n        }\n        // close tag\n        if (pos < length && sgml.charAt(pos) == lt && sgml.charAt(pos + 1) == slash) {\n            int closeGtIndex = sgml.indexOf(gt, pos + 2)\n            if (closeGtIndex > pos) pos = closeGtIndex + 1\n\n            // at this point pos is after the close of the tag, see if there is a next element, will be a sibling\n            int nextLtIndex = sgml.indexOf(lt, pos)\n            if (nextLtIndex == -1) pos = length else pos = nextLtIndex\n        }\n    }\n    protected void startNode(Node parent) {\n        // move pos to first char of element name\n        pos++\n        // find close of tag (>)\n        // TODO: handle self-closing element\n        int gtIndex = sgml.indexOf(gt, pos)\n        if (gtIndex == -1) return\n        String nodeName = sgml.substring(pos, gtIndex).trim()\n        Node curNode = new Node(parent, nodeName)\n        pos = gtIndex + 1\n        skipWhitespace()\n        if (pos == length) return\n\n        // find next element or close element, look for element value text\n        int ltIndex = sgml.indexOf(lt, pos)\n        if (ltIndex == -1) ltIndex = length\n        String value = null\n        if (ltIndex > pos) {\n            value = sgml.substring(pos, ltIndex).trim()\n            if (value) {\n                value = StringUtilities.decodeFromXml(value)\n                curNode.setValue(value)\n            }\n            pos = ltIndex\n        }\n        if (pos == length) return\n        // if no value element has children, otherwise we've got the value so just return\n        if (!value) handleChildren(curNode)\n    }\n\n    protected void skipWhitespace() { while (pos < length && Character.isWhitespace(sgml.charAt(pos))) pos++ }\n\n    /** possible formats include: yyyyMMdd (GMT), yyyyMMddHHmmss (GMT), yyyyMMddHHmmss.SSS (GMT), yyyyMMddHHmmss.SSS[-5:EST] */\n    static Timestamp parseOfxDateTime(String ofxStr) {\n        if (ofxStr.length() <= 18) {\n            while (ofxStr.length() < 14) { ofxStr = ofxStr + \"0\" }\n            if (ofxStr.length() < 15) { ofxStr = ofxStr + \".\" }\n            while (ofxStr.length() < 18) { ofxStr = ofxStr + \"0\" }\n            ofxStr = ofxStr + \" +0000\"\n        } else {\n            // has a time zone, strip it and create an RFC 822 time zone (like -0500)\n            int openBraceIndex = ofxStr.indexOf('[')\n            String tzStr = ofxStr.substring(openBraceIndex + 1, ofxStr.indexOf(':', openBraceIndex))\n            if (Character.isDigit(tzStr.charAt(0))) tzStr = '+' + tzStr\n            if (tzStr.length() == 2) tzStr = tzStr[0] + '0' + tzStr[1]\n            String dtStr = ofxStr.substring(0, openBraceIndex)\n            while (dtStr.length() < 14) { dtStr = dtStr + \"0\" }\n            if (dtStr.length() < 15) { dtStr = dtStr + \".\" }\n            while (dtStr.length() < 18) { dtStr = dtStr + \"0\" }\n            ofxStr = \"${dtStr} ${tzStr}00\"\n        }\n        Calendar cal = calendarValidator.validate(ofxStr, 'yyyyMMddHHmmss.SSS Z', null, null)\n        return cal ? new Timestamp(cal.getTimeInMillis()) : null\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/util/SimpleSigner.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.util;\n\nimport org.moqui.BaseException;\nimport org.moqui.util.ObjectUtilities;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.security.KeyFactory;\nimport java.security.PrivateKey;\nimport java.security.Signature;\nimport java.security.spec.PKCS8EncodedKeySpec;\nimport java.util.Base64;\n\npublic class SimpleSigner {\n    protected final static Logger logger = LoggerFactory.getLogger(SimpleSigner.class);\n\n    private String keyResource, keyType = \"RSA\", signatureType = \"SHA1withRSA\";\n    private PrivateKey key = null;\n\n    public SimpleSigner(String keyResource) {\n        this.keyResource = keyResource;\n        initKey();\n    }\n    public SimpleSigner(String keyResource, String keyType, String signatureType) {\n        this.keyResource = keyResource;\n        if (keyType != null) this.keyType = keyType;\n        if (signatureType != null) this.signatureType = signatureType;\n        initKey();\n    }\n\n    public String sign(String data) throws Exception {\n        if (key == null) throw new BaseException(\"Cannot sign message, key could not be loaded from resource \" + keyResource);\n        Signature signature = Signature.getInstance(signatureType);\n        signature.initSign(key);\n        signature.update(data.getBytes());\n        return Base64.getEncoder().encodeToString(signature.sign());\n    }\n\n    private void initKey() {\n        try {\n            byte[] keyData = readKey(keyResource);\n            PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyData);\n            KeyFactory kf = KeyFactory.getInstance(keyType);\n            key = kf.generatePrivate(keySpec);\n        } catch (Exception e) {\n            logger.warn(\"Could not initialize signing key \" + keyResource + \": \" + e.toString());\n        }\n    }\n    public static byte[] readKey(String resourcePath) throws IOException {\n        InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(resourcePath);\n        if (is == null) throw new BaseException(\"Could not find signing key resource \" + resourcePath + \" on classpath\");\n\n        String keyData = ObjectUtilities.getStreamText(is);\n\n        StringBuilder sb = new StringBuilder();\n        String[] lines = keyData.split(\"\\n\");\n        String[] skips = new String[]{\"-----BEGIN\", \"-----END\", \": \"};\n        for (String line : lines) {\n            boolean skipLine = false;\n            for (String skip : skips) if (line.contains(skip)) { skipLine = true; }\n            if (!skipLine) sb.append(line.trim());\n        }\n        return Base64.getDecoder().decode(sb.toString());\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/webapp/ElasticRequestLogFilter.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.webapp\n\nimport groovy.transform.CompileStatic\nimport org.moqui.Moqui\nimport org.moqui.impl.context.ElasticFacadeImpl.ElasticClientImpl\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.UserFacadeImpl\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport jakarta.servlet.*\nimport jakarta.servlet.http.HttpServletRequest\nimport jakarta.servlet.http.HttpServletResponse\nimport jakarta.servlet.http.HttpServletResponseWrapper\nimport jakarta.servlet.http.HttpSession\nimport java.util.concurrent.ConcurrentLinkedQueue\n\n/** Save data about HTTP requests to ElasticSearch using a Servlet Filter */\n@CompileStatic\nclass ElasticRequestLogFilter implements Filter {\n    protected final static Logger logger = LoggerFactory.getLogger(ElasticRequestLogFilter.class)\n    final static String INDEX_NAME = \"moqui_http_log\"\n    // final static String DOC_TYPE = \"MoquiHttpRequest\"\n\n    protected FilterConfig filterConfig = null\n    protected ExecutionContextFactoryImpl ecfi = null\n\n    private ElasticClientImpl elasticClient = null\n    private boolean disabled = false\n    final ConcurrentLinkedQueue<Map> requestLogQueue = new ConcurrentLinkedQueue<>()\n\n    ElasticRequestLogFilter() { super() }\n\n    @Override\n    void init(FilterConfig filterConfig) throws ServletException {\n        this.filterConfig = filterConfig\n\n        ecfi = (ExecutionContextFactoryImpl) filterConfig.servletContext.getAttribute(\"executionContextFactory\")\n        if (ecfi == null) ecfi = (ExecutionContextFactoryImpl) Moqui.executionContextFactory\n\n        elasticClient = (ElasticClientImpl) (ecfi.elasticFacade.getClient(\"logger\") ?: ecfi.elasticFacade.getDefault())\n        if (elasticClient == null) {\n            logger.error(\"In ElasticRequestLogFilter init could not find ElasticClient with name logger or default, not starting\")\n            return\n        }\n        if (elasticClient.esVersionUnder7) {\n            logger.warn(\"ElasticClient ${elasticClient.clusterName} has version under 7.0, not starting ElasticRequestLogFilter\")\n            return\n        }\n\n        // check for index exists, create with mapping for log doc if not\n        try {\n            boolean hasIndex = elasticClient.indexExists(INDEX_NAME)\n            if (!hasIndex) elasticClient.createIndex(INDEX_NAME, docMapping, null)\n        } catch (Exception e) {\n            logger.error(\"Error checking and creating ${INDEX_NAME} ES index, not starting ElasticRequestLogFilter\", e)\n            return\n        }\n\n        RequestLogQueueFlush rlqf = new RequestLogQueueFlush(this)\n        ecfi.scheduleAtFixedRate(rlqf, 15, 5)\n    }\n\n    // TODO: add geoip (see https://www.elastic.co/guide/en/logstash/current/plugins-filters-geoip.html)\n    // TODO: add user_agent (see https://www.elastic.co/guide/en/logstash/current/plugins-filters-useragent.html)\n\n    final static Map docMapping = [properties:[\n            '@timestamp':[type:'date', format:'epoch_millis'], remote_ip:[type:'ip'], remote_user:[type:'keyword'],\n            server_ip:[type:'keyword'], content_type:[type:'text'],\n            request_method:[type:'keyword'], request_scheme:[type:'keyword'], request_host:[type:'keyword'],\n            request_path:[type:'text'], request_query:[type:'text'], http_version:[type:'half_float'], response:[type:'short'],\n            time_initial_ms:[type:'integer'], time_final_ms:[type:'integer'], bytes:[type:'long'],\n            referrer:[type:'text'], agent:[type:'text'], session:[type:'keyword'], visitor_id:[type:'keyword']\n        ]\n    ]\n\n    @Override\n    void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {\n        long startTime = System.currentTimeMillis()\n\n        if (elasticClient == null || disabled || !DispatcherType.REQUEST.is(req.getDispatcherType()) ||\n                !(req instanceof HttpServletRequest) || !(resp instanceof HttpServletResponse)) {\n            chain.doFilter(req, resp)\n            return\n        }\n\n        HttpServletRequest request = (HttpServletRequest) req\n        HttpServletResponse response = (HttpServletResponse) resp\n        CountingHttpServletResponseWrapper responseWrapper = (CountingHttpServletResponseWrapper) null\n        try {\n            responseWrapper = new CountingHttpServletResponseWrapper(response)\n        } catch (Exception e) {\n            logger.warn(\"Error initializing CountingHttpServletResponseWrapper\", e)\n        }\n        // chain first so response is run\n        if (responseWrapper != null) {\n            chain.doFilter(req, responseWrapper)\n        } else {\n            chain.doFilter(req, resp)\n        }\n\n        if (request.isAsyncStarted()) {\n            request.getAsyncContext().addListener(new RequestLogAsyncListener(this, startTime), req, responseWrapper != null ? responseWrapper : response)\n        } else {\n            logRequest(request, responseWrapper != null ? responseWrapper : response, startTime)\n        }\n    }\n\n    void logRequest(HttpServletRequest request, HttpServletResponse response, long startTime) {\n        long initialTime = System.currentTimeMillis() - startTime\n        // always flush the buffer so we can get the final time; this is for some reason NECESSARY for the wrapper otherwise content doesn't make it through\n        response.flushBuffer()\n\n        String clientIp = UserFacadeImpl.getClientIp(request, null, ecfi)\n        String serverIp = request.getLocalAddr()\n        // IPv6 addresses sometimes have square braces around them, ElasticSearch doesn't like that so strip them if found\n        // NOTE: clientIp already has square braces removed by getClientIp()\n        if (serverIp != null && !serverIp.isEmpty()) {\n            if (serverIp.charAt(0) == (char) '[') serverIp = serverIp.substring(1)\n            if (serverIp.charAt(serverIp.length() - 1) == (char) ']')\n                serverIp = serverIp.substring(0, serverIp.length() - 1)\n        }\n\n        // IPv6 addresses have square braces but ElasticSearch doesn't like them, so if there are any get rid of them\n\n        float httpVersion = 0.0\n        String protocol = request.getProtocol().trim()\n        int psIdx = protocol.indexOf(\"/\")\n        if (psIdx > 0) try { httpVersion = Float.parseFloat(protocol.substring(psIdx + 1)) } catch (Exception e) { }\n\n        // get response size, only way to wrap the response with wrappers for Writer and OutputStream to count size? messy, slow...\n        long written = 0L\n        if (response instanceof CountingHttpServletResponseWrapper) written = ((CountingHttpServletResponseWrapper) response).getWritten()\n\n        HttpSession session = request.getSession(false)\n\n        // final time after streaming response (ie flush response)\n        long finalTime = System.currentTimeMillis() - startTime\n\n        Map reqMap = ['@timestamp':startTime, remote_ip:clientIp, remote_user:request.getRemoteUser(),\n                server_ip:serverIp, content_type:response.getContentType(),\n                request_method:request.getMethod(), request_scheme:request.getScheme(), request_host:request.getServerName(),\n                request_path:request.getRequestURI(), request_query:request.getQueryString(), http_version:httpVersion,\n                response:response.getStatus(), time_initial_ms:initialTime, time_final_ms:finalTime, bytes:written,\n                referrer:request.getHeader(\"Referer\"), agent:request.getHeader(\"User-Agent\"),\n                session:session?.getId(), visitor_id:session?.getAttribute(\"moqui.visitorId\")]\n        requestLogQueue.add(reqMap)\n        // logger.info(\"${request.getMethod()} ${request.getRequestURI()} - ${response.getStatus()} ${finalTime}ms ${written}b asyncs ${request.isAsyncStarted()}\\n${reqMap}\")\n    }\n\n    @Override void destroy() { }\n\n    static class RequestLogQueueFlush implements Runnable {\n        final static int maxCreates = 50\n        final ElasticRequestLogFilter filter\n\n        RequestLogQueueFlush(ElasticRequestLogFilter filter) { this.filter = filter }\n\n        @Override synchronized void run() {\n            while (filter.requestLogQueue.size() > 0) { flushQueue() }\n        }\n        void flushQueue() {\n            final ConcurrentLinkedQueue<Map> queue = filter.requestLogQueue\n            ArrayList<Map> createList = new ArrayList<>(maxCreates)\n            int createCount = 0\n            while (createCount < maxCreates) {\n                Map message = queue.poll()\n                if (message == null) break\n                // increment the count and add the message\n                createCount++\n                createList.add(message)\n            }\n            int retryCount = 5\n            while (retryCount > 0) {\n                int createListSize = createList.size()\n                if (createListSize == 0) break\n                try {\n                    // long startTime = System.currentTimeMillis()\n                    try {\n                        filter.elasticClient.bulkIndex(INDEX_NAME, null, createList)\n                    } catch (Exception e) {\n                        logger.error(\"Error logging to ElasticSearch: ${e.toString()}\")\n                    }\n                    // logger.warn(\"Indexed ${createListSize} ElasticSearch log messages in ${System.currentTimeMillis() - startTime}ms\")\n                    break\n                } catch (Throwable t) {\n                    logger.error(\"Error indexing ElasticSearch log messages, retrying (${retryCount}): ${t.toString()}\")\n                    retryCount--\n                }\n            }\n        }\n    }\n\n    static class RequestLogAsyncListener implements AsyncListener {\n        ElasticRequestLogFilter filter\n        private long startTime\n        RequestLogAsyncListener(ElasticRequestLogFilter filter, long startTime) { this.filter = filter; this.startTime = startTime }\n\n        @Override void onComplete(AsyncEvent event) throws IOException { logEvent(event) }\n        @Override void onTimeout(AsyncEvent event) throws IOException { logEvent(event) }\n        @Override void onError(AsyncEvent event) throws IOException { logEvent(event) }\n        @Override void onStartAsync(AsyncEvent event) throws IOException { }\n\n        void logEvent(AsyncEvent event) {\n            if (event.getSuppliedRequest() instanceof HttpServletRequest && event.getSuppliedResponse() instanceof HttpServletResponse) {\n                filter.logRequest((HttpServletRequest) event.getSuppliedRequest(), (HttpServletResponse) event.getSuppliedResponse(), startTime)\n            }\n        }\n    }\n\n    class CountingHttpServletResponseWrapper extends HttpServletResponseWrapper {\n        private OutputStreamCounter outputStream = null;\n        private PrintWriter writer = null;\n\n        CountingHttpServletResponseWrapper(HttpServletResponse response) throws IOException { super(response); }\n        long getWritten() { return outputStream != null ? outputStream.getWritten() : 0; }\n\n        @Override synchronized ServletOutputStream getOutputStream() throws IOException {\n            if (writer != null) throw new IllegalStateException(\"getWriter() already called\");\n            if (outputStream == null) outputStream = new OutputStreamCounter(super.getOutputStream());\n            return outputStream;\n        }\n\n        @Override synchronized PrintWriter getWriter() throws IOException {\n            if (writer == null && outputStream != null) throw new IllegalStateException(\"getOutputStream() already called\");\n            if (writer == null) {\n                outputStream = new OutputStreamCounter(super.getOutputStream());\n                writer = new PrintWriter(new OutputStreamWriter(outputStream, getCharacterEncoding()));\n            }\n            return this.writer;\n        }\n\n        @Override void flushBuffer() throws IOException {\n            if (writer != null) writer.flush();\n            else if (outputStream != null) outputStream.flush();\n            super.flushBuffer();\n        }\n\n        static class OutputStreamCounter extends ServletOutputStream {\n            private long written = 0;\n            private ServletOutputStream inner;\n            OutputStreamCounter(ServletOutputStream inner) { this.inner = inner; }\n            long getWritten() { return written; }\n\n            @Override void close() throws IOException { inner.close(); }\n            @Override void flush() throws IOException { inner.flush(); }\n\n            @Override void write(byte[] b) throws IOException { write(b, 0, b.length); }\n            @Override void write(byte[] b, int off, int len) throws IOException { inner.write(b, off, len); written += len; }\n            @Override void write(int b) throws IOException { inner.write(b); written++; }\n\n            @Override boolean isReady() { return inner.isReady(); }\n            @Override void setWriteListener(WriteListener writeListener) { inner.setWriteListener(writeListener); }\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/webapp/GroovyShellEndpoint.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.webapp\n\nimport java.io.PrintWriter\nimport java.io.Writer\nimport java.util.concurrent.ExecutorService\nimport java.util.concurrent.Executors\nimport java.util.concurrent.ScheduledExecutorService\nimport java.util.concurrent.ScheduledFuture\nimport java.util.concurrent.TimeUnit\n\nimport jakarta.websocket.CloseReason\nimport jakarta.websocket.EndpointConfig\nimport jakarta.websocket.Session\n\nimport groovy.lang.GroovyShell\nimport groovy.transform.CompileStatic\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport org.moqui.impl.context.ExecutionContextImpl\n\n@CompileStatic\nclass GroovyShellEndpoint extends MoquiAbstractEndpoint {\n    private static final Logger logger = LoggerFactory.getLogger(GroovyShellEndpoint.class)\n    private static final int idleSeconds = 300\n    private static final int maxEvalSeconds = 900 // kill infinite loops\n\n    ExecutionContextImpl eci\n    GroovyShell groovyShell\n    ExecutorService exec\n    ScheduledExecutorService scheduler\n    ScheduledFuture<?> idleTask\n    ScheduledFuture<?> evalTimeoutTask\n    volatile boolean closing = false\n\n    GroovyShellEndpoint() { super() }\n\n    @Override\n    void onOpen(Session session, EndpointConfig config) {\n        this.destroyInitialEci = false\n        super.onOpen(session, config)\n        logger.info(\"Opening GroovyShellEndpoint session ${session.getId()} for user ${userId}:${username}\")\n        eci = ecf.getEci()\n        if (!eci.userFacade.hasPermission(\"GROOVY_SHELL_WEB\")) {\n            throw new IllegalAccessException(\"User ${username} does not have permission to use Groovy Shell\")\n        }\n        exec = Executors.newSingleThreadExecutor(Thread.ofVirtual().name(\"GroovyShell-\" + session.getId(), 0).factory())\n        scheduler = Executors.newSingleThreadScheduledExecutor()\n        Writer wsWriter = new WsWriter(session)\n        def binding = eci.getContextBinding()\n        binding.setVariable(\"out\", new PrintWriter(wsWriter, true))\n        binding.setVariable(\"err\", new PrintWriter(wsWriter, true))\n        groovyShell = new GroovyShell(ecf.classLoader, binding)\n        resetIdleTimer()\n    }\n\n    @Override\n    void onMessage(String message) {\n        if (closing || groovyShell == null) return\n        String trimmed = message?.trim()\n        if (trimmed == ':exit') {\n            try {\n                checkSend(\"Ending session.${System.lineSeparator()}\")\n                session.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, \"Client requested exit\"))\n            } catch (Throwable t) {\n                logger.trace(\"Error in closing session\", t)\n            }\n            return\n        }\n        suspendIdleTimer()\n        try {\n            exec.submit {\n                scheduleEvalTimeout()\n                registerEci()\n                eci.artifactExecutionFacade.disableAuthz()\n                try {\n                    Object result = groovyShell.evaluate(message)\n                    checkSend(result.toString() + System.lineSeparator())\n                } catch (Throwable t) {\n                    logger.error(\"GroovyShell evaluation error\", t)\n                    checkSend(\"ERROR: ${t.class.simpleName}: ${t.message}${System.lineSeparator()}\")\n                } finally {\n                    try {\n                        if (!closing) eci.artifactExecutionFacade.enableAuthz()\n                    } finally {\n                        if (!closing) deregisterEci()\n                        cancelEvalTimeout()\n                        resumeIdleTimer()\n                    }\n                }\n            }\n        } catch (Throwable t) {\n            resumeIdleTimer()\n        }\n    }\n\n    @Override\n    void onClose(Session session, CloseReason closeReason) {\n        if (closing) {\n            super.onClose(session, closeReason)\n            return\n        }\n        closing = true\n        logger.info(\"Closing GroovyShellEndpoint session ${session.getId()} for user ${userId}:${username}\")\n        try {\n            idleTask?.cancel(false)\n            evalTimeoutTask?.cancel(false)\n            scheduler?.shutdownNow()\n            exec?.shutdownNow()\n            groovyShell = null\n            if (eci != null) {\n                try {\n                    eci.destroy()\n                } catch (Throwable t) {\n                    logger.error(\"Error destroying ExecutionContext in GroovyShellEndpoint\", t)\n                } finally {\n                    eci = null\n                }\n            }\n        } finally {\n            super.onClose(session, closeReason)\n        }\n    }\n\n    /**\n     * Bind this session's ExecutionContext to the current executor thread\n     * for the duration of a single Groovy evaluation.\n     */\n    void registerEci() {\n        ExecutionContextImpl activeEc = ecfi.activeContext.get()\n        if (activeEc != null && activeEc != eci) {\n            logger.warn(\"Foreign ExecutionContext found on thread; clearing ThreadLocal only\")\n            ecfi.activeContext.remove()\n            ecfi.activeContextMap.remove(Thread.currentThread().threadId())\n        }\n        eci.forThreadId = Thread.currentThread().threadId()\n        eci.forThreadName = Thread.currentThread().getName()\n        ecfi.activeContext.set(eci)\n        ecfi.activeContextMap.put(Thread.currentThread().threadId(), eci)\n    }\n\n    /**\n     * Remove the ExecutionContext from the executor thread after evaluation\n     * to avoid leaking it to the next task on the same thread.\n     */\n    void deregisterEci() {\n        ecfi.activeContext.remove()\n        ecfi.activeContextMap.remove(Thread.currentThread().threadId())\n    }\n\n    void resetIdleTimer() {\n        if (closing || scheduler == null || scheduler.isShutdown()) return\n        idleTask?.cancel(false)\n        try {\n            idleTask = scheduler.schedule({\n                try {\n                   if (session?.isOpen()) {\n                        session.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, \"Idle timeout\"))\n                    }\n                } catch (Throwable t) {\n                    logger.trace('Error in closing session', t)\n                }\n            }, idleSeconds, TimeUnit.SECONDS)\n        } catch (Throwable ignored) { }\n    }\n\n    void suspendIdleTimer() {\n        idleTask?.cancel(false)\n        idleTask = null\n    }\n\n    void resumeIdleTimer() {\n        resetIdleTimer()\n    }\n\n    void scheduleEvalTimeout() {\n        if (closing || scheduler == null || scheduler.isShutdown()) return\n        evalTimeoutTask?.cancel(false)\n        try {\n            evalTimeoutTask = scheduler.schedule({\n                try {\n                    if (session?.isOpen()) {\n                        checkSend(\"ERROR: Evaluation exceeded ${maxEvalSeconds} seconds, closing session.${System.lineSeparator()}\")\n                        session.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, \"Evaluation timeout\"))\n                    }\n                } catch (Throwable ignored) { }\n            }, maxEvalSeconds, TimeUnit.SECONDS)\n        } catch (Throwable ignored) { }\n    }\n\n    void cancelEvalTimeout() {\n        evalTimeoutTask?.cancel(false)\n        evalTimeoutTask = null\n    }\n\n    void checkSend(String text) {\n        try {\n            if (session?.isOpen()) {\n                session.asyncRemote.sendText(text)\n            }\n        } catch (Throwable ignored) { }\n    }\n\n    static class WsWriter extends Writer {\n        final Session session\n        WsWriter(Session session) { this.session = session }\n        @Override\n        void write(char[] cbuf, int off, int len) {\n            try {\n                if (session?.isOpen()) {\n                    session.asyncRemote.sendText(new String(cbuf, off, len))\n                }\n            } catch (Throwable ignored) { }\n        }\n        @Override void flush() throws IOException { }\n        @Override void close() throws IOException { }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/webapp/MoquiAbstractEndpoint.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.webapp\n\nimport groovy.transform.CompileStatic\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport jakarta.servlet.http.HttpSession\n\nimport jakarta.websocket.*\nimport jakarta.websocket.server.HandshakeRequest\n\n/**\n * An abstract class for WebSocket Endpoint that does basic setup, including creating an ExecutionContext with the user\n * logged if they were logged in for the corresponding HttpSession (based on the WebSocket HandshakeRequest, ie the\n * HTTP upgrade request, tied to an existing HttpSession).\n *\n * The main method to implement is the onMessage(String) method.\n *\n * If you override the onOpen() method call the super method first.\n * If you override the onClose() method call the super method last (will clear out all internal fields).\n */\n@CompileStatic\nabstract class MoquiAbstractEndpoint extends Endpoint implements MessageHandler.Whole<String> {\n    private final static Logger logger = LoggerFactory.getLogger(MoquiAbstractEndpoint.class)\n\n    protected ExecutionContextFactoryImpl ecfi = (ExecutionContextFactoryImpl) null\n    protected Session session = (Session) null\n    protected HttpSession httpSession = (HttpSession) null\n    protected HandshakeRequest handshakeRequest = (HandshakeRequest) null\n    protected String userId = (String) null\n    protected String username = (String) null\n    protected boolean destroyInitialEci = true\n\n    MoquiAbstractEndpoint() { super() }\n\n    ExecutionContextFactoryImpl getEcf() { return ecfi }\n    HttpSession getHttpSession() { return httpSession }\n    Session getSession() { return session }\n    String getUserId() { return userId }\n    String getUsername() { return username }\n\n    @Override\n    void onOpen(Session session, EndpointConfig config) {\n        this.session = session\n        ecfi = (ExecutionContextFactoryImpl) config.userProperties.get(\"executionContextFactory\")\n        handshakeRequest = (HandshakeRequest) config.userProperties.get(\"handshakeRequest\")\n        // Jetty 12 EE 11 bug https://github.com/jetty/jetty.project/issues/11809\n        // httpSession = handshakeRequest != null ? (HttpSession) handshakeRequest.getHttpSession() : (HttpSession) config.userProperties.get(\"httpSession\")\n        httpSession = (HttpSession) config.userProperties.get(\"httpSession\")\n        ExecutionContextImpl eci = ecfi.getEci()\n        try {\n            if (httpSession != null) {\n                eci.userFacade.initFromHttpSession(httpSession)\n            } else if (handshakeRequest != null) {\n                eci.userFacade.initFromHandshakeRequest(handshakeRequest)\n            } else {\n                logger.warn(\"No HandshakeRequest or HttpSession found opening WebSocket Session ${session.id}, not logging in user\")\n            }\n\n            userId = eci.user.userId\n            username = eci.user.username\n\n            Long timeout = (Long) config.userProperties.get(\"maxIdleTimeout\")\n            if (timeout != null && session.getMaxIdleTimeout() > 0 && session.getMaxIdleTimeout() < timeout)\n                session.setMaxIdleTimeout(timeout)\n\n            session.addMessageHandler(this)\n\n            if (logger.isTraceEnabled()) logger.trace(\"Opened WebSocket Session ${session.getId()}, userId: ${userId} (${username}), timeout: ${session.getMaxIdleTimeout()}ms\")\n        } finally {\n            if (eci != null && destroyInitialEci) {\n                eci.destroy()\n            }\n        }\n        /*\n        logger.info(\"Opened WebSocket Session ${session.getId()}, parameters: ${session.getRequestParameterMap()}, username: ${session.getUserPrincipal()?.getName()}, config props: ${config.userProperties}\")\n        for (String attrName in httpSession.getAttributeNames())\n            logger.info(\"WebSocket Session ${session.getId()}, session attribute: ${attrName}=${httpSession.getAttribute(attrName)}\")\n        */\n    }\n\n    @Override\n    abstract void onMessage(String message)\n\n    @Override\n    void onClose(Session session, CloseReason closeReason) {\n        this.session = null\n        this.httpSession = null\n        this.handshakeRequest = null\n        this.ecfi = null\n        if (logger.isTraceEnabled()) logger.trace(\"Closed WebSocket Session ${session.getId()}: ${closeReason.reasonPhrase}\")\n    }\n\n    @Override\n    void onError(Session session, Throwable thr) {\n        if (thr instanceof SocketTimeoutException || (thr.getMessage() != null && thr.getMessage().toLowerCase().contains(\"timeout\"))) {\n            logger.info(\"Timeout in WebSocket Session ${session.getId()}, User ${userId} (${username}): ${thr.getMessage()}\")\n        } else {\n            logger.warn(\"Error in WebSocket Session ${session.getId()}, User ${userId} (${username})\", thr)\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/webapp/MoquiAuthFilter.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.webapp\n\nimport groovy.transform.CompileStatic\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.impl.context.UserFacadeImpl\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport jakarta.servlet.Filter\nimport jakarta.servlet.FilterChain\nimport jakarta.servlet.FilterConfig\nimport jakarta.servlet.ServletContext\nimport jakarta.servlet.ServletException\nimport jakarta.servlet.ServletRequest\nimport jakarta.servlet.ServletResponse\nimport jakarta.servlet.http.HttpServletRequest\nimport jakarta.servlet.http.HttpServletResponse\n\n/** Check authentication and permission for servlets other than MoquiServlet, MoquiFopServlet.\n * Specify permission to check in 'permission' init-param. */\n@CompileStatic\nclass MoquiAuthFilter implements Filter {\n    protected final static Logger logger = LoggerFactory.getLogger(MoquiAuthFilter.class)\n\n    protected FilterConfig filterConfig = null\n    protected String permission = null\n\n    MoquiAuthFilter() { super() }\n\n    @Override\n    void init(FilterConfig filterConfig) throws ServletException {\n        this.filterConfig = filterConfig\n        permission = filterConfig.getInitParameter(\"permission\")\n    }\n\n    @Override\n    void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException {\n        if (!(req instanceof HttpServletRequest) || !(resp instanceof HttpServletResponse)) { chain.doFilter(req, resp); return }\n        HttpServletRequest request = (HttpServletRequest) req\n        HttpServletResponse response = (HttpServletResponse) resp\n        // HttpSession session = request.getSession()\n        ServletContext servletContext = req.getServletContext()\n\n        ExecutionContextFactoryImpl ecfi = (ExecutionContextFactoryImpl) servletContext.getAttribute(\"executionContextFactory\")\n        // check for and cleanly handle when executionContextFactory is not in place in ServletContext attr\n        if (ecfi == null) {\n            response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, \"System is initializing, try again soon.\")\n            return\n        }\n        ExecutionContextImpl activeEc = ecfi.activeContext.get()\n        if (activeEc != null) {\n            logger.warn(\"In MoquiAuthFilter.doFilter there is already an ExecutionContext for user ${activeEc.user.username} (from ${activeEc.forThreadId}:${activeEc.forThreadName}) in this thread (${Thread.currentThread().id}:${Thread.currentThread().name}), destroying\")\n            activeEc.destroy()\n        }\n\n        ExecutionContextImpl ec = ecfi.getEci()\n        try {\n            UserFacadeImpl ufi = ec.userFacade\n            ufi.initFromHttpRequest(request, response)\n\n            if (!ufi.username) {\n                String message = ec.messageFacade.getErrorsString()\n                if (!message) message = \"Authentication required\"\n                response.sendError(HttpServletResponse.SC_UNAUTHORIZED, message)\n                return\n            }\n\n            if (permission && !ufi.hasPermission(permission)) {\n                response.sendError(HttpServletResponse.SC_FORBIDDEN, \"User ${ufi.username} does not have permission ${permission}\")\n                return\n            }\n\n            chain.doFilter(req, resp)\n        } finally {\n            ec.destroy()\n        }\n    }\n\n    @Override\n    void destroy() {  }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/webapp/MoquiContextListener.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.webapp\n\nimport groovy.transform.CompileStatic\n\nimport jakarta.servlet.DispatcherType\nimport jakarta.servlet.Filter\nimport jakarta.servlet.FilterRegistration\nimport jakarta.servlet.Servlet\nimport jakarta.servlet.ServletContext\nimport jakarta.servlet.ServletContextEvent\nimport jakarta.servlet.ServletContextListener\nimport jakarta.servlet.ServletRegistration\n\nimport jakarta.websocket.HandshakeResponse\nimport jakarta.websocket.server.HandshakeRequest\nimport jakarta.websocket.server.ServerContainer\nimport jakarta.websocket.server.ServerEndpointConfig\n\nimport org.moqui.Moqui\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextFactoryImpl.WebappInfo\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.util.MNode\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n@CompileStatic\nclass MoquiContextListener implements ServletContextListener {\n    protected final static Logger logger = LoggerFactory.getLogger(MoquiContextListener.class)\n\n    protected static String getId(ServletContext sc) {\n        String contextPath = sc.getContextPath()\n        return contextPath.length() > 1 ? contextPath.substring(1) : \"ROOT\"\n    }\n\n    protected ExecutionContextFactoryImpl ecfi = null\n\n    @Override\n    void contextInitialized(ServletContextEvent servletContextEvent) {\n        long initStartTime = System.currentTimeMillis()\n\n        try {\n            ServletContext sc = servletContextEvent.servletContext\n            String webappId = getId(sc)\n            String moquiWebappName = sc.getInitParameter(\"moqui-name\")\n\n            // before we init the ECF, see if there is a runtime directory in the webappRealPath, and if so set that as the moqui.runtime System property\n            String webappRealPath = sc.getRealPath(\"/\")\n            String embeddedRuntimePath = webappRealPath + \"/runtime\"\n            if (new File(embeddedRuntimePath).exists()) System.setProperty(\"moqui.runtime\", embeddedRuntimePath)\n\n            logger.info(\"Loading Webapp '${moquiWebappName}' (${sc.getServletContextName()}) on ${webappId}, located at: ${webappRealPath}\")\n\n            ecfi = Moqui.dynamicInit(ExecutionContextFactoryImpl.class, sc)\n\n            // logger.warn(\"ServletContext (\" + (sc != null ? sc.getClass().getName() : \"\") + \":\" + (sc != null && sc.getClass().getClassLoader() != null ? sc.getClass().getClassLoader().getClass().getName() : \"\") + \")\" + \" value: \" + sc)\n\n            WebappInfo wi = ecfi.getWebappInfo(moquiWebappName)\n\n            // add webapp filters\n            List<MNode> filterNodeList = wi.webappNode.children(\"filter\")\n            filterNodeList = (List<MNode>) filterNodeList.sort(false, { Integer.parseInt(it.attribute(\"priority\") ?: '5') })\n            for (MNode filterNode in filterNodeList) {\n                if (filterNode.attribute(\"enabled\") == \"false\") continue\n\n                String filterName = filterNode.attribute(\"name\")\n                try {\n                    Filter filter = (Filter) Thread.currentThread().getContextClassLoader().loadClass(filterNode.attribute(\"class\")).newInstance()\n                    FilterRegistration.Dynamic filterReg = sc.addFilter(filterName, filter)\n                    for (MNode initParamNode in filterNode.children(\"init-param\")) {\n                        initParamNode.setSystemExpandAttributes(true)\n                        filterReg.setInitParameter(initParamNode.attribute(\"name\"), initParamNode.attribute(\"value\") ?: \"\")\n                    }\n\n                    if (\"true\".equals(filterNode.attribute(\"async-supported\"))) filterReg.setAsyncSupported(true)\n\n                    EnumSet<DispatcherType> dispatcherTypes = EnumSet.noneOf(DispatcherType.class)\n                    for (MNode dispatcherNode in filterNode.children(\"dispatcher\")) {\n                        DispatcherType dt = DispatcherType.valueOf(dispatcherNode.getText())\n                        if (dt == null) { logger.warn(\"Got invalid DispatcherType ${dispatcherNode.getText()} for filter ${filterName}\") }\n                        dispatcherTypes.add(dt)\n                    }\n\n                    Set<String> urlPatternSet = new LinkedHashSet<>()\n                    for (MNode urlPatternNode in filterNode.children(\"url-pattern\")) urlPatternSet.add(urlPatternNode.getText())\n                    String[] urlPatterns = urlPatternSet.toArray(new String[urlPatternSet.size()])\n\n                    filterReg.addMappingForUrlPatterns(dispatcherTypes.size() > 0 ? dispatcherTypes : null, false, urlPatterns)\n\n                    logger.info(\"Added webapp filter ${filterName} on: ${urlPatterns}, ${dispatcherTypes}\")\n                } catch (Exception e) {\n                    logger.error(\"Error adding filter ${filterName}\", e)\n                }\n            }\n\n            // add webapp listeners\n            for (MNode listenerNode in wi.webappNode.children(\"listener\")) {\n                if (listenerNode.attribute(\"enabled\") == \"false\") continue\n                String className = listenerNode.attribute(\"class\")\n                try {\n                    EventListener listener = (EventListener) Thread.currentThread().getContextClassLoader().loadClass(className).newInstance()\n                    sc.addListener(listener)\n                    logger.info(\"Added webapp listener ${className}\")\n                } catch (Exception e) {\n                    logger.error(\"Error adding listener ${className}\", e)\n                }\n            }\n\n            // add webapp servlets\n            for (MNode servletNode in wi.webappNode.children(\"servlet\")) {\n                if (servletNode.attribute(\"enabled\") == \"false\") continue\n\n                String servletName = servletNode.attribute(\"name\")\n                try {\n                    Servlet servlet = (Servlet) Thread.currentThread().getContextClassLoader().loadClass(servletNode.attribute(\"class\")).newInstance()\n                    ServletRegistration.Dynamic servletReg = sc.addServlet(servletName, servlet)\n\n                    for (MNode initParamNode in servletNode.children(\"init-param\")) {\n                        initParamNode.setSystemExpandAttributes(true)\n                        servletReg.setInitParameter(initParamNode.attribute(\"name\"), initParamNode.attribute(\"value\") ?: \"\")\n                    }\n\n                    String loadOnStartupStr = servletNode.attribute(\"load-on-startup\") ?: \"1\"\n                    servletReg.setLoadOnStartup(loadOnStartupStr as int)\n\n                    if (\"true\".equals(servletNode.attribute(\"async-supported\"))) servletReg.setAsyncSupported(true)\n\n                    Set<String> urlPatternSet = new LinkedHashSet<>()\n                    for (MNode urlPatternNode in servletNode.children(\"url-pattern\")) urlPatternSet.add(urlPatternNode.getText())\n                    String[] urlPatterns = urlPatternSet.toArray(new String[urlPatternSet.size()])\n\n                    Set<String> alreadyMapped = servletReg.addMapping(urlPatterns)\n                    if (alreadyMapped) logger.warn(\"For servlet ${servletName} to following URL patterns were already mapped: ${alreadyMapped}\")\n\n                    logger.info(\"Added servlet ${servletName} on: ${urlPatterns}\")\n                } catch (Exception e) {\n                    logger.error(\"Error adding servlet ${servletName}\", e)\n                }\n            }\n\n            // NOTE: webapp.session-config.@timeout handled in MoquiSessionListener\n\n            // WebSocket Endpoint Setup\n            ServerContainer wsServer = ecfi.getServerContainer()\n            if (wsServer != null) {\n                logger.info(\"Found WebSocket ServerContainer ${wsServer.class.name}\")\n                if (wi.webappNode.attribute(\"websocket-timeout\"))\n                    wsServer.setDefaultMaxSessionIdleTimeout(Long.valueOf(wi.webappNode.attribute(\"websocket-timeout\")))\n\n                for (MNode endpointNode in wi.webappNode.children(\"endpoint\")) {\n                    if (endpointNode.attribute(\"enabled\") == \"false\") continue\n\n                    try {\n                        Class<?> endpointClass = Thread.currentThread().getContextClassLoader().loadClass(endpointNode.attribute(\"class\"))\n                        String endpointPath = endpointNode.attribute(\"path\")\n                        if (!endpointPath.startsWith(\"/\")) endpointPath = \"/\" + endpointPath\n\n                        MoquiServerEndpointConfigurator configurator = new MoquiServerEndpointConfigurator(ecfi, endpointNode.attribute(\"timeout\"))\n                        ServerEndpointConfig sec = ServerEndpointConfig.Builder.create(endpointClass, endpointPath)\n                                .configurator(configurator).build()\n                        wsServer.addEndpoint(sec)\n\n                        logger.info(\"Added WebSocket endpoint ${endpointPath} for class ${endpointClass.name}\")\n                    } catch (Exception e) {\n                        logger.error(\"Error WebSocket endpoint on ${endpointNode.attribute(\"path\")}\", e)\n                    }\n                }\n            } else {\n                logger.info(\"No WebSocket ServerContainer found, web sockets disabled\")\n            }\n\n            // run after-startup actions\n            if (wi.afterStartupActions) {\n                ExecutionContextImpl eci = ecfi.getEci()\n                wi.afterStartupActions.run(eci)\n                eci.destroy()\n            }\n\n            logger.info(\"Moqui Framework initialized in ${(System.currentTimeMillis() - initStartTime)/1000} seconds\")\n        } catch (Throwable t) {\n            logger.error(\"Error initializing webapp context: ${t.toString()}\", t)\n            throw t\n        }\n    }\n\n    @Override\n    void contextDestroyed(ServletContextEvent servletContextEvent) {\n        ServletContext sc = servletContextEvent.servletContext\n        String webappId = getId(sc)\n        String moquiWebappName = sc.getInitParameter(\"moqui-name\")\n\n        logger.info(\"Context Destroyed for Moqui webapp [${webappId}]\")\n        if (ecfi != null) {\n            // run before-shutdown actions\n            WebappInfo wi = ecfi.getWebappInfo(moquiWebappName)\n            if (wi.beforeShutdownActions) {\n                ExecutionContextImpl eci = ecfi.getEci()\n                wi.beforeShutdownActions.run(eci)\n                eci.destroy()\n            }\n\n            ecfi.destroy()\n            ecfi = null\n        } else {\n            logger.warn(\"No ExecutionContextFactoryImpl referenced, not destroying\")\n        }\n        logger.info(\"Destroyed Moqui Execution Context Factory for webapp [${webappId}]\")\n    }\n\n    static class MoquiServerEndpointConfigurator extends ServerEndpointConfig.Configurator {\n        // for a good explanation of javax.websocket details related to this see:\n        // http://stackoverflow.com/questions/17936440/accessing-httpsession-from-httpservletrequest-in-a-web-socket-serverendpoint\n        ExecutionContextFactoryImpl ecfi\n        Long maxIdleTimeout = null\n        MoquiServerEndpointConfigurator(ExecutionContextFactoryImpl ecfi, String timeoutStr) {\n            this.ecfi = ecfi\n            if (timeoutStr) maxIdleTimeout = Long.valueOf(timeoutStr)\n        }\n        @Override\n        boolean checkOrigin(String originHeaderValue) {\n            // logger.info(\"New ServerEndpoint Origin: ${originHeaderValue}\")\n            // TODO: check this against what? will be something like 'http://localhost:8080'\n            return super.checkOrigin(originHeaderValue)\n        }\n\n        @Override\n        void modifyHandshake(ServerEndpointConfig config, HandshakeRequest request, HandshakeResponse response) {\n            config.getUserProperties().put(\"handshakeRequest\", request)\n            config.getUserProperties().put(\"httpSession\", request.getHttpSession())\n            config.getUserProperties().put(\"executionContextFactory\", ecfi)\n            if (maxIdleTimeout != null) config.getUserProperties().put(\"maxIdleTimeout\", maxIdleTimeout)\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/webapp/MoquiFopServlet.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.webapp\n\nimport groovy.transform.CompileStatic\nimport org.moqui.context.ArtifactTarpitException\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.util.StringUtilities\n\nimport jakarta.servlet.ServletConfig\nimport jakarta.servlet.ServletException\nimport jakarta.servlet.http.HttpServlet\nimport jakarta.servlet.http.HttpServletRequest\nimport jakarta.servlet.http.HttpServletResponse\n\nimport org.moqui.screen.ScreenRender\nimport org.moqui.context.ArtifactAuthorizationException\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\n\nimport javax.xml.transform.stream.StreamSource\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n@CompileStatic\nclass MoquiFopServlet extends HttpServlet {\n    protected final static Logger logger = LoggerFactory.getLogger(MoquiFopServlet.class)\n\n    MoquiFopServlet() {\n        super()\n    }\n\n    @Override\n    void init(ServletConfig config) throws ServletException {\n        super.init(config)\n        String webappName = config.getInitParameter(\"moqui-name\") ?: config.getServletContext().getInitParameter(\"moqui-name\")\n        logger.info(\"${config.getServletName()} initialized for webapp ${webappName}\")\n    }\n\n    @Override\n    void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {\n        ExecutionContextFactoryImpl ecfi =\n                (ExecutionContextFactoryImpl) getServletContext().getAttribute(\"executionContextFactory\")\n        String moquiWebappName = getServletContext().getInitParameter(\"moqui-name\")\n\n        if (ecfi == null || moquiWebappName == null) {\n            response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, \"System is initializing, try again soon.\")\n            return\n        }\n\n        // handle CORS actual and preflight request headers\n        if (MoquiServlet.handleCors(request, response, moquiWebappName, ecfi)) return\n\n        long startTime = System.currentTimeMillis()\n\n        if (logger.traceEnabled) logger.trace(\"Start request to [${request.getPathInfo()}] at time [${startTime}] in session [${request.session.id}] thread [${Thread.currentThread().id}:${Thread.currentThread().name}]\")\n\n        ExecutionContextImpl activeEc = ecfi.activeContext.get()\n        if (activeEc != null) {\n            logger.warn(\"In MoquiServlet.service there is already an ExecutionContext for user ${activeEc.user.username} (from ${activeEc.forThreadId}:${activeEc.forThreadName}) in this thread (${Thread.currentThread().id}:${Thread.currentThread().name}), destroying\")\n            activeEc.destroy()\n        }\n        ExecutionContextImpl ec = ecfi.getEci()\n\n        String xslFoText = null\n        try {\n            ec.initWebFacade(moquiWebappName, request, response)\n            ec.web.requestAttributes.put(\"moquiRequestStartTime\", startTime)\n\n            ArrayList<String> pathInfoList = ec.web.getPathInfoList()\n            ScreenRender sr = ec.screen.makeRender().webappName(moquiWebappName).renderMode(\"xsl-fo\")\n                    .rootScreenFromHost(request.getServerName()).screenPath(pathInfoList)\n            xslFoText = sr.render()\n\n            if (ec.message.hasError()) {\n                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ec.message.errorsString)\n                return\n            }\n\n            // logger.warn(\"======== XSL-FO content:\\n${xslFoText}\")\n            if (logger.traceEnabled) logger.trace(\"XSL-FO content:\\n${xslFoText}\")\n\n            String contentType = (String) ec.web.requestParameters.\"contentType\" ?: \"application/pdf\"\n            response.setContentType(contentType)\n\n            String filename = (ec.web.parameters.get(\"filename\") as String) ?: (ec.web.parameters.get(\"saveFilename\") as String)\n            if (filename) {\n                String utfFilename = StringUtilities.encodeAsciiFilename(filename)\n                response.setHeader(\"Content-Disposition\", \"attachment; filename=\\\"${filename}\\\"; filename*=utf-8''${utfFilename}\")\n            } else {\n                response.setHeader(\"Content-Disposition\", \"inline\")\n            }\n\n            // special case disable authz for resource access\n            boolean enableAuthz = !ecfi.getExecutionContext().getArtifactExecution().disableAuthz()\n            try {\n                /* FUTURE: pre-render to get page count, then pass in final rendered streamed to client\n                Integer pageCount = ec.resource.xslFoTransform(new StreamSource(new StringReader(xslFoText)), null,\n                        org.apache.commons.io.output.NullOutputStream.NULL_OUTPUT_STREAM, contentType)\n                logger.info(\"Rendered ${pathInfo} as ${contentType} has ${pageCount} pages\")\n                */\n                ec.resource.xslFoTransform(new StreamSource(new StringReader(xslFoText)), null,\n                        response.getOutputStream(), contentType)\n            } finally {\n                if (enableAuthz) ecfi.getExecutionContext().getArtifactExecution().enableAuthz()\n            }\n\n            if (logger.infoEnabled) logger.info(\"Finished XSL-FO request to ${pathInfoList}, content type ${response.getContentType()} in ${System.currentTimeMillis()-startTime}ms; session ${request.session.id} thread ${Thread.currentThread().id}:${Thread.currentThread().name}\")\n        } catch (ArtifactAuthorizationException e) {\n            // SC_UNAUTHORIZED 401 used when authc/login fails, use SC_FORBIDDEN 403 for authz failures\n            // See ScreenRenderImpl.checkWebappSettings for authc and SC_UNAUTHORIZED handling\n            logger.warn((String) \"Web Access Forbidden (no authz): \" + e.message)\n            response.sendError(HttpServletResponse.SC_FORBIDDEN, e.message)\n        } catch (ArtifactTarpitException e) {\n            logger.warn((String) \"Web Too Many Requests (tarpit): \" + e.message)\n            if (e.getRetryAfterSeconds()) response.addIntHeader(\"Retry-After\", e.getRetryAfterSeconds())\n            // NOTE: there is no constant on HttpServletResponse for 429; see RFC 6585 for details\n            response.sendError(429, e.message)\n        } catch (ScreenResourceNotFoundException e) {\n            logger.warn((String) \"Web Resource Not Found: \" + e.message)\n            response.sendError(HttpServletResponse.SC_NOT_FOUND, e.message)\n        } catch (Throwable t) {\n            logger.error(\"Error transforming XSL-FO content:\\n${xslFoText}\", t)\n            if (ec.message.hasError()) {\n                String errorsString = ec.message.errorsString\n                logger.error(errorsString, t)\n                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, errorsString)\n            } else {\n                throw t\n            }\n        } finally {\n            // make sure everything is cleaned up\n            ec.destroy()\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/webapp/MoquiServlet.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.webapp\n\nimport groovy.transform.CompileStatic\nimport org.moqui.context.ArtifactTarpitException\nimport org.moqui.context.AuthenticationRequiredException\nimport org.moqui.context.ArtifactAuthorizationException\nimport org.moqui.context.NotificationMessage\nimport org.moqui.context.WebMediaTypeException\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\nimport org.moqui.impl.context.ExecutionContextImpl\nimport org.moqui.impl.context.WebFacadeImpl\nimport org.moqui.impl.screen.ScreenRenderImpl\nimport org.moqui.util.MNode\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\nimport org.slf4j.MDC\n\nimport jakarta.servlet.ServletConfig\nimport jakarta.servlet.http.HttpServlet\nimport jakarta.servlet.http.HttpServletResponse\nimport jakarta.servlet.http.HttpServletRequest\nimport jakarta.servlet.ServletException\n\n\n@CompileStatic\nclass MoquiServlet extends HttpServlet {\n    protected final static Logger logger = LoggerFactory.getLogger(MoquiServlet.class)\n\n    MoquiServlet() { super() }\n\n    @Override\n    void init(ServletConfig config) throws ServletException {\n        super.init(config)\n        String webappName = config.getInitParameter(\"moqui-name\") ?: config.getServletContext().getInitParameter(\"moqui-name\")\n        logger.info(\"${config.getServletName()} initialized for webapp ${webappName}\")\n    }\n\n    @Override\n    void service(HttpServletRequest request, HttpServletResponse response) {\n        ExecutionContextFactoryImpl ecfi = (ExecutionContextFactoryImpl) getServletContext().getAttribute(\"executionContextFactory\")\n        String webappName = getInitParameter(\"moqui-name\") ?: getServletContext().getInitParameter(\"moqui-name\")\n\n        // check for and cleanly handle when executionContextFactory is not in place in ServletContext attr\n        if (ecfi == null || webappName == null) {\n            response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, \"System is initializing, try again soon.\")\n            return\n        }\n\n        // \"Connection:Upgrade or \" \"Upgrade\".equals(request.getHeader(\"Connection\")) ||\n        if (\"websocket\".equals(request.getHeader(\"Upgrade\"))) {\n            logger.warn(\"Got request for Upgrade:websocket which should have been handled by servlet container, returning error\")\n            response.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED)\n            return\n        }\n\n        // handle CORS actual and preflight request headers\n        if (handleCors(request, response, webappName, ecfi)) return\n\n        if (!request.characterEncoding) request.setCharacterEncoding(\"UTF-8\")\n        long startTime = System.currentTimeMillis()\n\n        if (logger.traceEnabled) logger.trace(\"Start request to [${request.getPathInfo()}] at time [${startTime}] in session [${request.session.id}] thread [${Thread.currentThread().id}:${Thread.currentThread().name}]\")\n        // logger.warn(\"Start request to [${pathInfo}] at time [${startTime}] in session [${request.session.id}] thread [${Thread.currentThread().id}:${Thread.currentThread().name}]\", new Exception(\"Start request\"))\n\n        if (MDC.get(\"moqui_userId\") != null) logger.warn(\"In MoquiServlet.service there is already a userId in thread (${Thread.currentThread().id}:${Thread.currentThread().name}), removing\")\n        MDC.remove(\"moqui_userId\")\n        MDC.remove(\"moqui_visitorId\")\n\n        // make sure no transaction is active in thread\n        if (ecfi.transactionFacade.isTransactionInPlace()) {\n            logger.warn(\"In MoquiServlet.service there is already a transaction for thread [${Thread.currentThread().id}:${Thread.currentThread().name}], closing\")\n            try {\n                ecfi.transactionFacade.destroyAllInThread()\n            } catch (Throwable t) {\n                logger.error(\"Error destroying transaction already in place in MoquiServlet.service\", t)\n            }\n        }\n\n        // check for active ExecutionContext\n        ExecutionContextImpl activeEc = ecfi.activeContext.get()\n        if (activeEc != null) {\n            logger.warn(\"In MoquiServlet.service there is already an ExecutionContext for user ${activeEc.user.username} (from ${activeEc.forThreadId}:${activeEc.forThreadName}) in this thread (${Thread.currentThread().id}:${Thread.currentThread().name}), destroying\")\n            try {\n                activeEc.destroy()\n            } catch (Throwable t) {\n                logger.error(\"Error destroying ExecutionContext already in place in MoquiServlet.service\", t)\n            }\n        }\n        // get a new ExecutionContext\n        ExecutionContextImpl ec = ecfi.getEci()\n\n        /** NOTE to set render settings manually do something like this, but it is not necessary to set these things\n         * for a web page render because if we call render(request, response) it can figure all of this out as defaults\n         *\n         * ScreenRender render = ec.screen.makeRender().webappName(moquiWebappName).renderMode(\"html\")\n         *         .rootScreenFromHost(request.getServerName()).screenPath(pathInfo.split(\"/\") as List)\n         */\n\n        ScreenRenderImpl sri = null\n        try {\n            ec.initWebFacade(webappName, request, response)\n            ec.web.requestAttributes.put(\"moquiRequestStartTime\", startTime)\n\n            sri = (ScreenRenderImpl) ec.screenFacade.makeRender().saveHistory(true)\n            sri.render(request, response)\n        } catch (AuthenticationRequiredException e) {\n            logger.warn(\"Web Unauthorized (no authc): \" + e.message)\n            sendErrorResponse(request, response, HttpServletResponse.SC_UNAUTHORIZED, \"unauthorized\", null, e, ecfi, webappName, sri)\n        } catch (ArtifactAuthorizationException e) {\n            // SC_UNAUTHORIZED 401 used when authc/login fails, use SC_FORBIDDEN 403 for authz failures\n            // See ScreenRenderImpl.checkWebappSettings for authc and SC_UNAUTHORIZED handling\n            logger.warn(\"Web Access Forbidden (no authz): \" + e.message)\n            sendErrorResponse(request, response, HttpServletResponse.SC_FORBIDDEN, \"forbidden\", null, e, ecfi, webappName, sri)\n        } catch (ScreenResourceNotFoundException e) {\n            logger.warn(\"Web Resource Not Found: \" + e.message)\n            sendErrorResponse(request, response, HttpServletResponse.SC_NOT_FOUND, \"not-found\", null, e, ecfi, webappName, sri)\n        } catch (ArtifactTarpitException e) {\n            logger.warn(\"Web Too Many Requests (tarpit): \" + e.message)\n            if (e.getRetryAfterSeconds()) response.addIntHeader(\"Retry-After\", e.getRetryAfterSeconds())\n            // NOTE: there is no constant on HttpServletResponse for 429; see RFC 6585 for details\n            sendErrorResponse(request, response, 429, \"too-many\", null, e, ecfi, webappName, sri)\n        } catch (WebMediaTypeException e) {\n            logger.warn(\"Web Unsupported Media Type: \" + e.message)\n            sendErrorResponse(request, response, HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, \"media-type\", e.message, e, ecfi, webappName, sri)\n        } catch (Throwable t) {\n            if (ec.message.hasError()) {\n                String errorsString = ec.message.errorsString\n                logger.error(errorsString, t)\n                if (\"true\".equals(request.getAttribute(\"moqui.login.error\"))) {\n                    sendErrorResponse(request, response, HttpServletResponse.SC_UNAUTHORIZED, \"unauthorized\",\n                            errorsString, t, ecfi, webappName, sri)\n                } else {\n                    sendErrorResponse(request, response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, \"internal-error\",\n                            errorsString, t, ecfi, webappName, sri)\n                }\n            } else {\n                String tString = t.toString()\n                if (isBrokenPipe(t)) {\n                    logger.error(\"Internal error processing request: \" + tString)\n                } else {\n                    logger.error(\"Internal error processing request: \" + tString, t)\n                }\n                sendErrorResponse(request, response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, \"internal-error\",\n                        null, t, ecfi, webappName, sri)\n            }\n        } finally {\n            /* this is here just for kicks, uncomment to log a list of all artifacts hit/used in the screen render\n            StringBuilder hits = new StringBuilder()\n            hits.append(\"Artifacts hit in this request: \")\n            for (def aei in ec.artifactExecution.history) hits.append(\"\\n\").append(aei)\n            logger.info(hits.toString())\n            */\n\n            // make sure everything is cleaned up\n            ec.destroy()\n        }\n\n        /* definitely don't want this normally, but uncomment to help debug session attribute issues:\n        logger.warn(\"Thread ClassLoader ${Thread.currentThread().getContextClassLoader()?.getClass()?.getName()}\")\n        for (String name in ec.web.session.getAttributeNames()) {\n            Object value = ec.web.session.getAttribute(name)\n            logger.warn(\"Session attr \" + name + \"(\" + (value != null ? value.getClass().getName() : \"\") + \":\" + (value != null && value.getClass().getClassLoader() != null ? value.getClass().getClassLoader().getClass().getName() : \"\") + \")\" + \" value: \" + value)\n        }\n        */\n    }\n\n    /** Handles CORS headers and if this a CORS preflight request or the origin is not allowed sends the proper response and returns true (caller should then not respond, ie just quit via return) */\n    static boolean handleCors(HttpServletRequest request, HttpServletResponse response, String webappName, ExecutionContextFactoryImpl ecfi) {\n        ExecutionContextFactoryImpl.WebappInfo webappInfo = ecfi.getWebappInfo(webappName)\n        String originHeader = request.getHeader(\"Origin\")\n\n        if (originHeader != null && !originHeader.isEmpty() && webappInfo != null &&\n                !\"false\".equals(webappInfo.webappNode.attribute(\"handle-cors\"))) {\n\n            originHeader = originHeader.toLowerCase()\n            // generate Access-Control-Allow-Origin based on Origin, if allowed\n            Set<String> allowOriginSet = webappInfo.allowOriginSet\n            int originSepIdx = originHeader.indexOf(\"://\")\n            String originDomain = originSepIdx > 0 ? originHeader.substring(originSepIdx + 3) : originHeader\n            int originDomColonIdx = originDomain.indexOf(\":\")\n            if (originDomColonIdx > 0) originDomain = originDomain.substring(0, originDomColonIdx)\n            // if * allowed or Origin domain matches request domain always allow (same origin)\n            String serverName = request.getServerName()\n            URL requestUrl = new URL(request.getRequestURL().toString())\n            String hostName = requestUrl.getHost()\n            if (allowOriginSet.contains(\"*\") || originDomain == serverName || originDomain == hostName) {\n                response.setHeader(\"Access-Control-Allow-Origin\", originHeader)\n            } else {\n                if (allowOriginSet.contains(originHeader) || allowOriginSet.contains(originDomain)) {\n                    response.setHeader(\"Access-Control-Allow-Origin\", originHeader)\n                } else {\n                    // no luck with simpler match, see if any configured domain matches by dot-separated segment\n                    // for example: moqui.org ==> 'org','moqui' so www.moqui.org ('org','moqui','www') will match but foo-moqui.org ('org','foo-moqui') will not\n                    boolean foundMatch = false\n                    for (String allowOrigin in allowOriginSet) {\n                        String[] originArray = originDomain.split(\"\\\\.\").reverse()\n                        String[] allowArray = allowOrigin.split(\"\\\\.\").reverse()\n                        // logger.warn(\"allowArray: ${allowArray} originArray: ${originArray}\")\n                        boolean allMatched = true\n                        for (int i = 0; i < allowArray.length; i++) {\n                            if (allowArray[i] != originArray[i]) {\n                                allMatched = false\n                                break\n                            }\n                        }\n                        if (allMatched) {\n                            foundMatch = true\n                            break\n                        }\n                    }\n                    if (foundMatch) {\n                        response.setHeader(\"Access-Control-Allow-Origin\", originHeader)\n                    } else {\n                        logger.warn(\"Returning 401, Origin ${originHeader} not allowed for configuration ${allowOriginSet} or server name ${serverName} or request host ${hostName}\")\n                        // Origin not allowed, send 401 response\n                        // response.sendError(HttpServletResponse.SC_UNAUTHORIZED, \"Origin not allowed\")\n                        WebFacadeImpl.sendError(HttpServletResponse.SC_UNAUTHORIZED, \"Origin not allowed\", null, request, response)\n                        return true\n                    }\n                }\n            }\n\n            String acRequestMethod = request.getHeader(\"Access-Control-Request-Method\")\n            if (\"OPTIONS\".equals(request.getMethod()) && acRequestMethod != null && !acRequestMethod.isEmpty()) {\n                // String acRequestHeaders = request.getHeader(\"Access-Control-Request-Headers\")\n                webappInfo.addHeaders(\"cors-preflight\", response)\n                response.setStatus(HttpServletResponse.SC_OK)\n                return true\n            } else {\n                webappInfo.addHeaders(\"cors-actual\", response)\n                return false\n            }\n        }\n\n        return false\n    }\n\n    static void sendErrorResponse(HttpServletRequest request, HttpServletResponse response, int errorCode, String errorType,\n            String message, Throwable origThrowable, ExecutionContextFactoryImpl ecfi, String moquiWebappName, ScreenRenderImpl sri) {\n\n        if (message == null && origThrowable != null) {\n            List<String> msgList = new ArrayList<>(10)\n            Throwable curt = origThrowable\n            while (curt != null) {\n                msgList.add(curt.message)\n                curt = curt.getCause()\n            }\n            int msgListSize = msgList.size()\n            if (msgListSize > 4) msgList = (List<String>) msgList.subList(msgListSize - 4, msgListSize)\n            message = msgList.join(\" \")\n        }\n\n        if (ecfi != null && errorCode == HttpServletResponse.SC_INTERNAL_SERVER_ERROR && !isBrokenPipe(origThrowable)) {\n            ExecutionContextImpl ec = ecfi.getEci()\n            ec.makeNotificationMessage().topic(\"WebServletError\").type(NotificationMessage.danger)\n                    .title('''Web Error ${errorCode?:''} (${username?:'no user'}) ${path?:''} ${message?:'N/A'}''')\n                    .message([errorCode:errorCode, errorType:errorType, message:message, exception:origThrowable?.toString(),\n                        path:ec.web?.getPathInfo(), parameters:ec.web?.getRequestParameters(), username:ec.user.username] as Map<String, Object>)\n                    .send()\n        }\n\n        if (ecfi == null) {\n            response.sendError(errorCode, message)\n            return\n        }\n        ExecutionContextImpl ec = ecfi.getEci()\n        String acceptHeader = request.getHeader(\"Accept\")\n        boolean acceptHtml = acceptHeader != null && acceptHeader.contains(\"text/html\")\n        MNode errorScreenNode = acceptHtml ? ecfi.getWebappInfo(moquiWebappName)?.getErrorScreenNode(errorType) : null\n        if (errorScreenNode != null) {\n            try {\n                ec.context.put(\"errorCode\", errorCode)\n                ec.context.put(\"errorType\", errorType)\n                ec.context.put(\"errorMessage\", message)\n                ec.context.put(\"errorThrowable\", origThrowable)\n                String screenPathAttr = errorScreenNode.attribute(\"screen-path\")\n                // NOTE 20180228: this seems to be working fine now and Jetty (at least) is returning the 404/etc responses with the custom HTML body unlike before\n                response.setStatus(errorCode)\n                ec.screen.makeRender().webappName(moquiWebappName).renderMode(\"html\")\n                        .rootScreenFromHost(request.getServerName()).screenPath(Arrays.asList(screenPathAttr.split(\"/\")))\n                        .render(request, response)\n            } catch (Throwable t) {\n                logger.error(\"Error rendering ${errorType} error screen, sending code ${errorCode} with message: ${message}\", t)\n                response.sendError(errorCode, message)\n            }\n        } else {\n            WebFacadeImpl.sendError(errorCode, message, origThrowable, request, response)\n        }\n    }\n\n    static boolean isBrokenPipe(Throwable throwable) {\n        Throwable curt = throwable\n        while (curt != null) {\n            // could constrain more looking for \"Broken pipe\" message\n            // works for Jetty, may have different exception patterns on other servlet containers\n            if (curt instanceof IOException) return true\n            curt = curt.getCause()\n        }\n        return false\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/webapp/MoquiSessionListener.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.webapp\n\nimport groovy.transform.CompileStatic\n\nimport jakarta.servlet.http.HttpSession\nimport jakarta.servlet.http.HttpSessionAttributeListener\nimport jakarta.servlet.http.HttpSessionBindingEvent\nimport jakarta.servlet.http.HttpSessionEvent\nimport jakarta.servlet.http.HttpSessionListener\n\nimport java.sql.Timestamp\n\nimport org.moqui.Moqui\nimport org.moqui.impl.context.ExecutionContextFactoryImpl\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n@CompileStatic\nclass MoquiSessionListener implements HttpSessionListener, HttpSessionAttributeListener {\n    protected final static Logger logger = LoggerFactory.getLogger(MoquiSessionListener.class)\n    private HashMap<String, String> visitIdBySession = new HashMap<>()\n\n    @Override void sessionCreated(HttpSessionEvent event) {\n        HttpSession session = event.session\n\n        ExecutionContextFactoryImpl ecfi = (ExecutionContextFactoryImpl) Moqui.getExecutionContextFactory()\n        String moquiWebappName = session.servletContext.getInitParameter(\"moqui-name\")\n        ExecutionContextFactoryImpl.WebappInfo wi = ecfi?.getWebappInfo(moquiWebappName)\n        if (wi?.sessionTimeoutSeconds != null) session.setMaxInactiveInterval(wi.sessionTimeoutSeconds)\n    }\n\n    @Override void sessionDestroyed(HttpSessionEvent event) {\n        String sessionId = event.session.id\n        String visitId = visitIdBySession.remove(sessionId)\n        if (!visitId) {\n            try { visitId = event.session.getAttribute(\"moqui.visitId\") }\n            catch (Throwable t) { logger.warn(\"No saved visitId for session ${sessionId} and error getting moqui.visitId session attribute: \" + t.toString()) }\n        }\n        if (!visitId) {\n            if (logger.traceEnabled) logger.trace(\"Not closing visit for session ${sessionId}, no value for visitId session attribute\")\n            return\n        }\n        closeVisit(visitId, sessionId)\n    }\n    @Override void attributeAdded(HttpSessionBindingEvent event) {\n        if (\"moqui.visitId\".equals(event.name)) visitIdBySession.put(event.session.id, event.value.toString())\n    }\n    @Override void attributeReplaced(HttpSessionBindingEvent event) {\n        if (\"moqui.visitId\".equals(event.name)) {\n            String sessionId = event.session.id\n            String oldValue = event.value.toString()\n            if (!oldValue) oldValue = visitIdBySession.get(sessionId)\n            String newValue = event.session.getAttribute(\"moqui.visitId\")\n            if (newValue) visitIdBySession.put(sessionId, newValue)\n            if (oldValue) closeVisit(oldValue, sessionId)\n        }\n    }\n\n    @Override void attributeRemoved(HttpSessionBindingEvent event) {\n        if (\"moqui.visitId\".equals(event.name)) {\n            String sessionId = event.session.id\n            String visitId = event.value\n            if (!visitId) {\n                if (logger.traceEnabled) logger.trace(\"Not closing visit for session ${sessionId}, no value for removed moqui.visitId session attribute\")\n                return\n            }\n            closeVisit(visitId, sessionId)\n        }\n    }\n    static void closeVisit(String visitId, String sessionId) {\n        ExecutionContextFactoryImpl ecfi = (ExecutionContextFactoryImpl) Moqui.getExecutionContextFactory()\n        if (ecfi.confXmlRoot.first(\"server-stats\").attribute(\"visit-enabled\") == \"false\") return\n\n        // set thruDate on Visit\n        Timestamp thruDate = new Timestamp(System.currentTimeMillis())\n        ecfi.serviceFacade.sync().name(\"update\", \"moqui.server.Visit\").parameter(\"visitId\", visitId).parameter(\"thruDate\", thruDate)\n                .disableAuthz().call()\n        if (logger.traceEnabled) logger.trace(\"Closed visit ${visitId} at ${thruDate} for session ${sessionId}\")\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/webapp/NotificationEndpoint.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.webapp\n\nimport groovy.transform.CompileStatic\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport jakarta.websocket.CloseReason\nimport jakarta.websocket.EndpointConfig\nimport jakarta.websocket.Session\n\n@CompileStatic\nclass NotificationEndpoint extends MoquiAbstractEndpoint {\n    private final static Logger logger = LoggerFactory.getLogger(NotificationEndpoint.class)\n\n    final static String subscribePrefix = \"subscribe:\"\n    final static String unsubscribePrefix = \"unsubscribe:\"\n\n    private Set<String> subscribedTopics = new HashSet<>()\n\n    NotificationEndpoint() { super() }\n\n    Set<String> getSubscribedTopics() { subscribedTopics }\n\n    @Override\n    void onOpen(Session session, EndpointConfig config) {\n        super.onOpen(session, config)\n        getEcf().getNotificationWebSocketListener().registerEndpoint(this)\n    }\n\n    @Override\n    void onMessage(String message) {\n        if (message.startsWith(subscribePrefix)) {\n            String topics = message.substring(subscribePrefix.length(), message.length())\n            for (String topic in topics.split(\",\")) {\n                String trimmedTopic = topic.trim()\n                if (trimmedTopic) subscribedTopics.add(trimmedTopic)\n            }\n            logger.debug(\"Notification subscribe user ${getUserId()} topics ${subscribedTopics} session ${session?.id}\")\n        } else if (message.startsWith(unsubscribePrefix)) {\n            String topics = message.substring(unsubscribePrefix.length(), message.length())\n            for (String topic in topics.split(\",\")) {\n                String trimmedTopic = topic.trim()\n                if (trimmedTopic) subscribedTopics.remove(trimmedTopic)\n            }\n            logger.info(\"Notification unsubscribe for user ${getUserId()} in session ${session?.id}, current topics: ${subscribedTopics}\")\n        } else {\n            logger.info(\"Unknown command prefix for message to NotificationEndpoint in session ${session?.id}: ${message}\")\n        }\n    }\n\n    @Override\n    void onClose(Session session, CloseReason closeReason) {\n        getEcf().getNotificationWebSocketListener().deregisterEndpoint(this)\n        super.onClose(session, closeReason)\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/webapp/NotificationWebSocketListener.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.webapp\n\nimport groovy.transform.CompileStatic\nimport org.moqui.context.ExecutionContextFactory\nimport org.moqui.context.NotificationMessage\nimport org.moqui.context.NotificationMessageListener\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport java.util.concurrent.ConcurrentHashMap\n\n@CompileStatic\nclass NotificationWebSocketListener implements NotificationMessageListener {\n    private final static Logger logger = LoggerFactory.getLogger(NotificationWebSocketListener.class)\n\n    private ExecutionContextFactory ecf = null\n    private ConcurrentHashMap<String, ConcurrentHashMap<String, NotificationEndpoint>> endpointsByUser = new ConcurrentHashMap<>()\n\n    void registerEndpoint(NotificationEndpoint endpoint) {\n        String userId = endpoint.userId\n        if (userId == null) return\n        String sessionId = endpoint.session.id\n        ConcurrentHashMap<String, NotificationEndpoint> registeredEndPoints = endpointsByUser.get(userId)\n        if (registeredEndPoints == null) {\n            registeredEndPoints = new ConcurrentHashMap<>()\n            ConcurrentHashMap<String, NotificationEndpoint> existing = endpointsByUser.putIfAbsent(userId, registeredEndPoints)\n            if (existing != null) registeredEndPoints = existing\n        }\n        NotificationEndpoint existing = registeredEndPoints.putIfAbsent(sessionId, endpoint)\n        if (existing != null) logger.warn(\"Found existing NotificationEndpoint for user ${endpoint.userId} (${existing.username}) session ${sessionId}; not registering additional endpoint\")\n    }\n    void deregisterEndpoint(NotificationEndpoint endpoint) {\n        String userId = endpoint.userId\n        if (userId == null) return\n        String sessionId = endpoint.session.id\n        ConcurrentHashMap<String, NotificationEndpoint> registeredEndPoints = endpointsByUser.get(userId)\n        if (registeredEndPoints == null) {\n            logger.warn(\"Tried to deregister endpoing for user ${endpoint.userId} but no endpoints found\")\n            return\n        }\n        registeredEndPoints.remove(sessionId)\n        if (registeredEndPoints.size() == 0) endpointsByUser.remove(userId, registeredEndPoints)\n    }\n\n    @Override\n    void init(ExecutionContextFactory ecf) {\n        this.ecf = ecf\n    }\n\n    @Override\n    void destroy() {\n        endpointsByUser.clear()\n        this.ecf = null\n    }\n\n    @Override\n    void onMessage(NotificationMessage nm) {\n        String messageWrapperJson = nm.getWrappedMessageJson()\n        for (String userId in nm.getNotifyUserIds()) {\n            ConcurrentHashMap<String, NotificationEndpoint> registeredEndPoints = endpointsByUser.get(userId)\n            if (registeredEndPoints == null) continue\n            for (NotificationEndpoint endpoint in registeredEndPoints.values()) {\n                if (endpoint.session != null && endpoint.session.isOpen() &&\n                        (endpoint.subscribedTopics.contains(\"ALL\") || endpoint.subscribedTopics.contains(nm.topic))) {\n                    endpoint.session.asyncRemote.sendText(messageWrapperJson)\n                    nm.markSent(userId)\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/groovy/org/moqui/impl/webapp/ScreenResourceNotFoundException.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.impl.webapp\n\nimport groovy.transform.CompileStatic\nimport org.moqui.impl.screen.ScreenDefinition\n\n@CompileStatic\nclass ScreenResourceNotFoundException extends RuntimeException {\n    ScreenDefinition rootSd\n    List<String> fullPathNameList\n    ScreenDefinition lastSd\n    String pathFromLastScreen\n    String resourceLocation\n    ScreenResourceNotFoundException(ScreenDefinition rootSd, List<String> fullPathNameList,\n                                           ScreenDefinition lastSd, String pathFromLastScreen, String resourceLocation,\n                                           Exception cause) {\n        super(\"Could not find subscreen or transition or file/content [\" + pathFromLastScreen +\n                (resourceLocation ? \":\" + resourceLocation : \"\") + \"] under screen [\" +\n                lastSd?.getLocation() + \"] while finding url for path \" + fullPathNameList + \" under from screen [\" +\n                rootSd?.getLocation() + \"]\", cause)\n        this.rootSd = rootSd\n        this.fullPathNameList = fullPathNameList\n        this.lastSd = lastSd\n        this.pathFromLastScreen = pathFromLastScreen\n        this.resourceLocation = resourceLocation\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/BaseArtifactException.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui;\n\nimport org.moqui.context.ArtifactExecutionInfo;\nimport org.moqui.context.ExecutionContextFactory;\n\nimport java.io.PrintStream;\nimport java.io.PrintWriter;\nimport java.util.Deque;\n\n/** BaseArtifactException - extends BaseException to add artifact stack info. */\npublic class BaseArtifactException extends BaseException {\n    transient private Deque<ArtifactExecutionInfo> artifactStack = null;\n\n    public BaseArtifactException(String message) { super(message); populateArtifactStack(); }\n    public BaseArtifactException(String message, Deque<ArtifactExecutionInfo> curStack) { super(message); artifactStack = curStack; }\n    public BaseArtifactException(String message, Throwable nested) { super(message, nested); populateArtifactStack(); }\n    public BaseArtifactException(String message, Throwable nested, Deque<ArtifactExecutionInfo> curStack) {\n        super(message, nested); artifactStack = curStack; }\n    public BaseArtifactException(Throwable nested) { super(nested); populateArtifactStack(); }\n\n    private void populateArtifactStack() {\n        ExecutionContextFactory ecf = Moqui.getExecutionContextFactory();\n        if (ecf != null) artifactStack = ecf.getExecutionContext().getArtifactExecution().getStack();\n    }\n\n    public Deque<ArtifactExecutionInfo> getArtifactStack() { return artifactStack; }\n\n    @Override public void printStackTrace() { printStackTrace(System.err); }\n    @Override public void printStackTrace(PrintStream printStream) {\n        if (artifactStack != null && artifactStack.size() > 0)\n            for (ArtifactExecutionInfo aei : artifactStack) printStream.println(aei.toBasicString());\n        filterStackTrace(this);\n        super.printStackTrace(printStream);\n    }\n    @Override public void printStackTrace(PrintWriter printWriter) {\n        if (artifactStack != null && artifactStack.size() > 0)\n            for (ArtifactExecutionInfo aei : artifactStack) printWriter.println(aei.toBasicString());\n        filterStackTrace(this);\n        super.printStackTrace(printWriter);\n    }\n    @Override public StackTraceElement[] getStackTrace() {\n        StackTraceElement[] filteredTrace = filterStackTrace(super.getStackTrace());\n        setStackTrace(filteredTrace);\n        return filteredTrace;\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/BaseException.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui;\n\nimport java.io.PrintStream;\nimport java.io.PrintWriter;\nimport java.util.ArrayList;\nimport java.util.List;\n\n/** BaseException - the base/root exception for all exception classes in Moqui Framework. */\npublic class BaseException extends RuntimeException {\n    public BaseException(String message) { super(message); }\n    public BaseException(String message, Throwable nested) { super(message, nested); }\n    public BaseException(Throwable nested) { super(nested); }\n\n    @Override public void printStackTrace() { filterStackTrace(this); super.printStackTrace(); }\n    @Override public void printStackTrace(PrintStream printStream) { filterStackTrace(this); super.printStackTrace(printStream); }\n    @Override public void printStackTrace(PrintWriter printWriter) { filterStackTrace(this); super.printStackTrace(printWriter); }\n    @Override public StackTraceElement[] getStackTrace() {\n        StackTraceElement[] filteredTrace = filterStackTrace(super.getStackTrace());\n        setStackTrace(filteredTrace);\n        return filteredTrace;\n    }\n\n    public static Throwable filterStackTrace(Throwable t) {\n        t.setStackTrace(filterStackTrace(t.getStackTrace()));\n        if (t.getCause() != null) filterStackTrace(t.getCause());\n        return t;\n    }\n    public static StackTraceElement[] filterStackTrace(StackTraceElement[] orig) {\n        List<StackTraceElement> newList = new ArrayList<>(orig.length);\n        for (StackTraceElement ste: orig) {\n            String cn = ste.getClassName();\n            if (cn.startsWith(\"freemarker.core.\") || cn.startsWith(\"freemarker.ext.beans.\") || cn.startsWith(\"org.eclipse.jetty.\") ||\n                    cn.startsWith(\"java.lang.reflect.\") || cn.startsWith(\"sun.reflect.\") ||\n                    cn.startsWith(\"org.codehaus.groovy.\") ||  cn.startsWith(\"groovy.lang.\")) {\n                continue;\n            }\n            // if (\"renderSingle\".equals(ste.getMethodName()) && cn.startsWith(\"org.moqui.impl.screen.ScreenSection\")) continue;\n            // if ((\"internalRender\".equals(ste.getMethodName()) || \"doActualRender\".equals(ste.getMethodName())) && cn.startsWith(\"org.moqui.impl.screen.ScreenRenderImpl\")) continue;\n            if ((\"call\".equals(ste.getMethodName()) || \"callCurrent\".equals(ste.getMethodName())) && ste.getLineNumber() == -1) continue;\n            //System.out.println(\"Adding className: \" + cn + \", line: \" + ste.getLineNumber());\n            newList.add(ste);\n        }\n        //System.out.println(\"Called getFilteredStackTrace, orig.length=\" + orig.length + \", newList.size()=\" + newList.size());\n        return newList.toArray(new StackTraceElement[newList.size()]);\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/Moqui.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui;\n\nimport org.moqui.context.ArtifactExecutionInfo;\nimport org.moqui.context.ExecutionContext;\nimport org.moqui.context.ExecutionContextFactory;\nimport org.moqui.entity.EntityDataLoader;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport jakarta.servlet.ServletContext;\nimport java.lang.reflect.InvocationTargetException;\nimport java.util.*;\n\n/**\n * This is a base class that implements a simple way to access the Moqui framework for use in simple deployments where\n * there is nothing available like a webapp or an OSGi component.\n *\n * In deployments where a static reference to the ExecutionContextFactory is not helpful, or not possible, this does\n * not need to be used and the ExecutionContextFactory instance should be referenced and used from somewhere else.\n */\n\n@SuppressWarnings(\"unused\")\npublic class Moqui {\n    protected final static Logger logger = LoggerFactory.getLogger(Moqui.class);\n\n    private static ExecutionContextFactory activeExecutionContextFactory = null;\n\n    private static final ServiceLoader<ExecutionContextFactory> executionContextFactoryLoader =\n            ServiceLoader.load(ExecutionContextFactory.class);\n    static {\n        // only do this if the moqui.init.static System property is true\n        if (\"true\".equals(System.getProperty(\"moqui.init.static\"))) {\n            // initialize the activeExecutionContextFactory from configuration using java.util.ServiceLoader\n            // the implementation class name should be in: \"META-INF/services/org.moqui.context.ExecutionContextFactory\"\n            activeExecutionContextFactory = executionContextFactoryLoader.iterator().next();\n        }\n    }\n\n    public static void dynamicInit(ExecutionContextFactory executionContextFactory) {\n        if (activeExecutionContextFactory != null && !activeExecutionContextFactory.isDestroyed())\n            throw new IllegalStateException(\"Active ExecutionContextFactory already in place, cannot set one dynamically.\");\n        activeExecutionContextFactory = executionContextFactory;\n    }\n    public static <K extends ExecutionContextFactory> K dynamicInit(Class<K> ecfClass, ServletContext sc)\n            throws InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException {\n        if (activeExecutionContextFactory != null && !activeExecutionContextFactory.isDestroyed())\n            throw new IllegalStateException(\"Active ExecutionContextFactory already in place, cannot set one dynamically.\");\n\n        K newEcf = ecfClass.getDeclaredConstructor().newInstance();\n        activeExecutionContextFactory = newEcf;\n        // check for an empty DB\n        if (newEcf.checkEmptyDb()) {\n            logger.warn(\"Data loaded into empty DB, re-initializing ExecutionContextFactory\");\n            // destroy old ECFI\n            newEcf.destroy();\n            // create new ECFI to get framework init data from DB\n            newEcf = ecfClass.getDeclaredConstructor().newInstance();\n            activeExecutionContextFactory = newEcf;\n        }\n\n        if (sc != null) {\n            // tell ECF about the ServletContext\n            newEcf.initServletContext(sc);\n            // set SC attribute and Moqui class static reference\n            sc.setAttribute(\"executionContextFactory\", newEcf);\n        }\n\n        return newEcf;\n    }\n    public static <K extends ExecutionContextFactory> K dynamicReInit(Class<K> ecfClass, ServletContext sc)\n            throws InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException {\n\n        // handle Servlet pause then resume taking requests after by removing executionContextFactory attribute\n        if (sc.getAttribute(\"executionContextFactory\") != null) sc.removeAttribute(\"executionContextFactory\");\n\n        if (activeExecutionContextFactory != null) {\n            if (!activeExecutionContextFactory.isDestroyed()) {\n                activeExecutionContextFactory.destroyActiveExecutionContext();\n                activeExecutionContextFactory.destroy();\n            }\n            activeExecutionContextFactory = null;\n            System.gc();\n        }\n\n        return dynamicInit(ecfClass, sc);\n    }\n\n    public static ExecutionContextFactory getExecutionContextFactory() { return activeExecutionContextFactory; }\n    \n    public static ExecutionContext getExecutionContext() {\n        return activeExecutionContextFactory.getExecutionContext();\n    }\n\n    /** This should be called when it is known a context won't be used any more, such as at the end of a web request or service execution. */\n    public static void destroyActiveExecutionContext() { activeExecutionContextFactory.destroyActiveExecutionContext(); }\n\n    /** This should be called when the process is terminating to clean up framework and tool operations and resources. */\n    public static void destroyActiveExecutionContextFactory() { activeExecutionContextFactory.destroy(); }\n\n    /** This method is meant to be run from a command-line interface and handle data loading in a generic way.\n     * @param argMap Arguments, generally from command line, to configure this data load.\n     */\n    public static void loadData(Map<String, String> argMap) {\n        if (argMap.containsKey(\"raw\") || argMap.containsKey(\"no-fk-create\"))\n            System.setProperty(\"entity_disable_fk_create\", \"true\");\n\n        // make sure we have a factory, even if moqui.init.static != true\n        if (activeExecutionContextFactory == null)\n            activeExecutionContextFactory = executionContextFactoryLoader.iterator().next();\n\n        ExecutionContext ec = activeExecutionContextFactory.getExecutionContext();\n        // disable authz and add an artifact set to anonymous authorized all\n        ec.getArtifactExecution().disableAuthz();\n        ec.getArtifactExecution().push(\"loadData\", ArtifactExecutionInfo.AT_OTHER, ArtifactExecutionInfo.AUTHZA_ALL, false);\n        ec.getArtifactExecution().setAnonymousAuthorizedAll();\n\n        // login anonymous user\n        ec.getUser().loginAnonymousIfNoUser();\n\n        // set the data load parameters\n        EntityDataLoader edl = ec.getEntity().makeDataLoader();\n        if (argMap.containsKey(\"types\")) {\n            String types = argMap.get(\"types\");\n            if (!\"all\".equals(types)) edl.dataTypes(new HashSet<>(Arrays.asList(types.split(\",\"))));\n        }\n        if (argMap.containsKey(\"components\")) edl.componentNameList(Arrays.asList(argMap.get(\"components\").split(\",\")));\n        if (argMap.containsKey(\"location\")) edl.location(argMap.get(\"location\"));\n        if (argMap.containsKey(\"timeout\")) edl.transactionTimeout(Integer.valueOf(argMap.get(\"timeout\")));\n        if (argMap.containsKey(\"dummy-fks\")) edl.dummyFks(true);\n        if (argMap.containsKey(\"raw\") || argMap.containsKey(\"no-fk-create\")) edl.disableFkCreate(true);\n        if (argMap.containsKey(\"raw\") || argMap.containsKey(\"use-try-insert\")) edl.useTryInsert(true);\n        if (argMap.containsKey(\"raw\") || argMap.containsKey(\"disable-eeca\")) edl.disableEntityEca(true);\n        if (argMap.containsKey(\"raw\") || argMap.containsKey(\"disable-audit-log\")) edl.disableAuditLog(true);\n        if (argMap.containsKey(\"raw\") || argMap.containsKey(\"disable-data-feed\")) edl.disableDataFeed(true);\n\n        // do the data load\n        try {\n            long startTime = System.currentTimeMillis();\n            long records = edl.load();\n            long totalSeconds = (System.currentTimeMillis() - startTime)/1000;\n            logger.info(\"Loaded [\" + records + \"] records in \" + totalSeconds + \" seconds.\");\n        } catch (Throwable t) {\n            System.out.println(\"Error loading data: \" + t.toString());\n            t.printStackTrace();\n        }\n\n        // cleanup and quit\n        activeExecutionContextFactory.destroyActiveExecutionContext();\n        activeExecutionContextFactory.destroy();\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/ArtifactAuthorizationException.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport org.moqui.BaseArtifactException;\n\nimport java.util.Deque;\n\n/** Thrown when artifact authz fails. */\npublic class ArtifactAuthorizationException extends BaseArtifactException {\n    transient private ArtifactExecutionInfo artifactInfo = null;\n\n    public ArtifactAuthorizationException(String str) { super(str); }\n    public ArtifactAuthorizationException(String str, Throwable nested) { super(str, nested); }\n    public ArtifactAuthorizationException(String str, ArtifactExecutionInfo curInfo, Deque<ArtifactExecutionInfo> curStack) {\n        super(str, curStack);\n        artifactInfo = curInfo;\n    }\n\n    public ArtifactExecutionInfo getArtifactInfo() { return artifactInfo; }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/ArtifactExecutionFacade.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport java.util.ArrayList;\nimport java.util.Deque;\nimport java.util.List;\n\n/** For information about artifacts as they are being executed. */\npublic interface ArtifactExecutionFacade {\n    /** Gets information about the current artifact being executed, and about authentication and authorization for\n     * that artifact.\n     *\n     * @return Current (most recent) ArtifactExecutionInfo\n     */\n    ArtifactExecutionInfo peek();\n\n    /** Push onto the artifact stack. This is generally called internally by the framework and does not need to be used\n     * in application code. */\n    void push(ArtifactExecutionInfo aei, boolean requiresAuthz);\n    ArtifactExecutionInfo push(String name, ArtifactExecutionInfo.ArtifactType typeEnum, ArtifactExecutionInfo.AuthzAction actionEnum, boolean requiresAuthz);\n    /** Pop from the artifact stack and verify it is the same artifact name and type. This is generally called internally\n     * by the framework and does not need to be used in application code. */\n    ArtifactExecutionInfo pop(ArtifactExecutionInfo aei);\n\n    /** Gets a stack/deque/list of objects representing artifacts that have been executed to get to the current artifact.\n     * The bottom artifact in the stack will generally be a screen or a service. If a service is run locally\n     * this will trace back to the screen or service that called it, and if a service was called remotely it will be\n     * the bottom of the stack.\n     *\n     * @return Actual ArtifactExecutionInfo stack/deque object\n     */\n    Deque<ArtifactExecutionInfo> getStack();\n    /** Like getStack() but as an ArrayList for more frequent use, less memory overhead, and faster to iterate by index;\n     * NOTE that this is cached and updated on push() and pop(), do not modify or other references to it will have incorrect data */\n    ArrayList<ArtifactExecutionInfo> getStackArray();\n\n    List<ArtifactExecutionInfo> getHistory();\n    String printHistory();\n\n    /** Disable authorization checks for the current ExecutionContext only.\n     * This should be used when the system automatically does something (possible based on a user action) that the user\n     * would not generally have permission to do themselves.\n     *\n     * @return boolean representing previous state of disable authorization (true if was disabled, false if not). If\n     *         this is true, you should not enableAuthz when you are done and instead allow whichever code first did the\n     *         disable to enable it.\n     */\n    boolean disableAuthz();\n    /** Enable authorization after a disableAuthz() call. Not that this should be done in a finally block with the code\n     * following the disableAuthz() in the corresponding try block. If this is not in a finally block an exception may\n     * result in authorizations being disabled for the rest of the scope of the ExecutionContext (a potential security\n     * whole).\n     */\n    void enableAuthz();\n\n    boolean disableTarpit();\n    void enableTarpit();\n\n    void setAnonymousAuthorizedAll();\n    void setAnonymousAuthorizedView();\n\n    /** Disable Entity Facade ECA rules (for this thread/ExecutionContext only, does not affect other things happening\n     * in the system).\n     * @return boolean following same pattern as disableAuthz(), and should be handled the same way.\n     */\n    boolean disableEntityEca();\n    /** Disable Entity Facade ECA rules (for this thread/ExecutionContext only, does not affect other things happening\n     * in the system).\n     */\n    void enableEntityEca();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/ArtifactExecutionInfo.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport java.io.Writer;\nimport java.math.BigDecimal;\nimport java.util.Collections;\nimport java.util.AbstractMap.SimpleEntry;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.stream.Collectors;\nimport java.util.stream.Stream;\n\n/** Information about execution of an artifact as the system is running */\n@SuppressWarnings(\"unused\")\npublic interface ArtifactExecutionInfo {\n    enum ArtifactType { AT_XML_SCREEN, AT_XML_SCREEN_TRANS, AT_XML_SCREEN_CONTENT, AT_SERVICE, AT_ENTITY, AT_REST_PATH, AT_OTHER }\n    enum AuthzAction { AUTHZA_VIEW, AUTHZA_CREATE, AUTHZA_UPDATE, AUTHZA_DELETE, AUTHZA_ALL }\n    enum AuthzType { AUTHZT_ALLOW, AUTHZT_DENY, AUTHZT_ALWAYS }\n\n    ArtifactType AT_XML_SCREEN = ArtifactType.AT_XML_SCREEN;\n    ArtifactType AT_XML_SCREEN_TRANS = ArtifactType.AT_XML_SCREEN_TRANS;\n    ArtifactType AT_XML_SCREEN_CONTENT = ArtifactType.AT_XML_SCREEN_CONTENT;\n    ArtifactType AT_SERVICE = ArtifactType.AT_SERVICE;\n    ArtifactType AT_ENTITY = ArtifactType.AT_ENTITY;\n    ArtifactType AT_REST_PATH = ArtifactType.AT_REST_PATH;\n    ArtifactType AT_OTHER = ArtifactType.AT_OTHER;\n\n    AuthzAction AUTHZA_VIEW = AuthzAction.AUTHZA_VIEW;\n    AuthzAction AUTHZA_CREATE = AuthzAction.AUTHZA_CREATE;\n    AuthzAction AUTHZA_UPDATE = AuthzAction.AUTHZA_UPDATE;\n    AuthzAction AUTHZA_DELETE = AuthzAction.AUTHZA_DELETE;\n    AuthzAction AUTHZA_ALL = AuthzAction.AUTHZA_ALL;\n    Map<String, AuthzAction> authzActionByName = Collections.unmodifiableMap(Stream.of(\n            new SimpleEntry<>(\"view\", AUTHZA_VIEW), new SimpleEntry<>(\"create\", AUTHZA_CREATE),\n            new SimpleEntry<>(\"update\", AUTHZA_UPDATE), new SimpleEntry<>(\"delete\", AUTHZA_DELETE),\n            new SimpleEntry<>(\"all\", AUTHZA_ALL)).collect(Collectors.toMap(SimpleEntry::getKey, SimpleEntry::getValue)));\n\n    AuthzType AUTHZT_ALLOW = AuthzType.AUTHZT_ALLOW;\n    AuthzType AUTHZT_DENY = AuthzType.AUTHZT_DENY;\n    AuthzType AUTHZT_ALWAYS = AuthzType.AUTHZT_ALWAYS;\n\n    String getName();\n    ArtifactType getTypeEnum();\n    String getTypeDescription();\n    AuthzAction getActionEnum();\n    String getActionDescription();\n\n    String getAuthorizedUserId();\n    AuthzType getAuthorizedAuthzType();\n    AuthzAction getAuthorizedActionEnum();\n    boolean isAuthorizationInheritable();\n    boolean getAuthorizationWasRequired();\n    boolean getAuthorizationWasGranted();\n\n    long getRunningTime();\n    BigDecimal getRunningTimeMillis();\n    long getThisRunningTime();\n    BigDecimal getThisRunningTimeMillis();\n    long getChildrenRunningTime();\n    BigDecimal getChildrenRunningTimeMillis();\n    List<ArtifactExecutionInfo> getChildList();\n    ArtifactExecutionInfo getParent();\n    BigDecimal getPercentOfParentTime();\n\n    void print(Writer writer, int level, boolean children);\n    String toBasicString();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/ArtifactTarpitException.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport org.moqui.BaseArtifactException;\n\n/** Thrown when artifact tarpit is hit, too many uses of artifact. */\npublic class ArtifactTarpitException extends BaseArtifactException {\n\n    private Integer retryAfterSeconds = null;\n\n    public ArtifactTarpitException(String str) { super(str); }\n    public ArtifactTarpitException(String str, Throwable nested) { super(str, nested); }\n    public ArtifactTarpitException(String str, Integer retryAfterSeconds) {\n        super(str);\n        this.retryAfterSeconds = retryAfterSeconds;\n    }\n\n    public Integer getRetryAfterSeconds() { return retryAfterSeconds; }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/AuthenticationRequiredException.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport org.moqui.BaseArtifactException;\n\n/** Thrown when an artifact or operation requires authentication and no user is logged in. */\npublic class AuthenticationRequiredException extends BaseArtifactException {\n    public AuthenticationRequiredException(String str) { super(str); }\n    public AuthenticationRequiredException(String str, Throwable nested) { super(str, nested); }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/CacheFacade.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport org.moqui.jcache.MCache;\nimport javax.cache.Cache;\nimport java.util.Set;\n\n/** A facade used for managing and accessing Cache instances. */\npublic interface CacheFacade {\n    void clearAllCaches();\n    void clearCachesByPrefix(String prefix);\n\n    /** Get the named Cache, creating one based on configuration and defaults if none exists.\n     * Defaults to local cache if no configuration found. */\n    Cache getCache(String cacheName);\n    /** A type-safe variation on getCache for configured caches. */\n    <K, V> Cache<K, V> getCache(String cacheName, Class<K> keyType, Class<V> valueType);\n    /** Get the named local Cache (MCache instance), creating one based on defaults if none exists.\n     * If the cache is configured with type != 'local' this will return an error. */\n    MCache getLocalCache(String cacheName);\n    /** Get the named distributed Cache, creating one based on configuration and defaults if none exists.\n     * If the cache is configured without type != 'distributed' this will return an error. */\n    Cache getDistributedCache(String cacheName);\n\n    /** Register an externally created cache for future gets, inclusion in cache management tools, etc.\n     * If a cache with the same name exists the call will be ignored (ie like putIfAbsent). */\n    void registerCache(Cache cache);\n\n    Set<String> getCacheNames();\n    boolean cacheExists(String cacheName);\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/ElasticFacade.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport org.moqui.util.RestClient;\n\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.Future;\n\n/** A facade for ElasticSearch operations.\n *\n * Designed for ElasticSearch 7.0 and later with the one doc type per index constraint.\n * See https://www.elastic.co/guide/en/elasticsearch/reference/current/removal-of-types.html\n */\npublic interface ElasticFacade {\n\n    /** Get a client for the 'default' cluster, same as calling getClient(\"default\") */\n    ElasticClient getDefault();\n    /** Get a client for named cluster configured in Moqui Conf XML elastic-facade.cluster */\n    ElasticClient getClient(String clusterName);\n\n    List<ElasticClient> getClientList();\n\n    interface ElasticClient {\n        String getClusterName();\n        String getClusterLocation();\n        /** Returns a Map with the response from ElasticSearch for GET on the root path with ES server info */\n        Map getServerInfo();\n\n        /** Returns true if index or alias exists. See https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-exists.html */\n        boolean indexExists(String index);\n        /** Returns true if alias exists. See https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-alias-exists.html */\n        boolean aliasExists(String alias);\n        /** Create an index with optional document mapping and alias. See https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html */\n        void createIndex(String index, Map docMapping, String alias);\n        /** Put document mapping on an existing index. See https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-put-mapping.html */\n        void putMapping(String index, Map docMapping);\n        /** Delete an index. See https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-delete-index.html */\n        void deleteIndex(String index);\n\n        /** Index a complete document (create or update). See https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html */\n        void index(String index, String _id, Map document);\n        /** Partial document update. See https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-update.html */\n        void update(String index, String _id, Map documentFragment);\n        /** Delete a document. See https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete.html */\n        void delete(String index, String _id);\n        /** Delete documents by query. See https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete-by-query.html */\n        Integer deleteByQuery(String index, Map queryMap);\n        /** Perform bulk operations. See https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html\n         * @param actionSourceList List of action objects each followed by a source object if relevant. */\n        void bulk(String index, List<Map> actionSourceList);\n        /** Bulk index documents with given index name and _id from the idField in each document (if idField empty don't specify ID, let ES generate) */\n        void bulkIndex(String index, String idField, List<Map> documentList);\n        void bulkIndex(String index, String docType, String idField, List<Map> documentList, boolean refresh);\n\n        /** Get full/wrapped single document by ID. See https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-get.html */\n        Map get(String index, String _id);\n        /** Get source for a single document by ID. See https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-get.html */\n        Map getSource(String index, String _id);\n        /** Get multiple documents by ID. See https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-multi-get.html */\n        List<Map> get(String index, List<String> _idList);\n\n        /** Search documents and get the plain object response.\n         * See https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-body.html */\n        Map search(String index, Map searchMap);\n        /** Search documents. Result is the list in 'hits.hits' from the plain object returned for convenience.\n         * See https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-body.html */\n        List<Map> searchHits(String index, Map searchMap);\n        /** Validate a query Map.\n         * Returns null if valid otherwise returns Map from JSON response with 'valid' boolean and if explain is true also 'explanations' for more information.\n         * See https://www.elastic.co/guide/en/elasticsearch/reference/current/search-validate.html\n         * @param queryMap Map sent to ElasticSearch as the 'query' field (should not include 'query' entry, may include 'bool', 'query_string', etc) */\n        Map validateQuery(String index, Map queryMap, boolean explain);\n\n        /** Count documents and get the long int value from the response.\n         * See https://www.elastic.co/guide/en/elasticsearch/reference/current/search-count.html */\n        long count(String index, Map countMap);\n        /** Count documents and get the plain object response.\n         * See https://www.elastic.co/guide/en/elasticsearch/reference/current/search-count.html */\n        Map countResponse(String index, Map countMap);\n\n        /** Create a Point-In-Time checkpoint and get the ID\n         * See https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html#scroll-search-results */\n        String getPitId(String index, String keepAlive);\n        /** Delete a Point-In-Time checkpoint, should always be done when finished (close operation) */\n        void deletePit(String pitId);\n\n        /** Basic REST endpoint synchronous call */\n        RestClient.RestResponse call(RestClient.Method method, String index, String path, Map<String, String> parameters, Object bodyJsonObject);\n        /** Basic REST endpoint future (asynchronous) call */\n        Future<RestClient.RestResponse> callFuture(RestClient.Method method, String index, String path, Map<String, String> parameters, Object bodyJsonObject);\n        /** Make a RestClient with configured protocol/host/port and user/password if configured, RequestFactory for this ElasticClient, and the given parameters */\n        RestClient makeRestClient(RestClient.Method method, String index, String path, Map<String, String> parameters);\n\n        /** Check and if needed create ElasticSearch indexes for all DataDocument records with given indexName */\n        void checkCreateDataDocumentIndexes(String indexName);\n        /** Check and if needed create ElasticSearch index for DataDocument with given ID */\n        void checkCreateDataDocumentIndex(String dataDocumentId);\n        /** Put document mappings for all DataDocument records with given indexName */\n        void putDataDocumentMappings(String indexName);\n        /** Verify index aliases and dataDocumentId based indexes from all distinct _index and _type values in documentList */\n        void verifyDataDocumentIndexes(List<Map> documentList);\n        /** Bulk index documents with standard _index, _type, _id, and _timestamp fields which are used for the index and id per\n         * document but removed from the actual document sent to ElasticSearch; note that for legacy reasons related to one type\n         * per index the _type is used for the index name */\n        void bulkIndexDataDocument(List<Map> documentList);\n\n        /** Convert Object (generally Map or List) to JSON String using internal ElasticSearch specific settings */\n        String objectToJson(Object jsonObject);\n        /** Convert JSON String to Object (generally Map or List) using internal ElasticSearch specific settings */\n        Object jsonToObject(String jsonString);\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/ExecutionContext.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport java.util.List;\nimport java.util.Map;\nimport java.util.concurrent.Future;\n\nimport groovy.lang.Closure;\nimport org.moqui.entity.EntityFacade;\nimport org.moqui.screen.ScreenFacade;\nimport org.moqui.service.ServiceFacade;\nimport org.moqui.util.ContextBinding;\nimport org.moqui.util.ContextStack;\n\nimport javax.annotation.Nonnull;\nimport javax.annotation.Nullable;\n\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\n\n/**\n * Interface definition for object used throughout the Moqui Framework to manage contextual execution information and\n * tool interfaces. One instance of this object will exist for each thread running code and will be applicable for that\n * thread only.\n */\n@SuppressWarnings(\"unused\")\npublic interface ExecutionContext {\n    /** Get the ExecutionContextFactory this came from. */\n    @Nonnull ExecutionContextFactory getFactory();\n\n    /** Returns a Map that represents the current local variable space (context) in whatever is being run. */\n    @Nonnull ContextStack getContext();\n    @Nonnull ContextBinding getContextBinding();\n    /** Returns a Map that represents the global/root variable space (context), ie the bottom of the context stack. */\n    @Nonnull Map<String, Object> getContextRoot();\n\n    /** Get an instance object from the named ToolFactory instance (loaded by configuration). Some tools return a\n     * singleton instance, others a new instance each time it is used and that instance is saved with this\n     * ExecutionContext to be reused. The instanceClass may be null in scripts or other contexts where static typing\n     * is not needed */\n    <V> V getTool(@Nonnull String toolName, Class<V> instanceClass, Object... parameters);\n\n    /** If running through a web (HTTP servlet) request offers access to the various web objects/information.\n     * If not running in a web context will return null.\n     */\n    @Nullable WebFacade getWeb();\n\n    /** For information about the user and user preferences (including locale, time zone, currency, etc). */\n    @Nonnull UserFacade getUser();\n\n    /** For user messages including general feedback, errors, and field-specific validation errors. */\n    @Nonnull MessageFacade getMessage();\n\n    /** For information about artifacts as they are being executed. */\n    @Nonnull ArtifactExecutionFacade getArtifactExecution();\n\n    /** For localization (l10n) functionality, like localizing messages. */\n    @Nonnull L10nFacade getL10n();\n\n    /** For accessing resources by location string (http://, jar://, component://, content://, classpath://, etc). */\n    @Nonnull ResourceFacade getResource();\n\n    /** For trace, error, etc logging to the console, files, etc. */\n    @Nonnull LoggerFacade getLogger();\n\n    /** For managing and accessing caches. */\n    @Nonnull CacheFacade getCache();\n\n    /** For transaction operations use this facade instead of the JTA UserTransaction and TransactionManager. See javadoc comments there for examples of code usage. */\n    @Nonnull TransactionFacade getTransaction();\n\n    /** For interactions with a relational database. */\n    @Nonnull EntityFacade getEntity();\n\n    /** For interactions with ElasticSearch using the built in HTTP REST client. */\n    @Nonnull ElasticFacade getElastic();\n\n    /** For calling services (local or remote, sync or async or scheduled). */\n    @Nonnull ServiceFacade getService();\n\n    /** For rendering screens for general use (mostly for things other than web pages or web page snippets). */\n    @Nonnull ScreenFacade getScreen();\n\n    @Nonnull NotificationMessage makeNotificationMessage();\n    @Nonnull List<NotificationMessage> getNotificationMessages(@Nullable String topic);\n\n    /** This should be called by a filter or servlet at the beginning of an HTTP request to initialize a web facade\n     * for the current thread. */\n    void initWebFacade(@Nonnull String webappMoquiName, @Nonnull HttpServletRequest request, @Nonnull HttpServletResponse response);\n\n    /** A lightweight asynchronous executor. ExecutionContext aware and uses a new ExecutionContext in the separate thread\n     * based on the current (retaining user, disable authz, etc and may be improved over time to copy more). */\n    Future runAsync(@Nonnull Closure closure);\n\n    /** This should be called when the ExecutionContext won't be used any more. Implementations should make sure\n     * any active transactions, database connections, etc are closed.\n     */\n    void destroy();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/ExecutionContextFactory.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport groovy.lang.GroovyClassLoader;\nimport org.moqui.entity.EntityFacade;\nimport org.moqui.screen.ScreenFacade;\nimport org.moqui.service.ServiceFacade;\n\nimport jakarta.servlet.ServletContext;\n\nimport javax.annotation.Nonnull;\nimport jakarta.websocket.server.ServerContainer;\nimport java.util.LinkedHashMap;\nimport java.util.List;\n\n/**\n * Interface for the object that will be used to get an ExecutionContext object and manage framework life cycle.\n */\npublic interface ExecutionContextFactory {\n    /** Get the ExecutionContext associated with the current thread or initialize one and associate it with the thread. */\n    @Nonnull ExecutionContext getExecutionContext();\n\n    /** Destroy the active Execution Context. When another is requested in this thread a new one will be created. */\n    void destroyActiveExecutionContext();\n\n    /** Called after construction but before registration with Moqui/Servlet, check for empty database and load configured data.\n     * If empty-db-load is not done and on-start-load-types has a value handles that as well.\n     * Also loads type 'test' data if instance_purpose=test. */\n    boolean checkEmptyDb();\n    /** Destroy this ExecutionContextFactory and all resources it uses (all facades, tools, etc) */\n    void destroy();\n    boolean isDestroyed();\n\n    /** Get the path of the runtime directory */\n    @Nonnull String getRuntimePath();\n    @Nonnull String getMoquiVersion();\n\n    /** Get the named ToolFactory instance (loaded by configuration) */\n    <V> ToolFactory<V> getToolFactory(@Nonnull String toolName);\n    /** Get an instance object from the named ToolFactory instance (loaded by configuration); the instanceClass may be\n     * null in scripts or other contexts where static typing is not needed */\n    <V> V getTool(@Nonnull String toolName, Class<V> instanceClass, Object... parameters);\n\n    /** Get a Map where each key is a component name and each value is the component's base location. */\n    @Nonnull LinkedHashMap<String, String> getComponentBaseLocations();\n\n    /** For localization (l10n) functionality, like localizing messages. */\n    @Nonnull L10nFacade getL10n();\n\n    /** For accessing resources by location string (http://, jar://, component://, content://, classpath://, etc). */\n    @Nonnull ResourceFacade getResource();\n\n    /** For trace, error, etc logging to the console, files, etc. */\n    @Nonnull LoggerFacade getLogger();\n\n    /** For managing and accessing caches. */\n    @Nonnull CacheFacade getCache();\n\n    /** For transaction operations use this facade instead of the JTA UserTransaction and TransactionManager. See javadoc comments there for examples of code usage. */\n    @Nonnull TransactionFacade getTransaction();\n\n    /** For interactions with a relational database. */\n    @Nonnull EntityFacade getEntity();\n\n    /** For interactions with ElasticSearch using the built in HTTP REST client. */\n    @Nonnull ElasticFacade getElastic();\n\n    /** For calling services (local or remote, sync or async or scheduled). */\n    @Nonnull ServiceFacade getService();\n\n    /** For rendering screens for general use (mostly for things other than web pages or web page snippets). */\n    @Nonnull ScreenFacade getScreen();\n\n    /** Get the framework ClassLoader, aware of all additional classes in runtime and in components. */\n    @Nonnull ClassLoader getClassLoader();\n    /** Get a GroovyClassLoader for runtime compilation, etc. */\n    @Nonnull GroovyClassLoader getGroovyClassLoader();\n\n    /** The ServletContext, if Moqui was initialized in a webapp (generally through MoquiContextListener) */\n    @Nonnull ServletContext getServletContext();\n    /** The WebSocket ServerContainer, if found in 'jakarta.websocket.server.ServerContainer' ServletContext attribute */\n    @Nonnull ServerContainer getServerContainer();\n    /** For starting initialization only, tell the ECF about the ServletContext for getServletContext() and getServerContainer() */\n    void initServletContext(ServletContext sc);\n\n    void registerNotificationMessageListener(@Nonnull NotificationMessageListener nml);\n\n    void registerLogEventSubscriber(@Nonnull LogEventSubscriber subscriber);\n    List<LogEventSubscriber> getLogEventSubscribers();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/L10nFacade.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.sql.Timestamp;\nimport java.util.Calendar;\nimport java.util.Locale;\nimport java.util.TimeZone;\n\n/** For localization (l10n) functionality, like localizing messages. */\npublic interface L10nFacade {\n\n    /** Use the current locale (see ec.user.getLocale() method) to localize the message based on data in the\n     * moqui.basic.LocalizedMessage entity. The localized message may have variables inserted using the ${} syntax that\n     * when this is called through ec.resource.expand().\n     *\n     * The approach here is that original messages are actual messages in the primary language of the application. This\n     * reduces issues with duplicated messages compared to the approach of explicit/artificial property keys. Longer\n     * messages (over 255 characters) should use an artificial message key with the actual value always coming\n     * from the database.\n     */\n    String localize(String original);\n    /** Localize a String using the given Locale instead of the current user's. */\n    String localize(String original, Locale locale);\n\n    /** Format currency amount for user to view.\n     * @param amount An object representing the amount, should be a subclass of Number.\n     * @param uomId The uomId (ISO currency code), required.\n     * @param fractionDigits Number of digits after the decimal point to display. If null defaults to number defined\n     *                       by java.util.Currency.defaultFractionDigits() for the specified currency in uomId.\n     * @param locale Locale to use for formatting.\n     * @param hideSymbol option to hide the Symbol of the currency and only display the number formatted according\n     *                   to locale.\n     * @return The formatted currency amount.\n     */\n    String formatCurrency(Object amount, String uomId, Integer fractionDigits, Locale locale, boolean hideSymbol);\n    String formatCurrency(Object amount, String uomId, Integer fractionDigits, Locale locale);\n    String formatCurrency(Object amount, String uomId, Integer fractionDigits);\n    String formatCurrency(Object amount, String uomId);\n    String formatCurrencyNoSymbol(Object amount, String uomId);\n\n    /** Round currency according to the currency's specified amount of digits and rounding method.\n     * @param amount The amount in BigDecimal to be rounded.\n     * @param uomId The currency uomId (ISO currency code), required\n     * @param precise A boolean indicating whether the currency should be treated with an additional digit\n     * @param roundingMode Rounding method to use (e.g. RoundingMode.HALF_UP)\n     * @return The rounded currency amount.\n     */\n    BigDecimal roundCurrency(BigDecimal amount, String uomId, boolean precise, RoundingMode roundingMode);\n    BigDecimal roundCurrency(java.math.BigDecimal amount, String uomId, boolean precise, int roundingMethod);\n    BigDecimal roundCurrency(java.math.BigDecimal amount, String uomId, boolean precise);\n    BigDecimal roundCurrency(java.math.BigDecimal amount, String uomId);\n\n    /** Format a Number, Timestamp, Date, Time, or Calendar object using the given format string. If no format string\n     * is specified the default for the user's locale and time zone will be used.\n     *\n     * @param value The value to format. Must be a Number, Timestamp, Date, Time, or Calendar object.\n     * @param format The format string used to specify how to format the value.\n     * @return The value as a String formatted according to the format string.\n     */\n    String format(Object value, String format);\n    String format(Object value, String format, Locale locale, TimeZone tz);\n\n    java.sql.Time parseTime(String input, String format);\n    java.sql.Date parseDate(String input, String format);\n    Timestamp parseTimestamp(String input, String format);\n    Timestamp parseTimestamp(String input, String format, Locale locale, TimeZone timeZone);\n    java.util.Calendar parseDateTime(String input, String format);\n    String formatDateTime(Calendar input, String format, Locale locale, TimeZone tz);\n\n    java.math.BigDecimal parseNumber(String input, String format);\n    String formatNumber(Number input, String format, Locale locale);\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/LogEventSubscriber.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport org.apache.logging.log4j.core.LogEvent;\n\n/** A simple interface for a method to receive LogEvent instances.\n * To use implement this interface and call ExecutionContextFactory.registerLogEventSubscriber(). */\npublic interface LogEventSubscriber {\n    void process(LogEvent event);\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/LoggerFacade.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\n/** For trace, error, etc logging to the console, files, etc. */\npublic interface LoggerFacade {\n    /** Log level copied from org.apache.logging.log4j.spi.StandardLevel to avoid requiring that on the classpath. */\n    int\tOFF_INT = 0;\n    int\tFATAL_INT = 100;\n    int\tERROR_INT = 200;\n    int\tWARN_INT = 300;\n    int\tINFO_INT = 400;\n    int\tDEBUG_INT = 500;\n    int\tTRACE_INT = 600;\n    int\tALL_INT = 2147483647;\n\n    /** Log a message and/or Throwable error at the given level.\n     *\n     * This is meant to be used for scripts, xml-actions, etc.\n     *\n     * In Java or Groovy classes it is better to use SLF4J directly, with something like:\n     * <code>\n     * public class Wombat {\n     *   final static org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(Wombat.class);\n     *\n     *   public void setTemperature(Integer temperature) {\n     *     Integer oldT = t;\n     *     Integer t = temperature;\n     *     logger.debug(\"Temperature set to {}. Old temperature was {}.\", t, oldT);\n     *     if(temperature.intValue() &gt; 50) {\n     *       logger.info(\"Temperature has risen above 50 degrees.\");\n     *     }\n     *   }\n     * }\n     * </code>\n     *\n     * @param level The logging level. Options should come from org.apache.log4j.Level.  \n     * @param message The message text to log. If contains ${} syntax will be expanded from the current context.\n     * @param thrown Throwable with stack trace, etc to be logged along with the message.\n     */\n    void log(int level, String message, Throwable thrown);\n\n    void trace(String message);\n    void debug(String message);\n    void info(String message);\n    void warn(String message);\n    void error(String message);\n\n    void trace(String message, Throwable thrown);\n    void debug(String message, Throwable thrown);\n    void info(String message, Throwable thrown);\n    void warn(String message, Throwable thrown);\n    void error(String message, Throwable thrown);\n\n    /** Is the given logging level enabled? */\n    boolean logEnabled(int level);\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/MessageFacade.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport java.io.Serializable;\nimport java.util.List;\nimport org.moqui.context.NotificationMessage.NotificationType;\n\n/** For user messages including general feedback, errors, and field-specific validation errors. */\npublic interface MessageFacade {\n    NotificationType info = NotificationType.info;\n    NotificationType success = NotificationType.success;\n    NotificationType warning = NotificationType.warning;\n    NotificationType danger = NotificationType.danger;\n\n    /** Immutable List of general (non-error) messages that will be shown to the user. */\n    List<String> getMessages();\n    /** Immutable List of general (non-error) messages that will be shown to the user. */\n    List<MessageInfo> getMessageInfos();\n    /** Make a single String with all messages separated by the new-line character.\n     * @return String with all messages.\n     */\n    String getMessagesString();\n    /** Add a non-error message for internal user to see, for messages not meant for display on public facing sites and portals.\n     * @param message The message to add.\n     */\n    void addMessage(String message);\n\n    /** Add a message not meant for display on public facing sites and portals. */\n    void addMessage(String message, NotificationType type);\n    /** A variation on addMessage() where the type is a String instead of NotificationType.\n     * @param type String representing one of the NotificationType values: info, success, warning, danger. Defaults to info.\n     */\n    void addMessage(String message, String type);\n\n    /** Add a message meant for display on public facing sites and portals leaving standard messages and errors for internal\n     * applications. Also adds the message like a call to addMessage() for internal and other display so that does not also need to be called. */\n    void addPublic(String message, NotificationType type);\n    /** A variation on addPublic where the type is a String instead of NotificationType.\n     * Also adds the message like a call to addMessage() for internal and other display so that does not also need to be called.\n     * @param type String representing one of the NotificationType values: info, success, warning, danger. Defaults to info.\n     */\n    void addPublic(String message, String type);\n\n    List<String> getPublicMessages();\n    List<MessageInfo> getPublicMessageInfos();\n\n    /** Immutable List of error messages that should be shown to internal users. */\n    List<String> getErrors();\n    /** Add a error message for the user to see.\n     * NOTE: system errors not meant for the user should be thrown as exceptions instead.\n     * @param error The error message to add\n     */\n    void addError(String error);\n\n    /** Immutable List of ValidationError objects that should be shown to internal or public users in the context of the\n     * fields that triggered the error.\n     */\n    List<ValidationError> getValidationErrors();\n    void addValidationError(String form, String field, String serviceName, String message, Throwable nested);\n    void addError(ValidationError error);\n\n    /** See if there is are any errors. Checks both error strings and validation errors. */\n    boolean hasError();\n    /** Make a single String with all error messages separated by the new-line character.\n     * @return String with all error messages.\n     */\n    String getErrorsString();\n\n    /** Clear all messages: general/internal from addMessage() and public from addPublic(), then calls clearErrors() for\n     * errors from addError(), and validation errors from addValidationError() */\n    void clearAll();\n    /** Clear error messages including errors from addError(), and validation errors from addValidationError();\n     * before clearing adds these error messages to the general/internal messages list (same as addMessage()) so the\n     * messages are not lost and make it back to the user (if applicable) */\n    void clearErrors();\n\n    /** Copy all messages from this instance of MessageFacade to another, mostly for internal framework use */\n    void copyMessages(MessageFacade mf);\n    /** Save current errors on a stack and clear them, mostly for internal framework use */\n    void pushErrors();\n    /** Remove last pushed errors from the stack and add them to current errors, mostly for internal framework use */\n    void popErrors();\n\n    class MessageInfo implements Serializable {\n        String message;\n        NotificationType type;\n        public MessageInfo(String message, NotificationType type) {\n            this.message = message;\n            this.type = type != null ? type : info;\n        }\n        public MessageInfo(String message, String type) {\n            this.message = message;\n            if (type != null && !type.isEmpty()) {\n                switch (Character.toLowerCase(type.charAt(0))) {\n                    case 's': this.type = success; break;\n                    case 'w': this.type = warning; break;\n                    case 'd': this.type = danger; break;\n                    default: this.type = info;\n                }\n            } else {\n                this.type = info;\n            }\n        }\n        public String getMessage() { return message; }\n        public NotificationType getType() { return type; }\n        public String getTypeString() { return type.toString(); }\n        public String toString() { return \"[\" + type.toString() + \"] \" + message; }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/MessageFacadeException.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport org.moqui.BaseArtifactException;\n\npublic class MessageFacadeException extends BaseArtifactException {\n    protected final MessageFacade messageFacade;\n    public MessageFacadeException(MessageFacade mf, Throwable nested) { super(mf.getErrorsString(), nested); messageFacade = mf; }\n    public MessageFacade getMessageFacade() { return messageFacade; }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/MoquiLog4jAppender.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport java.io.Serializable;\nimport java.util.List;\nimport org.apache.logging.log4j.core.*;\nimport org.apache.logging.log4j.core.appender.AbstractAppender;\nimport org.apache.logging.log4j.core.config.Property;\nimport org.apache.logging.log4j.core.config.plugins.Plugin;\nimport org.apache.logging.log4j.core.config.plugins.PluginAttribute;\nimport org.apache.logging.log4j.core.config.plugins.PluginElement;\nimport org.apache.logging.log4j.core.config.plugins.PluginFactory;\nimport org.moqui.Moqui;\n\n@Plugin(name=\"MoquiLog4jAppender\", category=\"Core\", elementType=\"appender\", printObject=true)\npublic final class MoquiLog4jAppender extends AbstractAppender {\n\n    // private final ReadWriteLock rwLock = new ReentrantReadWriteLock();\n    // private final Lock readLock = rwLock.readLock();\n\n    protected MoquiLog4jAppender(String name, Filter filter, Layout<? extends Serializable> layout,\n                                 final boolean ignoreExceptions, final Property[] properties) {\n        super(name, filter, layout, ignoreExceptions, properties);\n    }\n\n    @Override\n    public void append(LogEvent event) {\n        ExecutionContextFactory ecf = Moqui.getExecutionContextFactory();\n        // ECF may not yet be initialized\n        if (ecf == null) return;\n        List<LogEventSubscriber> subscribers = ecf.getLogEventSubscribers();\n        int subscribersSize = subscribers.size();\n        for (int i = 0; i < subscribersSize; i++) {\n            LogEventSubscriber subscriber = subscribers.get(i);\n            subscriber.process(event);\n        }\n        /*\n        readLock.lock();\n        try {\n            final String message = (String) getLayout().toSerializable(event);\n            System.out.write(message);\n        } catch (Exception e) {\n            if (!ignoreExceptions()) { throw new AppenderLoggingException(e); }\n        } finally {\n            readLock.unlock();\n        }\n        */\n    }\n\n    @PluginFactory\n    public static MoquiLog4jAppender createAppender(@PluginAttribute(\"name\") String name,\n                                                    @PluginElement(\"Filter\") final Filter filter) {\n        // not using Layout config, let subscribers choose: @PluginElement(\"Layout\") Layout<? extends Serializable> layout\n        if (name == null) { LOGGER.error(\"No name provided for MoquiLog4jAppender\"); return null; }\n        return new MoquiLog4jAppender(name, filter, null, true, null);\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/NotificationMessage.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport java.util.Map;\nimport java.util.Set;\n\n@SuppressWarnings(\"unused\")\npublic interface NotificationMessage extends java.io.Serializable {\n    enum NotificationType { info, success, warning, danger }\n    NotificationType info = NotificationType.info;\n    NotificationType success = NotificationType.success;\n    NotificationType warning = NotificationType.warning;\n    NotificationType danger = NotificationType.danger;\n\n    NotificationMessage userId(String userId);\n    NotificationMessage userIds(Set<String> userIds);\n    Set<String> getUserIds();\n    NotificationMessage userGroupId(String userGroupId);\n    String getUserGroupId();\n\n    /** Get userId for all users associated with this notification, directly or through the UserGroup, and who have\n     * NotificationTopicUser.receiveNotifications=Y, which if not set (or there is no NotificationTopicUser record)\n     * defaults to NotificationTopic.receiveNotifications (if not set defaults to Y) */\n    Set<String> getNotifyUserIds();\n\n    NotificationMessage topic(String topic);\n    String getTopic();\n\n    NotificationMessage subTopic(String subTopic);\n    String getSubTopic();\n\n    /** Set the message as a JSON String. The top-level should be a Map (JSON Object).\n     * @param messageJson The message as a JSON string containing a Map (JSON Object)\n     * @return Self-reference for convenience\n     */\n    NotificationMessage message(String messageJson);\n    /** Set the message as a JSON String. The top-level should be a Map (JSON Object).\n     * @param message The message as a Map (JSON Object), must be convertible to JSON String\n     * @return Self-reference for convenience\n     */\n    NotificationMessage message(Map<String, Object> message);\n    String getMessageJson();\n    Map<String, Object> getMessageMap();\n\n    /** Set the title to display, a GString (${} syntax) that will be expanded using the message Map; may be a localization template name */\n    NotificationMessage title(String title);\n    /** Get the title, expanded using the message Map; if not set and topic has a NotificationTopic record will default to value there */\n    String getTitle();\n    /** Set the link to get more detail about the notification or go to its source, a GString (${} syntax) expanded using the message Map */\n    NotificationMessage link(String link);\n    /** Get the link to detail/source, expanded using the message Map; if not set and topic has a NotificationTopic record will default to value there */\n    String getLink();\n\n    NotificationMessage type(NotificationType type);\n    /** Must be a String for a valid NotificationType (ie info, success, warning, or danger) */\n    NotificationMessage type(String type);\n    /** Get the type as a String; if not set and topic has a NotificationTopic record will default to value there */\n    String getType();\n\n    NotificationMessage showAlert(boolean show);\n    /** Show an alert for this notification? If not set and topic has a NotificationTopic record will default to value there */\n    boolean isShowAlert();\n    NotificationMessage alertNoAutoHide(boolean noAutoHide);\n    boolean isAlertNoAutoHide();\n\n    NotificationMessage persistOnSend(Boolean persist);\n    boolean isPersistOnSend();\n\n    NotificationMessage emailTemplateId(String id);\n    String getEmailTemplateId();\n\n    NotificationMessage emailMessageSave(Boolean save);\n    boolean isEmailMessageSave();\n\n    /** Call after send() to get emailMessageId values (if emailMessageSave is true) */\n    Map<String, String> getEmailMessageIdByUserId();\n\n    /** Send this Notification Message.\n     * @param persist If true this is persisted and message received is tracked. If false this is sent to active topic\n     *                listeners only.\n     * @return Self-reference for convenience\n     */\n    NotificationMessage send(boolean persist);\n    /** Send this Notification Message using persistOnSend setting (defaults to false). */\n    NotificationMessage send();\n\n    String getNotificationMessageId();\n    NotificationMessage markSent(String userId);\n    NotificationMessage markViewed(String userId);\n\n    /** Get a Map with: topic, sentDate, notificationMessageId, message, title, link, type, and showAlert using the get method for each */\n    Map<String, Object> getWrappedMessageMap();\n    /** Result of getWrappedMessageMap() as a JSON String */\n    String getWrappedMessageJson();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/NotificationMessageListener.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\npublic interface NotificationMessageListener {\n    void init(ExecutionContextFactory ecf);\n    void destroy();\n    void onMessage(NotificationMessage nm);\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/PasswordChangeRequiredException.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport org.apache.shiro.authc.CredentialsException;\n\n/** Thrown when user's password is correct but account requires second factor authentication. */\npublic class PasswordChangeRequiredException extends CredentialsException {\n    public PasswordChangeRequiredException() { super(); }\n    public PasswordChangeRequiredException(String str) { super(str); }\n    public PasswordChangeRequiredException(Throwable nested) { super(nested); }\n    public PasswordChangeRequiredException(String str, Throwable nested) { super(str, nested); }\n}\n\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/ResourceFacade.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport org.moqui.resource.ResourceReference;\n\nimport jakarta.activation.DataSource;\n\nimport javax.xml.transform.stream.StreamSource;\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.io.Writer;\nimport java.net.URI;\nimport java.util.Map;\n\n/** For accessing resources by location string (http://, jar://, component://, content://, classpath://, etc). */\npublic interface ResourceFacade {\n    /** Get a ResourceReference representing the Moqui location string passed.\n     *\n     * @param location A URL-style location string. In addition to the standard URL protocols (http, https, ftp, jar,\n     * and file) can also have the special Moqui protocols of \"component://\" for a resource location relative to a\n     * component base location, \"content://\" for a resource in the content repository, and \"classpath://\" to get a\n     * resource from the Java classpath.\n     */\n    ResourceReference getLocationReference(String location);\n    ResourceReference getUriReference(URI uri);\n\n    /** Open an InputStream to read the contents of a file/document at a location.\n     *\n     * @param location A URL-style location string that also support the Moqui-specific component and content protocols.\n     */\n    InputStream getLocationStream(String location);\n\n    /** Get the text at the given location, optionally from the cache (resource.text.location). */\n    String getLocationText(String location, boolean cache);\n    DataSource getLocationDataSource(String location);\n\n    /** Render a template at the given location using the current context and write the output to the given writer. */\n    void template(String location, Writer writer);\n    void template(String location, Writer writer, String defaultExtension);\n    String template(String location, String defaultExtension);\n\n    /** Run a script at the given location (optionally with the given method, like in a groovy class) using the current\n     * context for its variable space.\n     *\n     * @return The value returned by the script, if any.\n     */\n    Object script(String location, String method);\n    Object script(String location, String method, Map additionalContext);\n\n    /** Evaluate a Groovy expression as a condition.\n     *\n     * @return boolean representing the result of evaluating the expression\n     */\n    boolean condition(String expression, String debugLocation);\n    boolean condition(String expression, String debugLocation, Map additionalContext);\n\n    /** Evaluate a Groovy expression as a context field, or more generally as an expression that evaluates to an Object\n     * reference. This can be used to get a value from an expression or to run any general expression or script.\n     *\n     * @return Object reference representing result of evaluating the expression\n     */\n    Object expression(String expr, String debugLocation);\n    Object expression(String expr, String debugLocation, Map additionalContext);\n\n    /** Evaluate a Groovy expression as a GString to be expanded/interpolated into a simple String.\n     *\n     * NOTE: the inputString is always run through the L10nFacade.localize() method before evaluating the\n     * expression in order to implicitly internationalize string expansion.\n     *\n     * @return String representing localized and expanded inputString\n     */\n    String expand(String inputString, String debugLocation);\n    String expand(String inputString, String debugLocation, Map additionalContext);\n    String expand(String inputString, String debugLocation, Map additionalContext, boolean localize);\n    String expandNoL10n(String inputString, String debugLocation);\n\n    Integer xslFoTransform(StreamSource xslFoSrc, StreamSource xsltSrc, OutputStream out, String contentType);\n\n    String getContentType(String filename);\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/ScriptRunner.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport org.moqui.BaseException;\n\npublic interface ScriptRunner {\n    ScriptRunner init(ExecutionContextFactory ecf);\n    Object run(String location, String method, ExecutionContext ec) throws BaseException;\n    void destroy();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/SecondFactorRequiredException.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport org.apache.shiro.authc.AuthenticationException;\n\n/** Thrown when user's password is correct but account requires second factor authentication. */\npublic class SecondFactorRequiredException extends AuthenticationException {\n    public SecondFactorRequiredException() { super(); }\n    public SecondFactorRequiredException(String str) { super(str); }\n    public SecondFactorRequiredException(Throwable nested) { super(nested); }\n    public SecondFactorRequiredException(String str, Throwable nested) { super(str, nested); }\n}\n\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/TemplateRenderer.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport org.moqui.BaseException;\n\nimport java.io.Writer;\n\npublic interface TemplateRenderer {\n    TemplateRenderer init(ExecutionContextFactory ecf);\n    void render(String location, Writer writer) throws BaseException;\n    String stripTemplateExtension(String fileName);\n    void destroy();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/ToolFactory.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\n/** Implement this interface to manage lifecycle and factory for tools initialized and destroyed with Moqui Framework.\n * Implementations must have a public no parameter constructor. */\npublic interface ToolFactory<V> {\n    /** Return a name that the factory will be available under through the ExecutionContextFactory.getToolFactory()\n     * method and instances will be available under through the ExecutionContextFactory.getTool() method. */\n    default String getName() {\n        String className = this.getClass().getSimpleName();\n        int tfIndex = className.indexOf(\"ToolFactory\");\n        if (tfIndex > 0) className = className.substring(0, tfIndex);\n        return className;\n    }\n\n    /** Initialize the underlying tool and if the instance is a singleton also the instance. */\n    default void init(ExecutionContextFactory ecf) { }\n\n    /** Rarely used, initialize before Moqui Facades are initialized; useful for tools that ResourceReference,\n     * ScriptRunner, TemplateRenderer, ServiceRunner, etc implementations depend on. */\n    default void preFacadeInit(ExecutionContextFactory ecf) { }\n\n    /** Called by ExecutionContextFactory.getTool() to get an instance object for this tool.\n     * May be created for each call or a singleton.\n     *\n     * @throws IllegalStateException if not initialized\n     */\n    V getInstance(Object... parameters);\n\n    /** Called on destroy/shutdown of Moqui to destroy (shutdown, close, etc) the underlying tool. */\n    default void destroy() { }\n\n    /** Rarely used, like destroy() but runs after the facades are destroyed. */\n    default void postFacadeDestroy() { }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/TransactionException.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport org.moqui.BaseArtifactException;\n\n/**\n * TransactionException\n */\npublic class TransactionException extends BaseArtifactException {\n    public TransactionException(String str) { super(str); }\n    public TransactionException(String str, Throwable nested) { super(str, nested); }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/TransactionFacade.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport groovy.lang.Closure;\n\nimport jakarta.transaction.Synchronization;\nimport javax.transaction.xa.XAResource;\n\n/** Use this interface to do transaction demarcation and related operations.\n * This should be used instead of using the JTA UserTransaction and TransactionManager interfaces.\n *\n * When you do transaction demarcation yourself use something like:\n *\n * <pre>\n * boolean beganTransaction = transactionFacade.begin(timeout);\n * try {\n *     ...\n * } catch (Throwable t) {\n *     transactionFacade.rollback(beganTransaction, \"...\", t);\n *     throw t;\n * } finally {\n *     if (transactionFacade.isTransactionInPlace()) transactionFacade.commit(beganTransaction);\n * }\n * </pre>\n *\n * This code will use a transaction if one is already in place (including setRollbackOnly instead of rollbackon\n * error), or begin a new one if not.\n *\n * When you want to suspend the current transaction and create a new one use something like: \n *\n * <pre>\n * boolean suspendedTransaction = false;\n * try {\n *     if (transactionFacade.isTransactionInPlace()) suspendedTransaction = transactionFacade.suspend();\n *\n *     boolean beganTransaction = transactionFacade.begin(timeout);\n *     try {\n *         ...\n *     } catch (Throwable t) {\n *         transactionFacade.rollback(beganTransaction, \"...\", t);\n *         throw t;\n *     } finally {\n *         if (transactionFacade.isTransactionInPlace()) transactionFacade.commit(beganTransaction);\n *     }\n * } catch (TransactionException e) {\n *     ...\n * } finally {\n *     if (suspendedTransaction) transactionFacade.resume();\n * }\n * </pre>\n */\n@SuppressWarnings(\"unused\")\npublic interface TransactionFacade {\n\n    /** Run in current transaction if one is in place, begin and commit/rollback if none is. */\n    Object runUseOrBegin(Integer timeout, String rollbackMessage, Closure closure);\n    /** Run in a separate transaction, even if one is in place. */\n    Object runRequireNew(Integer timeout, String rollbackMessage, Closure closure);\n\n    jakarta.transaction.TransactionManager getTransactionManager();\n    jakarta.transaction.UserTransaction getUserTransaction();\n\n    /** Get the status of the current transaction */\n    int getStatus() throws TransactionException;\n\n    String getStatusString() throws TransactionException;\n\n    boolean isTransactionInPlace() throws TransactionException;\n\n    /** Begins a transaction in the current thread. Only tries if the current transaction status is not ACTIVE, if\n     * ACTIVE it returns false since no transaction was begun.\n     *\n     * @param timeout Optional Integer for the timeout. If null the default configured will be used.\n     * @return True if a transaction was begun, otherwise false.\n     * @throws TransactionException\n     */\n    boolean begin(Integer timeout) throws TransactionException;\n\n    /** Commits the transaction in the current thread if beganTransaction is true */\n    void commit(boolean beganTransaction) throws TransactionException;\n\n    /** Commits the transaction in the current thread */\n    void commit() throws TransactionException;\n\n    /** Rollback current transaction if beganTransaction is true, otherwise setRollbackOnly is called to mark current\n     * transaction as rollback only.\n     */\n    void rollback(boolean beganTransaction, String causeMessage, Throwable causeThrowable) throws TransactionException;\n\n    /** Rollback current transaction */\n    void rollback(String causeMessage, Throwable causeThrowable) throws TransactionException;\n\n    /** Mark current transaction as rollback-only (transaction can only be rolled back) */\n    void setRollbackOnly(String causeMessage, Throwable causeThrowable) throws TransactionException;\n\n    boolean suspend() throws TransactionException;\n\n    void resume() throws TransactionException;\n\n    java.sql.Connection enlistConnection(javax.sql.XAConnection con) throws TransactionException;\n\n    void enlistResource(XAResource resource) throws TransactionException;\n    XAResource getActiveXaResource(String resourceName);\n    void putAndEnlistActiveXaResource(String resourceName, XAResource xar);\n\n    void registerSynchronization(Synchronization sync) throws TransactionException;\n    Synchronization getActiveSynchronization(String syncName);\n    void putAndEnlistActiveSynchronization(String syncName, Synchronization sync);\n\n    void initTransactionCache(boolean readOnly);\n    boolean isTransactionCacheActive();\n    void flushAndDisableTransactionCache();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/TransactionInternal.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport org.moqui.entity.EntityFacade;\nimport org.moqui.util.MNode;\n\nimport javax.sql.DataSource;\nimport jakarta.transaction.TransactionManager;\nimport jakarta.transaction.UserTransaction;\n\npublic interface TransactionInternal {\n    TransactionInternal init(ExecutionContextFactory ecf);\n\n    TransactionManager getTransactionManager();\n    UserTransaction getUserTransaction();\n    DataSource getDataSource(EntityFacade ef, MNode datasourceNode);\n\n    void destroy();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/UserFacade.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport org.moqui.entity.EntityValue;\n\nimport java.sql.Timestamp;\nimport java.util.*;\n\n/** For information about the user and user preferences (including locale, time zone, currency, etc). */\n@SuppressWarnings(\"unused\")\npublic interface UserFacade {\n    /** @return Locale The active Locale from user preference or system default. */\n    Locale getLocale();\n\n    /** Set the user's Locale. This is used in this context and saved to the database for future contexts.\n     * @param locale The new Locale.\n     */\n    void setLocale(Locale locale);\n\n    /** @return TimeZone The active TimeZone from user preference or system default. */\n    TimeZone getTimeZone();\n\n    /** Set the user's Time Zone. This is used in this context and saved to the database for future contexts.\n     * @param tz The new TimeZone.\n     */\n    void setTimeZone(TimeZone tz);\n\n    /** @return String The active ISO currency code from user preference or system default. */\n    String getCurrencyUomId();\n\n    /** Set the user's Time Zone. This is used in this context and saved to the database for future contexts.\n     * @param uomId The new currency UOM ID (ISO currency code).\n     */\n    void setCurrencyUomId(String uomId);\n\n    /** Get the value of a user preference.\n     * @param preferenceKey The key for the preference, looked up on UserPreference.preferenceKey\n     * @return The value of the preference from the UserPreference.preferenceValue field\n     */\n    String getPreference(String preferenceKey);\n\n    /** Set the value of a user preference.\n     * @param preferenceKey The key for the preference, used to create or update a record with UserPreference.preferenceKey\n     * @param preferenceValue The value to set on the preference, set in UserPreference.preferenceValue\n     */\n    void setPreference(String preferenceKey, String preferenceValue);\n    /** Get a Map with multiple preferences, optionally filtered by a regular expression matched against each key */\n    Map<String, String> getPreferences(String keyRegexp);\n\n    /** A per-user context like the execution context for but data specific to a user and maintained through service\n     * calls, etc unlike ExecutionContext.getContext(). Used for security data, etc such as entity filter values. */\n    Map<String, Object> getContext();\n\n    /** Get the current date and time in a Timestamp object. This is either the current system time, or the Effective\n     * Time if that has been set for this context (allowing testing of past and future system behavior).\n     *\n     * All internal tools and code built on the framework should treat this as the actual current time.\n     *\n     * @return Timestamp representing current date/time, or the values passed to setEffectiveTime().\n     */\n    Timestamp getNowTimestamp();\n    /** Get a Calendar object with user's TimeZone and Locale set, and set to same time as returned by getNowTimestamp(). */\n    Calendar getNowCalendar();\n\n    /** Get a Timestamp range (from/thru) based on period (day, week, month, year; 7d, 30d, etc), offset, and anchor date (defaults to now)\n     * @return ArrayList with 2 entries, entry 0 is the from Timestamp, entry 1 is the thru Timestamp\n     */\n    ArrayList<Timestamp> getPeriodRange(String period, String poffset, String pdate);\n    ArrayList<Timestamp> getPeriodRange(String period, int poffset, java.sql.Date pdate);\n    ArrayList<Timestamp> getPeriodRange(String period, String poffset);\n    String getPeriodDescription(String period, String poffset, String pdate);\n    ArrayList<Timestamp> getPeriodRange(String baseName, Map<String, Object> inputFieldsMap);\n\n    /** Set an EffectiveTime for the current context which will then be returned from the getNowTimestamp() method.\n     * This is used to test past and future behavior of applications.\n     *\n     * @param effectiveTime The new effective date/time. Pass in null to reset to the default of the current system time.\n     */\n    void setEffectiveTime(Timestamp effectiveTime);\n\n    /** Authenticate a user and make active in this ExecutionContext (and session of WebExecutionContext if applicable).\n     * @param username An ID to match the UserAccount.username field.\n     * @param password The user's current password.\n     * @return true if user was logged in, otherwise false\n     */\n    boolean loginUser(String username, String password);\n    /** Remove (logout) active user. */\n    void logoutUser();\n\n    /** Authenticate a user and make active using a login key */\n    boolean loginUserKey(String loginKey);\n    /** Get a login key for the active user. By default expires in the number of hours configured in the Conf XML file in: user-facade.login-key.@expire-hours */\n    String getLoginKey();\n    String getLoginKey(float expireHours);\n\n    /** If no user is logged in consider an anonymous user logged in. For internal purposes to run things that require authentication. */\n    boolean loginAnonymousIfNoUser();\n\n    /** Check to see if current user has the given permission. To have a permission a user must be in a group\n     * (UserGroupMember =&gt; UserGroup) that has the given permission (UserGroupPermission).\n     *\n     * @param userPermissionId Permission ID for record in UserPermission or any arbitrary permission name (does\n     *     not have to be pre-configured, ie does not have to be in the UserPermission entity's table)\n     * @return boolean set to true if user has permission, false if not. If no user is logged in, returns false.\n     */\n    boolean hasPermission(String userPermissionId);\n\n    /** Check to see if current user is in the given group (UserGroup). The user group concept in Moqui is similar to\n     * the \"role\" concept in many security contexts (including Apache Shiro which is used in Moqui) though that term is\n     * avoided because of the use of the term \"role\" for the Party part of the Mantle Universal Data Model.\n     *\n     * @param userGroupId The user group ID to check against.\n     * @return boolean set to true if user is a member of the group, false if not. If no user is logged in, returns false.\n     */\n    boolean isInGroup(String userGroupId);\n\n    Set<String> getUserGroupIdSet();\n\n    /** @return ID of the current active user (from the moqui.security.UserAccount entity). */\n    String getUserId();\n\n    /** @return Username of the current active user (NOT the UserAccount.userId, may even be a username from another system). */\n    String getUsername();\n\n    /** @return EntityValue for the current active user (the moqui.security.UserAccount entity). */\n    EntityValue getUserAccount();\n\n    /** @return ID of the user associated with the visit. May be different from the active user ID if a service or something is run explicitly as another user. */\n    String getVisitUserId();\n\n    /** @return ID for the current visit (aka session; from the Visit entity). Depending on the artifact being executed this may be null. */\n    String getVisitId();\n    /** @return The current visit (aka session; from the Visit entity). Depending on the artifact being executed this may be null. */\n    EntityValue getVisit();\n    String getVisitorId();\n    /** @return Client IP address from HTTP request or the configured client IP header (like X-Forwarded-For) */\n    String getClientIp();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/ValidationError.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport org.moqui.BaseArtifactException;\nimport org.moqui.util.StringUtilities;\n\nimport java.util.HashMap;\nimport java.util.Map;\n\n/**\n * ValidationError - used to track information about validation errors.\n *\n * This extends the BaseException and has additional information about the field that had the error, etc.\n *\n * This is not generally thrown all the way up to the user and is instead added to a list of validation errors as\n * things are running, and then all of them can be shown in context of the fields with the errors.\n */\n@SuppressWarnings(\"unused\")\npublic class ValidationError extends BaseArtifactException {\n    protected final String form;\n    protected final String field;\n    protected final String serviceName;\n\n    public ValidationError(String field, String message, Throwable nested) {\n        super(message, nested);\n        this.form = null;\n        this.field = field;\n        this.serviceName = null;\n    }\n\n    public ValidationError(String form, String field, String serviceName, String message, Throwable nested) {\n        super(message, nested);\n        this.form = form;\n        this.field = field;\n        this.serviceName = serviceName;\n    }\n\n    public String getForm() { return form; }\n    public String getFormPretty() { return StringUtilities.camelCaseToPretty(form); }\n    public String getField() { return field; }\n    public String getFieldPretty() { return StringUtilities.camelCaseToPretty(field); }\n    public String getServiceName() { return serviceName; }\n    public String getServiceNamePretty() { return StringUtilities.camelCaseToPretty(serviceName); }\n\n    public String toStringPretty() {\n        StringBuilder errorBuilder = new StringBuilder();\n        String message = getMessage();\n        if (message != null) errorBuilder.append(message);\n        errorBuilder.append('(');\n        String fieldPretty = getFieldPretty();\n        if (fieldPretty != null && !fieldPretty.isEmpty()) errorBuilder.append(\"for field \").append(fieldPretty);\n        String formPretty = getFormPretty();\n        if (formPretty != null && !formPretty.isEmpty()) errorBuilder.append(\" on form \").append(formPretty);\n        String serviceNamePretty = getServiceNamePretty();\n        if (serviceNamePretty != null && !serviceNamePretty.isEmpty()) errorBuilder.append(\" of service \").append(serviceNamePretty);\n        return  errorBuilder.toString();\n    }\n\n    public Map<String, String> getMap() {\n        Map<String, String> veMap = new HashMap<>();\n        veMap.put(\"form\", form); veMap.put(\"field\", field); veMap.put(\"serviceName\", serviceName);\n        veMap.put(\"formPretty\", getFormPretty()); veMap.put(\"fieldPretty\", getFieldPretty());\n        veMap.put(\"serviceNamePretty\", getServiceNamePretty());\n        veMap.put(\"message\", getMessage());\n        if (getCause() != null) veMap.put(\"cause\", getCause().toString());\n        return veMap;\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/WebFacade.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.Map;\n\nimport jakarta.servlet.ServletContext;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\nimport jakarta.servlet.http.HttpSession;\n\nimport org.moqui.context.MessageFacade.MessageInfo;\n\n/** Web Facade for access to HTTP Servlet objects and information. */\n@SuppressWarnings(\"unused\")\npublic interface WebFacade {\n    String getRequestUrl();\n    Map<String, Object> getParameters();\n\n    HttpServletRequest getRequest();\n    Map<String, Object> getRequestAttributes();\n    /** Returns a Map with request parameters including session saved, multi-part body, json body, declared and named\n     * path parameters, and standard Servlet request parameters (query string parameters, form body parameters). */\n    Map<String, Object> getRequestParameters();\n    /** Returns a Map with only secure (encrypted if over HTTPS) request parameters including session saved,\n     * multi-part body, json body, and form body parameters (standard Servlet request parameters not in query string). */\n    Map<String, Object> getSecureRequestParameters();\n\n    String getHostName(boolean withPort);\n    /** Alternative to HttpServletRequest.getPathInfo() that uses URLDecoder to decode path segments to match the use of URLEncoder\n     * for URL generation using the 'application/x-www-form-urlencoded' MIME format */\n    String getPathInfo();\n    /** Like getPathInfo() but returns a list of decoded path segment Strings.\n     * If there is no extra path after the servlet path returns an empty list. */\n    ArrayList<String> getPathInfoList();\n    /** If Content-Type request header is a text type and body length is greater than zero you can get the full body text here */\n    String getRequestBodyText();\n    /** Returns a String to append to a URL to make it distinct to force browser reload */\n    String getResourceDistinctValue();\n\n    HttpServletResponse getResponse();\n\n    HttpSession getSession();\n    Map<String, Object> getSessionAttributes();\n    /** Get the token to include in all POST requests with the name moquiSessionToken or the X-CSRF-Token request header (in the session as 'moqui.session.token') */\n    String getSessionToken();\n\n    ServletContext getServletContext();\n    Map<String, Object> getApplicationAttributes();\n    String getWebappRootUrl(boolean requireFullUrl, Boolean useEncryption);\n\n    Map<String, Object> getErrorParameters();\n    List<MessageInfo> getSavedMessages();\n    List<MessageInfo> getSavedPublicMessages();\n    List<String> getSavedErrors();\n    List<ValidationError> getSavedValidationErrors();\n    /** Get saved (in session) and current MessageFacade validation errors for the given field name, if null returns all errors; if no errors found returns null */\n    List<ValidationError> getFieldValidationErrors(String fieldName);\n\n    /** A list of recent screen requests to show to a user (does not include requests to transitions or standalone screens).\n     * Map contains 'name' (screen name plus up to 2 parameter values), 'url' (full URL with parameters),\n     * 'screenLocation', 'image' (last menu image in screen render path), and 'imageType' fields. */\n    List<Map> getScreenHistory();\n\n    void sendJsonResponse(Object responseObj);\n    void sendJsonError(int statusCode, String message, Throwable origThrowable);\n    void sendTextResponse(String text);\n    void sendTextResponse(String text, String contentType, String filename);\n\n    /** Send content of specified resource location to client via HttpResponse. Always uses attachment Content-Disposition to tell browser to download. */\n    void sendResourceResponse(String location);\n    /** Send content of specified resource location to client via HttpResponse.\n     * @param location Resource location\n     * @param inline If true use inline Content-Disposition to tell browser to display, otherwise use attachment to tell browser to download.\n     */\n    void sendResourceResponse(String location, boolean inline);\n    void sendError(int errorCode, String message, Throwable origThrowable);\n\n    void handleJsonRpcServiceCall();\n    void handleEntityRestCall(List<String> extraPathNameList, boolean masterNameInPath);\n    void handleServiceRestCall(List<String> extraPathNameList);\n    void handleSystemMessage(List<String> extraPathNameList);\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/context/WebMediaTypeException.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.context;\n\nimport org.moqui.BaseArtifactException;\n\nimport java.util.Deque;\n\npublic class WebMediaTypeException extends BaseArtifactException {\n    public WebMediaTypeException(String str) { super(str); }\n    public WebMediaTypeException(String str, Throwable nested) { super(str, nested); }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/entity/EntityCondition.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.entity;\n\nimport java.io.Externalizable;\nimport java.util.Map;\n\n/** Represents the conditions to be used to constrain a query.\n *\n * These can be used in various combinations using the different condition types.\n *\n * This class is mostly empty because it is a placeholder for use in the EntityConditionFactory and most functionality\n * is internal only.\n */\npublic interface EntityCondition extends Externalizable {\n\n    ComparisonOperator EQUALS = ComparisonOperator.EQUALS;\n    ComparisonOperator NOT_EQUAL = ComparisonOperator.NOT_EQUAL;\n    ComparisonOperator LESS_THAN = ComparisonOperator.LESS_THAN;\n    ComparisonOperator GREATER_THAN = ComparisonOperator.GREATER_THAN;\n    ComparisonOperator LESS_THAN_EQUAL_TO = ComparisonOperator.LESS_THAN_EQUAL_TO;\n    ComparisonOperator GREATER_THAN_EQUAL_TO = ComparisonOperator.GREATER_THAN_EQUAL_TO;\n    ComparisonOperator IN = ComparisonOperator.IN;\n    ComparisonOperator NOT_IN = ComparisonOperator.NOT_IN;\n    ComparisonOperator BETWEEN = ComparisonOperator.BETWEEN;\n    ComparisonOperator NOT_BETWEEN = ComparisonOperator.NOT_BETWEEN;\n    ComparisonOperator LIKE = ComparisonOperator.LIKE;\n    ComparisonOperator NOT_LIKE = ComparisonOperator.NOT_LIKE;\n    ComparisonOperator IS_NULL = ComparisonOperator.IS_NULL;\n    ComparisonOperator IS_NOT_NULL = ComparisonOperator.IS_NOT_NULL;\n\n    JoinOperator AND = JoinOperator.AND;\n    JoinOperator OR = JoinOperator.OR;\n\n    enum ComparisonOperator { EQUALS, NOT_EQUAL,\n        LESS_THAN, GREATER_THAN, LESS_THAN_EQUAL_TO, GREATER_THAN_EQUAL_TO,\n        IN, NOT_IN, BETWEEN, NOT_BETWEEN, LIKE, NOT_LIKE, IS_NULL, IS_NOT_NULL }\n\n    enum JoinOperator { AND, OR }\n\n    /** Evaluate the condition in memory. */\n    boolean mapMatches(Map<String, Object> map);\n    /** Used for EntityCache view-entity clearing by member-entity change */\n    boolean mapMatchesAny(Map<String, Object> map);\n    /** Used for EntityCache view-entity clearing by member-entity change */\n    boolean mapKeysNotContained(Map<String, Object> map);\n    /** Create a map of name/value pairs representing the condition. Returns false if the condition can't be\n     * represented as simple name/value pairs ANDed together. */\n    boolean populateMap(Map<String, Object> map);\n\n    /** Set this condition to ignore case in the query.\n     * This may not have an effect for all types of conditions.\n     *\n     * @return Returns reference to the query for convenience.\n     */\n    EntityCondition ignoreCase();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/entity/EntityConditionFactory.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.entity;\n\nimport java.sql.Timestamp;\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * Represents the conditions to be used to constrain a query.\n *\n * These can be used in various combinations using the different condition types.\n *\n */\n@SuppressWarnings(\"unused\")\npublic interface EntityConditionFactory {\n\n    EntityCondition getTrueCondition();\n\n    EntityCondition makeCondition(EntityCondition lhs, EntityCondition.JoinOperator operator, EntityCondition rhs);\n\n    EntityCondition makeCondition(String fieldName, EntityCondition.ComparisonOperator operator, Object value);\n    EntityCondition makeCondition(String fieldName, EntityCondition.ComparisonOperator operator, Object value, boolean orNull);\n\n    EntityCondition makeConditionToField(String fieldName, EntityCondition.ComparisonOperator operator, String toFieldName);\n\n    /** Default to JoinOperator of AND */\n    EntityCondition makeCondition(List<EntityCondition> conditionList);\n    EntityCondition makeCondition(List<EntityCondition> conditionList, EntityCondition.JoinOperator operator);\n\n    /** More convenient for scripts, etc. The conditionList parameter may contain Map or EntityCondition objects. */\n    EntityCondition makeCondition(List<Object> conditionList, String listOperator, String mapComparisonOperator, String mapJoinOperator);\n\n    EntityCondition makeCondition(Map<String, Object> fieldMap, EntityCondition.ComparisonOperator comparisonOperator, EntityCondition.JoinOperator joinOperator);\n\n    /** Default to ComparisonOperator of EQUALS and JoinOperator of AND */\n    EntityCondition makeCondition(Map<String, Object> fieldMap);\n\n    EntityCondition makeConditionDate(String fromFieldName, String thruFieldName, Timestamp compareStamp);\n\n    EntityCondition makeConditionWhere(String sqlWhereClause);\n\n    /** Get a ComparisonOperator using an enumId for enum type \"ComparisonOperator\" */\n    EntityCondition.ComparisonOperator comparisonOperatorFromEnumId(String enumId);\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/entity/EntityDataLoader.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.entity;\n\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\n\n/** Used to load XML entity data into the database or into an EntityList. The XML can come from\n * a specific location, XML text already read from somewhere, or by searching all component data directories\n * and the entity-facade.load-data elements for XML entity data files that match a type in the Set of types\n * specified.\n *\n * The document should have a root element like <code>&lt;entity-facade-xml type=&quot;seed&quot;&gt;</code>. The\n * type attribute will be used to determine if the file should be loaded by whether or not it matches the values\n * specified for data types on the loader.\n */\n@SuppressWarnings(\"unused\")\npublic interface EntityDataLoader {\n\n    /** Location of the data file to load. Can be called multiple times to load multiple files.\n     * @return Reference to this for convenience.\n     */\n    EntityDataLoader location(String location);\n    /** List of locations of files to load. Will be added to running list, so can be called multiple times and along\n     * with the location() method too.\n     * @return Reference to this for convenience.\n     */\n    EntityDataLoader locationList(List<String> locationList);\n\n    /** String with XML text in it, ready to be parsed.\n     * @return Reference to this for convenience.\n     */\n    EntityDataLoader xmlText(String xmlText);\n    EntityDataLoader csvText(String csvText);\n    EntityDataLoader jsonText(String jsonText);\n\n    /** A Set of data types to match against the candidate files from the component data directories and the\n     * entity-facade.load-data elements.\n     * @return Reference to this for convenience.\n     */\n    EntityDataLoader dataTypes(Set<String> dataTypes);\n    /** Used along with dataTypes; a list of component names to load data from. If none specified will load from all components. */\n    EntityDataLoader componentNameList(List<String> componentNames);\n\n    /** The transaction timeout for this data load in seconds. Defaults to 3600 which is 1 hour.\n     * @return Reference to this for convenience.\n     */\n    EntityDataLoader transactionTimeout(int tt);\n\n    /** If true instead of doing a query for each value from the file it will just try to insert it and if it fails then\n     * it will try to update the existing record. Good for situations where most of the values will be new in the db.\n     * @return Reference to this for convenience.\n     */\n    EntityDataLoader useTryInsert(boolean useTryInsert);\n\n    /** If true only creates records that don't exist, does not update existing records.\n     * @return Reference to this for convenience.\n     */\n    EntityDataLoader onlyCreate(boolean onlyInsert);\n\n    /** If true will check all foreign key relationships for each value and if any of them are missing create a new\n     * record with primary key only to avoid foreign key constraint errors.\n     *\n     * This should only be used when you are confident that the rest of the data for these new fk records will be loaded\n     * from somewhere else to avoid having orphaned records.\n     *\n     * @return Reference to this for convenience.\n     */\n    EntityDataLoader dummyFks(boolean dummyFks);\n\n    /** Files with no actions (or no messages for check) are logged in the check and load message list by default,\n     * set to false to not add messages for them */\n    EntityDataLoader messageNoActionFiles(boolean messageNoActionFiles);\n\n    /** Set to true to disable Entity Facade ECA rules (for this import only, does not affect other things happening\n     * in the system).\n     * @return Reference to this for convenience.\n     */\n    EntityDataLoader disableEntityEca(boolean disable);\n    EntityDataLoader disableAuditLog(boolean disable);\n    EntityDataLoader disableFkCreate(boolean disable);\n    EntityDataLoader disableDataFeed(boolean disable);\n\n    EntityDataLoader csvDelimiter(char delimiter);\n    EntityDataLoader csvCommentStart(char commentStart);\n    EntityDataLoader csvQuoteChar(char quoteChar);\n\n    /** For CSV files use this name (entity or service name) instead of looking for it on line one in the file */\n    EntityDataLoader csvEntityName(String entityName);\n    /** For CSV files use these field names instead of looking for them on line two in the file */\n    EntityDataLoader csvFieldNames(List<String> fieldNames);\n    /** Default values for all records to load, if applicable for given entity or service */\n    EntityDataLoader defaultValues(Map<String, Object> defaultValues);\n\n    /** Only check the data against matching records in the database. Report on records that don't exist in the database\n     * and any differences with records that have matching primary keys.\n     *\n     * @return List of messages about each comparison between data in the file(s) and data in the database.\n     */\n    List<String> check();\n    long check(List<String> messageList);\n    /** A variation on check() that returns structured field diff information instead of diff info in messages */\n    List<Map<String, Object>> checkInfo();\n    long checkInfo(List<Map<String, Object>> diffInfoList, List<String> messageList);\n\n    /** Load the values into the database(s). */\n    long load();\n    long load(List<String> messageList);\n\n    /** Create an EntityList with all of the values from the data file(s).\n     *\n     * @return EntityList representing a List of EntityValue objects for the values in the XML document(s).\n     */\n    EntityList list();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/entity/EntityDataWriter.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.entity;\n\nimport java.io.OutputStream;\nimport java.io.Writer;\nimport java.sql.Timestamp;\nimport java.util.Collection;\nimport java.util.List;\nimport java.util.Map;\n\n/** Used to write XML entity data from the database to a writer.\n *\n * The document will have a root element like <code>&lt;entity-facade-xml&gt;</code>.\n */\n@SuppressWarnings(\"unused\")\npublic interface EntityDataWriter {\n\n    FileType XML = FileType.XML;\n    FileType JSON = FileType.JSON;\n    FileType CSV = FileType.CSV;\n\n    enum FileType { XML, JSON, CSV }\n\n    EntityDataWriter fileType(FileType ft);\n    EntityDataWriter fileType(String ft);\n\n    /** Specify the name of an entity to query and export. Data is queried and exporting from entities in the order they\n     * are added by calling this or entityNames() multiple times.\n     * @param entityName The entity name\n     * @return Reference to this for convenience.\n     */\n    EntityDataWriter entityName(String entityName);\n    /** A List of entity names to query and export. Data is queried and exporting from entities in the order they are\n     * specified in this list and other calls to this or entityName().\n     * @param entityNames The list of entity names\n     * @return Reference to this for convenience.\n     */\n    EntityDataWriter entityNames(Collection<String> entityNames);\n\n    EntityDataWriter skipEntityName(String entityName);\n    EntityDataWriter skipEntityNames(Collection<String> enList);\n\n    /**\n     * Add all entities to entity names.\n     * For backward compatibility (before the skip entity names feature), if any entity names were specified before\n     * calling this they are excluded from all entities instead of included.\n     */\n    EntityDataWriter allEntities();\n\n    /** Should the dependent records of each record be written? If set will include 2 levels of dependents by default,\n     * use dependentLevels() to specify a different number of levels.\n     * @param dependents Boolean dependents indicator\n     * @return Reference to this for convenience.\n     */\n    EntityDataWriter dependentRecords(boolean dependents);\n\n    /** The number of levels of dependents to include for records written. If set dependentRecords will be considered true.\n     * If dependentRecords is set but no level limit is specified all levels found will be written (may be large and not\n     * what is desired). */\n    EntityDataWriter dependentLevels(int levels);\n\n    /** The name of a master definition, applied to all written entities that have a matching master definition otherwise\n     * just the single record is written, or dependent records if dependentREcords or dependentLevels options specified. */\n    EntityDataWriter master(String masterName);\n\n    /** A Map of field name, value pairs to filter the results by. Each name/value only used on entities that have a\n     * field matching the name.\n     * @param filterMap Map with name/value pairs to filter by\n     * @return Reference to this for convenience.\n     */\n    EntityDataWriter filterMap(Map<String, Object> filterMap);\n\n    /** Field names to order (sort) the results by. Each name only used on entities with a field matching the name.\n     * May be called multiple times. Each entry may be a comma-separated list of field names.\n     * @param orderByList List with field names to order by\n     * @return Reference to this for convenience.\n     */\n    EntityDataWriter orderBy(List<String> orderByList);\n\n    /** From date for lastUpdatedStamp on each entity (lastUpdatedStamp must be greater than or equal (&gt;=) to fromDate).\n     * @param fromDate The from date\n     * @return Reference to this for convenience.\n     */\n    EntityDataWriter fromDate(Timestamp fromDate);\n    /** Thru date for lastUpdatedStamp on each entity (lastUpdatedStamp must be less than (&lt;) to thruDate).\n     * @param thruDate The thru date\n     * @return Reference to this for convenience.\n     */\n    EntityDataWriter thruDate(Timestamp thruDate);\n\n    /** Write Date, Time, and Timestamp fields in ISO format instead of millis since epoch integer; currently only supported for CSV */\n    EntityDataWriter isoDateTime(boolean iso);\n    /** Write table and column names instead of entity and field names; currently only supported for CSV */\n    EntityDataWriter tableColumnNames(boolean tcn);\n\n    /** Write all results to a single file.\n     * @param filename The path and name of the file to write values to\n     * @return Count of values written\n     */\n    int file(String filename);\n    int zipFile(String filenameWithinZip, String zipFilename);\n    /** Write the results to a file for each entity in the specified directory.\n     * @param path The path of the directory to create files in\n     * @return Count of values written\n     */\n    int directory(String path);\n    /** Write to a directory in a zip file located at zipFilename */\n    int zipDirectory(String pathWithinZip, String zipFilename);\n    /** Write to a directory in a zip file in an OutputStream; NOTE: closes OutputStream when done */\n    int zipDirectory(String pathWithinZip, OutputStream outputStream);\n    /** Write the results to a Writer.\n     * @param writer The Writer to write to\n     * @return Count of values written\n     */\n    int writer(Writer writer);\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/entity/EntityDatasourceFactory.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.entity;\n\nimport org.moqui.util.MNode;\nimport javax.sql.DataSource;\nimport java.util.List;\n\npublic interface EntityDatasourceFactory {\n    EntityDatasourceFactory init(EntityFacade ef, MNode datasourceNode);\n    void destroy();\n    boolean checkTableExists(String entityName);\n    boolean checkAndAddTable(String entityName);\n    int checkAndAddAllTables();\n    EntityValue makeEntityValue(String entityName);\n    EntityFind makeEntityFind(String entityName);\n    void createBulk(List<EntityValue> valueList);\n\n    /** Return the JDBC DataSource, if applicable. Return null if no JDBC DataSource exists for this Entity Datasource. */\n    DataSource getDataSource();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/entity/EntityDynamicView.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.entity;\n\nimport org.moqui.util.MNode;\n\nimport java.util.List;\nimport java.util.Map;\n\n/** This class is used for declaring Dynamic View Entities, to be used and thrown away.\n * A special method exists on the EntityFind to accept a EntityDynamicView instead of an entityName.\n * The methods here return a reference to itself (this) for convenience.\n */\n@SuppressWarnings(\"unused\")\npublic interface EntityDynamicView {\n    /** This optionally sets a name for the dynamic view entity. If not used will default to \"DynamicView\" */\n    EntityDynamicView setEntityName(String entityName);\n\n    EntityDynamicView addMemberEntity(String entityAlias, String entityName, String joinFromAlias,\n                                             Boolean joinOptional, Map<String, String> entityKeyMaps);\n\n    EntityDynamicView addRelationshipMember(String entityAlias, String joinFromAlias, String relationshipName,\n                                                   Boolean joinOptional);\n\n    List<MNode> getMemberEntityNodes();\n\n    EntityDynamicView addAliasAll(String entityAlias, String prefix);\n\n    EntityDynamicView addAlias(String entityAlias, String name);\n\n    /** Add an alias, full detail. All parameters can be null except entityAlias and name. */\n    EntityDynamicView addAlias(String entityAlias, String name, String field, String function);\n\n    EntityDynamicView addRelationship(String type, String title, String relatedEntityName,\n                                             Map<String, String> entityKeyMaps);\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/entity/EntityException.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.entity;\n\n/**\n * EntityException\n *\n */\npublic class EntityException extends org.moqui.BaseException {\n\n    public EntityException(String str) {\n        super(str);\n    }\n\n    public EntityException(String str, Throwable nested) {\n        super(str, nested);\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/entity/EntityFacade.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.entity;\n\nimport java.sql.Connection;\nimport java.sql.Timestamp;\nimport java.util.ArrayList;\nimport java.util.Calendar;\nimport java.util.List;\nimport java.util.Map;\n\nimport org.moqui.etl.SimpleEtl;\nimport org.moqui.util.MNode;\nimport org.w3c.dom.Element;\n\n/** The main interface for general database operations in Moqui. */\n@SuppressWarnings(\"unused\")\npublic interface EntityFacade {\n\n    /** Get a EntityDatasourceFactory implementation for a group. This is most useful for non-SQL databases to get\n     * access to underlying details. */\n    EntityDatasourceFactory getDatasourceFactory(String groupName);\n\n    /** Get a EntityConditionFactory object that can be used to create and assemble conditions used for finds.\n     *\n     * @return The facade's active EntityConditionFactory object.\n     */\n    EntityConditionFactory getConditionFactory();\n\n    /** Creates a Entity in the form of a EntityValue without persisting it\n     *\n     * @param entityName The name of the entity to make a value object for.\n     * @return EntityValue for the named entity. \n     */\n    EntityValue makeValue(String entityName);\n\n    /** Create an EntityFind object that can be used to specify additional options, and then to execute one or more\n     * finds (queries).\n     * \n     * @param entityName The Name of the Entity as defined in the entity XML file, can be null.\n     * @return An EntityFind object.\n     */\n    EntityFind find(String entityName);\n    EntityFind find(MNode entityFindNode);\n    EntityValue fastFindOne(String entityName, Boolean useCache, boolean disableAuthz, Object... values);\n\n    /** Bulk create EntityValue records. All values must be for the same entity. */\n    void createBulk(List<EntityValue> valueList);\n\n    /** Meant for processing entity REST requests, but useful more generally as a simple way to perform entity operations.\n     *\n     * @param operation Can be get/find, post/create, put/store, patch/update, or delete/delete.\n     * @param entityPath First element should be an entity name or short-alias, followed by (optionally depending on\n     *                   operation) the PK fields for the entity in the order they appear in the entity definition\n     *                   followed optionally by (one or more) relationship name or short-alias and then PK values for\n     *                   the related entity, not including any PK fields defined in the relationship.\n     * @param parameters A Map of extra parameters, used depending on the operation. For find operations these can be\n     *                   any parameters handled by the EntityFind.searchFormInputs() method. For create, update, store,\n     *                   and delete operations these parameters are for non-PK fields and as an alternative to the\n     *                   entity path for PK field values. For find operations also supports a \"dependents\" parameter\n     *                   that if true will get dependent values of the record referenced in the entity path.\n     * @param masterNameInPath If true the second entityPath entry must be the name of a master entity definition\n     */\n    Object rest(String operation, List<String> entityPath, Map parameters, boolean masterNameInPath);\n\n    /** Do a database query with the given SQL and return the results as an EntityList for the given entity and with\n     * selected columns mapped to the listed fields.\n     *\n     * @param sql The actual SQL to run.\n     * @param sqlParameterList Parameter values that correspond with any question marks (?) in the SQL.\n     * @param entityName Name of the entity to map the results to, may be a view-entity.\n     * @param fieldList List of entity field names in order that they match columns selected in the query.\n     *                  If not specified all fields will be used in the order they are specified in the entity definition.\n     * @return EntityListIterator with results of query.\n     */\n    EntityListIterator sqlFind(String sql, List<Object> sqlParameterList, String entityName, List<String> fieldList);\n\n    /** Find and assemble data documents represented by a Map that can be easily turned into a JSON document. This is\n     * used for searching by the Data Search feature and for data feeds to other systems with the Data Feed feature.\n     *\n     * @param dataDocumentId Used to look up the DataDocument and related records (DataDocument* entities).\n     * @param condition An optional condition to AND with from/thru updated timestamps and any DataDocumentCondition\n     *                  records associated with the DataDocument.\n     * @param fromUpdateStamp The lastUpdatedStamp on at least one entity selected must be after (&gt;=) this Timestamp.\n     * @param thruUpdatedStamp The lastUpdatedStamp on at least one entity selected must be before (&lt;) this Timestamp.\n     * @return List of Maps with these entries:\n     *      - _index = DataDocument.indexName\n     *      - _type = dataDocumentId\n     *      - _id = pk field values from primary entity, underscore separated\n     *      - _timestamp = timestamp when the document was created\n     *      - Map for primary entity (with primaryEntityName as key)\n     *      - nested List of Maps for each related entity from DataDocumentField records with aliased fields\n     *          (with relationship name as key)\n     */\n    ArrayList<Map> getDataDocuments(String dataDocumentId, EntityCondition condition, Timestamp fromUpdateStamp,\n                                    Timestamp thruUpdatedStamp);\n\n    /** Find and assemble data documents represented by a Map that can be easily turned into a JSON document. This is\n     * similar to the getDataDocuments() method except that the dataDocumentId(s) are looked up using the dataFeedId.\n     *\n     * @param dataFeedId Used to look up the DataFeed records to find the associated DataDocument records.\n     * @param fromUpdateStamp The lastUpdatedStamp on at least one entity selected must be after (&gt;=) this Timestamp.\n     * @param thruUpdatedStamp The lastUpdatedStamp on at least one entity selected must be before (&lt;) this Timestamp.\n     * @return List of Maps with these entries:\n     */\n    ArrayList<Map> getDataFeedDocuments(String dataFeedId, Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp);\n\n    /** Get the next guaranteed unique seq id from the sequence with the given sequence name;\n     * if the named sequence doesn't exist, it will be created.\n     *\n     * @param seqName The name of the sequence to get the next seq id from\n     * @param staggerMax The maximum amount to stagger the sequenced ID, if 1 the sequence will be incremented by 1,\n     *     otherwise the current sequence ID will be incremented by a value between 1 and staggerMax\n     * @param bankSize The size of the \"bank\" of values to get from the database (defaults to 1)\n     * @return Long with the next seq id for the given sequence name\n     */\n    String sequencedIdPrimary(String seqName, Long staggerMax, Long bankSize);\n\n    /** Gets the group name for specified entityName\n     * @param entityName The name of the entity to get the group name\n     * @return String with the group name that corresponds to the entityName\n     */\n    String getEntityGroupName(String entityName);\n\n    /** Use this to get a Connection if you want to do JDBC operations directly. This Connection will be enlisted in\n     * the active Transaction.\n     *\n     * @param groupName The name of entity group to get a connection for.\n     *     Corresponds to the entity.@group attribute and the moqui-conf datasource.@group-name attribute.\n     * @return JDBC Connection object for the associated database\n     * @throws EntityException if there is an error getting a Connection\n     */\n    Connection getConnection(String groupName) throws EntityException;\n    Connection getConnection(String groupName, boolean useClone) throws EntityException;\n\n    // ======= Import/Export (XML, CSV, etc) Related Methods ========\n\n    /** Make an object used to load XML or CSV entity data into the database or into an EntityList. The files come from\n     * a specific location, text already read from somewhere, or by searching all component data directories\n     * and the entity-facade.load-data elements for entity data files that match a type in the Set of types\n     * specified.\n     *\n     * An XML document should have a root element like <code>&lt;entity-facade-xml type=&quot;seed&quot;&gt;</code>. The\n     * type attribute will be used to determine if the file should be loaded by whether or not it matches the values\n     * specified for data types on the loader.\n     *\n     * @return EntityDataLoader instance\n     */\n    EntityDataLoader makeDataLoader();\n\n    /** Used to write XML entity data from the database to a writer.\n     *\n     * The document will have a root element like <code>&lt;entity-facade-xml&gt;</code>.\n     *\n     * @return EntityDataWriter instance\n     */\n    EntityDataWriter makeDataWriter();\n\n    SimpleEtl.Loader makeEtlLoader();\n\n    /** Make an EntityValue and populate it with the data (attributes and sub-elements) from the given XML element.\n     *\n     * @param element A XML DOM element representing a single value/record for an entity.\n     * @return EntityValue object populated with data from the element.\n     */\n    EntityValue makeValue(Element element);\n\n    Calendar getCalendarForTzLc();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/entity/EntityFind.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.entity;\n\nimport org.moqui.etl.SimpleEtl;\n\nimport java.util.ArrayList;\nimport java.util.Collection;\nimport java.util.List;\nimport java.util.Map;\n\n/**\n * Used to setup various options for an entity find (query).\n *\n * All methods to set options modify the option then return this modified object to allow method call chaining. It is\n * important to note that this object is not immutable and is modified internally, and returning EntityFind is just a\n * self reference for convenience.\n *\n * Even after a query a find object can be modified and then used to perform another query.\n */\n@SuppressWarnings(\"unused\")\npublic interface EntityFind extends java.io.Serializable, SimpleEtl.Extractor {\n\n    /** The Name of the Entity to use, as defined in an entity XML file.\n     *\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind entity(String entityName);\n    String getEntity();\n\n    /** Make a dynamic view object to use instead of the entity name (if used the entity name will be ignored).\n     *\n     * If called multiple times will return the same object.\n     *\n     * @return EntityDynamicView object to add view details to.\n     */\n    EntityDynamicView makeEntityDynamicView();\n\n    // ======================== Conditions (Where and Having) =================\n\n    /** Add a field to the find (where clause).\n     * If a field has been set with the same name, this will replace that field's value.\n     * If any other constraints are already in place this will be ANDed to them.\n     *\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind condition(String fieldName, Object value);\n\n    /** Compare the named field to the value using the operator. */\n    EntityFind condition(String fieldName, EntityCondition.ComparisonOperator operator, Object value);\n    EntityFind condition(String fieldName, String operator, Object value);\n\n    /** Compare a field to another field using the operator. */\n    EntityFind conditionToField(String fieldName, EntityCondition.ComparisonOperator operator, String toFieldName);\n\n    /** Add a Map of fields to the find (where clause).\n     * If a field has been set with the same name and any of the Map keys, this will replace that field's value.\n     * Fields set in this way will be combined with other conditions (if applicable) just before doing the query.\n     *\n     * This will do conversions if needed from Strings to field types as needed, and will only get keys that match\n     * entity fields. In other words, it does the same thing as:\n     * <code>EntityValue.setFields(fields, true, null, null)</code>\n     *\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind condition(Map<String, Object> fields);\n\n    /** Add a EntityCondition to the find (where clause).\n     *\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind condition(EntityCondition condition);\n\n    /** Add conditions for the standard effective date query pattern including from field is null or earlier than\n     * or equal to compareStamp and thru field is null or later than or equal to compareStamp.\n     */\n    EntityFind conditionDate(String fromFieldName, String thruFieldName, java.sql.Timestamp compareStamp);\n\n    boolean getHasCondition();\n    boolean getHasHavingCondition();\n\n    /** Add a EntityCondition to the having clause of the find.\n     * If any having constraints are already in place this will be ANDed to them.\n     *\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind havingCondition(EntityCondition condition);\n\n    /** Get the current where EntityCondition. */\n    EntityCondition getWhereEntityCondition();\n\n    /** Get the current having EntityCondition. */\n    EntityCondition getHavingEntityCondition();\n\n    /** Adds conditions for the fields found in the inputFieldsMapName Map.\n     *\n     * The fields and special fields with suffixes supported are the same as the *-find fields in the XML\n     * Forms. This means that you can use this to process the data from the various inputs generated by XML\n     * Forms. The suffixes include things like *_op for operators and *_ic for ignore case.\n     *\n     * For historical reference, this does basically what the Apache OFBiz prepareFind service does.\n     *\n     * @param inputFieldsMapName The map to get form fields from. If empty will look at the ec.web.parameters map if\n     *        the web facade is available, otherwise the current context (ec.context).\n     * @param defaultOrderBy If there is not an orderByField parameter this is used instead.\n     * @param alwaysPaginate If true pagination offset/limit will be set even if there is no pageIndex parameter.\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind searchFormInputs(String inputFieldsMapName, String defaultOrderBy, boolean alwaysPaginate);\n    EntityFind searchFormMap(Map <String, Object> inputFieldsMap, Map<String, Object> defaultParameters,\n                             String skipFields, String defaultOrderBy, boolean alwaysPaginate);\n\n    // ======================== General/Common Options ========================\n\n    /** The field of the named entity to get from the database.\n     * If any select fields have already been specified this will be added to the set.\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind selectField(String fieldToSelect);\n\n    /** The fields of the named entity to get from the database; if empty or null all fields will be retrieved.\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind selectFields(Collection<String> fieldsToSelect);\n    List<String> getSelectFields();\n\n    /** A field of the find entity to order the query by. Optionally add a \" ASC\" to the end or \"+\" to the\n     *     beginning for ascending, or \" DESC\" to the end of \"-\" to the beginning for descending.\n     * If any other order by fields have already been specified this will be added to the end of the list.\n     * The String may be a comma-separated list of field names. Only fields that actually exist on the entity will be\n     *     added to the order by list.\n     *\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind orderBy(String orderByFieldName);\n\n    /** Each List entry is passed to the orderBy(String orderByFieldName) method, see it for details.\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind orderBy(List<String> orderByFieldNames);\n    List<String> getOrderBy();\n\n    /** Look in the cache before finding in the datasource.\n     * Defaults to setting on entity definition.\n     *\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind useCache(Boolean useCache);\n    boolean getUseCache();\n\n    /** Use a clone of the configured datasource, if at least one clone is configured */\n    EntityFind useClone(boolean uc);\n\n    // ======================== Advanced Options ==============================\n\n    /** Specifies whether the values returned should be filtered to remove duplicate values.\n     * Default is false.\n     *\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind distinct(boolean distinct);\n    boolean getDistinct();\n\n    /** The offset, ie the starting row to return. Default (null) means start from the first actual row.\n     * Only applicable for list() and iterator() finds.\n     *\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind offset(Integer offset);\n    /** Specify the offset in terms of page index and size. Actual offset is pageIndex * pageSize. */\n    EntityFind offset(int pageIndex, int pageSize);\n    Integer getOffset();\n\n    /** The limit, ie max number of rows to return. Default (null) means all rows.\n     * Only applicable for list() and iterator() finds.\n     *\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind limit(Integer limit);\n    Integer getLimit();\n\n    /** For use with searchFormInputs when paginated. Equals offset (default 0) divided by page size. */\n    int getPageIndex();\n    /** For use with searchFormInputs when paginated. Equals limit (default 20; exists for consistency/convenience along with getPageIndex()). */\n    int getPageSize();\n\n    /** Lock the selected record so only this transaction can change it until it is ended.\n     * If this is set when the find is done the useCache setting will be ignored as this will always get the data from\n     *     the database.\n     * Default is false.\n     *\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind forUpdate(boolean forUpdate);\n    boolean getForUpdate();\n\n    // ======================== JDBC Options ==============================\n\n    /** Specifies how the ResultSet will be traversed. Available values: ResultSet.TYPE_FORWARD_ONLY,\n     *      ResultSet.TYPE_SCROLL_INSENSITIVE (default) or ResultSet.TYPE_SCROLL_SENSITIVE. See the java.sql.ResultSet JavaDoc for\n     *      more information. If you want it to be fast, use the common option: ResultSet.TYPE_FORWARD_ONLY.\n     *      For partial results where you want to jump to an index make sure to use TYPE_SCROLL_INSENSITIVE.\n     * Defaults to ResultSet.TYPE_SCROLL_INSENSITIVE.\n     *\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind resultSetType(int resultSetType);\n    int getResultSetType();\n\n    /** Specifies whether or not the ResultSet can be updated. Available values:\n     *      ResultSet.CONCUR_READ_ONLY (default) or ResultSet.CONCUR_UPDATABLE. Should pretty much always be\n     *      ResultSet.CONCUR_READ_ONLY with the Entity Facade since updates are generally done as separate operations.\n     * Defaults to CONCUR_READ_ONLY.\n     *\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind resultSetConcurrency(int resultSetConcurrency);\n    int getResultSetConcurrency();\n\n    /** The JDBC fetch size for this query. Default (null) will fall back to datasource settings.\n     * This is not the fetch as in the OFFSET/FETCH SQL clause (use limit for that), and is rather the JDBC fetch to\n     * determine how many rows to get back on each round-trip to the database.\n     *\n     * Only applicable for list() and iterator() finds.\n     *\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind fetchSize(Integer fetchSize);\n    Integer getFetchSize();\n\n    /** The JDBC max rows for this query. Default (null) will fall back to datasource settings.\n     * This is the maximum number of rows the ResultSet will keep in memory at any given time before releasing them\n     * and if requested they are retrieved from the database again.\n     *\n     * Only applicable for list() and iterator() finds.\n     *\n     * @return Returns this for chaining of method calls.\n     */\n    EntityFind maxRows(Integer maxRows);\n    Integer getMaxRows();\n\n    /** Disable authorization for this find */\n    EntityFind disableAuthz();\n    /** If true don't do find (return empty list or null) when there are no search form parameters */\n    EntityFind requireSearchFormParameters(boolean req);\n    /** Determine if this find should be cached by the various options on entity definition and EntityFind */\n    boolean shouldCache();\n\n    // ======================== Run Find Methods ==============================\n\n    /** Runs a find with current options to get a single record by primary key. */\n    EntityValue one() throws EntityException;\n\n    /** Runs a find with current options to get a single record by primary key, then gets all related/dependent\n     * entities according to the named master definition (default name is 'default'). */\n    Map<String, Object> oneMaster(String name) throws EntityException;\n\n    /** Runs a find with current options to get a list of records. */\n    EntityList list() throws EntityException;\n\n    /** Runs a find with current options to get a list of records, then for each result gets all related/dependent\n     * entities according to the named master definition (default name is 'default') */\n    List<Map<String, Object>> listMaster(String name) throws EntityException;\n\n    /**\n     * Runs a find with current options and returns an EntityListIterator object which retains an open JDBC Connection\n     * and ResultSet until closed. This method ignores the cache setting and always gets results from the database.\n     *\n     * The returned EntityListIterator must be closed when you are done with it using the close() method in a finally\n     * block to ensure it is closed regardless of exceptions. For example:\n     *\n     * <pre>\n     * EntityListIterator eli = entityFind.iterator();\n     * try {\n     *     EntityValue ev;\n     *     while ((ev = eli.next()) != null) {\n     *         // do stuff with ev\n     *     }\n     * } finally {\n     *     eli.close();\n     * }\n     * </pre>\n     */\n    EntityListIterator iterator() throws EntityException;\n\n    /** Runs a find with current options to get a count of matching records. */\n    long count() throws EntityException;\n\n    /** Update a set of values that match a condition.\n     *\n     * @param fieldsToSet The fields of the named entity to set in the database\n     * @return long representing number of rows effected by this operation\n     * @throws EntityException\n     */\n    long updateAll(Map<String, Object> fieldsToSet) throws EntityException;\n\n    /** Delete entity records that match a condition.\n     *\n     * @return long representing number of rows effected by this operation\n     * @throws EntityException\n     */\n    long deleteAll() throws EntityException;\n\n    /** If supported by underlying data source get the text (SQL, etc) used for the find query.\n     * Will have multiple values if multiple queries done with this find. */\n    ArrayList<String> getQueryTextList();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/entity/EntityList.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.entity;\n\nimport groovy.lang.Closure;\n\nimport java.io.Externalizable;\nimport java.io.Writer;\nimport java.sql.Timestamp;\nimport java.util.*;\n\n/**\n * Contains a list of EntityValue objects.\n * Entity List that adds some additional operations like filtering to the basic List&lt;EntityValue&gt;.\n *\n * The various methods here modify the internal list for efficiency and return a reference to this for convenience.\n * If you want a new EntityList with the modifications, use clone() or cloneList() then modify it.\n */\n@SuppressWarnings(\"unused\")\npublic interface EntityList extends List<EntityValue>, Iterable<EntityValue>, Cloneable, RandomAccess, Externalizable {\n\n    /** Get the first value in the list.\n     *\n     * @return EntityValue that is first in the list.\n     */\n    EntityValue getFirst();\n\n    /** Modify this EntityList so that it contains only the values that are active for the moment passed in.\n     * The results include values that match the fromDate, but exclude values that match the thruDate.\n     *\n     *@param fromDateName The name of the from/beginning date/time field. Defaults to \"fromDate\".\n     *@param thruDateName The name of the thru/ending date/time field. Defaults to \"thruDate\".\n     *@param moment The point in time to compare the values to; if null the current system date/time is used.\n     *@return A reference to this for convenience.\n     */\n    EntityList filterByDate(String fromDateName, String thruDateName, Timestamp moment);\n    EntityList filterByDate(String fromDateName, String thruDateName, Timestamp moment, boolean ignoreIfEmpty);\n\n    /** Modify this EntityList so that it contains only the values that match the values in the fields parameter.\n     *\n     *@param fields The name/value pairs that must match for a value to be included in the output list.\n     *@return List of EntityValue objects that match the values in the fields parameter.\n     */\n    EntityList filterByAnd(Map<String, Object> fields);\n    EntityList filterByAnd(Map<String, Object> fields, Boolean include);\n\n    /** Modify this EntityList so that it contains only the values that match the values in the namesAndValues parameter.\n     *\n     *@param namesAndValues Must be an even number of parameters as field name then value repeated as needed\n     *@return List of EntityValue objects that match the values in the fields parameter.\n     */\n    EntityList filterByAnd(Object... namesAndValues);\n\n    EntityList removeByAnd(Map<String, Object> fields);\n\n    /** Modify this EntityList so that it includes (or excludes) values matching the condition.\n     *\n     * @param condition EntityCondition to filter by.\n     * @param include If true include matching values, if false exclude matching values.\n     *     Defaults to true (include, ie only include values that do meet condition).\n     * @return List with filtered values.\n     */\n    EntityList filterByCondition(EntityCondition condition, Boolean include);\n\n    /** Modify this EntityList so that it includes (or excludes) entity values where the closure evaluates to true.\n     * The closure is called with a single argument, the current EntityValue in the list, and should evaluate to a Boolean. */\n    EntityList filter(Closure<Boolean> closure, Boolean include);\n\n    /** Find the first value in this EntityList where the closure evaluates to true. */\n    EntityValue find(Closure<Boolean> closure);\n    EntityValue findByAnd(Map<String, Object> fields);\n    EntityValue findByAnd(Object... namesAndValues);\n\n    /** Different from filter* method semantics, does not modify this EntityList. Returns a new EntityList with just the\n     * values where the closure evaluates to true. */\n    EntityList findAll(Closure<Boolean> closure);\n\n    /** Modify this EntityList to only contain up to limit values starting at the offset.\n     *\n     * @param offset Starting index to include\n     * @param limit Include only this many values\n     * @return List with filtered values.\n     */\n    EntityList filterByLimit(Integer offset, Integer limit);\n    /** For limit filter in a cached entity-find with search-form-inputs, done after the query */\n    EntityList filterByLimit(String inputFieldsMapName, boolean alwaysPaginate);\n\n    /** The offset used to filter the list, if filterByLimit has been called. */\n    Integer getOffset();\n    /** The limit used to filter the list, if filterByLimit has been called. */\n    Integer getLimit();\n    /** For use with filterByLimit when paginated. Equals offset (default 0) divided by page size. */\n    int getPageIndex();\n    /** For use with filterByLimit when paginated. Equals limit (default 20; for use along with getPageIndex()). */\n    int getPageSize();\n\n    /** Modify this EntityList so that is ordered by the field names passed in.\n     *\n     *@param fieldNames The field names for the entity values to sort the list by. Optionally prefix each field name\n     * with a plus sign (+) for ascending or a minus sign (-) for descending. Defaults to ascending.\n     *@return List of EntityValue objects in the specified order.\n     */\n    EntityList orderByFields(List<String> fieldNames);\n\n    int indexMatching(Map<String, Object> valueMap);\n    void move(int fromIndex, int toIndex);\n\n    /** Adds the value to this list if the value isn't already in it. Returns reference to this list. */\n    EntityList addIfMissing(EntityValue value);\n    /** Adds each value in the passed list to this list if the value isn't already in it. Returns reference to this list. */\n    EntityList addAllIfMissing(EntityList el);\n\n    /** Writes XML text with an attribute or CDATA element for each field of each record. If dependents is true also\n     * writes all dependent (descendant) records.\n     * @param writer A Writer object to write to\n     * @param prefix A prefix to put in front of the entity name in the tag name\n     * @param dependentLevels Write dependent (descendant) records this many levels deep, zero for no dependents\n     * @return The number of records written\n     */\n    int writeXmlText(Writer writer, String prefix, int dependentLevels);\n\n    /** Method to implement the Iterable interface to allow an EntityList to be used in a foreach loop.\n     *\n     * @return Iterator&lt;EntityValue&gt; to iterate over internal list.\n     */\n    @Override\n    Iterator<EntityValue> iterator();\n\n    /** Get a list of Map (not EntityValue) objects. If dependentLevels is greater than zero includes nested dependents\n     * in the Map for each value. */\n    List<Map<String, Object>> getPlainValueList(int dependentLevels);\n    List<Map<String, Object>> getMasterValueList(String name);\n    ArrayList<Map<String, Object>> getValueMapList();\n\n    EntityList cloneList();\n\n    void setFromCache();\n    boolean isFromCache();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/entity/EntityListIterator.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.entity;\n\nimport java.io.Writer;\nimport java.util.ListIterator;\n\n/**\n * Entity Cursor List Iterator for Handling Cursored Database Results\n */\n@SuppressWarnings(\"unused\")\npublic interface EntityListIterator extends ListIterator<EntityValue>, AutoCloseable {\n\n    /** Close the underlying ResultSet and Connection. This must ALWAYS be called when done with an EntityListIterator. */\n    void close() throws EntityException;\n\n    /** Sets the cursor position to just after the last result so that previous() will return the last result */\n    void afterLast() throws EntityException;\n\n    /** Sets the cursor position to just before the first result so that next() will return the first result */\n    void beforeFirst() throws EntityException;\n\n    /** Sets the cursor position to last result; if result set is empty returns false */\n    boolean last() throws EntityException;\n\n    /** Sets the cursor position to last result; if result set is empty returns false */\n    boolean first() throws EntityException;\n\n    /** NOTE: Calling this method does return the current value, but so does calling next() or previous(), so calling\n     * one of those AND this method will cause the value to be created twice\n     */\n    EntityValue currentEntityValue() throws EntityException;\n\n    int currentIndex() throws EntityException;\n\n    /** performs the same function as the ResultSet.absolute method;\n     * if rowNum is positive, goes to that position relative to the beginning of the list;\n     * if rowNum is negative, goes to that position relative to the end of the list;\n     * a rowNum of 1 is the same as first(); a rowNum of -1 is the same as last()\n     */\n    boolean absolute(int rowNum) throws EntityException;\n\n    /** performs the same function as the ResultSet.relative method;\n     * if rows is positive, goes forward relative to the current position;\n     * if rows is negative, goes backward relative to the current position;\n     */\n    boolean relative(int rows) throws EntityException;\n\n    /**\n     * PLEASE NOTE: Because of the nature of the JDBC ResultSet interface this method can be very inefficient; it is\n     * much better to just use next() until it returns null.\n     *\n     * For example, you could use the following to iterate through the results in an EntityListIterator:\n     *\n     * <pre>\n     * EntityValue nextValue;\n     * while ((nextValue = eli.next()) != null) { ... }\n     * </pre>\n     */\n    @Override boolean hasNext();\n\n    /** PLEASE NOTE: Because of the nature of the JDBC ResultSet interface this method can be very inefficient; it is\n     * much better to just use previous() until it returns null.\n     */\n    @Override boolean hasPrevious();\n\n    /** Moves the cursor to the next position and returns the EntityValue object for that position; if there is no next,\n     * returns null.\n     *\n     * For example, you could use the following to iterate through the results in an EntityListIterator:\n     *\n     * <pre>\n     * EntityValue nextValue;\n     * while ((nextValue = eli.next()) != null) { ... }\n     * </pre>\n     */\n    @Override EntityValue next();\n\n    /** Returns the index of the next result, but does not guarantee that there will be a next result */\n    @Override int nextIndex();\n\n    /** Moves the cursor to the previous position and returns the EntityValue object for that position; if there is no\n     * previous, returns null.\n     */\n    @Override EntityValue previous();\n\n    /** Returns the index of the previous result, but does not guarantee that there will be a previous result */\n    @Override int previousIndex();\n\n    void setFetchSize(int rows) throws EntityException;\n\n    EntityList getCompleteList(boolean closeAfter) throws EntityException;\n\n    /** Gets a partial list of results starting at start and containing at most number elements.\n     * Start is a one based value, ie 1 is the first element.\n     */\n    EntityList getPartialList(int offset, int limit, boolean closeAfter) throws EntityException;\n\n    /** Writes XML text with an attribute or CDATA element for each field of each record. If dependents is true also\n     * writes all dependent (descendant) records.\n     * @param writer A Writer object to write to\n     * @param prefix A prefix to put in front of the entity name in the tag name\n     * @param dependentLevels Write dependent (descendant) records this many levels deep, zero for no dependents\n     * @return The number of records written\n     */\n    int writeXmlText(Writer writer, String prefix, int dependentLevels);\n    int writeXmlTextMaster(Writer writer, String prefix, String masterName);\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/entity/EntityNotFoundException.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.entity;\n\npublic class EntityNotFoundException extends EntityException {\n    public EntityNotFoundException(String str) {\n        super(str);\n    }\n    // public EntityNotFoundException(String str, Throwable nested) { super(str, nested); }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/entity/EntityValue.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.entity;\n\nimport org.moqui.etl.SimpleEtl;\nimport org.w3c.dom.Document;\nimport org.w3c.dom.Element;\n\nimport javax.sql.rowset.serial.SerialBlob;\nimport java.io.Externalizable;\nimport java.io.Writer;\nimport java.math.BigDecimal;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\n\n\n/** Entity Value Interface - Represents a single database record. */\n@SuppressWarnings(\"unused\")\npublic interface EntityValue extends Map<String, Object>, Externalizable, Comparable<EntityValue>, Cloneable, SimpleEtl.Entry {\n\n    String resolveEntityName();\n    String resolveEntityNamePretty();\n\n    /** Returns true if any field has been modified */\n    boolean isModified();\n    /** Returns true if the field has been modified */\n    boolean isFieldModified(String name);\n    /** Treat field as modified for update() even if not to force a resend to the DB */\n    EntityValue touchField(String name);\n    /** Returns true if a value for the field is set, even if it is null */\n    boolean isFieldSet(String name);\n    /** Returns true if the name is a valid field name for the entity this is a value of,\n     * false otherwise (meaning get(), set(), etc calls with throw an exception with the field name) */\n    boolean isField(String name);\n\n    boolean isMutable();\n\n    /** Gets a cloned, mutable Map with the field values that is independent of this value object. Can be augmented or\n     * modified without modifying or being constrained by this entity value. */\n    Map<String, Object> getMap();\n\n    /** Get the named field.\n     *\n     * If there is a matching entry in the moqui.basic.LocalizedEntityField entity using the Locale in the current\n     * ExecutionContext then that will be returned instead.\n     *\n     * This method also supports getting related entities using their relationship name, formatted as\n     * \"${title}${related-entity-name}\". When doing so it is like calling\n     * <code>findRelated(relationshipName, null, null, null, null)</code> for type many relationships, or\n     * <code>findRelatedOne(relationshipName, null, null)</code> for type one relationships.\n     *\n     * @param name The field name to get, or the name of the relationship to get one or more related values from.\n     * @return Object with the value of the field, or the related EntityValue or EntityList.\n     */\n    Object get(String name);\n\n    /** Get simple fields only (no localization, no relationship) and don't check to see if it is a valid field; mostly\n     * for performance reasons and for well tested code with known field names. If it is not a valid field name will\n     * just return null and not throw an error, ie doesn't check for valid field names. */\n    Object getNoCheckSimple(String name);\n\n    /** Returns true if the entity contains all of the primary key fields. */\n    boolean containsPrimaryKey();\n\n    Map<String, Object> getPrimaryKeys();\n    String getPrimaryKeysString();\n\n    /** Sets the named field to the passed value, even if the value is null\n     * @param name The field name to set\n     * @param value The value to set\n     * @return reference to this for convenience\n     */\n    EntityValue set(String name, Object value);\n\n    /** Sets fields on this entity from the Map of fields passed in using the entity definition to only get valid\n     * fields from the Map. For any String values passed in this will call setString to convert based on the field\n     * definition, otherwise it sets the Object as-is.\n     *\n     * @param fields The fields Map to get the values from\n     * @return reference to this for convenience\n     */\n    EntityValue setAll(Map<String, Object> fields);\n\n    /** Sets the named field to the passed value, converting the value from a String to the corresponding type using \n     *   <code>Type.valueOf()</code>\n     *\n     * If the String \"null\" is passed in it will be treated the same as a null value. If you really want to set a\n     * String of \"null\" then pass in \"\\null\".\n     *\n     * @param name The field name to set\n     * @param value The String value to convert and set\n     * @return reference to this for convenience\n     */\n    EntityValue setString(String name, String value);\n\n    Boolean getBoolean(String name);\n\n    String getString(String name);\n\n    java.sql.Timestamp getTimestamp(String name);\n    java.sql.Time getTime(String name);\n    java.sql.Date getDate(String name);\n\n    Long getLong(String name);\n    Double getDouble(String name);\n    BigDecimal getBigDecimal(String name);\n\n    byte[] getBytes(String name);\n    EntityValue setBytes(String name, byte[] theBytes);\n    SerialBlob getSerialBlob(String name);\n\n    /** Sets fields on this entity from the Map of fields passed in using the entity definition to only get valid\n     * fields from the Map. For any String values passed in this will call setString to convert based on the field\n     * definition, otherwise it sets the Object as-is.\n     *\n     * @param fields The fields Map to get the values from\n     * @param setIfEmpty Used to specify whether empty/null values in the field Map should be set\n     * @param namePrefix If not null or empty will be pre-pended to each field name (upper-casing the first letter of\n     *   the field name first), and that will be used as the fields Map lookup name instead of the field-name\n     * @param pks If null, get all values, if TRUE just get PKs, if FALSE just get non-PKs\n     * @return reference to this for convenience\n     */\n    EntityValue setFields(Map<String, Object> fields, boolean setIfEmpty, String namePrefix, Boolean pks);\n\n    /** Get the next guaranteed unique seq id for this entity, and set it in the primary key field. This will set it in\n     * the first primary key field in the entity definition, but it really should be used for entities with only one\n     * primary key field.\n     *\n     * @return reference to this for convenience\n     */\n    EntityValue setSequencedIdPrimary();\n\n    /** Look at existing values with the same primary sequenced ID (first PK field) and get the highest existing\n     * value for the secondary sequenced ID (the second PK field), add 1 to it and set the result in this entity value.\n     *\n     * The current value object must have the primary sequenced field already populated.\n     *\n     * @return reference to this for convenience\n     */\n    EntityValue setSequencedIdSecondary();\n\n    /** Compares this EntityValue to the passed object\n     * @param that Object to compare this to\n     * @return int representing the result of the comparison (-1,0, or 1)\n     */\n    @Override\n    int compareTo(EntityValue that);\n\n    /** Returns true if all entries in the Map match field values. */\n    boolean mapMatches(Map<String, Object> theMap);\n\n    EntityValue cloneValue();\n\n    /** Creates a record for this entity value.\n     * @return reference to this for convenience\n     */\n    EntityValue create() throws EntityException;\n\n    /** Creates a record for this entity value, or updates the record if one exists that matches the primary key.\n     * @return reference to this for convenience\n     */\n    EntityValue createOrUpdate() throws EntityException;\n    /** Alias for createOrUpdate() */\n    EntityValue store() throws EntityException;\n\n    /** Updates the record that matches the primary key.\n     * @return reference to this for convenience\n     */\n    EntityValue update() throws EntityException;\n\n    /** Deletes the record that matches the primary key.\n     * @return reference to this for convenience\n     */\n    EntityValue delete() throws EntityException;\n\n    /** Refreshes this value based on the record that matches the primary key.\n     * @return true if a record was found, otherwise false also meaning no refresh was done\n     */\n    boolean refresh() throws EntityException;\n\n    Object getOriginalDbValue(String name);\n\n    /** Get the named Related Entity for the EntityValue from the persistent store\n     * @param relationshipName String containing the relationship name which is the combination of relationship.title\n     *   and relationship.related-entity-name as specified in the entity XML definition file\n     * @param byAndFields the fields that must equal in order to keep; may be null\n     * @param orderBy The fields of the named entity to order the query by; may be null;\n     *      optionally add a \" ASC\" for ascending or \" DESC\" for descending\n     * @param useCache Look in the cache before finding in the datasource. Defaults to setting on entity definition.\n     * @return List of EntityValue instances as specified in the relation definition\n     */\n    EntityList findRelated(String relationshipName, Map<String, Object> byAndFields, List<String> orderBy,\n                                  Boolean useCache, Boolean forUpdate) throws EntityException;\n\n    /** Get the named Related Entity for the EntityValue from the persistent store\n     * @param relationshipName String containing the relationship name which is the combination of relationship.title\n     *   and relationship.related-entity-name as specified in the entity XML definition file\n     * @param useCache Look in the cache before finding in the datasource. Defaults to setting on entity definition.\n     * @return List of EntityValue instances as specified in the relation definition\n     */\n    EntityValue findRelatedOne(String relationshipName, Boolean useCache, Boolean forUpdate) throws EntityException;\n\n    long findRelatedCount(final String relationshipName, Boolean useCache);\n\n    /** Find all records with a foreign key reference to this record. Operates on relationship definitions for any related entity\n     * that has a type one relationship to this entity.\n     *\n     * Does not recurse, finds directly related (dependant) records only.\n     *\n     * Will skip any related records whose entity name is in skipEntities.\n     *\n     * Useful as a validation before calling deleteWithCascade().\n     */\n    EntityList findRelatedFk(Set<String> skipEntities);\n\n    /** Remove the named Related Entity for the EntityValue from the persistent store\n     * @param relationshipName String containing the relationship name which is the combination of relationship.title\n     *   and relationship.related-entity-name as specified in the entity XML definition file\n     */\n    void deleteRelated(String relationshipName) throws EntityException;\n\n    /** Delete this record plus records for all relationships specified. If any records exist for other relationships not specified\n     * that depend on this record returns false and does not delete anything.\n     *\n     * Returns true if this and related records were deleted.\n     */\n    boolean deleteWithRelated(Set<String> relationshipsToDelete);\n\n    /** Deletes this record and all records that depend on it, doing the same for each (cascading delete).\n     * Deletes related records that depend on this record (records with a foreign key reference to this record).\n     *\n     * To clear the reference (set fields to null) instead of deleting records specify the entity names, related to this or any\n     * related entity, in the clearRefEntities parameter.\n     *\n     * To check for records that should prevent a delete you can optionally pass a Set of entities names in the\n     * validateAllowDeleteEntities parameter. If this is not null an exception will be thrown instead of deleting\n     * any record for an entity NOT in that Set.\n     *\n     * WARNING: this may delete records you don't want to. Look at the nested relationships in the Entity Reference in the\n     * Tools app to see what might might get deleted (anything with a type one relationship to this entity, or recursing\n     * anything with a type one relationship to those).\n     */\n    void deleteWithCascade(Set<String> clearRefEntities, Set<String> validateAllowDeleteEntities);\n\n    /**\n     * Checks to see if all foreign key records exist in the database (records this record refers to).\n     * Will attempt to create a dummy value (PK only) for those missing when specified insertDummy is true.\n     *\n     * @param insertDummy Create a dummy record using the provided fields\n     * @return true if all FKs exist (or when all missing are created)\n     */\n    boolean checkFks(boolean insertDummy) throws EntityException;\n    /** Compare this value to the database, adding messages about fields that differ or if the record doesn't exist to messages. */\n    long checkAgainstDatabase(List<String> messages);\n    long checkAgainstDatabaseInfo(List<Map<String, Object>> diffInfoList, List<String> messages, String location);\n\n    /** Makes an XML Element object with an attribute for each field of the entity\n     * @param document The XML Document that the new Element will be part of\n     * @param prefix A prefix to put in front of the entity name in the tag name\n     * @return org.w3c.dom.Element object representing this entity value\n     */\n    Element makeXmlElement(Document document, String prefix);\n\n    /** Writes XML text with an attribute or CDATA element for each field of the entity. If dependents is true also\n     * writes all dependent (descendant) records.\n     * @param writer A Writer object to write to\n     * @param prefix A prefix to put in front of the entity name in the tag name\n     * @param dependentLevels Write dependent (descendant) records this many levels deep, zero for no dependents\n     * @return The number of records written\n     */\n    int writeXmlText(Writer writer, String prefix, int dependentLevels);\n    int writeXmlTextMaster(Writer pw, String prefix, String masterName);\n\n    /** Get a Map with all non-null field values. If dependentLevels is greater than zero includes nested dependents\n     * in the Map as an entry with key of the dependent relationship's short-alias or if no short-alias then the\n     * relationship name (title + related-entity-name). Each dependent entity's Map may have its own dependent records\n     * up to dependentLevels levels deep.*/\n    Map<String, Object> getPlainValueMap(int dependentLevels);\n\n    /** List getPlainValueMap() but uses a master definition to determine which dependent/related records to get. */\n    Map<String, Object> getMasterValueMap(String name);\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/entity/EntityValueNotFoundException.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.entity;\n\npublic class EntityValueNotFoundException extends EntityException {\n    public EntityValueNotFoundException(String str) {\n        super(str);\n    }\n    // public EntityValueNotFoundException(String str, Throwable nested) { super(str, nested); }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/etl/FlatXmlExtractor.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.etl;\n\nimport org.moqui.BaseException;\nimport org.moqui.resource.ResourceReference;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.xml.sax.Attributes;\nimport org.xml.sax.InputSource;\nimport org.xml.sax.Locator;\nimport org.xml.sax.XMLReader;\nimport org.xml.sax.helpers.DefaultHandler;\n\nimport javax.xml.parsers.SAXParserFactory;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.util.HashMap;\n\npublic class FlatXmlExtractor implements SimpleEtl.Extractor {\n    protected final static Logger logger = LoggerFactory.getLogger(FlatXmlExtractor.class);\n\n    SimpleEtl etl = null;\n    private ResourceReference resourceRef;\n\n    FlatXmlExtractor(ResourceReference xmlRef) { resourceRef = xmlRef; }\n\n    @Override\n    public void extract(SimpleEtl etl) throws Exception {\n        this.etl = etl;\n\n        if (resourceRef == null || !resourceRef.getExists()) {\n            logger.warn(\"Resource does not exist, not extracting data from \" + (resourceRef != null ? resourceRef.getLocation() : \"[null ResourceReference]\"));\n            return;\n        }\n        InputStream is = resourceRef.openStream();\n        if (is == null) return;\n\n        try {\n            FlatXmlHandler xmlHandler = new FlatXmlHandler(this);\n            XMLReader reader = SAXParserFactory.newInstance().newSAXParser().getXMLReader();\n            reader.setContentHandler(xmlHandler);\n            reader.parse(new InputSource(is));\n        } catch (Exception e) {\n            throw new BaseException(\"Error parsing XML from \" + resourceRef.getLocation(), e);\n        } finally {\n            try { is.close(); }\n            catch (IOException e) { logger.error(\"Error closing XML stream from \" + resourceRef.getLocation(), e); }\n        }\n\n    }\n\n    private static class FlatXmlHandler extends DefaultHandler {\n        Locator locator = null;\n        FlatXmlExtractor extractor;\n\n        String rootName = null;\n        SimpleEtl.SimpleEntry curEntry = null;\n        String curTextName = null;\n        StringBuilder curText = null;\n        private boolean stopParse = false;\n\n        FlatXmlHandler(FlatXmlExtractor fxe) { extractor = fxe; }\n\n        @Override\n        public void startElement(String ns, String localName, String qName, Attributes attributes) {\n            if (stopParse) return;\n            if (rootName == null) {\n                rootName = qName;\n                return;\n            }\n\n            if (curEntry == null) {\n                curEntry = new SimpleEtl.SimpleEntry(qName, new HashMap<>());\n                int length = attributes.getLength();\n                for (int i = 0; i < length; i++) {\n                    String name = attributes.getLocalName(i);\n                    String value = attributes.getValue(i);\n                    if (name == null || name.length() == 0) name = attributes.getQName(i);\n                    curEntry.values.put(name, value);\n                }\n            } else {\n                curTextName = qName;\n                curText = new StringBuilder();\n            }\n        }\n\n        @Override\n        public void characters(char[] chars, int offset, int length) {\n            if (stopParse) return;\n\n            if (curText == null) curText = new StringBuilder();\n            curText.append(chars, offset, length);\n        }\n        @Override\n        public void endElement(String ns, String localName, String qName) {\n            if (stopParse) return;\n\n            if (curEntry == null) {\n                // should be the root element in a flat record file\n                if (rootName != null && rootName.equals(qName)) rootName = null;\n                return;\n            }\n\n            if (curTextName != null) {\n                curEntry.values.put(curTextName, curText.toString());\n                curTextName = null;\n                curText = null;\n                return;\n            }\n\n            if (!qName.equals(curEntry.type)) throw new IllegalStateException(\"Invalid close element \" + qName + \", was expecting \" + curEntry.type);\n\n            try {\n                extractor.etl.processEntry(curEntry);\n            } catch (SimpleEtl.StopException e) {\n                logger.warn(\"Got StopException\", e);\n                stopParse = true;\n            }\n\n            curEntry = null;\n            curTextName = null;\n            curText = null;\n        }\n\n        @Override\n        public void setDocumentLocator(Locator locator) { this.locator = locator; }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/etl/SimpleEtl.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.etl;\n\nimport javax.annotation.Nonnull;\nimport java.util.*;\n\n@SuppressWarnings(\"unused\")\npublic class SimpleEtl {\n    private Extractor extractor;\n    private TransformConfiguration internalConfig = null;\n    private Loader loader;\n\n    private List<String> messages = new LinkedList<>();\n    private Exception extractException = null;\n    private List<EtlError> transformErrors = new LinkedList<>();\n    private List<EtlError> loadErrors = new LinkedList<>();\n    private boolean stopOnError = false;\n    private Integer timeout = 3600; // default to one hour\n\n    private int extractCount = 0, skipCount = 0, loadCount = 0;\n    private long startTime = 0, endTime = 0;\n\n    public SimpleEtl(@Nonnull Extractor extractor, @Nonnull Loader loader) {\n        this.extractor = extractor;\n        this.loader = loader;\n    }\n\n\n    /** Call this to add a transformer to run for any type, will be run in order added */\n    public SimpleEtl addTransformer(@Nonnull Transformer transformer) {\n        if (internalConfig == null) internalConfig = new TransformConfiguration();\n        internalConfig.addTransformer(transformer);\n        return this;\n    }\n    /** Call this to add a transformer to run a particular type, which will be run in order for entries of the type */\n    public SimpleEtl addTransformer(@Nonnull String type, @Nonnull Transformer transformer) {\n        if (internalConfig == null) internalConfig = new TransformConfiguration();\n        internalConfig.addTransformer(type, transformer);\n        return this;\n    }\n    /** Add from an external TransformConfiguration, copies configuration to avoid modification */\n    public SimpleEtl addConfiguration(TransformConfiguration config) {\n        if (internalConfig == null) internalConfig = new TransformConfiguration();\n        internalConfig.copyFrom(config);\n        return this;\n    }\n    /** Use an external configuration as-is. Overrides any previous addTransformer() and addConfiguration() calls.\n     * Any calls to addTransformer() and addConfiguration() will modify this configuration. */\n    public SimpleEtl setConfiguration(TransformConfiguration config) {\n        internalConfig = config;\n        return this;\n    }\n    /** Call this to set stop on error flag */\n    public SimpleEtl stopOnError() { this.stopOnError = true; return this; }\n    /** Set timeout in seconds; passed to Loader.init() for transactions, etc */\n    public SimpleEtl setTimeout(Integer timeout) { this.timeout = timeout; return this; }\n\n    /** Call this to process the ETL */\n    public SimpleEtl process() {\n        startTime = System.currentTimeMillis();\n        // initialize loader\n        loader.init(timeout);\n\n        try {\n            // kick off extraction to process extracted entries\n            extractor.extract(this);\n        } catch (Exception e) {\n            extractException = e;\n        } finally {\n            // close the loader\n            loader.complete(this);\n            endTime = System.currentTimeMillis();\n        }\n\n        return this;\n    }\n\n    public Extractor getExtractor() { return extractor; }\n    public Loader getLoader() { return loader; }\n\n    public SimpleEtl addMessage(String msg) { this.messages.add(msg); return this; }\n    public List<String> getMessages() { return Collections.unmodifiableList(messages); }\n    public int getExtractCount() { return extractCount; }\n    public int getSkipCount() { return skipCount; }\n    public int getLoadCount() { return loadCount; }\n    public long getRunTime() { return endTime - startTime; }\n\n    public Exception getExtractException() { return extractException; }\n    public List<EtlError> getTransformErrors() { return Collections.unmodifiableList(transformErrors); }\n    public List<EtlError> getLoadErrors() { return Collections.unmodifiableList(loadErrors); }\n    public boolean hasError() { return extractException != null || transformErrors.size() > 0 || loadErrors.size() > 0; }\n    public Throwable getSingleErrorCause() {\n        if (extractException != null) return extractException;\n        if (transformErrors.size() > 0) return transformErrors.get(0).error;\n        if (loadErrors.size() > 0) return loadErrors.get(0).error;\n        return null;\n    }\n\n    /**\n     * Called by the Extractor to process an extracted entry.\n     * @return true if the entry loaded, false otherwise\n     * @throws StopException if thrown extraction should stop and return\n     */\n    public boolean processEntry(Entry extractEntry) throws StopException {\n        if (extractEntry == null) return false;\n        extractCount++;\n        ArrayList<Entry> loadEntries = new ArrayList<>();\n\n        if (internalConfig != null && internalConfig.hasTransformers) {\n            EntryTransform entryTransform = new EntryTransform(extractEntry);\n            internalConfig.runTransformers(this, entryTransform, loadEntries);\n            if (entryTransform.loadCurrent != null ? entryTransform.loadCurrent :\n                    entryTransform.newEntries == null || entryTransform.newEntries.size() == 0) {\n                loadEntries.add(0, entryTransform.entry);\n            } else if (entryTransform.newEntries == null || entryTransform.newEntries.size() == 0) {\n                skipCount++;\n                return false;\n            }\n        } else {\n            loadEntries.add(extractEntry);\n        }\n\n        int loadEntriesSize = loadEntries.size();\n        for (int i = 0; i < loadEntriesSize; i++) {\n            Entry loadEntry = loadEntries.get(i);\n            try {\n                loader.load(loadEntry);\n                loadCount++;\n            } catch (Throwable t) {\n                loadErrors.add(new EtlError(loadEntry, t));\n                if (stopOnError) throw new StopException(t);\n                return false;\n            }\n        }\n        return true;\n    }\n\n    public static class TransformConfiguration {\n        private ArrayList<Transformer> anyTransformers = new ArrayList<>();\n        private int anyTransformerSize = 0;\n        private LinkedHashMap<String, ArrayList<Transformer>> typeTransformers = new LinkedHashMap<>();\n        boolean hasTransformers = false;\n\n        public TransformConfiguration() { }\n\n        /** Call this to add a transformer to run for any type, which will be run in order */\n        public TransformConfiguration addTransformer(@Nonnull Transformer transformer) {\n            anyTransformers.add(transformer);\n            anyTransformerSize = anyTransformers.size();\n            hasTransformers = true;\n            return this;\n        }\n        /** Call this to add a transformer to run a particular type, which will be run in order for entries of the type */\n        public TransformConfiguration addTransformer(@Nonnull String type, @Nonnull Transformer transformer) {\n            typeTransformers.computeIfAbsent(type, k -> new ArrayList<>()).add(transformer);\n            hasTransformers = true;\n            return this;\n        }\n\n        // returns true to skip the entry (or remove from load list)\n        void runTransformers(SimpleEtl etl, EntryTransform entryTransform, ArrayList<Entry> loadEntries) throws StopException {\n            for (int i = 0; i < anyTransformerSize; i++) {\n                transformEntry(etl, anyTransformers.get(i), entryTransform);\n            }\n            String curType = entryTransform.entry.getEtlType();\n            if (curType != null && !curType.isEmpty()) {\n                ArrayList<Transformer> curTypeTrans = typeTransformers.get(curType);\n                int curTypeTransSize = curTypeTrans != null ? curTypeTrans.size() : 0;\n                for (int i = 0; i < curTypeTransSize; i++) {\n                    transformEntry(etl, curTypeTrans.get(i), entryTransform);\n                }\n            }\n            // handle new entries, run transforms then add to load list if not skipped\n            int newEntriesSize = entryTransform.newEntries != null ? entryTransform.newEntries.size() : 0;\n            for (int i = 0; i < newEntriesSize; i++) {\n                Entry newEntry = entryTransform.newEntries.get(i);\n                if (newEntry == null) continue;\n\n                EntryTransform newTransform = new EntryTransform(newEntry);\n                runTransformers(etl, newTransform, loadEntries);\n                if (newTransform.loadCurrent != null ? newTransform.loadCurrent : newTransform.newEntries == null || newTransform.newEntries.size() == 0) {\n                    loadEntries.add(newEntry);\n                }\n            }\n        }\n        // internal method, returns true to skip entry (or remove from load list)\n        void transformEntry(SimpleEtl etl, Transformer transformer, EntryTransform entryTransform) throws StopException {\n            try {\n                transformer.transform(entryTransform);\n            } catch (Throwable t) {\n                etl.transformErrors.add(new EtlError(entryTransform.entry, t));\n                if (etl.stopOnError) throw new StopException(t);\n                entryTransform.loadCurrent(false);\n            }\n        }\n\n        void copyFrom(TransformConfiguration conf) {\n            if (conf == null) return;\n            anyTransformers.addAll(conf.anyTransformers);\n            anyTransformerSize = anyTransformers.size();\n            for (Map.Entry<String, ArrayList<Transformer>> entry : conf.typeTransformers.entrySet()) {\n                typeTransformers.computeIfAbsent(entry.getKey(), k -> new ArrayList<>()).addAll(entry.getValue());\n            }\n        }\n    }\n\n    public static class StopException extends Exception {\n        public StopException(Throwable t) { super(t); }\n    }\n\n    public static class EtlError {\n        public final Entry entry;\n        public final Throwable error;\n        EtlError(Entry entry, Throwable t) { this.entry = entry; this.error = t; }\n    }\n\n    public interface Entry {\n        String getEtlType();\n        Map<String, Object> getEtlValues();\n    }\n    public static class SimpleEntry implements Entry {\n        public final String type;\n        public final Map<String, Object> values;\n        public SimpleEntry(String type, Map<String, Object> values) { this.type = type; this.values = values; }\n        @Override public String getEtlType() { return type; }\n        @Override public Map<String, Object> getEtlValues() { return values; }\n        // TODO: add equals and hash overrides\n    }\n    public static class EntryTransform {\n        final Entry entry;\n        ArrayList<Entry> newEntries = null;\n        Boolean loadCurrent = null;\n        EntryTransform(Entry entry) { this.entry = entry; }\n        /** Get the current entry to get type and get/put values as needed */\n        public Entry getEntry() { return entry; }\n        /** By default the current entry is loaded only if no new entries are added; set to false to not load even if no entries are\n         * added (filter); set to true to load even if no new entries are added */\n        public EntryTransform loadCurrent(boolean load) { loadCurrent = load; return this; }\n        /** Add a new entry to be transformed and if not filtered then loaded */\n        public EntryTransform addEntry(Entry newEntry) {\n            if (newEntries == null) newEntries = new ArrayList<>();\n            newEntries.add(newEntry);\n            return this;\n        }\n    }\n\n    public interface Extractor {\n        /** Called once to start processing, should call etl.processEntry() for each entry and close itself once finished */\n        void extract(SimpleEtl etl) throws Exception;\n    }\n    /** Stateless ETL entry transformer and filter interface */\n    public interface Transformer {\n        /** Call methods on EntryTransform to add new entries (generally with different types), modify the current entry's values, or filter the entry. */\n        void transform(EntryTransform entryTransform) throws Exception;\n    }\n    public interface Loader {\n        /** Called before SimpleEtl processing begins */\n        void init(Integer timeout);\n        /** Load a single, optionally transformed, entry into the data destination */\n        void load(Entry entry) throws Exception;\n        /** Called after all entries processed to close files, commit/rollback transactions, etc;  */\n        void complete(SimpleEtl etl);\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/jcache/MCache.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.jcache;\n\nimport javax.cache.Cache;\nimport javax.cache.CacheException;\nimport javax.cache.CacheManager;\nimport javax.cache.configuration.CacheEntryListenerConfiguration;\nimport javax.cache.configuration.CompleteConfiguration;\nimport javax.cache.configuration.Configuration;\nimport javax.cache.expiry.Duration;\nimport javax.cache.expiry.ExpiryPolicy;\nimport javax.cache.integration.CompletionListener;\nimport javax.cache.management.CacheStatisticsMXBean;\nimport javax.cache.processor.EntryProcessor;\nimport javax.cache.processor.EntryProcessorException;\nimport javax.cache.processor.EntryProcessorResult;\n\nimport java.util.*;\nimport java.util.concurrent.*;\nimport java.util.concurrent.atomic.AtomicInteger;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\n/** A simple implementation of the javax.cache.Cache interface. Basically a wrapper around a Map with stats and expiry. */\n@SuppressWarnings(\"unused\")\npublic class MCache<K, V> implements Cache<K, V> {\n    private static final Logger logger = LoggerFactory.getLogger(MCache.class);\n\n    private String name;\n    private CacheManager manager;\n    private Configuration<K, V> configuration;\n    // NOTE: use ConcurrentHashMap for write locks and such even if can't so easily use putIfAbsent/etc\n    private ConcurrentHashMap<K, MEntry<K, V>> entryStore = new ConcurrentHashMap<>();\n    // currently for future reference, no runtime type checking\n    // private Class<K> keyClass = null;\n    // private Class<V> valueClass = null;\n\n    private MStats stats = new MStats();\n    private boolean statsEnabled = true;\n\n    private Duration accessDuration = null;\n    private Duration creationDuration = null;\n    private Duration updateDuration = null;\n    private final boolean hasExpiry;\n    private boolean isClosed = false;\n\n    private EvictRunnable evictRunnable = null;\n    private ScheduledFuture<?> evictFuture = null;\n\n    private static class WorkerThreadFactory implements ThreadFactory {\n        private final ThreadGroup workerGroup = new ThreadGroup(\"MCacheEvict\");\n        private final AtomicInteger threadNumber = new AtomicInteger(1);\n        @Override public Thread newThread(Runnable r) { return new Thread(workerGroup, r, \"MCacheEvict-\" + threadNumber.getAndIncrement()); }\n    }\n    private static ScheduledThreadPoolExecutor workerPool = new ScheduledThreadPoolExecutor(1, new WorkerThreadFactory());\n    static { workerPool.setRemoveOnCancelPolicy(true); }\n\n    /** Supports a few configurations but both manager and configuration can be null. */\n    public MCache(String name, CacheManager manager, Configuration<K, V> configuration) {\n        this.name = name;\n        this.manager = manager;\n        this.configuration = configuration;\n        if (configuration != null) {\n            if (configuration instanceof CompleteConfiguration) {\n                CompleteConfiguration<K, V> compConf = (CompleteConfiguration<K, V>) configuration;\n\n                statsEnabled = compConf.isStatisticsEnabled();\n\n                if (compConf.getExpiryPolicyFactory() != null) {\n                    ExpiryPolicy ep = compConf.getExpiryPolicyFactory().create();\n                    accessDuration = ep.getExpiryForAccess();\n                    if (accessDuration != null && accessDuration.isEternal()) accessDuration = null;\n                    creationDuration = ep.getExpiryForCreation();\n                    if (creationDuration != null && creationDuration.isEternal()) creationDuration = null;\n                    updateDuration = ep.getExpiryForUpdate();\n                    if (updateDuration != null && updateDuration.isEternal()) updateDuration = null;\n                }\n            }\n\n            // keyClass = configuration.getKeyType();\n            // valueClass = configuration.getValueType();\n            // TODO: support any other configuration?\n\n            if (configuration instanceof MCacheConfiguration) {\n                MCacheConfiguration<K, V> mCacheConf = (MCacheConfiguration<K, V>) configuration;\n\n                if (mCacheConf.maxEntries > 0) {\n                    evictRunnable = new EvictRunnable(this, mCacheConf.maxEntries);\n                    evictFuture = workerPool.scheduleWithFixedDelay(evictRunnable, 30, mCacheConf.maxCheckSeconds, TimeUnit.SECONDS);\n                }\n            }\n        }\n        hasExpiry = accessDuration != null || creationDuration != null || updateDuration != null;\n    }\n\n    public synchronized void setMaxEntries(int elements) {\n        if (elements == 0) {\n            if (evictRunnable != null) {\n                evictRunnable = null;\n                evictFuture.cancel(false);\n                evictFuture = null;\n            }\n        } else {\n            if (evictRunnable != null) {\n                evictRunnable.maxEntries = elements;\n            } else {\n                evictRunnable = new EvictRunnable(this, elements);\n                long maxCheckSeconds = 30;\n                if (configuration instanceof MCacheConfiguration) maxCheckSeconds = ((MCacheConfiguration) configuration).maxCheckSeconds;\n                evictFuture = workerPool.scheduleWithFixedDelay(evictRunnable, 1, maxCheckSeconds, TimeUnit.SECONDS);\n            }\n        }\n    }\n    public int getMaxEntries() { return evictRunnable != null ? evictRunnable.maxEntries : 0; }\n\n    @Override\n    public String getName() { return name; }\n\n    @Override\n    public V get(K key) {\n        MEntry<K, V> entry = getEntryInternal(key, null, null, 0);\n        if (entry == null) return null;\n        return entry.value;\n    }\n    public V get(K key, ExpiryPolicy policy) {\n        MEntry<K, V> entry = getEntryInternal(key, policy, null, 0);\n        if (entry == null) return null;\n        return entry.value;\n    }\n    /** Get with expire if the entry's last updated time is before the expireBeforeTime.\n     * Useful when last updated time of a resource is known to see if the cached entry is out of date. */\n    public V get(K key, long expireBeforeTime) {\n        MEntry<K, V> entry = getEntryInternal(key, null, expireBeforeTime, 0);\n        if (entry == null) return null;\n        return entry.value;\n    }\n    /** Get an entry, if it is in the cache and not expired, otherwise returns null. The policy can be null to use cache's policy. */\n    public MEntry<K, V> getEntry(final K key, final ExpiryPolicy policy) { return getEntryInternal(key, policy, null, 0); }\n    /** Simple entry get, doesn't check if expired. */\n    public MEntry<K, V> getEntryNoCheck(K key) {\n        if (isClosed) throw new IllegalStateException(\"Cache \" + name + \" is closed\");\n        if (key == null) throw new IllegalArgumentException(\"Cache key cannot be null\");\n        MEntry<K, V> entry = entryStore.get(key);\n        if (entry != null) {\n            if (statsEnabled) { stats.gets++; stats.hits++; }\n            long accessTime = System.currentTimeMillis();\n            entry.accessCount++; if (accessTime > entry.lastAccessTime) entry.lastAccessTime = accessTime;\n        } else {\n            if (statsEnabled) { stats.gets++; stats.misses++; }\n        }\n        return entry;\n    }\n    private MEntry<K, V> getEntryInternal(final K key, final ExpiryPolicy policy, final Long expireBeforeTime, long currentTime) {\n        if (isClosed) throw new IllegalStateException(\"Cache \" + name + \" is closed\");\n        if (key == null) throw new IllegalArgumentException(\"Cache key cannot be null\");\n        MEntry<K, V> entry = entryStore.get(key);\n\n        if (entry != null) {\n            if (policy != null) {\n                if (currentTime == 0) currentTime = System.currentTimeMillis();\n                if (entry.isExpired(currentTime, policy)) {\n                    entryStore.remove(key);\n                    entry = null;\n                    if (statsEnabled) stats.countExpire();\n                }\n            } else if (hasExpiry) {\n                if (currentTime == 0) currentTime = System.currentTimeMillis();\n                if (entry.isExpired(currentTime, accessDuration, creationDuration, updateDuration)) {\n                    entryStore.remove(key);\n                    entry = null;\n                    if (statsEnabled) stats.countExpire();\n                }\n            }\n\n            if (expireBeforeTime != null && entry != null && entry.lastUpdatedTime < expireBeforeTime) {\n                entryStore.remove(key);\n                entry = null;\n                if (statsEnabled) stats.countExpire();\n            }\n\n            if (entry != null) {\n                if (statsEnabled) { stats.gets++; stats.hits++; }\n                entry.accessCount++;\n                // at this point if an ad-hoc policy is used or hasExpiry == true currentTime will be set, otherwise will be 0\n                // meaning we don't need to track the lastAccessTime (only thing we need System.currentTimeMillis() for)\n                // if (currentTime == 0) currentTime = System.currentTimeMillis();\n                if (currentTime > entry.lastAccessTime) entry.lastAccessTime = currentTime;\n            } else {\n                if (statsEnabled) { stats.gets++; stats.misses++; }\n            }\n        } else {\n            if (statsEnabled) { stats.gets++; stats.misses++; }\n        }\n\n        return entry;\n    }\n    private MEntry<K, V> getCheckExpired(K key) {\n        if (isClosed) throw new IllegalStateException(\"Cache \" + name + \" is closed\");\n        if (key == null) throw new IllegalArgumentException(\"Cache key cannot be null\");\n        MEntry<K, V> entry = entryStore.get(key);\n        if (hasExpiry && entry != null && entry.isExpired(accessDuration, creationDuration, updateDuration)) {\n            entryStore.remove(key);\n            entry = null;\n            if (statsEnabled) stats.countExpire();\n        }\n        return entry;\n    }\n    private MEntry<K, V> getCheckExpired(K key, long currentTime) {\n        if (isClosed) throw new IllegalStateException(\"Cache \" + name + \" is closed\");\n        if (key == null) throw new IllegalArgumentException(\"Cache key cannot be null\");\n        MEntry<K, V> entry = entryStore.get(key);\n        if (hasExpiry && entry != null && entry.isExpired(currentTime, accessDuration, creationDuration, updateDuration)) {\n            entryStore.remove(key);\n            entry = null;\n            if (statsEnabled) stats.countExpire();\n        }\n        return entry;\n    }\n\n    @Override\n    public Map<K, V> getAll(Set<? extends K> keys) {\n        long currentTime = System.currentTimeMillis();\n        Map<K, V> results = new HashMap<>();\n        for (K key: keys) {\n            MEntry<K, V> entry = getEntryInternal(key, null, null, currentTime);\n            results.put(key, entry != null ? entry.value : null);\n        }\n        return results;\n    }\n    @Override\n    public boolean containsKey(K key) {\n        MEntry<K, V> entry = getCheckExpired(key);\n        return entry != null;\n    }\n\n    @Override\n    public void put(K key, V value) {\n        long currentTime = System.currentTimeMillis();\n        // get entry, count hit/miss\n        MEntry<K, V> entry = getCheckExpired(key, currentTime);\n        if (entry != null) {\n            entry.setValue(value, currentTime);\n            if (statsEnabled) stats.puts++;\n        } else {\n            entry = new MEntry<>(key, value, currentTime);\n            entryStore.put(key, entry);\n            if (statsEnabled) stats.puts++;\n        }\n    }\n    @Override\n    public V getAndPut(K key, V value) {\n        long currentTime = System.currentTimeMillis();\n        // get entry, count hit/miss\n        MEntry<K, V> entry = getCheckExpired(key, currentTime);\n        if (entry != null) {\n            V oldValue = entry.value;\n            entry.setValue(value, currentTime);\n            if (statsEnabled) stats.puts++;\n            return oldValue;\n        } else {\n            entry = new MEntry<>(key, value, currentTime);\n            entryStore.put(key, entry);\n            if (statsEnabled) stats.puts++;\n            return null;\n        }\n    }\n\n    @Override\n    public void putAll(Map<? extends K, ? extends V> map) {\n        if (map == null) return;\n        for (Map.Entry<? extends K, ? extends V> me: map.entrySet()) getAndPut(me.getKey(), me.getValue());\n    }\n    @Override\n    public boolean putIfAbsent(K key, V value) {\n        long currentTime = System.currentTimeMillis();\n        MEntry<K, V> entry = getCheckExpired(key, currentTime);\n        if (entry != null) {\n            return false;\n        } else {\n            entry = new MEntry<>(key, value, currentTime);\n            MEntry<K, V> existingValue = entryStore.putIfAbsent(key, entry);\n            if (existingValue == null) {\n                if (statsEnabled) stats.puts++;\n                return true;\n            } else {\n                return false;\n            }\n        }\n    }\n\n    @Override\n    public boolean remove(K key) {\n        MEntry<K, V> entry = getCheckExpired(key);\n        if (entry != null) {\n            entryStore.remove(key);\n            if (statsEnabled) stats.countRemoval();\n            return true;\n        } else {\n            return false;\n        }\n    }\n    @Override\n    public boolean remove(K key, V oldValue) {\n        MEntry<K, V> entry = getCheckExpired(key);\n\n        if (entry != null) {\n            boolean remove = entry.valueEquals(oldValue);\n            if (remove) {\n                // remove with dummy MEntry instance for comparison to ensure still equals\n                remove = entryStore.remove(key, new MEntry<>(key, oldValue));\n                if (remove && statsEnabled) stats.countRemoval();\n            }\n            return remove;\n        } else {\n            return false;\n        }\n    }\n\n    @Override\n    public V getAndRemove(K key) {\n        // get entry, count hit/miss\n        MEntry<K, V> entry = getEntryInternal(key, null, null, 0);\n        if (entry != null) {\n            V oldValue = entry.value;\n            entryStore.remove(key);\n            if (statsEnabled) stats.countRemoval();\n            return oldValue;\n        }\n        return null;\n    }\n\n    @Override\n    public boolean replace(K key, V oldValue, V newValue) {\n        long currentTime = System.currentTimeMillis();\n        MEntry<K, V> entry = getCheckExpired(key, currentTime);\n\n        if (entry != null) {\n            boolean replaced = entry.setValueIfEquals(oldValue, newValue, currentTime);\n            if (replaced) if (statsEnabled) stats.puts++;\n            return replaced;\n        } else {\n            return false;\n        }\n    }\n\n    @Override\n    public boolean replace(K key, V value) {\n        long currentTime = System.currentTimeMillis();\n        MEntry<K, V> entry = getCheckExpired(key, currentTime);\n\n        if (entry != null) {\n            entry.setValue(value, currentTime);\n            if (statsEnabled) stats.puts++;\n            return true;\n        } else {\n            return false;\n        }\n    }\n\n    @Override\n    public V getAndReplace(K key, V value) {\n        long currentTime = System.currentTimeMillis();\n        // get entry, count hit/miss\n        MEntry<K, V> entry = getEntryInternal(key, null, null, currentTime);\n        if (entry != null) {\n            V oldValue = entry.value;\n            entry.setValue(value, currentTime);\n            if (statsEnabled) stats.puts++;\n            return oldValue;\n        } else {\n            return null;\n        }\n    }\n\n    @Override\n    public void removeAll(Set<? extends K> keys) {\n        if (isClosed) throw new IllegalStateException(\"Cache \" + name + \" is closed\");\n        for (K key: keys) remove(key);\n    }\n\n    @Override\n    public void removeAll() {\n        if (isClosed) throw new IllegalStateException(\"Cache \" + name + \" is closed\");\n        int size = entryStore.size();\n        entryStore.clear();\n        if (statsEnabled) stats.countBulkRemoval(size);\n    }\n\n    @Override\n    public void clear() {\n        if (isClosed) throw new IllegalStateException(\"Cache \" + name + \" is closed\");\n        // don't track removals or do anything else, removeAll does that\n        entryStore.clear();\n    }\n\n    @Override\n    public <C extends Configuration<K, V>> C getConfiguration(Class<C> clazz) {\n        if (configuration == null) return null;\n        if (clazz.isAssignableFrom(configuration.getClass())) return clazz.cast(configuration);\n        throw new IllegalArgumentException(\"Class \" + clazz.getName() + \" not compatible with configuration class \" + configuration.getClass().getName());\n    }\n\n    @Override\n    public void loadAll(Set<? extends K> keys, boolean replaceExistingValues, CompletionListener completionListener) {\n        throw new CacheException(\"loadAll not supported in MCache\");\n    }\n    @Override\n    public <T> T invoke(K key, EntryProcessor<K, V, T> entryProcessor, Object... arguments) throws EntryProcessorException {\n        throw new CacheException(\"invoke not supported in MCache\");\n    }\n    @Override\n    public <T> Map<K, EntryProcessorResult<T>> invokeAll(Set<? extends K> keys, EntryProcessor<K, V, T> entryProcessor, Object... arguments) {\n        throw new CacheException(\"invokeAll not supported in MCache\");\n    }\n    @Override\n    public void registerCacheEntryListener(CacheEntryListenerConfiguration<K, V> cacheEntryListenerConfiguration) {\n        throw new CacheException(\"registerCacheEntryListener not supported in MCache\");\n    }\n    @Override\n    public void deregisterCacheEntryListener(CacheEntryListenerConfiguration<K, V> cacheEntryListenerConfiguration) {\n        throw new CacheException(\"deregisterCacheEntryListener not supported in MCache\");\n    }\n\n    @Override\n    public CacheManager getCacheManager() { return manager; }\n\n    @Override\n    public void close() {\n        if (isClosed) throw new IllegalStateException(\"Cache \" + name + \" is already closed\");\n        isClosed = true;\n        entryStore.clear();\n    }\n    @Override\n    public boolean isClosed() { return isClosed; }\n\n    @Override\n    public <T> T unwrap(Class<T> clazz) {\n        if (clazz.isAssignableFrom(this.getClass())) return clazz.cast(this);\n        throw new IllegalArgumentException(\"Class \" + clazz.getName() + \" not compatible with MCache\");\n    }\n\n    @Override\n    public Iterator<Entry<K, V>> iterator() {\n        if (isClosed) throw new IllegalStateException(\"Cache \" + name + \" is closed\");\n        return new CacheIterator<>(this);\n    }\n\n    private static class CacheIterator<K, V> implements Iterator<Entry<K, V>> {\n        final MCache<K, V> mCache;\n        final long initialTime;\n        final ArrayList<MEntry<K, V>> entryList;\n        final int maxIndex;\n        int curIndex = -1;\n        MEntry<K, V> curEntry = null;\n\n        CacheIterator(MCache<K, V> mCache) {\n            this.mCache = mCache;\n            entryList = new ArrayList<>(mCache.entryStore.values());\n            maxIndex = entryList.size() - 1;\n            initialTime = System.currentTimeMillis();\n        }\n\n        @Override\n        public boolean hasNext() { return curIndex < maxIndex; }\n\n        @Override\n        public Entry<K, V> next() {\n            curEntry = null;\n            while (curIndex < maxIndex) {\n                curIndex++;\n                curEntry = entryList.get(curIndex);\n                if (curEntry.isExpired) {\n                    curEntry = null;\n                } else if (mCache.hasExpiry && curEntry.isExpired(initialTime, mCache.accessDuration, mCache.creationDuration, mCache.updateDuration)) {\n                    mCache.entryStore.remove(curEntry.getKey());\n                    if (mCache.statsEnabled) mCache.stats.countExpire();\n                    curEntry = null;\n                } else {\n                    if (mCache.statsEnabled)  { mCache.stats.gets++; mCache.stats.hits++; }\n                    break;\n                }\n            }\n            return curEntry;\n        }\n\n        @Override\n        public void remove() {\n            if (curEntry != null) {\n                mCache.entryStore.remove(curEntry.getKey());\n                if (mCache.statsEnabled) mCache.stats.countRemoval();\n                curEntry = null;\n            }\n        }\n    }\n\n    /** Gets all entries, checking for expiry and counts a get for each */\n    public ArrayList<Entry<K, V>> getEntryList() {\n        if (isClosed) throw new IllegalStateException(\"Cache \" + name + \" is closed\");\n        long currentTime = System.currentTimeMillis();\n        ArrayList<K> keyList = new ArrayList<>(entryStore.keySet());\n        int keyListSize = keyList.size();\n        ArrayList<Entry<K, V>> entryList = new ArrayList<>(keyListSize);\n        for (int i = 0; i < keyListSize; i++) {\n            K key = keyList.get(i);\n            MEntry<K, V> entry = getCheckExpired(key, currentTime);\n            if (entry != null) {\n                entryList.add(entry);\n                if (statsEnabled) { stats.gets++; stats.hits++; }\n                entry.accessCount++; if (currentTime > entry.lastAccessTime) entry.lastAccessTime = currentTime;\n            }\n        }\n        return entryList;\n    }\n    public int clearExpired() {\n        if (isClosed) throw new IllegalStateException(\"Cache \" + name + \" is closed\");\n        if (!hasExpiry) return 0;\n        long currentTime = System.currentTimeMillis();\n        ArrayList<K> keyList = new ArrayList<>(entryStore.keySet());\n        int keyListSize = keyList.size();\n        int expireCount = 0;\n        for (int i = 0; i < keyListSize; i++) {\n            K key = keyList.get(i);\n            MEntry<K, V> entry = entryStore.get(key);\n            if (entry != null && entry.isExpired(currentTime, accessDuration, creationDuration, updateDuration)) {\n                entryStore.remove(key);\n                if (statsEnabled) stats.countExpire();\n                expireCount++;\n            }\n        }\n        return expireCount;\n    }\n    public CacheStatisticsMXBean getStats() { return stats; }\n    public MStats getMStats() { return stats; }\n    public int size() { return entryStore.size(); }\n\n    public Duration getAccessDuration() { return accessDuration; }\n    public Duration getCreationDuration() { return creationDuration; }\n    public Duration getUpdateDuration() { return updateDuration; }\n\n    private static class EvictRunnable<K, V> implements Runnable {\n        static AccessComparator comparator = new AccessComparator();\n        MCache cache;\n        int maxEntries;\n        EvictRunnable(MCache mc, int entries) { cache = mc; maxEntries = entries; }\n        @Override\n        @SuppressWarnings(\"unchecked\")\n        public void run() {\n            if (maxEntries == 0) return;\n            int entriesToEvict = cache.entryStore.size() - maxEntries;\n            if (entriesToEvict <= 0) return;\n\n            long startTime = System.currentTimeMillis();\n\n            Collection<MEntry> entrySet = (Collection<MEntry>) cache.entryStore.values();\n            PriorityQueue<MEntry> priorityQueue = new PriorityQueue<>(entrySet.size(), comparator);\n            priorityQueue.addAll(entrySet);\n\n            int entriesEvicted = 0;\n            while (entriesToEvict > 0 && priorityQueue.size() > 0) {\n                MEntry curEntry = priorityQueue.poll();\n                // if an entry was expired after pulling the initial value set\n                if (curEntry.isExpired) continue;\n                cache.entryStore.remove(curEntry.getKey());\n                cache.stats.evictions++;\n                entriesEvicted++;\n                entriesToEvict--;\n            }\n            long timeElapsed = System.currentTimeMillis() - startTime;\n            logger.info(\"Evicted \" + entriesEvicted + \" entries in \" + timeElapsed + \"ms from cache \" + cache.name);\n        }\n    }\n    private static class AccessComparator implements Comparator<MEntry> {\n        @Override\n        public int compare(MEntry e1, MEntry e2) {\n            if (e1.accessCount == e2.accessCount) {\n                if (e1.lastAccessTime == e2.lastAccessTime) return 0;\n                else return e1.lastAccessTime > e2.lastAccessTime ? 1 : -1;\n            } else {\n                return e1.accessCount > e2.accessCount ? 1 : -1;\n            }\n        }\n    }\n\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/jcache/MCacheConfiguration.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.jcache;\n\nimport javax.cache.configuration.CompleteConfiguration;\nimport javax.cache.configuration.MutableConfiguration;\n\n@SuppressWarnings(\"unused\")\npublic class MCacheConfiguration<K, V> extends MutableConfiguration<K, V> {\n    public MCacheConfiguration() {\n        super();\n    }\n\n    public MCacheConfiguration(CompleteConfiguration<K, V> conf) {\n        super(conf);\n    }\n\n    int maxEntries = 0;\n    long maxCheckSeconds = 30;\n\n    /** Set maximum number of entries in the cache, 0 means no limit (default). Limit is enforced in a scheduled worker, not on put operations. */\n    public MCacheConfiguration<K, V> setMaxEntries(int elements) {\n        maxEntries = elements;\n        return this;\n    }\n    public int getMaxEntries() {\n        return maxEntries;\n    }\n\n    /** Set maximum number of entries in the cache, 0 means no limit (default). */\n    public MCacheConfiguration<K, V> setMaxCheckSeconds(long seconds) {\n        maxCheckSeconds = seconds;\n        return this;\n    }\n\n    public long getMaxCheckSeconds() {\n        return maxCheckSeconds;\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/jcache/MCacheManager.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.jcache;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport javax.cache.Cache;\nimport javax.cache.CacheManager;\nimport javax.cache.configuration.Configuration;\nimport javax.cache.spi.CachingProvider;\nimport java.net.URI;\nimport java.net.URISyntaxException;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\nimport java.util.Properties;\n\n/** This class does not completely support the javax.cache.CacheManager spec, it is just enough to use as a factory for MCache instances. */\npublic class MCacheManager implements CacheManager {\n    private static final Logger logger = LoggerFactory.getLogger(MCacheManager.class);\n\n    private static final MCacheManager singleCacheManager = new MCacheManager();\n    public static MCacheManager getMCacheManager() { return singleCacheManager; }\n\n    private URI cmUri = null;\n    private ClassLoader localClassLoader;\n    private Properties props = new Properties();\n    private Map<String, MCache> cacheMap = new LinkedHashMap<>();\n    private boolean isClosed = false;\n\n    private MCacheManager() {\n        try { cmUri = new URI(\"MCacheManager\"); }\n        catch (URISyntaxException e) { logger.error(\"URI Syntax error initializing MCacheManager\", e); }\n        localClassLoader = Thread.currentThread().getContextClassLoader();\n    }\n\n    @Override\n    public CachingProvider getCachingProvider() { return null; }\n    @Override\n    public URI getURI() { return cmUri; }\n    @Override\n    public ClassLoader getClassLoader() { return localClassLoader; }\n    @Override\n    public Properties getProperties() { return props; }\n\n    @Override\n    @SuppressWarnings(\"unchecked\")\n    public synchronized <K, V, C extends Configuration<K, V>> Cache<K, V> createCache(String cacheName, C configuration) throws IllegalArgumentException {\n        if (isClosed) throw new IllegalStateException(\"MCacheManager is closed\");\n        if (cacheMap.containsKey(cacheName)) {\n            // not per spec, but be more friendly and just return the existing cache: throw new CacheException(\"Cache with name \" + cacheName + \" already exists\");\n            return cacheMap.get(cacheName);\n        }\n\n        MCache<K, V> newCache = new MCache(cacheName, this, configuration);\n        cacheMap.put(cacheName, newCache);\n        return newCache;\n    }\n\n    @Override\n    @SuppressWarnings(\"unchecked\")\n    public <K, V> Cache<K, V> getCache(String cacheName, Class<K> keyType, Class<V> valueType) {\n        if (isClosed) throw new IllegalStateException(\"MCacheManager is closed\");\n        return cacheMap.get(cacheName);\n    }\n    @Override\n    @SuppressWarnings(\"unchecked\")\n    public <K, V> Cache<K, V> getCache(String cacheName) {\n        if (isClosed) throw new IllegalStateException(\"MCacheManager is closed\");\n        return cacheMap.get(cacheName);\n    }\n\n    @Override\n    public Iterable<String> getCacheNames() {\n        if (isClosed) throw new IllegalStateException(\"MCacheManager is closed\");\n        return cacheMap.keySet();\n    }\n\n    @Override\n    public void destroyCache(String cacheName) {\n        if (isClosed) throw new IllegalStateException(\"MCacheManager is closed\");\n        MCache cache = cacheMap.get(cacheName);\n        if (cache != null) {\n            cacheMap.remove(cacheName);\n            cache.close();\n        } else {\n            throw new IllegalStateException(\"Cache with name \" + cacheName + \" does not exist, cannot be destroyed\");\n        }\n    }\n\n    @Override\n    public void enableManagement(String cacheName, boolean enabled) {\n        throw new UnsupportedOperationException(\"MCacheManager does not support CacheMXBean\"); }\n    @Override\n    public void enableStatistics(String cacheName, boolean enabled) {\n        throw new UnsupportedOperationException(\"MCacheManager does not support registered statistics; use the MCache.getStats() or getMStats() methods\"); }\n\n    @Override\n    public void close() {\n        cacheMap.clear();\n        // doesn't work well with current singleton approach: isClosed = true;\n    }\n    @Override\n    public boolean isClosed() { return isClosed; }\n\n    @Override\n    public <T> T unwrap(Class<T> clazz) {\n        if (clazz.isAssignableFrom(this.getClass())) return clazz.cast(this);\n        throw new IllegalArgumentException(\"Class \" + clazz.getName() + \" not compatible with MCacheManager\");\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/jcache/MEntry.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.jcache;\n\nimport javax.cache.Cache;\nimport javax.cache.expiry.Duration;\nimport javax.cache.expiry.ExpiryPolicy;\n\npublic class MEntry<K, V> implements Cache.Entry<K, V> {\n    private static final Class<MEntry> thisClass = MEntry.class;\n    private final K key;\n    V value;\n    private long createdTime = 0;\n    long lastUpdatedTime = 0;\n    long lastAccessTime = 0;\n    long accessCount = 0;\n    boolean isExpired = false;\n\n    /**\n     * Use this only to create MEntry to compare with an existing entry\n     */\n    MEntry(K key, V value) {\n        this.key = key;\n        this.value = value;\n    }\n\n    /**\n     * Always use this for MEntry that may be put in the cache\n     */\n    MEntry(K key, V value, long createdTime) {\n        this.key = key;\n        this.value = value;\n        this.createdTime = createdTime;\n        lastUpdatedTime = createdTime;\n        lastAccessTime = createdTime;\n    }\n\n    @Override\n    public K getKey() {\n        return key;\n    }\n\n    @Override\n    public V getValue() {\n        return value;\n    }\n\n    @Override\n    public <T> T unwrap(Class<T> clazz) {\n        if (clazz.isAssignableFrom(this.getClass())) return clazz.cast(this);\n        throw new IllegalArgumentException(\"Class \" + clazz.getName() + \" not compatible with MCache.MEntry\");\n    }\n\n    boolean valueEquals(V otherValue) {\n        if (otherValue == null) {\n            return value == null;\n        } else {\n            return otherValue.equals(value);\n        }\n    }\n\n    void setValue(V val, long updateTime) {\n        synchronized (key) {\n            if (updateTime > lastUpdatedTime) {\n                value = val;\n                lastUpdatedTime = updateTime;\n            }\n        }\n    }\n\n    boolean setValueIfEquals(V oldVal, V val, long updateTime) {\n        synchronized (key) {\n            if (updateTime > lastUpdatedTime && valueEquals(oldVal)) {\n                value = val;\n                lastUpdatedTime = updateTime;\n                return true;\n            } else {\n                return false;\n            }\n        }\n    }\n\n    public long getCreatedTime() {\n        return createdTime;\n    }\n\n    public long getLastUpdatedTime() {\n        return lastUpdatedTime;\n    }\n\n    public long getLastAccessTime() {\n        return lastAccessTime;\n    }\n\n    public long getAccessCount() {\n        return accessCount;\n    }\n\n    /* done directly on fields for performance reasons\n    void countAccess(long accessTime) {\n        accessCount++; if (accessTime > lastAccessTime) lastAccessTime = accessTime;\n    }\n    */\n    @SuppressWarnings(\"unused\")\n    public boolean isExpired(ExpiryPolicy policy) {\n        return isExpired(System.currentTimeMillis(), policy.getExpiryForAccess(), policy.getExpiryForCreation(),\n                policy.getExpiryForUpdate());\n    }\n\n    boolean isExpired(long accessTime, ExpiryPolicy policy) {\n        return isExpired(accessTime, policy.getExpiryForAccess(), policy.getExpiryForCreation(),\n                policy.getExpiryForUpdate());\n    }\n\n    boolean isExpired(Duration accessDuration, Duration creationDuration, Duration updateDuration) {\n        return isExpired(System.currentTimeMillis(), accessDuration, creationDuration, updateDuration);\n    }\n\n    boolean isExpired(long accessTime, Duration accessDuration, Duration creationDuration, Duration updateDuration) {\n        if (isExpired) return true;\n        if (accessDuration != null && !accessDuration.isEternal()) {\n            if (accessDuration.getAdjustedTime(lastAccessTime) < accessTime) {\n                isExpired = true;\n                return true;\n            }\n        }\n        if (creationDuration != null && !creationDuration.isEternal()) {\n            if (creationDuration.getAdjustedTime(createdTime) < accessTime) {\n                isExpired = true;\n                return true;\n            }\n        }\n        if (updateDuration != null && !updateDuration.isEternal()) {\n            if (updateDuration.getAdjustedTime(lastUpdatedTime) < accessTime) {\n                isExpired = true;\n                return true;\n            }\n        }\n        return false;\n    }\n\n    @Override\n    public int hashCode() {\n        return value.hashCode();\n    }\n\n    @Override\n    public boolean equals(Object obj) {\n        if (obj == null || thisClass != obj.getClass()) return false;\n        MEntry that = (MEntry) obj;\n        if (value == null) {\n            return that.value == null;\n        } else {\n            return value.equals(that.value);\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/jcache/MStats.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.jcache;\n\nimport javax.cache.management.CacheStatisticsMXBean;\n\npublic class MStats implements CacheStatisticsMXBean {\n    long hits = 0;\n    long misses = 0;\n    long gets = 0;\n\n    long puts = 0;\n    private long removals = 0;\n    long evictions = 0;\n    private long expires = 0;\n\n    // long totalGetMicros = 0, totalPutMicros = 0, totalRemoveMicros = 0;\n\n    @Override\n    public void clear() {\n        hits = 0;\n        misses = 0;\n        gets = 0;\n        puts = 0;\n        removals = 0;\n        evictions = 0;\n        expires = 0;\n    }\n\n    @Override\n    public long getCacheHits() {\n        return hits;\n    }\n\n    @Override\n    public float getCacheHitPercentage() {\n        return (hits / gets) * 100;\n    }\n\n    @Override\n    public long getCacheMisses() {\n        return misses;\n    }\n\n    @Override\n    public float getCacheMissPercentage() {\n        return (misses / gets) * 100;\n    }\n\n    @Override\n    public long getCacheGets() {\n        return gets;\n    }\n\n    @Override\n    public long getCachePuts() {\n        return puts;\n    }\n\n    @Override\n    public long getCacheRemovals() {\n        return removals;\n    }\n\n    @Override\n    public long getCacheEvictions() {\n        return evictions;\n    }\n\n    @Override\n    public float getAverageGetTime() {\n        return 0;\n    } // totalGetMicros / gets\n\n    @Override\n    public float getAveragePutTime() {\n        return 0;\n    } // totalPutMicros / puts\n\n    @Override\n    public float getAverageRemoveTime() {\n        return 0;\n    } // totalRemoveMicros / removals\n\n    public long getCacheExpires() {\n        return expires;\n    }\n\n    /* have callers access fields directly for performance reasons:\n    void countHit() {\n        gets++; hits++;\n        // totalGetMicros += micros;\n    }\n    void countMiss() {\n        gets++; misses++;\n        // totalGetMicros += micros;\n    }\n    void countPut() {\n        puts++;\n        // totalPutMicros += micros;\n    }\n    */\n    void countRemoval() {\n        removals++;\n        // totalRemoveMicros += micros;\n    }\n\n    void countBulkRemoval(long entries) {\n        removals += entries;\n    }\n\n    void countExpire() {\n        expires++;\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/resource/ClasspathResourceReference.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.resource;\n\nimport org.moqui.BaseException;\n\nimport java.io.FileNotFoundException;\nimport java.io.IOException;\nimport java.io.InputStream;\n\npublic class ClasspathResourceReference extends UrlResourceReference {\n    private String strippedLocation;\n\n    public ClasspathResourceReference() { super(); }\n\n    @Override public ResourceReference init(String location) {\n        strippedLocation = ResourceReference.stripLocationPrefix(location);\n        // first try the current thread's context ClassLoader\n        locationUrl = Thread.currentThread().getContextClassLoader().getResource(strippedLocation);\n        // next try the ClassLoader that loaded this class\n        if (locationUrl == null) locationUrl = this.getClass().getClassLoader().getResource(strippedLocation);\n        // no luck? try the system ClassLoader\n        if (locationUrl == null) locationUrl = ClassLoader.getSystemResource(strippedLocation);\n        // if the URL was found this way then it exists, so remember that\n        if (locationUrl != null) {\n            exists = true;\n            isFileProtocol = \"file\".equals(locationUrl.getProtocol());\n        }\n\n        return this;\n    }\n\n    @Override public ResourceReference createNew(String location) {\n        ClasspathResourceReference resRef = new ClasspathResourceReference();\n        resRef.init(location);\n        return resRef;\n    }\n\n    @Override public InputStream openStream() {\n        if (locationUrl == null) throw new IllegalStateException(\"Classpath Resource not found at \" + strippedLocation);\n        try {\n            return locationUrl.openStream();\n        } catch (FileNotFoundException e) {\n            return null;\n        } catch (IOException e) {\n            throw new BaseException(\"Error opening stream for \" + locationUrl.toString(), e);\n        }\n    }\n\n    @Override public boolean getExists() {\n        // only count exists if true\n        return exists != null && exists;\n    }\n\n    @Override public String getLocation() { return \"classpath://\" + strippedLocation; }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/resource/ResourceReference.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.resource;\n\nimport org.moqui.BaseException;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport jakarta.activation.MimetypesFileTypeMap;\n\nimport java.io.ByteArrayInputStream;\nimport java.io.InputStream;\nimport java.io.OutputStream;\nimport java.io.Serializable;\nimport java.net.URI;\nimport java.net.URISyntaxException;\nimport java.net.URL;\nimport java.sql.Timestamp;\nimport java.util.*;\n\npublic abstract class ResourceReference implements Serializable {\n    private static final Logger logger = LoggerFactory.getLogger(ResourceReference.class);\n    private static final MimetypesFileTypeMap mimetypesFileTypeMap = new MimetypesFileTypeMap();\n\n    protected ResourceReference childOfResource = null;\n    private Map<String, ResourceReference> subContentRefByPath = null;\n\n    public abstract ResourceReference init(String location);\n\n    public abstract ResourceReference createNew(String location);\n\n    public abstract String getLocation();\n    public abstract InputStream openStream();\n    public abstract OutputStream getOutputStream();\n    public abstract String getText();\n\n    public abstract boolean supportsAll();\n    public abstract boolean supportsUrl();\n    public abstract URL getUrl();\n\n    public abstract boolean supportsDirectory();\n    public abstract boolean isFile();\n    public abstract boolean isDirectory();\n\n    public abstract boolean supportsExists();\n    public abstract boolean getExists();\n\n    public abstract boolean supportsLastModified();\n    public abstract long getLastModified();\n\n    public abstract boolean supportsSize();\n    public abstract long getSize();\n\n    public abstract boolean supportsWrite();\n    public abstract void putText(String text);\n    public abstract void putStream(InputStream stream);\n    public abstract void move(String newLocation);\n    public abstract ResourceReference makeDirectory(String name);\n    public abstract ResourceReference makeFile(String name);\n    public abstract boolean delete();\n\n    /** Get the entries of a directory */\n    public abstract List<ResourceReference> getDirectoryEntries();\n\n    public void putBytes(byte[] bytes) { putStream(new ByteArrayInputStream(bytes)); }\n\n    public URI getUri() {\n        try {\n            if (supportsUrl()) {\n                URL locUrl = getUrl();\n                if (locUrl == null) return null;\n                // use the multi-argument constructor to have it do character encoding and avoid an exception\n                // WARNING: a String from this URI may not equal the String from the URL (ie if characters are encoded)\n                // NOTE: this doesn't seem to work on Windows for local files: when protocol is plain \"file\" and path starts\n                //     with a drive letter like \"C:\\moqui\\...\" it produces a parse error showing the URI as \"file://C:/...\"\n                if (logger.isTraceEnabled()) logger.trace(\"Getting URI for URL \" + locUrl.toExternalForm());\n                String path = locUrl.getPath();\n\n                // Support Windows local files.\n                if (\"file\".equals(locUrl.getProtocol())) {\n                    if (!path.startsWith(\"/\"))\n                        path = \"/\" + path;\n                }\n                return new URI(locUrl.getProtocol(), locUrl.getUserInfo(), locUrl.getHost(),\n                        locUrl.getPort(), path, locUrl.getQuery(), locUrl.getRef());\n            } else {\n                String loc = getLocation();\n                if (loc == null || loc.isEmpty()) return null;\n                return new URI(loc);\n            }\n        } catch (URISyntaxException e) {\n            throw new BaseException(\"Error creating URI\", e);\n        }\n    }\n\n    /** One part of the URI not easy to get from the URI object, basically the last part of the path. */\n    public String getFileName() {\n        String loc = getLocation();\n        if (loc == null || loc.length() == 0) return null;\n        int slashIndex = loc.lastIndexOf(\"/\");\n        return slashIndex >= 0 ? loc.substring(slashIndex + 1) : loc;\n    }\n\n\n    /** The content (MIME) type for this content, if known or can be determined. */\n    public String getContentType() {\n        String fn = getFileName();\n        return fn != null && fn.length() > 0 ? getContentType(fn) : null;\n    }\n    public boolean isBinary() { return isBinaryContentType(getContentType()); }\n    public boolean isText() { return isTextContentType(getContentType()); }\n\n    /** Get the parent directory, null if it is the root (no parent). */\n    public ResourceReference getParent() {\n        String curLocation = getLocation();\n        if (curLocation.endsWith(\"/\")) curLocation = curLocation.substring(0, curLocation.length() - 1);\n        String strippedLocation = stripLocationPrefix(curLocation);\n        if (strippedLocation.isEmpty()) return null;\n        if (strippedLocation.startsWith(\"/\")) strippedLocation = strippedLocation.substring(1);\n        if (strippedLocation.contains(\"/\")) {\n            return createNew(curLocation.substring(0, curLocation.lastIndexOf(\"/\")));\n        } else {\n            String prefix = getLocationPrefix(curLocation);\n            if (prefix != null && !prefix.isEmpty()) return createNew(prefix);\n            return null;\n        }\n    }\n\n    /** Find the directory with a name that matches the current filename (minus the extension) */\n    public ResourceReference findMatchingDirectory() {\n        if (this.isDirectory()) return this;\n        StringBuilder dirLoc = new StringBuilder(getLocation());\n        ResourceReference directoryRef = this;\n        while (!(directoryRef.getExists() && directoryRef.isDirectory()) && dirLoc.lastIndexOf(\".\") > 0) {\n            // get rid of one suffix at a time (for screens probably .xml but use .* for other files, etc)\n            dirLoc.delete(dirLoc.lastIndexOf(\".\"), dirLoc.length());\n            directoryRef = createNew(dirLoc.toString());\n            // directoryRef = ecf.resource.getLocationReference(dirLoc.toString())\n        }\n        return directoryRef;\n    }\n\n    /** Get a reference to the child of this directory or this file in the matching directory */\n    public ResourceReference getChild(String childName) {\n        if (childName == null || childName.length() == 0) return null;\n        ResourceReference directoryRef = findMatchingDirectory();\n        StringBuilder fileLoc = new StringBuilder(directoryRef.getLocation());\n        if (fileLoc.charAt(fileLoc.length()-1) == '/') fileLoc.deleteCharAt(fileLoc.length()-1);\n        if (childName.charAt(0) != '/') fileLoc.append('/');\n        fileLoc.append(childName);\n\n        // NOTE: don't really care if it exists or not at this point\n        return createNew(fileLoc.toString());\n    }\n\n    /** Get a list of references to all files in this directory or for a file in the matching directory */\n    public List<ResourceReference> getChildren() {\n        List<ResourceReference> children = new LinkedList<>();\n        ResourceReference directoryRef = findMatchingDirectory();\n        if (directoryRef == null || !directoryRef.getExists()) return null;\n        for (ResourceReference childRef : directoryRef.getDirectoryEntries()) if (childRef.isFile()) children.add(childRef);\n        return children;\n    }\n\n    /** Find a file by path (can be single name) in the matching directory and child matching directories */\n    public ResourceReference findChildFile(String relativePath) {\n        // no path to child? that means this resource\n        if (relativePath == null || relativePath.length() == 0) return this;\n\n        if (!supportsAll()) {\n            throw new BaseException(\"Not looking for child file at \" + relativePath + \" under space root page \" +\n                    getLocation() + \" because exists, isFile, etc are not supported\");\n        }\n\n        // logger.warn(\"============= finding child resource of [${toString()}] path [${relativePath}]\")\n\n        // check the cache first\n        ResourceReference childRef = getSubContentRefByPath().get(relativePath);\n        if (childRef != null && childRef.getExists()) return childRef;\n\n        // this finds a file in a directory with the same name as this resource, unless this resource is a directory\n        ResourceReference directoryRef = findMatchingDirectory();\n\n        // logger.warn(\"============= finding child resource path [${relativePath}] directoryRef [${directoryRef}]\")\n        if (directoryRef.getExists()) {\n            StringBuilder fileLoc = new StringBuilder(directoryRef.getLocation());\n            if (fileLoc.charAt(fileLoc.length() - 1) == '/') fileLoc.deleteCharAt(fileLoc.length() - 1);\n            if (relativePath.charAt(0) != '/') fileLoc.append('/');\n            fileLoc.append(relativePath);\n\n            ResourceReference theFile = createNew(fileLoc.toString());\n            if (theFile.getExists() && theFile.isFile()) childRef = theFile;\n\n            // logger.warn(\"============= finding child resource path [${relativePath}] childRef [${childRef}]\")\n            if (childRef == null) {\n                // didn't find it at a literal path, try searching for it in all subdirectories\n                int lastSlashIdx = relativePath.lastIndexOf(\"/\");\n                String directoryPath = lastSlashIdx > 0 ? relativePath.substring(0, lastSlashIdx) : \"\";\n                String childFilename = lastSlashIdx >= 0 ? relativePath.substring(lastSlashIdx + 1) : relativePath;\n                // first find the most matching directory\n                ResourceReference childDirectoryRef = directoryRef.findChildDirectory(directoryPath);\n                // recursively walk the directory tree and find the childFilename\n                childRef = internalFindChildFile(childDirectoryRef, childFilename, null);\n                // logger.warn(\"============= finding child resource path [${relativePath}] directoryRef [${directoryRef}] childFilename [${childFilename}] childRef [${childRef}]\")\n            }\n\n            // logger.warn(\"============= finding child resource path [${relativePath}] childRef 3 [${childRef}]\")\n            if (childRef != null) childRef.childOfResource = directoryRef;\n        }\n\n\n        if (childRef == null) {\n            // still nothing? treat the path to the file as a literal and return it (exists will be false)\n            if (directoryRef.getExists()) {\n                childRef = createNew(directoryRef.getLocation() + \"/\" + relativePath);\n                childRef.childOfResource = directoryRef;\n            } else {\n                String newDirectoryLoc = getLocation();\n                // pop off the extension, everything past the first dot after the last slash\n                int lastSlashLoc = newDirectoryLoc.lastIndexOf(\"/\");\n                if (newDirectoryLoc.contains(\".\"))\n                    newDirectoryLoc = newDirectoryLoc.substring(0, newDirectoryLoc.indexOf(\".\", lastSlashLoc));\n                childRef = createNew(newDirectoryLoc + \"/\" + relativePath);\n            }\n        } else {\n            // put it in the cache before returning, but don't cache the literal reference\n            getSubContentRefByPath().put(relativePath, childRef);\n        }\n\n        // logger.warn(\"============= finding child resource of [${toString()}] path [${relativePath}] got [${childRef}]\")\n        return childRef;\n    }\n\n    /** Find a directory by path (can be single name) in the matching directory and child matching directories */\n    public ResourceReference findChildDirectory(String relativePath) {\n        if (relativePath == null || relativePath.isEmpty()) return this;\n\n        if (!supportsAll()) {\n            throw new BaseException(\"Not looking for child file at \" + relativePath + \" under space root page \" +\n                    getLocation() + \" because exists, isFile, etc are not supported\");\n        }\n\n        // check the cache first\n        ResourceReference childRef = getSubContentRefByPath().get(relativePath);\n        if (childRef != null && childRef.getExists()) return childRef;\n\n        List<String> relativePathNameList = Arrays.asList(relativePath.split(\"/\"));\n\n        ResourceReference childDirectoryRef = this;\n        if (this.isFile()) childDirectoryRef = this.findMatchingDirectory();\n\n        // search remaining relativePathNameList, ie partial directories leading up to filename\n        for (String relativePathName : relativePathNameList) {\n            childDirectoryRef = internalFindChildDir(childDirectoryRef, relativePathName, null);\n            if (childDirectoryRef == null) break;\n        }\n\n\n        if (childDirectoryRef == null) {\n            // still nothing? treat the path to the file as a literal and return it (exists will be false)\n            String newDirectoryLoc = getLocation();\n            if (this.isFile()) {\n                // pop off the extension, everything past the first dot after the last slash\n                int lastSlashLoc = newDirectoryLoc.lastIndexOf(\"/\");\n                if (newDirectoryLoc.contains(\".\"))\n                    newDirectoryLoc = newDirectoryLoc.substring(0, newDirectoryLoc.indexOf(\".\", lastSlashLoc));\n            }\n\n            childDirectoryRef = createNew(newDirectoryLoc + \"/\" + relativePath);\n        } else {\n            // put it in the cache before returning, but don't cache the literal reference\n            getSubContentRefByPath().put(relativePath, childRef);\n        }\n\n        return childDirectoryRef;\n    }\n\n    private ResourceReference internalFindChildDir(ResourceReference directoryRef, String childDirName, Set<String> parentLocSet) {\n        if (directoryRef == null || !directoryRef.getExists()) return null;\n        // no child dir name, means this/current dir\n        if (childDirName == null || childDirName.isEmpty()) return directoryRef;\n\n        // try a direct sub-directory, if it is there it's more efficient than a recursive search\n        StringBuilder dirLocation = new StringBuilder(directoryRef.getLocation());\n        if (dirLocation.charAt(dirLocation.length() - 1) == '/') dirLocation.deleteCharAt(dirLocation.length() - 1);\n        if (childDirName.charAt(0) != '/') dirLocation.append('/');\n        dirLocation.append(childDirName);\n        ResourceReference directRef = createNew(dirLocation.toString());\n        if (directRef != null && directRef.getExists()) return directRef;\n\n        // if no direct reference is found, try the more flexible search\n        for (ResourceReference childRef : directoryRef.getDirectoryEntries()) {\n            if (childRef.isDirectory() && (childRef.getFileName().equals(childDirName) || childRef.getFileName().contains(childDirName + \".\"))) {\n                // matching directory name, use it\n                return childRef;\n            } else if (childRef.isDirectory()) {\n                Set<String> recurseParentLocSet = new HashSet<>();\n                if (parentLocSet != null) {\n                    if (parentLocSet.contains(childRef.getLocation())) {\n                        logger.error(\"In internalFindChildDir found loop in directory tree, \" + childRef.getLocation() + \" already visited, parent locations: \" + parentLocSet);\n                        continue;\n                    }\n                    recurseParentLocSet.addAll(parentLocSet);\n                } else {\n                    recurseParentLocSet.add(directoryRef.getLocation());\n                }\n                recurseParentLocSet.add(childRef.getLocation());\n\n                // non-matching directory name, recurse into it\n                ResourceReference subRef = internalFindChildDir(childRef, childDirName, recurseParentLocSet);\n                if (subRef != null) return subRef;\n            }\n        }\n        return null;\n    }\n\n    private ResourceReference internalFindChildFile(ResourceReference directoryRef, String childFilename, Set<String> parentLocSet) {\n        // logger.warn(\"internalFindChildFile \" + directoryRef + \" [\" + childFilename + \"] \"  + parentLocSet);\n        if (directoryRef == null || !directoryRef.getExists()) return null;\n\n        // find check exact filename first\n        ResourceReference exactMatchRef = directoryRef.getChild(childFilename);\n        if (exactMatchRef.isFile() && exactMatchRef.getExists()) return exactMatchRef;\n\n        List<ResourceReference> childEntries = directoryRef.getDirectoryEntries();\n        // look through all files first, ie do a breadth-first search\n        for (ResourceReference childRef : childEntries) {\n            if (childRef.isFile() && (childRef.getFileName().equals(childFilename) || childRef.getFileName().startsWith(childFilename + \".\"))) {\n                return childRef;\n            }\n        }\n\n        for (ResourceReference childRef : childEntries) {\n            if (childRef.isDirectory()) {\n                Set<String> recurseParentLocSet = new HashSet<>();\n                if (parentLocSet != null) {\n                    if (parentLocSet.contains(childRef.getLocation())) {\n                        logger.error(\"In internalFindChildFile found loop in directory tree, \" + childRef.getLocation() + \" already visited, parent locations: \" + parentLocSet);\n                        continue;\n                    }\n                    recurseParentLocSet.addAll(parentLocSet);\n                } else {\n                    recurseParentLocSet.add(directoryRef.getLocation());\n                }\n                recurseParentLocSet.add(childRef.getLocation());\n\n                ResourceReference subRef = internalFindChildFile(childRef, childFilename, recurseParentLocSet);\n                if (subRef != null) return subRef;\n            }\n        }\n        return null;\n    }\n\n    public String getActualChildPath() {\n        if (childOfResource == null) return null;\n        String parentLocation = childOfResource.getLocation();\n        String childLocation = getLocation();\n        // this should be true, but just in case:\n        if (childLocation.startsWith(parentLocation)) {\n            String childPath = childLocation.substring(parentLocation.length());\n            if (childPath.startsWith(\"/\")) return childPath.substring(1);\n            else return childPath;\n        }\n        // if not, what to do?\n        return null;\n    }\n\n    public void walkChildTree(List<Map> allChildFileFlatList, List<Map> childResourceList) {\n        if (this.isFile()) walkChildFileTree(this, \"\", allChildFileFlatList, childResourceList);\n        if (this.isDirectory()) for (ResourceReference childRef : getDirectoryEntries()) {\n            childRef.walkChildFileTree(this, \"\", allChildFileFlatList, childResourceList);\n        }\n    }\n    private void walkChildFileTree(ResourceReference rootResource, String pathFromRoot,\n                           List<Map> allChildFileFlatList, List<Map> childResourceList) {\n        // logger.warn(\"================ walkChildFileTree rootResource=${rootResource} pathFromRoot=${pathFromRoot} curLocation=${getLocation()}\")\n        String childPathBase = pathFromRoot != null && !pathFromRoot.isEmpty() ? pathFromRoot + '/' : \"\";\n\n        if (this.isFile()) {\n            List<Map> curChildResourceList = new LinkedList<>();\n\n            String curFileName = getFileName();\n            if (curFileName.contains(\".\")) curFileName = curFileName.substring(0, curFileName.lastIndexOf('.'));\n            String curPath = childPathBase + curFileName;\n\n            if (allChildFileFlatList != null) {\n                Map<String, String> infoMap = new HashMap<>(3);\n                infoMap.put(\"path\", curPath); infoMap.put(\"name\", curFileName); infoMap.put(\"location\", getLocation());\n                allChildFileFlatList.add(infoMap);\n            }\n            if (childResourceList != null) {\n                Map<String, Object> infoMap = new HashMap<>(4);\n                infoMap.put(\"path\", curPath); infoMap.put(\"name\", curFileName); infoMap.put(\"location\", getLocation());\n                infoMap.put(\"childResourceList\", curChildResourceList);\n                childResourceList.add(infoMap);\n            }\n\n            ResourceReference matchingDirReference = this.findMatchingDirectory();\n            String childPath = childPathBase + matchingDirReference.getFileName();\n            for (ResourceReference childRef : matchingDirReference.getDirectoryEntries()) {\n                childRef.walkChildFileTree(rootResource, childPath, allChildFileFlatList, curChildResourceList);\n            }\n        }\n        // TODO: walk child directories somehow or just stick with files with matching directories?\n    }\n\n    public void destroy() { }\n    @Override public String toString() {\n        String loc = getLocation();\n        return loc != null && !loc.isEmpty() ? loc : (\"[no location (\" + getClass().getName() + \")]\");\n    }\n\n    private Map<String, ResourceReference> getSubContentRefByPath() {\n        if (subContentRefByPath == null) subContentRefByPath = new HashMap<>();\n        return subContentRefByPath;\n    }\n\n    public static boolean isTextFilename(String filename) {\n        String contentType = getContentType(filename);\n        if (contentType == null || contentType.isEmpty()) return false;\n        return isTextContentType(contentType);\n    }\n    public static boolean isBinaryFilename(String filename) {\n        String contentType = getContentType(filename);\n        if (contentType == null || contentType.isEmpty()) return false;\n        return !isTextContentType(contentType);\n    }\n    public static String getContentType(String filename) {\n        // need to check this, or type mapper handles it fine? || !filename.contains(\".\")\n        if (filename == null || filename.length() == 0) return null;\n        String type = mimetypesFileTypeMap.getContentType(filename);\n        // strip any parameters, ie after the ;\n        int semicolonIndex = type.indexOf(\";\");\n        if (semicolonIndex >= 0) type = type.substring(0, semicolonIndex);\n        return type;\n    }\n    public static boolean isTextContentType(String contentType) {\n        if (contentType == null) return false;\n        contentType = contentType.trim();\n\n        int scIdx = contentType.indexOf(\";\");\n        contentType = scIdx >= 0 ? contentType.substring(0, scIdx).trim() : contentType;\n        if (contentType.length() == 0) return false;\n\n        if (contentType.startsWith(\"text/\")) return true;\n        // aside from text/*, a few notable exceptions:\n        if (\"application/javascript\".equals(contentType)) return true;\n        if (\"application/json\".equals(contentType)) return true;\n        if (\"application/jwt\".equals(contentType)) return true;\n        if (contentType.endsWith(\"+json\")) return true;\n        if (\"application/rtf\".equals(contentType)) return true;\n        if (contentType.startsWith(\"application/xml\")) return true;\n        if (contentType.endsWith(\"+xml\")) return true;\n        if (contentType.startsWith(\"application/yaml\")) return true;\n        if (contentType.endsWith(\"+yaml\")) return true;\n\n        return false;\n    }\n    public static boolean isBinaryContentType(String contentType) {\n        if (contentType == null || contentType.length() == 0) return false;\n        return !isTextContentType(contentType);\n    }\n    public static String stripLocationPrefix(String location) {\n        if (location == null || location.isEmpty()) return \"\";\n        // first remove colon (:) and everything before it\n        StringBuilder strippedLocation = new StringBuilder(location);\n        int colonIndex = strippedLocation.indexOf(\":\");\n        if (colonIndex == 0) {\n            strippedLocation.deleteCharAt(0);\n        } else if (colonIndex > 0) {\n            strippedLocation.delete(0, colonIndex+1);\n        }\n        // delete all leading forward slashes\n        while (strippedLocation.length() > 0 && strippedLocation.charAt(0) == '/') strippedLocation.deleteCharAt(0);\n        return strippedLocation.toString();\n    }\n    public static String getLocationPrefix(String location) {\n        if (location == null || location.isEmpty()) return \"\";\n        if (location.contains(\"://\")) {\n            return location.substring(0, location.indexOf(\":\")) + \"://\";\n        } else if (location.contains(\":\")) {\n            return location.substring(0, location.indexOf(\":\")) + \":\";\n        } else {\n            return \"\";\n        }\n    }\n\n    public boolean supportsVersion() { return false; }\n    public Version getVersion(String versionName) { return null; }\n    public Version getCurrentVersion() { return null; }\n    public Version getRootVersion() { return null; }\n    public ArrayList<Version> getVersionHistory() { return new ArrayList<>(); }\n    public ArrayList<Version> getNextVersions(String versionName) { return new ArrayList<>(); }\n    public InputStream openStream(String versionName) { return openStream(); }\n    public String getText(String versionName) { return getText(); }\n\n    public static class Version {\n        private final ResourceReference ref;\n        private final String versionName, previousVersionName, userId;\n        private final Timestamp versionDate;\n        public Version(ResourceReference ref, String versionName, String previousVersionName, String userId, Timestamp versionDate) {\n            this.ref = ref; this.versionName = versionName; this.previousVersionName = previousVersionName;\n            this.userId = userId; this.versionDate = versionDate;\n        }\n        public ResourceReference getRef() { return ref; }\n        public String getVersionName() { return versionName; }\n        public String getPreviousVersionName() { return previousVersionName; }\n        public Version getPreviousVersion() { return ref.getVersion(previousVersionName); }\n        public ArrayList<Version> getNextVersions() { return ref.getNextVersions(versionName); }\n        public String getUserId() { return userId; }\n        public Timestamp getVersionDate() { return versionDate; }\n        public InputStream openStream() { return ref.openStream(versionName); }\n        public String getText() { return ref.getText(versionName); }\n        public Map<String, Object> getMap() {\n            Map<String, Object> map = new LinkedHashMap<>();\n            map.put(\"versionName\", versionName); map.put(\"previousVersionName\", previousVersionName);\n            map.put(\"userId\", userId); map.put(\"versionDate\", versionDate);\n            return map;\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/resource/UrlResourceReference.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.resource;\n\nimport org.moqui.BaseException;\nimport org.moqui.util.ObjectUtilities;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.*;\nimport java.net.MalformedURLException;\nimport java.net.URL;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.List;\nimport java.util.TreeSet;\n\npublic class UrlResourceReference extends ResourceReference {\n    private static final Logger logger = LoggerFactory.getLogger(UrlResourceReference.class);\n    static final String runtimePrefix = \"runtime://\";\n    URL locationUrl = null;\n    Boolean exists = null;\n    boolean isFileProtocol = false;\n    private transient File localFile = null;\n\n    public UrlResourceReference() { }\n    public UrlResourceReference(File file) {\n        isFileProtocol = true;\n        localFile = file;\n        try { locationUrl = file.toURI().toURL(); }\n        catch (MalformedURLException e) { throw new BaseException(\"Error creating URL for file \" + file.getAbsolutePath(), e); }\n    }\n\n    @Override\n    public ResourceReference init(String location) {\n        if (location == null || location.isEmpty()) throw new BaseException(\"Cannot create URL Resource Reference with empty location\");\n\n        if (location.startsWith(runtimePrefix)) location = location.substring(runtimePrefix.length());\n\n        if (location.startsWith(\"/\") || !location.contains(\":\")) {\n            // no prefix, local file: if starts with '/' is absolute, otherwise is relative to runtime path\n            if (location.charAt(0) != '/') {\n                String moquiRuntime = System.getProperty(\"moqui.runtime\");\n                if (moquiRuntime != null && !moquiRuntime.isEmpty()) {\n                    File runtimeFile = new File(moquiRuntime);\n                    location = runtimeFile.getAbsolutePath() + \"/\" + location;\n                }\n            }\n\n            try { locationUrl = new URL(\"file:\" + location); }\n            catch (MalformedURLException e) { throw new BaseException(\"Invalid file url for location \" + location, e); }\n            isFileProtocol = true;\n        } else {\n            try {\n                locationUrl = new URL(location);\n            } catch (MalformedURLException e) {\n                if (logger.isTraceEnabled())\n                    logger.trace(\"Ignoring MalformedURLException for location, trying a local file: \" + e.toString());\n                // special case for Windows, try going through a file:\n\n                try { locationUrl = new URL(\"file:/\" + location); }\n                catch (MalformedURLException se) { throw new BaseException(\"Invalid url for location \" + location, e); }\n            }\n\n            isFileProtocol = \"file\".equals(getUrl().getProtocol());\n        }\n\n        return this;\n    }\n\n    public File getFile() {\n        if (!isFileProtocol) throw new IllegalArgumentException(\"File not supported for resource with protocol [\" + locationUrl.getProtocol() + \"]\");\n        if (localFile != null) return localFile;\n        // NOTE: using toExternalForm().substring(5) instead of toURI because URI does not allow spaces in a filename\n        localFile = new File(locationUrl.toExternalForm().substring(5));\n        return localFile;\n    }\n\n    @Override public ResourceReference createNew(String location) {\n        UrlResourceReference resRef = new UrlResourceReference();\n        resRef.init(location);\n        return resRef;\n    }\n\n    @Override public String getLocation() { return locationUrl.toString(); }\n\n    @Override public InputStream openStream() {\n        try {\n            return locationUrl.openStream();\n        } catch (FileNotFoundException e) {\n            return null;\n        } catch (IOException e) {\n            throw new BaseException(\"Error opening stream for \" + locationUrl.toString(), e);\n        }\n    }\n\n    @Override public OutputStream getOutputStream() {\n        if (!isFileProtocol) {\n            final URL url = locationUrl;\n            throw new IllegalArgumentException(\"Write not supported for resource [\" + url.toString() + \"] with protocol [\" + url.getProtocol() + \"]\");\n        }\n\n        // first make sure the directory exists that this is in\n        File curFile = getFile();\n        if (!curFile.getParentFile().exists()) curFile.getParentFile().mkdirs();\n        try {\n            return new FileOutputStream(curFile);\n        } catch (FileNotFoundException e) {\n            throw new BaseException(\"Error opening output stream for file \" + curFile.getAbsolutePath(), e);\n        }\n    }\n\n    @Override public String getText() { return ObjectUtilities.getStreamText(openStream()); }\n    @Override public boolean supportsAll() { return isFileProtocol; }\n    @Override public boolean supportsUrl() { return true; }\n\n    @Override public URL getUrl() { return locationUrl; }\n\n    @Override public boolean supportsDirectory() { return isFileProtocol; }\n    @Override public boolean isFile() {\n        if (isFileProtocol) {\n            return getFile().isFile();\n        } else {\n            throw new IllegalArgumentException(\"Is file not supported for resource with protocol [\" + locationUrl.getProtocol() + \"]\");\n        }\n    }\n\n    @Override public boolean isDirectory() {\n        if (isFileProtocol) {\n            return getFile().isDirectory();\n        } else {\n            throw new IllegalArgumentException(\"Is directory not supported for resource with protocol [\" + locationUrl.getProtocol() + \"]\");\n        }\n    }\n\n    @Override public List<ResourceReference> getDirectoryEntries() {\n        if (isFileProtocol) {\n            File f = getFile();\n            List<ResourceReference> children = new ArrayList<>();\n            String baseLocation = getLocation();\n            if (baseLocation.endsWith(\"/\")) baseLocation = baseLocation.substring(0, baseLocation.length() - 1);\n            File[] listFiles = f.listFiles();\n            TreeSet<String> fileNameSet = new TreeSet<>();\n            if (listFiles != null) for (File dirFile : listFiles) fileNameSet.add(dirFile.getName());\n            for (String filename : fileNameSet) children.add(new UrlResourceReference().init(baseLocation + \"/\" + filename));\n            return children;\n        } else {\n            throw new IllegalArgumentException(\"Children not supported for resource with protocol [\" + locationUrl.getProtocol() + \"]\");\n        }\n    }\n\n    @Override public boolean supportsExists() { return isFileProtocol || exists != null; }\n\n    @Override public boolean getExists() {\n        // only count exists if true\n        if (exists != null && exists) return true;\n\n        if (isFileProtocol) {\n            exists = getFile().exists();\n            return exists;\n        } else {\n            final URL url = locationUrl;\n            throw new IllegalArgumentException(\"Exists not supported for resource with protocol [\" + (url == null ? null : url.getProtocol()) + \"]\");\n        }\n    }\n\n    @Override public boolean supportsLastModified() { return isFileProtocol; }\n    @Override public long getLastModified() {\n        if (isFileProtocol) {\n            return getFile().lastModified();\n        } else {\n            return System.currentTimeMillis();\n        }\n    }\n\n    @Override public boolean supportsSize() { return isFileProtocol; }\n    @Override public long getSize() { return isFileProtocol ? getFile().length() : 0; }\n\n    @Override public boolean supportsWrite() { return isFileProtocol; }\n    @Override public void putText(String text) {\n        if (!isFileProtocol) {\n            final URL url = locationUrl;\n            throw new IllegalArgumentException(\"Write not supported for resource [\" + getLocation() + \"] with protocol [\" + (url == null ? null : getUrl().getProtocol()) + \"]\");\n        }\n\n        // first make sure the directory exists that this is in\n        File curFile = getFile();\n        if (!curFile.getParentFile().exists()) curFile.getParentFile().mkdirs();\n        // now write the text to the file and close it\n        try {\n            Writer fw = new OutputStreamWriter(new FileOutputStream(curFile), StandardCharsets.UTF_8);\n            fw.write(text);\n            fw.close();\n            this.exists = null;\n        } catch (IOException e) {\n            throw new BaseException(\"Error writing text to file \" + curFile.getAbsolutePath(), e);\n        }\n    }\n\n    @Override public void putStream(InputStream stream) {\n        if (!isFileProtocol) {\n            throw new IllegalArgumentException(\"Write not supported for resource [\" + locationUrl + \"] with protocol [\" + (locationUrl == null ? null : locationUrl.getProtocol()) + \"]\");\n        }\n\n        // first make sure the directory exists that this is in\n        File curFile = getFile();\n        if (!curFile.getParentFile().exists()) curFile.getParentFile().mkdirs();\n\n        try {\n            OutputStream os = new FileOutputStream(curFile);\n            ObjectUtilities.copyStream(stream, os);\n            stream.close();\n            os.close();\n            this.exists = null;\n        } catch (IOException e) {\n            throw new BaseException(\"Error writing stream to file \" + curFile.getAbsolutePath(), e);\n        }\n    }\n\n    @Override public void move(final String newLocation) {\n        if (newLocation == null || newLocation.isEmpty())\n            throw new IllegalArgumentException(\"No location specified, not moving resource at \" + getLocation());\n        ResourceReference newRr = createNew(newLocation);\n\n        if (!newRr.getUrl().getProtocol().equals(\"file\")) throw new IllegalArgumentException(\"Location [\" + newLocation + \"] is not a file location, not moving resource at \" + getLocation());\n        if (!isFileProtocol) throw new IllegalArgumentException(\"Move not supported for resource [\" + locationUrl + \"] with protocol [\" + (locationUrl == null ? null : locationUrl.getProtocol()) + \"]\");\n\n        File curFile = getFile();\n        if (!curFile.exists()) throw new IllegalArgumentException(\"File at \" + getLocation() + \" [\" + curFile.getAbsolutePath() + \"] does not exist, cannot move\");\n\n        String path = newRr.getUrl().toExternalForm().substring(5);\n        File newFile = new File(path);\n        File newFileParent = newFile.getParentFile();\n        if (newFileParent != null && !newFileParent.exists()) newFileParent.mkdirs();\n        if (!curFile.renameTo(newFile)) {\n            throw new IllegalArgumentException(\"Could not move \" + curFile + \" to \" + newFile);\n        }\n    }\n\n    @Override public ResourceReference makeDirectory(final String name) {\n        if (!isFileProtocol) {\n            final URL url = locationUrl;\n            throw new IllegalArgumentException(\"Write not supported for resource [\" + getLocation() + \"] with protocol [\" + (url == null ? null : url.getProtocol()) + \"]\");\n        }\n\n        UrlResourceReference newRef = (UrlResourceReference) new UrlResourceReference().init(getLocation() + \"/\" + name);\n        newRef.getFile().mkdirs();\n        return newRef;\n    }\n\n    @Override public ResourceReference makeFile(final String name) {\n        if (!isFileProtocol) {\n            final URL url = locationUrl;\n            throw new IllegalArgumentException(\"Write not supported for resource [\" + getLocation() + \"] with protocol [\" + (url == null ? null : url.getProtocol()) + \"]\");\n        }\n\n        UrlResourceReference newRef = (UrlResourceReference) new UrlResourceReference().init(getLocation() + \"/\" + name);\n        // first make sure the directory exists that this is in\n        if (!getFile().exists()) getFile().mkdirs();\n        try {\n            newRef.getFile().createNewFile();\n            return newRef;\n        } catch (IOException e) {\n            throw new BaseException(\"Error writing text to file \" + newRef.getLocation(), e);\n        }\n    }\n\n    @Override\n    public boolean delete() {\n        if (!isFileProtocol) {\n            final URL url = locationUrl;\n            throw new IllegalArgumentException(\"Write not supported for resource [\" + getLocation() + \"] with protocol [\" + (url == null ? null : url.getProtocol()) + \"]\");\n        }\n\n        return getFile().delete();\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/screen/ScreenFacade.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.screen;\n\n/** For rendering screens for general use (mostly for things other than web pages or web page snippets). */\npublic interface ScreenFacade {\n\n    /** Make a ScreenRender object to render a screen. */\n    ScreenRender makeRender();\n    /** Make a ScreenTest object to test render one or more screens. */\n    ScreenTest makeTest();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/screen/ScreenRender.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.screen;\n\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\n\nimport java.io.OutputStream;\nimport java.io.Writer;\nimport java.util.List;\n\npublic interface ScreenRender {\n    /** Location of the root XML Screen file to render.\n     *\n     * @return Reference to this ScreenRender for convenience\n     */\n    ScreenRender rootScreen(String screenLocation);\n\n    /** Determine location of the root XML Screen file to render based on a host name.\n     *\n     * @param host The host name, usually from ServletRequest.getServerName()\n     * @return Reference to this ScreenRender for convenience\n     */\n    ScreenRender rootScreenFromHost(String host);\n\n    /** A list of screen names used to determine which screens to use when rendering subscreens.\n     *\n     * @return Reference to this ScreenRender for convenience\n     */\n    ScreenRender screenPath(List<String> screenNameList);\n    ScreenRender screenPath(String path);\n    /** Alternative to lastStandalone parameter and accepts same values (true, false, positive numbers to render that many from\n     * end of path (true = 1), negative to render not render that many from start of path */\n    ScreenRender lastStandalone(String ls);\n\n    /** The mode to render for (type of output). Used to select sub-elements of the <code>render-mode</code>\n     * element and the default macro template (if one is not specified for this render).\n     *\n     * If macroTemplateLocation is not specified is also used to determine the default macro template\n     * based on configuration.\n     *\n     * @param outputType Can be anything. Default supported values include: text, html, xsl-fo, xml, and csv.\n     * @return Reference to this ScreenRender for convenience\n     */\n    ScreenRender renderMode(String outputType);\n\n    /** The MIME character encoding for the text produced. Defaults to <code>UTF-8</code>. Must be a valid charset in\n     * the java.nio.charset.Charset class.\n     *\n     * @return Reference to this ScreenRender for convenience\n     */\n    ScreenRender encoding(String characterEncoding);\n\n    /** Location of an FTL file with macros used to generate output. If not specified macro file from the screen\n     * configuration will be used depending on the outputType.\n     *\n     * @return Reference to this ScreenRender for convenience\n     */\n    ScreenRender macroTemplate(String macroTemplateLocation);\n\n    /** If specified will be used as the base URL for links. If not specified the base URL will come from configuration\n     * on the webapp-list.webapp element and the servletContextPath.\n     *\n     * @return Reference to this ScreenRender for convenience\n     */\n    ScreenRender baseLinkUrl(String baseLinkUrl);\n\n    /** If baseLinkUrl is not specified then this is used along with the webapp-list.webapp configuration to create\n     * a base URL. If this is not specified and the active ExecutionContext has a WebFacade active then it will get\n     * it from that (meaning with a WebFacade this is not necessary to get a correct result).\n     *\n     * @param scp The servletContext.contextPath\n     * @return Reference to this ScreenRender for convenience\n     */\n    ScreenRender servletContextPath(String scp);\n\n    /** The webapp name to use to look up webapp (webapp-list.webapp.@name) settings for URL building, request actions\n     * running, etc.\n     *\n     * @param wan The webapp name\n     * @return Reference to this ScreenRender for convenience\n     */\n    ScreenRender webappName(String wan);\n\n    /** By default history is not saved, set to true to save this screen render in the web session history */\n    ScreenRender saveHistory(boolean sh);\n\n    /** Render a screen to a response using the current context. The screen will run in a sub-context so the original\n     * context will not be changed. The request will be used to check web settings such as secure connection, etc.\n     */\n    void render(HttpServletRequest request, HttpServletResponse response);\n\n    /** Render a screen to a writer using the current context. The screen will run in a sub-context so the original\n     * context will not be changed.\n     */\n    void render(Writer writer);\n    void render(OutputStream os);\n\n    /** Render a screen and return the output as a String. Context semantics are the same as other render methods. */\n    String render();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/screen/ScreenTest.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.screen;\n\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\n\n/** A test harness for screen rendering. Does internal rendering without HTTP request/response */\n@SuppressWarnings(\"unused\")\npublic interface ScreenTest {\n    /** Location of the root XML Screen file to render */\n    ScreenTest rootScreen(String screenLocation);\n    /** A screen path prepended to the screenPath used for all subsequent render() calls */\n    ScreenTest baseScreenPath(String screenPath);\n\n    /** @see ScreenRender#renderMode(String) */\n    ScreenTest renderMode(String outputType);\n    /** @see ScreenRender#encoding(String) */\n    ScreenTest encoding(String characterEncoding);\n\n    /** @see ScreenRender#macroTemplate(String) */\n    ScreenTest macroTemplate(String macroTemplateLocation);\n\n    /** @see ScreenRender#baseLinkUrl(String) */\n    ScreenTest baseLinkUrl(String baseLinkUrl);\n    /** @see ScreenRender#servletContextPath(String) */\n    ScreenTest servletContextPath(String scp);\n    /** @see ScreenRender#webappName(String) */\n    ScreenTest webappName(String wan);\n\n    /** Calls to WebFacade.sendJsonResponse will not be serialized, use along with ScreenTestRender.getJsonObject() */\n    ScreenTest skipJsonSerialize(boolean skip);\n\n    /** Get screen name paths to all screens with no required parameters under the rootScreen and (if specified) baseScreenPath */\n    List<String> getNoRequiredParameterPaths(Set<String> screensToSkip);\n\n    /** Test render a screen.\n     * @param screenPath Path from rootScreen in the sub-screen hierarchy\n     * @param parameters Map with name/value pairs to use as if they were URL or body parameters\n     * @param requestMethod The HTTP request method to use when selecting a transition (defaults to get)\n     * @return ScreenTestRender object with the render result\n     */\n    ScreenTestRender render(String screenPath, Map<String, Object> parameters, String requestMethod);\n    void renderAll(List<String> screenPathList, Map<String, Object> parameters, String requestMethod);\n\n    long getRenderCount();\n    long getErrorCount();\n    long getRenderTotalChars();\n    long getStartTime();\n\n    interface ScreenTestRender {\n        ScreenRender getScreenRender();\n        String getOutput();\n        Object getJsonObject();\n        long getRenderTime();\n        Map getPostRenderContext();\n        List<String> getErrorMessages();\n        boolean assertContains(String text);\n        boolean assertNotContains(String text);\n        boolean assertRegex(String regex);\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/service/ServiceCall.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.service;\n\nimport java.util.Map;\n\npublic interface ServiceCall {\n    String getServiceName();\n\n    /** Map of name, value pairs that make up the context (in parameters) passed to the service. */\n    Map<String, Object> getCurrentParameters();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/service/ServiceCallAsync.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.service;\n\nimport java.util.Map;\nimport java.util.concurrent.Callable;\nimport java.util.concurrent.Future;\n\n@SuppressWarnings(\"unused\")\npublic interface ServiceCallAsync extends ServiceCall {\n    /** Name of the service to run. The combined service name, like: \"${path}.${verb}${noun}\". To explicitly separate\n     * the verb and noun put a hash (#) between them, like: \"${path}.${verb}#${noun}\" (this is useful for calling the\n     * implicit entity CrUD services where verb is create, update, or delete and noun is the name of the entity).\n     */\n    ServiceCallAsync name(String serviceName);\n\n    ServiceCallAsync name(String verb, String noun);\n\n    ServiceCallAsync name(String path, String verb, String noun);\n\n    /** Map of name, value pairs that make up the context (in parameters) passed to the service. */\n    ServiceCallAsync parameters(Map<String, Object> context);\n\n    /** Single name, value pairs to put in the context (in parameters) passed to the service. */\n    ServiceCallAsync parameter(String name, Object value);\n\n\n    /** If true the service call will be run distributed and may run on a different member of the cluster. Parameter\n     * entries MUST be java.io.Serializable (or java.io.Externalizable).\n     *\n     * If false it will be run local only (default).\n     *\n     * @return Reference to this for convenience.\n     */\n    ServiceCallAsync distribute(boolean dist);\n\n    /**\n     * Call the service asynchronously, ignoring the result.\n     * This effectively calls the service through a java.lang.Runnable implementation.\n     */\n    void call() throws ServiceException;\n\n    /**\n     * Call the service asynchronously, and get a java.util.concurrent.Future object back so you can wait for the service to\n     * complete and get the result.\n     *\n     * This is useful for running a number of service simultaneously and then getting\n     * all of the results back which will reduce the total running time from the sum of the time to run each service\n     * to just the time the longest service takes to run.\n     *\n     * This effectively calls the service through a java.util.concurrent.Callable implementation.\n     */\n    Future<Map<String, Object>> callFuture() throws ServiceException;\n\n    /** Get a Runnable object to do this service call through an ExecutorService or other runner of your choice. */\n    Runnable getRunnable();\n    /** Get a Callable object to do this service call through an ExecutorService of your choice. */\n    Callable<Map<String, Object>> getCallable();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/service/ServiceCallJob.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.service;\n\nimport java.util.Map;\nimport java.util.concurrent.Future;\n\n/**\n * An interface for ad-hoc (explicit) run of configured service jobs (in the moqui.service.job.ServiceJob entity).\n *\n * This interface has minimal options as most should be configured using ServiceJob entity fields.\n */\n@SuppressWarnings(\"unused\")\npublic interface ServiceCallJob extends ServiceCall, Future<Map<String, Object>> {\n    /** Map of name, value pairs that make up the context (in parameters) passed to the service. */\n    ServiceCallJob parameters(Map<String, Object> context);\n    /** Single name, value pairs to put in the context (in parameters) passed to the service. */\n    ServiceCallJob parameter(String name, Object value);\n    /** Set to true to run local even if a distributed executor service is configured (defaults to false) */\n    ServiceCallJob localOnly(boolean local);\n\n    /**\n     * Run a service job.\n     *\n     * The job will always run asynchronously. To get the results of the service call without looking at the\n     * ServiceJobRun.results field keep a reference to this object and use the methods on the\n     * java.util.concurrent.Future interface.\n     *\n     * If the ServiceJob.topic field has a value a notification will be sent to the current user and all users\n     * configured using ServiceJobUser records. The NotificationMessage.message field will be the results of this\n     * service call.\n     *\n     * @return The jobRunId for the corresponding moqui.service.job.ServiceJobRun record\n     */\n    String run() throws ServiceException;\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/service/ServiceCallSpecial.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.service;\n\nimport java.util.Map;\n\n@SuppressWarnings(\"unused\")\npublic interface ServiceCallSpecial extends ServiceCall {\n    /** Name of the service to run. The combined service name, like: \"${path}.${verb}${noun}\". To explicitly separate\n     * the verb and noun put a hash (#) between them, like: \"${path}.${verb}#${noun}\" (this is useful for calling the\n     * implicit entity CrUD services where verb is create, update, or delete and noun is the name of the entity).\n     */\n    ServiceCallSpecial name(String serviceName);\n\n    ServiceCallSpecial name(String verb, String noun);\n\n    ServiceCallSpecial name(String path, String verb, String noun);\n\n    /** Map of name, value pairs that make up the context (in parameters) passed to the service. */\n    ServiceCallSpecial parameters(Map<String, Object> context);\n\n    /** Single name, value pairs to put in the context (in parameters) passed to the service. */\n    ServiceCallSpecial parameter(String name, Object value);\n\n\n    /** Add a service to run on commit of the current transaction using the ServiceXaWrapper */\n    void registerOnCommit();\n\n    /** Add a service to run on rollback of the current transaction using the ServiceXaWrapper */\n    void registerOnRollback();\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/service/ServiceCallSync.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.service;\n\nimport java.util.Map;\n\n@SuppressWarnings(\"unused\")\npublic interface ServiceCallSync extends ServiceCall {\n    /** Name of the service to run. The combined service name, like: \"${path}.${verb}${noun}\". To explicitly separate\n     * the verb and noun put a hash (#) between them, like: \"${path}.${verb}#${noun}\" (this is useful for calling the\n     * implicit entity CrUD services where verb is create, update, or delete and noun is the name of the entity).\n     */\n    ServiceCallSync name(String serviceName);\n\n    ServiceCallSync name(String verb, String noun);\n\n    ServiceCallSync name(String path, String verb, String noun);\n\n    /** Map of name, value pairs that make up the context (in parameters) passed to the service. */\n    ServiceCallSync parameters(Map<String, ?> context);\n\n    /** Single name, value pairs to put in the context (in parameters) passed to the service. */\n    ServiceCallSync parameter(String name, Object value);\n\n    /** By default a service uses the existing transaction or begins a new one if no tx is in place. Set this flag to\n     * ignore the transaction, not checking for one or starting one if no transaction is in place. */\n    ServiceCallSync ignoreTransaction(boolean ignoreTransaction);\n\n    /** If true suspend/resume the current transaction (if a transaction is active) and begin a new transaction for the\n     * scope of this service call.\n     *\n     * @return Reference to this for convenience.\n     */\n    ServiceCallSync requireNewTransaction(boolean requireNewTransaction);\n    /** Override the transaction-timeout attribute in the service definition, only used if a transaction is begun in this service call. */\n    ServiceCallSync transactionTimeout(int timeout);\n\n    /** Use the write-through TransactionCache.\n     *\n     * WARNING: test thoroughly with this. While various services will run much faster there can be issues with no\n     * changes going to the database until commit (for view-entity queries depending on data, etc).\n     *\n     * Some known limitations:\n     * - find list and iterate don't cache results (but do filter and add to results aside from limitations below)\n     * - EntityListIterator.getPartialList(), .relative(), and .absolute() are not supported when tx cache is in place and values\n     *      have been created; getCompleteList(), iteration using next() calls, etc are supported\n     * - find with DB limit will return wrong number of values if deleted values were in the results\n     * - find count doesn't add for created values, subtract for deleted values, and for updates if old matched and new doesn't subtract and vice-versa\n     * - view-entities won't work, they don't incorporate results from TX Cache\n     * - for-update queries are remembered but for best results do for-update queries before non for-update queries on the same record\n     *\n     * @return Reference to this for convenience.\n     */\n    ServiceCallSync useTransactionCache(boolean useTransactionCache);\n\n    /** Normally service won't run if there was an error (ec.message.hasError()), set this to true to run anyway. */\n    ServiceCallSync ignorePreviousError(boolean ipe);\n    /** If true add danger messages instead of hard error messages for validation */\n    ServiceCallSync softValidate(boolean sv);\n\n    /** If true expect multiple sets of parameters passed in a single map, each set with a suffix of an underscore\n     * and the row of the number, ie something like \"userId_8\" for the userId parameter in the 8th row.\n     * @return Reference to this for convenience.\n     */\n    ServiceCallSync multi(boolean mlt);\n\n    /** Do not remember parameters in ArtifactExecutionFacade history and stack,\n     * important for service calls with large parameters that should be de-referenced for GC before ExecutionContext is destroyed. */\n    ServiceCallSync noRememberParameters();\n\n    /** Disable authorization for the current thread during this service call. */\n    ServiceCallSync disableAuthz();\n\n    /* * If null defaults to configured value for service, or container. For possible values see JavaDoc for javax.sql.Connection.\n     * @return Reference to this for convenience.\n     */\n    /* not supported by Atomikos/etc right now, consider for later: ServiceCallSync transactionIsolation(int transactionIsolation);\n\n    /** Call the service synchronously and immediately get the result.\n     * @return Map containing the result (out parameters) from the service call.\n     */\n    Map<String, Object> call() throws ServiceException;\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/service/ServiceCallback.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.service;\n\nimport java.util.Map;\n\npublic interface ServiceCallback {\n    boolean isEnabled();\n    void receiveEvent(Map<String, Object> context, Map<String, Object> result);\n    void receiveEvent(Map<String, Object> context, Throwable t);\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/service/ServiceException.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.service;\n\n/**\n * ServiceFacade Exception\n */\npublic class ServiceException extends org.moqui.BaseException {\n\n    public ServiceException(String str) {\n        super(str);\n    }\n\n    public ServiceException(String str, Throwable nested) {\n        super(str, nested);\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/service/ServiceFacade.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.service;\n\nimport org.moqui.util.RestClient;\n\nimport java.util.Map;\n\n/** ServiceFacade Interface */\n@SuppressWarnings(\"unused\")\npublic interface ServiceFacade {\n\n    /** Get a service caller to call a service synchronously. */\n    ServiceCallSync sync();\n\n    /** Get a service caller to call a service asynchronously. */\n    ServiceCallAsync async();\n\n    /**\n     * Get a service caller to call a service job.\n     *\n     * @param jobName The name of the job. There must be a moqui.service.job.ServiceJob record for this jobName.\n     */\n    ServiceCallJob job(String jobName);\n\n    /** Get a service caller for special service calls such as on commit and on rollback of current transaction. */\n    ServiceCallSpecial special();\n\n    /** Call a JSON remote service. For Moqui services the location will be something like \"http://hostname/rpc/json\". */\n    Map<String, Object> callJsonRpc(String location, String method, Map<String, Object> parameters);\n\n    /** Get a RestClient instance to call remote REST services */\n    RestClient rest();\n\n    /** Register a callback listener on a specific service.\n     * @param serviceName Name of the service to run. The combined service name, like: \"${path}.${verb}${noun}\". To\n     *   explicitly separate the verb and noun put a hash (#) between them, like: \"${path}.${verb}#${noun}\".\n     * @param serviceCallback The callback implementation.\n     */\n    void registerCallback(String serviceName, ServiceCallback serviceCallback);\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/util/CollectionUtilities.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.util;\n\nimport groovy.util.Node;\nimport groovy.util.NodeList;\nimport org.codehaus.groovy.runtime.DefaultGroovyMethods;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.sql.Timestamp;\nimport java.util.*;\n\n/**\n * These are utilities that should exist elsewhere, but I can't find a good simple library for them, and they are\n * stupid but necessary for certain things.\n */\n@SuppressWarnings(\"unused\")\npublic class CollectionUtilities {\n    protected static final Logger logger = LoggerFactory.getLogger(CollectionUtilities.class);\n\n    public static class KeyValue {\n        public String key;\n        public Object value;\n        public KeyValue(String key, Object value) { this.key = key; this.value = value; }\n    }\n\n    public static HashMap<String, Object> toHashMap(Object... keyValues) {\n        if (keyValues.length % 2 != 0) throw new IllegalArgumentException(\"Must have even number of arguments in name, value pairs\");\n        HashMap<String, Object> newMap = new HashMap<>();\n        int pairs = keyValues.length / 2;\n        for (int p = 0; p < pairs; p++) {\n            int i = p * 2;\n            newMap.put((String) keyValues[i], keyValues[i+1]);\n        }\n        return newMap;\n    }\n\n    public static ArrayList<Object> getMapArrayListValues(ArrayList<Map<Object, Object>> mapList, Object key, boolean excludeNullValues) {\n        if (mapList == null) return null;\n        int mapListSize = mapList.size();\n        ArrayList<Object> valList = new ArrayList<>(mapListSize);\n        for (int i = 0; i < mapListSize; i++) {\n            Map<Object, Object> curMap = mapList.get(i);\n            if (curMap == null) continue;\n            Object curVal = curMap.get(key);\n            if (excludeNullValues && curVal == null) continue;\n            valList.add(curVal);\n        }\n        return valList;\n    }\n\n    public static void filterMapList(List<Map> theList, Map<String, Object> fieldValues) {\n        filterMapList(theList, fieldValues, false);\n    }\n    /** Filter theList (of Map) using fieldValues; if exclude=true remove matching items, else keep only matching items */\n    public static void filterMapList(List<Map> theList, Map<String, Object> fieldValues, boolean exclude) {\n        if (theList == null || fieldValues == null) return;\n        int listSize = theList.size();\n        if (listSize == 0) return;\n        int numFields = fieldValues.size();\n        if (numFields == 0) return;\n\n        String[] fieldNameArray = new String[numFields];\n        Object[] fieldValueArray = new Object[numFields];\n        int index = 0;\n        for (Map.Entry<String, Object> entry : fieldValues.entrySet()) {\n            fieldNameArray[index] = entry.getKey();\n            fieldValueArray[index] = entry.getValue();\n            index++;\n        }\n\n        if (theList instanceof RandomAccess) {\n            for (int li = 0; li < listSize; ) {\n                Map curMap = theList.get(li);\n                if (checkRemove(curMap, fieldNameArray, fieldValueArray, numFields, exclude)) {\n                    theList.remove(li);\n                    listSize--;\n                } else { li++; }\n            }\n        } else {\n            Iterator<Map> theIterator = theList.iterator();\n            while (theIterator.hasNext()) {\n                Map curMap = theIterator.next();\n                if (checkRemove(curMap, fieldNameArray, fieldValueArray, numFields, exclude)) theIterator.remove();\n            }\n        }\n    }\n    private static boolean checkRemove(Map curMap, String[] fieldNameArray, Object[] fieldValueArray, int numFields, boolean exclude) {\n        boolean remove = exclude;\n        for (int i = 0; i < numFields; i++) {\n            String fieldName = fieldNameArray[i];\n            Object compareObj = fieldValueArray[i];\n            Object curObj = curMap.get(fieldName);\n            if (compareObj == null) { if (curObj != null) { remove = !exclude; break; } }\n            else { if (!compareObj.equals(curObj)) { remove = !exclude; break; } }\n        }\n        return remove;\n    }\n\n    public static List<Map> filterMapListByDate(List<Map> theList, String fromDateName, String thruDateName, Timestamp compareStamp) {\n        if (theList == null || theList.size() == 0) return theList;\n\n        if (fromDateName == null || fromDateName.isEmpty()) fromDateName = \"fromDate\";\n        if (thruDateName == null || thruDateName.isEmpty()) thruDateName = \"thruDate\";\n        // no access to ec.user here, so this should always be passed in, but just in case\n        if (compareStamp == null) compareStamp = new Timestamp(System.currentTimeMillis());\n\n        Iterator<Map> theIterator = theList.iterator();\n        while (theIterator.hasNext()) {\n            Map curMap = theIterator.next();\n            Timestamp fromDate = DefaultGroovyMethods.asType(curMap.get(fromDateName), Timestamp.class);\n            if (fromDate != null && compareStamp.compareTo(fromDate) < 0) {\n                theIterator.remove();\n                continue;\n            }\n            Timestamp thruDate = DefaultGroovyMethods.asType(curMap.get(thruDateName), Timestamp.class);\n            if (thruDate != null && compareStamp.compareTo(thruDate) >= 0) theIterator.remove();\n        }\n        return theList;\n    }\n\n    public static void filterMapListByDate(List<Map> theList, String fromDateName, String thruDateName, Timestamp compareStamp, boolean ignoreIfEmpty) {\n        if (ignoreIfEmpty && compareStamp == null) return;\n        filterMapListByDate(theList, fromDateName, thruDateName, compareStamp);\n    }\n\n    /** Order list elements in place (modifies the list passed in), returns the list for convenience */\n    public static List<Map<String, Object>> orderMapList(List<Map<String, Object>> theList, List<? extends CharSequence> fieldNames) {\n        return orderMapList(theList, fieldNames, null);\n    }\n    public static List<Map<String, Object>> orderMapList(List<Map<String, Object>> theList, List<? extends CharSequence> fieldNames, Boolean nullsLast) {\n        if (fieldNames == null) throw new IllegalArgumentException(\"Cannot order List of Maps with null order by field list\");\n        if (theList != null && fieldNames.size() > 0) theList.sort(new MapOrderByComparator(fieldNames).nullsLast(nullsLast));\n        return theList;\n    }\n\n    public static class MapOrderByComparator implements Comparator<Map> {\n        String[] fieldNameArray;\n        Boolean nullsLast = null;\n\n        public MapOrderByComparator(List<? extends CharSequence> fieldNameList) {\n            ArrayList<String> fieldArrayList = new ArrayList<>();\n            for (CharSequence fieldName : fieldNameList) {\n                String fieldStr = fieldName.toString();\n                if (fieldStr.contains(\",\")) {\n                    String[] curFieldArray = fieldStr.split(\",\");\n                    for (int i = 0; i < curFieldArray.length; i++) {\n                        String curField = curFieldArray[i];\n                        if (curField == null) continue;\n                        fieldArrayList.add(curField.trim());\n                    }\n                } else {\n                    fieldArrayList.add(fieldStr);\n                }\n            }\n            fieldNameArray = fieldArrayList.toArray(new String[0]);\n            // logger.warn(\"Order list by \" + Arrays.asList(fieldNameArray));\n        }\n\n        public MapOrderByComparator nullsLast(Boolean nl) {\n            nullsLast = nl;\n            return this;\n        }\n\n        @SuppressWarnings(\"unchecked\")\n        @Override public int compare(Map map1, Map map2) {\n            if (map1 == null) return -1;\n            if (map2 == null) return 1;\n            for (int i = 0; i < fieldNameArray.length; i++) {\n                String fieldName = fieldNameArray[i];\n                boolean ascending = true;\n                boolean ignoreCase = false;\n                if (fieldName.charAt(0) == '-') {\n                    ascending = false;\n                    fieldName = fieldName.substring(1);\n                } else if (fieldName.charAt(0) == '+') {\n                    fieldName = fieldName.substring(1);\n                }\n                if (fieldName.charAt(0) == '^') {\n                    ignoreCase = true;\n                    fieldName = fieldName.substring(1);\n                }\n\n                boolean nullsFirst = nullsLast != null ? !nullsLast.booleanValue() : ascending;\n\n                Comparable value1 = (Comparable) map1.get(fieldName);\n                Comparable value2 = (Comparable) map2.get(fieldName);\n                // NOTE: nulls go earlier in the list for ascending, later in the list for !ascending\n                if (value1 == null) {\n                    if (value2 != null) return nullsFirst ? -1 : 1;\n                } else {\n                    if (value2 == null) {\n                        return nullsFirst ? 1 : -1;\n                    } else {\n                        if (ignoreCase && value1 instanceof String && value2 instanceof String) {\n                            int comp = ((String) value1).compareToIgnoreCase((String) value2);\n                            if (comp != 0) return ascending ? comp : -comp;\n                        } else {\n                            if (value1.getClass() != value2.getClass()) {\n                                if (value1 instanceof Number && value2 instanceof Number) {\n                                    value1 = new BigDecimal(value1.toString());\n                                    value2 = new BigDecimal(value2.toString());\n                                }\n                                // NOTE: any other type normalization to avoid compareTo() casting exceptions?\n                            }\n                            int comp = value1.compareTo(value2);\n                            if (comp != 0) return ascending ? comp : -comp;\n                        }\n                    }\n                }\n            }\n            // all evaluated to 0, so is the same, so return 0\n            return 0;\n        }\n\n        @Override public boolean equals(Object obj) {\n            return obj instanceof MapOrderByComparator && Arrays.equals(fieldNameArray, ((MapOrderByComparator) obj).fieldNameArray);\n        }\n\n        @Override public String toString() { return Arrays.toString(fieldNameArray); }\n    }\n\n    /**\n     * For a list of Map find the entry that best matches the fieldsByPriority Ordered Map; null field values in a Map\n     * in mapList match against any value but do not contribute to maximal match score, otherwise value for each field\n     * in fieldsByPriority must match for it to be a candidate.\n     */\n    public static Map<String, Object> findMaximalMatch(List<Map<String, Object>> mapList, LinkedHashMap<String, Object> fieldsByPriority) {\n        int numFields = fieldsByPriority.size();\n        String[] fieldNames = new String[numFields];\n        Object[] fieldValues = new Object[numFields];\n        int index = 0;\n        for (Map.Entry<String, Object> entry : fieldsByPriority.entrySet()) {\n            fieldNames[index] = entry.getKey();\n            fieldValues[index] = entry.getValue();\n            index++;\n        }\n\n        int highScore = -1;\n        Map<String, Object> highMap = null;\n        for (Map<String, Object> curMap : mapList) {\n            int curScore = 0;\n            boolean skipMap = false;\n            for (int i = 0; i < numFields; i++) {\n                String curField = fieldNames[i];\n                Object compareValue = fieldValues[i];\n                // if curMap value is null skip field (null value in Map means allow any match value\n                Object curValue = curMap.get(curField);\n                if (curValue == null) continue;\n                // if not equal skip Map\n                if (!curValue.equals(compareValue)) {\n                    skipMap = true;\n                    break;\n                }\n                // add to score based on index (lower index higher score), also add numFields so more fields matched weights higher\n                curScore += (numFields - i) + numFields;\n            }\n\n            if (skipMap) continue;\n            // have a higher score?\n            if (curScore > highScore) {\n                highScore = curScore;\n                highMap = curMap;\n            }\n        }\n\n        return highMap;\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    public static void addToListInMap(Object key, Object value, Map theMap) {\n        if (theMap == null) return;\n\n        List theList = (List) theMap.get(key);\n        if (theList == null) {\n            theList = new ArrayList();\n            theMap.put(key, theList);\n        }\n        theList.add(value);\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    public static boolean addToSetInMap(Object key, Object value, Map theMap) {\n        if (theMap == null) return false;\n        Set theSet = (Set) theMap.get(key);\n        if (theSet == null) {\n            theSet = new LinkedHashSet();\n            theMap.put(key, theSet);\n        }\n        return theSet.add(value);\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    public static void addToMapInMap(Object keyOuter, Object keyInner, Object value, Map theMap) {\n        if (theMap == null) return;\n        Map innerMap = (Map) theMap.get(keyOuter);\n        if (innerMap == null) {\n            innerMap = new LinkedHashMap();\n            theMap.put(keyOuter, innerMap);\n        }\n        innerMap.put(keyInner, value);\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    public static void addToBigDecimalInMap(Object key, BigDecimal value, Map theMap) {\n        if (value == null || theMap == null) return;\n        Object curObj = theMap.get(key);\n        if (curObj == null) {\n            theMap.put(key, value);\n        } else {\n            BigDecimal curVal;\n            if (curObj instanceof BigDecimal) curVal = (BigDecimal) curObj;\n            else curVal = new BigDecimal(curObj.toString());\n            theMap.put(key, curVal.add(value));\n        }\n    }\n\n    public static void addBigDecimalsInMap(Map<String, Object> baseMap, Map<String, Object> addMap) {\n        if (baseMap == null || addMap == null) return;\n        for (Map.Entry<String, Object> entry : addMap.entrySet()) {\n            if (!(entry.getValue() instanceof BigDecimal)) continue;\n            BigDecimal addVal = (BigDecimal) entry.getValue();\n            Object baseObj = baseMap.get(entry.getKey());\n            if (baseObj == null || !(baseObj instanceof BigDecimal)) baseObj = BigDecimal.ZERO;\n            BigDecimal baseVal = (BigDecimal) baseObj;\n            baseMap.put(entry.getKey(), baseVal.add(addVal));\n        }\n    }\n    public static void divideBigDecimalsInMap(Map<String, Object> baseMap, BigDecimal divisor) {\n        if (baseMap == null || divisor == null || divisor.doubleValue() == 0.0) return;\n        for (Map.Entry<String, Object> entry : baseMap.entrySet()) {\n            if (!(entry.getValue() instanceof BigDecimal)) continue;\n            BigDecimal baseVal = (BigDecimal) entry.getValue();\n            entry.setValue(baseVal.divide(divisor, RoundingMode.HALF_UP));\n        }\n    }\n\n    /** Returns Map with total, squaredTotal, count, average, stdDev, maximum; fieldName field in Maps must have type BigDecimal;\n     * if count of non-null fields is less than 2 returns null as cannot calculate a standard deviation */\n    public static Map<String, BigDecimal> stdDevMaxFromMapField(List<Map<String, Object>> dataList, String fieldName, BigDecimal stdDevMultiplier) {\n        BigDecimal total = BigDecimal.ZERO;\n        BigDecimal squaredTotal = BigDecimal.ZERO;\n        int count = 0;\n        for (Map<String, Object> dataMap : dataList) {\n            if (dataMap == null) continue;\n            BigDecimal value = (BigDecimal) dataMap.get(fieldName);\n            if (value == null) continue;\n            total = total.add(value);\n            squaredTotal = squaredTotal.add(value.multiply(value));\n            count++;\n        }\n        if (count < 2) return null;\n\n        BigDecimal countBd = new BigDecimal(count);\n        BigDecimal average = total.divide(countBd, RoundingMode.HALF_UP);\n        double totalDouble = total.doubleValue();\n        BigDecimal stdDev = new BigDecimal(Math.sqrt(Math.abs(squaredTotal.doubleValue() - ((totalDouble*totalDouble) / count)) / (count - 1)));\n\n        Map<String, BigDecimal> retMap = new HashMap<>(6);\n        retMap.put(\"total\", total); retMap.put(\"squaredTotal\", squaredTotal); retMap.put(\"count\", countBd);\n        retMap.put(\"average\", average); retMap.put(\"stdDev\", stdDev);\n\n        if (stdDevMultiplier != null) retMap.put(\"maximum\", average.add(stdDev.multiply(stdDevMultiplier)));\n\n        return retMap;\n    }\n\n    /** Find a field value in a nested Map containing fields, Maps, and Collections of Maps (Lists, etc) */\n    public static Object findFieldNestedMap(String key, Map theMap) {\n        if (theMap.containsKey(key)) return theMap.get(key);\n        for (Object value : theMap.values()) {\n            if (value instanceof Map) {\n                Object fieldValue = findFieldNestedMap(key, (Map) value);\n                if (fieldValue != null) return fieldValue;\n            } else if (value instanceof Collection) {\n                // only look in Collections of Maps\n                for (Object colValue : (Collection) value) {\n                    if (colValue instanceof Map) {\n                        Object fieldValue = findFieldNestedMap(key, (Map) colValue);\n                        if (fieldValue != null) return fieldValue;\n                    }\n                }\n            }\n        }\n        return null;\n    }\n\n    /** Find all values of a named field in a nested Map containing fields, Maps, and Collections of Maps (Lists, etc) */\n    public static void findAllFieldsNestedMap(String key, Map theMap, Set<Object> valueSet) {\n        if (theMap instanceof LiteStringMap) {\n            LiteStringMap lsm = (LiteStringMap) theMap;\n            int keyLength = key != null ? key.length() : 0;\n            int keyHashCode = key != null ? key.hashCode() : 0;\n            boolean foundKey = false;\n            int lsmSize = lsm.size();\n            for (int i = 0; i < lsmSize; i++) {\n                String curKey = lsm.getKey(i);\n                Object curValue = lsm.getValue(i);\n                if (!foundKey && keyLength == curKey.length() && keyHashCode == curKey.hashCode() && curKey.equals(key)) {\n                    foundKey = true;\n                    if (curValue != null) valueSet.add(curValue);\n                }\n                if (curValue instanceof Map) {\n                    findAllFieldsNestedMap(key, (Map) curValue, valueSet);\n                } else if (curValue instanceof Collection) {\n                    // only look in Collections of Maps\n                    for (Object colValue : (Collection) curValue) {\n                        if (colValue instanceof Map) findAllFieldsNestedMap(key, (Map) colValue, valueSet);\n                    }\n                }\n            }\n        } else {\n            Object localValue = theMap.get(key);\n            if (localValue != null) valueSet.add(localValue);\n            for (Object value : theMap.values()) {\n                if (value instanceof Map) {\n                    findAllFieldsNestedMap(key, (Map) value, valueSet);\n                } else if (value instanceof Collection) {\n                    // only look in Collections of Maps\n                    for (Object colValue : (Collection) value) {\n                        if (colValue instanceof Map) findAllFieldsNestedMap(key, (Map) colValue, valueSet);\n                    }\n                }\n            }\n        }\n    }\n\n    /** Creates a single Map with fields from the passed in Map and all nested Maps (for Map and Collection of Map entry values) */\n    @SuppressWarnings(\"unchecked\")\n    public static Map flattenNestedMap(Map theMap) {\n        if (theMap == null) return null;\n        Map outMap = new LinkedHashMap();\n        for (Object entryObj : theMap.entrySet()) {\n            Map.Entry entry = (Map.Entry) entryObj;\n            Object value = entry.getValue();\n            if (value instanceof Map) {\n                outMap.putAll(flattenNestedMap((Map) value));\n            } else if (value instanceof Collection) {\n                for (Object colValue : (Collection) value) {\n                    if (colValue instanceof Map) outMap.putAll(flattenNestedMap((Map) colValue));\n                }\n            } else {\n                outMap.put(entry.getKey(), entry.getValue());\n            }\n        }\n        return outMap;\n    }\n\n    public static Map<String, String> flattenNestedMapWithKeys(Map<String, Object> theMap) {\n        return flattenNestedMapWithKeys(theMap, \"\");\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private static Map<String, String> flattenNestedMapWithKeys(Map<String, Object> theMap, String parentKey) {\n        Map<String, String> output = new LinkedHashMap<>();\n\n        if (theMap == null) return output;\n\n        for (Map.Entry<String, Object> entry : theMap.entrySet()) {\n            String key = entry.getKey();\n            Object value = entry.getValue();\n            String newKey = parentKey.isEmpty() ? key : parentKey + \"[\" + key + \"]\";\n\n            if (value instanceof Map) {\n                output.putAll(flattenNestedMapWithKeys((Map<String, Object>) value, newKey));\n            } else if (value instanceof Collection) {\n                int index = 0;\n                for (Object colValue : (Collection<?>) value) {\n                    if (colValue instanceof Map) {\n                        output.putAll(flattenNestedMapWithKeys((Map<String, Object>) colValue, newKey + \"[\" + index + \"]\"));\n                    } else {\n                        output.put(newKey + \"[\" + index + \"]\", colValue.toString());\n                    }\n                    index++;\n                }\n            } else {\n                output.put(newKey, value.toString());\n            }\n        }\n        return output;\n    }\n    @SuppressWarnings(\"unchecked\")\n    public static void mergeNestedMap(Map<Object, Object> baseMap, Map<Object, Object> overrideMap, boolean overrideEmpty) {\n        if (baseMap == null || overrideMap == null) return;\n        for (Map.Entry<Object, Object> entry : overrideMap.entrySet()) {\n            Object key = entry.getKey();\n            Object value = entry.getValue();\n            if (baseMap.containsKey(key)) {\n                if (value == null) {\n                    if (overrideEmpty) baseMap.put(key, null);\n                } else {\n                    if (value instanceof CharSequence) {\n                        if (overrideEmpty || ((CharSequence) value).length() > 0) baseMap.put(key, value);\n                    } else if (value instanceof Map) {\n                        Object baseValue = baseMap.get(key);\n                        if (baseValue != null && baseValue instanceof Map) {\n                            mergeNestedMap((Map) baseValue, (Map) value, overrideEmpty);\n                        } else {\n                            baseMap.put(key, value);\n                        }\n                    } else if (value instanceof Collection) {\n                        Object baseValue = baseMap.get(key);\n                        if (baseValue != null && baseValue instanceof Collection) {\n                            Collection baseCol = (Collection) baseValue;\n                            Collection overrideCol = (Collection) value;\n                            for (Object overrideObj : overrideCol) {\n                                // NOTE: if we have a Collection of Map we have no way to merge the Maps without knowing the 'key' entries to use to match them\n                                if (!baseCol.contains(overrideObj)) baseCol.add(overrideObj);\n                            }\n                        } else {\n                            baseMap.put(key, value);\n                        }\n                    } else {\n                        // NOTE: no way to check empty, if not null not empty so put it\n                        baseMap.put(key, value);\n                    }\n                }\n            } else {\n                baseMap.put(key, value);\n            }\n        }\n    }\n\n    public final static Collection<Object> singleNullCollection;\n    static {\n        singleNullCollection = new ArrayList<>();\n        singleNullCollection.add(null);\n    }\n    /** Removes entries with a null value from the Map, returns the passed in Map for convenience (does not clone before removes!). */\n    @SuppressWarnings(\"unchecked\")\n    public static Map removeNullsFromMap(Map theMap) {\n        if (theMap == null) return null;\n        theMap.values().removeAll(singleNullCollection);\n        return theMap;\n    }\n\n    public static boolean mapMatchesFields(Map<String, Object> baseMap, Map<String, Object> compareMap) {\n        for (Map.Entry<String, Object> entry : compareMap.entrySet()) {\n            Object compareObj = compareMap.get(entry.getKey());\n            Object baseObj = baseMap.get(entry.getKey());\n            if (compareObj == null) {\n                if (baseObj != null) return false;\n            } else {\n                if (!compareObj.equals(baseObj)) return false;\n            }\n        }\n        return true;\n    }\n\n    public static Node deepCopyNode(Node original) { return deepCopyNode(original, null); }\n\n    @SuppressWarnings(\"unchecked\")\n    public static Node deepCopyNode(Node original, Node parent) {\n        if (original == null) return null;\n\n        Node newNode = new Node(parent, original.name(), new HashMap(original.attributes()));\n        Object newValue = original.value();\n        if (newValue != null && newValue instanceof List) {\n            NodeList childList = new NodeList();\n            for (Object child : (List) newValue) {\n                if (child instanceof Node) {\n                    childList.add(deepCopyNode((Node) child, newNode));\n                } else if (child != null) {\n                    childList.add(child);\n                }\n            }\n            newValue = childList;\n        }\n\n        if (newValue != null) newNode.setValue(newValue);\n        return newNode;\n    }\n\n    public static String nodeText(Object nodeObj) {\n        if (!DefaultGroovyMethods.asBoolean(nodeObj)) return \"\";\n        Node theNode = null;\n        if (nodeObj instanceof Node) {\n            theNode = (Node) nodeObj;\n        } else if (nodeObj instanceof NodeList) {\n            NodeList nl = DefaultGroovyMethods.asType((Collection) nodeObj, NodeList.class);\n            if (nl.size() > 0) theNode = (Node) nl.get(0);\n        }\n\n        if (theNode == null) return \"\";\n        List<String> textList = theNode.localText();\n        if (DefaultGroovyMethods.asBoolean(textList)) {\n            if (textList.size() == 1) {\n                return textList.get(0);\n            } else {\n                StringBuilder sb = new StringBuilder();\n                for (String txt : textList) sb.append(txt).append(\"\\n\");\n                return sb.toString();\n            }\n        } else {\n            return \"\";\n        }\n    }\n\n    public static Node nodeChild(Node parent, String childName) {\n        if (parent == null) return null;\n        NodeList childList = (NodeList) parent.get(childName);\n        if (childList != null && childList.size() > 0) return (Node) childList.get(0);\n        return null;\n    }\n\n    public static void paginateList(String listName, String pageListName, Map<String, Object> context) {\n        if (pageListName == null || pageListName.isEmpty()) pageListName = listName;\n        List theList = (List) context.get(listName);\n        if (theList == null) theList = new ArrayList();\n\n        List pageList = paginateList(theList, pageListName, context);\n        context.put(pageListName, pageList);\n    }\n    public static List paginateList(List theList, String pageListName, Map<String, Object> context) {\n        // if this exists then was already paginated so don't do a subList()\n        if (context.containsKey(pageListName + \"AlreadyPaginated\")) return theList;\n\n        Integer pageRangeLow = (Integer) context.get(pageListName + \"PageRangeLow\");\n        Integer pageRangeHigh = (Integer) context.get(pageListName + \"PageRangeHigh\");\n        if (pageRangeLow == null || pageRangeHigh == null) {\n            paginateParameters(theList != null ? theList.size() : 0, pageListName, context);\n            pageRangeLow = (Integer) context.get(pageListName + \"PageRangeLow\");\n            pageRangeHigh = (Integer) context.get(pageListName + \"PageRangeHigh\");\n        }\n        return theList.subList(pageRangeLow - 1, pageRangeHigh);\n    }\n    public static Map paginateParameters(int listSize, String pageListName, Map<String, Object> context) {\n        final Object pageIndexObj = context.get(\"pageIndex\");\n        int pageIndex = 0;\n        if (!ObjectUtilities.isEmpty(pageIndexObj)) {\n            try { pageIndex = Integer.parseInt(pageIndexObj.toString()); }\n            catch (Exception e) { /* just use the 0 default above */ }\n        }\n        if (pageIndex < 0) pageIndex = 0;\n\n        final Object pageSizeObj = context.get(\"pageSize\");\n        int pageSize = 20;\n        if (!ObjectUtilities.isEmpty(pageSizeObj)) {\n            try { pageSize = Integer.parseInt(pageSizeObj.toString()); }\n            catch (Exception e) { /* just use the 20 default above */ }\n        }\n        if (pageSize < 0) pageSize = 20;\n\n        // NOTE: if context has a *Count field don't calc and set values, assume are already in place\n        if (context.get(pageListName + \"Count\") == null) {\n            int count = listSize;\n            // calculate the pagination values\n            int maxIndex = (new BigDecimal(count - 1)).divide(new BigDecimal(pageSize), 0, RoundingMode.DOWN).intValue();\n            int pageRangeLow = (pageIndex * pageSize) + 1;\n            if (pageRangeLow > count) pageRangeLow = count + 1;\n            int pageRangeHigh = (pageIndex * pageSize) + pageSize;\n            if (pageRangeHigh > count) pageRangeHigh = count;\n\n            context.put(pageListName + \"Count\", count);\n            context.put(pageListName + \"PageIndex\", pageIndex);\n            context.put(pageListName + \"PageSize\", pageSize);\n            context.put(pageListName + \"PageMaxIndex\", maxIndex);\n            context.put(pageListName + \"PageRangeLow\", pageRangeLow);\n            context.put(pageListName + \"PageRangeHigh\", pageRangeHigh);\n        } else {\n            context.put(pageListName + \"AlreadyPaginated\", true);\n        }\n\n        return context;\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/util/ContextBinding.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.util;\n\nimport groovy.lang.Binding;\n\npublic class ContextBinding extends Binding {\n    private ContextStack contextStack;\n    public ContextBinding(ContextStack variables) {\n        super(variables);\n        contextStack = variables;\n    }\n\n    @Override\n    public Object getVariable(String name) {\n        // NOTE: this code is part of the original Groovy groovy.lang.Binding.getVariable() method and leaving it out\n        //     is the reason to override this method:\n        //if (result == null && !variables.containsKey(name)) {\n        //    throw new MissingPropertyException(name, this.getClass());\n        //}\n        return contextStack.getByString(name);\n    }\n\n    @Override\n    public void setVariable(String name, Object value) {\n        contextStack.put(name, value);\n    }\n\n    @Override\n    public boolean hasVariable(String name) {\n        // always treat it like the variable exists and is null to change the behavior for variable scope and\n        //     declaration, easier in simple scripts\n        return true;\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/util/ContextStack.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.util;\n\nimport javax.annotation.Nonnull;\nimport java.util.*;\n\n@SuppressWarnings(\"unchecked\")\npublic class ContextStack implements Map<String, Object> {\n    private final static org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(ContextStack.class);\n    private final int INITIAL_STACK_SIZE = 32;\n\n    private HashMap<String, Object> sharedMap = null;\n    private LinkedList<ContextInfo> contextStack = null;\n\n    private Map<String, Object>[] stackArray = new Map[INITIAL_STACK_SIZE];\n    private int stackIndex = 0;\n\n    private boolean includeContext = true;\n\n    private static class ContextInfo {\n        Map<String, Object>[] stackArray;\n        int stackIndex;\n        ContextInfo(Map<String, Object>[] stackArray, int stackIndex) { this.stackArray = stackArray; this.stackIndex = stackIndex; }\n        ContextInfo cloneInfo() {\n            Map<String, Object>[] newArray = new Map[stackArray.length];\n            System.arraycopy(stackArray, 0, newArray, 0, stackIndex + 1);\n            return new ContextInfo(newArray, stackIndex);\n        }\n    }\n\n    public ContextStack() { }\n    public ContextStack(boolean includeContext) { this.includeContext = includeContext; }\n\n    public Map<String, Object> getSharedMap() {\n        if (sharedMap == null) sharedMap = new HashMap<>();\n        return sharedMap;\n    }\n\n    /** Push (save) the entire context, ie the whole Map stack, to create an isolated empty context. */\n    public ContextStack pushContext() {\n        if (contextStack == null) contextStack = new LinkedList<>();\n        contextStack.addFirst(new ContextInfo(stackArray, stackIndex));\n        stackArray = new Map[INITIAL_STACK_SIZE];\n        stackIndex = 0;\n        return this;\n    }\n\n    /** Pop (restore) the entire context, ie the whole Map stack, undo isolated empty context and get the original one. */\n    public ContextStack popContext() {\n        if (contextStack == null || contextStack.size() == 0) throw new IllegalStateException(\"Cannot pop context, no context pushed\");\n        ContextInfo ci = contextStack.removeFirst();\n        stackArray = ci.stackArray;\n        stackIndex = ci.stackIndex;\n        return this;\n    }\n\n    private void pushInternal(Map theMap) {\n        stackIndex++;\n        if (stackIndex >= stackArray.length) growStackArray();\n        // NOTE: if null leave null for lazy init on put\n        stackArray[stackIndex] = theMap;\n    }\n    private void growStackArray() {\n        // logger.warn(\"Growing ContextStack internal array from \" + stackArray.length);\n\n        stackArray = Arrays.copyOf(stackArray, stackArray.length * 2);\n    }\n\n    /** Puts a new Map on the top of the stack for a fresh local context\n     * @return Returns reference to this ContextStack\n     */\n    public ContextStack push() {\n        pushInternal(null);\n        return this;\n    }\n\n    /** Puts an existing Map on the top of the stack (top meaning will override lower layers on the stack)\n     * @param existingMap An existing Map\n     * @return Returns reference to this ContextStack\n     */\n    public ContextStack push(Map<String, Object> existingMap) {\n        if (existingMap == null) throw new IllegalArgumentException(\"Cannot push null as an existing Map\");\n        if (includeContext && existingMap.containsKey(\"context\"))\n            throw new IllegalArgumentException(\"Cannot push existing Map containing key 'context', reserved key\");\n\n        pushInternal(existingMap);\n        return this;\n    }\n\n    /** Remove and returns the Map from the top of the stack (the local context).\n     * If there is only one Map on the stack it returns null and does not remove it.\n     *\n     * @return The first/top Map\n     */\n    public Map<String, Object> pop() {\n        if (stackIndex == 0) throw new IllegalArgumentException(\"ContextStack is empty, cannot pop the context\");\n        Map<String, Object> oldMap = stackArray[stackIndex];\n        stackArray[stackIndex] = null;\n        stackIndex--;\n        return oldMap;\n    }\n\n    /** Add an existing Map as the Root Map, ie on the BOTTOM of the stack meaning it will be overridden by other Maps on the stack\n     * @param existingMap An existing Map\n     */\n    public void addRootMap(Map<String, Object> existingMap) {\n        if (existingMap == null) throw new IllegalArgumentException(\"Cannot add null as an existing Map\");\n        if (includeContext && existingMap.containsKey(\"context\"))\n            throw new IllegalArgumentException(\"Cannot push existing Map containing key 'context', reserved key\");\n\n        if ((stackIndex + 1) >= stackArray.length) growStackArray();\n        // move all elements up one\n        for (int i = stackIndex; i >= 0; i--) stackArray[i+1] = stackArray[i];\n        stackIndex++;\n        stackArray[0] = existingMap;\n    }\n\n    public Map<String, Object> getRootMap() { return stackArray[0]; }\n\n    /**\n     * Creates a ContextStack object that has the same Map objects on its stack (a shallow clone).\n     * Meant to be used to enable a situation where a parent and child context are operating simultaneously using two\n     * different ContextStack objects, but sharing the Maps between them.\n     *\n     * @return Clone of this ContextStack\n     */\n    @Override public ContextStack clone() throws CloneNotSupportedException {\n        ContextStack newStack = new ContextStack();\n        newStack.stackArray = new Map[stackArray.length];\n        System.arraycopy(stackArray, 0, newStack.stackArray, 0, stackIndex + 1);\n        newStack.stackIndex = stackIndex;\n        \n        if (sharedMap != null) newStack.sharedMap = new HashMap<>(sharedMap);\n\n        if (contextStack != null) {\n            newStack.contextStack = new LinkedList<>();\n            for (ContextInfo ci : contextStack) newStack.contextStack.add(ci.cloneInfo());\n        }\n        newStack.includeContext = includeContext;\n\n        return newStack;\n    }\n\n    @Override public int size() {\n        // use the keySet since this gets a set of all unique keys for all Maps in the stack\n        Set keys = keySet();\n        return keys.size();\n    }\n    @Override public boolean isEmpty() {\n        for (int i = stackIndex; i >= 0; i--) { if (stackArray[i] != null && !stackArray[i].isEmpty()) return false; }\n        return true;\n    }\n\n    @Override public boolean containsKey(Object key) {\n        for (int i = stackIndex; i >= 0; i--) { if (stackArray[i] != null && stackArray[i].containsKey(key)) return true; }\n        return false;\n    }\n    @Override public boolean containsValue(Object value) {\n        // this keeps track of keys looked at for values at each level of the stack so that the same key is not\n        // considered more than once (the earlier Maps overriding later ones)\n        Set<Object> keysObserved = new HashSet<>();\n        for (int i = stackIndex; i >= 0; i--) {\n            Map<String, Object> curMap = stackArray[i];\n            for (Map.Entry<String, Object> curEntry : curMap.entrySet()) {\n                String curKey = curEntry.getKey();\n                if (!keysObserved.contains(curKey)) {\n                    keysObserved.add(curKey);\n                    if (value == null) {\n                        if (curEntry.getValue() == null) return true;\n                    } else {\n                        if (value.equals(curEntry.getValue())) return true;\n                    }\n                }\n            }\n        }\n        return false;\n\n        // maybe do simpler but not as correct? for (int i = stackIndex; i >= 0; i--) { if (stackArray[i] != null && stackArray[i].containsValue(value)) return true; }\n    }\n\n    /** For faster access to multiple entries; do not write to this Map or use when any changes to ContextStack are possible */\n    public Map<String, Object> getCombinedMap() {\n        Map<String, Object> combinedMap = new HashMap<>();\n        // opposite order of get(), root down so later maps override earlier\n        for (int i = 0; i <= stackIndex; i++) {\n            if (stackArray[i] != null) combinedMap.putAll(stackArray[i]);\n        }\n        return combinedMap;\n    }\n\n    public Object getByString(String key) {\n        for (int i = stackIndex; i >= 0; i--) {\n            Map<String, Object> curMap = stackArray[i];\n            if (curMap == null || curMap.isEmpty()) continue;\n            // optimize for non-null get, avoid double lookup with containsKey/get\n            Object value = curMap.get(key);\n            if (value != null) return value;\n            if (curMap.containsKey(key)) return null;\n        }\n\n        // handle \"context\" reserved key to represent this\n        if (includeContext && \"context\".equals(key)) return this;\n\n        return null;\n    }\n    @Override public Object get(Object keyObj) {\n        String key = null;\n        if (keyObj instanceof String) {\n            key = (String) keyObj;\n        } else if (keyObj != null) {\n            if (keyObj instanceof CharSequence) {\n                key = keyObj.toString();\n            } else {\n                return null;\n            }\n        }\n\n        // with combinedMap now handling all changes this is a simple call\n        return getByString(key);\n    }\n\n    @Override public Object put(String key, Object value) {\n        if (includeContext && \"context\".equals(key)) throw new IllegalArgumentException(\"Cannot put with key 'context', reserved key\");\n\n        if (stackArray[stackIndex] == null) stackArray[stackIndex] = new HashMap<>();\n        return stackArray[stackIndex].put(key, value);\n    }\n\n    @Override public Object remove(Object key) {\n        if (stackArray[stackIndex] == null) return null;\n        return stackArray[stackIndex].remove(key);\n    }\n\n    @Override public void putAll(@Nonnull Map<? extends String, ?> theMap) {\n        // using Nonnull: if (theMap == null) return;\n        if (includeContext && theMap.containsKey(\"context\"))\n            throw new IllegalArgumentException(\"Cannot push existing Map containing key 'context', reserved key\");\n\n        if (stackArray[stackIndex] == null) stackArray[stackIndex] = new HashMap<>();\n        stackArray[stackIndex].putAll(theMap);\n    }\n\n    @Override public void clear() {\n        if (stackArray[stackIndex] == null) return;\n        stackArray[stackIndex].clear();\n    }\n\n    @Override public @Nonnull Set<String> keySet() {\n        Set<String> resultSet = new HashSet<>();\n        // resultSet.add(\"context\");\n        for (int i = stackIndex; i >= 0; i--) if (stackArray[i] != null) resultSet.addAll(stackArray[i].keySet());\n        return Collections.unmodifiableSet(resultSet);\n    }\n    @Override public @Nonnull Collection<Object> values() {\n        Set<Object> keysObserved = new HashSet<>();\n        List<Object> resultValues = new LinkedList<>();\n        for (int i = stackIndex; i >= 0; i--) {\n            if (stackArray[i] == null) continue;\n            for (Map.Entry<String, Object> curEntry: stackArray[i].entrySet()) {\n                String curKey = curEntry.getKey();\n                if (!keysObserved.contains(curKey)) {\n                    keysObserved.add(curKey);\n                    resultValues.add(curEntry.getValue());\n                }\n            }\n        }\n        return Collections.unmodifiableCollection(resultValues);\n    }\n    @Override public @Nonnull Set<Map.Entry<String, Object>> entrySet() {\n        Set<Object> keysObserved = new HashSet<>();\n        Set<Map.Entry<String, Object>> resultEntrySet = new HashSet<>();\n        for (int i = stackIndex; i >= 0; i--) {\n            if (stackArray[i] == null) continue;\n            for (Map.Entry<String, Object> curEntry: stackArray[i].entrySet()) {\n                String curKey = curEntry.getKey();\n                if (!keysObserved.contains(curKey)) {\n                    keysObserved.add(curKey);\n                    resultEntrySet.add(curEntry);\n                }\n            }\n        }\n        return Collections.unmodifiableSet(resultEntrySet);    }\n\n    @Override\n    public String toString() {\n        StringBuilder fullMapString = new StringBuilder();\n        for (int i = 0; i <= stackIndex; i++) {\n            Map<String, Object> curMap = stackArray[i];\n            if (curMap == null) continue;\n            fullMapString.append(\"========== Start stack level \").append(i).append(\"\\n\");\n            for (Map.Entry<String, Object> curEntry: curMap.entrySet()) {\n                fullMapString.append(\"==>[\");\n                fullMapString.append(curEntry.getKey());\n                fullMapString.append(\"]:\");\n                if (curEntry.getValue() instanceof ContextStack) {\n                    // skip instances of ContextStack to avoid infinite recursion\n                    fullMapString.append(\"<Instance of ContextStack, not printing to avoid infinite recursion>\");\n                } else {\n                    fullMapString.append(curEntry.getValue());\n                }\n                fullMapString.append(\"\\n\");\n            }\n            fullMapString.append(\"========== End stack level \").append(i).append(\"\\n\");\n        }\n        return fullMapString.toString();\n    }\n\n    @Override public int hashCode() { return Arrays.deepHashCode(stackArray); }\n    @Override public boolean equals(Object o) {\n        return !(o == null || o.getClass() != this.getClass()) && Arrays.deepEquals(stackArray, ((ContextStack) o).stackArray);\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/util/LiteStringMap.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.util;\n\nimport java.io.Externalizable;\nimport java.io.IOException;\nimport java.io.ObjectInput;\nimport java.io.ObjectOutput;\nimport java.util.*;\n\n/** Light weight String Keyed Map optimized for memory usage and garbage collection overhead.\n * Uses parallel key and value arrays internally and does not create an object for each Map.Entry unless entrySet() is used.\n * This is generally slower than HashMap unless key String objects are already interned.\n * With '*IString' variations of methods a call with a known already interned String can operate as fast, and for smaller Maps faster, than HashMap (such as in the EntityFacade where field names come from an interned String in a FieldInfo object).\n * This is most certainly not thread-safe.\n */\npublic class LiteStringMap<V> implements Map<String, V>, Externalizable, Comparable<Map<String,? extends V>>, Cloneable {\n    // NOTE: for over the wire compatibility do not change this unless writeExternal() and readExternal() are changed OR the non-transient fields change from only keyArray, valueArray, and lastIndex\n    private static final long serialVersionUID = 688763341199951234L;\n    private static final int DEFAULT_CAPACITY = 8;\n\n    // NOTE: from basic profiling HashMap.get() runs in just over half the time (0.13 microseconds) of String.intern() (0.24 microseconds) over ~500k runs with OpenJDK 8\n    private static HashMap<String, String> internedMap = new HashMap<>();\n    public static String internString(String orig) {\n        String interned = internedMap.get(orig);\n        if (interned != null) return interned;\n        // don't even check for null until we have to\n        if (orig == null) return null;\n        interned = orig.intern();\n        internedMap.put(interned, interned);\n        return interned;\n    }\n\n    // NOTE: key design point is to use parallel arrays with simple values in each so that no Object need be created per entry (minimize GC overhead, etc)\n    private String[] keyArray;\n    private V[] valueArray;\n    private int lastIndex = -1;\n    private transient int mapHash = 0;\n    private transient boolean useManualIndex = false;\n\n    public LiteStringMap() { init(DEFAULT_CAPACITY); }\n    public LiteStringMap(int initialCapacity) { init(initialCapacity); }\n    public LiteStringMap(Map<String, V> cloneMap) {\n        init(cloneMap.size());\n        if (cloneMap instanceof LiteStringMap && ((LiteStringMap<V>) cloneMap).useManualIndex) useManualIndex = true;\n        putAll(cloneMap);\n    }\n    public LiteStringMap(Map<String, V> cloneMap, Set<String> skipKeys) {\n        init(cloneMap.size());\n        if (cloneMap instanceof LiteStringMap && ((LiteStringMap<V>) cloneMap).useManualIndex) useManualIndex = true;\n        putAll(cloneMap, skipKeys);\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    private void init(int capacity) {\n        keyArray = new String[capacity];\n        valueArray = (V[]) new Object[capacity];\n    }\n    private void growArrays(Integer minLength) {\n        int newLength = keyArray.length * 2;\n        if (minLength != null && newLength < minLength) newLength = minLength;\n        // System.out.println(\"=============================\\n============= grow to \" + newLength);\n        keyArray = Arrays.copyOf(keyArray, newLength);\n        valueArray = Arrays.copyOf(valueArray, newLength);\n    }\n\n\n    public LiteStringMap<V> ensureCapacity(int capacity) {\n        if (keyArray.length < capacity) {\n            keyArray = Arrays.copyOf(keyArray, capacity);\n            valueArray = Arrays.copyOf(valueArray, capacity);\n        }\n        return this;\n    }\n    public LiteStringMap<V> useManualIndex() { useManualIndex = true; return this; }\n\n    public int findIndex(String keyOrig) {\n        if (keyOrig == null) return -1;\n        return findIndexIString(internString(keyOrig));\n\n        /* safer but slower approach, needed? by String.intern() JavaDoc no, consistency guaranteed\n        int keyLength = keyString.length();\n        int keyHashCode = keyString.hashCode();\n        // NOTE: can't use Arrays.binarySearch() as we want to maintain the insertion order and not use natural order for array elements\n        for (int i = 0; i <= lastIndex; i++) {\n            // all strings in keyArray should be interned, only added via put()\n            String curKey = keyArray[i];\n            // first optimization is using interned String with identity compare, but don't always rely on this\n            // next optimization comparing length() and hashCode() first to eliminate mismatches more quickly (by far the most common case)\n            //     basic premise is that key Strings will be reused frequently and will already have a hashCode calculated\n            if (curKey == keyString || (curKey.length() == keyLength && curKey.hashCode() == keyHashCode && keyString.equals(curKey))) return i;\n        }\n        return -1;\n        */\n    }\n    /** For this method the String key must be non-null and interned (returned value from String.intern()) */\n    public int findIndexIString(String key) {\n        for (int i = 0; i <= lastIndex; i++) {\n            // all strings in keyArray should be interned, only added via put()\n            if (keyArray[i] == key) return i;\n        }\n        return -1;\n    }\n\n    public String getKey(int index) { return keyArray[index]; }\n\n    public V getValue(int index) { return (V) valueArray[index]; }\n\n    @Override public int size() { return lastIndex + 1; }\n    @Override public boolean isEmpty() { return lastIndex == -1; }\n    @Override public boolean containsKey(Object key) {\n        if (key == null) return false;\n        return findIndex(key.toString()) != -1;\n    }\n    /** For this method the String key must be non-null and interned (returned value from String.intern()) */\n    public boolean containsKeyIString(String key) {\n        return findIndexIString(key) != -1;\n    }\n    /** For this method the String key must be non-null and interned (returned value from String.intern()) */\n    public boolean containsKeyIString(String key, int index) {\n        String idxKey = keyArray[index];\n        if (idxKey == null) return false;\n        if (idxKey != key) throw new IllegalArgumentException(\"Index \" + index + \" has key \" + keyArray[index] + \", cannot check contains with key \" + key);\n        return true;\n    }\n\n    @Override\n    public boolean containsValue(Object value) {\n        for (int i = 0; i <= lastIndex; i++) {\n            if (valueArray[i] == null) {\n                if (value == null) return true;\n            } else {\n                if (valueArray[i].equals(value)) return true;\n            }\n        }\n        return false;\n    }\n\n    @Override\n    public V get(Object key) {\n        if (key == null) return null;\n        int keyIndex = findIndex(key.toString());\n        if (keyIndex == -1) return null;\n        return valueArray[keyIndex];\n    }\n    public V getByString(String key) {\n        int keyIndex = findIndex(key);\n        if (keyIndex == -1) return null;\n        return valueArray[keyIndex];\n    }\n    /** For this method the String key must be non-null and interned (returned value from String.intern()) */\n    public V getByIString(String key) {\n        int keyIndex = findIndexIString(key);\n        if (keyIndex == -1) return null;\n        return valueArray[keyIndex];\n    }\n    /** For this method the String key must be non-null and interned (returned value from String.intern()) */\n    public V getByIString(String key, int index) {\n        if (index >= keyArray.length) throw new ArrayIndexOutOfBoundsException(\"Index \" + index + \" invalid, internal array length \" + keyArray.length + \"; for key: \" + key);\n        String idxKey = keyArray[index];\n        if (idxKey == null) return null;\n        if (idxKey != key) throw new IllegalArgumentException(\"Index \" + index + \" has key \" + keyArray[index] + \", cannot get with key \" + key);\n        return valueArray[index];\n    }\n\n    /* ========= Start Mutate Methods ========= */\n\n    @Override\n    public V put(String keyOrig, V value) {\n        if (keyOrig == null) throw new IllegalArgumentException(\"LiteStringMap Key may not be null\");\n        return putByIString(internString(keyOrig), value);\n    }\n    /** For this method the String key must be non-null and interned (returned value from String.intern()) */\n    public V putByIString(String key, V value) {\n        // if (\"pseudoId\".equals(key)) { System.out.println(\"========= put no index \" + key + \": \" + value); new Exception(\"location\").printStackTrace(); }\n        int keyIndex = findIndexIString(key);\n        if (keyIndex == -1) {\n            lastIndex++;\n            if (lastIndex >= keyArray.length) growArrays(null);\n            keyArray[lastIndex] = key;\n            valueArray[lastIndex] = value;\n            mapHash = 0;\n            return null;\n        } else {\n            V oldValue = valueArray[keyIndex];\n            valueArray[keyIndex] = value;\n            mapHash = 0;\n            return oldValue;\n        }\n    }\n    /** For this method the String key must be non-null and interned (returned value from String.intern()) */\n    public V putByIString(String key, V value, int index) {\n        // if (\"pseudoId\".equals(key)) { System.out.println(\"========= put index \" + index + \" key \" + key + \": \" + value); new Exception(\"location\").printStackTrace(); }\n        useManualIndex = true;\n        if (index >= keyArray.length) growArrays(index + 1);\n        if (index > lastIndex) lastIndex = index;\n        if (keyArray[index] == null) {\n            keyArray[index] = key;\n            valueArray[index] = value;\n            mapHash = 0;\n            return null;\n        } else {\n            // identity compare for interned String\n            if (key != keyArray[index]) throw new IllegalArgumentException(\"Index \" + index + \" already has key \" + keyArray[index] + \", cannot use with key \" + key);\n            V oldValue = valueArray[index];\n            valueArray[index] = value;\n            mapHash = 0;\n            return oldValue;\n        }\n    }\n\n    @Override\n    public V remove(Object key) {\n        if (key == null) return null;\n        int keyIndex = findIndexIString(internString(key.toString()));\n        return removeByIndex(keyIndex);\n    }\n\n    private V removeByIndex(int keyIndex) {\n        if (keyIndex == -1) {\n            return null;\n        } else {\n            V oldValue = valueArray[keyIndex];\n            if (useManualIndex) {\n                // with manual indexes don't shift entries, will cause manually specified indexes to be wrong\n                keyArray[keyIndex] = null;\n                valueArray[keyIndex] = null;\n            } else {\n                // shift all later values up one position\n                for (int i = keyIndex; i < lastIndex; i++) {\n                    keyArray[i] = keyArray[i+1];\n                    valueArray[i] = valueArray[i+1];\n                }\n                // null the last values to avoid memory leak\n                keyArray[lastIndex] = null;\n                valueArray[lastIndex] = null;\n                // decrement last index\n                lastIndex--;\n            }\n            // reset hash\n            mapHash = 0;\n            return oldValue;\n        }\n    }\n\n    public boolean removeAllKeys(Collection<?> collection) {\n        if (collection == null) return false;\n        boolean removedAny = false;\n        for (Object obj : collection) {\n            // keys in LiteStringMap cannot be null\n            if (obj == null) continue;\n            int idx = findIndex(obj.toString());\n            if (idx != -1) {\n                removeByIndex(idx);\n                removedAny = true;\n            }\n        }\n        return removedAny;\n    }\n    public boolean removeValue(Object value) {\n        boolean removedAny = false;\n        for (int i = 0; i < valueArray.length; i++) {\n            Object curVal = valueArray[i];\n            if (value == null) {\n                if (curVal == null) {\n                    removeByIndex(i);\n                    removedAny = true;\n                }\n            } else if (value.equals(curVal)) {\n                removeByIndex(i);\n                removedAny = true;\n            }\n        }\n        return removedAny;\n    }\n    public boolean removeAllValues(Collection<?> collection) {\n        if (collection == null) return false;\n        boolean removedAny = false;\n        // NOTE: could iterate over valueArray outer and collection inner but value array has no Iterator overhead so do that inner (and nice to reuse removeValue())\n        for (Object obj : collection) {\n            if (removeValue(obj)) removedAny = true;\n        }\n        return removedAny;\n    }\n\n    @Override public void putAll(Map<? extends String, ? extends V> map) { putAll(map, null); }\n\n    @SuppressWarnings(\"unchecked\")\n    public void putAll(Map<? extends String, ? extends V> map, Set<String> skipKeys) {\n        if (map == null) return;\n        boolean initialEmpty = lastIndex == -1;\n        if (map instanceof LiteStringMap) {\n            LiteStringMap<V> lsm = (LiteStringMap<V>) map;\n            if (useManualIndex) {\n                this.lastIndex = lsm.lastIndex;\n                if (keyArray.length <= lsm.lastIndex) growArrays(lsm.lastIndex);\n            }\n            for (int i = 0; i <= lsm.lastIndex; i++) {\n                if (skipKeys != null && skipKeys.contains(lsm.keyArray[i])) continue;\n                if (useManualIndex) {\n                    keyArray[i] = lsm.keyArray[i];\n                    valueArray[i] = lsm.valueArray[i];\n                } else if (initialEmpty) {\n                    putNoFind(lsm.keyArray[i], lsm.valueArray[i]);\n                } else {\n                    putByIString(lsm.keyArray[i], lsm.valueArray[i]);\n                }\n            }\n        } else {\n            for (Map.Entry<? extends String, ? extends V> entry : map.entrySet()) {\n                String key = entry.getKey();\n                if (key == null) throw new IllegalArgumentException(\"LiteStringMap Key may not be null\");\n                if (skipKeys != null && skipKeys.contains(key)) continue;\n                if (initialEmpty) {\n                    putNoFind(internString(key), entry.getValue());\n                } else {\n                    putByIString(internString(key), entry.getValue());\n                }\n            }\n        }\n        mapHash = 0;\n    }\n    /** For this method the String key must be non-null and interned (returned value from String.intern()) */\n    private void putNoFind(String key, V value) {\n        lastIndex++;\n        if (lastIndex >= keyArray.length) growArrays(null);\n        keyArray[lastIndex] = key;\n        valueArray[lastIndex] = value;\n        mapHash = 0;\n    }\n\n    @Override\n    public void clear() {\n        lastIndex = -1;\n        Arrays.fill(keyArray, null);\n        Arrays.fill(valueArray, null);\n        mapHash = 0;\n    }\n\n    /* ========= End Mutate Methods ========= */\n\n    @Override public Set<String> keySet() { return new KeySetWrapper(this); }\n    @Override public Collection<V> values() { return new ValueCollectionWrapper<>(this); }\n    @Override public Set<Entry<String, V>> entrySet() { return new EntrySetWrapper<>(this); }\n\n    @Override\n    public int hashCode() {\n        if (mapHash == 0) {\n            // NOTE: this mimics the HashMap implementation from AbstractMap.java for the outer (add entry hash codes) and HashMap.java for the Map.Entry impl\n            for (int i = 0; i <= lastIndex; i++) {\n                mapHash += (keyArray[i] == null ? 0 : keyArray[i].hashCode()) ^ (valueArray[i] == null ? 0 : valueArray[i].hashCode());\n            }\n        }\n        return mapHash;\n    }\n\n    @Override\n    public boolean equals(Object o) {\n        if (o instanceof LiteStringMap) {\n            LiteStringMap lsm = (LiteStringMap) o;\n            if (lastIndex != lsm.lastIndex) return false;\n            for (int i = 0; i <= lastIndex; i++) {\n                // identity compare of interned String keys, if equal the value in the other LSM is conveniently at the same index\n                if (keyArray[i] == lsm.keyArray[i]) {\n                    if (!Objects.equals(valueArray[i], lsm.valueArray[i])) return false;\n                } else {\n                    Object value = lsm.getByIString(keyArray[i]);\n                    if (!Objects.equals(valueArray[i], value)) return false;\n                }\n            }\n            return true;\n        } else if (o instanceof Map) {\n            Map map = (Map) o;\n            if ((lastIndex + 1) != map.size()) return false;\n            for (int i = 0; i <= lastIndex; i++) {\n                Object value = map.get(keyArray[i]);\n                if (!Objects.equals(valueArray[i], value)) return false;\n            }\n            return true;\n        } else {\n            return false;\n        }\n    }\n\n    @Override protected Object clone() { return new LiteStringMap<V>(this); }\n    public LiteStringMap<V> cloneLite() { return new LiteStringMap<V>(this); }\n\n    @Override\n    public String toString() {\n        StringBuilder sb = new StringBuilder();\n        sb.append('[');\n        for (int i = 0; i <= lastIndex; i++) {\n            if (i != 0) sb.append(\", \");\n            sb.append(keyArray[i]).append(\":\").append(valueArray[i]);\n        }\n        sb.append(']');\n        return sb.toString();\n    }\n\n    @Override\n    public void writeExternal(ObjectOutput out) throws IOException {\n        int size = lastIndex + 1;\n        out.writeInt(size);\n        // after writing size write each key/value pair\n        for (int i = 0; i < size; i++) {\n            out.writeObject(keyArray[i]);\n            out.writeObject(valueArray[i]);\n        }\n    }\n\n    @Override\n    @SuppressWarnings(\"unchecked\")\n    public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {\n        int size = in.readInt();\n        if (keyArray.length < size) {\n            keyArray = new String[size];\n            valueArray = (V[]) new Object[size];\n        }\n        lastIndex = size - 1;\n        mapHash = 0;\n        // now that we know the size read each key/value pair\n        for (int i = 0; i < size; i++) {\n            // intern Strings, from deserialize they will not be interned\n            String key = (String) in.readObject();\n            keyArray[i] = key != null ? internString(key) : null;\n            valueArray[i] = (V) in.readObject();\n        }\n    }\n\n    @Override\n    @SuppressWarnings(\"unchecked\")\n    public int compareTo(Map<String, ? extends V> that) {\n        int result = 0;\n        if (that instanceof LiteStringMap) {\n            LiteStringMap lsm = (LiteStringMap) that;\n            result = Integer.compare(lastIndex, lsm.lastIndex);\n            if (result != 0) return result;\n\n            for (int i = 0; i <= lastIndex; i++) {\n                Comparable thisVal = (Comparable) valueArray[i];\n                // identity compare of interned String keys, if equal the value in the other LSM is conveniently at the same index\n                Comparable thatVal = keyArray[i] == lsm.keyArray[i] ? (Comparable) lsm.valueArray[i] : (Comparable) lsm.getByIString(keyArray[i]);\n                // NOTE: nulls go earlier in the list\n                if (thisVal == null) {\n                    result = thatVal == null ? 0 : 1;\n                } else {\n                    result = thatVal == null ? -1 : thisVal.compareTo(thatVal);\n                }\n                if (result != 0) return result;\n            }\n        } else {\n            result = Integer.compare(lastIndex + 1, that.size());\n            if (result != 0) return result;\n\n            for (int i = 0; i <= lastIndex; i++) {\n                Comparable thisVal = (Comparable) valueArray[i];\n                Comparable thatVal = (Comparable) that.get(keyArray[i]);\n                // NOTE: nulls go earlier in the list\n                if (thisVal == null) {\n                    result = thatVal == null ? 0 : 1;\n                } else {\n                    result = thatVal == null ? -1 : thisVal.compareTo(thatVal);\n                }\n                if (result != 0) return result;\n            }\n        }\n\n        return result;\n    }\n\n    /* ========== Interface Wrapper Classes ========== */\n\n    public static class KeyIterator implements Iterator<String> {\n        private final LiteStringMap lsm;\n        private int curIndex = -1;\n        KeyIterator(LiteStringMap liteStringMap) { lsm = liteStringMap; }\n        @Override public boolean hasNext() { return lsm.lastIndex > curIndex; }\n        @Override public String next() { curIndex++; return lsm.keyArray[curIndex]; }\n    }\n    public static class ValueIterator<V> implements Iterator<V> {\n        private final LiteStringMap<V> lsm;\n        private int curIndex = -1;\n        ValueIterator(LiteStringMap<V> liteStringMap) { lsm = liteStringMap; }\n        @Override public boolean hasNext() { return lsm.lastIndex > curIndex; }\n        @Override public V next() { curIndex++; return lsm.valueArray[curIndex]; }\n    }\n\n    public static class KeySetWrapper implements Set<String> {\n        private final LiteStringMap lsm;\n        KeySetWrapper(LiteStringMap liteStringMap) { lsm = liteStringMap; }\n\n        @Override public int size() { return lsm.size(); }\n        @Override public boolean isEmpty() { return lsm.isEmpty(); }\n        @Override public boolean contains(Object o) { return lsm.containsKey(o); }\n        @Override public Iterator<String> iterator() { return new KeyIterator(lsm); }\n\n        @Override public Object[] toArray() { return Arrays.copyOf(lsm.keyArray, lsm.lastIndex + 1); }\n        @Override public <T> T[] toArray(T[] ts) {\n            int toCopy = ts.length > lsm.lastIndex ? lsm.lastIndex + 1 : ts.length;\n            System.arraycopy(lsm.keyArray, 0, ts, 0, toCopy);\n            return ts;\n        }\n        @Override\n        public boolean containsAll(Collection<?> collection) {\n            if (collection == null) return false;\n            for (Object obj : collection)  if (obj == null || lsm.findIndex(obj.toString()) == -1) return false;\n            return true;\n        }\n\n        @Override public boolean add(String s) { throw new UnsupportedOperationException(\"Key Set add not allowed\"); }\n        @Override public boolean remove(Object o) {\n            if (o == null) return false;\n            int idx = lsm.findIndex(o.toString());\n            if (idx == -1) {\n                return false;\n            } else {\n                lsm.removeByIndex(idx);\n                return true;\n            }\n        }\n        @Override public boolean addAll(Collection<? extends String> collection) { throw new UnsupportedOperationException(\"Key Set add all not allowed\"); }\n        @Override public boolean retainAll(Collection<?> collection) { throw new UnsupportedOperationException(\"Key Set retain all not allowed\"); }\n        @Override @SuppressWarnings(\"unchecked\")\n        public boolean removeAll(Collection<?> collection) {\n            return lsm.removeAllKeys(collection);\n        }\n        @Override public void clear() { throw new UnsupportedOperationException(\"Key Set clear not allowed\"); }\n    }\n\n    public static class ValueCollectionWrapper<V> implements Collection<V> {\n        private final LiteStringMap<V> lsm;\n        ValueCollectionWrapper(LiteStringMap<V> liteStringMap) { lsm = liteStringMap; }\n\n        @Override public int size() { return lsm.size(); }\n        @Override public boolean isEmpty() { return lsm.isEmpty(); }\n        @Override public boolean contains(Object o) { return lsm.containsValue(o); }\n        @Override public boolean containsAll(Collection<?> collection) {\n            if (collection == null || collection.isEmpty()) return true;\n            for (Object obj : collection) {\n                if (!lsm.containsValue(obj)) return false;\n            }\n            return true;\n        }\n\n        @Override public Iterator<V> iterator() { return new ValueIterator<V>(lsm); }\n\n        @Override public Object[] toArray() { return Arrays.copyOf(lsm.valueArray, lsm.lastIndex + 1); }\n        @Override public <T> T[] toArray(T[] ts) {\n            int toCopy = ts.length > lsm.lastIndex ? lsm.lastIndex + 1 : ts.length;\n            System.arraycopy(lsm.valueArray, 0, ts, 0, toCopy);\n            return ts;\n        }\n\n        @Override public boolean add(Object s) { throw new UnsupportedOperationException(\"Value Collection add not allowed\"); }\n        @Override public boolean remove(Object o) {\n            return lsm.removeValue(o);\n        }\n        @Override public boolean addAll(Collection<? extends V> collection) { throw new UnsupportedOperationException(\"Value Collection add all not allowed\"); }\n        @Override public boolean retainAll(Collection<?> collection) { throw new UnsupportedOperationException(\"Value Collection retain all not allowed\"); }\n        @Override public boolean removeAll(Collection<?> collection) {\n            return lsm.removeAllValues(collection);\n        }\n        @Override public void clear() { throw new UnsupportedOperationException(\"Value Collection clear not allowed\"); }\n    }\n\n    public static class EntryWrapper<V> implements Entry<String, V> {\n        private final LiteStringMap<V> lsm;\n        private final String key;\n        private int curIndex;\n\n        EntryWrapper(LiteStringMap<V> liteStringMap, int index) {\n            lsm = liteStringMap;\n            curIndex = index;\n            key = lsm.keyArray[index];\n        }\n\n        @Override public String getKey() { return key; }\n\n        @Override public V getValue() {\n            String keyCheck = lsm.keyArray[curIndex];\n            if (!Objects.equals(key, keyCheck)) curIndex = lsm.findIndex(key);\n            if (curIndex == -1) return null;\n            return lsm.valueArray[curIndex];\n        }\n        @Override public V setValue(V value) {\n            String keyCheck = lsm.keyArray[curIndex];\n            if (!Objects.equals(key, keyCheck)) curIndex = lsm.findIndex(key);\n            if (curIndex == -1) return lsm.put(key, value);\n            V oldValue = lsm.valueArray[curIndex];\n            lsm.valueArray[curIndex] = value;\n            return oldValue;\n        }\n    }\n    public static class EntrySetWrapper<V> implements Set<Entry<String, V>> {\n        private final LiteStringMap<V> lsm;\n        EntrySetWrapper(LiteStringMap<V> liteStringMap) { lsm = liteStringMap; }\n\n        @Override public int size() { return lsm.size(); }\n        @Override public boolean isEmpty() { return lsm.isEmpty(); }\n        @Override public boolean contains(Object obj) {\n            if (obj instanceof Entry) {\n                Entry entry = (Entry) obj;\n                Object keyObj = entry.getKey();\n                if (keyObj == null) return false;\n                int idx = lsm.findIndex(keyObj.toString());\n                if (idx == -1) return false;\n                Object entryValue = entry.getValue();\n                Object keyValue = lsm.valueArray[idx];\n                return Objects.equals(entryValue, keyValue);\n            } else {\n                return false;\n            }\n        }\n        @Override public Iterator<Entry<String, V>> iterator() {\n            ArrayList<Entry<String, V>> entryList = new ArrayList<>(lsm.lastIndex + 1);\n            for (int i = 0; i <= lsm.lastIndex; i++) {\n                if (lsm.getKey(i) == null) continue;\n                entryList.add(new EntryWrapper<V>(lsm, i));\n            }\n            return entryList.iterator();\n        }\n\n        @Override public V[] toArray() { throw new UnsupportedOperationException(\"Entry Set to array not supported\"); }\n        @Override public <T> T[] toArray(T[] ts) { throw new UnsupportedOperationException(\"Entry Set copy to array not supported\"); }\n\n        @Override\n        public boolean containsAll(Collection<?> collection) {\n            if (collection == null) return false;\n            for (Object obj : collection) if (obj == null || lsm.findIndex(obj.toString()) == -1) return false;\n            return true;\n        }\n\n        @Override public boolean add(Entry<String, V> entry) { throw new UnsupportedOperationException(\"Entry Set add not allowed\"); }\n        @Override public boolean remove(Object o) { throw new UnsupportedOperationException(\"Entry Set remove not allowed\"); }\n        @Override public boolean addAll(Collection<? extends Entry<String, V>> collection) { throw new UnsupportedOperationException(\"Entry Set add all not allowed\"); }\n        @Override public boolean retainAll(Collection<?> collection) { throw new UnsupportedOperationException(\"Entry Set retain all not allowed\"); }\n        @Override public boolean removeAll(Collection<?> collection) { throw new UnsupportedOperationException(\"Entry Set remove all not allowed\"); }\n        @Override public void clear() { throw new UnsupportedOperationException(\"Entry Set clear not allowed\"); }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/util/MClassLoader.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.util;\n\nimport java.io.*;\nimport java.net.MalformedURLException;\nimport java.net.URL;\nimport java.security.CodeSource;\nimport java.security.ProtectionDomain;\nimport java.security.cert.Certificate;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.jar.Attributes;\nimport java.util.jar.JarEntry;\nimport java.util.jar.JarFile;\nimport java.util.jar.Manifest;\n\n/**\n * A caching ClassLoader that allows addition of JAR files and class directories to the classpath at runtime.\n *\n * This loads resources from its class directories and JAR files first, then tries the parent. This is not the standard\n * approach, but needed for configuration in moqui/runtime and components to override other classpath resources.\n *\n * This loads classes from the parent first, then its class directories and JAR files.\n */\npublic class MClassLoader extends ClassLoader {\n    private static final boolean checkJars = false;\n    // rememberClassNotFound causes problems with Groovy that tries to load variations on class names, then creates them, then tries again\n    private static final boolean rememberClassNotFound = false;\n    private static final boolean rememberResourceNotFound = true;\n    // don't track known: with a few tool components in place uses 20MB memory and really doesn't help start/etc time much:\n    private static boolean trackKnown = false;\n\n    private static final Map<String, Class<?>> commonJavaClassesMap = createCommonJavaClassesMap();\n    private static Map<String, Class<?>> createCommonJavaClassesMap() {\n        Map<String, Class<?>> m = new HashMap<>();\n        m.put(\"java.lang.String\",java.lang.String.class); m.put(\"String\", java.lang.String.class);\n        m.put(\"java.lang.CharSequence\",java.lang.CharSequence.class); m.put(\"CharSequence\", java.lang.CharSequence.class);\n        m.put(\"java.sql.Timestamp\", java.sql.Timestamp.class); m.put(\"Timestamp\", java.sql.Timestamp.class);\n        m.put(\"java.sql.Time\", java.sql.Time.class); m.put(\"Time\", java.sql.Time.class);\n        m.put(\"java.sql.Date\", java.sql.Date.class); m.put(\"Date\", java.sql.Date.class);\n        m.put(\"java.util.Locale\", Locale.class); m.put(\"java.util.TimeZone\", TimeZone.class);\n        m.put(\"java.lang.Byte\", java.lang.Byte.class); m.put(\"java.lang.Character\", java.lang.Character.class);\n        m.put(\"java.lang.Integer\", java.lang.Integer.class); m.put(\"Integer\", java.lang.Integer.class);\n        m.put(\"java.lang.Long\", java.lang.Long.class); m.put(\"Long\", java.lang.Long.class);\n        m.put(\"java.lang.Short\", java.lang.Short.class);\n        m.put(\"java.lang.Float\", java.lang.Float.class); m.put(\"Float\", java.lang.Float.class);\n        m.put(\"java.lang.Double\", java.lang.Double.class); m.put(\"Double\", java.lang.Double.class);\n        m.put(\"java.math.BigDecimal\", java.math.BigDecimal.class); m.put(\"BigDecimal\", java.math.BigDecimal.class);\n        m.put(\"java.math.BigInteger\", java.math.BigInteger.class); m.put(\"BigInteger\", java.math.BigInteger.class);\n        m.put(\"java.lang.Boolean\", java.lang.Boolean.class); m.put(\"Boolean\", java.lang.Boolean.class);\n        m.put(\"java.lang.Object\", java.lang.Object.class); m.put(\"Object\", java.lang.Object.class);\n        m.put(\"java.sql.Blob\", java.sql.Blob.class); m.put(\"Blob\", java.sql.Blob.class);\n        m.put(\"java.nio.ByteBuffer\", java.nio.ByteBuffer.class);\n        m.put(\"java.sql.Clob\", java.sql.Clob.class); m.put(\"Clob\", java.sql.Clob.class);\n        m.put(\"java.util.Date\", Date.class);\n        m.put(\"java.util.Collection\", Collection.class); m.put(\"Collection\", Collection.class);\n        m.put(\"java.util.List\", List.class); m.put(\"List\", List.class);\n        m.put(\"java.util.ArrayList\", ArrayList.class); m.put(\"ArrayList\", ArrayList.class);\n        m.put(\"java.util.Map\", Map.class); m.put(\"Map\", Map.class); m.put(\"java.util.HashMap\", HashMap.class);\n        m.put(\"java.util.Set\", Set.class); m.put(\"Set\", Set.class); m.put(\"java.util.HashSet\", HashSet.class);\n        m.put(\"groovy.util.Node\", groovy.util.Node.class); m.put(\"Node\", groovy.util.Node.class);\n        m.put(\"org.moqui.util.MNode\", org.moqui.util.MNode.class); m.put(\"MNode\", org.moqui.util.MNode.class);\n        m.put(Boolean.TYPE.getName(), Boolean.TYPE); m.put(Short.TYPE.getName(), Short.TYPE);\n        m.put(Integer.TYPE.getName(), Integer.TYPE); m.put(Long.TYPE.getName(), Long.TYPE);\n        m.put(Float.TYPE.getName(), Float.TYPE); m.put(Double.TYPE.getName(), Double.TYPE);\n        m.put(Byte.TYPE.getName(), Byte.TYPE); m.put(Character.TYPE.getName(), Character.TYPE);\n        m.put(\"long[]\", long[].class); m.put(\"char[]\", char[].class);\n        return m;\n    }\n\n    public static Class<?> getCommonClass(String className) { return commonJavaClassesMap.get(className); }\n    public static void addCommonClass(String className, Class<?> cls) { commonJavaClassesMap.putIfAbsent(className, cls); }\n\n    private final ArrayList<JarFile> jarFileList = new ArrayList<>();\n    private final Map<String, URL> jarLocationByJarName = new HashMap<>();\n    private final ArrayList<File> classesDirectoryList = new ArrayList<>();\n    private final Map<String, String> jarByClass = new HashMap<>();\n\n    private final HashMap<String, File> knownClassFiles = new HashMap<>();\n    private final HashMap<String, JarEntryInfo> knownClassJarEntries = new HashMap<>();\n    private static class JarEntryInfo {\n        JarEntry entry; JarFile file; URL jarLocation;\n        JarEntryInfo(JarEntry je, JarFile jf, URL loc) { entry = je; file = jf; jarLocation = loc; }\n    }\n\n\n    // This Map contains either a Class or a ClassNotFoundException, cached for fast access because Groovy hits a LOT of\n    //     weird invalid class names resulting in expensive new ClassNotFoundException instances\n    private final ConcurrentHashMap<String, Class> classCache = new ConcurrentHashMap<>();\n    private final ConcurrentHashMap<String, ClassNotFoundException> notFoundCache = new ConcurrentHashMap<>();\n    private final ConcurrentHashMap<String, URL> resourceCache = new ConcurrentHashMap<>();\n    private final ConcurrentHashMap<String, ArrayList<URL>> resourceAllCache = new ConcurrentHashMap<>();\n    private final Set<String> resourcesNotFound = new HashSet<>();\n    private ProtectionDomain pd;\n\n    public MClassLoader(ClassLoader parent) {\n        super(parent);\n\n        if (parent == null) throw new IllegalArgumentException(\"Parent ClassLoader cannot be null\");\n        System.out.println(\"Starting MClassLoader with parent \" + parent.getClass().getName());\n\n        pd = getClass().getProtectionDomain();\n\n        for (Map.Entry<String, Class<?>> commonClassEntry: commonJavaClassesMap.entrySet())\n            classCache.put(commonClassEntry.getKey(), commonClassEntry.getValue());\n    }\n\n    public void addJarFile(JarFile jf, URL jarLocation) {\n        jarFileList.add(jf);\n        jarLocationByJarName.put(jf.getName(), jarLocation);\n\n        String jfName = jf.getName();\n        Enumeration<JarEntry> jeEnum = jf.entries();\n        while (jeEnum.hasMoreElements()) {\n            JarEntry je = jeEnum.nextElement();\n            if (je.isDirectory()) continue;\n            String jeName = je.getName();\n            if (!jeName.endsWith(\".class\")) continue;\n            String className = jeName.substring(0, jeName.length() - 6).replace('/', '.');\n\n            if (classCache.containsKey(className)) {\n                System.out.println(\"Ignoring duplicate class \" + className + \" in jar \" + jfName);\n                continue;\n            }\n            if (trackKnown) knownClassJarEntries.put(className, new JarEntryInfo(je, jf, jarLocation));\n\n            /* NOTE: can't do this as classes are defined out of order, end up with NoClassDefFoundError for dependencies:\n            Class<?> cls = makeClass(className, jf, je);\n            if (cls != null) classCache.put(className, cls);\n            */\n\n            if (checkJars) {\n                try {\n                    getParent().loadClass(className);\n                    System.out.println(\"Class \" + className + \" in jar \" + jfName + \" already loaded from parent ClassLoader\");\n                } catch (ClassNotFoundException e) { /* hoping class is not found! */ }\n                if (jarByClass.containsKey(className)) {\n                    System.out.println(\"Found class \" + className + \" in \\njar \" + jfName + \", already loaded from \\njar \" + jarByClass.get(className));\n                } else {\n                    jarByClass.put(className, jfName);\n                }\n            }\n        }\n    }\n    //List<JarFile> getJarFileList() { return jarFileList; }\n    //Map<String, Class> getClassCache() { return classCache; }\n    //Map<String, URL> getResourceCache() { return resourceCache; }\n\n    public void addClassesDirectory(File classesDir) {\n        if (!classesDir.exists()) throw new IllegalArgumentException(\"Classes directory [\" + classesDir + \"] does not exist.\");\n        if (!classesDir.isDirectory()) throw new IllegalArgumentException(\"Classes directory [\" + classesDir + \"] is not a directory.\");\n        classesDirectoryList.add(classesDir);\n        findClassFiles(\"\", classesDir);\n    }\n    private void findClassFiles(String pathSoFar, File dir) {\n        File[] children = dir.listFiles();\n        if (children == null) return;\n        String pathSoFarDot = pathSoFar.concat(\".\");\n        for (int i = 0; i < children.length; i++) {\n            File child = children[i];\n            String fileName = child.getName();\n            if (child.isDirectory()) {\n                findClassFiles(pathSoFarDot.concat(fileName), child);\n            } else if (fileName.endsWith(\".class\")) {\n                String className = pathSoFarDot.concat(fileName.substring(0, fileName.length() - 6));\n                if (knownClassFiles.containsKey(className)) {\n                    System.out.println(\"Ignoring duplicate class \" + className + \" at \" + child.getPath());\n                    continue;\n                }\n                if (trackKnown) knownClassFiles.put(className, child);\n\n                /* NOTE: can't do this as classes are defined out of order, end up with NoClassDefFoundError for dependencies:\n                Class<?> cls = makeClass(className, child);\n                if (cls != null) classCache.put(className, cls);\n                */\n            }\n        }\n    }\n\n    public void clearNotFoundInfo() {\n        notFoundCache.clear();\n        resourcesNotFound.clear();\n    }\n\n\n    /** @see java.lang.ClassLoader#getResource(String) */\n    @Override\n    public URL getResource(String name) {\n        return findResource(name);\n    }\n\n    /** @see java.lang.ClassLoader#getResources(String) */\n    @Override\n    public Enumeration<URL> getResources(String name) throws IOException {\n        return findResources(name);\n    }\n\n    /** @see java.lang.ClassLoader#findResource(java.lang.String) */\n    @Override\n    protected URL findResource(String resourceName) {\n        URL cachedUrl = resourceCache.get(resourceName);\n        if (cachedUrl != null) return cachedUrl;\n        if (rememberResourceNotFound && resourcesNotFound.contains(resourceName)) return null;\n\n        // Groovy looks for BeanInfo and Customizer groovy resources, even for anonymous scripts and they will never exist\n        if (rememberResourceNotFound) {\n            if ((resourceName.endsWith(\"BeanInfo.groovy\") || resourceName.endsWith(\"Customizer.groovy\")) &&\n                    (resourceName.startsWith(\"script\") || resourceName.contains(\"_actions\") || resourceName.contains(\"_condition\"))) {\n                resourcesNotFound.add(resourceName);\n                return null;\n            }\n        }\n\n        URL resourceUrl = null;\n\n        int classesDirectoryListSize = classesDirectoryList.size();\n        for (int i = 0; i < classesDirectoryListSize; i++) {\n            File classesDir = classesDirectoryList.get(i);\n            File testFile = new File(classesDir.getAbsolutePath() + \"/\" + resourceName);\n            try {\n                if (testFile.exists() && testFile.isFile()) resourceUrl = testFile.toURI().toURL();\n            } catch (MalformedURLException e) {\n                System.out.println(\"Error making URL for [\" + resourceName + \"] in classes directory [\" + classesDir + \"]: \" + e.toString());\n            }\n        }\n\n        if (resourceUrl == null) {\n            int jarFileListSize = jarFileList.size();\n            for (int i = 0; i < jarFileListSize; i++) {\n                JarFile jarFile = jarFileList.get(i);\n                JarEntry jarEntry = jarFile.getJarEntry(resourceName);\n                if (jarEntry != null) {\n                    try {\n                        String jarFileName = jarFile.getName();\n                        if (jarFileName.contains(\"\\\\\")) jarFileName = jarFileName.replace('\\\\', '/');\n                        resourceUrl = new URL(\"jar:file:\" + jarFileName + \"!/\" + jarEntry);\n                    } catch (MalformedURLException e) {\n                        System.out.println(\"Error making URL for [\" + resourceName + \"] in jar [\" + jarFile + \"]: \" + e.toString());\n                    }\n                }\n            }\n        }\n\n        if (resourceUrl == null) {\n            // NOTE: it is weird for any ClassLoader to throw exceptions for valid resource names, but\n            //   org.eclipse.jetty.webapp.WebAppClassLoader does just that as part of\n            //   org.eclipse.jetty.webapp.WebAppContext.isServerResource(WebAppContext.java:816)\n            // As a workaround catch that exception, try the grand-parent classloader, and move on...\n            try {\n                resourceUrl = getParent().getResource(resourceName);\n            } catch (Throwable t) {\n                System.out.println(\"Error in findResource() in parent classloader \" + getParent().getClass().getCanonicalName() + \" for name [\" + resourceName + \"]: \" + t.toString());\n                // t.printStackTrace();\n\n                // try grand-parent classloader if there is one\n                ClassLoader grandParent = getParent().getParent();\n                if (grandParent != null) {\n                    try {\n                        resourceUrl = grandParent.getResource(resourceName);\n                        if (resourceUrl != null) System.out.println(\"Found \" + resourceName + \" in grand-parent ClassLoader \" + grandParent.getClass().getCanonicalName());\n                    } catch (Throwable t2) {\n                        System.out.println(\"Error in findResource() in grand-parent classloader \" + grandParent.getClass().getCanonicalName() + \" for name [\" + resourceName + \"]: \" + t2.toString());\n                    }\n                }\n            }\n        }\n        if (resourceUrl != null) {\n            // System.out.println(\"finding resource \" + resourceName + \" got \" + resourceUrl.toExternalForm());\n            URL existingUrl = resourceCache.putIfAbsent(resourceName, resourceUrl);\n            if (existingUrl != null) return existingUrl;\n            else return resourceUrl;\n        } else {\n            // for testing to see if resource not found cache is working, should see this once for each not found resource\n            // System.out.println(\"Classpath resource not found with name \" + resourceName);\n            if (rememberResourceNotFound) resourcesNotFound.add(resourceName);\n            return null;\n        }\n    }\n\n    /** @see java.lang.ClassLoader#findResources(java.lang.String) */\n    @Override\n    public Enumeration<URL> findResources(String resourceName) throws IOException {\n        ArrayList<URL> cachedUrls = resourceAllCache.get(resourceName);\n        if (cachedUrls != null) return Collections.enumeration(cachedUrls);\n\n        ArrayList<URL> urlList = new ArrayList<>();\n        int classesDirectoryListSize = classesDirectoryList.size();\n        for (int i = 0; i < classesDirectoryListSize; i++) {\n            File classesDir = classesDirectoryList.get(i);\n            File testFile = new File(classesDir.getAbsolutePath() + \"/\" + resourceName);\n            try {\n                if (testFile.exists() && testFile.isFile()) urlList.add(testFile.toURI().toURL());\n            } catch (MalformedURLException e) {\n                System.out.println(\"Error making URL for [\" + resourceName + \"] in classes directory [\" + classesDir + \"]: \" + e.toString());\n            }\n        }\n        int jarFileListSize = jarFileList.size();\n        for (int i = 0; i < jarFileListSize; i++) {\n            JarFile jarFile = jarFileList.get(i);\n            JarEntry jarEntry = jarFile.getJarEntry(resourceName);\n            if (jarEntry != null) {\n                try {\n                    String jarFileName = jarFile.getName();\n                    if (jarFileName.contains(\"\\\\\")) jarFileName = jarFileName.replace('\\\\', '/');\n                    urlList.add(new URL(\"jar:file:\" + jarFileName + \"!/\" + jarEntry));\n                } catch (MalformedURLException e) {\n                    System.out.println(\"Error making URL for [\" + resourceName + \"] in jar [\" + jarFile + \"]: \" + e.toString());\n                }\n            }\n        }\n\n        // add all resources found in parent loader too\n        // NOTE: it is weird for any ClassLoader to throw exceptions for valid resource names, but\n        //   org.eclipse.jetty.webapp.WebAppClassLoader does just that as part of\n        //   org.eclipse.jetty.webapp.WebAppContext.isServerResource(WebAppContext.java:816)\n        // As a workaround catch that exception, try the grand-parent classloader, and move on...\n        try {\n            Enumeration<URL> superResources = getParent().getResources(resourceName);\n            while (superResources.hasMoreElements()) urlList.add(superResources.nextElement());\n        } catch (Throwable t) {\n            System.out.println(\"Error in findResources() in parent classloader \" + getParent().getClass().getCanonicalName() + \" for name [\" + resourceName + \"]: \" + t.toString());\n            // t.printStackTrace();\n\n            // try grand-parent classloader if there is one\n            ClassLoader grandParent = getParent().getParent();\n            if (grandParent != null) {\n                try {\n                    Enumeration<URL> superResources = grandParent.getResources(resourceName);\n                    while (superResources.hasMoreElements()) urlList.add(superResources.nextElement());\n                } catch (Throwable t2) {\n                    System.out.println(\"Error in findResources() in grand-parent classloader \" + grandParent.getClass().getCanonicalName() + \" for name [\" + resourceName + \"]: \" + t2.toString());\n                }\n            }\n        }\n\n        resourceAllCache.putIfAbsent(resourceName, urlList);\n        // System.out.println(\"finding all resources with name \" + resourceName + \" got \" + urlList);\n        return Collections.enumeration(urlList);\n    }\n\n    /** @see java.lang.ClassLoader#getResourceAsStream(String) */\n    @Override\n    public InputStream getResourceAsStream(String name) {\n        URL resourceUrl = findResource(name);\n        if (resourceUrl == null) {\n            // System.out.println(\"Classpath resource not found with name \" + name);\n            return null;\n        }\n        try {\n            return resourceUrl.openStream();\n        } catch (IOException e) {\n            System.out.println(\"Error opening stream for classpath resource \" + name + \": \" + e.toString());\n            return null;\n        }\n    }\n\n    @Override\n    public Class<?> loadClass(String name) throws ClassNotFoundException { return loadClass(name, false); }\n\n    @Override\n    protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {\n        Class cachedClass = classCache.get(className);\n        if (cachedClass != null) return cachedClass;\n        if (rememberClassNotFound) {\n            ClassNotFoundException cachedExc = notFoundCache.get(className);\n            if (cachedExc != null) throw cachedExc;\n        }\n\n        return loadClassInternal(className, resolve);\n    }\n\n    // private static final ArrayList<String> ignoreSuffixes = new ArrayList<>(Arrays.asList(\"Customizer\", \"BeanInfo\"));\n    // private static final int ignoreSuffixesSize = ignoreSuffixes.size();\n    // TODO: does this need synchronized? slows it down...\n    private Class<?> loadClassInternal(String className, boolean resolve) throws ClassNotFoundException {\n        /* This may not be a good idea, Groovy looks for all sorts of bogus class name but there may be a reason so not doing this or looking for other patterns:\n        for (int i = 0; i < ignoreSuffixesSize; i++) {\n            String ignoreSuffix = ignoreSuffixes.get(i);\n            if (className.endsWith(ignoreSuffix)) {\n                ClassNotFoundException cfne = new ClassNotFoundException(\"Ignoring Groovy style bogus class name \" + className);\n                classCache.put(className, cfne);\n                throw cfne;\n            }\n        }\n        */\n\n        Class<?> c = null;\n        try {\n            // classes handled opposite of resources, try parent chain first (avoid java.lang.LinkageError)\n            ClassLoader cl = getParent();\n            int depth = 0;\n            /* When in jetty embedded mode (MoquiStart) first try\n             * org.eclipse.jetty.ee.webapp.WebAppClassLoader\n             * then MoquiStart.StartClassLoader. If in a servlet container, then\n             * use the classloader parents provided by that container. */\n            while (cl != null && depth < 2) {\n                try {\n                    c = cl.loadClass(className);\n                    break;\n                } catch (ClassNotFoundException|NoClassDefFoundError e) {\n                    cl = cl.getParent();\n                    depth++;\n                } catch (RuntimeException e) {\n                    e.printStackTrace();\n                    throw e;\n                }\n            }\n\n            // now try MClassLoader if parents fail to load\n            if (c == null) {\n                try {\n                    if (trackKnown) {\n                        File classFile = knownClassFiles.get(className);\n                        if (classFile != null) c = makeClass(className, classFile);\n                        if (c == null) {\n                            JarEntryInfo jei = knownClassJarEntries.get(className);\n                            if (jei != null) c = makeClass(className, jei.file, jei.entry, jei.jarLocation);\n                        }\n                    }\n\n                    // not found in known? search through all\n                    c = findJarClass(className);\n                } catch (Exception e) {\n                    System.out.println(\"Error loading class [\" + className + \"] from additional jars: \" + e.toString());\n                    e.printStackTrace();\n                }\n            }\n\n            // System.out.println(\"Loading class name [\" + className + \"] got class: \" + c);\n            if (c == null) {\n                ClassNotFoundException cnfe = new ClassNotFoundException(\"Class \" + className + \" not found.\");\n                if (rememberClassNotFound) {\n                    // Groovy seems to look, then re-look, for funny names like:\n                    //     groovy.lang.GroovyObject$java$io$org$moqui$entity$EntityListIterator\n                    //     java.io.org$moqui$entity$EntityListIterator\n                    //     groovy.util.org$moqui$context$ExecutionContext\n                    //     org$moqui$context$ExecutionContext\n                    // Groovy does similar with *Customizer and *BeanInfo; so just don't remember any of these\n                    // In general it seems that anything with a '$' needs to be excluded\n                    if (!className.contains(\"$\") && !className.endsWith(\"Customizer\") && !className.endsWith(\"BeanInfo\")) {\n                        ClassNotFoundException existingExc = notFoundCache.putIfAbsent(className, cnfe);\n                        if (existingExc != null) throw existingExc;\n                    }\n                }\n                throw cnfe;\n            } else {\n                classCache.put(className, c);\n            }\n            return c;\n        } finally {\n            if (c != null && resolve) resolveClass(c);\n        }\n    }\n\n    private ConcurrentHashMap<URL, ProtectionDomain> protectionDomainByUrl = new ConcurrentHashMap<>();\n    private ProtectionDomain getProtectionDomain(URL jarLocation) {\n        ProtectionDomain curPd = protectionDomainByUrl.get(jarLocation);\n        if (curPd != null) return curPd;\n        CodeSource codeSource = new CodeSource(jarLocation, (Certificate[]) null);\n        ProtectionDomain newPd = new ProtectionDomain(codeSource, null, this, null);\n        ProtectionDomain existingPd = protectionDomainByUrl.putIfAbsent(jarLocation, newPd);\n        return existingPd != null ? existingPd : newPd;\n    }\n\n    private Class<?> makeClass(String className, File classFile) {\n        try {\n            byte[] jeBytes = getFileBytes(classFile);\n            if (jeBytes == null) {\n                System.out.println(\"Could not get bytes for \" + classFile);\n                return null;\n            }\n            return defineClass(className, jeBytes, 0, jeBytes.length, pd);\n        } catch (Throwable t) {\n            System.out.println(\"Error reading class file \" + classFile + \": \" + t.toString());\n            return null;\n        }\n    }\n    private Class<?> makeClass(String className, JarFile file, JarEntry entry, URL jarLocation) {\n        try {\n            definePackage(className, file);\n            byte[] jeBytes = getJarEntryBytes(file, entry);\n            if (jeBytes == null) {\n                System.out.println(\"Could not get bytes for entry \" + entry.getName() + \" in jar\" + file.getName());\n                return null;\n            } else {\n                // System.out.println(\"Loading class \" + className + \" from \" + entry.getName() + \" in \" + file.getName());\n                return defineClass(className, jeBytes, 0, jeBytes.length, jarLocation != null ? getProtectionDomain(jarLocation) : pd);\n            }\n        } catch (Throwable t) {\n            System.out.println(\"Error reading class file \" + entry.getName() + \" in jar\" + file.getName() + \": \" + t.toString());\n            return null;\n        }\n    }\n    @SuppressWarnings(\"ThrowFromFinallyBlock\")\n    private byte[] getJarEntryBytes(JarFile jarFile, JarEntry je) throws IOException {\n        DataInputStream dis = null;\n        byte[] jeBytes = null;\n        try {\n            long lSize = je.getSize();\n            if (lSize <= 0 || lSize >= Integer.MAX_VALUE)\n                throw new IllegalArgumentException(\"Size [\" + lSize + \"] not valid for jar entry [\" + je + \"]\");\n            jeBytes = new byte[(int) lSize];\n            InputStream is = jarFile.getInputStream(je);\n            dis = new DataInputStream(is);\n            dis.readFully(jeBytes);\n        } finally {\n            if (dis != null) dis.close();\n        }\n        return jeBytes;\n    }\n\n    @SuppressWarnings(\"ThrowFromFinallyBlock\")\n    private byte[] getFileBytes(File classFile) throws IOException {\n        DataInputStream dis = null;\n        byte[] jeBytes = null;\n        try {\n            long lSize = classFile.length();\n            if (lSize <= 0  ||  lSize >= Integer.MAX_VALUE) {\n                throw new IllegalArgumentException(\"Size [\" + lSize + \"] not valid for classpath file [\" + classFile + \"]\");\n            }\n            jeBytes = new byte[(int)lSize];\n            InputStream is = new FileInputStream(classFile);\n            dis = new DataInputStream(is);\n            dis.readFully(jeBytes);\n        } finally {\n            if (dis != null) dis.close();\n        }\n        return jeBytes;\n    }\n\n    private Class<?> findJarClass(String className) throws IOException, ClassFormatError, ClassNotFoundException {\n        Class cachedClass = classCache.get(className);\n        if (cachedClass != null) return cachedClass;\n        if (rememberClassNotFound) {\n            ClassNotFoundException cachedExc = notFoundCache.get(className);\n            if (cachedExc != null) throw cachedExc;\n        }\n\n        Class<?> c = null;\n        String classFileName = className.replace('.', '/').concat(\".class\");\n\n        int classesDirectoryListSize = classesDirectoryList.size();\n        for (int i = 0; i < classesDirectoryListSize; i++) {\n            File classesDir = classesDirectoryList.get(i);\n            File testFile = new File(classesDir.getAbsolutePath() + \"/\" + classFileName);\n            if (testFile.exists() && testFile.isFile()) {\n                c = makeClass(className, testFile);\n                if (c != null) break;\n            }\n        }\n\n        if (c == null) {\n            int jarFileListSize = jarFileList.size();\n            for (int i = 0; i < jarFileListSize; i++) {\n                JarFile jarFile = jarFileList.get(i);\n                // System.out.println(\"Finding class file \" + classFileName + \" in jar file \" + jarFile.getName());\n                JarEntry jarEntry = jarFile.getJarEntry(classFileName);\n                if (jarEntry != null) {\n                    c = makeClass(className, jarFile, jarEntry, jarLocationByJarName.get(jarFile.getName()));\n                    break;\n                }\n            }\n        }\n\n        // down here only cache if found\n        if (c != null) {\n            Class existingClass = classCache.putIfAbsent(className, c);\n            if (existingClass != null) return existingClass;\n            else return c;\n        } else {\n            return null;\n        }\n    }\n\n    private void definePackage(String className, JarFile jarFile) throws IllegalArgumentException {\n        Manifest mf = null;\n        try {\n            mf = jarFile.getManifest();\n        } catch (IOException e) {\n            System.out.println(\"Error getting manifest from \" + jarFile.getName() + \": \" + e.toString());\n        }\n        // if no manifest use default\n        if (mf == null) mf = new Manifest();\n\n        int dotIndex = className.lastIndexOf('.');\n        String packageName = dotIndex > 0 ? className.substring(0, dotIndex) : \"\";\n        // NOTE: for Java 11 changed getPackage() to getDefinedPackage(), can't do before because getDefinedPackage() doesn't exist in Java 8\n        if (getDefinedPackage(packageName) == null) {\n            definePackage(packageName,\n                    mf.getMainAttributes().getValue(Attributes.Name.SPECIFICATION_TITLE),\n                    mf.getMainAttributes().getValue(Attributes.Name.SPECIFICATION_VERSION),\n                    mf.getMainAttributes().getValue(Attributes.Name.SPECIFICATION_VENDOR),\n                    mf.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_TITLE),\n                    mf.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VERSION),\n                    mf.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VENDOR),\n                    getSealURL(mf));\n        }\n    }\n\n    private URL getSealURL(Manifest mf) {\n        String seal = mf.getMainAttributes().getValue(Attributes.Name.SEALED);\n        if (seal == null) return null;\n        try {\n            return new URL(seal);\n        } catch (MalformedURLException e) {\n            return null;\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/util/MNode.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.util;\n\nimport freemarker.ext.beans.BeansWrapper;\nimport freemarker.ext.beans.BeansWrapperBuilder;\nimport freemarker.template.*;\nimport groovy.lang.Closure;\nimport groovy.util.Node;\nimport groovy.util.NodeList;\n\nimport org.moqui.BaseException;\nimport org.moqui.resource.ResourceReference;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.xml.sax.Attributes;\nimport org.xml.sax.InputSource;\nimport org.xml.sax.Locator;\nimport org.xml.sax.XMLReader;\nimport org.xml.sax.helpers.DefaultHandler;\n\nimport javax.xml.parsers.SAXParserFactory;\nimport java.io.*;\nimport java.nio.file.Files;\nimport java.util.*;\nimport java.util.concurrent.ConcurrentHashMap;\n\nimport static java.nio.charset.StandardCharsets.UTF_8;\n\n/** An alternative to groovy.util.Node with methods more type safe and generally useful in Moqui. */\n@SuppressWarnings(\"unused\")\npublic class MNode implements TemplateNodeModel, TemplateSequenceModel, TemplateHashModelEx, AdapterTemplateModel, TemplateScalarModel {\n    protected final static Logger logger = LoggerFactory.getLogger(MNode.class);\n    private static final Version FTL_VERSION = Configuration.VERSION_2_3_34;\n\n    private final static Map<String, MNode> parsedNodeCache = new HashMap<>();\n    public static void clearParsedNodeCache() { parsedNodeCache.clear(); }\n\n    /* ========== Factories (XML Parsing) ========== */\n\n    public static MNode parse(ResourceReference rr) throws BaseException {\n        if (rr == null || (rr.supportsExists() && !rr.getExists())) return null;\n        String location = rr.getLocation();\n        MNode cached = parsedNodeCache.get(location);\n        if (cached != null && cached.lastModified >= rr.getLastModified()) return cached;\n\n        MNode node = parse(location, rr.openStream());\n        node.lastModified = rr.getLastModified();\n        if (node.lastModified > 0) parsedNodeCache.put(location, node);\n        return node;\n    }\n    /** Parse from an InputStream and close the stream */\n    public static MNode parse(String location, InputStream is) throws BaseException {\n        if (is == null) return null;\n        try {\n            return parse(location, new InputSource(new InputStreamReader(is, UTF_8)));\n        } finally {\n            try { is.close(); }\n            catch (IOException e) { logger.error(\"Error closing XML stream from \" + location, e); }\n        }\n    }\n    public static MNode parse(File fl) throws BaseException {\n        if (fl == null || !fl.exists()) return null;\n\n        String location = fl.getPath();\n        MNode cached = parsedNodeCache.get(location);\n        if (cached != null && cached.lastModified >= fl.lastModified()) return cached;\n\n        BufferedReader fr = null;\n        try {\n            fr = Files.newBufferedReader(fl.toPath(), UTF_8); // new FileReader(fl);\n            MNode node = parse(fl.getPath(), new InputSource(fr));\n            node.lastModified = fl.lastModified();\n            if (node.lastModified > 0) parsedNodeCache.put(location, node);\n            return node;\n        } catch (Exception e) {\n            throw new BaseException(\"Error parsing XML file at \" + fl.getPath(), e);\n        } finally {\n            try { if (fr != null) fr.close(); }\n            catch (IOException e) { logger.error(\"Error closing XML file at \" + fl.getPath(), e); }\n        }\n    }\n    public static MNode parseText(String location, String text) throws BaseException {\n        if (text == null || text.length() == 0) return null;\n        return parse(location, new InputSource(new StringReader(text)));\n    }\n\n    public static MNode parse(String location, InputSource isrc) {\n        try {\n            MNodeXmlHandler xmlHandler = new MNodeXmlHandler(false, location);\n            XMLReader reader = SAXParserFactory.newInstance().newSAXParser().getXMLReader();\n            reader.setContentHandler(xmlHandler);\n            reader.parse(isrc);\n            return xmlHandler.getRootNode();\n        } catch (Exception e) {\n            throw new BaseException(\"Error parsing XML from \" + location, e);\n        }\n    }\n\n    public static MNode parseRootOnly(ResourceReference rr) {\n        InputStream is = rr.openStream();\n        if (is == null) return null;\n        try {\n            return parseRootOnly(rr.getLocation(), new InputSource(is));\n        } finally {\n            if (is != null) {\n                try { is.close(); }\n                catch (IOException e) { logger.error(\"Error closing XML stream from \" + rr.getLocation(), e); }\n            }\n        }\n    }\n    public static MNode parseRootOnly(String location, InputSource isrc) {\n        try {\n            MNodeXmlHandler xmlHandler = new MNodeXmlHandler(true, location);\n            XMLReader reader = SAXParserFactory.newInstance().newSAXParser().getXMLReader();\n            reader.setContentHandler(xmlHandler);\n            reader.parse(isrc);\n            return xmlHandler.getRootNode();\n        } catch (Exception e) {\n            throw new BaseException(\"Error parsing XML from \" + location, e);\n        }\n    }\n\n    /* ========== Fields ========== */\n\n    private String nodeName;\n    // NOTE: start with small capacity, optimize for memory use vs put overhead to grow as mostly used for config kept long term\n    private final Map<String, String> attributeMap = new LinkedHashMap<>(4);\n    private MNode parentNode = null;\n    private ArrayList<MNode> childList = null;\n    private Map<String, ArrayList<MNode>> childrenByName = null;\n    private String childText = null;\n    private long lastModified = 0;\n    private boolean systemExpandAttributes = false;\n    private String fileLocation = null;\n\n    /* ========== Constructors ========== */\n\n    public MNode(Node node) {\n        nodeName = (String) node.name();\n        Set attrEntries = node.attributes().entrySet();\n        for (Object entryObj : attrEntries) if (entryObj instanceof Map.Entry) {\n            Map.Entry entry = (Map.Entry) entryObj;\n            if (entry.getKey() != null)\n                attributeMap.put(entry.getKey().toString(), entry.getValue() != null ? entry.getValue().toString() : null);\n        }\n        for (Object childObj : node.children()) {\n            if (childObj instanceof Node) {\n                append((Node) childObj);\n            } else if (childObj instanceof NodeList) {\n                NodeList nl = (NodeList) childObj;\n                for (Object nlEntry : nl) {\n                    if (nlEntry instanceof Node) {\n                        append((Node) nlEntry);\n                    }\n                }\n            }\n        }\n        childText = gnodeText(node);\n        if (childText != null && childText.trim().length() == 0) childText = null;\n\n        // if (\"entity\".equals(nodeName)) logger.info(\"Groovy Node:\\n\" + node + \"\\n MNode:\\n\" + toString());\n    }\n    public MNode(String name, Map<String, String> attributes, MNode parent, List<MNode> children, String text) {\n        nodeName = name;\n        if (attributes != null) attributeMap.putAll(attributes);\n        parentNode = parent;\n        if (children != null && children.size() > 0) {\n            childList = new ArrayList<>();\n            childList.addAll(children);\n        }\n        if (text != null && text.trim().length() > 0) childText = text;\n    }\n    public MNode(String name, Map<String, String> attributes) {\n        nodeName = name;\n        if (attributes != null) attributeMap.putAll(attributes);\n    }\n\n    public MNode setFileLocation(String location) { fileLocation = location; return this; }\n    public String getFileLocation() { return fileLocation; }\n\n    /* ========== Get Methods ========== */\n\n    /** If name starts with an ampersand (@) then get an attribute, otherwise get a list of child nodes with the given name. */\n    public Object getObject(String name) {\n        if (name != null && name.length() > 0 && name.charAt(0) == '@') {\n            return attribute(name.substring(1));\n        } else {\n            return children(name);\n        }\n    }\n    /** Groovy specific method for square brace syntax */\n    public Object getAt(String name) { return getObject(name); }\n\n    public String getName() { return nodeName; }\n    public void setName(String name) {\n        if (parentNode != null && parentNode.childrenByName != null) {\n            parentNode.childrenByName.remove(name);\n            parentNode.childrenByName.remove(nodeName);\n        }\n        nodeName = name;\n    }\n    public Map<String, String> getAttributes() { return attributeMap; }\n    public String attribute(String attrName) {\n        String attrValue = attributeMap.get(attrName);\n        if (systemExpandAttributes && attrValue != null && attrValue.contains(\"${\")) {\n            attrValue = SystemBinding.expand(attrValue);\n            // system properties and environment variables don't generally change once initial init is done, so save expanded value\n            attributeMap.put(attrName, attrValue);\n        }\n        return attrValue;\n    }\n    public void setSystemExpandAttributes(boolean b) { systemExpandAttributes = b; }\n\n    public MNode getParent() { return parentNode; }\n\n    public boolean hasAncestor(String nodeName) {\n        if (parentNode == null) return false;\n        if (nodeName == null || nodeName.isEmpty() || nodeName.equals(parentNode.nodeName)) return true;\n        return parentNode.hasAncestor(nodeName);\n    }\n\n    public ArrayList<MNode> getChildren() {\n        if (childList == null) childList = new ArrayList<>();\n        return childList;\n    }\n    public ArrayList<MNode> children(String name) {\n        if (childList == null) childList = new ArrayList<>(4);\n        if (childrenByName == null) childrenByName = new HashMap<>(4);\n        if (name == null) return childList;\n        ArrayList<MNode> curList = childrenByName.get(name);\n        if (curList != null) return curList;\n\n        curList = new ArrayList<>();\n        int childListSize = childList.size();\n        for (int i = 0; i < childListSize; i++) {\n            MNode curChild = childList.get(i);\n            if (name.equals(curChild.nodeName)) curList.add(curChild);\n        }\n        childrenByName.put(name, curList);\n        return curList;\n    }\n    public ArrayList<MNode> children(String name, String... attrNamesValues) {\n        int attrNvLength = attrNamesValues.length;\n        if (attrNvLength % 2 != 0) throw new IllegalArgumentException(\"Must pass an even number of attribute name/value strings\");\n        ArrayList<MNode> fullList = children(name);\n        ArrayList<MNode> filteredList = new ArrayList<>();\n        int fullListSize = fullList.size();\n        for (int i = 0; i < fullListSize; i++) {\n            MNode node = fullList.get(i);\n            boolean allEqual = true;\n            for (int j = 0; j < attrNvLength; j += 2) {\n                String attrValue = node.attribute(attrNamesValues[j]);\n                String argValue = attrNamesValues[j+1];\n                if (attrValue == null) {\n                    if (argValue != null) {\n                        allEqual = false;\n                        break;\n                    }\n                } else {\n                    if (!attrValue.equals(argValue)) {\n                        allEqual = false;\n                        break;\n                    }\n                }\n            }\n            if (allEqual) filteredList.add(node);\n        }\n        return filteredList;\n    }\n    public ArrayList<MNode> children(Closure<Boolean> condition) {\n        ArrayList<MNode> curList = new ArrayList<>();\n        if (childList == null) return curList;\n        int childListSize = childList.size();\n        for (int i = 0; i < childListSize; i++) {\n            MNode curChild = childList.get(i);\n            if (condition == null || condition.call(curChild)) curList.add(curChild);\n        }\n        return curList;\n    }\n    public boolean hasChild(String name) {\n        if (childList == null) return false;\n        if (name == null) return false;\n        if (childrenByName != null) {\n            ArrayList<MNode> curList = childrenByName.get(name);\n            if (curList != null && curList.size() > 0) return true;\n        }\n\n        int childListSize = childList.size();\n        for (int i = 0; i < childListSize; i++) {\n            MNode curChild = childList.get(i);\n            if (name.equals(curChild.nodeName)) return true;\n        }\n        return false;\n    }\n    /** Get child at index, will throw an exception if index out of bounds */\n    public MNode child(int index) { return childList.get(index); }\n\n    public Map<String, ArrayList<MNode>> getChildrenByName() {\n        Map<String, ArrayList<MNode>> allByName = new HashMap<>(4);\n        if (childList == null) return allByName;\n        int childListSize = childList.size();\n        if (childListSize == 0) return allByName;\n        if (childrenByName == null) childrenByName = new HashMap<>(4);\n\n        ArrayList<String> newChildNames = new ArrayList<>();\n        for (int i = 0; i < childListSize; i++) {\n            MNode curChild = childList.get(i);\n            String name = curChild.nodeName;\n            ArrayList<MNode> existingList = childrenByName.get(name);\n            if (existingList != null) {\n                if (existingList.size() > 0 && !allByName.containsKey(name)) allByName.put(name, existingList);\n                continue;\n            }\n\n            ArrayList<MNode> curList = allByName.get(name);\n            if (curList == null) {\n                curList = new ArrayList<>();\n                allByName.put(name, curList);\n                newChildNames.add(name);\n            }\n            curList.add(curChild);\n        }\n        // since we got all children by name save them for future use\n        int newChildNamesSize = newChildNames.size();\n        for (int i = 0; i < newChildNamesSize; i++) {\n            String newChildName = newChildNames.get(i);\n            childrenByName.put(newChildName, allByName.get(newChildName));\n        }\n        childrenByName.putAll(allByName);\n        return allByName;\n    }\n\n    /** Search all descendants for nodes matching any of the names, return a Map with a List for each name with nodes\n     * found or empty List if no nodes found */\n    public Map<String, ArrayList<MNode>> descendants(Set<String> names) {\n        Map<String, ArrayList<MNode>> nodes = new HashMap<>(names.size());\n        for (String name : names) nodes.put(name, new ArrayList<>());\n        descendants(names, nodes);\n        return nodes;\n    }\n    public void descendants(Set<String> names, Map<String, ArrayList<MNode>> nodes) {\n        if (childList == null) return;\n\n        int childListSize = childList.size();\n        for (int i = 0; i < childListSize; i++) {\n            MNode curChild = childList.get(i);\n            if (names == null || names.contains(curChild.nodeName)) {\n                ArrayList<MNode> curList = nodes.get(curChild.nodeName);\n                if (curList == null) {\n                    curList = new ArrayList<>();\n                    nodes.put(curChild.nodeName, curList);\n                }\n                curList.add(curChild);\n            }\n            curChild.descendants(names, nodes);\n        }\n    }\n    public ArrayList<MNode> descendants(String name) {\n        ArrayList<MNode> nodes = new ArrayList<>();\n        descendantsInternal(name, nodes);\n        return nodes;\n    }\n    private void descendantsInternal(String name, ArrayList<MNode> nodes) {\n        if (childList == null) return;\n\n        int childListSize = childList.size();\n        for (int i = 0; i < childListSize; i++) {\n            MNode curChild = childList.get(i);\n            if (name == null || name.equals(curChild.nodeName)) {\n                nodes.add(curChild);\n            }\n            curChild.descendantsInternal(name, nodes);\n        }\n    }\n\n    public ArrayList<MNode> depthFirst(Closure<Boolean> condition) {\n        ArrayList<MNode> curList = new ArrayList<>();\n        depthFirstInternal(condition, curList);\n        return curList;\n    }\n    private void depthFirstInternal(Closure<Boolean> condition, ArrayList<MNode> curList) {\n        if (childList == null) return;\n\n        int childListSize = childList.size();\n        // all grand-children first\n        for (int i = 0; i < childListSize; i++) {\n            MNode curChild = childList.get(i);\n            curChild.depthFirstInternal(condition, curList);\n        }\n        // then children\n        for (int i = 0; i < childListSize; i++) {\n            MNode curChild = childList.get(i);\n            if (condition == null || condition.call(curChild)) curList.add(curChild);\n        }\n    }\n    public ArrayList<MNode> breadthFirst(Closure<Boolean> condition) {\n        ArrayList<MNode> curList = new ArrayList<>();\n        breadthFirstInternal(condition, curList);\n        return curList;\n    }\n    private void breadthFirstInternal(Closure<Boolean> condition, ArrayList<MNode> curList) {\n        if (childList == null) return;\n\n        int childListSize = childList.size();\n        // direct children first\n        for (int i = 0; i < childListSize; i++) {\n            MNode curChild = childList.get(i);\n            if (condition == null || condition.call(curChild)) curList.add(curChild);\n        }\n        // then grand-children\n        for (int i = 0; i < childListSize; i++) {\n            MNode curChild = childList.get(i);\n            curChild.breadthFirstInternal(condition, curList);\n        }\n    }\n\n    /** Get the first child node */\n    public MNode first() {\n        if (childList == null) return null;\n        return childList.size() > 0 ? childList.get(0) : null;\n    }\n    /** Get the first child node with the given name */\n    public MNode first(String name) {\n        if (childList == null) return null;\n        if (name == null) return first();\n\n        ArrayList<MNode> nameChildren = children(name);\n        if (nameChildren.size() > 0) return nameChildren.get(0);\n        return null;\n\n        /* with cache in children(name) that is faster than searching every time here:\n        int childListSize = childList.size();\n        for (int i = 0; i < childListSize; i++) {\n            MNode curChild = childList.get(i);\n            if (name.equals(curChild.nodeName)) return curChild;\n        }\n        return null;\n        */\n    }\n    public MNode first(String name, String... attrNamesValues) {\n        if (childList == null) return null;\n        if (name == null) return first();\n\n        ArrayList<MNode> nameChildren = children(name, attrNamesValues);\n        if (nameChildren.size() > 0) return nameChildren.get(0);\n        return null;\n    }\n    public MNode first(Closure<Boolean> condition) {\n        if (childList == null) return null;\n        if (condition == null) return first();\n        int childListSize = childList.size();\n        for (int i = 0; i < childListSize; i++) {\n            MNode curChild = childList.get(i);\n            if (condition.call(curChild)) return curChild;\n        }\n        return null;\n    }\n    public int firstIndex(String name) {\n        if (childList == null) return -1;\n        if (name == null) return childList.size() - 1;\n        int childListSize = childList.size();\n        for (int i = 0; i < childListSize; i++) {\n            MNode curChild = childList.get(i);\n            if (name.equals(curChild.getName())) return i;\n        }\n        return -1;\n    }\n    public int firstIndex(Closure<Boolean> condition) {\n        if (childList == null) return -1;\n        if (condition == null) return childList.size() - 1;\n        int childListSize = childList.size();\n        for (int i = 0; i < childListSize; i++) {\n            MNode curChild = childList.get(i);\n            if (condition.call(curChild)) return i;\n        }\n        return -1;\n    }\n    public int firstIndex(MNode child) {\n        if (childList == null || child == null) return -1;\n        int childListSize = childList.size();\n        // first find match by identity (same object), most reliable match\n        for (int i = 0; i < childListSize; i++) {\n            MNode curChild = childList.get(i);\n            if (child == curChild) return i;\n        }\n        // if not found find by node name and attributes\n        for (int i = 0; i < childListSize; i++) {\n            MNode curChild = childList.get(i);\n            if (!child.getName().equals(curChild.getName())) continue;\n            if (child.getAttributes().equals(curChild.getAttributes())) return i;\n        }\n        return -1;\n    }\n\n    public String getText() { return childText; }\n\n    public MNode deepCopy(MNode parent) {\n        MNode newNode = new MNode(nodeName, attributeMap, parent, null, childText);\n        if (fileLocation != null) newNode.fileLocation = fileLocation;\n        if (childList != null) {\n            int childListSize = childList.size();\n            if (childListSize > 0) {\n                newNode.childList = new ArrayList<>();\n                for (int i = 0; i < childListSize; i++) {\n                    MNode curChild = childList.get(i);\n                    newNode.childList.add(curChild.deepCopy(newNode));\n                }\n            }\n        }\n        // if (\"entity\".equals(nodeName)) logger.info(\"Original MNode:\\n\" + this.toString() + \"\\n Clone MNode:\\n\" + newNode.toString());\n        return newNode;\n    }\n\n    /* ========== Child Modify Methods ========== */\n\n    public void append(MNode child) {\n        if (childrenByName != null) childrenByName.remove(child.nodeName);\n        if (childList == null) childList = new ArrayList<>();\n        childList.add(child);\n        child.parentNode = this;\n    }\n    public void append(MNode child, int index) {\n        if (childrenByName != null) childrenByName.remove(child.nodeName);\n        if (childList == null) childList = new ArrayList<>();\n        if (index > childList.size()) index = childList.size();\n        childList.add(index, child);\n        child.parentNode = this;\n    }\n    public MNode append(Node child) {\n        MNode newNode = new MNode(child);\n        append(newNode);\n        return newNode;\n    }\n    public MNode append(String name, Map<String, String> attributes, List<MNode> children, String text) {\n        MNode newNode = new MNode(name, attributes, this, children, text);\n        append(newNode);\n        return newNode;\n    }\n    public MNode append(String name, Map<String, String> attributes) {\n        MNode newNode = new MNode(name, attributes, this, null, null);\n        append(newNode);\n        return newNode;\n    }\n    /** Append nodes to end of current child nodes, optionally clone */\n    public void appendAll(List<MNode> children, boolean clone) {\n        for (MNode child : children) {\n            append(clone ? child.deepCopy(this) : child);\n        }\n    }\n    /** Append child nodes at given index, optionally clone */\n    public void appendAll(List<MNode> children, int index, boolean clone) {\n        int insertIdx = index;\n        for (MNode child : children) {\n            append(clone ? child.deepCopy(this) : child, insertIdx);\n            insertIdx++;\n        }\n    }\n\n    public MNode replace(int index, MNode child) {\n        if (childList == null || childList.size() < index)\n            throw new IllegalArgumentException(\"Index \" + index + \" not valid, size is \" + (childList == null ? 0 : childList.size()));\n        return childList.set(index, child);\n    }\n    public MNode replace(int index, String name, Map<String, String> attributes) {\n        if (childList == null || childList.size() < index)\n            throw new IllegalArgumentException(\"Index \" + index + \" not valid, size is \" + (childList == null ? 0 : childList.size()));\n        MNode newNode = new MNode(name, attributes, this, null, null);\n        childList.set(index, newNode);\n        return newNode;\n    }\n\n    /** Remove the child at the given index */\n    public void remove(int index) {\n        if (childList == null || childList.size() < index)\n            throw new IllegalArgumentException(\"Index \" + index + \" not valid, size is \" + (childList == null ? 0 : childList.size()));\n        childList.remove(index);\n    }\n    /** Remove children matching the node/element name */\n    public boolean remove(String name) {\n        if (childrenByName != null) childrenByName.remove(name);\n        if (childList == null) return false;\n        boolean removed = false;\n        for (int i = 0; i < childList.size(); ) {\n            MNode curChild = childList.get(i);\n            if (curChild.nodeName.equals(name)) {\n                childList.remove(i);\n                removed = true;\n            } else {\n                i++;\n            }\n        }\n        return removed;\n    }\n    /** Remove children where Closure evaluates to true */\n    public boolean remove(Closure<Boolean> condition) {\n        if (childList == null) return false;\n        boolean removed = false;\n        for (int i = 0; i < childList.size(); ) {\n            MNode curChild = childList.get(i);\n            if (condition.call(curChild)) {\n                if (childrenByName != null) childrenByName.remove(curChild.nodeName);\n                childList.remove(i);\n                removed = true;\n            } else {\n                i++;\n            }\n        }\n        return removed;\n    }\n    /** Remove all children */\n    public boolean removeAll() {\n        if (childList == null || childList.size() == 0) return false;\n        childList.clear();\n        if (childrenByName != null) childrenByName.clear();\n        return true;\n    }\n\n    /** Merge a single child node with the given name from overrideNode if it has a child with that name.\n     *\n     * If this node has a child with the same name copies/overwrites attributes from the overrideNode's child and if\n     * overrideNode's child has children the children of this node's child will be replaced by them.\n     *\n     * Otherwise appends a copy of the override child as a child of the current node.\n     */\n    public void mergeSingleChild(MNode overrideNode, String childNodeName) {\n        MNode childOverrideNode = overrideNode.first(childNodeName);\n        if (childOverrideNode == null) return;\n\n        MNode childBaseNode = first(childNodeName);\n        if (childBaseNode != null) {\n            childBaseNode.attributeMap.putAll(childOverrideNode.attributeMap);\n            if (childOverrideNode.childList != null && childOverrideNode.childList.size() > 0) {\n                if (childBaseNode.childList != null) {\n                    if (childBaseNode.childrenByName != null) childBaseNode.childrenByName.clear();\n                    childBaseNode.childList.clear();\n                } else {\n                    childBaseNode.childList = new ArrayList<>();\n                }\n                ArrayList<MNode> conChildList = childOverrideNode.childList;\n                int conChildListSize = conChildList.size();\n                for (int i = 0; i < conChildListSize; i++) {\n                    MNode grandchild = conChildList.get(i);\n                    childBaseNode.childList.add(grandchild.deepCopy(childBaseNode));\n                }\n            }\n        } else {\n            if (childrenByName != null) childrenByName.remove(childOverrideNode.nodeName);\n            if (childList == null) childList = new ArrayList<>();\n            childList.add(childOverrideNode.deepCopy(this));\n        }\n    }\n\n    public void mergeChildWithChildKey(MNode overrideNode, String childName, String grandchildName, String keyAttributeName, Closure grandchildMerger) {\n        MNode overrideChildNode = overrideNode.first(childName);\n        if (overrideChildNode == null) return;\n        MNode baseChildNode = first(childName);\n        if (baseChildNode != null) {\n            baseChildNode.mergeNodeWithChildKey(overrideChildNode, grandchildName, keyAttributeName, grandchildMerger);\n        } else {\n            if (childrenByName != null) childrenByName.remove(overrideChildNode.nodeName);\n            if (childList == null) childList = new ArrayList<>();\n            childList.add(overrideChildNode.deepCopy(this));\n        }\n    }\n\n    /** Merge attributes and child nodes from overrideNode into this node, matching on childNodesName and optionally the value of the\n     * attribute in each named by keyAttributeName.\n     *\n     * Always copies/overwrites attributes from override child node, and merges their child nodes using childMerger or\n     * if null the default merge of removing all children under the child of this node and appending copies of the\n     * children of the override child node.\n     */\n    public void mergeNodeWithChildKey(MNode overrideNode, String childNodesName, String keyAttributeName, Closure childMerger) {\n        if (overrideNode == null) throw new IllegalArgumentException(\"No overrideNode specified in call to mergeNodeWithChildKey\");\n        if (childNodesName == null || childNodesName.length() == 0) throw new IllegalArgumentException(\"No childNodesName specified in call to mergeNodeWithChildKey\");\n\n        // override attributes for this node\n        attributeMap.putAll(overrideNode.attributeMap);\n\n        mergeChildrenByKey(overrideNode, childNodesName, keyAttributeName, childMerger);\n    }\n    public void mergeChildrenByKey(MNode overrideNode, String childNodesName, String keyAttributeName, Closure childMerger) {\n        if (keyAttributeName == null || keyAttributeName.isEmpty()) {\n            mergeChildrenByKeys(overrideNode, childNodesName, childMerger);\n        } else {\n            mergeChildrenByKeys(overrideNode, childNodesName, childMerger, keyAttributeName);\n        }\n    }\n    public void mergeChildrenByKeys(MNode overrideNode, String childNodesName, Closure childMerger, String... keyAttributeNames) {\n        if (overrideNode == null) throw new IllegalArgumentException(\"No overrideNode specified in call to mergeChildrenByKey\");\n        if (childNodesName == null || childNodesName.length() == 0) throw new IllegalArgumentException(\"No childNodesName specified in call to mergeChildrenByKey\");\n\n        if (childList == null) childList = new ArrayList<>();\n        ArrayList<MNode> overrideChildren = overrideNode.children(childNodesName);\n        int overrideChildrenSize = overrideChildren.size();\n        for (int curOc = 0; curOc < overrideChildrenSize; curOc++) {\n            MNode childOverrideNode = overrideChildren.get(curOc);\n\n            String[] keyAttributeValues = null;\n            if (keyAttributeNames != null && keyAttributeNames.length > 0) {\n                keyAttributeValues = new String[keyAttributeNames.length];\n                boolean skipChild = false;\n                for (int ai = 0; ai < keyAttributeNames.length; ai++) {\n                    String keyValue = childOverrideNode.attribute(keyAttributeNames[ai]);\n                    // if we have a keyAttributeName but no keyValue for this child node, skip it\n                    if (keyValue == null || keyValue.length() == 0) { skipChild = true; continue; }\n                    keyAttributeValues[ai] = keyValue;\n                }\n                if (skipChild) continue;\n            }\n\n            MNode childBaseNode = null;\n            int childListSize = childList.size();\n            for (int i = 0; i < childListSize; i++) {\n                MNode curChild = childList.get(i);\n                if (!curChild.getName().equals(childNodesName)) continue;\n                if (keyAttributeNames == null || keyAttributeNames.length == 0) { childBaseNode = curChild; break; }\n                boolean allMatch = true;\n                for (int ai = 0; ai < keyAttributeNames.length; ai++) {\n                    String keyValue = keyAttributeValues[ai];\n                    if (!keyValue.equals(curChild.attribute(keyAttributeNames[ai]))) allMatch = false;\n                }\n                if (allMatch) { childBaseNode = curChild; break; }\n            }\n\n            if (childBaseNode != null) {\n                // merge the node attributes\n                childBaseNode.attributeMap.putAll(childOverrideNode.attributeMap);\n\n                if (childMerger != null) {\n                    childMerger.call(childBaseNode, childOverrideNode);\n                } else {\n                    // do the default child merge: remove current nodes children and replace with a copy of the override node's children\n                    if (childBaseNode.childList != null) {\n                        if (childBaseNode.childrenByName != null) childBaseNode.childrenByName.clear();\n                        childBaseNode.childList.clear();\n                    } else {\n                        childBaseNode.childList = new ArrayList<>();\n                    }\n                    ArrayList<MNode> conChildList = childOverrideNode.childList;\n                    int conChildListSize = conChildList != null ? conChildList.size() : 0;\n                    for (int i = 0; i < conChildListSize; i++) {\n                        MNode grandchild = conChildList.get(i);\n                        childBaseNode.childList.add(grandchild.deepCopy(childBaseNode));\n                    }\n                }\n            } else {\n                // no matching child base node, so add a new one\n                append(childOverrideNode.deepCopy(this));\n            }\n        }\n    }\n\n    /* ========== String Methods ========== */\n\n    @Override\n    public String toString() {\n        StringBuilder sb = new StringBuilder();\n        addToSb(sb, 0);\n        return sb.toString();\n    }\n    private void addToSb(StringBuilder sb, int level) {\n        for (int i = 0; i < level; i++) sb.append(\"    \");\n        sb.append('<').append(nodeName);\n        for (Map.Entry<String, String> entry : attributeMap.entrySet())\n            sb.append(' ').append(entry.getKey()).append(\"=\\\"\").append(entry.getValue()).append(\"\\\"\");\n        if ((childText != null && childText.length() > 0) || (childList != null && childList.size() > 0)) {\n            sb.append(\">\");\n            if (childText != null) sb.append(\"<![CDATA[\").append(childText).append(\"]]>\");\n            if (childList != null && childList.size() > 0) {\n                for (MNode child : childList) {\n                    sb.append('\\n');\n                    child.addToSb(sb, level + 1);\n                }\n                if (childList.size() > 1) {\n                    sb.append(\"\\n\");\n                    for (int i = 0; i < level; i++) sb.append(\"    \");\n                }\n            }\n            sb.append(\"</\").append(nodeName).append('>');\n        } else {\n            sb.append(\"/>\");\n        }\n    }\n\n    private static String gnodeText(Object nodeObj) {\n        if (nodeObj == null) return \"\";\n        Node theNode = null;\n        if (nodeObj instanceof Node) {\n            theNode = (Node) nodeObj;\n        } else if (nodeObj instanceof NodeList) {\n            NodeList nl = (NodeList) nodeObj;\n            if (nl.size() > 0) theNode = (Node) nl.get(0);\n        }\n        if (theNode == null) return \"\";\n        List<String> textList = theNode.localText();\n        if (textList != null) {\n            if (textList.size() == 1) {\n                return textList.get(0);\n            } else {\n                StringBuilder sb = new StringBuilder();\n                for (String txt : textList) sb.append(txt).append(\"\\n\");\n                return sb.toString();\n            }\n        } else {\n            return \"\";\n        }\n    }\n\n    private static class MNodeXmlHandler extends DefaultHandler {\n        Locator locator = null;\n        long nodesRead = 0;\n\n        MNode rootNode = null;\n        MNode curNode = null;\n        StringBuilder curText = null;\n\n        final boolean rootOnly;\n        String fileLocation = null;\n        private boolean stopParse = false;\n\n        MNodeXmlHandler(boolean rootOnly, String fileLocation) { this.rootOnly = rootOnly; this.fileLocation = fileLocation; }\n        MNode getRootNode() { return rootNode; }\n        long getNodesRead() { return nodesRead; }\n\n        @Override\n        public void startElement(String ns, String localName, String qName, Attributes attributes) {\n            if (stopParse) return;\n\n            // logger.info(\"startElement ns [${ns}], localName [${localName}] qName [${qName}]\")\n            if (curNode == null) {\n                curNode = new MNode(qName, null);\n                if (rootNode == null) rootNode = curNode;\n            } else {\n                curNode = curNode.append(qName, null);\n            }\n            if (fileLocation != null) curNode.setFileLocation(fileLocation);\n\n            int length = attributes.getLength();\n            for (int i = 0; i < length; i++) {\n                String name = attributes.getLocalName(i);\n                String value = attributes.getValue(i);\n                if (name == null || name.length() == 0) name = attributes.getQName(i);\n                curNode.attributeMap.put(name, value);\n            }\n\n            if (rootOnly) stopParse = true;\n        }\n\n        @Override\n        public void characters(char[] chars, int offset, int length) {\n            if (stopParse) return;\n\n            if (curText == null) curText = new StringBuilder();\n            curText.append(chars, offset, length);\n        }\n        @Override\n        public void endElement(String ns, String localName, String qName) {\n            if (stopParse) return;\n\n            if (!qName.equals(curNode.nodeName)) throw new IllegalStateException(\"Invalid close element \" + qName + \", was expecting \" + curNode.nodeName);\n            if (curText != null) {\n                String curString = curText.toString().trim();\n                if (curString.length() > 0) curNode.childText = curString;\n            }\n            curNode = curNode.parentNode;\n            curText = null;\n        }\n\n        @Override\n        public void setDocumentLocator(Locator locator) { this.locator = locator; }\n    }\n\n    /* ============================================================== */\n    /* ========== FreeMarker (FTL) Fields Methods, Classes ========== */\n    /* ============================================================== */\n\n    private static final BeansWrapper wrapper = new BeansWrapperBuilder(FTL_VERSION).build();\n    private static final FtlNodeListWrapper emptyNodeListWrapper = new FtlNodeListWrapper(new ArrayList<>(), null);\n    private FtlNodeListWrapper allChildren = null;\n    private ConcurrentHashMap<String, TemplateModel> ftlAttrAndChildren = null;\n    private ConcurrentHashMap<String, Boolean> knownNullAttributes = null;\n\n    public Object getAdaptedObject(Class aClass) { return this; }\n\n    // TemplateHashModel methods\n    @Override public TemplateModel get(String s) {\n        if (s == null) return null;\n        // first try the attribute and children caches, then if not found in either pick it apart and create what is needed\n        ConcurrentHashMap<String, TemplateModel> localAttrAndChildren = ftlAttrAndChildren != null ? ftlAttrAndChildren : makeAttrAndChildrenByName();\n        TemplateModel attrOrChildWrapper = localAttrAndChildren.get(s);\n        if (attrOrChildWrapper != null) return attrOrChildWrapper;\n        if (knownNullAttributes != null && knownNullAttributes.containsKey(s)) return null;\n\n        // at this point we got a null value but attributes and child nodes were pre-loaded so return null or empty list\n        if (s.startsWith(\"@\")) {\n            if (\"@@text\".equals(s)) {\n                // if we got this once will get it again so add @@text always, always want wrapper though may be null\n                FtlTextWrapper textWrapper = new FtlTextWrapper(childText, this);\n                localAttrAndChildren.putIfAbsent(\"@@text\", textWrapper);\n                return localAttrAndChildren.get(\"@@text\");\n                // TODO: handle other special hashes? (see http://www.freemarker.org/docs/xgui_imperative_formal.html)\n            } else {\n                String attrName = s.substring(1, s.length());\n                String attrValue = attributeMap.get(attrName);\n                if (attrValue == null) {\n                    if (knownNullAttributes == null) knownNullAttributes = new ConcurrentHashMap<>();\n                    knownNullAttributes.put(s, Boolean.TRUE);\n                    return null;\n                } else {\n                    FtlAttributeWrapper attrWrapper = new FtlAttributeWrapper(attrName, attrValue, this);\n                    TemplateModel existingAttr = localAttrAndChildren.putIfAbsent(s, attrWrapper);\n                    if (existingAttr != null) return existingAttr;\n                    return attrWrapper;\n                }\n            }\n        } else {\n            if (hasChild(s)) {\n                FtlNodeListWrapper nodeListWrapper = new FtlNodeListWrapper(children(s), this);\n                TemplateModel existingNodeList = localAttrAndChildren.putIfAbsent(s, nodeListWrapper);\n                if (existingNodeList != null) return existingNodeList;\n                return nodeListWrapper;\n            } else {\n                return emptyNodeListWrapper;\n            }\n        }\n    }\n    private synchronized ConcurrentHashMap<String, TemplateModel> makeAttrAndChildrenByName() {\n        if (ftlAttrAndChildren == null) ftlAttrAndChildren = new ConcurrentHashMap<>();\n        return ftlAttrAndChildren;\n    }\n    @Override public boolean isEmpty() {\n        return attributeMap.isEmpty() && (childList == null || childList.isEmpty()) && (childText == null || childText.length() == 0);\n    }\n\n    // TemplateHashModelEx methods\n    @Override public TemplateCollectionModel keys() throws TemplateModelException { return new SimpleCollection(attributeMap.keySet(), wrapper); }\n    @Override public TemplateCollectionModel values() throws TemplateModelException { return new SimpleCollection(attributeMap.values(), wrapper); }\n\n    // TemplateNodeModel methods\n    @Override public TemplateNodeModel getParentNode() { return parentNode; }\n    @Override public TemplateSequenceModel getChildNodes() { return this; }\n    @Override public String getNodeName() { return getName(); }\n    @Override public String getNodeType() { return \"element\"; }\n    @Override public String getNodeNamespace() { return null; } /* Namespace not supported for now. */\n\n    // TemplateSequenceModel methods\n    @Override public TemplateModel get(int i) {\n        if (allChildren == null) return getSequenceList().get(i);\n        return allChildren.get(i);\n    }\n    @Override public int size() {\n        if (allChildren == null) return getSequenceList().size();\n        return allChildren.size();\n    }\n    private FtlNodeListWrapper getSequenceList() {\n        // Looks like attributes should NOT go in the FTL children list, so just use the node.children()\n        if (allChildren == null) allChildren = (childText != null && childText.length() > 0) ?\n                new FtlNodeListWrapper(childText, this) : new FtlNodeListWrapper(childList, this);\n        return allChildren;\n    }\n\n    // TemplateScalarModel methods\n    @Override public String getAsString() { return childText != null ? childText : \"\"; }\n\n    private static class FtlAttributeWrapper implements TemplateNodeModel, TemplateSequenceModel, AdapterTemplateModel,\n            TemplateScalarModel {\n        protected String key;\n        protected String value;\n        MNode parentNode;\n        FtlAttributeWrapper(String key, String value, MNode parentNode) {\n            this.key = key;\n            this.value = value;\n            this.parentNode = parentNode;\n        }\n\n        @Override public Object getAdaptedObject(Class aClass) { return value; }\n\n        // TemplateNodeModel methods\n        @Override public TemplateNodeModel getParentNode() { return parentNode; }\n        @Override public TemplateSequenceModel getChildNodes() { return this; }\n        @Override public String getNodeName() { return key; }\n        @Override public String getNodeType() { return \"attribute\"; }\n        @Override public String getNodeNamespace() { return null; } /* Namespace not supported for now. */\n\n        // TemplateSequenceModel methods\n        @Override public TemplateModel get(int i) {\n            if (i == 0) try {\n                return wrapper.wrap(value);\n            } catch (TemplateModelException e) {\n                throw new BaseException(\"Error wrapping object for FreeMarker\", e);\n            }\n            throw new IndexOutOfBoundsException(\"Attribute node only has 1 value. Tried to get index [${i}] for attribute [${key}]\");\n        }\n        @Override public int size() { return 1; }\n\n        // TemplateScalarModel methods\n        @Override public String getAsString() { return value; }\n        @Override public String toString() { return value; }\n    }\n\n    private static class FtlTextWrapper implements TemplateNodeModel, TemplateSequenceModel, AdapterTemplateModel, TemplateScalarModel {\n        protected String text;\n        MNode parentNode;\n        FtlTextWrapper(String text, MNode parentNode) {\n            this.text = text;\n            this.parentNode = parentNode;\n        }\n\n        @Override public Object getAdaptedObject(Class aClass) { return text; }\n\n        // TemplateNodeModel methods\n        @Override public TemplateNodeModel getParentNode() { return parentNode; }\n        @Override public TemplateSequenceModel getChildNodes() { return this; }\n        @Override public String getNodeName() { return \"@text\"; }\n        @Override public String getNodeType() { return \"text\"; }\n        @Override public String getNodeNamespace() { return null; } /* Namespace not supported for now. */\n\n        // TemplateSequenceModel methods\n        @Override public TemplateModel get(int i) {\n            if (i == 0) try {\n                return wrapper.wrap(getAsString());\n            } catch (TemplateModelException e) {\n                throw new BaseException(\"Error wrapping object for FreeMarker\", e);\n            }\n            throw new IndexOutOfBoundsException(\"Text node only has 1 value. Tried to get index [${i}]\");\n        }\n        @Override public int size() { return 1; }\n\n        // TemplateScalarModel methods\n        @Override public String getAsString() { return text != null ? text : \"\"; }\n        @Override public String toString() { return getAsString(); }\n    }\n\n    private static class FtlNodeListWrapper implements TemplateSequenceModel {\n        ArrayList<TemplateModel> nodeList = new ArrayList<>();\n        FtlNodeListWrapper(ArrayList<MNode> mnodeList, MNode parentNode) {\n            if (mnodeList != null) nodeList.addAll(mnodeList);\n        }\n        FtlNodeListWrapper(String text, MNode parentNode) {\n            nodeList.add(new FtlTextWrapper(text, parentNode));\n        }\n\n        @Override public TemplateModel get(int i) { return nodeList.get(i); }\n        @Override public int size() { return nodeList.size(); }\n        @Override public String toString() { return nodeList.toString(); }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/util/ObjectUtilities.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.util;\n\nimport org.codehaus.groovy.runtime.DefaultGroovyMethods;\nimport org.moqui.BaseException;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.beans.BeanInfo;\nimport java.beans.IntrospectionException;\nimport java.beans.Introspector;\nimport java.beans.PropertyDescriptor;\nimport java.io.*;\nimport java.lang.reflect.Field;\nimport java.lang.reflect.InvocationTargetException;\nimport java.lang.reflect.Method;\nimport java.math.BigDecimal;\nimport java.nio.charset.Charset;\nimport java.nio.charset.StandardCharsets;\nimport java.sql.Date;\nimport java.sql.Time;\nimport java.sql.Timestamp;\nimport java.time.temporal.ChronoUnit;\nimport java.time.temporal.TemporalUnit;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\n/**\n * These are utilities that should exist elsewhere, but I can't find a good simple library for them, and they are\n * stupid but necessary for certain things.\n */\n@SuppressWarnings(\"unused\")\npublic class ObjectUtilities {\n    protected static final Logger logger = LoggerFactory.getLogger(ObjectUtilities.class);\n    public static final Map<String, Integer> calendarFieldByUomId;\n    public static final Map<String, TemporalUnit> temporalUnitByUomId;\n    static {\n        HashMap<String, Integer> cfm = new HashMap<>(8);\n        cfm.put(\"TF_ms\", Calendar.MILLISECOND); cfm.put(\"TF_s\", Calendar.SECOND); cfm.put(\"TF_min\", Calendar.MINUTE);\n        cfm.put(\"TF_hr\", Calendar.HOUR); cfm.put(\"TF_day\", Calendar.DAY_OF_MONTH); cfm.put(\"TF_wk\", Calendar.WEEK_OF_YEAR);\n        cfm.put(\"TF_mon\", Calendar.MONTH); cfm.put(\"TF_yr\", Calendar.YEAR);\n        calendarFieldByUomId = cfm;\n\n        HashMap<String, TemporalUnit> tum = new HashMap<>(8);\n        tum.put(\"TF_ms\", ChronoUnit.MILLIS); tum.put(\"TF_s\", ChronoUnit.SECONDS); tum.put(\"TF_min\", ChronoUnit.MINUTES);\n        tum.put(\"TF_hr\", ChronoUnit.HOURS); tum.put(\"TF_day\", ChronoUnit.DAYS); tum.put(\"TF_wk\", ChronoUnit.WEEKS);\n        tum.put(\"TF_mon\", ChronoUnit.MONTHS); tum.put(\"TF_yr\", ChronoUnit.YEARS);\n        temporalUnitByUomId = tum;\n    }\n\n    /** Populate a Map with public fields and Java Bean style properties (using java.beans.BeanInfo) */\n    public static Map<String, Object> objectToMap(Object bean) {\n        if (bean == null) return null;\n        Map<String, Object> map = new HashMap<>();\n\n        Class clazz = bean.getClass();\n        Field[] fields = clazz.getFields();\n        for (int fi = 0; fi < fields.length; fi++) {\n            Field field = fields[fi];\n            try {\n                map.put(field.getName(), field.get(bean));\n            } catch (IllegalAccessException e) {\n                // do nothing, maybe log at some point if we care enough and are okay with the potential performance hit\n            }\n        }\n\n        /* commenting for now, seems to call a bunch of undesired methods, will need some work to filter them out:\n        try {\n            BeanInfo beanInfo = Introspector.getBeanInfo(clazz);\n            PropertyDescriptor[] propertyDescriptors = beanInfo.getPropertyDescriptors();\n            for (int pi = 0; pi < propertyDescriptors.length; pi++) {\n                PropertyDescriptor propertyDescriptor = propertyDescriptors[pi];\n                Method readMethod = propertyDescriptor.getReadMethod();\n                if (readMethod != null) {\n                    try {\n                        map.put(propertyDescriptor.getName(), readMethod.invoke(bean));\n                    } catch (IllegalAccessException | InvocationTargetException e) {\n                        // nothing again\n                    }\n                }\n            }\n        } catch (IntrospectionException e) {\n            // nothing again\n        }\n        */\n\n        // this gets picked up automatically, just remove at the end, faster than checking along the way\n        map.remove(\"class\");\n\n        return map;\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    public static Object basicConvert(Object value, final String javaType) {\n        if (value == null) return null;\n\n        Class theClass = MClassLoader.getCommonClass(javaType);\n        // only support the classes we have pre-configured\n        if (theClass == null) return value;\n        boolean origString = false;\n        if (value instanceof CharSequence) {\n            value = value.toString();\n            origString = true;\n        }\n        Class origClass = origString ? String.class : value.getClass();\n        if (origClass.equals(theClass)) return value;\n        try {\n            if (origString) {\n                if (theClass == Boolean.class) {\n                    // for non-empty String to Boolean don't use normal not-empty rules, look for \"true\", \"false\", etc\n                    String valStr = value.toString();\n                    return \"true\".equalsIgnoreCase(valStr) || \"y\".equalsIgnoreCase(valStr);\n                } else if (theClass == Integer.class) {\n                    // groovy does funny things with single character strings, ie gets the int value of the single char, so do it ourselves\n                    return Integer.valueOf(value.toString());\n                } else if (theClass == Long.class) {\n                    return Long.valueOf(value.toString());\n                } else if (theClass == BigDecimal.class) {\n                    return new BigDecimal(value.toString());\n                }\n            }\n            if (theClass == Date.class && value instanceof Timestamp) {\n                // Groovy doesn't handle this one, but easy conversion\n                return new Date(((Timestamp) value).getTime());\n            } else {\n                // let groovy do the work\n                // logger.warn(\"Converted \" + value + \" of type \" + origClass.getName() + \" to \" + DefaultGroovyMethods.asType(value, theClass) + \" for class \" + theClass.getName());\n                return DefaultGroovyMethods.asType(value, theClass);\n            }\n        } catch (Throwable t) {\n            logger.warn(\"Error doing type conversion to \" + javaType + \" for value [\" + value + \"]\", t);\n            return value;\n        }\n    }\n\n    public static boolean compareLike(Object value1, Object value2) {\n        // nothing to be like? consider a match\n        if (value2 == null) return true;\n        // something to be like but nothing to compare? consider a mismatch\n        if (value1 == null) return false;\n        if (value1 instanceof CharSequence && value2 instanceof CharSequence) {\n            // first escape the characters that would be interpreted as part of the regular expression\n            int length2 = ((CharSequence) value2).length();\n            StringBuilder sb = new StringBuilder(length2 * 2);\n            for (int i = 0; i < length2; i++) {\n                char c = ((CharSequence) value2).charAt(i);\n                if (\"[](){}.*+?$^|#\\\\\".indexOf(c) != -1) sb.append('\\\\');\n                sb.append(c);\n            }\n            // change the SQL wildcards to regex wildcards\n            String regex = sb.toString().replace(\"_\", \".\").replace(\"%\", \".*?\");\n            // run it...\n            Pattern pattern = Pattern.compile(regex, (Pattern.CASE_INSENSITIVE | Pattern.DOTALL));\n            return pattern.matcher(value1.toString()).matches();\n        } else {\n            return false;\n        }\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    public static boolean compare(Object field, final String operator, final String value, Object toField, String format, final String type) {\n        if (isEmpty(toField)) toField = value;\n\n        // FUTURE handle type conversion with format for Date, Time, Timestamp\n        // if (format) { }\n\n        field = basicConvert(field, type);\n        toField = basicConvert(toField, type);\n\n        boolean result;\n        if (\"less\".equals(operator)) { result = compareObj(field, toField) < 0; }\n        else if (\"greater\".equals(operator)) { result = compareObj(field, toField) > 0; }\n        else if (\"less-equals\".equals(operator)) { result = compareObj(field, toField) <= 0; }\n        else if (\"greater-equals\".equals(operator)) { result = compareObj(field, toField) >= 0; }\n        else if (\"contains\".equals(operator)) { result = Objects.toString(field).contains(Objects.toString(toField)); }\n        else if (\"not-contains\".equals(operator)) { result = !Objects.toString(field).contains(Objects.toString(toField)); }\n        else if (\"empty\".equals(operator)) { result = isEmpty(field); }\n        else if (\"not-empty\".equals(operator)) { result = !isEmpty(field); }\n        else if (\"matches\".equals(operator)) { result = Objects.toString(field).matches(toField.toString()); }\n        else if (\"not-matches\".equals(operator)) { result = !Objects.toString(field).matches(toField.toString()); }\n        else if (\"not-equals\".equals(operator)) { result = !Objects.equals(field, toField); }\n        else { result = Objects.equals(field, toField); }\n\n        if (logger.isTraceEnabled()) logger.trace(\"Compare result [\" + result + \"] for field [\" + field + \"] operator [\" + operator + \"] value [\" + value + \"] toField [\" + toField + \"] type [\" + type + \"]\");\n        return result;\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    public static int compareObj(Object field1, Object field2) {\n        if (field1 == null) {\n            if (field2 == null) return 0;\n            else return 1;\n        }\n        Comparable comp1 = makeComparable(field1);\n        Comparable comp2 = makeComparable(field2);\n        return comp1.compareTo(comp2);\n    }\n    public static Comparable makeComparable(final Object obj) {\n        if (obj == null) return null;\n        if (obj instanceof Comparable) return (Comparable) obj;\n        else throw new IllegalArgumentException(\"Object of type \" + obj.getClass().getName() + \" is not Comparable, cannot compare\");\n    }\n\n    public static int countChars(String s, boolean countDigits, boolean countLetters, boolean countOthers) {\n        // this seems like it should be part of some standard Java API, but I haven't found it\n        // (can use Pattern/Matcher, but that is even uglier and probably a lot slower)\n        int count = 0;\n        for (char c : s.toCharArray()) {\n            if (Character.isDigit(c)) {\n                if (countDigits) count++;\n            } else if (Character.isLetter(c)) {\n                if (countLetters) count++;\n            } else {\n                if (countOthers) count++;\n            }\n        }\n        return count;\n    }\n\n    public static int countChars(String s, char cMatch) {\n        int count = 0;\n        for (char c : s.toCharArray()) if (c == cMatch) count++;\n        return count;\n    }\n\n    public static String getStreamText(InputStream is) { return getStreamText(is, StandardCharsets.UTF_8); }\n    public static String getStreamText(InputStream is, Charset charset) {\n        if (is == null) return null;\n        Reader r = null;\n        try {\n            r = new InputStreamReader(new BufferedInputStream(is), charset);\n\n            StringBuilder sb = new StringBuilder();\n            char[] buf = new char[4096];\n            int i;\n            while ((i = r.read(buf, 0, 4096)) > 0) sb.append(buf, 0, i);\n            return sb.toString();\n        } catch (IOException e) {\n            throw new BaseException(\"Error getting stream text\", e);\n        } finally {\n            try {\n                if (r != null) r.close();\n            } catch (IOException e) {\n                logger.warn(\"Error in close after reading text from stream\", e);\n            }\n        }\n    }\n\n    public static int copyStream(InputStream is, OutputStream os) {\n        byte[] buffer = new byte[4096];\n        int totalLen = 0;\n        try {\n            int len = is.read(buffer);\n            while (len != -1) {\n                totalLen += len;\n                os.write(buffer, 0, len);\n                len = is.read(buffer);\n                if (Thread.interrupted()) break;\n            }\n            return totalLen;\n        } catch (IOException e) {\n            throw new BaseException(\"Error copying stream\", e);\n        }\n    }\n\n    public static String toPlainString(Object obj) {\n        if (obj == null) return \"\";\n        // Common case, check first\n        if (obj instanceof CharSequence) return obj.toString();\n        Class objClass = obj.getClass();\n        // BigDecimal toString() uses scientific notation, annoying, so use toPlainString()\n        if (objClass == BigDecimal.class) return ((BigDecimal) obj).toPlainString();\n        // handle the special case of timestamps used for primary keys, make sure we avoid TZ, etc problems\n        if (objClass == Timestamp.class) return Long.toString(((Timestamp) obj).getTime());\n        if (objClass == Date.class) return Long.toString(((Date) obj).getTime());\n        if (objClass == Time.class) return Long.toString(((Time) obj).getTime());\n        if (obj instanceof Collection) {\n            Collection col = (Collection) obj;\n            StringBuilder sb = new StringBuilder();\n            for (Object entry : col) {\n                if (entry == null) continue;\n                if (sb.length() > 0) sb.append(\",\");\n                sb.append(entry);\n            }\n            return sb.toString();\n        }\n\n        // no special case? do a simple toString()\n        return obj.toString();\n    }\n\n    /** Like the Groovy empty except doesn't consider empty 0 value numbers, false Boolean, etc; only null values,\n     *   0 length String (actually CharSequence to include GString, etc), and 0 size Collection/Map are considered empty. */\n    public static boolean isEmpty(Object obj) {\n        if (obj == null) return true;\n        /* faster not to do this\n        Class objClass = obj.getClass();\n        // some common direct classes\n        if (objClass == String.class) return ((String) obj).length() == 0;\n        if (objClass == GString.class) return ((GString) obj).length() == 0;\n        if (objClass == ArrayList.class) return ((ArrayList) obj).size() == 0;\n        if (objClass == HashMap.class) return ((HashMap) obj).size() == 0;\n        if (objClass == LinkedHashMap.class) return ((HashMap) obj).size() == 0;\n        // hopefully less common sub-classes\n        */\n        if (obj instanceof CharSequence) return ((CharSequence) obj).length() == 0;\n        if (obj instanceof Collection) return ((Collection) obj).size() == 0;\n        return obj instanceof Map && ((Map) obj).size() == 0;\n    }\n\n    public static Class getClass(String javaType) {\n        Class theClass = MClassLoader.getCommonClass(javaType);\n        if (theClass == null) {\n            try {\n                theClass = Thread.currentThread().getContextClassLoader().loadClass(javaType);\n            } catch (ClassNotFoundException e) { /* ignore */ }\n        }\n        return theClass;\n    }\n\n    public static boolean isInstanceOf(Object theObjectInQuestion, String javaType) {\n        Class theClass = MClassLoader.getCommonClass(javaType);\n        if (theClass == null) {\n            try {\n                theClass = Thread.currentThread().getContextClassLoader().loadClass(javaType);\n            } catch (ClassNotFoundException e) { /* ignore */ }\n        }\n        if (theClass == null) throw new IllegalArgumentException(\"Cannot find class for type: \" + javaType);\n        return theClass.isInstance(theObjectInQuestion);\n    }\n\n    public static Number addNumbers(Number a, Number b) {\n        if (a == null) return b;\n        if (b == null) return a;\n        Class<?> aClass = a.getClass();\n        Class<?> bClass = b.getClass();\n        // handle BigDecimal as a special case, most common case\n        if (aClass == BigDecimal.class) {\n            if (bClass == BigDecimal.class) return ((BigDecimal) a).add((BigDecimal) b);\n            else return ((BigDecimal) a).add(new BigDecimal(b.toString()));\n        }\n        if (bClass == BigDecimal.class) {\n            if (aClass == BigDecimal.class) return ((BigDecimal) b).add((BigDecimal) a);\n            else return ((BigDecimal) b).add(new BigDecimal(a.toString()));\n        }\n        // handle other numbers in descending order of most to least precision\n        if (aClass == Double.class || bClass == Double.class) {\n            return a.doubleValue() + b.doubleValue();\n        } else if (aClass == Float.class || bClass == Float.class) {\n            return a.floatValue() + b.floatValue();\n        } else if (aClass == Long.class || bClass == Long.class) {\n            return a.longValue() + b.longValue();\n        } else {\n            return a.intValue() + b.intValue();\n        }\n    }\n\n    public static int getCalendarFieldFromUomId(final String uomId) {\n        Integer calField = calendarFieldByUomId.get(uomId);\n        if (calField == null) throw new IllegalArgumentException(\"No equivalent Calendar field found for UOM ID \" + uomId);\n        return calField;\n    }\n\n    public static TemporalUnit getTemporalUnitFromUomId(final String uomId) {\n        TemporalUnit temporalUnit = temporalUnitByUomId.get(uomId);\n        if (temporalUnit == null) throw new IllegalArgumentException(\"No equivalent Temporal Unit found for UOM ID \" + uomId);\n        return temporalUnit;\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/util/RestClient.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.util;\n\nimport groovy.json.JsonBuilder;\nimport groovy.json.JsonSlurperClassic;\n\nimport java.io.InputStream;\nimport java.io.UnsupportedEncodingException;\nimport java.net.URI;\nimport java.net.URISyntaxException;\nimport java.net.URLEncoder;\nimport java.nio.charset.Charset;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Base64;\nimport java.util.EnumSet;\nimport java.util.HashSet;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedList;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Set;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.ExecutionException;\nimport java.util.concurrent.Future;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.TimeoutException;\nimport java.util.concurrent.locks.ReentrantLock;\n\nimport org.eclipse.jetty.client.CompletableResponseListener;\nimport org.eclipse.jetty.client.ContentResponse;\nimport org.eclipse.jetty.client.HttpClient;\nimport org.eclipse.jetty.client.HttpClientTransport;\nimport org.eclipse.jetty.client.HttpResponseException;\nimport org.eclipse.jetty.client.InputStreamRequestContent;\nimport org.eclipse.jetty.client.MultiPartRequestContent;\nimport org.eclipse.jetty.client.Request;\nimport org.eclipse.jetty.client.Response;\nimport org.eclipse.jetty.client.Result;\nimport org.eclipse.jetty.client.StringRequestContent;\nimport org.eclipse.jetty.client.ValidatingConnectionPool;\nimport org.eclipse.jetty.client.transport.HttpClientTransportDynamic;\nimport org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP;\nimport org.eclipse.jetty.http.HttpCookieStore;\nimport org.eclipse.jetty.http.HttpField;\nimport org.eclipse.jetty.http.HttpFields;\nimport org.eclipse.jetty.http.HttpHeader;\nimport org.eclipse.jetty.http.MultiPart;\nimport org.eclipse.jetty.io.ClientConnector;\nimport org.eclipse.jetty.util.ssl.SslContextFactory;\nimport org.eclipse.jetty.util.thread.QueuedThreadPool;\nimport org.eclipse.jetty.util.thread.ScheduledExecutorScheduler;\nimport org.eclipse.jetty.util.thread.Scheduler;\n\nimport org.moqui.BaseException;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\n@SuppressWarnings(\"unused\")\npublic class RestClient {\n    public enum Method { GET, PATCH, PUT, POST, DELETE, OPTIONS, HEAD }\n    public static final Method GET = Method.GET, PATCH = Method.PATCH, PUT = Method.PUT, POST = Method.POST,\n            DELETE = Method.DELETE, OPTIONS = Method.OPTIONS, HEAD = Method.HEAD;\n    public static final String[] METHOD_ARRAY = { \"GET\", \"PATCH\", \"PUT\", \"POST\", \"DELETE\", \"OPTIONS\", \"HEAD\" };\n    public static final Set<String> METHOD_SET = new HashSet<>(Arrays.asList(METHOD_ARRAY));\n\n    // NOTE: there is no constant on HttpServletResponse for 429; see RFC 6585 for details\n    public static final int TOO_MANY = 429;\n\n    // NOTE: DELETE doesn't normally support a body, but some APIs use it\n    private static final EnumSet<Method> BODY_METHODS = EnumSet.of(Method.GET, Method.PATCH, Method.POST, Method.PUT, Method.DELETE);\n    private static final Logger logger = LoggerFactory.getLogger(RestClient.class);\n\n    // Default RequestFactory (avoid new per request)\n    private static final ReentrantLock defaultReqFacLock = new ReentrantLock();\n    private static RequestFactory defaultRequestFactoryInternal = null;\n    public static RequestFactory getDefaultRequestFactory() {\n        if (defaultRequestFactoryInternal != null) return defaultRequestFactoryInternal;\n        defaultReqFacLock.lock();\n        try { defaultRequestFactoryInternal = new SimpleRequestFactory(); return defaultRequestFactoryInternal; }\n        finally { defaultReqFacLock.unlock(); }\n    }\n    // TODO: consider creating a RequestFactory in ECFI, init and destroy along with ECFI\n    public static void setDefaultRequestFactory(RequestFactory newRequestFactory) {\n        defaultReqFacLock.lock();\n        try {\n            RequestFactory tempRf = defaultRequestFactoryInternal;\n            defaultRequestFactoryInternal = newRequestFactory;\n            if (tempRf != null) tempRf.destroy();\n        } finally { defaultReqFacLock.unlock(); }\n    }\n    public static void destroyDefaultRequestFactory() {\n        defaultReqFacLock.lock();\n        try { if (defaultRequestFactoryInternal != null) { defaultRequestFactoryInternal.destroy(); defaultRequestFactoryInternal = null; } }\n        finally { defaultReqFacLock.unlock(); }\n    }\n\n    // ========== Instance Fields ==========\n    private String uriString = null;\n    private Method method = Method.GET;\n    private String contentType = \"application/json\";\n    private String acceptContentType = null;\n    private Charset charset = StandardCharsets.UTF_8;\n    private String bodyText = null;\n    private MultiPartRequestContent multiPart = null;\n    private List<KeyValueString> headerList = new LinkedList<>();\n    private List<KeyValueString> bodyParameterList = new LinkedList<>();\n    private String username = null;\n    private String password = null;\n    private float initialWaitSeconds = 2.0F;\n    private int maxRetries = 0;\n    private int maxResponseSize = 4 * 1024 * 1024;\n    private int timeoutSeconds = 30;\n    private boolean timeoutRetry = false;\n    private RequestFactory overrideRequestFactory = null;\n    private boolean isolate = false;\n\n    public RestClient() { }\n\n    /** Full URL String including protocol, host, path, parameters, etc */\n    public RestClient uri(String location) { uriString = location; return this; }\n    /** URL object including protocol, host, path, parameters, etc */\n    public RestClient uri(URI uri) { this.uriString = uri.toASCIIString(); return this; }\n    public UriBuilder uri() { return new UriBuilder(this); }\n    public URI getUri() { return URI.create(uriString); }\n    public String getUriString() { return uriString; }\n\n    /** Sets the HTTP request method, defaults to 'GET'; must be in the METHODS array */\n    public RestClient method(String method) {\n        if (method == null || method.isEmpty()) {\n            this.method = Method.GET;\n            return this;\n        }\n        method = method.toUpperCase();\n        try {\n            this.method = Method.valueOf(method);\n        } catch (Exception e) {\n            throw new IllegalArgumentException(\"Method \" + method + \" not valid\");\n        }\n        return this;\n    }\n    public RestClient method(Method method) { this.method = method; return this; }\n    public Method getMethod() { return method; }\n\n    /** Defaults to 'application/json', could also be 'text/xml', etc */\n    public RestClient contentType(String contentType) {\n        this.contentType = contentType;\n        return this;\n    }\n\n    public RestClient acceptContentType(String acceptContentType) {\n        this.acceptContentType = acceptContentType;\n        return this;\n    }\n\n    /** The MIME character encoding for the body sent and response. Defaults to <code>UTF-8</code>. Must be a valid\n     * charset in the java.nio.charset.Charset class. */\n    public RestClient encoding(String characterEncoding) { this.charset = Charset.forName(characterEncoding); return this; }\n    public RestClient addHeaders(Map<String, String> headers) {\n        for (Map.Entry<String, String> entry : headers.entrySet())\n            headerList.add(new KeyValueString(entry.getKey(), entry.getValue()));\n        return this;\n    }\n\n    public RestClient addHeader(String name, String value) {\n        headerList.add(new KeyValueString(name, value));\n        return this;\n    }\n\n    public RestClient basicAuth(String username, String password) {\n        this.username = username;\n        this.password = password;\n        return this;\n    }\n\n    /** Set the body text to use */\n    public RestClient text(String bodyText) {\n        if (!BODY_METHODS.contains(method)) throw new IllegalStateException(\"Cannot use body text with method \" + method);\n        this.bodyText = bodyText;\n        return this;\n    }\n\n    /** Set the body text as JSON from an Object */\n    public RestClient jsonObject(Object bodyJsonObject) {\n        if (bodyJsonObject == null) {\n            bodyText = null;\n            return this;\n        }\n        if (bodyJsonObject instanceof CharSequence) {\n            return text(bodyJsonObject.toString());\n        }\n\n        JsonBuilder jb = new JsonBuilder();\n        if (bodyJsonObject instanceof Map) {\n            jb.call((Map) bodyJsonObject);\n        } else if (bodyJsonObject instanceof List) {\n            jb.call((List) bodyJsonObject);\n        } else {\n            jb.call((Object) bodyJsonObject);\n        }\n\n        return text(jb.toString());\n    }\n\n    /** Set the body text as XML from a MNode */\n    public RestClient xmlNode(MNode bodyXmlNode) {\n        if (bodyXmlNode == null) {\n            bodyText = null;\n            return this;\n        }\n\n        return text(bodyXmlNode.toString());\n    }\n\n    public String getBodyText() { return bodyText; }\n\n    /** Add fields to put in body form parameters */\n    public RestClient addBodyParameters(Map<String, String> formFields) {\n        for (Map.Entry<String, String> entry : formFields.entrySet())\n            bodyParameterList.add(new KeyValueString(entry.getKey(), entry.getValue()));\n        return this;\n    }\n    /** Add a field to put in body form parameters */\n    public RestClient addBodyParameter(String name, String value) {\n        bodyParameterList.add(new KeyValueString(name, value));\n        return this;\n    }\n    /** Add a field part to a multi part request **/\n    public RestClient addFieldPart(String field, String value) {\n        if (method != Method.POST) throw new IllegalStateException(\"Can only use multipart body with POST method, not supported for method \" + method + \"; if you need a different effective request method try using the X-HTTP-Method-Override header\");\n\n        if (multiPart == null) multiPart = new MultiPartRequestContent();\n        multiPart.addPart(new MultiPart.ContentSourcePart(field, null, null, new StringRequestContent(value)));\n        return this;\n    }\n    /** Add a String file part to a multi part request **/\n    public RestClient addFilePart(String name, String fileName, String stringContent) {\n        return addFilePart(name, fileName, new StringRequestContent(stringContent), null);\n    }\n    /** Add a InputStream file part to a multi part request **/\n    public RestClient addFilePart(String name, String fileName, InputStream streamContent) {\n        return addFilePart(name, fileName, new InputStreamRequestContent(streamContent), null);\n    }\n    /** Add file part using Jetty ContentProvider.\n     * WARNING: This uses Jetty HTTP Client API objects and may change over time, do not use if alternative will work.\n     */\n    public RestClient addFilePart(String name, String fileName, Request.Content content, HttpFields fields) {\n        if (method != Method.POST) throw new IllegalStateException(\"Can only use multipart body with POST method, not supported for method \" + method + \"; if you need a different effective request method try using the X-HTTP-Method-Override header\");\n        if (multiPart == null) multiPart = new MultiPartRequestContent();\n        multiPart.addPart(new MultiPart.ContentSourcePart(name, fileName, fields, content));\n        return this;\n    }\n\n    /** If a velocity limit (429) response is received then retry up to maxRetries with\n     * exponential back off (initialWaitSeconds^i) sleep time in between requests. */\n    public RestClient retry(float initialWaitSeconds, int maxRetries) {\n        this.initialWaitSeconds = initialWaitSeconds;\n        this.maxRetries = maxRetries;\n        return this;\n    }\n    /** Same as retry(int, int) with defaults of 2 for initialWaitSeconds and 5 for maxRetries\n     * (2, 4, 8, 16, 32 seconds; up to total 62 seconds wait time and 6 HTTP requests) */\n    public RestClient retry() { return retry(2.0F, 5); }\n\n    /** Set a maximum response size, defaults to 4MB (4 * 1024 * 1024) */\n    public RestClient maxResponseSize(int maxSize) { this.maxResponseSize = maxSize; return this; }\n    /** Set a full response timeout in seconds, defaults to 30 */\n    public RestClient timeout(int seconds) { this.timeoutSeconds = seconds; return this; }\n    /** Set to true if retry should also be done on timeout; must call retry() to set retry parameters otherwise defaults to 1 retry with 2.0 initial wait time. */\n    public RestClient timeoutRetry(boolean tr) { this.timeoutRetry = tr; if (maxRetries == 0) maxRetries = 1; return this; }\n\n    /** Use a specific RequestFactory for pooling, keep alive, etc */\n    public RestClient withRequestFactory(RequestFactory requestFactory) { overrideRequestFactory = requestFactory; return this; }\n    /** If true isolate the request from all other requests by using a new HttpClient instance per request (no cookies, keep alive, etc; each request isolated from others) */\n    public RestClient isolate(boolean isolate) { this.isolate = isolate; return this; }\n\n    /** Do the HTTP request and get the response */\n    public RestResponse call() {\n        float curWaitSeconds = initialWaitSeconds;\n        if (curWaitSeconds == 0) curWaitSeconds = 1;\n\n        RestResponse curResponse = null;\n        for (int i = 0; i <= maxRetries; i++) {\n            try {\n                // do the request\n                curResponse = callInternal();\n            } catch (TimeoutException e) {\n                // if set to do so retry on timeout\n                if (timeoutRetry && i < maxRetries) {\n                    try {\n                        Thread.sleep(Math.round(curWaitSeconds * 1000));\n                    } catch (InterruptedException ie) {\n                        logger.warn(\"RestClient timeout retry sleep interrupted, returning most recent response\", ie);\n                        return curResponse;\n                    }\n                    curWaitSeconds = curWaitSeconds * initialWaitSeconds;\n                    continue;\n                } else {\n                    throw new BaseException(\"Timeout error calling REST request\", e);\n                }\n            }\n            if (curResponse.statusCode == TOO_MANY && i < maxRetries) {\n                try {\n                    Thread.sleep(Math.round(curWaitSeconds * 1000));\n                } catch (InterruptedException e) {\n                    logger.warn(\"RestClient velocity retry sleep interrupted, returning most recent response\", e);\n                    return curResponse;\n                }\n                curWaitSeconds = curWaitSeconds * initialWaitSeconds;\n            } else {\n                break;\n            }\n        }\n\n        return curResponse;\n    }\n    protected RestResponse callInternal() throws TimeoutException {\n        if (uriString == null || uriString.isEmpty()) throw new IllegalStateException(\"No URI set in RestClient\");\n        RequestFactory tempFactory = this.isolate ? new SimpleRequestFactory() : null;\n        try {\n            Request request = makeRequest(tempFactory != null ? tempFactory : (overrideRequestFactory != null ? overrideRequestFactory : getDefaultRequestFactory()));\n            if (timeoutSeconds < 2) timeoutSeconds = 2;\n            request.idleTimeout(timeoutSeconds-1, TimeUnit.SECONDS);\n            CompletableResponseListener listener = new CompletableResponseListener(request, maxResponseSize);\n            try {\n                CompletableFuture<ContentResponse> future = listener.send();\n                ContentResponse response = future.get(timeoutSeconds, TimeUnit.SECONDS);\n                return new RestResponse(this, response);\n            } catch (TimeoutException e) {\n                logger.warn(\"RestClient request timed out after \" + timeoutSeconds + \"s to \" + request.getURI());\n                // abort request to make sure it gets closed and cleaned up\n                request.abort(e);\n                throw e;\n            }\n        } catch (Exception e) {\n            throw new BaseException(\"Error calling HTTP request to \" + uriString, e);\n        } finally {\n            if (tempFactory != null) tempFactory.destroy();\n        }\n    }\n\n    protected Request makeRequest(RequestFactory requestFactory) {\n        final Request request = requestFactory.makeRequest(uriString);\n        request.method(method.name());\n        // set charset on request?\n\n        // add headers and parameters\n        for (KeyValueString nvp : headerList) request.headers(headers -> headers.put(nvp.key, nvp.value));\n        for (KeyValueString nvp : bodyParameterList) request.param(nvp.key, nvp.value);\n        // authc\n        if (username != null && !username.isEmpty()) {\n            String unPwString = username + ':' + password;\n            String basicAuthStr  = \"Basic \" + Base64.getEncoder().encodeToString(unPwString.getBytes());\n            request.headers(headers -> headers.put(HttpHeader.AUTHORIZATION, basicAuthStr));\n\n            // using basic Authorization header instead, too many issues with this: httpClient.getAuthenticationStore().addAuthentication(new BasicAuthentication(uri, BasicAuthentication.ANY_REALM, username, password));\n        }\n\n        if (multiPart != null) {\n            multiPart.close();\n            if (method == Method.POST) {\n                // HttpClient will send the correct headers when it's a multi-part content type (ie set content type to multipart/form-data, etc)\n                request.body(multiPart);\n            } else {\n                throw new IllegalStateException(\"Can only use multipart body with POST method, not supported for method \" + method + \"; if you need a different effective request method try using the X-HTTP-Method-Override header\");\n            }\n        } else if (bodyText != null && !bodyText.isEmpty()) {\n            request.body(new StringRequestContent(contentType, bodyText, charset));\n            // not needed, set by call to request.content() with passed contentType: request.header(HttpHeader.CONTENT_TYPE, contentType);\n        }\n\n        request.accept(acceptContentType != null && !acceptContentType.isEmpty() ? acceptContentType : contentType);\n\n        if (logger.isTraceEnabled())\n            logger.trace(\"RestClient request \" + request.getMethod() + \" \" + request.getURI() + \" Headers: \" + request.getHeaders());\n\n        return request;\n    }\n\n    /** Call in background  */\n    public Future<RestResponse> callFuture() {\n        if (uriString == null || uriString.isEmpty()) throw new IllegalStateException(\"No URI set in RestClient\");\n        return new RestClientFuture(this);\n    }\n\n    public static class RestResponse {\n        private RestClient rci;\n        protected ContentResponse response;\n        protected byte[] bytes = null;\n        private Map<String, ArrayList<String>> headers = new LinkedHashMap<>();\n        private int statusCode;\n        private String reasonPhrase, contentType, encoding;\n\n        RestResponse(RestClient rci, ContentResponse response) {\n            this.rci = rci;\n            this.response = response;\n            statusCode = response.getStatus();\n            reasonPhrase = response.getReason();\n            contentType = response.getMediaType();\n            encoding = response.getEncoding();\n            if (encoding == null || encoding.isEmpty()) encoding = \"UTF-8\";\n\n            // get headers\n            for (HttpField hdr : response.getHeaders()) {\n                String name = hdr.getName();\n                ArrayList<String> curList = headers.get(name);\n                if (curList == null) {\n                    curList = new ArrayList<>();\n                    headers.put(name, curList);\n                }\n                curList.addAll(Arrays.asList(hdr.getValues()));\n            }\n\n            // get the response body\n            bytes = response.getContent();\n        }\n\n        /** If status code is not in the 200 range throw an exception with details; call this first for easy error\n         * handling or skip it to handle manually or allow errors */\n        public RestResponse checkError() {\n            if (statusCode < 200 || statusCode >= 300) {\n                logger.info(\"Error \" + statusCode + \" (\" + reasonPhrase + \") in response to \" + rci.method + \" to \" + rci.uriString + \", response text:\\n\" + text());\n                throw new HttpResponseException(\"Error \" + statusCode + \" (\" + reasonPhrase + \") in response to \" + rci.method + \" to \" + rci.uriString, response);\n            }\n\n            return this;\n        }\n\n        public RestClient getClient() { return rci; }\n\n        public int getStatusCode() { return statusCode; }\n        public String getReasonPhrase() { return reasonPhrase; }\n        public String getContentType() { return contentType; }\n        public String getEncoding() { return encoding; }\n\n        /** Get the plain text of the response */\n        public String text() {\n            try {\n                if (\"UTF-8\".equals(encoding)) {\n                    return toStringCleanBom(bytes);\n                } else {\n                    return new String(bytes, encoding);\n                }\n            } catch (UnsupportedEncodingException e) {\n                throw new BaseException(\"Error decoding REST response\", e);\n            }\n        }\n\n        /** Parse the response as JSON and return an Object */\n        public Object jsonObject() {\n            try {\n                return new JsonSlurperClassic().parseText(text());\n            } catch (Throwable t) {\n                throw new BaseException(\"Error parsing JSON response from request to \" + rci.uriString, t);\n            }\n        }\n        /** Parse the response as XML and return a MNode */\n        public MNode xmlNode() { return MNode.parseText(rci.uriString, text()); }\n\n        /** Get bytes from a binary response */\n        public byte[] bytes() { return bytes; }\n        // FUTURE: handle stream response, but in a way that avoids requiring an explicit close for other methods\n\n        public Map<String, ArrayList<String>> headers() { return headers; }\n        public String headerFirst(String name) {\n            List<String> valueList = headers.get(name);\n            return valueList != null && valueList.size() > 0 ? valueList.get(0) : null;\n        }\n\n        static String toStringCleanBom(byte[] bytes) throws UnsupportedEncodingException {\n            // NOTE: this only supports UTF-8 for now!\n            if (bytes == null || bytes.length == 0) return \"\";\n            // UTF-8 BOM = 239, 187, 191\n            if (bytes[0] == (byte) 239) {\n                return new String(bytes, 3, bytes.length - 3, StandardCharsets.UTF_8);\n            } else {\n                return new String(bytes, StandardCharsets.UTF_8);\n            }\n        }\n    }\n\n    public static class UriBuilder {\n        private RestClient rci;\n        private String protocol = \"http\";\n        private String host = null;\n        private int port = 80;\n        private StringBuilder path = new StringBuilder();\n        private Map<String, String> parameters = null;\n        private String fragment = null;\n\n        UriBuilder(RestClient rci) { this.rci = rci; }\n\n        public UriBuilder protocol(String protocol) {\n            if (protocol == null || protocol.isEmpty()) throw new IllegalArgumentException(\"Empty protocol not allowed\");\n            this.protocol = protocol;\n            return this;\n        }\n\n        public UriBuilder host(String host) {\n            if (host == null || host.isEmpty()) throw new IllegalArgumentException(\"Empty host not allowed\");\n            this.host = host;\n            return this;\n        }\n\n        public UriBuilder port(int port) {\n            if (port <= 0) throw new IllegalArgumentException(\"Invalid port \" + port);\n            this.port = port;\n            return this;\n        }\n\n        public UriBuilder path(String pathEl) {\n            if (pathEl == null || pathEl.isEmpty()) return this;\n            if (!pathEl.startsWith(\"/\")) path.append(\"/\");\n            path.append(pathEl);\n            int lastIndex = path.length() - 1;\n            if ('/' == path.charAt(lastIndex)) path.deleteCharAt(lastIndex);\n            return this;\n        }\n\n        public UriBuilder parameter(String name, String value) {\n            if (parameters == null) parameters = new LinkedHashMap<>();\n            parameters.put(name, value);\n            return this;\n        }\n\n        public UriBuilder parameters(Map<String, String> parms) {\n            if (parms == null) return this;\n            if (parameters == null) {\n                parameters = new LinkedHashMap<>(parms);\n            } else {\n                parameters.putAll(parms);\n            }\n\n            return this;\n        }\n\n        public UriBuilder fragment(String fragment) {\n            this.fragment = fragment;\n            return this;\n        }\n\n        public RestClient build() throws URISyntaxException, UnsupportedEncodingException {\n            if (host == null || host.isEmpty())\n                throw new IllegalArgumentException(\"No host specified, call the host() method before build()\");\n\n            StringBuilder uriSb = new StringBuilder();\n            uriSb.append(protocol).append(\"://\").append(host).append(':').append(port);\n\n            if (path.length() == 0) path.append(\"/\");\n            uriSb.append(path);\n\n            String query = parametersMapToString(parameters);\n            if (query != null && query.length() > 0) uriSb.append('?').append(query);\n\n            return rci.uri(uriSb.toString());\n        }\n    }\n\n    public static String parametersMapToString(Map<String, ?> parameters) throws UnsupportedEncodingException {\n        if (parameters == null || parameters.size() == 0) return null;\n        StringBuilder query = new StringBuilder();\n        for (Map.Entry<String, ?> parm : parameters.entrySet()) {\n            if (query.length() > 0) query.append(\"&\");\n            Object valueObj = parm.getValue();\n            if (valueObj == null) continue;\n            String valueStr = ObjectUtilities.toPlainString(valueObj);\n            query.append(URLEncoder.encode(parm.getKey(), \"UTF-8\"))\n                    .append(\"=\").append(URLEncoder.encode(valueStr, \"UTF-8\"));\n        }\n        return query.toString();\n    }\n\n    private static class KeyValueString {\n        KeyValueString(String key, String value) {\n            this.key = key;\n            this.value = value;\n        }\n\n        public String key;\n        public String value;\n    }\n\n    public static class RetryListener implements Response.CompleteListener {\n        RestClientFuture rcf;\n        RetryListener(RestClientFuture rcf) { this.rcf = rcf; }\n        @Override public void onComplete(Result result) {\n            if (result.getResponse().getStatus() == TOO_MANY && rcf.retryCount < rcf.rci.maxRetries && !rcf.cancelled) {\n                // lock before new request to make sure not in the middle of get()\n                rcf.retryLock.lock();\n                try {\n                    try {\n                        Thread.sleep(Math.round(rcf.curWaitSeconds * 1000));\n                    } catch (InterruptedException e) {\n                        logger.warn(\"RestClientFuture retry sleep interrupted, returning most recent response\", e);\n                        return;\n                    }\n                    // update wait time and count\n                    rcf.curWaitSeconds = rcf.curWaitSeconds * rcf.rci.initialWaitSeconds;\n                    rcf.retryCount++;\n\n                    // do a new request, still in the background\n                    rcf.newRequest();\n                } finally { rcf.retryLock.unlock(); }\n            }\n        }\n    }\n    public static class RestClientFuture implements Future<RestResponse> {\n        RestClient rci;\n        RequestFactory tempRequestFactory = null;\n        CompletableResponseListener listener;\n        CompletableFuture<ContentResponse> future;\n        volatile float curWaitSeconds;\n        volatile int retryCount = 0;\n        volatile boolean cancelled = false;\n        ReentrantLock retryLock = new ReentrantLock();\n        ContentResponse lastResponse = null;\n\n        RestClientFuture(RestClient rci) {\n            this.rci = rci;\n            curWaitSeconds = rci.initialWaitSeconds;\n            if (curWaitSeconds == 0) curWaitSeconds = 1;\n            // start the initial request\n            newRequest();\n        }\n\n        void newRequest() {\n            if (tempRequestFactory != null) tempRequestFactory.destroy();\n            tempRequestFactory = rci.isolate ? new SimpleRequestFactory() : null;\n\n            // NOTE: RestClientFuture methods call httpClient.stop() so not handled here\n            try {\n                Request request = rci.makeRequest(tempRequestFactory != null ? tempRequestFactory :\n                        (rci.overrideRequestFactory != null ? rci.overrideRequestFactory : getDefaultRequestFactory()));\n                // use a CompleteListener to retry in background\n                request.onComplete(new RetryListener(this));\n                listener = new CompletableResponseListener(request, rci.maxResponseSize);\n                future = listener.send();\n            } catch (Exception e) {\n                throw new BaseException(\"Error calling REST request to \" + rci.uriString, e);\n            }\n        }\n\n        @Override public boolean isCancelled() { return cancelled || (future != null && future.isCancelled()); }\n        @Override public boolean isDone() { return retryCount >= rci.maxRetries && (future != null && future.isDone()); }\n\n        @Override public boolean cancel(boolean mayInterruptIfRunning) {\n            retryLock.lock();\n            try {\n                try {\n                    cancelled = true;\n                    return future != null && future.cancel(mayInterruptIfRunning);\n                } finally {\n                    if (tempRequestFactory != null) {\n                        tempRequestFactory.destroy();\n                        tempRequestFactory = null;\n                    }\n                }\n            } finally { retryLock.unlock(); }\n        }\n\n        @Override\n        public RestResponse get() throws InterruptedException, ExecutionException {\n            try {\n                return get(rci.timeoutSeconds, TimeUnit.SECONDS);\n            } catch (TimeoutException e) {\n                throw new BaseException(\"Timeout error calling REST request\", e);\n            }\n        }\n\n        @Override\n        public RestResponse get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException {\n            do {\n                // lock before new request to make sure not in the middle of retry\n                retryLock.lock();\n                try {\n                    try {\n                        lastResponse = future.get(timeout, unit);\n                        if (lastResponse.getStatus() != TOO_MANY) break;\n                    } finally {\n                        if (tempRequestFactory != null) {\n                            tempRequestFactory.destroy();\n                            tempRequestFactory = null;\n                        }\n                    }\n                } finally { retryLock.unlock(); }\n            } while (!cancelled && retryCount < rci.maxRetries);\n\n            return new RestResponse(rci, lastResponse);\n        }\n    }\n\n    public interface RequestFactory {\n        Request makeRequest(String uriString);\n        void destroy();\n    }\n    /** The default RequestFactory, uses mostly Jetty HttpClient defaults and retains HttpClient instance between requests. */\n    public static class SimpleRequestFactory implements RequestFactory {\n        private final HttpClient httpClient;\n\n        public SimpleRequestFactory() {\n            this(true, false);\n        }\n\n        public SimpleRequestFactory(boolean trustAll, boolean disableCookieManagement) {\n            SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(true);\n            sslContextFactory.setEndpointIdentificationAlgorithm(null);\n            ClientConnector clientConnector = new ClientConnector();\n            clientConnector.setSslContextFactory(sslContextFactory);\n            httpClient = new HttpClient(new HttpClientTransportDynamic(clientConnector));\n\n            if (disableCookieManagement) httpClient.setHttpCookieStore(new HttpCookieStore.Empty());\n            // use a default idle timeout of 15 seconds, should be lower than server idle timeouts which will vary by server but 30 seconds seems to be common\n            httpClient.setIdleTimeout(15000);\n            try { httpClient.start(); } catch (Exception e) { throw new BaseException(\"Error starting HTTP client\", e); }\n        }\n\n        @Override public Request makeRequest(String uriString) {\n            return httpClient.newRequest(uriString);\n        }\n\n        HttpClient getHttpClient() { return httpClient; }\n\n        @Override public void destroy() {\n            if (httpClient != null && httpClient.isRunning()) {\n                try { httpClient.stop(); }\n                catch (Exception e) { logger.error(\"Error stopping SimpleRequestFactory HttpClient\", e); }\n            }\n        }\n    }\n    /** RequestFactory with explicit pooling parameters and options specific to the Jetty HttpClient */\n    public static class PooledRequestFactory implements RequestFactory {\n        private HttpClient httpClient;\n        private final String shortName;\n        private int poolSize = 64;\n        private int queueSize = 1024;\n        private long validationTimeoutMillis = 1000;\n\n        private SslContextFactory.Client sslContextFactory = null;\n        private HttpClientTransport transport = null;\n        private QueuedThreadPool executor = null;\n        private Scheduler scheduler = null;\n\n        /** The required shortName is used as a prefix for thread names and should be distinct. */\n        public PooledRequestFactory(String shortName) { this.shortName = shortName; }\n\n        /** Note that if a transport is specified it must include the SslContextFactory.Client so this is ignored. */\n        public PooledRequestFactory with(SslContextFactory.Client sslcf) { sslContextFactory = sslcf; return this; }\n        public PooledRequestFactory with(HttpClientTransport transport) { this.transport = transport; return this; }\n        public PooledRequestFactory with(QueuedThreadPool executor) { this.executor = executor; return this; }\n        public PooledRequestFactory with(Scheduler scheduler) { this.scheduler = scheduler; return this; }\n\n        /** Size of the HTTP connection pool per destination (scheme + host + port) */\n        public PooledRequestFactory poolSize(int size) { poolSize = size; return this; }\n        /** Size of the HTTP request queue per destination (scheme + host + port) */\n        public PooledRequestFactory queueSize(int size) { queueSize = size; return this; }\n        /** Quarantine timeout for connection validation, see ValidatingConnectionPool javadoc for details */\n        public PooledRequestFactory validationTimeout(long millis) { validationTimeoutMillis = millis; return this; }\n\n        public PooledRequestFactory init() {\n            if (transport == null) {\n                if (sslContextFactory == null) {\n                    sslContextFactory = new SslContextFactory.Client(true);\n                    sslContextFactory.setEndpointIdentificationAlgorithm(null);\n                }\n                ClientConnector clientConnector = new ClientConnector();\n                clientConnector.setSslContextFactory(sslContextFactory);\n                transport = new HttpClientTransportDynamic(clientConnector);\n            }\n\n            if (executor == null) { executor = new QueuedThreadPool(); executor.setName(shortName + \"-queue\"); }\n            if (scheduler == null) scheduler = new ScheduledExecutorScheduler(shortName + \"-scheduler\", false);\n\n            transport.setConnectionPoolFactory(destination -> new ValidatingConnectionPool(destination,\n                    destination.getHttpClient().getMaxConnectionsPerDestination(),\n                    destination.getHttpClient().getScheduler(), validationTimeoutMillis));\n\n            httpClient = new HttpClient(transport);\n            httpClient.setExecutor(executor);\n            httpClient.setScheduler(scheduler);\n            httpClient.setMaxConnectionsPerDestination(poolSize);\n            httpClient.setMaxRequestsQueuedPerDestination(queueSize);\n\n            try { httpClient.start(); } catch (Exception e) { throw new BaseException(\"Error starting HTTP client for \" + shortName, e); }\n\n            return this;\n        }\n\n        @Override public Request makeRequest(String uriString) {\n            return httpClient.newRequest(uriString);\n        }\n\n        public HttpClient getHttpClient() { return httpClient; }\n\n        @Override public void destroy() {\n            if (httpClient != null && httpClient.isRunning()) {\n                try { httpClient.stop(); }\n                catch (Exception e) { logger.error(\"Error stopping PooledRequestFactory HttpClient for \" + shortName, e); }\n            }\n        }\n    }\n}"
  },
  {
    "path": "framework/src/main/java/org/moqui/util/SimpleTopic.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.util;\n\n/** A simple topic publish interface. Listeners (subscribers) should be handled directly on the topic implementation. */\npublic interface SimpleTopic<E> {\n    void publish(E message);\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/util/StringUtilities.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.util;\n\nimport org.apache.commons.codec.binary.*;\nimport org.codehaus.groovy.runtime.DefaultGroovyMethods;\nimport org.moqui.BaseException;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\nimport org.w3c.dom.Element;\n\nimport javax.swing.text.MaskFormatter;\nimport java.io.UnsupportedEncodingException;\nimport java.math.BigDecimal;\nimport java.math.RoundingMode;\nimport java.net.URI;\nimport java.net.URISyntaxException;\nimport java.net.URLDecoder;\nimport java.net.URLEncoder;\nimport java.security.SecureRandom;\nimport java.text.ParseException;\nimport java.util.*;\nimport java.util.regex.Pattern;\n\n/**\n * These are utilities that should exist elsewhere, but I can't find a good simple library for them, and they are\n * stupid but necessary for certain things.\n */\n@SuppressWarnings(\"unused\")\npublic class StringUtilities {\n    protected static final Logger logger = LoggerFactory.getLogger(StringUtilities.class);\n\n    public static final Map<String, String> xmlEntityMap;\n    static {\n        HashMap<String, String> map = new HashMap<>(5);\n        map.put(\"apos\", \"\\'\"); map.put(\"quot\", \"\\\"\"); map.put(\"amp\", \"&\"); map.put(\"lt\", \"<\"); map.put(\"gt\", \">\");\n        xmlEntityMap = map;\n    }\n\n    private static final String[] SCALES = new String[]{\"\", \"thousand\", \"million\", \"billion\", \"trillion\", \"quadrillion\", \"quintillion\", \"sextillion\"};\n    private static final String[] SUBTWENTY = new String[]{\"\", \"one\", \"two\", \"three\", \"four\", \"five\", \"six\", \"seven\", \"eight\", \"nine\",\n            \"ten\", \"eleven\", \"twelve\", \"thirteen\", \"fourteen\", \"fifteen\", \"sixteen\", \"seventeen\", \"eighteen\", \"nineteen\"};\n    private static final String[] DECADES = new String[]{\"\", \"ten\", \"twenty\", \"thirty\", \"forty\", \"fifty\", \"sixty\", \"seventy\", \"eighty\", \"ninety\"};\n    private static final String NEG_NAME = \"negative\";\n\n    public static String elementValue(Element element) {\n        if (element == null) return null;\n        element.normalize();\n        org.w3c.dom.Node textNode = element.getFirstChild();\n        if (textNode == null) return null;\n\n        StringBuilder value = new StringBuilder();\n        if (textNode.getNodeType() == org.w3c.dom.Node.CDATA_SECTION_NODE || textNode.getNodeType() == org.w3c.dom.Node.TEXT_NODE)\n            value.append(textNode.getNodeValue());\n        while ((textNode = textNode.getNextSibling()) != null) {\n            if (textNode.getNodeType() == org.w3c.dom.Node.CDATA_SECTION_NODE || textNode.getNodeType() == org.w3c.dom.Node.TEXT_NODE)\n                value.append(textNode.getNodeValue());\n        }\n\n        return value.toString();\n    }\n\n    public static String encodeForXmlAttribute(String original) { return encodeForXmlAttribute(original, false); }\n\n    public static String encodeForXmlAttribute(String original, boolean addZeroWidthSpaces) {\n        if (original == null) return \"\";\n        StringBuilder newValue = new StringBuilder(original);\n        for (int i = 0; i < newValue.length(); i++) {\n            char curChar = newValue.charAt(i);\n            switch (curChar) {\n                case '\\'': newValue.replace(i, i + 1, \"&apos;\"); i += 5; break;\n                case '\"': newValue.replace(i, i + 1, \"&quot;\"); i += 5; break;\n                case '&': newValue.replace(i, i + 1, \"&amp;\"); i += 4; break;\n                case '<': newValue.replace(i, i + 1, \"&lt;\"); i += 3; break;\n                case '>': newValue.replace(i, i + 1, \"&gt;\"); i += 3; break;\n                case 0x5: newValue.replace(i, i + 1, \"...\"); i += 2; break;\n                case 0x12: newValue.replace(i, i + 1, \"&apos;\"); i += 5; break;\n                case 0x13: newValue.replace(i, i + 1, \"&quot;\"); i += 5; break;\n                case 0x14: newValue.replace(i, i + 1, \"&quot;\"); i += 5; break;\n                case 0x16: newValue.replace(i, i + 1, \"-\"); break;\n                case 0x17: newValue.replace(i, i + 1, \"-\"); break;\n                case 0x19: newValue.replace(i, i + 1, \"tm\"); i++; break;\n                default:\n                    if (DefaultGroovyMethods.compareTo(curChar, 0x20) < 0 && curChar != 0x9 && curChar != 0xA && curChar != 0xD) {\n                        // the only valid values < 0x20 are 0x9 (tab), 0xA (newline), 0xD (carriage return)\n                        newValue.deleteCharAt(i);\n                        i--;\n                    } else if (DefaultGroovyMethods.compareTo(curChar, 0x7F) > 0) {\n                        // Replace each char which is out of the ASCII range with a XML entity\n                        String s = \"&#\" + ((int) curChar) + \";\";\n                        newValue.replace(i, i + 1, s);\n                        i += s.length() - 1;\n                    } else if (addZeroWidthSpaces) {\n                        newValue.insert(i, \"&#8203;\");\n                        i += 7;\n                    }\n            }\n        }\n        return newValue.toString();\n    }\n\n    /** See if contains only characters allowed by URLDecoder, if so doesn't need to be encoded or is already encoded */\n    public static boolean isUrlDecoderSafe(String text) {\n        // see https://docs.oracle.com/javase/8/docs/api/index.html?java/net/URLEncoder.html\n        // letters, digits, and: \"-\", \"_\", \".\", and \"*\"\n        // allow '%' for strings already encoded\n        // '+' is treated as space, so allow but means we can't detect if already encoded vs doesn't need to be encoded\n        if (text == null) return true;\n        // NOTE: expect mostly shorter strings to charAt() faster than text.toCharArray() and chars[i]; more memory efficient too\n        int textLen = text.length();\n        for (int i = 0; i < textLen; i++) {\n            char ch = text.charAt(i);\n            if (Character.isLetterOrDigit(ch)) continue;\n            if (ch == '.' || ch == '_' || ch == '-' || ch == '*' || ch == '+') continue;\n            if (ch == '%') {\n                if (i + 2 < textLen) {\n                    char ch1 = text.charAt(i + 1);\n                    char ch2 = text.charAt(i + 2);\n                    if (isHexChar(ch1) && isHexChar(ch2)) {\n                        i += 2;\n                        continue;\n                    }\n                }\n                return false;\n            }\n            return false;\n        }\n        return true;\n    }\n    public static String urlEncodeIfNeeded(String text) {\n        if (isUrlDecoderSafe(text)) return text;\n        try {\n            return URLEncoder.encode(text, \"UTF-8\");\n        } catch (UnsupportedEncodingException e) {\n            // should never happen with hard coded encoding\n            return text;\n        }\n    }\n    public static boolean isUrlSafeRfc3986(String text) {\n        if (text == null) return true;\n        // RFC 3986 URL path chars: a-z A-Z 0-9 . _ - + ~ ! $ & ' ( ) * , ; = : @\n        char[] chars = text.toCharArray();\n        for (int i = 0; i < chars.length; i++) {\n            char ch = chars[i];\n            if (Character.isLetterOrDigit(ch)) continue;\n            if (ch == '.' || ch == '_' || ch == '-' || ch == '+' || ch == '~' || ch == '!' || ch == '$' || ch == '&' || ch == '\\'' ||\n                    ch == '(' || ch == ')' || ch == '*' || ch == ',' || ch == ';' || ch == '=' || ch == ':' || ch == '@') continue;\n            return false;\n        }\n        return true;\n    }\n\n    public static ArrayList<String> pathStringToList(String path, int skipSegments) {\n        ArrayList<String> pathList = new ArrayList<>();\n        if (path == null || path.isEmpty()) return pathList;\n        if (path.charAt(0) == '/') path = path.substring(1);\n        String[] pathArray = path.split(\"/\");\n        for (int i = skipSegments; i < pathArray.length; i++) {\n            String pathSegment = pathArray[i];\n            if (pathSegment == null || pathSegment.isEmpty()) continue;\n            try { pathSegment = URLDecoder.decode(pathSegment, \"UTF-8\"); }\n            catch (Exception e) { if (logger.isTraceEnabled()) logger.trace(\"Error decoding screen path segment ${pathSegment}\", e); }\n            pathList.add(pathSegment);\n        }\n        return pathList;\n    }\n\n    public static String camelCaseToPretty(String camelCase) {\n        if (camelCase == null || camelCase.length() == 0) return \"\";\n        StringBuilder prettyName = new StringBuilder();\n        String lastPart = null;\n        for (String part : camelCase.split(\"(?=[A-Z0-9\\\\.#])\")) {\n            if (part.length() == 0) continue;\n            char firstChar = part.charAt(0);\n            if (firstChar == '.' || firstChar == '#') {\n                if (part.length() == 1) continue;\n                part = part.substring(1);\n                firstChar = part.charAt(0);\n            }\n            if (Character.isLowerCase(firstChar)) part = Character.toUpperCase(firstChar) + part.substring(1);\n            if (part.equalsIgnoreCase(\"id\")) part = \"ID\";\n\n            if (part.equals(lastPart)) continue;\n            lastPart = part;\n            if (prettyName.length() > 0) prettyName.append(\" \");\n            prettyName.append(part);\n        }\n        return prettyName.toString();\n    }\n    public static String prettyToCamelCase(String pretty, boolean firstUpper) {\n        if (pretty == null || pretty.length() == 0) return \"\";\n        StringBuilder camelCase = new StringBuilder();\n        char[] prettyChars = pretty.toCharArray();\n        boolean upperNext = firstUpper;\n        for (int i = 0; i < prettyChars.length; i++) {\n            char curChar = prettyChars[i];\n            if (Character.isLetterOrDigit(curChar)) {\n                curChar = upperNext ? Character.toUpperCase(curChar) : Character.toLowerCase(curChar);\n                camelCase.append(curChar);\n                upperNext = false;\n            } else {\n                upperNext = true;\n            }\n        }\n        return camelCase.toString();\n    }\n\n    public static String removeNonAlphaNumeric(String origString) {\n        if (origString == null || origString.isEmpty()) return origString;\n        int origLength = origString.length();\n        char[] orig = origString.toCharArray();\n        StringBuilder remBuffer = new StringBuilder();\n        int replIdx = 0;\n        for (int i = 0; i < origLength; i++) {\n            char ochr = orig[i];\n            if (Character.isLetterOrDigit(ochr)) { remBuffer.append(ochr); }\n        }\n        return remBuffer.toString();\n    }\n    public static String replaceNonAlphaNumeric(String origString, char chr) {\n        if (origString == null || origString.isEmpty()) return origString;\n        int origLength = origString.length();\n        char[] orig = origString.toCharArray();\n        char[] repl = new char[origLength];\n        int replIdx = 0;\n        for (int i = 0; i < origLength; i++) {\n            char ochr = orig[i];\n            if (Character.isLetterOrDigit(ochr)) { repl[replIdx++] = ochr; }\n            else { if (replIdx == 0 || repl[replIdx-1] != chr) { repl[replIdx++] = chr; } }\n        }\n        return new String(repl, 0, replIdx);\n    }\n    public static boolean isAlphaNumeric(String str, String allowedChars) {\n        if (str == null) return true;\n        char[] strChars = str.toCharArray();\n        for (int i = 0; i < strChars.length; i++) {\n            char c = strChars[i];\n            if (!Character.isLetterOrDigit(c) && (allowedChars == null || allowedChars.indexOf(c) == -1)) return false;\n        }\n        return true;\n    }\n    public static String findFirstNumber(String orig) {\n        if (orig == null || orig.isEmpty()) return orig;\n        int origLength = orig.length();\n        StringBuilder numBuffer = new StringBuilder();\n        for (int i = 0; i < origLength; i++) {\n            char curChar = orig.charAt(i);\n            if (Character.isDigit(curChar)) {\n                numBuffer.append(curChar);\n            } else if (numBuffer.length() > 0 && (curChar == '.' || curChar == ',')) {\n                numBuffer.append(curChar);\n            } else if (numBuffer.length() > 0) {\n                // if we have any numbers and find something else we're done\n                break;\n            }\n        }\n        if (numBuffer.length() == 0) return null;\n        return numBuffer.toString();\n    }\n\n    public static String decodeFromXml(String original) {\n        if (original == null || original.isEmpty()) return original;\n        int pos = original.indexOf(\"&\");\n        if (pos == -1) return original;\n\n        StringBuilder newValue = new StringBuilder(original);\n        while (pos < newValue.length() && pos >= 0) {\n            int scIndex = newValue.indexOf(\";\", pos + 1);\n            if (scIndex == -1) break;\n            String entityName = newValue.substring(pos + 1, scIndex);\n            String replaceChar;\n            if (entityName.charAt(0) == '#') {\n                String decStr = entityName.substring(1);\n                int decInt = Integer.valueOf(decStr);\n                replaceChar = new String(Character.toChars(decInt));\n            } else {\n                replaceChar = xmlEntityMap.get(entityName);\n            }\n            // logger.warn(\"========= pos=${pos}, entityName=${entityName}, replaceChar=${replaceChar}\")\n            if (replaceChar != null) newValue.replace(pos, scIndex + 1, replaceChar);\n            pos = newValue.indexOf(\"&\", pos + 1);\n        }\n        return newValue.toString();\n    }\n\n    public static String cleanStringForJavaName(String original) {\n        if (original == null || original.isEmpty()) return original;\n        char[] origChars = original.toCharArray();\n        char[] cleanChars = new char[origChars.length];\n        boolean isIdentifierStart = true;\n        for (int i = 0; i < origChars.length; i++) {\n            char curChar = origChars[i];\n            // remove dots too, get down to simple class name to work best with Groovy class compiling and loading\n            // if (curChar == '.') { cleanChars[i] = '.'; isIdentifierStart = true; continue; }\n            // also don't allow $ as groovy blows up on it with class compile/load\n            if (curChar != '$' && (isIdentifierStart ? Character.isJavaIdentifierStart(curChar) : Character.isJavaIdentifierPart(curChar))) {\n                cleanChars[i] = curChar;\n            } else {\n                cleanChars[i] = '_';\n            }\n            isIdentifierStart = false;\n        }\n        // logger.warn(\"cleaned \" + original + \" to \" + new String(cleanChars));\n        return new String(cleanChars);\n    }\n    public static String getExpressionClassName(String expression) {\n        String hashCode = Integer.toHexString(expression.hashCode());\n        int hashLength = hashCode.length();\n        int exprLength = expression.length();\n        int copyChars = exprLength < 30 ? exprLength : 30;\n        int length = hashLength + copyChars + 1;\n        char[] cnChars = new char[length];\n        cnChars[0] = 'S';\n        for (int i = 0; i < hashLength; i++) cnChars[i + 1] = hashCode.charAt(i);\n        for (int i = 0; i < copyChars; i++) {\n            char exprChar = expression.charAt(i);\n            if (exprChar == '$' || !Character.isJavaIdentifierPart(exprChar)) exprChar = '_';\n            cnChars[i + hashLength + 1] = exprChar;\n        }\n        return new String(cnChars);\n    }\n\n    public static String encodeAsciiFilename(String filename) {\n        try {\n            URI uri = new URI(null, null, filename, null);\n            return uri.toASCIIString();\n        } catch (URISyntaxException e) {\n            logger.warn(\"Error encoding ASCII filename: \" + e.toString());\n            return filename;\n        }\n    }\n\n    public static String toStringCleanBom(byte[] bytes) {\n        // NOTE: this only supports UTF-8 for now!\n        if (bytes == null || bytes.length == 0) return \"\";\n        try {\n            // UTF-8 BOM = 239, 187, 191\n            if (bytes[0] == (byte) 239) {\n                return new String(bytes, 3, bytes.length - 3, \"UTF-8\");\n            } else {\n                return new String(bytes, \"UTF-8\");\n            }\n        } catch (UnsupportedEncodingException e) {\n            throw new BaseException(\"Error converting bytes to String\", e);\n        }\n    }\n\n    public static String escapeElasticQueryString(CharSequence queryString) {\n        if (queryString == null || queryString.length() == 0) return \"\";\n        int length = queryString.length();\n        StringBuilder sb = new StringBuilder(length * 2);\n        for (int i = 0; i < length; i++) {\n            char c = queryString.charAt(i);\n            if (\"+-=&|><!(){}[]^\\\"~*?:\\\\/\".indexOf(c) != -1) sb.append(\"\\\\\");\n            sb.append(c);\n        }\n        return sb.toString();\n    }\n    public static Pattern elasticSearchChars = Pattern.compile(\"[^*:\\\\\\\\?_~\\\\/\\\\.\\\\[\\\\]\\\\{\\\\}+?*><=\\\"^-]*\");\n    public static Set<String> elasticSearchWords = new HashSet<>(Arrays.asList(\"AND\", \"OR\", \"NOT\"));\n    public static String elasticQueryAutoWildcard(CharSequence query, boolean allFieldPrefix) {\n        // TODO: would be nice to somehow parse the query string, matching parentheses and quotes, and add *: for the field if none for each term\n        if (query == null) return \"*\";\n        String queryString = query.toString().trim();\n        int length = queryString.length();\n        if (length == 0) return \"*\";\n\n        StringBuilder sb = new StringBuilder(length * 2);\n        String[] querySplit = queryString.split(\" \");\n\n        for (int i = 0; i < querySplit.length; i++) {\n            String term = querySplit[i].trim();\n            if (term.length() == 0) continue;\n            boolean isEsWord = elasticSearchWords.contains(term);\n            boolean noEsChars = !isEsWord && elasticSearchChars.matcher(term).matches();\n            if (sb.length() > 0) sb.append(' ');\n            if (!isEsWord && allFieldPrefix && noEsChars) sb.append(\"*:\");\n            sb.append(term);\n            if (!isEsWord && noEsChars) sb.append('*');\n        }\n        return sb.toString();\n\n        /* based on old code:\n        if (term) { termSb.append((term.split(' ') as List).collect({ it.matches(/[^*:\\\\?_~\\/\\.\\[\\]\\{\\}+?*><=\"^-]* /) ? (it + '*') : it }).join(' ')) } else { termSb.append('*') }\n\n        <if condition=\"queryString &amp;&amp; isAlphaNumeric(queryString, ' *?')\">\n            <set field=\"queryString\" from=\"queryString.split(' ').collect({ (!it || it in ['AND', 'OR', 'NOT']) ? it : '*:' + (it.contains('*') || it.contains('?') ? it : it + '*') }).join(' ')\"/></if>\n         */\n    }\n\n    public static String paddedNumber(long number, Integer desiredLength) {\n        StringBuilder outStrBfr = new StringBuilder(Long.toString(number));\n        if (desiredLength == null) return outStrBfr.toString();\n        while (desiredLength > outStrBfr.length()) outStrBfr.insert(0, \"0\");\n        return outStrBfr.toString();\n    }\n\n    public static String paddedString(String input, Integer desiredLength, Character padChar, boolean rightPad) {\n        if (!DefaultGroovyMethods.asBoolean(padChar)) padChar = ' ';\n        if (input == null) input = \"\";\n        StringBuilder outStrBfr = new StringBuilder(input);\n        if (desiredLength == null) return outStrBfr.toString();\n        while (desiredLength > outStrBfr.length()) if (rightPad) outStrBfr.append(padChar);\n        else outStrBfr.insert(0, padChar);\n        return outStrBfr.toString();\n    }\n\n    public static String paddedString(String input, Integer desiredLength, boolean rightPad) {\n        return paddedString(input, desiredLength, ' ', rightPad);\n    }\n\n    public static MaskFormatter masker(String mask, String placeholder) throws ParseException {\n        if (mask == null || mask.isEmpty()) return null;\n        MaskFormatter formatter = new MaskFormatter(mask);\n        formatter.setValueContainsLiteralCharacters(false);\n        if (placeholder != null && !placeholder.isEmpty()) {\n            if (placeholder.length() == 1) formatter.setPlaceholderCharacter(placeholder.charAt(0));\n            else formatter.setPlaceholder(placeholder);\n        }\n        return formatter;\n    }\n\n    public static String getRandomString(int length) {\n        return getRandomString(length, null);\n    }\n    public static String getRandomString(int length, BaseNCodec baseNCodec) {\n        if (baseNCodec == null) baseNCodec = org.apache.commons.codec.binary.Base64.builder()\n                .setUrlSafe(true)\n                .get();\n        SecureRandom sr = new SecureRandom();\n        byte[] randomBytes = new byte[length];\n        sr.nextBytes(randomBytes);\n        String randomStr = baseNCodec.encodeToString(randomBytes);\n        if (randomStr.length() > length) randomStr = randomStr.substring(0, length);\n        return randomStr;\n    }\n\n    public static ArrayList<String> getYearList(int years) {\n        ArrayList<String> yearList = new ArrayList<>(years);\n        int startYear = Calendar.getInstance().get(Calendar.YEAR);\n        for (int i = 0; i < years; i++) yearList.add(Integer.toString(startYear + i));\n        return yearList;\n    }\n\n    /** Convert any value from 0 to 999 inclusive, to a string. */\n    private static String tripleAsWords(int value, boolean useAnd) {\n        if (value < 0 || value >= 1000) throw new IllegalArgumentException(\"Illegal triple-value \" + value);\n        if (value < SUBTWENTY.length) return SUBTWENTY[value];\n\n        int subhun = value % 100;\n        int hun = value / 100;\n        StringBuilder sb = new StringBuilder(50);\n        if (hun > 0) sb.append(SUBTWENTY[hun]).append(\" hundred\");\n        if (subhun > 0) {\n            if (hun > 0) sb.append(useAnd ? \" and \" : \" \");\n            if (subhun < SUBTWENTY.length) {\n                sb.append(\" \").append(SUBTWENTY[subhun]);\n            } else {\n                int tens = subhun / 10;\n                int units = subhun % 10;\n                if (tens > 0) sb.append(DECADES[tens]);\n                if (units > 0) sb.append(\" \").append(SUBTWENTY[units]);\n            }\n        }\n        return sb.toString();\n    }\n\n    /** Convert any long input value to a text representation\n     * @param value  The value to convert\n     * @param useAnd true if you want to use the word 'and' in the text (eleven thousand and thirteen)\n     */\n    public static String numberToWords(long value, boolean useAnd) {\n        if (value == 0L) return SUBTWENTY[0];\n\n        // break the value down in to sets of three digits (thousands)\n        Integer[] thous = new Integer[SCALES.length];\n        boolean neg = value < 0;\n        // do not make negative numbers positive, to handle Long.MIN_VALUE\n        int scale = 0;\n        while (value != 0) {\n            // use abs to convert thousand-groups to positive, if needed.\n            thous[scale] = Math.abs((int) (value % 1000));\n            value = value / 1000;\n            scale++;\n        }\n\n        StringBuilder sb = new StringBuilder(scale * 40);\n        if (neg) sb.append(NEG_NAME).append(\" \");\n        boolean first = true;\n        while ((scale = --scale) > 0) {\n            if (!first) sb.append(\", \");\n            first = false;\n            if (thous[scale] > 0) sb.append(tripleAsWords(thous[scale], useAnd)).append(\" \").append(SCALES[scale]);\n        }\n\n        if (!first && thous[0] != 0) {\n            if (useAnd) sb.append(\" and \");\n            else sb.append(\" \");\n        }\n\n        sb.append(tripleAsWords(thous[0], useAnd));\n\n        sb.setCharAt(0, Character.toUpperCase(sb.charAt(0)));\n        return sb.toString();\n    }\n\n    public static String numberToWordsWithDecimal(BigDecimal value) {\n        final String integerText = numberToWords(value.longValue(), false);\n        String decimalText = value.setScale(2, RoundingMode.HALF_UP).toPlainString();\n        decimalText = decimalText.substring(decimalText.indexOf(\".\") + 1);\n        return integerText + \" and \" + decimalText + \"/100\";\n    }\n\n    public static String removeChar(String orig, char ch) {\n        if (orig == null) return null;\n        char[] origChars = orig.toCharArray();\n        int origLength = origChars.length;\n        // NOTE: this seems to run pretty slow, plain replace might be faster, but avoiding its use anyway (in ServiceFacadeImpl for SECA rules)\n        char[] newChars = new char[origLength];\n        int lastPos = 0;\n        for (int i = 0; i < origLength; i++) {\n            char curChar = origChars[i];\n            if (curChar != ch) {\n                newChars[lastPos] = curChar;\n                lastPos++;\n            }\n        }\n        if (lastPos == origLength) return orig;\n        return new String(newChars, 0, lastPos);\n    }\n\n    // Lookup table for CRC16 based on irreducible polynomial: 1 + x^2 + x^15 + x^16\n    private static final int[] crc16Table = {\n            0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241,\n            0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440,\n            0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40,\n            0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841,\n            0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40,\n            0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41,\n            0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641,\n            0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040,\n            0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240,\n            0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441,\n            0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41,\n            0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840,\n            0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41,\n            0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40,\n            0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640,\n            0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041,\n            0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240,\n            0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441,\n            0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41,\n            0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840,\n            0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41,\n            0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40,\n            0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640,\n            0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041,\n            0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241,\n            0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440,\n            0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40,\n            0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841,\n            0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40,\n            0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41,\n            0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641,\n            0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040,\n    };\n    public static int calculateCrc16(String input) {\n        byte[] bytes = input.getBytes();\n        int crc = 0x0000;\n        for (byte b : bytes) crc = (crc >>> 8) ^ crc16Table[(crc ^ b) & 0xff];\n        return crc;\n    }\n\n    public static boolean isHexChar(char c) {\n        switch (c) {\n            case '0':\n            case '1':\n            case '2':\n            case '3':\n            case '4':\n            case '5':\n            case '6':\n            case '7':\n            case '8':\n            case '9':\n            case 'a':\n            case 'b':\n            case 'c':\n            case 'd':\n            case 'e':\n            case 'f':\n            case 'A':\n            case 'B':\n            case 'C':\n            case 'D':\n            case 'E':\n            case 'F':\n                return true;\n            default:\n                return false;\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/util/SystemBinding.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.util;\n\nimport groovy.lang.Binding;\nimport groovy.lang.GroovyClassLoader;\nimport groovy.lang.Script;\nimport org.codehaus.groovy.runtime.InvokerHelper;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\n/** Simple class for evaluating expressions to get System properties and environment variables by string expansion */\npublic class SystemBinding extends Binding {\n    private final static Logger logger = LoggerFactory.getLogger(SystemBinding.class);\n    private final static boolean isTraceEnabled = logger.isTraceEnabled();\n\n    private SystemBinding() { super(); }\n\n    public static String getPropOrEnv(String name) {\n        // start with System properties\n        String value = System.getProperty(name);\n        if (value != null && !value.isEmpty()) return value;\n        //  try environment variables\n        value = System.getenv(name);\n        if (value != null && !value.isEmpty()) return value;\n\n        // no luck? try replacing underscores with dots (dots used for map access in Groovy so need workaround)\n        String dotName = null;\n        if (name.contains(\"_\")) {\n            dotName = name.replace('_', '.');\n            value = System.getProperty(dotName);\n            if (value != null && !value.isEmpty()) return value;\n            value = System.getenv(dotName);\n            if (value != null && !value.isEmpty()) return value;\n        }\n        if (isTraceEnabled) logger.trace(\"No '\" + name + (dotName != null ? \"' (or '\" + dotName + \"')\" : \"'\") +\n                \" system property or environment variable found, using empty string\");\n        return \"\";\n    }\n\n    @Override\n    public Object getVariable(String name) {\n        // NOTE: this code is part of the original Groovy groovy.lang.Binding.getVariable() method and leaving it out\n        //     is the reason to override this method:\n        //if (result == null && !variables.containsKey(name)) {\n        //    throw new MissingPropertyException(name, this.getClass());\n        //}\n        return getPropOrEnv(name);\n    }\n\n    @Override\n    public void setVariable(String name, Object value) {\n        throw new UnsupportedOperationException(\"Cannot set a variable with SystemBinding, use System.setProperty()\");\n        // super.setVariable(name, value);\n    }\n\n    @Override\n    public boolean hasVariable(String name) {\n        // always treat it like the variable exists and is null to change the behavior for variable scope and\n        //     declaration, easier in simple scripts\n        return true;\n    }\n\n\n    private static SystemBinding defaultBinding = new SystemBinding();\n    public static String expand(String value) {\n        if (value == null || value.length() == 0) return \"\";\n        if (!value.contains(\"${\")) return value;\n        String expression = \"\\\"\\\"\\\"\" + value + \"\\\"\\\"\\\"\";\n        Class groovyClass = new GroovyClassLoader().parseClass(expression);\n        Script script = InvokerHelper.createScript(groovyClass, defaultBinding);\n        Object result = script.run();\n        if (result == null) return \"\"; // should never happen, always at least empty String\n        return result.toString();\n    }\n}\n"
  },
  {
    "path": "framework/src/main/java/org/moqui/util/WebUtilities.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\npackage org.moqui.util;\n\nimport jakarta.servlet.ServletContext;\nimport jakarta.servlet.ServletRequest;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpSession;\n\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.io.ObjectOutputStream;\nimport java.io.UnsupportedEncodingException;\nimport java.math.BigDecimal;\nimport java.net.URLDecoder;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collection;\nimport java.util.Enumeration;\nimport java.util.HashMap;\nimport java.util.HashSet;\nimport java.util.Hashtable;\nimport java.util.Iterator;\nimport java.util.LinkedHashMap;\nimport java.util.LinkedList;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.RandomAccess;\nimport java.util.Set;\n\nimport javax.annotation.Nonnull;\n\nimport org.apache.commons.fileupload2.core.FileItem;\n\nimport org.eclipse.jetty.client.ContentResponse;\nimport org.eclipse.jetty.client.HttpClient;\nimport org.eclipse.jetty.client.Request;\nimport org.eclipse.jetty.client.StringRequestContent;\nimport org.eclipse.jetty.client.transport.HttpClientTransportDynamic;\nimport org.eclipse.jetty.io.ClientConnector;\nimport org.eclipse.jetty.util.ssl.SslContextFactory;\n\nimport org.moqui.BaseException;\n\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\npublic class WebUtilities {\n    private static final Logger logger = LoggerFactory.getLogger(WebUtilities.class);\n\n    public static Map<String, Object> getPathInfoParameterMap(String pathInfoStr) {\n        if (pathInfoStr == null || pathInfoStr.length() == 0) return null;\n        Map<String, Object> paramMap = null;\n        // add in all path info parameters /~name1=value1/~name2=value2/\n        String[] pathElements = pathInfoStr.split(\"/\");\n        for (int i = 0; i < pathElements.length; i++) {\n            String element = pathElements[i];\n            int equalsIndex = element.indexOf(\"=\");\n            if (element.length() > 0 && element.charAt(0) == '~' && equalsIndex > 0) {\n                try {\n                    String name = URLDecoder.decode(element.substring(1, equalsIndex), \"UTF-8\");\n                    String value = URLDecoder.decode(element.substring(equalsIndex + 1), \"UTF-8\");\n                    // NOTE: currently ignoring existing values, likely won't be any: Object curValue = paramMap.get(name)\n                    if (paramMap == null) paramMap = new HashMap<>();\n                    paramMap.put(name, value);\n                } catch (UnsupportedEncodingException e) {\n                    logger.error(\"Error decoding path parameter\", e);\n                }\n            }\n        }\n        return paramMap;\n    }\n\n    public static Object canonicalizeValue(Object orig) {\n        Object canVal = orig;\n        List lst = null;\n        if (orig instanceof List) { lst = (List) orig; }\n        else if (orig instanceof String[]) { lst = Arrays.asList((String[]) orig); }\n        else if (orig instanceof Object[]) { lst = Arrays.asList((Object[]) orig); }\n        if (lst != null) {\n            if (lst.size() == 1) {\n                canVal = lst.get(0);\n            } else if (lst.size() > 1) {\n                List<Object> newList = new ArrayList<>(lst.size());\n                canVal = newList;\n                for (Object obj : lst) {\n                    if (obj instanceof CharSequence) {\n                        try {\n                            newList.add(URLDecoder.decode(obj.toString(), \"UTF-8\"));\n                        } catch (UnsupportedEncodingException e) {\n                            logger.warn(\"Error decoding string \" + obj, e);\n                        }\n                    } else {\n                        newList.add(obj);\n                    }\n                }\n            }\n        }\n        // catch strings or lists with a single string in them unwrapped above\n        try {\n            if (canVal instanceof CharSequence) canVal = URLDecoder.decode(canVal.toString(), \"UTF-8\");\n        } catch (UnsupportedEncodingException e) {\n            logger.warn(\"Error decoding string \" + canVal, e);\n        }\n        return canVal;\n    }\n\n    public static Map<String, Object> simplifyRequestParameters(HttpServletRequest request, boolean bodyOnly) {\n        Set<String> urlParms = null;\n        if (bodyOnly) {\n            urlParms = new HashSet<>();\n            String query = request.getQueryString();\n            if (query != null && !query.isEmpty()) {\n                for (String nameValuePair : query.split(\"&\")) {\n                    int eqIdx = nameValuePair.indexOf(\"=\");\n                    if (eqIdx < 0) urlParms.add(nameValuePair);\n                    else urlParms.add(nameValuePair.substring(0, eqIdx));\n                }\n            }\n        }\n        Map<String, String[]> reqParmOrigMap = request.getParameterMap();\n        Map<String, Object> reqParmMap = new LinkedHashMap<>();\n        for (Map.Entry<String, String[]> entry : reqParmOrigMap.entrySet()) {\n            String key = entry.getKey();\n            if (bodyOnly && urlParms.contains(key)) continue;\n            String[] valArray = entry.getValue();\n            if (valArray == null) {\n                reqParmMap.put(key, null);\n            } else {\n                int valLength = valArray.length;\n                if (valLength == 0) {\n                    reqParmMap.put(key, null);\n                } else if (valLength == 1) {\n                    String singleVal = valArray[0];\n                    // change &nbsp; (\\u00a0) to null, used as a placeholder when empty string doesn't work\n                    if (\"\\u00a0\".equals(singleVal)) {\n                        reqParmMap.put(key, null);\n                    } else {\n                        reqParmMap.put(key, singleVal);\n                    }\n                } else {\n                    reqParmMap.put(key, Arrays.asList(valArray));\n                }\n            }\n        }\n        return reqParmMap;\n    }\n\n    /** Sort of like JSON but output in JS syntax for HTML attributes like in a Vue Template */\n    public static String encodeHtmlJsSafe(CharSequence original) {\n        if (original == null) return \"\";\n        StringBuilder newValue = new StringBuilder(original);\n        for (int i = 0; i < newValue.length(); i++) {\n            char curChar = newValue.charAt(i);\n            switch (curChar) {\n                case '\\'': newValue.replace(i, i + 1, \"\\\\'\"); i += 1; break;\n                case '\"': newValue.replace(i, i + 1, \"&quot;\"); i += 5; break;\n                case '&': newValue.replace(i, i + 1, \"&amp;\"); i += 4; break;\n                case '<': newValue.replace(i, i + 1, \"&lt;\"); i += 3; break;\n                case '>': newValue.replace(i, i + 1, \"&gt;\"); i += 3; break;\n                case '\\n': newValue.replace(i, i + 1, \"\\\\n\"); i += 1; break;\n                case '\\r': newValue.replace(i, i + 1, \"\\\\r\"); i += 1; break;\n                case 0x5: newValue.replace(i, i + 1, \"...\"); i += 2; break;\n                case 0x12: newValue.replace(i, i + 1, \"&apos;\"); i += 5; break;\n                case 0x13: newValue.replace(i, i + 1, \"&quot;\"); i += 5; break;\n                case 0x14: newValue.replace(i, i + 1, \"&quot;\"); i += 5; break;\n                case 0x16: newValue.replace(i, i + 1, \"-\"); break;\n                case 0x17: newValue.replace(i, i + 1, \"-\"); break;\n                case 0x19: newValue.replace(i, i + 1, \"tm\"); i++; break;\n            }\n        }\n        return newValue.toString();\n    }\n\n    public static String encodeHtmlJsSafeObject(Object value) {\n        if (value == null) {\n            return \"null\";\n        } else if (value instanceof Collection) {\n            return encodeHtmlJsSafeCollection((Collection) value);\n        } else if (value instanceof Map) {\n            return encodeHtmlJsSafeMap((Map) value);\n        } else if (value instanceof Number) {\n            if (value instanceof BigDecimal) return ((BigDecimal) value).toPlainString();\n            else return value.toString();\n        } else if (value instanceof Boolean) {\n            Boolean boolVal = (Boolean) value;\n            return boolVal ? \"true\" : \"false\";\n        } else {\n            return \"'\" + encodeHtmlJsSafe(value.toString()) + \"'\";\n        }\n    }\n\n    public static String encodeHtmlJsSafeMap(Map fieldValues) {\n        if (fieldValues == null) return \"null\";\n        StringBuilder out = new StringBuilder().append(\"{\");\n        boolean isFirst = true;\n        for (Object entryObj : fieldValues.entrySet()) {\n            Map.Entry entry = (Map.Entry) entryObj;\n            Object key = entry.getKey();\n            if (key == null) continue;\n            if (isFirst) { isFirst = false; } else { out.append(\",\"); }\n            out.append(\"'\").append(encodeHtmlJsSafe(key.toString())).append(\"':\");\n            Object value = entry.getValue();\n            out.append(encodeHtmlJsSafeObject(value));\n        }\n        out.append(\"}\");\n        return out.toString();\n    }\n\n    public static String encodeHtmlJsSafeCollection(Collection value) {\n        if (value == null) return \"null\";\n        StringBuilder out = new StringBuilder();\n        out.append(\"[\");\n        if (value instanceof RandomAccess) {\n            List curList = (List) value;\n            int curListSize = curList.size();\n            for (int vi = 0; vi < curListSize; vi++) {\n                Object listVal = curList.get(vi);\n                out.append(encodeHtmlJsSafeObject(listVal));\n                if ((vi + 1) < curListSize) out.append(\",\");\n            }\n        } else {\n            Iterator colIter = value.iterator();\n            while (colIter.hasNext()) {\n                Object colVal = colIter.next();\n                out.append(encodeHtmlJsSafeObject(colVal));\n                if (colIter.hasNext()) out.append(\",\");\n            }\n        }\n        out.append(\"]\");\n        return out.toString();\n    }\n\n    // for backward compatibility:\n    public static String fieldValuesEncodeHtmlJsSafe(Map fieldValues) { return encodeHtmlJsSafeMap(fieldValues); }\n\n    public static String encodeHtml(String original) {\n        if (original == null) return \"\";\n        StringBuilder newValue = new StringBuilder(original);\n        for (int i = 0; i < newValue.length(); i++) {\n            char curChar = newValue.charAt(i);\n            switch (curChar) {\n                case '\\'': newValue.replace(i, i + 1, \"&#39;\"); i += 4; break;\n                case '\"': newValue.replace(i, i + 1, \"&quot;\"); i += 5; break;\n                case '&': newValue.replace(i, i + 1, \"&amp;\"); i += 4; break;\n                case '<': newValue.replace(i, i + 1, \"&lt;\"); i += 3; break;\n                case '>': newValue.replace(i, i + 1, \"&gt;\"); i += 3; break;\n                case 0x5: newValue.replace(i, i + 1, \"...\"); i += 2; break;\n                case 0x12: newValue.replace(i, i + 1, \"&apos;\"); i += 5; break;\n                case 0x13: newValue.replace(i, i + 1, \"&quot;\"); i += 5; break;\n                case 0x14: newValue.replace(i, i + 1, \"&quot;\"); i += 5; break;\n                case 0x16: newValue.replace(i, i + 1, \"-\"); break;\n                case 0x17: newValue.replace(i, i + 1, \"-\"); break;\n                case 0x19: newValue.replace(i, i + 1, \"tm\"); i++; break;\n            }\n        }\n        return newValue.toString();\n    }\n\n    /** Pattern may have a plain number, '*' for wildcard, or a '-' separated number range for each dot separated segment;\n     * may also have multiple comma-separated patterns */\n    public static boolean ip4Matches(String patternString, String address) {\n        if (patternString == null || patternString.isEmpty()) return true;\n        if (address == null || address.isEmpty()) return false;\n        String[] patterns = patternString.split(\",\");\n        boolean anyMatches = false;\n        for (int pi = 0; pi < patterns.length; pi++) {\n            String pattern = patterns[pi].trim();\n            if (pattern.isEmpty()) continue;\n            if (pattern.equals(\"*.*.*.*\") || pattern.equals(\"*\")) {\n                anyMatches = true;\n                break;\n            }\n            String[] patternArray = pattern.split(\"\\\\.\");\n            String[] addressArray = address.split(\"\\\\.\");\n            boolean allMatch = true;\n            for (int i = 0; i < patternArray.length; i++) {\n                String curPattern = patternArray[i];\n                String curAddress = addressArray[i];\n                if (curPattern.equals(\"*\") || curPattern.equals(curAddress)) continue;\n                if (curPattern.contains(\"-\")) {\n                    byte min = Byte.parseByte(curPattern.split(\"-\")[0]);\n                    byte max = Byte.parseByte(curPattern.split(\"-\")[1]);\n                    byte ip = Byte.parseByte(curAddress);\n                    if (ip < min || ip > max) { allMatch = false; break; }\n                } else {\n                    allMatch = false;\n                    break;\n                }\n            }\n            if (allMatch) { anyMatches = true; break; }\n        }\n        return anyMatches;\n    }\n\n    public static byte[] windowsPex = {(byte) 0x4d, (byte) 0x5a};\n    public static byte[] linuxElf = {(byte) 0x7f, (byte) 0x45, (byte) 0x4c, (byte) 0x46};\n    public static byte[] javaClass = {(byte) 0xca, (byte) 0xfe, (byte) 0xba, (byte) 0xbe};\n    public static byte[] macOs = {(byte) 0xfe, (byte) 0xed, (byte) 0xfa, (byte) 0xce};\n    public static byte[][] allOsExecutables = {windowsPex, linuxElf, javaClass, macOs};\n\n    /** Looks for byte patterns for Windows Portable Executable (4d5a), Linux ELF (7f454c46), Java class (cafebabe), macOS (feedface) */\n    public static boolean isExecutable(FileItem item) throws IOException {\n        InputStream is = item.getInputStream();\n        byte[] bytes = new byte[4];\n        is.read(bytes, 0, 4);\n        is.close();\n        return isExecutable(bytes);\n    }\n    /** Looks for byte patterns for Windows Portable Executable (4d5a), Linux ELF (7f454c46), Java class (cafebabe), macOS (feedface) */\n    public static boolean isExecutable(byte[] bytes) {\n        boolean foundPattern = false;\n        for (int i = 0; i < allOsExecutables.length; i++) {\n            byte[] execPattern = allOsExecutables[i];\n            boolean execMatches = true;\n            for (int j = 0; j < execPattern.length; j++) {\n                if (bytes[j] != execPattern[j]) {\n                    execMatches = false;\n                    break;\n                }\n            }\n            if (execMatches) {\n                foundPattern = true;\n                break;\n            }\n        }\n        return foundPattern;\n    }\n\n    public static String simpleHttpStringRequest(String location, String requestBody, String contentType) {\n        if (contentType == null || contentType.isEmpty()) contentType = \"text/plain\";\n        String resultString = \"\";\n\n        SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(true);\n        ClientConnector clientConnector = new ClientConnector();\n        clientConnector.setSslContextFactory(sslContextFactory);\n        HttpClient httpClient = new HttpClient(new HttpClientTransportDynamic(clientConnector));\n\n        try {\n            httpClient.start();\n            Request request = httpClient.POST(location);\n            if (requestBody != null && !requestBody.isEmpty())\n                request.body(new StringRequestContent(contentType, requestBody, StandardCharsets.UTF_8));\n            ContentResponse response = request.send();\n            resultString = StringUtilities.toStringCleanBom(response.getContent());\n        } catch (Exception e) {\n            throw new BaseException(\"Error in http client request\", e);\n        } finally {\n            try {\n                httpClient.stop();\n            } catch (Exception e) {\n                logger.error(\"Error stopping http client\", e);\n            }\n        }\n        return resultString;\n    }\n\n    public static String simpleHttpMapRequest(String location, Map requestMap) {\n        String resultString = \"\";\n\n        SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(true);\n        ClientConnector clientConnector = new ClientConnector();\n        clientConnector.setSslContextFactory(sslContextFactory);\n        HttpClient httpClient = new HttpClient(new HttpClientTransportDynamic(clientConnector));\n\n        try {\n            httpClient.start();\n            Request request = httpClient.POST(location);\n            if (requestMap != null) for (Object entryObj : requestMap.entrySet()) {\n                Map.Entry requestEntry = (Map.Entry) entryObj;\n                request.param(requestEntry.getKey() != null ? requestEntry.getKey().toString() : null,\n                        requestEntry.getValue() != null ? requestEntry.getValue().toString() : null);\n            }\n            ContentResponse response = request.send();\n            resultString = StringUtilities.toStringCleanBom(response.getContent());\n        } catch (Exception e) {\n            throw new BaseException(\"Error in http client request\", e);\n        } finally {\n            try {\n                httpClient.stop();\n            } catch (Exception e) {\n                logger.error(\"Error stopping http client\", e);\n            }\n        }\n        return resultString;\n    }\n\n    public static class SimpleEntry implements Map.Entry<String, Object> {\n        protected String key;\n        protected Object value;\n        SimpleEntry(String key, Object value) {\n            this.key = key;\n            this.value = value;\n        }\n        public String getKey() { return key; }\n        public Object getValue() { return value; }\n        public Object setValue(Object v) {\n            Object orig = value;\n            value = v;\n            return orig;\n        }\n    }\n\n    public static Enumeration<String> emptyStringEnum = new Enumeration<String>() {\n        @Override public boolean hasMoreElements() { return false; }\n        @Override public String nextElement() { return null; }\n    };\n    public static boolean testSerialization(String name, Object value) {\n        // return true;\n        /* for testing purposes only, don't enable by default: */\n        // logger.warn(\"Test ser \" + name + \"(\" + (value != null ? value.getClass().getName() : \"\") + \":\" + (value != null && value.getClass().getClassLoader() != null ? value.getClass().getClassLoader().getClass().getName() : \"\") + \")\" + \" value: \" + value);\n        if (value == null) return true;\n        try {\n            ObjectOutputStream out = new ObjectOutputStream(new ByteArrayOutputStream());\n            out.writeObject(value);\n            out.close();\n            return true;\n        } catch (IOException e) {\n            logger.warn(\"Tried to set session attribute [\" + name + \"] with non-serializable value of type \" + value.getClass().getName(), e);\n            return false;\n        }\n    }\n\n    public interface AttributeContainer {\n        Enumeration<String> getAttributeNames();\n        Object getAttribute(String name);\n        void setAttribute(String name, Object value);\n        void removeAttribute(String name);\n        default List<String> getAttributeNameList() {\n            List<String> nameList = new LinkedList<>();\n            Enumeration<String> attrNames = getAttributeNames();\n            while (attrNames.hasMoreElements()) nameList.add(attrNames.nextElement());\n            return nameList;\n        }\n    }\n\n    public static class ServletRequestContainer implements AttributeContainer {\n        ServletRequest req;\n        public ServletRequestContainer(ServletRequest request) { req = request; }\n        @Override public Enumeration<String> getAttributeNames() { return req.getAttributeNames(); }\n        @Override public Object getAttribute(String name) { return req.getAttribute(name); }\n        @Override public void setAttribute(String name, Object value) {\n            if (!testSerialization(name, value)) return;\n            req.setAttribute(name, value);\n        }\n        @Override public void removeAttribute(String name) { req.removeAttribute(name); }\n    }\n\n    public static class HttpSessionContainer implements AttributeContainer {\n        HttpSession ses;\n        public HttpSessionContainer(HttpSession session) { ses = session; }\n        @Override public Enumeration<String> getAttributeNames() {\n            try {\n                return ses.getAttributeNames();\n            } catch (IllegalStateException e) {\n                logger.warn(\"Tried getAttributeNames() on invalidated session \" + ses.getId() + \": \" + e.toString());\n                return emptyStringEnum;\n            }\n        }\n        @Override public Object getAttribute(String name) {\n            try {\n                return ses.getAttribute(name);\n            } catch (IllegalStateException e) {\n                logger.warn(\"Tried getAttribute(\" + name + \") on invalidated session \" + ses.getId(), BaseException.filterStackTrace(e));\n                return null;\n            }\n        }\n        @Override public void setAttribute(String name, Object value) {\n            if (!testSerialization(name, value)) return;\n\n            try {\n                ses.setAttribute(name, value);\n            } catch (IllegalStateException e) {\n                logger.warn(\"Tried setAttribute(\" + name + \", \" + value + \") on invalidated session \" + ses.getId(), BaseException.filterStackTrace(e));\n            }\n        }\n        @Override public void removeAttribute(String name) {\n            try {\n                ses.removeAttribute(name);\n            } catch (IllegalStateException e) {\n                logger.warn(\"Tried removeAttribute(\" + name + \") on invalidated session \" + ses.getId() + \": \" + e.toString());\n            }\n        }\n    }\n\n    public static class ServletContextContainer implements AttributeContainer {\n        ServletContext scxt;\n        public ServletContextContainer(ServletContext servletContext) { scxt = servletContext; }\n        @Override public Enumeration<String> getAttributeNames() { return scxt.getAttributeNames(); }\n        @Override public Object getAttribute(String name) { return scxt.getAttribute(name); }\n        @Override public void setAttribute(String name, Object value) { scxt.setAttribute(name, value); }\n        @Override public void removeAttribute(String name) { scxt.removeAttribute(name); }\n    }\n\n    static final Set<String> keysToIgnore = new HashSet<>(Arrays.asList(\"javax.servlet.context.tempdir\",\n            \"org.apache.catalina.jsp_classpath\", \"org.apache.commons.fileupload.servlet.FileCleanerCleanup.FileCleaningTracker\"));\n\n    public static class AttributeContainerMap implements Map<String, Object> {\n        private AttributeContainer cont;\n        public AttributeContainerMap(AttributeContainer container) { cont = container; }\n        public int size() { return cont.getAttributeNameList().size(); }\n        public boolean isEmpty() { return !cont.getAttributeNames().hasMoreElements(); }\n        public boolean containsKey(Object o) {\n            if (keysToIgnore.contains(o)) return false;\n            Enumeration<String> attrNames = cont.getAttributeNames();\n            while (attrNames.hasMoreElements()) {\n                String name = attrNames.nextElement();\n                if (name.equals(o)) return true;\n            }\n            return false;\n        }\n        public boolean containsValue(Object o) {\n            Enumeration<String> attrNames = cont.getAttributeNames();\n            while (attrNames.hasMoreElements()) {\n                String name = attrNames.nextElement();\n                if (keysToIgnore.contains(o)) continue;\n                Object attrValue = cont.getAttribute(name);\n                if (attrValue == null) { if (o == null) return true; }\n                else { if (attrValue.equals(o)) return true; }\n            }\n            return false;\n        }\n        public Object get(Object o) { return cont.getAttribute((String) o); }\n        public Object put(String s, Object o) {\n            Object orig = cont.getAttribute(s);\n            cont.setAttribute(s, o);\n            return orig;\n        }\n        public Object remove(Object o) {\n            Object orig = cont.getAttribute((String) o);\n            cont.removeAttribute((String) o);\n            return orig;\n        }\n        public void putAll(Map<? extends String, ? extends Object> map) {\n            // if (map == null) return;\n            for (Entry entry : map.entrySet()) {\n                cont.setAttribute((String) entry.getKey(), entry.getValue());\n            }\n        }\n        public void clear() {\n            Enumeration<String> attrNames = cont.getAttributeNames();\n            while (attrNames.hasMoreElements()) {\n                String name = attrNames.nextElement();\n                if (!keysToIgnore.contains(name)) cont.removeAttribute(name);\n            }\n        }\n        public @Nonnull Set<String> keySet() {\n            Set<String> ks = new HashSet<>();\n            Enumeration<String> attrNames = cont.getAttributeNames();\n            while (attrNames.hasMoreElements()) {\n                String name = attrNames.nextElement();\n                if (!keysToIgnore.contains(name)) ks.add(name);\n            }\n            return ks;\n        }\n        public @Nonnull Collection<Object> values() {\n            List<Object> values = new LinkedList<>();\n            Enumeration<String> attrNames = cont.getAttributeNames();\n            while (attrNames.hasMoreElements()) {\n                String name = attrNames.nextElement();\n                if (!keysToIgnore.contains(name)) values.add(cont.getAttribute(name));\n            }\n            return values;\n        }\n        public @Nonnull Set<Entry<String, Object>> entrySet() {\n            Set<Entry<String, Object>> es = new HashSet<>();\n            Enumeration<String> attrNames = cont.getAttributeNames();\n            while (attrNames.hasMoreElements()) {\n                String name = attrNames.nextElement();\n                if (!keysToIgnore.contains(name)) es.add(new SimpleEntry(name, cont.getAttribute(name)));\n            }\n            return es;\n        }\n        @Override\n        public String toString() {\n            StringBuilder sb = new StringBuilder(\"[\");\n            Enumeration<String> attrNames = cont.getAttributeNames();\n            while (attrNames.hasMoreElements()) {\n                String name = attrNames.nextElement();\n                if (sb.length() > 1) sb.append(\", \");\n                sb.append(name).append(\":\").append(cont.getAttribute(name));\n            }\n            sb.append(\"]\");\n            return sb.toString();\n        }\n    }\n\n    @SuppressWarnings(\"unchecked\")\n    public static class CanonicalizeMap implements Map<String, Object> {\n        Map mp;\n        boolean supportsNull = true;\n        public CanonicalizeMap(Map map) {\n            mp = map;\n            if (mp instanceof Hashtable) supportsNull = false;\n        }\n        public int size() { return mp.size(); }\n        public boolean isEmpty() { return mp.isEmpty(); }\n        public boolean containsKey(Object o) { return !(o == null && !supportsNull) && mp.containsKey(o); }\n        public boolean containsValue(Object o) { return mp.containsValue(o); }\n        public Object get(Object o) { return (o == null && !supportsNull) ? null : canonicalizeValue(mp.get(o)); }\n        public Object put(String k, Object v) { return canonicalizeValue(mp.put(k, v)); }\n        public Object remove(Object o) { return (o == null && !supportsNull) ? null : canonicalizeValue(mp.remove(o)); }\n        public void putAll(Map<? extends String, ? extends Object> map) { mp.putAll(map); }\n        public void clear() { mp.clear(); }\n        public @Nonnull Set<String> keySet() { return mp.keySet(); }\n        public @Nonnull Collection<Object> values() {\n            List<Object> values = new ArrayList<>(mp.size());\n            for (Object orig : mp.values()) values.add(canonicalizeValue(orig));\n            return values;\n        }\n        public @Nonnull Set<Entry<String, Object>> entrySet() {\n            Set<Entry<String, Object>> es = new HashSet<>();\n            for (Object entryObj : mp.entrySet()) {\n                Entry entry = (Entry) entryObj;\n                es.add(new CanonicalizeEntry(entry.getKey().toString(), entry.getValue()));\n            }\n            return es;\n        }\n    }\n\n    private static class CanonicalizeEntry implements Map.Entry<String, Object> {\n        protected String key;\n        protected Object value;\n        CanonicalizeEntry(String key, Object value) { this.key = key; this.value = value; }\n        // CanonicalizeEntry(Map.Entry<String, Object> entry) { this.key = entry.getKey(); this.value = entry.getValue(); }\n        public String getKey() { return key; }\n        public Object getValue() { return canonicalizeValue(value); }\n        public Object setValue(Object v) {\n            Object orig = value;\n            value = v;\n            return orig;\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/main/resources/META-INF/jakarta.mime.types",
    "content": "# This file maps Internet media types to unique file extension(s).\n# Although created for httpd, this file is used by many software systems\n# and has been placed in the public domain for unlimited redisribution.\n#\n# The table below contains both registered and (common) unregistered types.\n# A type that has no unique extension can be ignored -- they are listed\n# here to guide configurations toward known types and to make it easier to\n# identify \"new\" types.  File extensions are also commonly used to indicate\n# content languages and encodings, so choose them carefully.\n#\n# Internet media types should be registered as described in RFC 4288.\n# The registry is at <http://www.iana.org/assignments/media-types/>.\n#\n# MIME type\t\t\t\t\tExtensions\n# application/3gpp-ims+xml\n# application/activemessage\napplication/andrew-inset\t\t\tez\n# application/applefile\napplication/applixware\t\t\t\taw\napplication/atom+xml\t\t\t\tatom\napplication/atomcat+xml\t\t\t\tatomcat\n# application/atomicmail\napplication/atomsvc+xml\t\t\t\tatomsvc\n# application/auth-policy+xml\n# application/batch-smtp\n# application/beep+xml\n# application/cals-1840\napplication/ccxml+xml\t\t\t\tccxml\n# application/cea-2018+xml\n# application/cellml+xml\n# application/cnrp+xml\n# application/commonground\n# application/conference-info+xml\n# application/cpl+xml\n# application/csta+xml\n# application/cstadata+xml\napplication/cu-seeme\t\t\t\tcu\n# application/cybercash\napplication/davmount+xml\t\t\tdavmount\n# application/dca-rft\n# application/dec-dx\n# application/dialog-info+xml\n# application/dicom\n# application/dns\napplication/dssc+der\t\t\t\tdssc\napplication/dssc+xml\t\t\t\txdssc\n# application/dvcs\napplication/ecmascript\t\t\t\tecma\n# application/edi-consent\n# application/edi-x12\n# application/edifact\napplication/emma+xml\t\t\t\temma\n# application/epp+xml\napplication/epub+zip\t\t\t\tepub\n# application/eshop\n# application/example\n# application/fastinfoset\n# application/fastsoap\n# application/fits\napplication/font-tdpfr\t\t\t\tpfr\n# application/h224\n# application/held+xml\n# application/http\napplication/hyperstudio\t\t\t\tstk\n# application/ibe-key-request+xml\n# application/ibe-pkg-reply+xml\n# application/ibe-pp-data\n# application/iges\n# application/im-iscomposing+xml\n# application/index\n# application/index.cmd\n# application/index.obj\n# application/index.response\n# application/index.vnd\n# application/iotp\napplication/ipfix\t\t\t\tipfix\n# application/ipp\n# application/isup\napplication/java-archive\t\t\tjar\napplication/java-serialized-object\t\tser\napplication/java-vm\t\t\t\tclass\napplication/javascript\t\t\t\tjs\napplication/json\t\t\t\tjson\n# application/kpml-request+xml\n# application/kpml-response+xml\napplication/lost+xml\t\t\t\tlostxml\napplication/mac-binhex40\t\t\thqx\napplication/mac-compactpro\t\t\tcpt\n# application/macwriteii\napplication/marc\t\t\t\tmrc\napplication/mathematica\t\t\t\tma nb mb\napplication/mathml+xml\t\t\t\tmathml\n# application/mbms-associated-procedure-description+xml\n# application/mbms-deregister+xml\n# application/mbms-envelope+xml\n# application/mbms-msk+xml\n# application/mbms-msk-response+xml\n# application/mbms-protection-description+xml\n# application/mbms-reception-report+xml\n# application/mbms-register+xml\n# application/mbms-register-response+xml\n# application/mbms-user-service-description+xml\napplication/mbox\t\t\t\tmbox\n# application/media_control+xml\napplication/mediaservercontrol+xml\t\tmscml\n# application/mikey\n# application/moss-keys\n# application/moss-signature\n# application/mosskey-data\n# application/mosskey-request\napplication/mp4\t\t\t\t\tmp4s\n# application/mpeg4-generic\n# application/mpeg4-iod\n# application/mpeg4-iod-xmt\napplication/msword\t\t\t\tdoc dot\napplication/mxf\t\t\t\t\tmxf\n# application/nasdata\n# application/news-checkgroups\n# application/news-groupinfo\n# application/news-transmission\n# application/nss\n# application/ocsp-request\n# application/ocsp-response\napplication/octet-stream\tbin dms lha lrf lzh so iso dmg dist distz pkg bpk dump elc deploy\napplication/oda\t\t\t\t\toda\napplication/oebps-package+xml\t\t\topf\napplication/ogg\t\t\t\t\togx\napplication/onenote\t\t\t\tonetoc onetoc2 onetmp onepkg\n# application/parityfec\napplication/patch-ops-error+xml\t\t\txer\napplication/pdf\t\t\t\t\tpdf\napplication/pgp-encrypted\t\t\tpgp\n# application/pgp-keys\napplication/pgp-signature\t\t\tasc sig\napplication/pics-rules\t\t\t\tprf\n# application/pidf+xml\n# application/pidf-diff+xml\napplication/pkcs10\t\t\t\tp10\napplication/pkcs7-mime\t\t\t\tp7m p7c\napplication/pkcs7-signature\t\t\tp7s\napplication/pkix-cert\t\t\t\tcer\napplication/pkix-crl\t\t\t\tcrl\napplication/pkix-pkipath\t\t\tpkipath\napplication/pkixcmp\t\t\t\tpki\napplication/pls+xml\t\t\t\tpls\n# application/poc-settings+xml\napplication/postscript\t\t\t\tai eps ps\n# application/prs.alvestrand.titrax-sheet\napplication/prs.cww\t\t\t\tcww\n# application/prs.nprend\n# application/prs.plucker\n# application/qsig\napplication/rdf+xml\t\t\t\trdf\napplication/reginfo+xml\t\t\t\trif\napplication/relax-ng-compact-syntax\t\trnc\n# application/remote-printing\napplication/resource-lists+xml\t\t\trl\napplication/resource-lists-diff+xml\t\trld\n# application/riscos\n# application/rlmi+xml\napplication/rls-services+xml\t\t\trs\napplication/rsd+xml\t\t\t\trsd\napplication/rss+xml\t\t\t\trss\napplication/rtf\t\t\t\t\trtf\n# application/rtx\n# application/samlassertion+xml\n# application/samlmetadata+xml\napplication/sbml+xml\t\t\t\tsbml\napplication/scvp-cv-request\t\t\tscq\napplication/scvp-cv-response\t\t\tscs\napplication/scvp-vp-request\t\t\tspq\napplication/scvp-vp-response\t\t\tspp\napplication/sdp\t\t\t\t\tsdp\n# application/set-payment\napplication/set-payment-initiation\t\tsetpay\n# application/set-registration\napplication/set-registration-initiation\t\tsetreg\n# application/sgml\n# application/sgml-open-catalog\napplication/shf+xml\t\t\t\tshf\n# application/sieve\n# application/simple-filter+xml\n# application/simple-message-summary\n# application/simplesymbolcontainer\n# application/slate\n# application/smil\napplication/smil+xml\t\t\t\tsmi smil\n# application/soap+fastinfoset\n# application/soap+xml\napplication/sparql-query\t\t\trq\napplication/sparql-results+xml\t\t\tsrx\n# application/spirits-event+xml\napplication/srgs\t\t\t\tgram\napplication/srgs+xml\t\t\t\tgrxml\napplication/ssml+xml\t\t\t\tssml\n# application/timestamp-query\n# application/timestamp-reply\n# application/tve-trigger\n# application/ulpfec\n# application/vemmi\n# application/vividence.scriptfile\n# application/vnd.3gpp.bsf+xml\napplication/vnd.3gpp.pic-bw-large\t\tplb\napplication/vnd.3gpp.pic-bw-small\t\tpsb\napplication/vnd.3gpp.pic-bw-var\t\t\tpvb\n# application/vnd.3gpp.sms\n# application/vnd.3gpp2.bcmcsinfo+xml\n# application/vnd.3gpp2.sms\napplication/vnd.3gpp2.tcap\t\t\ttcap\napplication/vnd.3m.post-it-notes\t\tpwn\napplication/vnd.accpac.simply.aso\t\taso\napplication/vnd.accpac.simply.imp\t\timp\napplication/vnd.acucobol\t\t\tacu\napplication/vnd.acucorp\t\t\t\tatc acutc\napplication/vnd.adobe.air-application-installer-package+zip\tair\n# application/vnd.adobe.partial-upload\napplication/vnd.adobe.xdp+xml\t\t\txdp\napplication/vnd.adobe.xfdf\t\t\txfdf\n# application/vnd.aether.imp\napplication/vnd.airzip.filesecure.azf\t\tazf\napplication/vnd.airzip.filesecure.azs\t\tazs\napplication/vnd.amazon.ebook\t\t\tazw\napplication/vnd.americandynamics.acc\t\tacc\napplication/vnd.amiga.ami\t\t\tami\napplication/vnd.android.package-archive\t\tapk\napplication/vnd.anser-web-certificate-issue-initiation\tcii\napplication/vnd.anser-web-funds-transfer-initiation\tfti\napplication/vnd.antix.game-component\t\tatx\napplication/vnd.apple.installer+xml\t\tmpkg\napplication/vnd.apple.mpegurl\t\t\tm3u8\n# application/vnd.arastra.swi\napplication/vnd.aristanetworks.swi\t\tswi\napplication/vnd.audiograph\t\t\taep\n# application/vnd.autopackage\n# application/vnd.avistar+xml\napplication/vnd.blueice.multipass\t\tmpm\n# application/vnd.bluetooth.ep.oob\napplication/vnd.bmi\t\t\t\tbmi\napplication/vnd.businessobjects\t\t\trep\n# application/vnd.cab-jscript\n# application/vnd.canon-cpdl\n# application/vnd.canon-lips\n# application/vnd.cendio.thinlinc.clientconf\napplication/vnd.chemdraw+xml\t\t\tcdxml\napplication/vnd.chipnuts.karaoke-mmd\t\tmmd\napplication/vnd.cinderella\t\t\tcdy\n# application/vnd.cirpack.isdn-ext\napplication/vnd.claymore\t\t\tcla\napplication/vnd.cloanto.rp9\t\t\trp9\napplication/vnd.clonk.c4group\t\t\tc4g c4d c4f c4p c4u\n# application/vnd.commerce-battelle\napplication/vnd.commonspace\t\t\tcsp\napplication/vnd.contact.cmsg\t\t\tcdbcmsg\napplication/vnd.cosmocaller\t\t\tcmc\napplication/vnd.crick.clicker\t\t\tclkx\napplication/vnd.crick.clicker.keyboard\t\tclkk\napplication/vnd.crick.clicker.palette\t\tclkp\napplication/vnd.crick.clicker.template\t\tclkt\napplication/vnd.crick.clicker.wordbank\t\tclkw\napplication/vnd.criticaltools.wbs+xml\t\twbs\napplication/vnd.ctc-posml\t\t\tpml\n# application/vnd.ctct.ws+xml\n# application/vnd.cups-pdf\n# application/vnd.cups-postscript\napplication/vnd.cups-ppd\t\t\tppd\n# application/vnd.cups-raster\n# application/vnd.cups-raw\napplication/vnd.curl.car\t\t\tcar\napplication/vnd.curl.pcurl\t\t\tpcurl\n# application/vnd.cybank\napplication/vnd.data-vision.rdz\t\t\trdz\napplication/vnd.denovo.fcselayout-link\t\tfe_launch\n# application/vnd.dir-bi.plate-dl-nosuffix\napplication/vnd.dna\t\t\t\tdna\napplication/vnd.dolby.mlp\t\t\tmlp\n# application/vnd.dolby.mobile.1\n# application/vnd.dolby.mobile.2\napplication/vnd.dpgraph\t\t\t\tdpg\napplication/vnd.dreamfactory\t\t\tdfac\n# application/vnd.dvb.esgcontainer\n# application/vnd.dvb.ipdcdftnotifaccess\n# application/vnd.dvb.ipdcesgaccess\n# application/vnd.dvb.ipdcroaming\n# application/vnd.dvb.iptv.alfec-base\n# application/vnd.dvb.iptv.alfec-enhancement\n# application/vnd.dvb.notif-aggregate-root+xml\n# application/vnd.dvb.notif-container+xml\n# application/vnd.dvb.notif-generic+xml\n# application/vnd.dvb.notif-ia-msglist+xml\n# application/vnd.dvb.notif-ia-registration-request+xml\n# application/vnd.dvb.notif-ia-registration-response+xml\n# application/vnd.dvb.notif-init+xml\n# application/vnd.dxr\napplication/vnd.dynageo\t\t\t\tgeo\n# application/vnd.ecdis-update\napplication/vnd.ecowin.chart\t\t\tmag\n# application/vnd.ecowin.filerequest\n# application/vnd.ecowin.fileupdate\n# application/vnd.ecowin.series\n# application/vnd.ecowin.seriesrequest\n# application/vnd.ecowin.seriesupdate\n# application/vnd.emclient.accessrequest+xml\napplication/vnd.enliven\t\t\t\tnml\napplication/vnd.epson.esf\t\t\tesf\napplication/vnd.epson.msf\t\t\tmsf\napplication/vnd.epson.quickanime\t\tqam\napplication/vnd.epson.salt\t\t\tslt\napplication/vnd.epson.ssf\t\t\tssf\n# application/vnd.ericsson.quickcall\napplication/vnd.eszigno3+xml\t\t\tes3 et3\n# application/vnd.etsi.aoc+xml\n# application/vnd.etsi.cug+xml\n# application/vnd.etsi.iptvcommand+xml\n# application/vnd.etsi.iptvdiscovery+xml\n# application/vnd.etsi.iptvprofile+xml\n# application/vnd.etsi.iptvsad-bc+xml\n# application/vnd.etsi.iptvsad-cod+xml\n# application/vnd.etsi.iptvsad-npvr+xml\n# application/vnd.etsi.iptvueprofile+xml\n# application/vnd.etsi.mcid+xml\n# application/vnd.etsi.sci+xml\n# application/vnd.etsi.simservs+xml\n# application/vnd.etsi.tsl+xml\n# application/vnd.etsi.tsl.der\n# application/vnd.eudora.data\napplication/vnd.ezpix-album\t\t\tez2\napplication/vnd.ezpix-package\t\t\tez3\n# application/vnd.f-secure.mobile\napplication/vnd.fdf\t\t\t\tfdf\napplication/vnd.fdsn.mseed\t\t\tmseed\napplication/vnd.fdsn.seed\t\t\tseed dataless\n# application/vnd.ffsns\n# application/vnd.fints\napplication/vnd.flographit\t\t\tgph\napplication/vnd.fluxtime.clip\t\t\tftc\n# application/vnd.font-fontforge-sfd\napplication/vnd.framemaker\t\t\tfm frame maker book\napplication/vnd.frogans.fnc\t\t\tfnc\napplication/vnd.frogans.ltf\t\t\tltf\napplication/vnd.fsc.weblaunch\t\t\tfsc\napplication/vnd.fujitsu.oasys\t\t\toas\napplication/vnd.fujitsu.oasys2\t\t\toa2\napplication/vnd.fujitsu.oasys3\t\t\toa3\napplication/vnd.fujitsu.oasysgp\t\t\tfg5\napplication/vnd.fujitsu.oasysprs\t\tbh2\n# application/vnd.fujixerox.art-ex\n# application/vnd.fujixerox.art4\n# application/vnd.fujixerox.hbpl\napplication/vnd.fujixerox.ddd\t\t\tddd\napplication/vnd.fujixerox.docuworks\t\txdw\napplication/vnd.fujixerox.docuworks.binder\txbd\n# application/vnd.fut-misnet\napplication/vnd.fuzzysheet\t\t\tfzs\napplication/vnd.genomatix.tuxedo\t\ttxd\n# application/vnd.geocube+xml\napplication/vnd.geogebra.file\t\t\tggb\napplication/vnd.geogebra.tool\t\t\tggt\napplication/vnd.geometry-explorer\t\tgex gre\napplication/vnd.geonext\t\t\t\tgxt\napplication/vnd.geoplan\t\t\t\tg2w\napplication/vnd.geospace\t\t\tg3w\n# application/vnd.globalplatform.card-content-mgt\n# application/vnd.globalplatform.card-content-mgt-response\napplication/vnd.gmx\t\t\t\tgmx\napplication/vnd.google-earth.kml+xml\t\tkml\napplication/vnd.google-earth.kmz\t\tkmz\napplication/vnd.grafeq\t\t\t\tgqf gqs\n# application/vnd.gridmp\napplication/vnd.groove-account\t\t\tgac\napplication/vnd.groove-help\t\t\tghf\napplication/vnd.groove-identity-message\t\tgim\napplication/vnd.groove-injector\t\t\tgrv\napplication/vnd.groove-tool-message\t\tgtm\napplication/vnd.groove-tool-template\t\ttpl\napplication/vnd.groove-vcard\t\t\tvcg\napplication/vnd.handheld-entertainment+xml\tzmm\napplication/vnd.hbci\t\t\t\thbci\n# application/vnd.hcl-bireports\napplication/vnd.hhe.lesson-player\t\tles\napplication/vnd.hp-hpgl\t\t\t\thpgl\napplication/vnd.hp-hpid\t\t\t\thpid\napplication/vnd.hp-hps\t\t\t\thps\napplication/vnd.hp-jlyt\t\t\t\tjlt\napplication/vnd.hp-pcl\t\t\t\tpcl\napplication/vnd.hp-pclxl\t\t\tpclxl\n# application/vnd.httphone\napplication/vnd.hydrostatix.sof-data\t\tsfd-hdstx\napplication/vnd.hzn-3d-crossword\t\tx3d\n# application/vnd.ibm.afplinedata\n# application/vnd.ibm.electronic-media\napplication/vnd.ibm.minipay\t\t\tmpy\napplication/vnd.ibm.modcap\t\t\tafp listafp list3820\napplication/vnd.ibm.rights-management\t\tirm\napplication/vnd.ibm.secure-container\t\tsc\napplication/vnd.iccprofile\t\t\ticc icm\napplication/vnd.igloader\t\t\tigl\napplication/vnd.immervision-ivp\t\t\tivp\napplication/vnd.immervision-ivu\t\t\tivu\n# application/vnd.informedcontrol.rms+xml\n# application/vnd.informix-visionary\napplication/vnd.intercon.formnet\t\txpw xpx\n# application/vnd.intertrust.digibox\n# application/vnd.intertrust.nncp\napplication/vnd.intu.qbo\t\t\tqbo\napplication/vnd.intu.qfx\t\t\tqfx\n# application/vnd.iptc.g2.conceptitem+xml\n# application/vnd.iptc.g2.knowledgeitem+xml\n# application/vnd.iptc.g2.newsitem+xml\n# application/vnd.iptc.g2.packageitem+xml\napplication/vnd.ipunplugged.rcprofile\t\trcprofile\napplication/vnd.irepository.package+xml\t\tirp\napplication/vnd.is-xpr\t\t\t\txpr\napplication/vnd.jam\t\t\t\tjam\n# application/vnd.japannet-directory-service\n# application/vnd.japannet-jpnstore-wakeup\n# application/vnd.japannet-payment-wakeup\n# application/vnd.japannet-registration\n# application/vnd.japannet-registration-wakeup\n# application/vnd.japannet-setstore-wakeup\n# application/vnd.japannet-verification\n# application/vnd.japannet-verification-wakeup\napplication/vnd.jcp.javame.midlet-rms\t\trms\napplication/vnd.jisp\t\t\t\tjisp\napplication/vnd.joost.joda-archive\t\tjoda\napplication/vnd.kahootz\t\t\t\tktz ktr\napplication/vnd.kde.karbon\t\t\tkarbon\napplication/vnd.kde.kchart\t\t\tchrt\napplication/vnd.kde.kformula\t\t\tkfo\napplication/vnd.kde.kivio\t\t\tflw\napplication/vnd.kde.kontour\t\t\tkon\napplication/vnd.kde.kpresenter\t\t\tkpr kpt\napplication/vnd.kde.kspread\t\t\tksp\napplication/vnd.kde.kword\t\t\tkwd kwt\napplication/vnd.kenameaapp\t\t\thtke\napplication/vnd.kidspiration\t\t\tkia\napplication/vnd.kinar\t\t\t\tkne knp\napplication/vnd.koan\t\t\t\tskp skd skt skm\napplication/vnd.kodak-descriptor\t\tsse\n# application/vnd.liberty-request+xml\napplication/vnd.llamagraphics.life-balance.desktop\tlbd\napplication/vnd.llamagraphics.life-balance.exchange+xml\tlbe\napplication/vnd.lotus-1-2-3\t\t\t123\napplication/vnd.lotus-approach\t\t\tapr\napplication/vnd.lotus-freelance\t\t\tpre\napplication/vnd.lotus-notes\t\t\tnsf\napplication/vnd.lotus-organizer\t\t\torg\napplication/vnd.lotus-screencam\t\t\tscm\napplication/vnd.lotus-wordpro\t\t\tlwp\napplication/vnd.macports.portpkg\t\tportpkg\n# application/vnd.marlin.drm.actiontoken+xml\n# application/vnd.marlin.drm.conftoken+xml\n# application/vnd.marlin.drm.license+xml\n# application/vnd.marlin.drm.mdcf\napplication/vnd.mcd\t\t\t\tmcd\napplication/vnd.medcalcdata\t\t\tmc1\napplication/vnd.mediastation.cdkey\t\tcdkey\n# application/vnd.meridian-slingshot\napplication/vnd.mfer\t\t\t\tmwf\napplication/vnd.mfmp\t\t\t\tmfm\napplication/vnd.micrografx.flo\t\t\tflo\napplication/vnd.micrografx.igx\t\t\tigx\napplication/vnd.mif\t\t\t\tmif\n# application/vnd.minisoft-hp3000-save\n# application/vnd.mitsubishi.misty-guard.trustweb\napplication/vnd.mobius.daf\t\t\tdaf\napplication/vnd.mobius.dis\t\t\tdis\napplication/vnd.mobius.mbk\t\t\tmbk\napplication/vnd.mobius.mqy\t\t\tmqy\napplication/vnd.mobius.msl\t\t\tmsl\napplication/vnd.mobius.plc\t\t\tplc\napplication/vnd.mobius.txf\t\t\ttxf\napplication/vnd.mophun.application\t\tmpn\napplication/vnd.mophun.certificate\t\tmpc\n# application/vnd.motorola.flexsuite\n# application/vnd.motorola.flexsuite.adsi\n# application/vnd.motorola.flexsuite.fis\n# application/vnd.motorola.flexsuite.gotap\n# application/vnd.motorola.flexsuite.kmr\n# application/vnd.motorola.flexsuite.ttc\n# application/vnd.motorola.flexsuite.wem\n# application/vnd.motorola.iprm\napplication/vnd.mozilla.xul+xml\t\t\txul\napplication/vnd.ms-artgalry\t\t\tcil\n# application/vnd.ms-asf\napplication/vnd.ms-cab-compressed\t\tcab\napplication/vnd.ms-excel\t\t\txls xlm xla xlc xlt xlw\napplication/vnd.ms-excel.addin.macroenabled.12\t\txlam\napplication/vnd.ms-excel.sheet.binary.macroenabled.12\txlsb\napplication/vnd.ms-excel.sheet.macroenabled.12\t\txlsm\napplication/vnd.ms-excel.template.macroenabled.12\txltm\napplication/vnd.ms-fontobject\t\t\teot\napplication/vnd.ms-htmlhelp\t\t\tchm\napplication/vnd.ms-ims\t\t\t\tims\napplication/vnd.ms-lrm\t\t\t\tlrm\napplication/vnd.ms-pki.seccat\t\t\tcat\napplication/vnd.ms-pki.stl\t\t\tstl\n# application/vnd.ms-playready.initiator+xml\napplication/vnd.ms-powerpoint\t\t\tppt pps pot\napplication/vnd.ms-powerpoint.addin.macroenabled.12\t\tppam\napplication/vnd.ms-powerpoint.presentation.macroenabled.12\tpptm\napplication/vnd.ms-powerpoint.slide.macroenabled.12\t\tsldm\napplication/vnd.ms-powerpoint.slideshow.macroenabled.12\t\tppsm\napplication/vnd.ms-powerpoint.template.macroenabled.12\t\tpotm\napplication/vnd.ms-project\t\t\tmpp mpt\n# application/vnd.ms-tnef\n# application/vnd.ms-wmdrm.lic-chlg-req\n# application/vnd.ms-wmdrm.lic-resp\n# application/vnd.ms-wmdrm.meter-chlg-req\n# application/vnd.ms-wmdrm.meter-resp\napplication/vnd.ms-word.document.macroenabled.12\tdocm\napplication/vnd.ms-word.template.macroenabled.12\tdotm\napplication/vnd.ms-works\t\t\twps wks wcm wdb\napplication/vnd.ms-wpl\t\t\t\twpl\napplication/vnd.ms-xpsdocument\t\t\txps\napplication/vnd.mseq\t\t\t\tmseq\n# application/vnd.msign\n# application/vnd.multiad.creator\n# application/vnd.multiad.creator.cif\n# application/vnd.music-niff\napplication/vnd.musician\t\t\tmus\napplication/vnd.muvee.style\t\t\tmsty\n# application/vnd.ncd.control\n# application/vnd.ncd.reference\n# application/vnd.nervana\n# application/vnd.netfpx\napplication/vnd.neurolanguage.nlu\t\tnlu\napplication/vnd.noblenet-directory\t\tnnd\napplication/vnd.noblenet-sealer\t\t\tnns\napplication/vnd.noblenet-web\t\t\tnnw\n# application/vnd.nokia.catalogs\n# application/vnd.nokia.conml+wbxml\n# application/vnd.nokia.conml+xml\n# application/vnd.nokia.isds-radio-presets\n# application/vnd.nokia.iptv.config+xml\n# application/vnd.nokia.landmark+wbxml\n# application/vnd.nokia.landmark+xml\n# application/vnd.nokia.landmarkcollection+xml\n# application/vnd.nokia.n-gage.ac+xml\napplication/vnd.nokia.n-gage.data\t\tngdat\napplication/vnd.nokia.n-gage.symbian.install\tn-gage\n# application/vnd.nokia.ncd\n# application/vnd.nokia.pcd+wbxml\n# application/vnd.nokia.pcd+xml\napplication/vnd.nokia.radio-preset\t\trpst\napplication/vnd.nokia.radio-presets\t\trpss\napplication/vnd.novadigm.edm\t\t\tedm\napplication/vnd.novadigm.edx\t\t\tedx\napplication/vnd.novadigm.ext\t\t\text\n# application/vnd.ntt-local.file-transfer\napplication/vnd.oasis.opendocument.chart\t\todc\napplication/vnd.oasis.opendocument.chart-template\totc\napplication/vnd.oasis.opendocument.database\t\todb\napplication/vnd.oasis.opendocument.formula\t\todf\napplication/vnd.oasis.opendocument.formula-template\todft\napplication/vnd.oasis.opendocument.graphics\t\todg\napplication/vnd.oasis.opendocument.graphics-template\totg\napplication/vnd.oasis.opendocument.image\t\todi\napplication/vnd.oasis.opendocument.image-template\toti\napplication/vnd.oasis.opendocument.presentation\t\todp\napplication/vnd.oasis.opendocument.presentation-template\totp\napplication/vnd.oasis.opendocument.spreadsheet\t\tods\napplication/vnd.oasis.opendocument.spreadsheet-template\tots\napplication/vnd.oasis.opendocument.text\t\t\todt\napplication/vnd.oasis.opendocument.text-master\t\totm\napplication/vnd.oasis.opendocument.text-template\tott\napplication/vnd.oasis.opendocument.text-web\t\toth\n# application/vnd.obn\napplication/vnd.olpc-sugar\t\t\txo\n# application/vnd.oma-scws-config\n# application/vnd.oma-scws-http-request\n# application/vnd.oma-scws-http-response\n# application/vnd.oma.bcast.associated-procedure-parameter+xml\n# application/vnd.oma.bcast.drm-trigger+xml\n# application/vnd.oma.bcast.imd+xml\n# application/vnd.oma.bcast.ltkm\n# application/vnd.oma.bcast.notification+xml\n# application/vnd.oma.bcast.provisioningtrigger\n# application/vnd.oma.bcast.sgboot\n# application/vnd.oma.bcast.sgdd+xml\n# application/vnd.oma.bcast.sgdu\n# application/vnd.oma.bcast.simple-symbol-container\n# application/vnd.oma.bcast.smartcard-trigger+xml\n# application/vnd.oma.bcast.sprov+xml\n# application/vnd.oma.bcast.stkm\n# application/vnd.oma.dcd\n# application/vnd.oma.dcdc\napplication/vnd.oma.dd2+xml\t\t\tdd2\n# application/vnd.oma.drm.risd+xml\n# application/vnd.oma.group-usage-list+xml\n# application/vnd.oma.poc.detailed-progress-report+xml\n# application/vnd.oma.poc.final-report+xml\n# application/vnd.oma.poc.groups+xml\n# application/vnd.oma.poc.invocation-descriptor+xml\n# application/vnd.oma.poc.optimized-progress-report+xml\n# application/vnd.oma.push\n# application/vnd.oma.scidm.messages+xml\n# application/vnd.oma.xcap-directory+xml\n# application/vnd.omads-email+xml\n# application/vnd.omads-file+xml\n# application/vnd.omads-folder+xml\n# application/vnd.omaloc-supl-init\napplication/vnd.openofficeorg.extension\t\toxt\n# application/vnd.openxmlformats-officedocument.custom-properties+xml\n# application/vnd.openxmlformats-officedocument.customxmlproperties+xml\n# application/vnd.openxmlformats-officedocument.drawing+xml\n# application/vnd.openxmlformats-officedocument.drawingml.chart+xml\n# application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml\n# application/vnd.openxmlformats-officedocument.drawingml.diagramcolors+xml\n# application/vnd.openxmlformats-officedocument.drawingml.diagramdata+xml\n# application/vnd.openxmlformats-officedocument.drawingml.diagramlayout+xml\n# application/vnd.openxmlformats-officedocument.drawingml.diagramstyle+xml\n# application/vnd.openxmlformats-officedocument.extended-properties+xml\n# application/vnd.openxmlformats-officedocument.presentationml.commentauthors+xml\n# application/vnd.openxmlformats-officedocument.presentationml.comments+xml\n# application/vnd.openxmlformats-officedocument.presentationml.handoutmaster+xml\n# application/vnd.openxmlformats-officedocument.presentationml.notesmaster+xml\n# application/vnd.openxmlformats-officedocument.presentationml.notesslide+xml\napplication/vnd.openxmlformats-officedocument.presentationml.presentation\tpptx\n# application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml\n# application/vnd.openxmlformats-officedocument.presentationml.presprops+xml\napplication/vnd.openxmlformats-officedocument.presentationml.slide\tsldx\n# application/vnd.openxmlformats-officedocument.presentationml.slide+xml\n# application/vnd.openxmlformats-officedocument.presentationml.slidelayout+xml\n# application/vnd.openxmlformats-officedocument.presentationml.slidemaster+xml\napplication/vnd.openxmlformats-officedocument.presentationml.slideshow\tppsx\n# application/vnd.openxmlformats-officedocument.presentationml.slideshow.main+xml\n# application/vnd.openxmlformats-officedocument.presentationml.slideupdateinfo+xml\n# application/vnd.openxmlformats-officedocument.presentationml.tablestyles+xml\n# application/vnd.openxmlformats-officedocument.presentationml.tags+xml\napplication/vnd.openxmlformats-officedocument.presentationml.template\tpotx\n# application/vnd.openxmlformats-officedocument.presentationml.template.main+xml\n# application/vnd.openxmlformats-officedocument.presentationml.viewprops+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.calcchain+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.dialogsheet+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.externallink+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.pivotcachedefinition+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.pivotcacherecords+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.pivottable+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.querytable+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.revisionheaders+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.revisionlog+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.sharedstrings+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet\txlsx\n# application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.sheetmetadata+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.tablesinglecells+xml\napplication/vnd.openxmlformats-officedocument.spreadsheetml.template\txltx\n# application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.usernames+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.volatiledependencies+xml\n# application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml\n# application/vnd.openxmlformats-officedocument.theme+xml\n# application/vnd.openxmlformats-officedocument.themeoverride+xml\n# application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml\napplication/vnd.openxmlformats-officedocument.wordprocessingml.document\tdocx\n# application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml\n# application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\n# application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml\n# application/vnd.openxmlformats-officedocument.wordprocessingml.fonttable+xml\n# application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml\n# application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml\n# application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml\n# application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml\n# application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml\napplication/vnd.openxmlformats-officedocument.wordprocessingml.template\tdotx\n# application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml\n# application/vnd.openxmlformats-officedocument.wordprocessingml.websettings+xml\n# application/vnd.openxmlformats-package.core-properties+xml\n# application/vnd.openxmlformats-package.digital-signature-xmlsignature+xml\n# application/vnd.osa.netdeploy\n# application/vnd.osgi.bundle\napplication/vnd.osgi.dp\t\t\t\tdp\n# application/vnd.otps.ct-kip+xml\napplication/vnd.palm\t\t\t\tpdb pqa oprc\n# application/vnd.paos.xml\napplication/vnd.pawaafile\t\t\tpaw\napplication/vnd.pg.format\t\t\tstr\napplication/vnd.pg.osasli\t\t\tei6\n# application/vnd.piaccess.application-licence\napplication/vnd.picsel\t\t\t\tefif\napplication/vnd.pmi.widget\t\t\twg\n# application/vnd.poc.group-advertisement+xml\napplication/vnd.pocketlearn\t\t\tplf\napplication/vnd.powerbuilder6\t\t\tpbd\n# application/vnd.powerbuilder6-s\n# application/vnd.powerbuilder7\n# application/vnd.powerbuilder7-s\n# application/vnd.powerbuilder75\n# application/vnd.powerbuilder75-s\n# application/vnd.preminet\napplication/vnd.previewsystems.box\t\tbox\napplication/vnd.proteus.magazine\t\tmgz\napplication/vnd.publishare-delta-tree\t\tqps\napplication/vnd.pvi.ptid1\t\t\tptid\n# application/vnd.pwg-multiplexed\n# application/vnd.pwg-xhtml-print+xml\n# application/vnd.qualcomm.brew-app-res\napplication/vnd.quark.quarkxpress\t\tqxd qxt qwd qwt qxl qxb\n# application/vnd.radisys.moml+xml\n# application/vnd.radisys.msml+xml\n# application/vnd.radisys.msml-audit+xml\n# application/vnd.radisys.msml-audit-conf+xml\n# application/vnd.radisys.msml-audit-conn+xml\n# application/vnd.radisys.msml-audit-dialog+xml\n# application/vnd.radisys.msml-audit-stream+xml\n# application/vnd.radisys.msml-conf+xml\n# application/vnd.radisys.msml-dialog+xml\n# application/vnd.radisys.msml-dialog-base+xml\n# application/vnd.radisys.msml-dialog-fax-detect+xml\n# application/vnd.radisys.msml-dialog-fax-sendrecv+xml\n# application/vnd.radisys.msml-dialog-group+xml\n# application/vnd.radisys.msml-dialog-speech+xml\n# application/vnd.radisys.msml-dialog-transform+xml\n# application/vnd.rapid\napplication/vnd.realvnc.bed\t\t\tbed\napplication/vnd.recordare.musicxml\t\tmxl\napplication/vnd.recordare.musicxml+xml\t\tmusicxml\n# application/vnd.renlearn.rlprint\napplication/vnd.rim.cod\t\t\t\tcod\napplication/vnd.rn-realmedia\t\t\trm\napplication/vnd.route66.link66+xml\t\tlink66\n# application/vnd.ruckus.download\n# application/vnd.s3sms\napplication/vnd.sailingtracker.track\t\tst\n# application/vnd.sbm.cid\n# application/vnd.sbm.mid2\n# application/vnd.scribus\n# application/vnd.sealed.3df\n# application/vnd.sealed.csf\n# application/vnd.sealed.doc\n# application/vnd.sealed.eml\n# application/vnd.sealed.mht\n# application/vnd.sealed.net\n# application/vnd.sealed.ppt\n# application/vnd.sealed.tiff\n# application/vnd.sealed.xls\n# application/vnd.sealedmedia.softseal.html\n# application/vnd.sealedmedia.softseal.pdf\napplication/vnd.seemail\t\t\t\tsee\napplication/vnd.sema\t\t\t\tsema\napplication/vnd.semd\t\t\t\tsemd\napplication/vnd.semf\t\t\t\tsemf\napplication/vnd.shana.informed.formdata\t\tifm\napplication/vnd.shana.informed.formtemplate\titp\napplication/vnd.shana.informed.interchange\tiif\napplication/vnd.shana.informed.package\t\tipk\napplication/vnd.simtech-mindmapper\t\ttwd twds\napplication/vnd.smaf\t\t\t\tmmf\n# application/vnd.smart.notebook\napplication/vnd.smart.teacher\t\t\tteacher\n# application/vnd.software602.filler.form+xml\n# application/vnd.software602.filler.form-xml-zip\napplication/vnd.solent.sdkm+xml\t\t\tsdkm sdkd\napplication/vnd.spotfire.dxp\t\t\tdxp\napplication/vnd.spotfire.sfs\t\t\tsfs\n# application/vnd.sss-cod\n# application/vnd.sss-dtf\n# application/vnd.sss-ntf\napplication/vnd.stardivision.calc\t\tsdc\napplication/vnd.stardivision.draw\t\tsda\napplication/vnd.stardivision.impress\t\tsdd\napplication/vnd.stardivision.math\t\tsmf\napplication/vnd.stardivision.writer\t\tsdw\napplication/vnd.stardivision.writer\t\tvor\napplication/vnd.stardivision.writer-global\tsgl\n# application/vnd.street-stream\napplication/vnd.sun.xml.calc\t\t\tsxc\napplication/vnd.sun.xml.calc.template\t\tstc\napplication/vnd.sun.xml.draw\t\t\tsxd\napplication/vnd.sun.xml.draw.template\t\tstd\napplication/vnd.sun.xml.impress\t\t\tsxi\napplication/vnd.sun.xml.impress.template\tsti\napplication/vnd.sun.xml.math\t\t\tsxm\napplication/vnd.sun.xml.writer\t\t\tsxw\napplication/vnd.sun.xml.writer.global\t\tsxg\napplication/vnd.sun.xml.writer.template\t\tstw\n# application/vnd.sun.wadl+xml\napplication/vnd.sus-calendar\t\t\tsus susp\napplication/vnd.svd\t\t\t\tsvd\n# application/vnd.swiftview-ics\napplication/vnd.symbian.install\t\t\tsis sisx\napplication/vnd.syncml+xml\t\t\txsm\napplication/vnd.syncml.dm+wbxml\t\t\tbdm\napplication/vnd.syncml.dm+xml\t\t\txdm\n# application/vnd.syncml.dm.notification\n# application/vnd.syncml.ds.notification\napplication/vnd.tao.intent-module-archive\ttao\napplication/vnd.tmobile-livetv\t\t\ttmo\napplication/vnd.trid.tpt\t\t\ttpt\napplication/vnd.triscape.mxs\t\t\tmxs\napplication/vnd.trueapp\t\t\t\ttra\n# application/vnd.truedoc\napplication/vnd.ufdl\t\t\t\tufd ufdl\napplication/vnd.uiq.theme\t\t\tutz\napplication/vnd.umajin\t\t\t\tumj\napplication/vnd.unity\t\t\t\tunityweb\napplication/vnd.uoml+xml\t\t\tuoml\n# application/vnd.uplanet.alert\n# application/vnd.uplanet.alert-wbxml\n# application/vnd.uplanet.bearer-choice\n# application/vnd.uplanet.bearer-choice-wbxml\n# application/vnd.uplanet.cacheop\n# application/vnd.uplanet.cacheop-wbxml\n# application/vnd.uplanet.channel\n# application/vnd.uplanet.channel-wbxml\n# application/vnd.uplanet.list\n# application/vnd.uplanet.list-wbxml\n# application/vnd.uplanet.listcmd\n# application/vnd.uplanet.listcmd-wbxml\n# application/vnd.uplanet.signal\napplication/vnd.vcx\t\t\t\tvcx\n# application/vnd.vd-study\n# application/vnd.vectorworks\n# application/vnd.vidsoft.vidconference\napplication/vnd.visio\t\t\t\tvsd vst vss vsw\napplication/vnd.visionary\t\t\tvis\n# application/vnd.vividence.scriptfile\napplication/vnd.vsf\t\t\t\tvsf\n# application/vnd.wap.sic\n# application/vnd.wap.slc\napplication/vnd.wap.wbxml\t\t\twbxml\napplication/vnd.wap.wmlc\t\t\twmlc\napplication/vnd.wap.wmlscriptc\t\t\twmlsc\napplication/vnd.webturbo\t\t\twtb\n# application/vnd.wfa.wsc\n# application/vnd.wmc\n# application/vnd.wmf.bootstrap\n# application/vnd.wolfram.mathematica\n# application/vnd.wolfram.mathematica.package\napplication/vnd.wolfram.player\t\t\tnbp\napplication/vnd.wordperfect\t\t\twpd\napplication/vnd.wqd\t\t\t\twqd\n# application/vnd.wrq-hp3000-labelled\napplication/vnd.wt.stf\t\t\t\tstf\n# application/vnd.wv.csp+wbxml\n# application/vnd.wv.csp+xml\n# application/vnd.wv.ssp+xml\napplication/vnd.xara\t\t\t\txar\napplication/vnd.xfdl\t\t\t\txfdl\n# application/vnd.xfdl.webform\n# application/vnd.xmi+xml\n# application/vnd.xmpie.cpkg\n# application/vnd.xmpie.dpkg\n# application/vnd.xmpie.plan\n# application/vnd.xmpie.ppkg\n# application/vnd.xmpie.xlim\napplication/vnd.yamaha.hv-dic\t\t\thvd\napplication/vnd.yamaha.hv-script\t\thvs\napplication/vnd.yamaha.hv-voice\t\t\thvp\napplication/vnd.yamaha.openscoreformat\t\t\tosf\napplication/vnd.yamaha.openscoreformat.osfpvg+xml\tosfpvg\napplication/vnd.yamaha.smaf-audio\t\tsaf\napplication/vnd.yamaha.smaf-phrase\t\tspf\napplication/vnd.yellowriver-custom-menu\t\tcmp\napplication/vnd.zul\t\t\t\tzir zirz\napplication/vnd.zzazz.deck+xml\t\t\tzaz\napplication/voicexml+xml\t\t\tvxml\n# application/watcherinfo+xml\n# application/whoispp-query\n# application/whoispp-response\napplication/winhlp\t\t\t\thlp\n# application/wita\n# application/wordperfect5.1\napplication/wsdl+xml\t\t\t\twsdl\napplication/wspolicy+xml\t\t\twspolicy\napplication/x-abiword\t\t\t\tabw\napplication/x-ace-compressed\t\t\tace\napplication/x-authorware-bin\t\t\taab x32 u32 vox\napplication/x-authorware-map\t\t\taam\napplication/x-authorware-seg\t\t\taas\napplication/x-bcpio\t\t\t\tbcpio\napplication/x-bittorrent\t\t\ttorrent\napplication/x-bzip\t\t\t\tbz\napplication/x-bzip2\t\t\t\tbz2 boz\napplication/x-cdlink\t\t\t\tvcd\napplication/x-chat\t\t\t\tchat\napplication/x-chess-pgn\t\t\t\tpgn\n# application/x-compress\napplication/x-cpio\t\t\t\tcpio\napplication/x-csh\t\t\t\tcsh\napplication/x-debian-package\t\t\tdeb udeb\napplication/x-director\t\t\tdir dcr dxr cst cct cxt w3d fgd swa\napplication/x-doom\t\t\t\twad\napplication/x-dtbncx+xml\t\t\tncx\napplication/x-dtbook+xml\t\t\tdtb\napplication/x-dtbresource+xml\t\t\tres\napplication/x-dvi\t\t\t\tdvi\napplication/x-font-bdf\t\t\t\tbdf\n# application/x-font-dos\n# application/x-font-framemaker\napplication/x-font-ghostscript\t\t\tgsf\n# application/x-font-libgrx\napplication/x-font-linux-psf\t\t\tpsf\napplication/x-font-otf\t\t\t\totf\napplication/x-font-pcf\t\t\t\tpcf\napplication/x-font-snf\t\t\t\tsnf\n# application/x-font-speedo\n# application/x-font-sunos-news\napplication/x-font-ttf\t\t\t\tttf ttc\napplication/x-font-type1\t\t\tpfa pfb pfm afm\n# application/x-font-vfont\napplication/x-futuresplash\t\t\tspl\napplication/x-gnumeric\t\t\t\tgnumeric\napplication/x-gtar\t\t\t\tgtar\n# application/x-gzip\napplication/x-hdf\t\t\t\thdf\napplication/x-java-jnlp-file\t\t\tjnlp\napplication/x-latex\t\t\t\tlatex\napplication/x-mobipocket-ebook\t\t\tprc mobi\napplication/x-ms-application\t\t\tapplication\napplication/x-ms-wmd\t\t\t\twmd\napplication/x-ms-wmz\t\t\t\twmz\napplication/x-ms-xbap\t\t\t\txbap\napplication/x-msaccess\t\t\t\tmdb\napplication/x-msbinder\t\t\t\tobd\napplication/x-mscardfile\t\t\tcrd\napplication/x-msclip\t\t\t\tclp\napplication/x-msdownload\t\t\texe dll com bat msi\napplication/x-msmediaview\t\t\tmvb m13 m14\napplication/x-msmetafile\t\t\twmf\napplication/x-msmoney\t\t\t\tmny\napplication/x-mspublisher\t\t\tpub\napplication/x-msschedule\t\t\tscd\napplication/x-msterminal\t\t\ttrm\napplication/x-mswrite\t\t\t\twri\napplication/x-netcdf\t\t\t\tnc cdf\napplication/x-pkcs12\t\t\t\tp12 pfx\napplication/x-pkcs7-certificates\t\tp7b spc\napplication/x-pkcs7-certreqresp\t\t\tp7r\napplication/x-rar-compressed\t\t\trar\napplication/x-sh\t\t\t\tsh\napplication/x-shar\t\t\t\tshar\napplication/x-shockwave-flash\t\t\tswf\napplication/x-silverlight-app\t\t\txap\napplication/x-stuffit\t\t\t\tsit\napplication/x-stuffitx\t\t\t\tsitx\napplication/x-sv4cpio\t\t\t\tsv4cpio\napplication/x-sv4crc\t\t\t\tsv4crc\napplication/x-tar\t\t\t\ttar\napplication/x-tcl\t\t\t\ttcl\napplication/x-tex\t\t\t\ttex\napplication/x-tex-tfm\t\t\t\ttfm\napplication/x-texinfo\t\t\t\ttexinfo texi\napplication/x-ustar\t\t\t\tustar\napplication/x-wais-source\t\t\tsrc\napplication/x-x509-ca-cert\t\t\tder crt\napplication/x-xfig\t\t\t\tfig\napplication/x-xpinstall\t\t\t\txpi\n# application/x400-bp\n# application/xcap-att+xml\n# application/xcap-caps+xml\n# application/xcap-el+xml\n# application/xcap-error+xml\n# application/xcap-ns+xml\n# application/xcon-conference-info-diff+xml\n# application/xcon-conference-info+xml\napplication/xenc+xml\t\t\t\txenc\napplication/xhtml+xml\t\t\t\txhtml xht\n# application/xhtml-voice+xml\n# NOTE: using text/xml instead: application/xml\t\t\t\t\txml xsl\napplication/xml-dtd\t\t\t\tdtd\n# application/xml-external-parsed-entity\n# application/xmpp+xml\napplication/xop+xml\t\t\t\txop\napplication/xslt+xml\t\t\t\txslt\napplication/xspf+xml\t\t\t\txspf\napplication/xv+xml\t\t\t\tmxml xhvml xvml xvm\napplication/zip\t\t\t\t\tzip\n# audio/32kadpcm\n# audio/3gpp\n# audio/3gpp2\n# audio/ac3\naudio/adpcm\t\t\t\t\tadp\n# audio/amr\n# audio/amr-wb\n# audio/amr-wb+\n# audio/asc\n# audio/atrac-advanced-lossless\n# audio/atrac-x\n# audio/atrac3\naudio/basic\t\t\t\t\tau snd\n# audio/bv16\n# audio/bv32\n# audio/clearmode\n# audio/cn\n# audio/dat12\n# audio/dls\n# audio/dsr-es201108\n# audio/dsr-es202050\n# audio/dsr-es202211\n# audio/dsr-es202212\n# audio/dvi4\n# audio/eac3\n# audio/evrc\n# audio/evrc-qcp\n# audio/evrc0\n# audio/evrc1\n# audio/evrcb\n# audio/evrcb0\n# audio/evrcb1\n# audio/evrcwb\n# audio/evrcwb0\n# audio/evrcwb1\n# audio/example\n# audio/g719\n# audio/g722\n# audio/g7221\n# audio/g723\n# audio/g726-16\n# audio/g726-24\n# audio/g726-32\n# audio/g726-40\n# audio/g728\n# audio/g729\n# audio/g7291\n# audio/g729d\n# audio/g729e\n# audio/gsm\n# audio/gsm-efr\n# audio/ilbc\n# audio/l16\n# audio/l20\n# audio/l24\n# audio/l8\n# audio/lpc\naudio/midi\t\t\t\t\tmid midi kar rmi\n# audio/mobile-xmf\naudio/mp4\t\t\t\t\tmp4a\n# audio/mp4a-latm\n# audio/mpa\n# audio/mpa-robust\naudio/mpeg\t\t\t\t\tmpga mp2 mp2a mp3 m2a m3a\n# audio/mpeg4-generic\naudio/ogg\t\t\t\t\toga ogg spx\n# audio/parityfec\n# audio/pcma\n# audio/pcma-wb\n# audio/pcmu-wb\n# audio/pcmu\n# audio/prs.sid\n# audio/qcelp\n# audio/red\n# audio/rtp-enc-aescm128\n# audio/rtp-midi\n# audio/rtx\n# audio/smv\n# audio/smv0\n# audio/smv-qcp\n# audio/sp-midi\n# audio/speex\n# audio/t140c\n# audio/t38\n# audio/telephone-event\n# audio/tone\n# audio/uemclip\n# audio/ulpfec\n# audio/vdvi\n# audio/vmr-wb\n# audio/vnd.3gpp.iufp\n# audio/vnd.4sb\n# audio/vnd.audiokoz\n# audio/vnd.celp\n# audio/vnd.cisco.nse\n# audio/vnd.cmles.radio-events\n# audio/vnd.cns.anp1\n# audio/vnd.cns.inf1\naudio/vnd.digital-winds\t\t\t\teol\n# audio/vnd.dlna.adts\n# audio/vnd.dolby.heaac.1\n# audio/vnd.dolby.heaac.2\n# audio/vnd.dolby.mlp\n# audio/vnd.dolby.mps\n# audio/vnd.dolby.pl2\n# audio/vnd.dolby.pl2x\n# audio/vnd.dolby.pl2z\n# audio/vnd.dolby.pulse.1\naudio/vnd.dra\t\t\t\t\tdra\naudio/vnd.dts\t\t\t\t\tdts\naudio/vnd.dts.hd\t\t\t\tdtshd\n# audio/vnd.everad.plj\n# audio/vnd.hns.audio\naudio/vnd.lucent.voice\t\t\t\tlvp\naudio/vnd.ms-playready.media.pya\t\tpya\n# audio/vnd.nokia.mobile-xmf\n# audio/vnd.nortel.vbk\naudio/vnd.nuera.ecelp4800\t\t\tecelp4800\naudio/vnd.nuera.ecelp7470\t\t\tecelp7470\naudio/vnd.nuera.ecelp9600\t\t\tecelp9600\n# audio/vnd.octel.sbc\n# audio/vnd.qcelp\n# audio/vnd.rhetorex.32kadpcm\n# audio/vnd.sealedmedia.softseal.mpeg\n# audio/vnd.vmx.cvsd\n# audio/vorbis\n# audio/vorbis-config\naudio/x-aac\t\t\t\t\taac\naudio/x-aiff\t\t\t\t\taif aiff aifc\naudio/x-mpegurl\t\t\t\t\tm3u\naudio/x-ms-wax\t\t\t\t\twax\naudio/x-ms-wma\t\t\t\t\twma\naudio/x-pn-realaudio\t\t\t\tram ra\naudio/x-pn-realaudio-plugin\t\t\trmp\naudio/x-wav\t\t\t\t\twav\nchemical/x-cdx\t\t\t\t\tcdx\nchemical/x-cif\t\t\t\t\tcif\nchemical/x-cmdf\t\t\t\t\tcmdf\nchemical/x-cml\t\t\t\t\tcml\nchemical/x-csml\t\t\t\t\tcsml\n# chemical/x-pdb\nchemical/x-xyz\t\t\t\t\txyz\nimage/bmp\t\t\t\t\tbmp\nimage/cgm\t\t\t\t\tcgm\n# image/example\n# image/fits\nimage/g3fax\t\t\t\t\tg3\nimage/gif\t\t\t\t\tgif\nimage/ief\t\t\t\t\tief\n# image/jp2\nimage/jpeg\t\t\t\t\tjpeg jpg jpe\n# image/jpm\n# image/jpx\n# image/naplps\nimage/png\t\t\t\t\tpng\nimage/prs.btif\t\t\t\t\tbtif\n# image/prs.pti\nimage/svg+xml\t\t\t\t\tsvg svgz\n# image/t38\nimage/tiff\t\t\t\t\ttiff tif\n# image/tiff-fx\nimage/vnd.adobe.photoshop\t\t\tpsd\n# image/vnd.cns.inf2\nimage/vnd.djvu\t\t\t\t\tdjvu djv\nimage/vnd.dwg\t\t\t\t\tdwg\nimage/vnd.dxf\t\t\t\t\tdxf\nimage/vnd.fastbidsheet\t\t\t\tfbs\nimage/vnd.fpx\t\t\t\t\tfpx\nimage/vnd.fst\t\t\t\t\tfst\nimage/vnd.fujixerox.edmics-mmr\t\t\tmmr\nimage/vnd.fujixerox.edmics-rlc\t\t\trlc\n# image/vnd.globalgraphics.pgb\n# image/vnd.microsoft.icon\n# image/vnd.mix\nimage/vnd.ms-modi\t\t\t\tmdi\nimage/vnd.net-fpx\t\t\t\tnpx\n# image/vnd.radiance\n# image/vnd.sealed.png\n# image/vnd.sealedmedia.softseal.gif\n# image/vnd.sealedmedia.softseal.jpg\n# image/vnd.svf\nimage/vnd.wap.wbmp\t\t\t\twbmp\nimage/vnd.xiff\t\t\t\t\txif\nimage/x-cmu-raster\t\t\t\tras\nimage/x-cmx\t\t\t\t\tcmx\nimage/x-freehand\t\t\t\tfh fhc fh4 fh5 fh7\nimage/x-icon\t\t\t\t\tico\nimage/x-pcx\t\t\t\t\tpcx\nimage/x-pict\t\t\t\t\tpic pct\nimage/x-portable-anymap\t\t\t\tpnm\nimage/x-portable-bitmap\t\t\t\tpbm\nimage/x-portable-graymap\t\t\tpgm\nimage/x-portable-pixmap\t\t\t\tppm\nimage/x-rgb\t\t\t\t\trgb\nimage/x-xbitmap\t\t\t\t\txbm\nimage/x-xpixmap\t\t\t\t\txpm\nimage/x-xwindowdump\t\t\t\txwd\n# message/cpim\n# message/delivery-status\n# message/disposition-notification\n# message/example\n# message/external-body\n# message/global\n# message/global-delivery-status\n# message/global-disposition-notification\n# message/global-headers\n# message/http\n# message/imdn+xml\n# message/news\n# message/partial\nmessage/rfc822\t\t\t\t\teml mime\n# message/s-http\n# message/sip\n# message/sipfrag\n# message/tracking-status\n# message/vnd.si.simp\n# model/example\nmodel/iges\t\t\t\t\tigs iges\nmodel/mesh\t\t\t\t\tmsh mesh silo\nmodel/vnd.dwf\t\t\t\t\tdwf\n# model/vnd.flatland.3dml\nmodel/vnd.gdl\t\t\t\t\tgdl\n# model/vnd.gs-gdl\n# model/vnd.gs.gdl\nmodel/vnd.gtw\t\t\t\t\tgtw\n# model/vnd.moml+xml\nmodel/vnd.mts\t\t\t\t\tmts\n# model/vnd.parasolid.transmit.binary\n# model/vnd.parasolid.transmit.text\nmodel/vnd.vtu\t\t\t\t\tvtu\nmodel/vrml\t\t\t\t\twrl vrml\n# multipart/alternative\n# multipart/appledouble\n# multipart/byteranges\n# multipart/digest\n# multipart/encrypted\n# multipart/example\n# multipart/form-data\n# multipart/header-set\n# multipart/mixed\n# multipart/parallel\n# multipart/related\n# multipart/report\n# multipart/signed\n# multipart/voice-message\ntext/calendar\t\t\t\t\tics ifb\ntext/css\t\t\t\t\tcss\ntext/csv\t\t\t\t\tcsv\n# text/directory\n# text/dns\n# text/ecmascript\n# text/enriched\n# text/example\ntext/html\t\t\t\t\thtml htm\n# text/javascript\n# text/parityfec\ntext/plain\t\t\t\t\ttxt text conf def list log in ini cwiki gstring mediawiki textile tracwiki twiki\n# text/prs.fallenstein.rst\ntext/prs.lines.tag\t\t\t\tdsc\n# text/vnd.radisys.msml-basic-layout\n# text/red\n# text/rfc822-headers\ntext/richtext\t\t\t\t\trtx\n# text/rtf\n# text/rtp-enc-aescm128\n# text/rtx\ntext/sgml\t\t\t\t\tsgml sgm\n# text/t140\ntext/tab-separated-values\t\t\ttsv\ntext/troff\t\t\t\t\tt tr roff man me ms\n# text/ulpfec\ntext/uri-list\t\t\t\t\turi uris urls\n# text/vnd.abc\ntext/vnd.curl\t\t\t\t\tcurl\ntext/vnd.curl.dcurl\t\t\t\tdcurl\ntext/vnd.curl.scurl\t\t\t\tscurl\ntext/vnd.curl.mcurl\t\t\t\tmcurl\n# text/vnd.dmclientscript\n# text/vnd.esmertec.theme-descriptor\ntext/vnd.fly\t\t\t\t\tfly\ntext/vnd.fmi.flexstor\t\t\t\tflx\ntext/vnd.graphviz\t\t\t\tgv\ntext/vnd.in3d.3dml\t\t\t\t3dml\ntext/vnd.in3d.spot\t\t\t\tspot\n# text/vnd.iptc.newsml\n# text/vnd.iptc.nitf\n# text/vnd.latex-z\n# text/vnd.motorola.reflex\n# text/vnd.ms-mediapackage\n# text/vnd.net2phone.commcenter.command\n# text/vnd.si.uricatalogue\ntext/vnd.sun.j2me.app-descriptor\t\tjad\n# text/vnd.trolltech.linguist\n# text/vnd.wap.si\n# text/vnd.wap.sl\ntext/vnd.wap.wml\t\t\t\twml\ntext/vnd.wap.wmlscript\t\t\t\twmls\ntext/x-asm\t\t\t\t\ts asm\ntext/x-c\t\t\t\t\tc cc cxx cpp h hh dic\ntext/x-fortran\t\t\t\t\tf for f77 f90\ntext/x-freemarker               ftl\ntext/x-markdown                 md markdown\ntext/x-pascal\t\t\t\t\tp pas\ntext/x-java-source\t\t\t\tjava\ntext/x-java-properties          properties\ntext/x-typescript               ts\ntext/x-setext\t\t\t\t\tetx\ntext/x-uuencode\t\t\t\t\tuu\ntext/x-vcalendar\t\t\t\tvcs\ntext/x-vcard\t\t\t\t\tvcf\ntext/xml                        xml xsd xsl\n# text/xml-external-parsed-entity\nvideo/3gpp\t\t\t\t\t3gp\n# video/3gpp-tt\nvideo/3gpp2\t\t\t\t\t3g2\n# video/bmpeg\n# video/bt656\n# video/celb\n# video/dv\n# video/example\nvideo/h261\t\t\t\t\th261\nvideo/h263\t\t\t\t\th263\n# video/h263-1998\n# video/h263-2000\nvideo/h264\t\t\t\t\th264\nvideo/jpeg\t\t\t\t\tjpgv\n# video/jpeg2000\nvideo/jpm\t\t\t\t\tjpm jpgm\nvideo/mj2\t\t\t\t\tmj2 mjp2\n# video/mp1s\n# video/mp2p\n# video/mp2t\nvideo/mp4\t\t\t\t\tmp4 mp4v mpg4\n# video/mp4v-es\nvideo/mpeg\t\t\t\t\tmpeg mpg mpe m1v m2v\n# video/mpeg4-generic\n# video/mpv\n# video/nv\nvideo/ogg\t\t\t\t\togv\n# video/parityfec\n# video/pointer\nvideo/quicktime\t\t\t\t\tqt mov\n# video/raw\n# video/rtp-enc-aescm128\n# video/rtx\n# video/smpte292m\n# video/ulpfec\n# video/vc1\n# video/vnd.cctv\n# video/vnd.dlna.mpeg-tts\nvideo/vnd.fvt\t\t\t\t\tfvt\n# video/vnd.hns.video\n# video/vnd.iptvforum.1dparityfec-1010\n# video/vnd.iptvforum.1dparityfec-2005\n# video/vnd.iptvforum.2dparityfec-1010\n# video/vnd.iptvforum.2dparityfec-2005\n# video/vnd.iptvforum.ttsavc\n# video/vnd.iptvforum.ttsmpeg2\n# video/vnd.motorola.video\n# video/vnd.motorola.videop\nvideo/vnd.mpegurl\t\t\t\tmxu m4u\nvideo/vnd.ms-playready.media.pyv\t\tpyv\n# video/vnd.nokia.interleaved-multimedia\n# video/vnd.nokia.videovoip\n# video/vnd.objectvideo\n# video/vnd.sealed.mpeg1\n# video/vnd.sealed.mpeg4\n# video/vnd.sealed.swf\n# video/vnd.sealedmedia.softseal.mov\nvideo/vnd.vivo\t\t\t\t\tviv\nvideo/x-f4v\t\t\t\t\tf4v\nvideo/x-fli\t\t\t\t\tfli\nvideo/x-flv\t\t\t\t\tflv\nvideo/x-m4v\t\t\t\t\tm4v\nvideo/x-ms-asf\t\t\t\t\tasf asx\nvideo/x-ms-wm\t\t\t\t\twm\nvideo/x-ms-wmv\t\t\t\t\twmv\nvideo/x-ms-wmx\t\t\t\t\twmx\nvideo/x-ms-wvx\t\t\t\t\twvx\nvideo/x-msvideo\t\t\t\t\tavi\nvideo/x-sgi-movie\t\t\t\tmovie\nx-conference/x-cooltalk\t\t\t\tice\n"
  },
  {
    "path": "framework/src/main/resources/META-INF/services/org.moqui.context.ExecutionContextFactory",
    "content": "# Implementation of the org.moqui.context.ExecutionContextFactory interface:\norg.moqui.impl.context.ExecutionContextFactoryImpl\n\n"
  },
  {
    "path": "framework/src/main/resources/MoquiDefaultConf.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\" ?>\n<!-- No copyright or license for configuration file, details here are not considered a creative work. -->\n<moqui-conf xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:noNamespaceSchemaLocation=\"http://moqui.org/xsd/moqui-conf-3.xsd\">\n\n    <!-- Settings in this file can be overridden in a component (MoquiConf.xml) and/or runtime conf file that has\n        any of the desired sub-elements. -->\n\n    <!-- Default Properties: only set if there is no Java property or environment variable of the same name (or with underscores replaced by dots) -->\n\n    <!-- recommended values: production, dev, test; any code may look at these Java System properties to vary behavior (System.getProperty(\"instance_purpose\")) -->\n    <default-property name=\"instance_purpose\" value=\"production\"/>\n\n    <!-- Locale and Time Zone Properties -->\n    <default-property name=\"default_locale\" value=\"en_US\"/>\n    <default-property name=\"default_time_zone\" value=\"UTC\"/>\n    <default-property name=\"database_time_zone\" value=\"UTC\"/>\n\n    <!-- Web App Properties -->\n    <default-property name=\"webapp_http_host\" value=\"\"/>\n    <default-property name=\"webapp_http_port\" value=\"\"/>\n    <default-property name=\"webapp_https_port\" value=\"\"/>\n    <default-property name=\"webapp_https_enabled\" value=\"false\"/>\n    <default-property name=\"webapp_allow_origins\" value=\"\"/>\n    <default-property name=\"webapp_handle_cors\" value=\"true\"/>\n    <default-property name=\"webapp_status_ips\" value=\"127.0.0.1\"/>\n    <default-property name=\"webapp_require_session_token\" value=\"true\"/>\n    <default-property name=\"webapp_upload_executable_allow\" value=\"false\"/>\n\n    <!--\n        While there is no default value for webapp_client_ip_header, embedded Jetty looks at Forwarded and\n        X-Forwarded-For headers via ForwardedRequestCustomizer (see MoquiStart.java); this is necessary so that\n        Jetty knows if a proxied request came via HTTPS vs HTTP, but is risky because clients can set a value for\n        the header to spoof any of these settings including a client IP address; if the outer-most proxy handles\n        X-Forwarded-For by always setting it to its client IP address instead of the common default behavior of\n        appending to an existing X-Forwarded-For header then it is fine, but to avoid this set webapp_client_ip_header\n        to something more reliable for the proxy used.\n\n        nginx-proxy: X-Real-IP (see https://github.com/nginx-proxy/nginx-proxy/blob/main/nginx.tmpl)\n        Cloudflare: CF-Connecting-IP (or True-Client-IP if that feature is enabled)\n        AWS Cloudfront: CloudFront-Viewer-Address (requires config in Cloudfront origin request policy)\n    -->\n    <default-property name=\"webapp_client_ip_header\" value=\"\"/>\n\n    <!-- Properties for the primary (transactional group) datasource -->\n    <default-property name=\"entity_ds_db_conf\" value=\"h2\"/>\n    <default-property name=\"entity_ds_host\" value=\"127.0.0.1\"/>\n    <default-property name=\"entity_ds_port\" value=\"\"/>\n    <default-property name=\"entity_ds_database\" value=\"moqui\"/>\n    <default-property name=\"entity_ds_url\" value=\"jdbc:h2:${moqui_runtime}/db/h2/${entity_ds_database};lock_timeout=30000\"/>\n    <default-property name=\"entity_ds_schema\" value=\"\"/>\n    <default-property name=\"entity_ds_user\" value=\"sa\"/>\n    <default-property name=\"entity_ds_password\" value=\"sa\" is-secret=\"true\"/>\n    <default-property name=\"entity_ds_crypt_pass\" value=\"MoquiDefaultPassword:CHANGEME\" is-secret=\"true\"/>\n    <default-property name=\"entity_ds_crypt_pass_old\" value=\"MoquiDefaultPassword:CHANGEME\" is-secret=\"true\"/>\n    <default-property name=\"entity_add_missing_runtime\" value=\"false\"/>\n    <default-property name=\"entity_add_missing_startup\" value=\"true\"/>\n    <default-property name=\"entity_lock_track\" value=\"false\"/>\n    <default-property name=\"entity_statement_timeout\" value=\"false\"/>\n    <default-property name=\"entity_empty_db_load\" value=\"seed,seed-initial,install\"/>\n    <default-property name=\"entity_on_start_load_types\" value=\"none\"/>\n    <default-property name=\"entity_on_start_load_components\" value=\"\"/>\n\n    <!-- Properties for a database clone; note that without overriding the transactional#clone1 datasource the default works only\n        with MySQL and Postgres because of different properties on XADataSource classes of other JDBC drivers -->\n    <default-property name=\"entity_ds_c1_disabled\" value=\"true\"/>\n    <default-property name=\"entity_ds_c1_db_conf\" value=\"mysql\"/>\n    <default-property name=\"entity_ds_c1_schema\" value=\"\"/>\n    <default-property name=\"entity_ds_c1_host\" value=\"\"/>\n    <default-property name=\"entity_ds_c1_port\" value=\"\"/>\n    <default-property name=\"entity_ds_c1_database\" value=\"\"/>\n    <default-property name=\"entity_ds_c1_user\" value=\"\"/>\n    <default-property name=\"entity_ds_c1_password\" value=\"\" is-secret=\"true\"/>\n\n    <!-- How often (in seconds) to check for scheduled jobs to run, set to 0 to not run scheduled jobs -->\n    <default-property name=\"scheduled_job_check_time\" value=\"60\"/>\n\n    <!-- ElasticSearch Client and Proxy Servlet settings -->\n    <default-property name=\"elasticsearch_url\" value=\"http://127.0.0.1:9200\"/>\n    <default-property name=\"elasticsearch_user\" value=\"\"/>\n    <default-property name=\"elasticsearch_password\" value=\"\" is-secret=\"true\"/>\n    <default-property name=\"elasticsearch_index_prefix\" value=\"\"/>\n    <!-- Kibana Proxy Servlet settings -->\n    <default-property name=\"kibana_host\" value=\"127.0.0.1\"/>\n    <default-property name=\"kibana_port\" value=\"5601\"/>\n\n    <tools worker-queue=\"65535\" worker-pool-core=\"16\" worker-pool-max=\"32\" worker-pool-alive=\"60\"\n            empty-db-load=\"${entity_empty_db_load}\"\n            on-start-load-types=\"${entity_on_start_load_types}\" on-start-load-components=\"${entity_on_start_load_components}\">\n        <tool-factory class=\"org.moqui.impl.tools.MCacheToolFactory\" init-priority=\"03\" disabled=\"false\"/>\n        <!-- Apache Commons JCS, an alternative for distributed caches (cannot be used as local cache as requires Serializable keys/values just like Hazelcast) -->\n        <!-- <tool-factory class=\"org.moqui.impl.tools.JCSCacheToolFactory\" init-priority=\"09\" disabled=\"true\"/> -->\n        <!-- H2 Database ToolFactory - if h2 database active runs the H2 server for external access (local only depending on conf) -->\n        <tool-factory class=\"org.moqui.impl.tools.H2ServerToolFactory\" init-priority=\"12\" disabled=\"false\"/>\n        <!-- Jackrabbit ToolFactory for running Jackrabbit if plugged in -->\n        <tool-factory class=\"org.moqui.impl.tools.JackrabbitRunToolFactory\" init-priority=\"40\" disabled=\"true\"/>\n        <!-- SubEtha SMTP ToolFactory starts an SMTP server using the MOQUI_LOCAL EmailServer settings, emails received trigger EMECA rules -->\n        <tool-factory class=\"org.moqui.impl.tools.SubEthaSmtpToolFactory\" init-priority=\"50\" disabled=\"true\"/>\n    </tools>\n\n    <cache-list warm-on-start=\"true\" local-factory=\"MCache\" distributed-factory=\"MCache\">\n        <!-- Entity Database Record Caches (and cache clear assist data) -->\n        <!-- set type=\"distributed\" to use the distributed cache -->\n        <cache name=\"entity.record.one.\" max-elements=\"20000\" eviction-strategy=\"least-frequently-used\"\n                key-type=\"org.moqui.entity.EntityCondition\" value-type=\"org.moqui.impl.entity.EntityValueBase\"/>\n        <cache name=\"entity.record.list.\" max-elements=\"10000\" eviction-strategy=\"least-frequently-used\"\n                key-type=\"org.moqui.entity.EntityCondition\" value-type=\"org.moqui.impl.entity.EntityListImpl\"/>\n        <cache name=\"entity.record.count.\" max-elements=\"10000\" eviction-strategy=\"least-frequently-used\"\n                key-type=\"org.moqui.entity.EntityCondition\" value-type=\"Long\"/>\n\n        <cache name=\"entity.record.one_ra.\" max-elements=\"40000\" eviction-strategy=\"least-frequently-used\"\n                key-type=\"org.moqui.entity.EntityCondition\" value-type=\"Set\"/>\n        <cache name=\"entity.record.one_view_ra.\" max-elements=\"40000\" eviction-strategy=\"least-frequently-used\"\n                key-type=\"org.moqui.entity.EntityCondition\" value-type=\"Set\"/>\n        <cache name=\"entity.record.one_bf\" max-elements=\"1000\" eviction-strategy=\"least-frequently-used\"\n                value-type=\"Set\"/>\n\n        <cache name=\"entity.record.list_ra.\" max-elements=\"20000\" eviction-strategy=\"least-frequently-used\"\n                key-type=\"org.moqui.entity.EntityCondition\" value-type=\"Set\"/>\n        <cache name=\"entity.record.list_view_ra.\" max-elements=\"20000\" eviction-strategy=\"least-frequently-used\"\n                key-type=\"org.moqui.entity.EntityCondition\" value-type=\"Set\"/>\n\n        <!-- Framework configuration and artifact caches -->\n        <!-- NOTE: Production mode by default - No expiration of conf and impl artifacts. -->\n\n        <cache name=\"entity.definition\" value-type=\"org.moqui.impl.entity.EntityDefinition\"/>\n        <cache name=\"entity.location\" value-type=\"Map\"/>\n        <cache name=\"entity.sequence.bank\" value-type=\"long[]\"/>\n        <!-- this is info for each entity for real-time push DataFeeds; expires every 15 min to get DataFeed and DataDocument updates -->\n        <cache name=\"entity.data.feed.info\" expire-time-live=\"900\" value-type=\"ArrayList\"/>\n\n        <cache name=\"service.location\" value-type=\"org.moqui.impl.service.ServiceDefinition\"/>\n        <cache name=\"service.rest.api\" value-type=\"org.moqui.impl.service.RestApi$ResourceNode\"/>\n        <cache name=\"kie.component.releaseId\" value-type=\"org.kie.api.builder.ReleaseId\"/>\n        <cache name=\"kie.session.component\" value-type=\"String\"/>\n\n        <cache name=\"screen.location\" value-type=\"org.moqui.impl.screen.ScreenDefinition\"/>\n        <cache name=\"screen.location.perm\" value-type=\"org.moqui.impl.screen.ScreenDefinition\"/>\n        <cache name=\"screen.url\" value-type=\"org.moqui.impl.screen.ScreenUrlInfo\"/>\n        <cache name=\"screen.info\" value-type=\"List\"/>\n        <cache name=\"screen.info.ref.rev\" value-type=\"Set\"/>\n        <cache name=\"screen.template.mode\" value-type=\"freemarker.template.Template\"/>\n        <cache name=\"screen.template.location\" value-type=\"freemarker.template.Template\"/>\n        <cache name=\"widget.template.location\" value-type=\"MNode\"/>\n        <cache name=\"screen.find.path\" value-type=\"ArrayList\"/>\n        <cache name=\"screen.form.db.node\" value-type=\"MNode\"/>\n\n        <cache name=\"resource.xml-actions.location\" value-type=\"org.moqui.impl.actions.XmlAction\"/>\n        <cache name=\"resource.groovy.location\" value-type=\"java.lang.Class\"/>\n        <cache name=\"resource.javascript.location\" value-type=\"java.lang.Class\"/>\n\n        <!-- These caches must be local caches (use additional methods on MCache not in javax.cache.Cache) -->\n        <cache name=\"resource.ftl.location\" value-type=\"freemarker.template.Template\" type=\"local\" max-elements=\"10000\"/>\n        <cache name=\"resource.gstring.location\" value-type=\"groovy.text.Template\" type=\"local\" max-elements=\"10000\"/>\n        <cache name=\"resource.wiki.location\" value-type=\"String\" type=\"local\" max-elements=\"10000\" expire-time-live=\"3600\"/>\n        <cache name=\"resource.markdown.location\" value-type=\"String\" type=\"local\" max-elements=\"10000\" expire-time-live=\"3600\"/>\n        <cache name=\"resource.text.location\" value-type=\"String\" type=\"local\" max-elements=\"10000\" expire-time-live=\"3600\"/>\n\n        <cache name=\"resource.reference.location\" value-type=\"org.moqui.resource.ResourceReference\"/>\n\n        <cache name=\"l10n.message\" expire-time-live=\"3600\" max-elements=\"50000\" value-type=\"String\"/>\n\n        <!-- this is a count of all artifact hits, expire once idle for over 15 minutes -->\n        <cache name=\"artifact.tarpit.hits\" expire-time-idle=\"900\" max-elements=\"10000\" value-type=\"ArrayList\"/>\n    </cache-list>\n    <server-stats bin-length-seconds=\"900\" visit-enabled=\"true\" visit-ip-info-on-login=\"true\" visitor-enabled=\"true\">\n        <!-- these are meant to be good production settings -->\n        <artifact-stats type=\"AT_XML_SCREEN\" persist-bin=\"true\" persist-hit=\"true\"/>\n        <artifact-stats type=\"AT_XML_SCREEN_CONTENT\" persist-bin=\"true\" persist-hit=\"false\"/>\n        <artifact-stats type=\"AT_XML_SCREEN_TRANS\" persist-bin=\"true\" persist-hit=\"true\"/>\n        <artifact-stats type=\"AT_SERVICE\" persist-bin=\"true\" persist-hit=\"false\"/>\n        <artifact-stats type=\"AT_ENTITY\" persist-bin=\"false\"/>\n    </server-stats>\n\n    <webapp-list>\n        <!-- The webapp.@name is looked up based on the value of the 'moqui-name' context-param in the web.xml file -->\n        <webapp name=\"webroot\" http-port=\"${webapp_http_port}\" http-host=\"${webapp_http_host}\"\n                https-port=\"${webapp_https_port}\" https-host=\"${webapp_http_host}\" https-enabled=\"${webapp_https_enabled}\"\n                handle-cors=\"${webapp_handle_cors}\" allow-origins=\"${webapp_allow_origins}\"\n                require-session-token=\"${webapp_require_session_token}\" upload-executable-allow=\"${webapp_upload_executable_allow}\"\n                websocket-timeout=\"600000\" client-ip-header=\"${webapp_client_ip_header}\">\n            <!-- root and error screens for OOTB runtime directory (moqui/moqui-runtime repository) -->\n            <root-screen host=\".*\" location=\"component://webroot/screen/webroot.xml\"/>\n            <error-screen error=\"unauthorized\" screen-path=\"error/Unauthorized\"/>\n            <error-screen error=\"forbidden\" screen-path=\"error/Forbidden\"/>\n            <error-screen error=\"not-found\" screen-path=\"error/NotFound\"/>\n            <error-screen error=\"too-many\" screen-path=\"error/TooMany\"/>\n            <error-screen error=\"internal-error\" screen-path=\"error/InternalError\"/>\n            <!-- lifecycle actions examples, none by default:\n            <first-hit-in-visit><actions><log level=\"info\" message=\"========================== first-hit-in-visit actions\"/></actions></first-hit-in-visit>\n            <before-request><actions><log level=\"info\" message=\"========================== before-request actions\"/></actions></before-request>\n            <after-request><actions><log level=\"info\" message=\"========================== after-request actions\"/></actions></after-request>\n            <after-login><actions><log level=\"info\" message=\"========================== after-login actions\"/></actions></after-login>\n            <before-logout><actions><log level=\"info\" message=\"========================== before-logout actions\"/></actions></before-logout>\n            <after-startup><actions><log level=\"info\" message=\"========================== after-startup actions\"/></actions></after-login>\n            <before-shutdown><actions><log level=\"info\" message=\"========================== before-shutdown actions\"/></actions></before-logout>\n            -->\n\n            <!-- ElasticSearch Request Log Filter (log requests directly to ElasticSearch) -->\n            <filter name=\"ElasticRequestLogFilter\" class=\"org.moqui.impl.webapp.ElasticRequestLogFilter\" async-supported=\"true\">\n                <url-pattern>/*</url-pattern>\n                <dispatcher>REQUEST</dispatcher>\n            </filter>\n            <!-- ElasticSearch and Kibana auth filters, see below for proxy servlets on the same url-pattern -->\n            <filter name=\"ElasticAuthFilter\" class=\"org.moqui.impl.webapp.MoquiAuthFilter\" async-supported=\"true\">\n                <init-param name=\"permission\" value=\"ElasticRemote\"/>\n                <url-pattern>/elastic/*</url-pattern>\n            </filter>\n            <filter name=\"KibanaAuthFilter\" class=\"org.moqui.impl.webapp.MoquiAuthFilter\" async-supported=\"true\">\n                <init-param name=\"permission\" value=\"KibanaRemote\"/>\n                <url-pattern>/kibana/*</url-pattern>\n            </filter>\n\n            <!-- Moqui Session Listener (necessary to handle expired sessions, etc) -->\n            <listener class=\"org.moqui.impl.webapp.MoquiSessionListener\"/>\n\n            <servlet name=\"MoquiServlet\" class=\"org.moqui.impl.webapp.MoquiServlet\" load-on-startup=\"1\">\n                <url-pattern>/*</url-pattern>\n            </servlet>\n            <servlet name=\"MoquiFopServlet\" class=\"org.moqui.impl.webapp.MoquiFopServlet\" load-on-startup=\"1\">\n                <url-pattern>/fop/*</url-pattern>\n            </servlet>\n\n            <!-- ElasticSearch and Kibana proxy servlets, see above for auth filters on the same url-pattern -->\n            <servlet name=\"ElasticSearchProxy\" class=\"org.eclipse.jetty.ee11.proxy.ProxyServlet$Transparent\" load-on-startup=\"1\" async-supported=\"true\">\n                <!-- this proxies to the embedded ElasticSearch instance -->\n                <init-param name=\"proxyTo\" value=\"${elasticsearch_url ?: 'http://127.0.0.1:9200'}\"/>\n                <init-param name=\"prefix\" value=\"/elastic\"/>\n                <url-pattern>/elastic/*</url-pattern>\n            </servlet>\n            <servlet name=\"KibanaProxy\" class=\"org.eclipse.jetty.ee11.proxy.ProxyServlet$Transparent\" load-on-startup=\"1\" async-supported=\"true\">\n                <!-- this proxies to a Kibana instance run locally, in another container, etc -->\n                <!-- in kibana.yml set server.basePath: \"/kibana\" -->\n                <init-param name=\"proxyTo\" value=\"http://${kibana_host}:${kibana_port}\"/>\n                <init-param name=\"prefix\" value=\"/kibana\"/>\n                <url-pattern>/kibana/*</url-pattern>\n            </servlet>\n\n            <!-- timeout session in 60 minutes without a request -->\n            <session-config timeout=\"60\"/>\n\n            <!-- Notification Message Endpoint -->\n            <endpoint path=\"/notws\" class=\"org.moqui.impl.webapp.NotificationEndpoint\" timeout=\"3600000\" enabled=\"true\"/>\n            <endpoint path=\"/groovysh\" class=\"org.moqui.impl.webapp.GroovyShellEndpoint\" timeout=\"900000\" enabled=\"true\"/>\n\n            <!-- Default response headers, for override keyed on type+name -->\n            <response-header type=\"web-resource-inline\" name=\"Cache-Control\" value=\"max-age=86400, must-revalidate, public\"/>\n            <response-header type=\"screen-resource-binary\" name=\"Cache-Control\" value=\"max-age=86400, must-revalidate, public\"/>\n            <response-header type=\"screen-resource-text\" name=\"Cache-Control\" value=\"max-age=86400, must-revalidate, public\"/>\n            <response-header type=\"screen-resource-template\" name=\"Cache-Control\" value=\"no-cache, no-store, must-revalidate, private\"/>\n            <response-header type=\"screen-server-static\" name=\"Cache-Control\" value=\"max-age=86400, must-revalidate, public\"/>\n            <response-header type=\"screen-secure\" name=\"Strict-Transport-Security\" value=\"max-age=31536000\"/>\n            <response-header type=\"screen-render\" name=\"Cache-Control\" value=\"no-cache, no-store, must-revalidate, private\"/>\n            <!--\n            Content-Security-Policy by default to not allow use in iframe or allow form actions on different host\n            see https://content-security-policy.com/\n            consider \"child-src 'self';\", leaving out as default because 3rd party hosts needed for payment processing hosted forms, etc\n            consider \"connect-src 'self';\" Applies to XMLHttpRequest (AJAX), WebSocket or EventSource\n            CSP more secure, should work for back office (internal) but may not for ecommerce or custom apps depending on external resources used and their domain names:\n                see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src\n                value=\"frame-ancestors 'none'; form-action 'self'; default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com;\"\n            -->\n            <response-header type=\"screen-render\" name=\"Content-Security-Policy\" value=\"frame-ancestors 'none'; form-action 'self';\"/>\n            <response-header type=\"screen-render\" name=\"X-Frame-Options\" add=\"true\" value=\"sameorigin\"/>\n            <!-- X-XSS-Protection: this is largely useless with the Content-Security-Policy but here because some web security scanners look for it -->\n            <response-header type=\"screen-render\" name=\"X-XSS-Protection\" value=\"1; mode=block\"/>\n            <!-- X-Content-Type-Options: this could cause problems as much as help but here because some web security scanners look for it -->\n            <response-header type=\"screen-render\" name=\"X-Content-Type-Options\" add=\"true\" value=\"nosniff\"/>\n            <!-- CORS actual and preflight headers -->\n            <!-- NOTE: Access-Control-Allow-Origin is set dynamically by MoquiServlet if Origin request header value is in the value of the webapp.@allow-origins attribute -->\n            <!-- Access-Control-Allow-Credentials - always true so cookies, authc, etc always allowed -->\n            <response-header type=\"cors-actual\" name=\"Vary\" value=\"Origin\"/>\n            <response-header type=\"cors-actual\" name=\"Access-Control-Allow-Credentials\" value=\"true\"/>\n            <response-header type=\"cors-actual\" name=\"Access-Control-Expose-Headers\" add=\"true\"\n                    value=\"Access-Control-Allow-Origin,Access-Control-Allow-Credentials,X-CSRF-Token,moquiSessionToken\"/>\n            <response-header type=\"cors-preflight\" name=\"Vary\" value=\"Origin\"/>\n            <response-header type=\"cors-preflight\" name=\"Access-Control-Allow-Credentials\" value=\"true\"/>\n            <!-- doesn't seem these are needed, allowed by default: ,Authorization,Referrer,User-Agent -->\n            <response-header type=\"cors-preflight\" name=\"Access-Control-Allow-Headers\" add=\"true\"\n                    value=\"Accept,Accept-Encoding,Content-Type,Content-Encoding,Origin,X-Requested-With,Access-Control-Request-Method,Access-Control-Request-Headers,api_key,login_key,X-HTTP-Method-Override,moquiSessionToken,SessionToken,X-CSRF-Token,Authorization\"/>\n            <!-- TODO: would be nice to handle this differently, ie dynamically based on preflight request path -->\n            <response-header type=\"cors-preflight\" name=\"Access-Control-Allow-Methods\" add=\"true\" value=\"GET,PUT,POST,PATCH,DELETE,OPTIONS\"/>\n            <response-header type=\"cors-preflight\" name=\"Access-Control-Max-Age\" value=\"3600\"/>\n        </webapp>\n    </webapp-list>\n\n    <artifact-execution-facade>\n        <artifact-execution type=\"AT_XML_SCREEN\" authz-enabled=\"true\" tarpit-enabled=\"true\"/>\n        <artifact-execution type=\"AT_XML_SCREEN_TRANS\" authz-enabled=\"true\" tarpit-enabled=\"true\"/>\n        <artifact-execution type=\"AT_SERVICE\" authz-enabled=\"true\" tarpit-enabled=\"true\"/>\n        <!-- NOTE: entity tarpit disabled by default for performance and tracking overhead reasons -->\n        <artifact-execution type=\"AT_ENTITY\" authz-enabled=\"true\" tarpit-enabled=\"false\"/>\n    </artifact-execution-facade>\n    <user-facade>\n        <password encrypt-hash-type=\"SHA-256\" min-length=\"8\" min-digits=\"1\" min-others=\"1\"\n                  history-limit=\"5\" change-weeks=\"104\" email-require-change=\"false\" email-expire-hours=\"48\"/>\n        <login-key encrypt-hash-type=\"SHA-256\" expire-hours=\"144\"/><!-- default expire 6 days, 144 hours -->\n        <login max-failures=\"3\" disable-minutes=\"5\" history-store=\"true\" history-incorrect-password=\"false\"/>\n    </user-facade>\n\n    <transaction-facade use-transaction-cache=\"true\" use-connection-stash=\"true\" use-lock-track=\"${entity_lock_track}\" use-statement-timeout=\"${entity_statement_timeout}\">\n        <!-- Use this for the internal transaction manager (not through JNDI) -->\n        <transaction-internal class=\"org.moqui.impl.context.TransactionInternalBitronix\"/>\n\n        <!-- If this is not present the default JNDI server will be used -->\n        <!-- <server-jndi context-provider-url=\"rmi://127.0.0.1:1099\"\n                     initial-context-factory=\"com.sun.jndi.rmi.registry.RegistryContextFactory\"\n                     url-pkg-prefixes=\"java.naming.rmi.security.manager\"\n                     security-principal=\"\" security-credentials=\"\"/> -->\n\n        <!-- Use this for getting JTA objects from JNDI -->\n        <!-- <transaction-jndi transaction-manager-jndi-name=\"java:comp/UserTransaction\"\n                             user-transaction-jndi-name=\"java:comp/UserTransaction\"/> -->\n        <!-- UserTransaction JNDI name for most servers: java:comp/UserTransaction (Resin, Orion, OC4J, etc);\n            JBoss (separate objects): \"java:comp/UserTransaction\" and \"java:comp/TransactionManager\" -->\n    </transaction-facade>\n\n    <resource-facade xml-actions-template-location=\"classpath://template/XmlActions.groovy.ftl\">\n        <!-- resource reference class needs to implement the org.moqui.resource.ResourceReference interface -->\n        <resource-reference scheme=\"http\" class=\"org.moqui.resource.UrlResourceReference\"/>\n        <resource-reference scheme=\"https\" class=\"org.moqui.resource.UrlResourceReference\"/>\n        <resource-reference scheme=\"file\" class=\"org.moqui.resource.UrlResourceReference\"/>\n        <resource-reference scheme=\"ftp\" class=\"org.moqui.resource.UrlResourceReference\"/>\n        <resource-reference scheme=\"jar\" class=\"org.moqui.resource.UrlResourceReference\"/>\n        <resource-reference scheme=\"bundleresource\" class=\"org.moqui.resource.UrlResourceReference\"/>\n        <resource-reference scheme=\"wsjar\" class=\"org.moqui.resource.UrlResourceReference\"/>\n        <resource-reference scheme=\"runtime\" class=\"org.moqui.resource.UrlResourceReference\"/>\n\n        <resource-reference scheme=\"classpath\" class=\"org.moqui.resource.ClasspathResourceReference\"/>\n        <resource-reference scheme=\"component\" class=\"org.moqui.impl.context.reference.ComponentResourceReference\"/>\n        <resource-reference scheme=\"content\" class=\"org.moqui.impl.context.reference.ContentResourceReference\"/>\n        <resource-reference scheme=\"dbresource\" class=\"org.moqui.impl.context.reference.DbResourceReference\"/>\n\n        <!-- renderer class needs to implement the org.moqui.context.TemplateRenderer interface -->\n        <template-renderer extension=\"ftl\" class=\"org.moqui.impl.context.renderer.FtlTemplateRenderer\"/>\n        <template-renderer extension=\"html.ftl\" class=\"org.moqui.impl.context.renderer.FtlTemplateRenderer\"/>\n        <template-renderer extension=\"gstring\" class=\"org.moqui.impl.context.renderer.GStringTemplateRenderer\"/>\n        <template-renderer extension=\"html.gstring\" class=\"org.moqui.impl.context.renderer.GStringTemplateRenderer\"/>\n\n        <template-renderer extension=\"md\" class=\"org.moqui.impl.context.renderer.MarkdownTemplateRenderer\"/>\n        <template-renderer extension=\"markdown\" class=\"org.moqui.impl.context.renderer.MarkdownTemplateRenderer\"/>\n        <template-renderer extension=\"md.ftl\" class=\"org.moqui.impl.context.renderer.FtlMarkdownTemplateRenderer\"/>\n        <template-renderer extension=\"markdown.ftl\" class=\"org.moqui.impl.context.renderer.FtlMarkdownTemplateRenderer\"/>\n\n        <!-- a renderer for .html isn't necessary because the default is to write the text as-is, however this is useful\n            so that the extension becomes a default extension and doesn't have to be in the URL -->\n        <template-renderer extension=\"html\" class=\"org.moqui.impl.context.renderer.NoTemplateRenderer\"/>\n\n        <!-- this is just an example, always use the ScriptRunner interface instead of javax.script because javax.script\n            does not support compiling class scripts and running methods within the class, but Groovy does\n        <script-runner extension=\".groovy\" engine=\"groovy\"/>\n        -->\n        <script-runner extension=\".groovy\" class=\"org.moqui.impl.context.runner.GroovyScriptRunner\"/>\n        <script-runner extension=\".xml\" class=\"org.moqui.impl.context.runner.XmlActionsScriptRunner\"/>\n\n        <!-- the javascript engine (Rhino) is built into Java starting with version 6, so we can support it without extra libs -->\n        <script-runner extension=\".js\" engine=\"javascript\"/>\n    </resource-facade>\n\n    <screen-facade boundary-comments=\"false\" default-autocomplete-rows=\"20\" default-paginate-rows=\"20\">\n        <screen-text-output type=\"csv\" mime-type=\"text/csv\" always-standalone=\"true\"\n                macro-template-location=\"template/screen-macro/DefaultScreenMacros.csv.ftl\"/>\n        <screen-text-output type=\"html\" mime-type=\"text/html\"\n                macro-template-location=\"template/screen-macro/DefaultScreenMacros.html.ftl\"/>\n        <screen-text-output type=\"text\" mime-type=\"text/plain\"\n                macro-template-location=\"template/screen-macro/DefaultScreenMacros.text.ftl\"/>\n        <screen-text-output type=\"xml\" mime-type=\"text/xml\"\n                macro-template-location=\"template/screen-macro/DefaultScreenMacros.xml.ftl\"/>\n        <screen-text-output type=\"xsl-fo\" mime-type=\"text/xml\" always-standalone=\"true\"\n                macro-template-location=\"template/screen-macro/DefaultScreenMacros.xsl-fo.ftl\"/>\n\n        <!-- vuet is a custom extension for Vue JS component templates (extended HTML) -->\n        <screen-text-output type=\"vuet\" mime-type=\"text/html\" always-standalone=\"true\"\n                macro-template-location=\"template/screen-macro/DefaultScreenMacros.vuet.ftl\"/>\n        <screen-text-output type=\"js\" mime-type=\"application/javascript\" always-standalone=\"true\"\n                macro-template-location=\"template/screen-macro/DefaultScreenMacros.plain.ftl\"/>\n        <screen-text-output type=\"vue\" mime-type=\"text/html\" always-standalone=\"true\"\n                macro-template-location=\"template/screen-macro/DefaultScreenMacros.plain.ftl\"/>\n\n        <!-- qvt is a custom extension for Quasar & Vue JS component templates (extended HTML) -->\n        <screen-text-output type=\"qvt\" mime-type=\"text/html\" always-standalone=\"true\"\n                macro-template-location=\"template/screen-macro/DefaultScreenMacros.qvt.ftl\"/>\n        <screen-text-output type=\"qjs\" mime-type=\"application/javascript\" always-standalone=\"true\"\n                macro-template-location=\"template/screen-macro/DefaultScreenMacros.plain.ftl\"/>\n        <screen-text-output type=\"qvue\" mime-type=\"text/html\" always-standalone=\"true\"\n                macro-template-location=\"template/screen-macro/DefaultScreenMacros.plain.ftl\"/>\n    </screen-facade>\n\n    <service-facade distributed-factory=\"\" scheduled-job-check-time=\"${scheduled_job_check_time}\"\n            job-queue-max=\"0\" job-pool-core=\"2\" job-pool-max=\"8\" job-pool-alive=\"120\">\n        <service-location name=\"main-json\" location=\"http://localhost:8080/rpc/json\"/>\n\n        <!-- runner-class needs to implement the org.moqui.impl.service.ServiceRunner interface -->\n        <service-type name=\"inline\" runner-class=\"org.moqui.impl.service.runner.InlineServiceRunner\"/>\n        <service-type name=\"entity-auto\" runner-class=\"org.moqui.impl.service.runner.EntityAutoServiceRunner\"/>\n        <service-type name=\"script\" runner-class=\"org.moqui.impl.service.runner.ScriptServiceRunner\"/>\n        <service-type name=\"java\" runner-class=\"org.moqui.impl.service.runner.JavaServiceRunner\"/>\n        <service-type name=\"remote-json-rpc\" runner-class=\"org.moqui.impl.service.runner.RemoteJsonRpcServiceRunner\"/>\n        <service-type name=\"remote-rest\" runner-class=\"org.moqui.impl.service.runner.RemoteRestServiceRunner\"/>\n\n        <!-- These are not needed for running classpath services, are for service reference (known services) -->\n        <service-file location=\"classpath://service/org/moqui/EmailServices.xml\"/>\n        <service-file location=\"classpath://service/org/moqui/EntityServices.xml\"/>\n        <service-file location=\"classpath://service/org/moqui/SmsServices.xml\"/>\n        <service-file location=\"classpath://service/org/moqui/impl/BasicServices.xml\"/>\n        <service-file location=\"classpath://service/org/moqui/impl/ElFinderServices.xml\"/>\n        <service-file location=\"classpath://service/org/moqui/impl/EmailServices.xml\"/>\n        <service-file location=\"classpath://service/org/moqui/impl/EntityServices.xml\"/>\n        <service-file location=\"classpath://service/org/moqui/impl/EntitySyncServices.xml\"/>\n        <service-file location=\"classpath://service/org/moqui/impl/GoogleServices.xml\"/>\n        <service-file location=\"classpath://service/org/moqui/impl/InstanceServices.xml\"/>\n        <service-file location=\"classpath://service/org/moqui/impl/PrintServices.xml\"/>\n        <service-file location=\"classpath://service/org/moqui/impl/ScreenServices.xml\"/>\n        <service-file location=\"classpath://service/org/moqui/impl/ServerServices.xml\"/>\n        <service-file location=\"classpath://service/org/moqui/impl/ServiceServices.xml\"/>\n        <service-file location=\"classpath://service/org/moqui/impl/SystemMessageServices.xml\"/>\n        <service-file location=\"classpath://service/org/moqui/impl/UserServices.xml\"/>\n        <service-file location=\"classpath://service/org/moqui/impl/WikiServices.xml\"/>\n        <service-file location=\"classpath://service/org/moqui/search/SearchServices.xml\"/>\n    </service-facade>\n\n    <elastic-facade>\n        <!-- NOTE: elasticsearch_host1 used from moqui-elasticsearch with elasticsearch_mode=rest for compatibility until moqui-elasticsearch deprecated -->\n        <cluster name=\"default\" url=\"${elasticsearch_host1 ?: elasticsearch_url}\" user=\"${elasticsearch_user}\" password=\"${elasticsearch_password}\"\n                index-prefix=\"${elasticsearch_index_prefix}\" pool-max=\"64\" queue-size=\"1024\"/>\n    </elastic-facade>\n\n    <entity-facade default-group-name=\"transactional\" entity-eca-enabled=\"true\" sequenced-id-prefix=\"\"\n            distributed-cache-invalidate=\"false\" dci-topic-factory=\"\" query-stats=\"false\"\n            database-locale=\"${default_locale}\" database-time-zone=\"${database_time_zone ?: default_time_zone}\"\n            crypt-salt=\"20201202\" crypt-iter=\"10\" crypt-algo=\"PBEWithHmacSHA256AndAES_128\" crypt-pass=\"${entity_ds_crypt_pass}\">\n\n        <!-- alternate decrypt using original algo (PBEWithMD5AndDES), etc for decrypting old values, all new values will encrypt using the 4 attributes on entity-facade element -->\n        <decrypt-alt crypt-salt=\"SkcorIuqom\" crypt-iter=\"10\" crypt-algo=\"PBEWithMD5AndDES\" crypt-pass=\"${entity_ds_crypt_pass}\"/>\n        <!-- alternate decrypts for crypt pass migration with both DES and AES -->\n        <decrypt-alt crypt-salt=\"SkcorIuqom\" crypt-iter=\"10\" crypt-algo=\"PBEWithMD5AndDES\" crypt-pass=\"${entity_ds_crypt_pass_old}\"/>\n        <decrypt-alt crypt-salt=\"20201202\" crypt-iter=\"10\" crypt-algo=\"PBEWithHmacSHA256AndAES_128\" crypt-pass=\"${entity_ds_crypt_pass_old}\"/>\n\n        <!-- The entity-facade.@default-group-name=transactional, so if other entity groups do not have a data source\n            the transactional group's datasource will be used. -->\n\n        <!-- If this is not present the default JNDI server will be used:\n        <server-jndi context-provider-url=\"rmi://127.0.0.1:1099\"\n                 initial-context-factory=\"com.sun.jndi.rmi.registry.RegistryContextFactory\"\n                 url-pkg-prefixes=\"java.naming.rmi.security.manager\"\n                 security-principal=\"\" security-credentials=\"\"/>\n         -->\n\n        <!--\n            The configurations below will use the XADataSource directly to connect, which is required for proper\n            transaction handling with multiple data sources.\n\n            To use the non-XA variety just specify the normal JDBC parameters (like jdbc-uri, etc) and leave out the\n            xa-properties element. If the xa-properties element is present the normal JDBC parameters will be ignored.\n\n            To use a DataSource from JNDI use something the jndi-jdbc element under the datasource element:\n                <jndi-jdbc jndi-name=\"java:/MoquiDataSource\"/>\n\n            NOTE: for production usage there is one connection pool per datasource so use as few datasources as possible.\n\n            The main datasource should have a max size large enough for all servlet container threads plus a few for\n            the worker pool (see tools.@worker-pool-max). Note that Bitronix has a 30 second timeout waiting for a\n            connection from the pool, and currently connections are NOT shared between transactions (only released when\n            a tx is closed).\n\n            Good numbers for a decent sized server are 100 servlet container (Jetty) threads, 16 worker threads, plus a\n            few extra so 120 connections in the main pool.\n        -->\n        <datasource group-name=\"transactional\" database-conf-name=\"${entity_ds_db_conf}\" schema-name=\"${entity_ds_schema}\"\n                runtime-add-missing=\"${entity_add_missing_runtime}\" startup-add-missing=\"${entity_add_missing_startup}\">\n            <!-- by default no inline-jdbc or jndi-jdbc elements, use default from database conf -->\n        </datasource>\n\n        <!-- H2 clone for testing, use same URL as just for testing\n        <datasource group-name=\"transactional#clone1\" database-conf-name=\"h2\" schema-name=\"\"\n                runtime-add-missing=\"false\" startup-add-missing=\"false\">\n            <inline-jdbc><xa-properties url=\"${entity_ds_url}\" user=\"${entity_ds_user}\" password=\"${entity_ds_password}\"/></inline-jdbc>\n        </datasource>\n        -->\n\n        <!--\n            MySQL clone server example configuration, for reporting/etc queries to run against a read-only clone.\n            NOTE: this is only for MySQL, adjust or override for other databases as the xa-properties attributes differ.\n            This will also work for Postgres but you will get warnings about errors setting invalid properties on the XADataSource.\n        -->\n        <datasource group-name=\"transactional#clone1\" database-conf-name=\"${entity_ds_c1_db_conf}\" schema-name=\"${entity_ds_c1_schema}\"\n                runtime-add-missing=\"false\" startup-add-missing=\"false\" disabled=\"${entity_ds_c1_disabled}\">\n            <!-- NOTE: isolation-level=\"None\" not supported (by Bitronix?), use most lenient instead: ReadUncommitted -->\n            <inline-jdbc pool-maxsize=\"40\" isolation-level=\"ReadUncommitted\">\n                <xa-properties serverName=\"${entity_ds_c1_host}\" port=\"${entity_ds_c1_port?:'3306'}\"\n                        databaseName=\"${entity_ds_c1_database}\" user=\"${entity_ds_c1_user}\" password=\"${entity_ds_c1_password}\"\n                        pinGlobalTxToPhysicalConnection=\"true\" autoReconnectForPools=\"true\" useUnicode=\"true\" encoding=\"UTF-8\"/>\n            </inline-jdbc>\n        </datasource>\n\n        <!-- The logging group and datasource uses the elastic-facade.cluster configuration to talk to Elastic/OpenSearch -->\n        <datasource group-name=\"logging\" runtime-add-missing=\"true\" startup-add-missing=\"false\"\n                object-factory=\"org.moqui.impl.entity.elastic.ElasticDatasourceFactory\">\n            <inline-other index-prefix=\"logging-\" cluster-name=\"default\"/>\n        </datasource>\n\n        <!-- The nontransactional group is in the transactional db by default. To use OrientDB use the datasource below.\n            NOTE: startup-add-missing=true is required because OrientDB doesn't allow class create in a TX\n            NOTE: adding more entities to the nontransactional group is a work in progress, this will not currently run,\n                but you can use a similar configuration for your own entity groups\n            NOTE: to test on an entity that is not used in general in view entities (can't join across DBs) try adding\n                group=\"nontransactional\" to ArtifactHit in ServerEntities.xml\n        -->\n        <!--\n        <datasource group-name=\"nontransactional\" startup-add-missing=\"true\"\n                object-factory=\"org.moqui.impl.entity.orientdb.OrientDatasourceFactory\">\n            <inline-other uri=\"plocal:${ORIENTDB_HOME}/databases/MoquiNoSql\" username=\"admin\" password=\"admin\"/>\n        </datasource>\n        -->\n\n        <!-- Refer to these explicitly instead of by directory name convention as is done in components.\n             There is no reliable way to search within directories on the classpath (if in a file or jar is fine, but\n             not in a war/ear file or from special ClassLoaders) -->\n        <!-- in framework/entity -->\n        <load-entity location=\"classpath://entity/BasicEntities.xml\"/>\n        <load-entity location=\"classpath://entity/EntityEntities.xml\"/>\n        <load-entity location=\"classpath://entity/OlapEntities.xml\"/>\n        <load-entity location=\"classpath://entity/ResourceEntities.xml\"/>\n        <load-entity location=\"classpath://entity/ScreenEntities.xml\"/>\n        <load-entity location=\"classpath://entity/Screen.eecas.xml\"/>\n        <load-entity location=\"classpath://entity/SecurityEntities.xml\"/>\n        <load-entity location=\"classpath://entity/ServerEntities.xml\"/>\n        <load-entity location=\"classpath://entity/ServiceEntities.xml\"/>\n        <load-entity location=\"classpath://entity/TestEntities.xml\"/>\n        <!-- in framework/data -->\n        <load-data location=\"classpath://data/CommonL10nData.xml\"/>\n        <load-data location=\"classpath://data/CurrencyData.xml\"/>\n        <load-data location=\"classpath://data/EntityTypeData.xml\"/>\n        <load-data location=\"classpath://data/GeoCountryData.xml\"/>\n        <load-data location=\"classpath://data/SecurityTypeData.xml\"/>\n        <load-data location=\"classpath://data/UnitData.xml\"/>\n        <load-data location=\"classpath://data/MoquiSetupData.xml\"/>\n    </entity-facade>\n    <database-list>\n        <dictionary-type type=\"id\" java-type=\"java.lang.String\" default-sql-type=\"VARCHAR(40)\"/>\n        <dictionary-type type=\"id-long\" java-type=\"java.lang.String\" default-sql-type=\"VARCHAR(255)\"/>\n\n        <dictionary-type type=\"date\" java-type=\"java.sql.Date\" default-sql-type=\"DATE\"/>\n        <dictionary-type type=\"time\" java-type=\"java.sql.Time\" default-sql-type=\"TIME\"/>\n        <dictionary-type type=\"date-time\" java-type=\"java.sql.Timestamp\" default-sql-type=\"TIMESTAMP\"/>\n\n        <dictionary-type type=\"number-integer\" java-type=\"java.lang.Long\" default-sql-type=\"NUMERIC(20,0)\"/>\n        <dictionary-type type=\"number-decimal\" java-type=\"java.math.BigDecimal\" default-sql-type=\"NUMERIC(26,6)\"/>\n        <dictionary-type type=\"number-float\" java-type=\"java.lang.Double\" default-sql-type=\"DOUBLE\"/>\n\n        <dictionary-type type=\"currency-amount\" java-type=\"java.math.BigDecimal\" default-sql-type=\"NUMERIC(24,4)\"/>\n        <dictionary-type type=\"currency-precise\" java-type=\"java.math.BigDecimal\" default-sql-type=\"NUMERIC(25,5)\"/>\n\n        <dictionary-type type=\"text-indicator\" java-type=\"java.lang.String\" default-sql-type=\"CHAR(1)\"/>\n        <dictionary-type type=\"text-short\" java-type=\"java.lang.String\" default-sql-type=\"VARCHAR(63)\"/>\n        <dictionary-type type=\"text-medium\" java-type=\"java.lang.String\" default-sql-type=\"VARCHAR(255)\"/>\n        <dictionary-type type=\"text-intermediate\" java-type=\"java.lang.String\" default-sql-type=\"VARCHAR(1023)\"/>\n        <!-- text-long was 32000, but changed to 4095 because MySQL max size is 21K or so for a VARCHAR, and 64K for a\n            row (not including text-very-long), and it seems that the limit is bytes, not characters, so 4K characters\n            is 12K bytes... just like the old annoying column size which is now in characters, but the row size still\n            in bytes -->\n        <dictionary-type type=\"text-long\" java-type=\"java.lang.String\" default-sql-type=\"VARCHAR(4095)\"/>\n        <dictionary-type type=\"text-very-long\" java-type=\"java.lang.String\" default-sql-type=\"CLOB\"/>\n\n        <dictionary-type type=\"binary-very-long\" java-type=\"java.sql.Blob\" default-sql-type=\"BLOB\"/>\n\n        <!--\n        * DB2:\n        * to support LIMIT and OFFSET: \"db2set DB2_COMPATIBILITY_VECTOR=MYS\" (and if already running: db2stop then db2start)\n        * default page size of 4k not adequate, use something like: \"create database moqui pagesize 32 k\"\n        * the database name in the example is 'moqui', note that in DB2 database names are limits to 8 bytes\n\n        <datasource group-name=\"transactional\" database-conf-name=\"db2\" schema-name=\"DB2INST1\" startup-add-missing=\"true\" runtime-add-missing=\"false\">\n            <inline-jdbc><xa-properties user=\"db2inst1\" password=\"db2inst1\" serverName=\"localhost\" portNumber=\"50000\"\n                    driverType=\"4\" databaseName=\"moqui\"/></inline-jdbc>\n        </datasource>\n        <datasource group-name=\"transactional\" database-conf-name=\"db2\" schema-name=\"\" startup-add-missing=\"true\" runtime-add-missing=\"false\">\n            <inline-jdbc jdbc-uri=\"jdbc:db2://localhost:50000/moqui\" jdbc-username=\"moqui\" jdbc-password=\"moqui\"/>\n        </datasource>\n        -->\n        <database name=\"db2\" join-style=\"ansi\" offset-style=\"limit\" from-lateral-style=\"lateral\" never-nulls=\"true\"\n                default-isolation-level=\"ReadCommitted\" for-update=\"FOR UPDATE WITH RS\"\n                use-schema-for-all=\"true\" use-indexes-unique=\"false\" use-pk-constraint-names=\"false\" fk-style=\"name_fk\"\n                default-test-query=\"SELECT 1 FROM SYSIBM.SYSDUMMY1\"\n                default-jdbc-driver=\"com.ibm.db2.jcc.DB2Driver\"\n                default-xa-ds-class=\"com.ibm.db2.jcc.DB2XADataSource\"\n                default-startup-add-missing=\"true\" default-runtime-add-missing=\"false\">\n            <database-type type=\"number-integer\" sql-type=\"DECIMAL(20,0)\"/>\n            <database-type type=\"number-decimal\" sql-type=\"DECIMAL(26,6)\"/>\n            <database-type type=\"number-float\" sql-type=\"DECIMAL(30,12)\"/>\n            <database-type type=\"currency-amount\" sql-type=\"DECIMAL(24,4)\"/>\n            <database-type type=\"currency-precise\" sql-type=\"DECIMAL(25,5)\"/>\n\n            <inline-jdbc><xa-properties driverType=\"4\" serverName=\"${entity_ds_host}\" portNumber=\"${entity_ds_port?:'50000'}\"\n                    databaseName=\"${entity_ds_database}\" user=\"${entity_ds_user}\" password=\"${entity_ds_password}\"/></inline-jdbc>\n        </database>\n        <database name=\"db2i\" lb-name=\"db2\" join-style=\"ansi\" offset-style=\"limit\" from-lateral-style=\"lateral\" never-nulls=\"true\"\n                default-isolation-level=\"ReadCommitted\"  for-update=\"FOR UPDATE WITH RS\"\n                use-schema-for-all=\"true\" use-indexes-unique-where-not-null=\"true\"\n                default-test-query=\"SELECT 1 FROM SYSIBM.SYSDUMMY1\"\n                default-jdbc-driver=\"com.ibm.as400.access.AS400JDBCDriver\"\n                default-xa-ds-class=\"com.ibm.as400.access.AS400JDBCXADataSource\"\n                default-startup-add-missing=\"true\" default-runtime-add-missing=\"false\">\n            <database-type type=\"number-integer\" sql-type=\"DECIMAL(20,0)\"/>\n            <database-type type=\"number-decimal\" sql-type=\"DECIMAL(26,6)\"/>\n            <database-type type=\"number-float\" sql-type=\"DECIMAL(30,12)\"/>\n            <database-type type=\"currency-amount\" sql-type=\"DECIMAL(24,4)\"/>\n            <database-type type=\"currency-precise\" sql-type=\"DECIMAL(25,5)\"/>\n\n            <inline-jdbc><xa-properties serverName=\"${entity_ds_host}\" databaseName=\"${entity_ds_database}\" dateFormat=\"iso\"\n                    user=\"${entity_ds_user}\" password=\"${entity_ds_password}\" cursorHold=\"false\" threadUsed=\"false\"/></inline-jdbc>\n        </database>\n        <!--\n        * Derby:\n        <datasource group-name=\"transactional\" database-conf-name=\"derby\" schema-name=\"MOQUI\">\n            <inline-jdbc><xa-properties databaseName=\"${moqui_runtime}/db/derby/moqui\" createDatabase=\"create\"/></inline-jdbc>\n        </datasource>\n        <datasource group-name=\"transactional\" database-conf-name=\"derby\" schema-name=\"MOQUI\">\n            <inline-jdbc jdbc-uri=\"jdbc:derby:moqui;create=true\" jdbc-username=\"moqui\" jdbc-password=\"moqui\"/>\n        </datasource>\n        -->\n        <database name=\"derby\" use-pk-constraint-names=\"false\" use-indexes-unique=\"false\" default-isolation-level=\"ReadCommitted\"\n                default-jdbc-driver=\"org.apache.derby.jdbc.EmbeddedDriver\"\n                default-xa-ds-class=\"org.apache.derby.jdbc.EmbeddedXADataSource\">\n            <!-- default-test-query=\"???\" maybe like SELECT 1 FROM SEQUENCE_VALUE_ITEM WHERE 1=0 -->\n            <inline-jdbc><xa-properties databaseName=\"${moqui_runtime}/db/derby/${entity_ds_database}\" createDatabase=\"create\"/></inline-jdbc>\n        </database>\n        <!--\n        * H2 Database:\n        * NOTE: With this embedded H2 setup you can connect remotely using \"jdbc:h2:tcp://localhost:9092/moqui\"\n\n        <datasource group-name=\"transactional\" database-conf-name=\"h2\" schema-name=\"\">\n            <inline-jdbc><xa-properties url=\"jdbc:h2:${moqui_runtime}/db/h2/moqui;lock_timeout=30000\" user=\"sa\" password=\"\"/></inline-jdbc>\n        </datasource>\n        <datasource group-name=\"transactional\" database-conf-name=\"h2\" schema-name=\"\">\n            <inline-jdbc jdbc-uri=\"jdbc:h2:${moqui_runtime}/db/h2/moqui;lock_timeout=30000\" jdbc-username=\"sa\" jdbc-password=\"sa\"/>\n        </datasource>\n        -->\n        <database name=\"h2\" use-pk-constraint-names=\"false\" use-indexes-unique=\"true\" add-unique-as=\"true\" default-isolation-level=\"ReadCommitted\"\n                default-jdbc-driver=\"org.h2.Driver\" default-xa-ds-class=\"org.h2.jdbcx.JdbcDataSource\"\n                default-start-server-args=\"-tcpPort 9092 -ifExists -baseDir ${moqui_runtime}/db/h2\">\n            <!-- 'VALUE' is a reserved word in H2 starting with version 2.0.202 -->\n            <name-replace original=\"VALUE\" replace=\"THE_VALUE\"/>\n            <inline-jdbc><xa-properties url=\"${entity_ds_url}\" user=\"${entity_ds_user}\" password=\"${entity_ds_password}\"/></inline-jdbc>\n        </database>\n        <!-- TODO: add configuration examples -->\n        <database name=\"hsql\" lb-name=\"hsqldb\" use-fk-initially-deferred=\"false\" join-style=\"ansi-no-parenthesis\"\n                default-isolation-level=\"ReadUncommitted\" default-jdbc-driver=\"org.hsqldb.jdbcDriver\"\n                default-test-query=\"SELECT 1 FROM SEQUENCE_VALUE_ITEM WHERE 1=0\">\n            <database-type type=\"id\" sql-type=\"VARCHAR\"/>\n            <database-type type=\"id-long\" sql-type=\"VARCHAR\"/>\n\n            <database-type type=\"number-integer\" sql-type=\"BIGINT\"/>\n            <database-type type=\"number-decimal\" sql-type=\"DOUBLE\"/>\n            <database-type type=\"number-float\" sql-type=\"DOUBLE\"/>\n\n            <database-type type=\"currency-amount\" sql-type=\"DOUBLE\"/>\n            <database-type type=\"currency-precise\" sql-type=\"DOUBLE\"/>\n\n            <database-type type=\"text-indicator\" sql-type=\"CHAR\"/>\n            <database-type type=\"text-short\" sql-type=\"VARCHAR\"/>\n            <database-type type=\"text-medium\" sql-type=\"VARCHAR\"/>\n            <database-type type=\"text-intermediate\" sql-type=\"VARCHAR\"/>\n            <database-type type=\"text-long\" sql-type=\"VARCHAR\"/>\n            <database-type type=\"text-very-long\" sql-type=\"VARCHAR\"/>\n\n            <database-type type=\"binary-very-long\" sql-type=\"OBJECT\" sql-type-alias=\"OTHER\"/>\n        </database>\n        <!--\n        * MS SQL Server:\n        <datasource group-name=\"transactional\" database-conf-name=\"mssql\" schema-name=\"\">\n            <inline-jdbc><xa-properties user=\"moqui\" password=\"moqui\" serverName=\"localhost\" portNumber=\"1433\"\n                    databaseName=\"moqui\" selectMethod=\"Cursor\"/></inline-jdbc>\n        </datasource>\n        <datasource group-name=\"transactional\" database-conf-name=\"mssql\" schema-name=\"\">\n            <inline-jdbc jdbc-uri=\"jdbc:sqlserver://localhost:1433;databaseName=moqui;SelectMethod=Cursor\" jdbc-username=\"moqui\" jdbc-password=\"moqui\"/>\n        </datasource>\n\n        NOTE: for MS SQL Server 2012 and later can use offset-style=fetch for better performance and consistent behavior\n        -->\n        <database name=\"mssql\" join-style=\"ansi\" default-isolation-level=\"ReadCommitted\" offset-style=\"fetch\" from-lateral-style=\"apply\"\n                default-test-query=\"SELECT 1\" default-jdbc-driver=\"com.microsoft.sqlserver.jdbc.SQLServerDriver\"\n                default-xa-ds-class=\"com.microsoft.sqlserver.jdbc.SQLServerXADataSource\"\n                default-startup-add-missing=\"true\" default-runtime-add-missing=\"false\" never-nulls=\"true\">\n            <database-type type=\"id\" sql-type=\"NVARCHAR(40)\"/>\n            <database-type type=\"id-long\" sql-type=\"NVARCHAR(255)\"/>\n\n            <database-type type=\"date\" sql-type=\"DATETIME\"/>\n            <database-type type=\"time\" sql-type=\"DATETIME\"/>\n            <database-type type=\"date-time\" sql-type=\"DATETIME\"/>\n\n            <database-type type=\"number-integer\" sql-type=\"DECIMAL(20,0)\"/>\n            <database-type type=\"number-decimal\" sql-type=\"DECIMAL(26,6)\"/>\n            <database-type type=\"number-float\" sql-type=\"DECIMAL(32,12)\"/>\n\n            <database-type type=\"currency-amount\" sql-type=\"DECIMAL(24,4)\"/>\n            <database-type type=\"currency-precise\" sql-type=\"DECIMAL(25,5)\"/>\n\n            <database-type type=\"text-indicator\" sql-type=\"CHAR(1)\"/>\n            <database-type type=\"text-short\" sql-type=\"NVARCHAR(63)\"/>\n            <database-type type=\"text-medium\" sql-type=\"NVARCHAR(255)\"/>\n            <database-type type=\"text-intermediate\" sql-type=\"NVARCHAR(1023)\"/>\n            <database-type type=\"text-long\" sql-type=\"NVARCHAR(4000)\"/>\n            <database-type type=\"text-very-long\" sql-type=\"TEXT\"/>\n\n            <database-type type=\"binary-very-long\" sql-type=\"IMAGE\"/>\n\n            <inline-jdbc><xa-properties serverName=\"${entity_ds_host}\" portNumber=\"${entity_ds_port?:'1433'}\"\n                    databaseName=\"${entity_ds_database}\" user=\"${entity_ds_user}\" password=\"${entity_ds_password}\"\n                    selectMethod=\"Cursor\"/></inline-jdbc>\n        </database>\n        <!--\n        * MySQL (similar for Percona Server, MariaDB, AWS Aurora):\n        <datasource group-name=\"transactional\" database-conf-name=\"mysql\" schema-name=\"\">\n            <inline-jdbc><xa-properties user=\"moqui\" password=\"moqui\" pinGlobalTxToPhysicalConnection=\"true\"\n                    serverName=\"127.0.0.1\" port=\"3306\" databaseName=\"moqui\" autoReconnectForPools=\"true\"\n                    useUnicode=\"true\" encoding=\"UTF-8\"/></inline-jdbc>\n        </datasource>\n        <datasource group-name=\"transactional\" database-conf-name=\"mysql\" schema-name=\"\">\n            <inline-jdbc jdbc-uri=\"jdbc:mysql://127.0.0.1:3306/moqui?autoReconnect=true&amp;useUnicode=true&amp;characterEncoding=UTF-8\"\n                    jdbc-username=\"moqui\" jdbc-password=\"moqui\"/>\n        </datasource>\n        -->\n        <database name=\"mysql\" join-style=\"ansi-no-parenthesis\" offset-style=\"limit\" never-nulls=\"true\"\n                table-engine=\"InnoDB\" character-set=\"utf8\" collate=\"utf8_general_ci\" fk-style=\"name_fk\"\n                constraint-name-clip-length=\"60\"\n                default-isolation-level=\"ReadCommitted\" default-test-query=\"SELECT 1\"\n                default-jdbc-driver=\"com.mysql.jdbc.Driver\"\n                default-xa-ds-class=\"com.mysql.jdbc.jdbc2.optional.MysqlXADataSource\">\n            <!--\n                NOTE: to support 4 byte UTF-8 characters use 'utf8mb4' for character-set, and a corresponding collate such as\n                'utf8mb4_col'. Use this character set and collate when creating your database/schema in MySQL before running Moqui,\n                and if set here it will be set on tables that are created as well. For existing databases already set to plain utf8\n                you'll need to run SQL statements on MySQL to change the character set and collate for each table.\n            -->\n\n            <!-- use DATETIME instead of TIMESTAMP so can be null and gets no default value; issue with newer MySQL versions -->\n            <!-- NOTE: DATETIME(3) is supported, and needed, on MySQL 5.7; for earlier versions removed the '(3)' as it has millisecond precision by default -->\n            <database-type type=\"date-time\" sql-type=\"DATETIME(3)\"/>\n\n            <database-type type=\"number-integer\" sql-type=\"DECIMAL(20,0)\"/>\n            <database-type type=\"number-decimal\" sql-type=\"DECIMAL(26,6)\"/>\n            <database-type type=\"number-float\" sql-type=\"DECIMAL(32,12)\"/>\n\n            <database-type type=\"currency-amount\" sql-type=\"DECIMAL(24,4)\"/>\n            <database-type type=\"currency-precise\" sql-type=\"DECIMAL(25,5)\"/>\n\n            <database-type type=\"text-very-long\" sql-type=\"LONGTEXT\"/>\n            <database-type type=\"binary-very-long\" sql-type=\"LONGBLOB\"/>\n\n            <name-replace original=\"CONDITION\" replace=\"THE_CONDITION\"/>\n\n            <!-- default max connections for MySQL is 151 -->\n            <inline-jdbc pool-maxsize=\"140\"><xa-properties serverName=\"${entity_ds_host}\" port=\"${entity_ds_port?:'3306'}\"\n                    pinGlobalTxToPhysicalConnection=\"true\" autoReconnectForPools=\"true\" useUnicode=\"true\" encoding=\"UTF-8\" useCursorFetch=\"true\"\n                    databaseName=\"${entity_ds_database}\" user=\"${entity_ds_user}\" password=\"${entity_ds_password}\"/></inline-jdbc>\n        </database>\n        <database name=\"mysql8\" lb-name=\"mysql\" join-style=\"ansi-no-parenthesis\" offset-style=\"limit\" from-lateral-style=\"lateral\"\n                never-nulls=\"true\" table-engine=\"InnoDB\" character-set=\"utf8\" collate=\"utf8_general_ci\" fk-style=\"name_fk\"\n                constraint-name-clip-length=\"60\"\n                default-isolation-level=\"ReadCommitted\" default-test-query=\"SELECT 1\"\n                default-startup-add-missing=\"true\" default-runtime-add-missing=\"false\"\n                default-jdbc-driver=\"com.mysql.cj.jdbc.Driver\"\n                default-xa-ds-class=\"com.mysql.cj.jdbc.MysqlXADataSource\">\n            <!-- NOTE that from-lateral-style=lateral requires MySQL 8.0.14 or later -->\n            <!--\n                When Bitronix starts it attempts to recover transactions, this may result in an error like:\n                bitronix.tm.recovery.RecoveryException: failed recovering resource transactional_DS\n\n                See https://dev.mysql.com/doc/refman/8.0/en/privileges-provided.html#priv_xa-recover-admin\n\n                The MySQL user Moqui uses to access the database must have 'XA_RECOVER_ADMIN' privilege, if not the 'root' user:\n                GRANT XA_RECOVER_ADMIN ON *.* TO 'username'@'%'; FLUSH PRIVILEGES;\n            -->\n            <database-type type=\"date-time\" sql-type=\"DATETIME(3)\"/>\n\n            <database-type type=\"number-integer\" sql-type=\"DECIMAL(20,0)\"/>\n            <database-type type=\"number-decimal\" sql-type=\"DECIMAL(26,6)\"/>\n            <database-type type=\"number-float\" sql-type=\"DECIMAL(32,12)\"/>\n\n            <database-type type=\"currency-amount\" sql-type=\"DECIMAL(24,4)\"/>\n            <database-type type=\"currency-precise\" sql-type=\"DECIMAL(25,5)\"/>\n\n            <database-type type=\"text-very-long\" sql-type=\"LONGTEXT\"/>\n            <database-type type=\"binary-very-long\" sql-type=\"LONGBLOB\"/>\n\n            <name-replace original=\"CONDITION\" replace=\"THE_CONDITION\"/>\n\n            <!-- default max connections for MySQL is 151 -->\n            <inline-jdbc pool-maxsize=\"140\"><xa-properties serverName=\"${entity_ds_host}\" port=\"${entity_ds_port?:'3306'}\"\n                    pinGlobalTxToPhysicalConnection=\"true\" autoReconnectForPools=\"true\" useCursorFetch=\"true\"\n                    serverTimezone=\"${database_time_zone ?: default_time_zone}\" useSSL=\"false\" allowPublicKeyRetrieval=\"true\"\n                    databaseName=\"${entity_ds_database}\" user=\"${entity_ds_user}\" password=\"${entity_ds_password}\"/></inline-jdbc>\n        </database>\n        <!--\n        * Oracle:\n        <datasource group-name=\"transactional\" database-conf-name=\"oracle\" schema-name=\"MOQUI\">\n            <inline-jdbc><xa-properties user=\"moqui\" password=\"moqui\" URL=\"jdbc:oracle:thin:@127.0.0.1:1521:moqui\"/></inline-jdbc>\n        </datasource>\n        <datasource group-name=\"transactional\" database-conf-name=\"oracle\" schema-name=\"MOQUI\">\n            <inline-jdbc jdbc-uri=\"jdbc:oracle:thin:@127.0.0.1:1521:moqui\" jdbc-username=\"moqui\" jdbc-password=\"moqui\"/>\n        </datasource>\n        -->\n        <database name=\"oracle\" add-unique-as=\"true\" join-style=\"ansi\" from-lateral-style=\"apply\" default-isolation-level=\"ReadCommitted\"\n                default-test-query=\"SELECT 1 FROM DUAL\" default-jdbc-driver=\"oracle.jdbc.driver.OracleDriver\"\n                default-xa-ds-class=\"oracle.jdbc.xa.client.OracleXADataSource\"\n                default-startup-add-missing=\"true\" default-runtime-add-missing=\"false\">\n            <database-type type=\"id\" sql-type=\"VARCHAR2(40)\"/>\n            <database-type type=\"id-long\" sql-type=\"VARCHAR2(255)\"/>\n\n            <database-type type=\"time\" sql-type=\"DATE\"/>\n\n            <database-type type=\"number-integer\" sql-type=\"NUMBER(20,0)\"/>\n            <database-type type=\"number-decimal\" sql-type=\"NUMBER(26,6)\"/>\n            <database-type type=\"number-float\" sql-type=\"NUMBER(32,12)\"/>\n\n            <database-type type=\"currency-amount\" sql-type=\"NUMBER(24,4)\"/>\n            <database-type type=\"currency-precise\" sql-type=\"NUMBER(25,5)\"/>\n\n            <database-type type=\"text-short\" sql-type=\"VARCHAR2(63)\"/>\n            <database-type type=\"text-medium\" sql-type=\"VARCHAR2(255)\"/>\n            <database-type type=\"text-intermediate\" sql-type=\"VARCHAR2(1023)\"/>\n            <database-type type=\"text-long\" sql-type=\"VARCHAR2(4000)\"/>\n\n            <inline-jdbc><xa-properties user=\"${entity_ds_user}\" password=\"${entity_ds_password}\"\n                    URL=\"jdbc:oracle:thin:@${entity_ds_host}:${entity_ds_port?:'1521'}:${entity_ds_database}\"/></inline-jdbc>\n        </database>\n\n        <!-- for future reference (OrientDB with JDBC driver): <database name=\"orientdb\"\n                use-pk-constraint-names=\"false\" default-isolation-level=\"ReadCommitted\"\n                default-jdbc-driver=\"com.orientechnologies.orient.jdbc.OrientJdbcDriver\"\n                default-xa-ds-class=\"\">\n        </database> -->\n\n        <!--\n        * PostgreSQL:\n        <datasource group-name=\"transactional\" database-conf-name=\"postgres\" schema-name=\"public\" startup-add-missing=\"true\" runtime-add-missing=\"false\">\n            <inline-jdbc><xa-properties user=\"moqui\" password=\"moqui\" serverName=\"localhost\" portNumber=\"5432\"\n                    databaseName=\"moqui\"/></inline-jdbc>\n        </datasource>\n        <datasource group-name=\"transactional\" database-conf-name=\"postgres\" schema-name=\"public\" startup-add-missing=\"true\" runtime-add-missing=\"false\">\n            <inline-jdbc jdbc-uri=\"jdbc:postgresql://127.0.0.1/moqui\" jdbc-username=\"moqui\" jdbc-password=\"moqui\"/>\n        </datasource>\n        -->\n        <database name=\"postgres\" lb-name=\"postgresql\" join-style=\"ansi\" from-lateral-style=\"lateral\" result-fetch-size=\"50\"\n                never-try-insert=\"true\" default-isolation-level=\"ReadCommitted\" use-tm-join=\"true\" default-test-query=\"SELECT 1\"\n                constraint-name-clip-length=\"60\"\n                default-jdbc-driver=\"org.postgresql.Driver\" default-xa-ds-class=\"org.postgresql.xa.PGXADataSource\"\n                default-startup-add-missing=\"true\" default-runtime-add-missing=\"false\" use-binary-type-for-blob=\"true\">\n            <!-- NOTE: when Postgres JDBC driver updated can set use-tm-join=\"true\" -->\n            <database-type type=\"number-float\" sql-type=\"FLOAT8\"/>\n\n            <database-type type=\"text-medium\" sql-type=\"TEXT\"/>\n            <database-type type=\"text-intermediate\" sql-type=\"TEXT\"/>\n            <database-type type=\"text-long\" sql-type=\"TEXT\"/>\n            <database-type type=\"text-very-long\" sql-type=\"TEXT\"/>\n\n            <database-type type=\"binary-very-long\" sql-type=\"BYTEA\"/>\n\n            <!-- default max connections for PostgreSQL is 100 -->\n            <inline-jdbc pool-maxsize=\"90\"><xa-properties serverName=\"${entity_ds_host}\" portNumber=\"${entity_ds_port?:'5432'}\"\n                    databaseName=\"${entity_ds_database}\" user=\"${entity_ds_user}\" password=\"${entity_ds_password}\"/></inline-jdbc>\n        </database>\n    </database-list>\n\n    <repository-list>\n        <!-- No JCR repo by default, but here are some examples: -->\n        <!-- requires 'org.apache.jackrabbit:jackrabbit-jcr-rmi' in the classpath:\n        <repository name=\"main\" workspace=\"default\" username=\"admin\" password=\"admin\">\n            <init-param name=\"org.apache.jackrabbit.repository.uri\" value=\"http://localhost:8081/rmi\"/></repository> -->\n        <!-- requires 'org.apache.jackrabbit:jackrabbit-jcr2dav' in the classpath:\n        <repository name=\"main\" workspace=\"default\" username=\"admin\" password=\"admin\">\n            <init-param name=\"org.apache.jackrabbit.spi2davex.uri\" value=\"http://localhost:8081/server\"/></repository> -->\n    </repository-list>\n\n    <component-list>\n        <component-dir location=\"base-component\"/>\n        <component-dir location=\"mantle\"/>\n        <component-dir location=\"component\"/>\n        <component-dir location=\"ecomponent\"/>\n        <!-- Here are some examples of explicitly loading the example component in different ways: -->\n        <!-- <component name=\"example\" location=\"component/example\"/> -->\n        <!-- <component name=\"example\" location=\"content://main/component/example\"/> -->\n    </component-list>\n</moqui-conf>\n"
  },
  {
    "path": "framework/src/main/resources/bitronix-default-config.properties",
    "content": "# For configuration options see:\n# https://github.com/bitronix/btm/wiki/Transaction-manager-configuration\n# https://github.com/bitronix/btm/blob/master/btm-docs/src/main/asciidoc/Configuration2x.adoc\n\n# Leave this commented to use IP address as server ID\n#bitronix.tm.serverId=server-id\n\nbitronix.tm.2pc.async=false\nbitronix.tm.2pc.warnAboutZeroResourceTransactions=false\nbitronix.tm.2pc.debugZeroResourceTransactions=false\n\nbitronix.tm.allowMultipleLrc=true\n\nbitronix.tm.journal.disk.logPart1Filename=${moqui.runtime}/txlog/btm1.tlog\nbitronix.tm.journal.disk.logPart2Filename=${moqui.runtime}/txlog/btm2.tlog\n\n#bitronix.tm.journal.disk.forcedWriteEnabled=true\n#bitronix.tm.journal.disk.forceBatchingEnabled=true\n#bitronix.tm.journal.disk.skipCorruptedLogs=false\n\n# maxLogSize is in MB\n#bitronix.tm.journal.disk.maxLogSize=2\n#bitronix.tm.journal.disk.filterLogStatus=false\n\n# these timer parameters are all in seconds\nbitronix.tm.timer.defaultTransactionTimeout=60\n#bitronix.tm.timer.transactionRetryInterval=10\nbitronix.tm.timer.gracefulShutdownInterval=60\nbitronix.tm.timer.backgroundRecoveryIntervalSeconds=60\n\n# this one is in minutes - this appears to be an old setting, see bitronix.tm.timer.backgroundRecoveryIntervalSeconds above\n#bitronix.tm.timer.backgroundRecoveryInterval=0\n\n# resources configuration file\n#bitronix.tm.resource.configuration=\n"
  },
  {
    "path": "framework/src/main/resources/cache.ccf",
    "content": "# Apache Commons JCS Configuration\n# See: https://commons.apache.org/proper/commons-jcs/BasicJCSConfiguration.html\n\n# DEFAULT CACHE REGION\njcs.default=\njcs.default.cacheattributes=org.apache.commons.jcs.engine.CompositeCacheAttributes\njcs.default.cacheattributes.UseDisk=true\njcs.default.cacheattributes.UseLateral=true\njcs.default.cacheattributes.UseRemote=true\njcs.default.cacheattributes.MemoryCacheName=org.apache.commons.jcs.engine.memory.lru.LRUMemoryCache\njcs.default.cacheattributes.UseMemoryShrinker=false\njcs.default.cacheattributes.MaxMemoryIdleTime=3600\njcs.default.cacheattributes.ShrinkerInterval=60\njcs.default.elementattributes=org.apache.commons.jcs.engine.ElementAttributes\njcs.default.elementattributes.IsEternal=false\njcs.default.elementattributes.MaxLife=700\njcs.default.elementattributes.IsSpool=true\njcs.default.elementattributes.IsRemote=true\njcs.default.elementattributes.IsLateral=true\n"
  },
  {
    "path": "framework/src/main/resources/log4j2.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!-- For documentation see:\nhttps://logging.apache.org/log4j/2.x/manual/configuration.html\nhttps://logging.apache.org/log4j/2.x/manual/layouts.html#PatternLayout\n-->\n<Configuration status=\"ERROR\" name=\"Moqui\" shutdownHook=\"disable\" packages=\"org.moqui.context\">\n    <Properties>\n        <!-- the ${sys:moqui.runtime} expansion fails under Tomcat/etc as moqui.runtime isn't set before Log4J2\n            initializes (like with MoquiStart.java), so specify a default property here -->\n        <Property name=\"moqui.runtime\">moqui_logs</Property>\n        <Property name=\"moqui.logger.level.xml_action\">info</Property>\n        <Property name=\"moqui.logger.level.entity_find\">info</Property>\n        <Property name=\"moqui.logger.level.entity_query\">info</Property>\n        <!-- set log4j2.formatMsgNoLookups=true for log string based vulnerability -->\n        <Property name=\"log4j2.formatMsgNoLookups\">true</Property>\n    </Properties>\n    <Appenders>\n        <RollingFile name=\"LogFile\" fileName=\"${sys:moqui.runtime}/log/moqui.log\"\n                filePattern=\"${sys:moqui.runtime}/log/moqui%d{yyyy-MM-dd}-%i.log\">\n            <PatternLayout pattern=\"--- %d{yyyy-MM-dd HH:mm:ss.SSS} [%15.15t] %-5p %50.50c %x%n %m%n\"/>\n            <Policies>\n                <TimeBasedTriggeringPolicy/>\n                <SizeBasedTriggeringPolicy size=\"10 MB\"/>\n            </Policies>\n            <DefaultRolloverStrategy max=\"20\"/>\n        </RollingFile>\n        <RollingFile name=\"ErrorFile\" fileName=\"${sys:moqui.runtime}/log/error.log\"\n                filePattern=\"${sys:moqui.runtime}/log/error%d{yyyy-MM-dd}-%i.log\">\n            <PatternLayout pattern=\"--- %d{yyyy-MM-dd HH:mm:ss.SSS} [%15.15t] %-5p %50.50c [%l] %x%n %m%n\"/>\n            <ThresholdFilter level=\"ERROR\" onMatch=\"ACCEPT\" onMismatch=\"DENY\"/><!-- should get error and fatal -->\n            <Policies>\n                <TimeBasedTriggeringPolicy/>\n                <SizeBasedTriggeringPolicy size=\"10 MB\"/>\n            </Policies>\n            <DefaultRolloverStrategy max=\"20\"/>\n        </RollingFile>\n        <MoquiLog4jAppender name=\"MoquiSubscribers\"/>\n        <Console name=\"STDOUT\" target=\"SYSTEM_OUT\">\n            <PatternLayout pattern=\"%highlight{%d{HH:mm:ss.SSS} %5p %12.12t %38.38c{1.9.1.}} %m%n\"/>\n        </Console>\n        <Async name=\"AsyncLog\">\n            <AppenderRef ref=\"STDOUT\"/>\n            <AppenderRef ref=\"LogFile\"/>\n            <AppenderRef ref=\"ErrorFile\"/>\n            <AppenderRef ref=\"MoquiSubscribers\"/>\n        </Async>\n    </Appenders>\n    <Loggers>\n        <Logger name=\"org.apache\" level=\"warn\"/>\n        <Logger name=\"org.eclipse.jetty.annotations\" level=\"error\"/>\n        <Logger name=\"org.apache.fop.fo.extensions.svg.SVGElementMapping\" level=\"fatal\"/>\n\n        <Logger name=\"freemarker\" level=\"warn\"/>\n        <Logger name=\"org.elasticsearch\" level=\"warn\"/>\n        <Logger name=\"org.drools\" level=\"info\"/>\n        <Logger name=\"atomikos\" level=\"warn\"/>\n        <Logger name=\"com.atomikos\" level=\"warn\"/>\n        <Logger name=\"bitronix\" level=\"warn\"/>\n        <Logger name=\"cz.vutbr\" level=\"warn\"/><!-- cssbox -->\n\n        <!-- show Groovy generated from XML Actions: -->\n        <Logger name=\"org.moqui.impl.actions.XmlAction\" level=\"${sys:moqui.logger.level.xml_action}\"/>\n        <!-- show SQL generated for finds: -->\n        <Logger name=\"org.moqui.impl.entity.EntityFindBuilder\" level=\"${sys:moqui.logger.level.entity_find}\"/>\n        <!-- show SQL generated for crud ops: -->\n        <Logger name=\"org.moqui.impl.entity.EntityQueryBuilder\" level=\"${sys:moqui.logger.level.entity_query}\"/>\n\n        <Logger name=\"org.moqui\" level=\"info\"/>\n\n        <Root level=\"info\"><AppenderRef ref=\"AsyncLog\"/></Root>\n    </Loggers>\n</Configuration>\n"
  },
  {
    "path": "framework/src/main/resources/org/moqui/impl/pollEmailServer.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\n/*\n    JavaMail API Documentation at: https://java.net/projects/javamail/pages/Home\n    For JavaMail JavaDocs see: https://javamail.java.net/nonav/docs/api/index.html\n */\n\nimport jakarta.mail.FetchProfile\nimport jakarta.mail.Flags\nimport jakarta.mail.Folder\nimport jakarta.mail.Message\nimport jakarta.mail.Session\nimport jakarta.mail.Store\nimport jakarta.mail.internet.MimeMessage\nimport jakarta.mail.search.FlagTerm\nimport jakarta.mail.search.SearchTerm\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.context.ExecutionContextImpl\n\nLogger logger = LoggerFactory.getLogger(\"org.moqui.impl.pollEmailServer\")\n\nExecutionContextImpl ec = context.ec\n\nEntityValue emailServer = ec.entity.find(\"moqui.basic.email.EmailServer\").condition(\"emailServerId\", emailServerId).one()\nif (!emailServer) { ec.message.addError(ec.resource.expand('No EmailServer found for ID [${emailServerId}]','')); return }\nif (!emailServer.storeHost) { ec.message.addError(ec.resource.expand('EmailServer [${emailServerId}] has no storeHost','')) }\nif (!emailServer.mailUsername) { ec.message.addError(ec.resource.expand('EmailServer [${emailServerId}] has no mailUsername','')) }\nif (!emailServer.mailPassword) { ec.message.addError(ec.resource.expand('EmailServer [${emailServerId}] has no mailPassword','')) }\nif (ec.message.hasError()) return\n\nString host = emailServer.storeHost\nString user = emailServer.mailUsername\nString password = emailServer.mailPassword\nString protocol = emailServer.storeProtocol ?: \"imaps\"\nint port = (emailServer.storePort ?: \"993\") as int\nString storeFolder = emailServer.storeFolder ?: \"INBOX\"\n\n// def urlName = new URLName(protocol, host, port as int, \"\", user, password)\nSession session = Session.getInstance(System.getProperties())\nlogger.info(\"Polling Email from ${user}@${host}:${port}/${storeFolder}, properties ${session.getProperties()}\")\n\nStore store = session.getStore(protocol)\nif (!store.isConnected()) store.connect(host, port, user, password)\n\n// open the folder\nFolder folder = store.getFolder(storeFolder)\nif (folder == null || !folder.exists()) { ec.message.addError(ec.resource.expand('No ${storeFolder} folder found','')); return }\n\n// get message count\nfolder.open(Folder.READ_WRITE)\nint totalMessages = folder.getMessageCount()\n// close and return if no messages\nif (totalMessages == 0) { folder.close(false); return }\n\n// get messages not deleted (and optionally not seen)\nFlags searchFlags = new Flags(Flags.Flag.DELETED)\nif (emailServer.storeSkipSeen == \"Y\") searchFlags.add(Flags.Flag.SEEN)\nSearchTerm searchTerm = new FlagTerm(searchFlags, false)\nMessage[] messages = folder.search(searchTerm)\nFetchProfile profile = new FetchProfile()\nprofile.add(FetchProfile.Item.ENVELOPE)\nprofile.add(FetchProfile.Item.FLAGS)\nprofile.add(\"X-Mailer\")\nfolder.fetch(messages, profile)\n\nlogger.info(\"Found ${totalMessages} messages (${messages.size()} filtered) at ${user}@${host}:${port}/${storeFolder}\")\n\nfor (Message message in messages) {\n    if (emailServer.storeSkipSeen == \"Y\" && message.isSet(Flags.Flag.SEEN)) continue\n\n    // NOTE: should we check size? long messageSize = message.getSize()\n    if (message instanceof MimeMessage) {\n        // use copy constructor to have it download the full message, may fix BODYSTRUCTURE issue from some email servers (see details in issue #97)\n        MimeMessage fullMessage = new MimeMessage(message)\n        ec.service.runEmecaRules(fullMessage, emailServerId)\n\n        // mark seen if setup to do so\n        if (emailServer.storeMarkSeen == \"Y\") message.setFlag(Flags.Flag.SEEN, true)\n        // delete the message if setup to do so\n        if (emailServer.storeDelete == \"Y\") message.setFlag(Flags.Flag.DELETED, true)\n    } else {\n        logger.warn(\"Doing nothing with non-MimeMessage message: ${message}\")\n    }\n}\n\n// expunge and close the folder\nfolder.close(true)\n"
  },
  {
    "path": "framework/src/main/resources/org/moqui/impl/sendEmailMessage.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\n/*\n    JavaMail API Documentation at: https://java.net/projects/javamail/pages/Home\n    For JavaMail JavaDocs see: https://javamail.java.net/nonav/docs/api/index.html\n */\n\nimport org.apache.commons.mail2.jakarta.DefaultAuthenticator\nimport org.apache.commons.mail2.jakarta.HtmlEmail\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.context.ExecutionContextImpl\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n\nLogger logger = LoggerFactory.getLogger(\"org.moqui.impl.sendEmailMessage\")\nExecutionContextImpl ec = context.ec\n\ntry {\n\n    EntityValue emailMessage = ec.entity.find(\"moqui.basic.email.EmailMessage\").condition(\"emailMessageId\", emailMessageId).one()\n    if (emailMessage == null) { ec.message.addError(ec.resource.expand('No EmailMessage record found for ID ${emailMessageId}','')); return }\n    String statusId = emailMessage.statusId\n    if (statusId == 'ES_DRAFT') ec.message.addError(ec.resource.expand('Email Message ${emailMessageId} is in Draft status',''))\n    if (statusId == 'ES_CANCELLED') ec.message.addError(ec.resource.expand('Email Message ${emailMessageId} is Cancelled',''))\n\n    String bodyHtml = emailMessage.body\n    String bodyText = emailMessage.bodyText\n    String fromAddress = emailMessage.fromAddress\n    String toAddresses = emailMessage.toAddresses\n    String ccAddresses = emailMessage.ccAddresses\n    String bccAddresses = emailMessage.bccAddresses\n\n    if (!bodyHtml && !bodyText) ec.message.addError(ec.resource.expand('Email Message ${emailMessageId} has no body',''))\n    if (!fromAddress) ec.message.addError(ec.resource.expand('Email Message ${emailMessageId} has no from address',''))\n    if (!toAddresses) ec.message.addError(ec.resource.expand('Email Message ${emailMessageId} has no to address',''))\n    if (ec.message.hasError()) return\n\n    EntityValue emailTemplate = (EntityValue) emailMessage.template\n\n    EntityValue emailServer = (EntityValue) emailMessage.server\n    if (emailServer == null) { ec.message.addError(ec.resource.expand('No Email Server record found for Email Message ${emailMessageId}','')); return }\n    if (!emailServer.smtpHost) {\n        logger.warn(\"SMTP Host is empty for EmailServer ${emailServer.emailServerId}, not sending email message ${emailMessageId}\")\n        // logger.warn(\"SMTP Host is empty for EmailServer ${emailServer.emailServerId}, not sending email:\\nbodyHtml:\\n${bodyHtml}\\nbodyText:\\n${bodyText}\")\n        return\n    }\n\n    String host = emailServer.smtpHost\n    int port = (emailServer.smtpPort ?: \"25\") as int\n\n    HtmlEmail email = new HtmlEmail()\n    email.setCharset(\"utf-8\")\n    email.setHostName(host)\n    email.setSmtpPort(port)\n    if (emailServer.mailUsername) {\n        email.setAuthenticator(new DefaultAuthenticator((String) emailServer.mailUsername, (String) emailServer.mailPassword))\n        // logger.info(\"Set user=${emailServer.mailUsername}, password=${emailServer.mailPassword}\")\n    }\n    if (emailServer.smtpStartTls == \"Y\") {\n        email.setStartTLSEnabled(true)\n        // email.setStartTLSRequired(true)\n    }\n    if (emailServer.smtpSsl == \"Y\") {\n        email.setSSLOnConnect(true)\n        email.setSslSmtpPort(port as String)\n        // email.setSSLCheckServerIdentity(true)\n    }\n\n    // set the subject\n    if (emailMessage.subject) email.setSubject((String) emailMessage.subject)\n\n    // set from, reply to, bounce addresses\n    email.setFrom(fromAddress, (String) emailMessage.fromName)\n    if (emailTemplate?.replyToAddresses) {\n        def rtList = ((String) emailTemplate.replyToAddresses).split(\",\")\n        for (address in rtList) email.addReplyTo(address.trim())\n    }\n    if (emailTemplate?.bounceAddress) email.setBounceAddress((String) emailTemplate.bounceAddress)\n\n    // prep list of allowed to domains, if configured\n    String allowedToDomains = emailServer.allowedToDomains\n    ArrayList<String> toDomainList = null\n    List<String> skippedToAddresses = null\n    if (allowedToDomains) {\n        toDomainList = new ArrayList<>(allowedToDomains.split(\",\").collect({ it.trim() }))\n        skippedToAddresses = []\n    }\n\n    // set to, cc, bcc addresses\n    def toList = ((String) toAddresses).split(\",\")\n    for (toAddress in toList) {\n        if (isDomainAllowed(toAddress, toDomainList)) email.addTo(toAddress.trim())\n        else skippedToAddresses.add(toAddress)\n    }\n    if (ccAddresses) {\n        def ccList = ((String) ccAddresses).split(\",\")\n        for (ccAddress in ccList) {\n            if (isDomainAllowed(ccAddress, toDomainList)) email.addCc(ccAddress.trim())\n            else skippedToAddresses.add(ccAddress)\n        }\n    }\n    if (bccAddresses) {\n        def bccList = ((String) bccAddresses).split(\",\")\n        for (def bccAddress in bccList) {\n            if (isDomainAllowed(bccAddress, toDomainList)) email.addBcc(bccAddress.trim())\n            else skippedToAddresses.add(bccAddress)\n        }\n    }\n\n    if (!email.getToAddresses()) {\n        logger.warn(\"Not sending EmailMessage ${emailMessageId} with no To Addresses; To, CC, BCC addresses skipped because domain not allowed: ${skippedToAddresses} allowed domains: ${toDomainList}\")\n        ec.message.addMessage(\"Not sending email message with no To Address; address(es) skipped because domain not allowed: ${skippedToAddresses}\", \"warning\")\n        return\n    } else if (skippedToAddresses) {\n        logger.warn(\"Sending EmailMessage ${emailMessageId} to remaining To Address(es) ${email.getToAddresses()}; some To, CC, BCC addresses skipped because domain not allowed: ${skippedToAddresses} allowed domains: ${toDomainList}\")\n    }\n\n    // set the html message\n    if (bodyHtml) email.setHtmlMsg(bodyHtml)\n    // set the alternative plain text message\n    if (bodyText) email.setTextMsg(bodyText)\n\n    if (logger.infoEnabled) logger.info(\"Sending email [${email.getSubject()}] from ${email.getFromAddress()} to ${email.getToAddresses()} cc ${email.getCcAddresses()} bcc ${email.getBccAddresses()} via ${emailServer.mailUsername}@${email.getHostName()}:${email.getSmtpPort()} SSL? ${email.isSSLOnConnect()}:${email.isSSLCheckServerIdentity()} StartTLS? ${email.isStartTLSEnabled()}:${email.isStartTLSRequired()}\")\n    if (logger.traceEnabled) logger.trace(\"Sending email [${email.getSubject()}] to ${email.getToAddresses()} with bodyHtml:\\n${bodyHtml}\\nbodyText:\\n${bodyText}\")\n    // email.setDebug(true)\n\n    // send the email\n    try {\n        messageId = email.send()\n        if (statusId in ['ES_READY', 'ES_BOUNCED']) {\n            ec.service.sync().name(\"update\", \"moqui.basic.email.EmailMessage\").requireNewTransaction(true)\n                    .parameters([emailMessageId:emailMessageId, sentDate:ec.user.nowTimestamp, statusId:\"ES_SENT\", messageId:messageId])\n                    .disableAuthz().call()\n        }\n    } catch (Throwable t) {\n        logger.error(\"Error in sendEmailTemplate\", t)\n        ec.message.addMessage(\"Error sending email: ${t.toString()}\")\n    }\n\n    return\n} catch (Throwable t) {\n    logger.error(\"Error in sendEmailTemplate\", t)\n    ec.message.addMessage(\"Error sending email: ${t.toString()}\")\n    // don't rethrow: throw new BaseArtifactException(\"Error in sendEmailTemplate\", t)\n}\n\nstatic boolean isDomainAllowed(String emailAddress, ArrayList<String> toDomainList) {\n    if (emailAddress == null || emailAddress.isEmpty()) return false\n    boolean domainAllowed = true\n    if (toDomainList != null && !toDomainList.isEmpty()) {\n        domainAllowed = false\n        int atIndex = emailAddress.indexOf(\"@\")\n        if (atIndex == -1) return false\n        String emailDomain = emailAddress.substring(atIndex + 1, emailAddress.length())\n\n        for (toDomain in toDomainList) {\n            if (emailDomain.endsWith(toDomain)) {\n                domainAllowed = true\n                break\n            }\n        }\n    }\n    return domainAllowed\n}\n"
  },
  {
    "path": "framework/src/main/resources/org/moqui/impl/sendEmailTemplate.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\n/*\nJavaMail API Documentation at: https://java.net/projects/javamail/pages/Home\nFor JavaMail JavaDocs see: https://javamail.java.net/nonav/docs/api/index.html\n */\n\nimport org.apache.commons.mail2.jakarta.DefaultAuthenticator\nimport org.apache.commons.mail2.jakarta.HtmlEmail\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityValue\nimport org.moqui.impl.context.ExecutionContextImpl\n\nimport jakarta.activation.DataSource\nimport jakarta.mail.util.ByteArrayDataSource\nimport javax.xml.transform.stream.StreamSource\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\n\n\nLogger logger = LoggerFactory.getLogger(\"org.moqui.impl.sendEmailTemplate\")\nExecutionContextImpl ec = context.ec\n\ntry {\n    // logger.info(\"sendEmailTemplate with emailTemplateId [${emailTemplateId}], bodyParameters [${bodyParameters}]\")\n\n    // add the bodyParameters to the context so they are available throughout this script\n    if (bodyParameters) context.putAll(bodyParameters)\n\n    EntityValue emailTemplate = ec.entity.find(\"moqui.basic.email.EmailTemplate\").condition(\"emailTemplateId\", emailTemplateId).one()\n    if (emailTemplate == null) ec.message.addError(ec.resource.expand('No EmailTemplate record found for ID [${emailTemplateId}]',''))\n    if (ec.message.hasError()) return\n\n    emailTypeEnumId = emailTypeEnumId ?: emailTemplate.emailTypeEnumId\n\n    // combine ccAddresses and bccAddresses\n    if (ccAddresses) {\n        if (emailTemplate.ccAddresses) ccAddresses = ccAddresses + \",\" + emailTemplate.ccAddresses\n    } else { ccAddresses = emailTemplate.ccAddresses }\n    if (bccAddresses) {\n        if (emailTemplate.bccAddresses) bccAddresses = bccAddresses + \",\" + emailTemplate.bccAddresses\n    } else { bccAddresses = emailTemplate.bccAddresses }\n\n    // prepare the fromAddress, fromName, subject, etc; no type or def so that they go into the context for templates\n    fromAddress = ec.resource.expand((String) emailTemplate.fromAddress, \"\")\n    fromName = ec.resource.expand((String) emailTemplate.fromName, \"\")\n    subject = ec.resource.expand((String) emailTemplate.subject, \"\")\n    webappName = (String) emailTemplate.webappName ?: \"webroot\"\n    webHostName = (String) emailTemplate.webHostName\n\n    // create an moqui.basic.email.EmailMessage record with info about this sent message\n    // NOTE: can do anything with? purposeEnumId\n    if (createEmailMessage) {\n        Map cemParms = [statusId:\"ES_DRAFT\", subject:subject,\n                        fromAddress:fromAddress, fromName:fromName, toAddresses:toAddresses, ccAddresses:ccAddresses, bccAddresses:bccAddresses,\n                        contentType:\"text/html\", emailTypeEnumId:emailTypeEnumId,\n                        emailTemplateId:emailTemplateId, emailServerId:emailTemplate.emailServerId,\n                        fromUserId:(fromUserId ?: ec.user?.userId), toUserId:toUserId]\n        Map cemResults = ec.service.sync().name(\"create\", \"moqui.basic.email.EmailMessage\").requireNewTransaction(true)\n                .parameters(cemParms).disableAuthz().call()\n        emailMessageId = cemResults.emailMessageId\n    }\n\n    // prepare the html message\n    def bodyRender = ec.screen.makeRender().rootScreen((String) emailTemplate.bodyScreenLocation)\n            .webappName(webappName).renderMode(\"html\")\n    String bodyHtml = bodyRender.render()\n\n    // prepare the alternative plain text message\n    // render screen with renderMode=text for this\n    def bodyTextRender = ec.screen.makeRender().rootScreen((String) emailTemplate.bodyScreenLocation)\n            .webappName(webappName).renderMode(\"text\")\n    String bodyText = bodyTextRender.render()\n\n    if (emailMessageId) {\n        ec.service.sync().name(\"update\", \"moqui.basic.email.EmailMessage\").requireNewTransaction(true)\n                .parameters([emailMessageId:emailMessageId, statusId:\"ES_READY\", body:bodyHtml, bodyText:bodyText])\n                .disableAuthz().call()\n    }\n\n    EntityList emailTemplateAttachmentList = (EntityList) emailTemplate.attachments\n    emailServer = (EntityValue) emailTemplate.server\n\n    // check a couple of required fields\n    if (emailServer == null) ec.message.addError(ec.resource.expand('No EmailServer record found for EmailTemplate ${emailTemplateId}',''))\n    if (!fromAddress) ec.message.addError(ec.resource.expand('From address is empty for EmailTemplate ${emailTemplateId}',''))\n    if (ec.message.hasError()) {\n        logger.info(\"Error sending email: ${ec.message.getErrorsString()}\\nsubject: ${subject}\\nbodyHtml:\\n${bodyHtml}\\nbodyText:\\n${bodyText}\")\n        if (emailMessageId) logger.info(\"Email with error saved as Ready in EmailMessage [${emailMessageId}]\")\n        return\n    }\n    if (!emailServer.smtpHost) {\n        logger.warn(\"SMTP Host is empty for EmailServer ${emailServer.emailServerId}, not sending email ${emailMessageId} template ${emailTemplateId}\")\n        // logger.warn(\"SMTP Host is empty for EmailServer ${emailServer.emailServerId}, not sending email:\\nbodyHtml:\\n${bodyHtml}\\nbodyText:\\n${bodyText}\")\n        return\n    }\n\n    String smtpHost = emailServer.smtpHost\n    int smtpPort = (emailServer.smtpPort ?: \"25\") as int\n\n    HtmlEmail email = new HtmlEmail()\n    email.setCharset(\"utf-8\")\n    email.setHostName(smtpHost)\n    email.setSmtpPort(smtpPort)\n    if (emailServer.mailUsername) {\n        email.setAuthenticator(new DefaultAuthenticator((String) emailServer.mailUsername, (String) emailServer.mailPassword))\n        // logger.info(\"Set user=${emailServer.mailUsername}, password=${emailServer.mailPassword}\")\n    }\n    if (emailServer.smtpStartTls == \"Y\") {\n        email.setStartTLSEnabled(true)\n        // email.setStartTLSRequired(true)\n    }\n    if (emailServer.smtpSsl == \"Y\") {\n        email.setSSLOnConnect(true)\n        email.setSslSmtpPort(smtpPort as String)\n        // email.setSSLCheckServerIdentity(true)\n    }\n\n    // set the subject\n    email.setSubject(subject)\n\n    // set from, reply to, bounce addresses\n    email.setFrom(fromAddress, fromName)\n    if (emailTemplate.replyToAddresses) {\n        def rtList = ((String) emailTemplate.replyToAddresses).split(\",\")\n        for (address in rtList) email.addReplyTo(address.trim())\n    }\n    if (emailTemplate.bounceAddress) email.setBounceAddress((String) emailTemplate.bounceAddress)\n\n    // prep list of allowed to domains, if configured\n    String allowedToDomains = emailServer.allowedToDomains\n    ArrayList<String> toDomainList = null\n    List<String> skippedToAddresses = null\n    if (allowedToDomains) {\n        toDomainList = new ArrayList<>(allowedToDomains.split(\",\").collect({ it.trim() }))\n        skippedToAddresses = []\n    }\n\n    // set to, cc, bcc addresses\n    def toList = ((String) toAddresses).split(\",\")\n    for (toAddress in toList) {\n        if (isDomainAllowed(toAddress, toDomainList)) email.addTo(toAddress.trim())\n        else skippedToAddresses.add(toAddress)\n    }\n    if (ccAddresses) {\n        def ccList = ((String) ccAddresses).split(\",\")\n        for (ccAddress in ccList) {\n            if (isDomainAllowed(ccAddress, toDomainList)) email.addCc(ccAddress.trim())\n            else skippedToAddresses.add(ccAddress)\n        }\n    }\n    if (bccAddresses) {\n        def bccList = ((String) bccAddresses).split(\",\")\n        for (def bccAddress in bccList) {\n            if (isDomainAllowed(bccAddress, toDomainList)) email.addBcc(bccAddress.trim())\n            else skippedToAddresses.add(bccAddress)\n        }\n    }\n\n    if (!email.getToAddresses()) {\n        logger.warn(\"Not sending EmailMessage ${emailMessageId} for Template ${emailTemplateId} with no To Addresses; To, CC, BCC addresses skipped because domain not allowed: ${skippedToAddresses} allowed domains: ${toDomainList}\")\n        ec.message.addMessage(\"Not sending email message with no To Address; address(es) skipped because domain not allowed: ${skippedToAddresses}\", \"warning\")\n        return\n    } else if (skippedToAddresses) {\n        logger.warn(\"Sending EmailMessage ${emailMessageId} for Template ${emailTemplateId} to remaining To Address(es) ${email.getToAddresses()}; some To, CC, BCC addresses skipped because domain not allowed: ${skippedToAddresses} allowed domains: ${toDomainList}\")\n    }\n\n    // set the html message\n    if (bodyHtml) email.setHtmlMsg(bodyHtml)\n    // set the alternative plain text message\n    if (bodyText) email.setTextMsg(bodyText)\n    //email.setTextMsg(\"Your email client does not support HTML messages\")\n\n    // parameter attachments\n    if (attachments instanceof List) for (Map attachmentInfo in attachments) {\n        String filename = ec.resourceFacade.expand((String) attachmentInfo.fileName, null)\n        if (attachmentInfo.contentText) {\n            String mimeType = (String) attachmentInfo.contentType ?: ec.resourceFacade.getContentType(filename) ?: \"text/plain\"\n            DataSource dataSource = new ByteArrayDataSource(attachmentInfo.contentText.toString(), mimeType)\n            email.attach(dataSource, filename, \"\")\n        } else if (attachmentInfo.contentBytes) {\n            String mimeType = (String) attachmentInfo.contentType ?: ec.resourceFacade.getContentType(filename) ?: \"application/octet-stream\"\n            DataSource dataSource = new ByteArrayDataSource((byte[]) attachmentInfo.contentBytes, mimeType)\n            email.attach(dataSource, (String) attachmentInfo.fileName, \"\")\n        } else if (attachmentInfo.screenRenderMode && (attachmentInfo.attachmentLocation || attachmentInfo.screenPath)) {\n            renderScreenAttachment(emailTemplate, email, ec, logger, filename,\n                    (String) attachmentInfo.screenRenderMode, (String) attachmentInfo.attachmentLocation,\n                    (String) attachmentInfo.screenPath, (String) attachmentInfo.contentType)\n        } else if (attachmentInfo.attachmentLocation) {\n            // not a screen, get straight data with type depending on extension\n            DataSource dataSource = ec.resource.getLocationDataSource((String) attachmentInfo.attachmentLocation)\n            email.attach(dataSource, (String) attachmentInfo.fileName, \"\")\n        } else {\n            logger.error(\"Attachment info invalid for email template ${emailTemplateId} to ${toList} subject '${subject}': ${attachmentInfo}\")\n        }\n    }\n\n    // DB configured attachments\n    for (EntityValue emailTemplateAttachment in emailTemplateAttachmentList) {\n        // check attachmentCondition if there is one\n        String attachmentCondition = (String) emailTemplateAttachment.attachmentCondition\n        if (attachmentCondition && !ec.resourceFacade.condition(attachmentCondition, null)) continue\n        // if screenRenderMode render attachment, otherwise just get attachment from location\n        if (emailTemplateAttachment.screenRenderMode) {\n            String forEachIn = (String) emailTemplateAttachment.forEachIn\n            if (forEachIn) {\n                Collection forEachCol = (Collection) ec.resourceFacade.expression(forEachIn, null)\n                if (forEachCol) for (Object forEachEntry in forEachCol) {\n                    ec.contextStack.push()\n                    try {\n                        if (forEachEntry instanceof Map) { ec.contextStack.putAll((Map) forEachEntry) }\n                        else { ec.contextStack.put(\"forEachEntry\", forEachEntry) }\n\n                        renderScreenAttachment(emailTemplate, emailTemplateAttachment, email, ec, logger)\n                    } finally {\n                        ec.contextStack.pop()\n                    }\n                }\n            } else {\n                renderScreenAttachment(emailTemplate, emailTemplateAttachment, email, ec, logger)\n            }\n        } else {\n            // not a screen, get straight data with type depending on extension\n            DataSource dataSource = ec.resource.getLocationDataSource((String) emailTemplateAttachment.attachmentLocation)\n            email.attach(dataSource, (String) emailTemplateAttachment.fileName, \"\")\n        }\n    }\n\n    if (logger.infoEnabled) logger.info(\"Sending email [${email.getSubject()}] from ${email.getFromAddress()} to ${email.getToAddresses()} cc ${email.getCcAddresses()} bcc ${email.getBccAddresses()} via ${emailServer.mailUsername}@${email.getHostName()}:${email.getSmtpPort()} SSL? ${email.isSSLOnConnect()}:${email.isSSLCheckServerIdentity()} StartTLS? ${email.isStartTLSEnabled()}:${email.isStartTLSRequired()}\")\n    if (logger.traceEnabled) logger.trace(\"Sending email [${email.getSubject()}] to ${email.getToAddresses()} with bodyHtml:\\n${bodyHtml}\\nbodyText:\\n${bodyText}\")\n    // email.setDebug(true)\n\n    // send the email\n    try {\n        messageId = email.send()\n        // if we created an EmailMessage record update it now with the messageId\n        if (emailMessageId) {\n            ec.service.sync().name(\"update\", \"moqui.basic.email.EmailMessage\").requireNewTransaction(true)\n                    .parameters([emailMessageId:emailMessageId, sentDate:ec.user.nowTimestamp, statusId:\"ES_SENT\", messageId:messageId])\n                    .disableAuthz().call()\n        }\n    } catch (Throwable t) {\n        logger.error(\"Error in sendEmailTemplate\", t)\n        ec.message.addMessage(\"Error sending email: ${t.toString()}\")\n    }\n\n    return\n} catch (Throwable t) {\n    logger.error(\"Error in sendEmailTemplate\", t)\n    ec.message.addMessage(\"Error sending email: ${t.toString()}\")\n    // don't rethrow: throw new BaseArtifactException(\"Error in sendEmailTemplate\", t)\n}\n\nstatic void renderScreenAttachment(EntityValue emailTemplate, EntityValue emailTemplateAttachment, HtmlEmail email, ExecutionContextImpl ec, Logger logger) {\n    renderScreenAttachment(emailTemplate, email, ec, logger, (String) emailTemplateAttachment.fileName,\n            (String) emailTemplateAttachment.screenRenderMode, (String) emailTemplateAttachment.attachmentLocation,\n            (String) emailTemplateAttachment.screenPath, null)\n}\nstatic void renderScreenAttachment(EntityValue emailTemplate, HtmlEmail email, ExecutionContextImpl ec, Logger logger,\n        String filename, String renderMode, String attachmentLocation, String screenPath, String contentType) {\n\n    if (!filename) {\n        String extension = renderMode == \"xsl-fo\" ? \"pdf\" : renderMode\n        filename = attachmentLocation.substring(attachmentLocation.lastIndexOf(\"/\")+1, attachmentLocation.length()-4) + \".\" + extension\n    }\n    String filenameExp = ec.resource.expand(filename, null)\n\n    String webappName = (String) emailTemplate.webappName ?: \"webroot\"\n    String webHostName = (String) emailTemplate.webHostName\n\n    def attachmentRender\n    if (screenPath == null || screenPath.isEmpty()) {\n        attachmentRender = ec.screen.makeRender().rootScreen(attachmentLocation).webappName(webappName).renderMode(renderMode)\n    } else {\n        attachmentRender = ec.screen.makeRender().webappName(webappName).rootScreenFromHost(webHostName ?: \"localhost\")\n                .screenPath(screenPath).renderMode(renderMode).lastStandalone(\"true\")\n    }\n\n    if (ec.screenFacade.isRenderModeText(renderMode)) {\n        String attachmentText = attachmentRender.render()\n        if (attachmentText == null) return\n        if (attachmentText.trim().length() == 0) return\n\n        if (renderMode == \"xsl-fo\") {\n            // use ResourceFacade.xslFoTransform() to change to PDF, then attach that\n            try {\n                ByteArrayOutputStream baos = new ByteArrayOutputStream()\n                ec.resource.xslFoTransform(new StreamSource(new StringReader(attachmentText)), null, baos, \"application/pdf\")\n                email.attach(new ByteArrayDataSource(baos.toByteArray(), \"application/pdf\"), filenameExp, \"\")\n            } catch (Exception e) {\n                logger.warn(\"Error generating PDF from XSL-FO: ${e.toString()}\")\n            }\n        } else {\n            String mimeType = contentType ?: ec.screenFacade.getMimeTypeByMode(renderMode)\n            DataSource dataSource = new ByteArrayDataSource(attachmentText, mimeType)\n            email.attach(dataSource, filenameExp, \"\")\n        }\n    } else {\n        ByteArrayOutputStream baos = new ByteArrayOutputStream()\n        attachmentRender.render(baos)\n\n        String mimeType = contentType ?: ec.screenFacade.getMimeTypeByMode(renderMode)\n        DataSource dataSource = new ByteArrayDataSource(baos.toByteArray(), mimeType)\n        email.attach(dataSource, filenameExp, \"\")\n    }\n}\n\nstatic boolean isDomainAllowed(String emailAddress, ArrayList<String> toDomainList) {\n    if (emailAddress == null || emailAddress.isEmpty()) return false\n    boolean domainAllowed = true\n    if (toDomainList != null && !toDomainList.isEmpty()) {\n        domainAllowed = false\n        int atIndex = emailAddress.indexOf(\"@\")\n        if (atIndex == -1) return false\n        String emailDomain = emailAddress.substring(atIndex + 1, emailAddress.length())\n\n        for (toDomain in toDomainList) {\n            if (emailDomain.endsWith(toDomain)) {\n                domainAllowed = true\n                break\n            }\n        }\n    }\n    return domainAllowed\n}\n"
  },
  {
    "path": "framework/src/main/resources/shiro.ini",
    "content": "# =======================\n# Shiro INI configuration\n# =======================\n\n[main]\n\n# for conf details see: http://shiro.apache.org/session-management.html\n# before enabling this make sure shiro-ehcache is included in framework/build.gradle\n# ehcacheManager = org.apache.shiro.cache.ehcache.EhCacheManager\n\n# NOTE: no credentialsMatcher set here, configured in Moqui conf file (moqui-conf.user-facade.password.@encrypt-hash-type)\nmoquiRealm = org.moqui.impl.util.MoquiShiroRealm\n\n# securityManager.cacheManager = $ehcacheManager\nsecurityManager.realms = $moquiRealm\n"
  },
  {
    "path": "framework/src/main/webapp/WEB-INF/web.xml",
    "content": "<?xml version=\"1.0\"?>\n<web-app version=\"6.1\"\n         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n         xmlns=\"https://jakarta.ee/xml/ns/jakartaee\"\n         xsi:schemaLocation=\"https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_1.xsd\">\n    <display-name>Moqui Root Webapp</display-name>\n\n    <context-param>\n        <description>The name of the Moqui webapp used to lookup configuration in the moqui-conf.webapp-list.webapp.@moqui-name attribute.</description>\n        <param-name>moqui-name</param-name><param-value>webroot</param-value>\n    </context-param>\n\n    <!-- Moqui Context Listener (necessary to init Moqui, etc) -->\n    <listener><listener-class>org.moqui.impl.webapp.MoquiContextListener</listener-class></listener>\n\n    <!-- Apache Commons FileUpload Cleanup; this must be configured here as it is a ServletContextListener -->\n    <listener><listener-class>org.apache.commons.fileupload2.jakarta.servlet6.JakartaFileCleaner</listener-class></listener>\n\n    <session-config>\n        <!-- session timeout in minutes; note that this may be overridden with webapp.session-config.@timeout in the Moqui Conf XML file -->\n        <session-timeout>60</session-timeout>\n        <cookie-config><http-only>true</http-only><comment>__SAME_SITE_LAX__</comment></cookie-config>\n        <tracking-mode>COOKIE</tracking-mode>\n    </session-config>\n</web-app>\n"
  },
  {
    "path": "framework/src/start/java/MoquiStart.java",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\nimport java.io.BufferedOutputStream;\nimport java.io.DataInputStream;\nimport java.io.File;\nimport java.io.FileOutputStream;\nimport java.io.IOException;\nimport java.io.InputStream;\nimport java.lang.reflect.Array;\nimport java.lang.reflect.Method;\nimport java.net.MalformedURLException;\nimport java.net.URL;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.security.CodeSource;\nimport java.security.ProtectionDomain;\nimport java.security.cert.Certificate;\nimport java.util.ArrayList;\nimport java.util.Arrays;\nimport java.util.Collections;\nimport java.util.Comparator;\nimport java.util.Enumeration;\nimport java.util.HashMap;\nimport java.util.HashSet;\nimport java.util.LinkedHashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.Properties;\nimport java.util.Set;\nimport java.util.TreeSet;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.jar.Attributes;\nimport java.util.jar.JarEntry;\nimport java.util.jar.JarFile;\nimport java.util.jar.Manifest;\n\n/** This start class implements a ClassLoader and supports loading jars within a jar or war file in order to facilitate\n * an executable war file. To do this it overrides the findResource, findResources, and loadClass methods of the\n * ClassLoader class.\n *\n * The best source for research on the topic seems to be at http://www.jdotsoft.com, with a lot of good comments in the\n * JarClassLoader source file there.\n */\npublic class MoquiStart {\n    // this default is for development and is here instead of having a buried properties file that might cause conflicts when trying to override\n    private static final String defaultConf = \"conf/MoquiDevConf.xml\";\n    private static final String tempDirName = \"execwartmp\";\n    private static final boolean reportJarsUnused = Boolean.valueOf(System.getProperty(\"report.jars.unused\", \"false\"));\n    // private final static boolean reportJarsUnused = true;\n\n    public static void main(String[] args) throws IOException {\n        // now grab the first arg and see if it is a known command\n        String firstArg = args.length > 0 ? args[0] : \"\";\n\n        // make a list of arguments\n        List<String> argList = Arrays.asList(args);\n        Map<String, String> argMap = new LinkedHashMap<>();\n        for (String arg: argList) {\n            // run twice to allow one or two dashes\n            if (arg.startsWith(\"-\")) arg = arg.substring(1);\n            if (arg.startsWith(\"-\")) arg = arg.substring(1);\n            if (arg.contains(\"=\")) {\n                argMap.put(arg.substring(0, arg.indexOf(\"=\")), arg.substring(arg.indexOf(\"=\")+1));\n            } else {\n                argMap.put(arg, \"\");\n            }\n        }\n\n        if (firstArg.endsWith(\"help\") || \"-?\".equals(firstArg)) {\n            // setup the class loader\n            StartClassLoader moquiStartLoader = new StartClassLoader(true);\n            Thread.currentThread().setContextClassLoader(moquiStartLoader);\n            Runtime.getRuntime().addShutdownHook(new MoquiShutdown(null, null, moquiStartLoader));\n            initSystemProperties(moquiStartLoader, false, argMap);\n\n            /* nice for debugging, messy otherwise:\n            System.out.println(\"Internal Class Path Jars:\");\n            for (JarFile jf: moquiStartLoader.jarFileList) {\n                String fn = jf.getName();\n                System.out.println(fn.contains(\"moqui_temp\") ? fn.substring(fn.indexOf(\"moqui_temp\")) : fn);\n            }\n            */\n            System.out.println(\"------------------------------------------------\");\n            System.out.println(\"Current runtime directory (moqui.runtime): \" + System.getProperty(\"moqui.runtime\"));\n            System.out.println(\"Current configuration file (moqui.conf): \" + System.getProperty(\"moqui.conf\"));\n            System.out.println(\"To set these properties use something like: java -Dmoqui.conf=conf/MoquiProductionConf.xml -jar moqui.war ...\");\n            System.out.println(\"------------------------------------------------\");\n            System.out.println(\"Executable WAR : java -jar moqui.war [command] [arguments]\");\n            System.out.println(\"Expanded WAR   : java -cp . MoquiStart [command] [arguments]\");\n            System.out.println(\"help, -? ---- Help (this text)\");\n            System.out.println(\"load -------- Run data loader\");\n            System.out.println(\"    types=<type>[,<type>] ------- Data types to load (can be anything, common are: seed, seed-initial, install, demo, ...)\");\n            System.out.println(\"    components=<name>[,<name>] -- Component names to load for data types; if none specified loads from all\");\n            System.out.println(\"    location=<location> --------- Location of data file to load\");\n            System.out.println(\"    timeout=<seconds> ----------- Transaction timeout for each file, defaults to 600 seconds (10 minutes)\");\n            System.out.println(\"    no-fk-create ---------------- Don't create foreign-keys, for empty database to avoid referential integrity errors\");\n            System.out.println(\"    dummy-fks ------------------- Use dummy foreign-keys to avoid referential integrity errors\");\n            System.out.println(\"    use-try-insert -------------- Try insert and update on error instead of checking for record first\");\n            System.out.println(\"    disable-eeca ---------------- Disable Entity ECA rules\");\n            System.out.println(\"    disable-audit-log ----------- Disable Entity Audit Log\");\n            System.out.println(\"    disable-data-feed ----------- Disable Entity DataFeed\");\n            System.out.println(\"    raw ------------------------- For raw data load to an empty database; short for no-fk-create, use-try-insert, disable-eeca, disable-audit-log, disable-data-feed\");\n            System.out.println(\"    conf=<moqui.conf> ----------- The Moqui Conf XML file to use, overrides other ways of specifying it\");\n            System.out.println(\"    no-run-es ------------------- Don't Try starting and stopping ElasticSearch in runtime/elasticsearch\");\n            System.out.println(\"    If no -types or -location argument is used all known data files of all types will be loaded.\");\n            System.out.println(\"[default] ---- Run embedded Jetty server\");\n            System.out.println(\"    port=<port> ---------------- The http listening port. Default is 8080\");\n            System.out.println(\"    threads=<max threads> ------ Maximum number of threads. Default is 100\");\n            System.out.println(\"    conf=<moqui.conf> ---------- The Moqui Conf XML file to use, overrides other ways of specifying it\");\n            System.out.println(\"    no-run-es ------------------- Don't Try starting and stopping OpenSearch in runtime/opensearch or ElasticSearch in runtime/elasticsearch\");\n            System.out.println(\"\");\n            System.exit(0);\n        }\n\n        boolean isInWar = true;\n        try {\n            ProtectionDomain pd = MoquiStart.class.getProtectionDomain();\n            CodeSource cs = pd.getCodeSource();\n            URL wrapperUrl = cs.getLocation();\n            File wrapperFile = new File(wrapperUrl.toURI());\n            if (wrapperFile.isDirectory()) isInWar = false;\n            /* to accommodate an executable start.jar file inside the executable WAR file:\n            if (isInWar && wrapperFile.getName().equals(\"start.jar\")) {\n                isInWar = false;\n                // wrapperFile = wrapperFile.getParentFile();\n            }\n            */\n        } catch (Exception e) {\n            System.out.println(\"Error checking class wrapper: \" + e.toString());\n        }\n\n        // if doing anything other than help make sure temp dir deleted\n        if (isInWar) {\n            File tempDir = new File(tempDirName);\n            System.out.println(\"Using temporary directory: \" + tempDir.getCanonicalPath());\n            if (tempDir.exists()) {\n                System.out.println(\"Found temporary directory \" + tempDirName + \", deleting\");\n                try {\n                    Files.walk(tempDir.toPath())\n                            .sorted(Comparator.reverseOrder())\n                            .map(Path::toFile)\n                            .forEach(File::delete);\n                } catch (IOException e) {\n                    System.out.println(\"Error deleting temp directory \" + tempDirName + \": \" + e);\n                }\n            }\n        }\n\n        // run load if is first argument\n        if (firstArg.endsWith(\"load\")) {\n            StartClassLoader moquiStartLoader = new StartClassLoader(true);\n            Thread.currentThread().setContextClassLoader(moquiStartLoader);\n            // Runtime.getRuntime().addShutdownHook(new MoquiShutdown(null, null, moquiStartLoader));\n            initSystemProperties(moquiStartLoader, false, argMap);\n            Process esProcess = argMap.containsKey(\"no-run-es\") ? null : checkStartElasticSearch();\n\n            boolean successfullLoad = true;\n            try {\n                System.out.println(\"Loading data with args \" + argMap);\n                Class<?> c = moquiStartLoader.loadClass(\"org.moqui.Moqui\");\n                Method m = c.getMethod(\"loadData\", Map.class);\n                m.invoke(null, argMap);\n            } catch (Throwable e) {\n                successfullLoad = false;\n                System.out.println(\"Error loading or running Moqui.loadData with args [\" + argMap + \"]: \" + e.toString());\n                e.printStackTrace();\n            } finally {\n                checkStopElasticSearch(esProcess);\n                System.exit(successfullLoad ? 0 : 1);\n            }\n        }\n\n        // ===== Done trying specific commands, so load the embedded server\n\n        // Get a start loader with loadWebInf=false since the container will load those we don't want to here (would be on classpath twice)\n        // NOTE DEJ20210520: now always using StartClassLoader because of breaking classloader changes in 9.4.37 (likely from https://github.com/eclipse/jetty.project/pull/5894)\n        StartClassLoader moquiStartLoader = new StartClassLoader(true);\n        Thread.currentThread().setContextClassLoader(moquiStartLoader);\n\n        // NOTE: not using MoquiShutdown hook any more, let Jetty stop everything\n        //   may need to add back for jar file close, cleaner delete on exit\n        // Thread shutdownHook = new MoquiShutdown(null, null, moquiStartLoader);\n        // shutdownHook.setDaemon(true);\n        // Runtime.getRuntime().addShutdownHook(shutdownHook);\n\n        initSystemProperties(moquiStartLoader, false, argMap);\n        String runtimePath = System.getProperty(\"moqui.runtime\");\n\n        Process esProcess = argMap.containsKey(\"no-run-es\") ? null : checkStartElasticSearch();\n        if (esProcess != null) {\n            Thread shutdownHook = new ElasticShutdown(esProcess);\n            shutdownHook.setDaemon(true);\n            Runtime.getRuntime().addShutdownHook(shutdownHook);\n        }\n\n        try {\n            int port = 8080;\n            String portStr = argMap.get(\"port\");\n            if (portStr != null && portStr.length() > 0) port = Integer.parseInt(portStr);\n            int threads = 100;\n            String threadsStr = argMap.get(\"threads\");\n            if (threadsStr != null && threadsStr.length() > 0) threads = Integer.parseInt(threadsStr);\n\n            System.out.println(\"Running Jetty server on port \" + port + \" max threads \" + threads + \" with args [\" + argMap + \"]\");\n\n            Class<?> serverClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.server.Server\");\n            Class<?> handlerClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.server.Handler\");\n            Class<?> sizedThreadPoolClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.util.thread.ThreadPool$SizedThreadPool\");\n\n            Class<?> httpConfigurationClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.server.HttpConfiguration\");\n            Class<?> forwardedRequestCustomizerClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.server.ForwardedRequestCustomizer\");\n            Class<?> customizerClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.server.HttpConfiguration$Customizer\");\n\n            Class<?> sessionIdManagerClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.session.SessionIdManager\");\n            Class<?> sessionManagerClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.session.SessionManager\");\n            Class<?> sessionHandlerClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.ee11.servlet.SessionHandler\");\n            Class<?> defaultSessionIdManagerClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.session.DefaultSessionIdManager\");\n            Class<?> sessionCacheClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.session.SessionCache\");\n            Class<?> sessionCacheFactoryClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.session.DefaultSessionCacheFactory\");\n            Class<?> sessionDataStoreClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.session.SessionDataStore\");\n            Class<?> fileSessionDataStoreClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.session.FileSessionDataStore\");\n\n            Class<?> connectorClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.server.Connector\");\n            Class<?> serverConnectorClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.server.ServerConnector\");\n            Class<?> webappClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.ee11.webapp.WebAppContext\");\n\n            Class<?> connectionFactoryClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.server.ConnectionFactory\");\n            Class<?> connectionFactoryArrayClass = Array.newInstance(connectionFactoryClass, 1).getClass();\n            Class<?> httpConnectionFactoryClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.server.HttpConnectionFactory\");\n\n            Class<?> scHandlerClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.ee11.servlet.ServletContextHandler\");\n            Class<?> wsInitializerClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.ee11.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer\");\n            Class<?> wsInitializerConfiguratorClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.ee11.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer$Configurator\");\n\n            Class<?> gzipHandlerClass = moquiStartLoader.loadClass(\"org.eclipse.jetty.server.handler.gzip.GzipHandler\");\n\n            Object server = serverClass.getConstructor().newInstance();\n            Object httpConfig = httpConfigurationClass.getConstructor().newInstance();\n\n            // add ForwardedRequestCustomizer to handle Forwarded and X-Forwarded-* HTTP Request Headers\n            // see https://javadoc.jetty.org/jetty-12.1/org/eclipse/jetty/server/ForwardedRequestCustomizer.html\n            // NOTE: this is the only way Jetty knows about HTTPS/SSL so is needed, but the problem is these headers\n            //     are easily spoofed; this isn't too bad for X-Proxied-Https and X-Forwarded-Proto, and those are needed\n            // TODO: at least find some way to skip X-Forwarded-For: current behavior with new client-ip-header setting\n            //     is it will use that but if no client IP found that way it gets it from Jetty, which gets it from X-Forwarded-For, opening to spoofing\n            Object forwardedRequestCustomizer = forwardedRequestCustomizerClass.getConstructor().newInstance();\n            httpConfigurationClass.getMethod(\"addCustomizer\", customizerClass).invoke(httpConfig, forwardedRequestCustomizer);\n\n            Object httpConnectionFactory = httpConnectionFactoryClass.getConstructor(httpConfigurationClass).newInstance(httpConfig);\n            Object connectionFactoryArray = Array.newInstance(connectionFactoryClass, 1);\n            Array.set(connectionFactoryArray, 0, httpConnectionFactory);\n            Object httpConnector = serverConnectorClass.getConstructor(serverClass, connectionFactoryArrayClass).newInstance(server, connectionFactoryArray);\n            serverConnectorClass.getMethod(\"setPort\", int.class).invoke(httpConnector, port);\n\n            serverClass.getMethod(\"addConnector\", connectorClass).invoke(server, httpConnector);\n\n            // SessionDataStore\n            File storeDir = new File(runtimePath + \"/sessions\");\n            if (!storeDir.exists()) storeDir.mkdirs();\n            System.out.println(\"Creating Jetty FileSessionDataStore with directory \" + storeDir.getCanonicalPath());\n            Object sessionHandler = sessionHandlerClass.getConstructor().newInstance();\n            sessionHandlerClass.getMethod(\"setServer\", serverClass).invoke(sessionHandler, server);\n            Object sessionCacheFactory = sessionCacheFactoryClass.getConstructor().newInstance();\n            Object sessionCache = sessionCacheFactoryClass.getMethod(\"newSessionCache\", sessionManagerClass).invoke(sessionCacheFactory, sessionHandler);\n            Object sessionDataStore = fileSessionDataStoreClass.getConstructor().newInstance();\n            fileSessionDataStoreClass.getMethod(\"setStoreDir\", File.class).invoke(sessionDataStore, storeDir);\n            fileSessionDataStoreClass.getMethod(\"setDeleteUnrestorableFiles\", boolean.class).invoke(sessionDataStore, true);\n            sessionCacheClass.getMethod(\"setSessionDataStore\", sessionDataStoreClass).invoke(sessionCache, sessionDataStore);\n            sessionHandlerClass.getMethod(\"setSessionCache\", sessionCacheClass).invoke(sessionHandler, sessionCache);\n            Object sidMgr = defaultSessionIdManagerClass.getConstructor(serverClass).newInstance(server);\n            defaultSessionIdManagerClass.getMethod(\"setServer\", serverClass).invoke(sidMgr, server);\n            sessionHandlerClass.getMethod(\"setSessionIdManager\", sessionIdManagerClass).invoke(sessionHandler, sidMgr);\n            serverClass.getMethod(\"addBean\", Object.class).invoke(server, sidMgr);\n\n            // WebApp\n            Object webapp = webappClass.getConstructor().newInstance();\n\n            webappClass.getMethod(\"setContextPath\", String.class).invoke(webapp, \"/\");\n            webappClass.getMethod(\"setServer\", serverClass).invoke(webapp, server);\n            webappClass.getMethod(\"setSessionHandler\", sessionHandlerClass).invoke(webapp, sessionHandler);\n            webappClass.getMethod(\"setMaxFormKeys\", int.class).invoke(webapp, 5000);\n            if (isInWar) {\n                webappClass.getMethod(\"setWar\", String.class).invoke(webapp, moquiStartLoader.wrapperUrl.toExternalForm());\n                webappClass.getMethod(\"setTempDirectory\", File.class).invoke(webapp, new File(tempDirName + \"/ROOT\"));\n            } else {\n                webappClass.getMethod(\"setBaseResourceAsString\", String.class).invoke(webapp, moquiStartLoader.wrapperUrl.toExternalForm());\n            }\n            webappClass.getMethod(\"setClassLoader\", ClassLoader.class).invoke(webapp, moquiStartLoader);\n\n            // handle webapp_session_cookie_max_age with setInitParameter (1209600 seconds is about 2 weeks 60 * 60 * 24 * 14)\n            String sessionMaxAge = System.getenv(\"webapp_session_cookie_max_age\");\n            if (sessionMaxAge != null && !sessionMaxAge.isEmpty()) {\n                Integer maxAgeInt = null;\n                try { maxAgeInt = Integer.parseInt(sessionMaxAge); }\n                catch (Exception e) { System.out.println(\"Found webapp_session_cookie_max_age env var with invalid number, ignoring: \" + sessionMaxAge); }\n\n                if (maxAgeInt != null) {\n                    System.out.println(\"Setting Servlet Session Max Age based on webapp_session_cookie_max_age \" + maxAgeInt);\n                    webappClass.getMethod(\"setInitParameter\", String.class, String.class)\n                            .invoke(webapp, \"org.eclipse.jetty.servlet.MaxAge\", maxAgeInt.toString());\n                }\n            }\n\n            // WebSocket\n            Object wsContainer = wsInitializerClass.getMethod(\"configure\", scHandlerClass, wsInitializerConfiguratorClass).invoke(null, webapp, null);\n            webappClass.getMethod(\"setAttribute\", String.class, Object.class).invoke(webapp, \"jakarta.websocket.server.ServerContainer\", wsContainer);\n\n            // GzipHandler\n            Object gzipHandler = gzipHandlerClass.getConstructor().newInstance();\n            // use defaults, should include all except certain excludes:\n            // gzipHandlerClass.getMethod(\"setIncludedMimeTypes\", String[].class).invoke(gzipHandler, new Object[] { new String[] {\"text/html\", \"text/plain\", \"text/xml\", \"text/css\", \"application/javascript\", \"text/javascript\"} });\n            gzipHandlerClass.getMethod(\"setHandler\", handlerClass).invoke(gzipHandler, webapp);\n            serverClass.getMethod(\"setHandler\", handlerClass).invoke(server, gzipHandler);\n\n            // Log getMinThreads, getMaxThreads\n            Object threadPool = serverClass.getMethod(\"getThreadPool\").invoke(server);\n            sizedThreadPoolClass.getMethod(\"setMaxThreads\", int.class).invoke(threadPool, threads);\n            int minThreads = (int) sizedThreadPoolClass.getMethod(\"getMinThreads\").invoke(threadPool);\n            int maxThreads = (int) sizedThreadPoolClass.getMethod(\"getMaxThreads\").invoke(threadPool);\n            System.out.println(\"Jetty min threads \" + minThreads + \", max threads \" + maxThreads);\n\n            // Tell Jetty to stop on JVM shutdown\n            serverClass.getMethod(\"setStopAtShutdown\", boolean.class).invoke(server, true);\n            serverClass.getMethod(\"setStopTimeout\", long.class).invoke(server, 30000L);\n\n            // Start\n            serverClass.getMethod(\"start\").invoke(server);\n            serverClass.getMethod(\"join\").invoke(server);\n\n            /*\n               Jetty 12 / Jakarta EE 11 notes:\n               - SessionIdManager is server-scoped and must be registered as a Server bean.\n               - SessionHandler discovers the SessionIdManager automatically.\n               - Handler hierarchy:\n                 Server\n                  └── GzipHandler\n                      └── WebAppContext\n                          └── SessionHandler\n\n            The classpath dependent code we are running:\n\n            Server server = new Server();\n            HttpConfiguration httpConfig = new HttpConfiguration();\n            httpConfig.addCustomizer(new ForwardedRequestCustomizer());\n            HttpConnectionFactory httpConnectionFactory = new HttpConnectionFactory(httpConfig);\n            ServerConnector httpConnector = new ServerConnector(server, httpConnectionFactory);\n            httpConnector.setPort(port);\n            server.addConnector(httpConnector);\n            File storeDir = new File(runtimePath + \"/sessions\");\n            storeDir.mkdirs();\n            SessionHandler sessionHandler = new SessionHandler();\n            sessionHandler.setServer(server);\n            DefaultSessionCacheFactory sessionCacheFactory = new DefaultSessionCacheFactory();\n            SessionCache sessionCache = sessionCacheFactory.newSessionCache(sessionHandler);\n            FileSessionDataStore sessionDataStore = new FileSessionDataStore();\n            sessionDataStore.setStoreDir(storeDir);\n            sessionDataStore.setDeleteUnrestorableFiles(true);\n            sessionCache.setSessionDataStore(sessionDataStore);\n            sessionHandler.setSessionCache(sessionCache);\n            SessionIdManager sessionIdManager = new DefaultSessionIdManager(server);\n            server.addBean(sessionIdManager);\n            sessionHandler.setSessionIdManager(sessionIdManager);\n            WebAppContext webapp = new WebAppContext();\n            webapp.setContextPath(\"/\");\n            webapp.setServer(server);\n            webapp.setSessionHandler(sessionHandler);\n            webapp.setMaxFormKeys(5000);\n            if (isInWar) {\n                webapp.setWar(moquiStartLoader.wrapperUrl.toExternalForm());\n                webapp.setTempDirectory(new File(\"execwartmp/ROOT\"));\n            } else {\n                webapp.setBaseResourceAsString(moquiStartLoader.wrapperUrl.toExternalForm());\n            }\n            webapp.setClassLoader(moquiStartLoader);\n            String sessionMaxAge = System.getenv(\"webapp_session_cookie_max_age\");\n            if (sessionMaxAge != null && !sessionMaxAge.isEmpty()) {\n                try {\n                    Integer maxAgeInt = Integer.parseInt(sessionMaxAge);\n                    webapp.setInitParameter(\"org.eclipse.jetty.servlet.MaxAge\", maxAgeInt.toString());\n                } catch (Exception ignored) {}\n            }\n            ServerContainer wsContainer = JakartaWebSocketServletContainerInitializer.configure(webapp, null);\n            webapp.setAttribute(\"jakarta.websocket.server.ServerContainer\", wsContainer);\n            GzipHandler gzipHandler = new GzipHandler();\n            gzipHandler.setHandler(webapp);\n            server.setHandler(gzipHandler);\n            ThreadPool.SizedThreadPool threadPool = (ThreadPool.SizedThreadPool) server.getThreadPool();\n            threadPool.setMaxThreads(threads);\n            server.setStopAtShutdown(true);\n            server.setStopTimeout(30000L);\n            server.start();\n            // The use of server.join() the will make the current thread join and\n            // wait until the server is done executing.\n            // See http://docs.oracle.com/javase/7/docs/api/java/lang/Thread.html#join()\n            server.join();\n            */\n        } catch (Exception e) {\n            System.out.println(\"Error loading or running Jetty embedded server with args [\" + argMap + \"]: \" + e.toString());\n            e.printStackTrace();\n        }\n\n        // now wait for break...\n    }\n\n    private static void initSystemProperties(StartClassLoader cl, boolean useProperties, Map<String, String> argMap) throws IOException {\n        Properties moquiInitProperties = null;\n        if (useProperties) {\n            moquiInitProperties = new Properties();\n            URL initProps = cl.getResource(\"MoquiInit.properties\");\n            if (initProps != null) { InputStream is = initProps.openStream(); moquiInitProperties.load(is); is.close(); }\n        }\n\n        // before doing anything else make sure the moqui.runtime system property exists (needed for config of various things)\n        String runtimePath = System.getProperty(\"moqui.runtime\");\n        if (runtimePath != null && runtimePath.length() > 0)\n            System.out.println(\"Determined runtime from Java system property: \" + runtimePath);\n        if (moquiInitProperties != null && (runtimePath == null || runtimePath.length() == 0)) {\n            runtimePath = moquiInitProperties.getProperty(\"moqui.runtime\");\n            if (runtimePath != null && runtimePath.length() > 0)\n                System.out.println(\"Determined runtime from MoquiInit.properties file: \" + runtimePath);\n        }\n        if (runtimePath == null || runtimePath.length() == 0) {\n            // see if runtime directory under the current directory exists, if not default to the current directory\n            File testFile = new File(\"runtime\");\n            if (testFile.exists()) runtimePath = \"runtime\";\n            if (runtimePath != null && runtimePath.length() > 0)\n                System.out.println(\"Determined runtime from existing runtime subdirectory: \" + testFile.getCanonicalPath());\n        }\n        if (runtimePath == null || runtimePath.length() == 0) {\n            runtimePath = \".\";\n            System.out.println(\"Determined runtime by defaulting to current directory: \" + runtimePath);\n        }\n        File runtimeFile = new File(runtimePath);\n        runtimePath = runtimeFile.getCanonicalPath();\n        System.out.println(\"Canonicalized runtimePath: \" + runtimePath);\n        if (runtimePath.endsWith(\"/\")) runtimePath = runtimePath.substring(0, runtimePath.length()-1);\n        System.setProperty(\"moqui.runtime\", runtimePath);\n\n        /* Don't do this here... loads as lower-level that WEB-INF/lib jars and so can't have dependencies on those,\n            and dependencies on those are necessary\n        // add runtime/lib jar files to the class loader\n        File runtimeLibFile = new File(runtimePath + \"/lib\");\n        for (File jarFile: runtimeLibFile.listFiles()) {\n            if (jarFile.getName().endsWith(\".jar\")) cl.jarFileList.add(new JarFile(jarFile));\n        }\n        */\n\n        String confPath = argMap.get(\"conf\");\n        if (confPath != null && !confPath.isEmpty()) System.out.println(\"Determined conf from conf argument: \" + confPath);\n        if (confPath == null || confPath.isEmpty()) {\n            confPath = System.getProperty(\"moqui.conf\");\n            if (confPath != null && !confPath.isEmpty()) System.out.println(\"Determined conf from Java system property: \" + confPath);\n        }\n        if (moquiInitProperties != null && (confPath == null || confPath.isEmpty())) {\n            confPath = moquiInitProperties.getProperty(\"moqui.conf\");\n            if (confPath != null && !confPath.isEmpty()) System.out.println(\"Determined conf from MoquiInit.properties file: \" + confPath);\n        }\n        if (confPath == null || confPath.isEmpty()) {\n            File testFile = new File(runtimePath + \"/\" + defaultConf);\n            if (testFile.exists()) confPath = defaultConf;\n            System.out.println(\"Determined conf by default (dev conf file): \" + confPath);\n        }\n        if (confPath != null && !confPath.isEmpty()) System.setProperty(\"moqui.conf\", confPath);\n    }\n\n    private static Process checkStartElasticSearch() {\n        String runtimePath = System.getProperty(\"moqui.runtime\");\n        File osDir = new File(runtimePath + \"/opensearch\");\n        boolean osDirExists = osDir.exists();\n        String baseName = osDirExists ? \"opensearch\" : \"elasticsearch\";\n        String workDir = runtimePath + \"/\" + baseName;\n        if (!new File(workDir + \"/bin\").exists()) return null;\n        if (new File(workDir + \"/pid\").exists()) {\n            System.out.println((osDirExists ? \"OpenSearch\" : \"ElasticSearch\") + \" install found in \" + workDir + \", pid file found so not starting\");\n            return null;\n        }\n        String javaHome = System.getProperty(\"java.home\");\n        System.out.println(\"Starting \" + (osDirExists ? \"OpenSearch\" : \"ElasticSearch\") + \" install found in \" + workDir + \", pid file not found (JDK: \" + javaHome + \")\");\n\n        String os = System.getProperty(\"os.name\").toLowerCase();\n        boolean isWindows = os.startsWith(\"windows\");\n        boolean isMac = os.startsWith(\"mac\");\n        boolean isLinux = os.contains(\"nix\") || os.contains(\"nux\") || os.contains(\"aix\");\n\n        try {\n            String[] command;\n            if (isWindows) {\n                command = new String[] {\"cmd.exe\", \"/c\", \"bin\\\\\" + baseName + \".bat\"};\n            } else {\n                command = new String[]{\"./bin/\" + baseName};\n                try {\n                    boolean elasticsearchOwner = Files.getOwner(Paths.get(runtimePath, baseName)).getName().equals(baseName);\n                    boolean suAble = false;\n                    if (isLinux) {\n                        suAble = Runtime.getRuntime().exec(new String[]{\"/bin/su\", \"-c\", \"/bin/true\", baseName}).waitFor() == 0;\n                    } else if(isMac) {\n                        suAble = Runtime.getRuntime().exec(new String[]{\"/usr/bin/sudo\", \"-n\", \"/usr/bin/true\"}).waitFor() == 0;\n                    }\n                    if (elasticsearchOwner && suAble) command = new String[]{\"su\", \"-c\", \"./bin/\" + baseName, baseName};\n                } catch (IOException e) {\n                    System.out.println(\"Error to run \" + (Arrays.toString(new String[]{\"/usr/bin/sudo\", \"-n\", \"/usr/bin/true\"})) + \": \" + e.getMessage());\n                }\n            }\n            ProcessBuilder pb = new ProcessBuilder(command);\n            pb.redirectErrorStream(true);\n            pb.directory(new File(workDir));\n            pb.environment().put(\"JAVA_HOME\", javaHome);\n            pb.inheritIO();\n            Process esProcess = pb.start();\n            System.setProperty(\"moqui.elasticsearch.started\", \"true\");\n            return esProcess;\n        } catch (Exception e) {\n            System.out.println(\"Error starting \" + (osDirExists ? \"OpenSearch\" : \"ElasticSearch\") + \" in \" + workDir + \": \" + e);\n            return null;\n        }\n    }\n    private static void checkStopElasticSearch(Process esProcess) {\n        if (esProcess != null) esProcess.destroy();\n    }\n    private static class ElasticShutdown extends Thread {\n        final Process esProcess;\n        ElasticShutdown(Process esProcess) { super(); this.esProcess = esProcess; }\n        @Override public void run() { esProcess.destroy(); }\n    }\n\n    private static class MoquiShutdown extends Thread {\n        final Method callMethod;\n        final Object callObject;\n        final StartClassLoader moquiStart;\n        MoquiShutdown(Method callMethod, Object callObject, StartClassLoader moquiStart) {\n            super();\n            this.callMethod = callMethod;\n            this.callObject = callObject;\n            this.moquiStart = moquiStart;\n        }\n        @Override\n        public void run() {\n            // run this first, ie shutdown the container before closing jarFiles to avoid errors with classes missing\n            if (callMethod != null) {\n                try { callMethod.invoke(callObject); } catch (Exception e) { System.out.println(\"Error in shutdown: \" + e.toString()); }\n            }\n\n            // give things a couple seconds to destroy; this way of running is mostly for dev/test where this should be sufficient\n            try { synchronized (this) { this.wait(2000); } } catch (Exception e) { System.out.println(\"Shutdown wait interrupted\"); }\n            System.out.println(\"========== Shutting down Moqui Executable (closing jars, etc) ==========\");\n\n            // close all jarFiles so they will \"deleteOnExit\"\n            for (JarFile jarFile : moquiStart.jarFileList) {\n                try {\n                    jarFile.close();\n                } catch (IOException e) {\n                    System.out.println(\"Error closing jar [\" + jarFile + \"]: \" + e.toString());\n                }\n            }\n\n            if (reportJarsUnused) {\n                Set<String> sortedJars = new TreeSet<>();\n                String baseName = \"execwartmp/moqui_temp\";\n                for (String jarName: moquiStart.jarsUnused) {\n                    if (jarName.startsWith(baseName)) {\n                        jarName = jarName.substring(baseName.length());\n                        while (Character.isDigit(jarName.charAt(0))) jarName = jarName.substring(1);\n                    }\n                    sortedJars.add(jarName);\n                }\n                for (String jarName: sortedJars) System.out.println(\"JAR unused: \" + jarName);\n            }\n        }\n    }\n\n    private static class StartClassLoader extends ClassLoader {\n\n        private URL wrapperUrl = null;\n        private boolean isInWar = true;\n        final ArrayList<JarFile> jarFileList = new ArrayList<>();\n        private final Map<String, URL> jarLocationByJarName = new HashMap<>();\n        private final Map<String, Class<?>> classCache = new HashMap<>();\n        private final Map<String, URL> resourceCache = new HashMap<>();\n        private ProtectionDomain pd;\n        private final boolean loadWebInf;\n\n        final Set<String> jarsUnused = new HashSet<>();\n\n        private StartClassLoader(boolean loadWebInf) {\n            this(ClassLoader.getSystemClassLoader(), loadWebInf);\n        }\n\n        private StartClassLoader(ClassLoader parent, boolean loadWebInf) {\n            super(parent);\n            this.loadWebInf = loadWebInf;\n\n            try {\n                // get outer file (the war file)\n                pd = getClass().getProtectionDomain();\n                CodeSource cs = pd.getCodeSource();\n                wrapperUrl = cs.getLocation();\n                File wrapperFile = new File(wrapperUrl.toURI());\n                isInWar = !wrapperFile.isDirectory();\n\n                /* to accommodate an executable start.jar file inside the executable WAR file:\n                if (isInWar && wrapperFile.getName().equals(\"start.jar\")) {\n                    isInWar = false;\n                    wrapperFile = wrapperFile.getParentFile();\n                    wrapperUrl = wrapperFile.toURI().toURL();\n                }\n                */\n\n                if (isInWar) {\n                    JarFile outerFile = new JarFile(wrapperFile);\n\n                    // allow for classes in the outerFile as well\n                    jarFileList.add(outerFile);\n                    jarLocationByJarName.put(outerFile.getName(), wrapperUrl);\n\n                    Enumeration<JarEntry> jarEntries = outerFile.entries();\n                    while (jarEntries.hasMoreElements()) {\n                        JarEntry je = jarEntries.nextElement();\n                        if (je.isDirectory()) continue;\n                        // if we aren't loading the WEB-INF files and it is one, skip it\n                        if (!loadWebInf && je.getName().startsWith(\"WEB-INF\")) continue;\n                        // get jars, can be anywhere in the file\n                        String jeName = je.getName().toLowerCase();\n                        if (jeName.lastIndexOf(\".jar\") == jeName.length() - 4) {\n                            File file = createTempFile(outerFile, je);\n                            JarFile newJarFile = new JarFile(file);\n                            jarFileList.add(newJarFile);\n                            jarLocationByJarName.put(newJarFile.getName(), file.toURI().toURL());\n                        }\n                    }\n                } else {\n                    ArrayList<File> jarList = new ArrayList<>();\n                    addJarFilesNested(wrapperFile, jarList, loadWebInf);\n                    for (File jarFile : jarList) {\n                        JarFile newJarFile = new JarFile(jarFile);\n                        jarFileList.add(newJarFile);\n                        jarLocationByJarName.put(newJarFile.getName(), jarFile.toURI().toURL());\n                        // System.out.println(\"jar file: \" + jarFile.getAbsolutePath());\n                    }\n                }\n            } catch (Exception e) {\n                System.out.println(\"Error loading jars in war file [\" + wrapperUrl + \"]: \" + e.toString());\n            }\n\n            if (reportJarsUnused) for (JarFile jf : jarFileList) jarsUnused.add(jf.getName());\n        }\n\n        private ConcurrentHashMap<URL, ProtectionDomain> protectionDomainByUrl = new ConcurrentHashMap<>();\n        private ProtectionDomain getProtectionDomain(URL jarLocation) {\n            ProtectionDomain curPd = protectionDomainByUrl.get(jarLocation);\n            if (curPd != null) return curPd;\n            CodeSource codeSource = new CodeSource(jarLocation, (Certificate[]) null);\n            ProtectionDomain newPd = new ProtectionDomain(codeSource, null, this, null);\n            ProtectionDomain existingPd = protectionDomainByUrl.putIfAbsent(jarLocation, newPd);\n            return existingPd != null ? existingPd : newPd;\n        }\n\n        private void addJarFilesNested(File file, List<File> jarList, boolean loadWebInf) {\n            for (File child : file.listFiles()) {\n                if (child.isDirectory()) {\n                    // generally run with the runtime directory in the same directory, so skip it (or causes weird class dependency errors)\n                    if (\"runtime\".equals(child.getName())) continue;\n                    // if we aren't loading the WEB-INF files and it is one, skip it\n                    if (!loadWebInf && \"WEB-INF\".equals(child.getName())) continue;\n                    addJarFilesNested(child, jarList, loadWebInf);\n                } else if (child.getName().endsWith(\".jar\")) {\n                    jarList.add(child);\n                }\n            }\n        }\n\n        @SuppressWarnings(\"ThrowFromFinallyBlock\")\n        private File createTempFile(JarFile outerFile, JarEntry je) throws IOException {\n            byte[] jeBytes = getJarEntryBytes(outerFile, je);\n\n            String tempName = je.getName().replace('/', '_') + \".\";\n            File tempDir = new File(tempDirName);\n            if (tempDir.mkdir()) tempDir.deleteOnExit();\n            File file = File.createTempFile(\"moqui_temp\", tempName, tempDir);\n            file.deleteOnExit();\n            BufferedOutputStream os = null;\n            try {\n                os = new BufferedOutputStream(new FileOutputStream(file));\n                os.write(jeBytes);\n            } finally {\n                if (os != null) os.close();\n            }\n            return file;\n        }\n\n        @SuppressWarnings(\"ThrowFromFinallyBlock\")\n        private byte[] getJarEntryBytes(JarFile jarFile, JarEntry je) throws IOException {\n            DataInputStream dis = null;\n            byte[] jeBytes = null;\n            try {\n                long lSize = je.getSize();\n                if (lSize <= 0  ||  lSize >= Integer.MAX_VALUE) {\n                    throw new IllegalArgumentException(\"Size [\" + lSize + \"] not valid for war entry [\" + je + \"]\");\n                }\n                jeBytes = new byte[(int)lSize];\n                InputStream is = jarFile.getInputStream(je);\n                dis = new DataInputStream(is);\n                dis.readFully(jeBytes);\n            } finally {\n                if (dis != null) dis.close();\n            }\n            return jeBytes;\n        }\n\n        /** @see java.lang.ClassLoader#findResource(java.lang.String) */\n        @Override\n        protected URL findResource(String resourceName) {\n            if (resourceCache.containsKey(resourceName)) return resourceCache.get(resourceName);\n\n            // try the runtime/classes directory for conf files and such\n            String runtimePath = System.getProperty(\"moqui.runtime\");\n            String fullPath = runtimePath + \"/classes/\" + resourceName;\n            File resourceFile = new File(fullPath);\n            if (resourceFile.exists()) try {\n                return resourceFile.toURI().toURL();\n            } catch (MalformedURLException e) {\n                System.out.println(\"Error making URL for [\" + resourceName + \"] in runtime classes directory [\" + runtimePath + \"/classes/\" + \"]: \" + e.toString());\n            }\n\n            String webInfResourceName = \"WEB-INF/classes/\" + resourceName;\n            int jarFileListSize = jarFileList.size();\n            for (int i = 0; i < jarFileListSize; i++) {\n                JarFile jarFile = jarFileList.get(i);\n                JarEntry jarEntry = jarFile.getJarEntry(resourceName);\n                if (reportJarsUnused && jarEntry != null) jarsUnused.remove(jarFile.getName());\n                // to better support war format, look for the resourceName in the WEB-INF/classes directory\n                if (loadWebInf && jarEntry == null) jarEntry = jarFile.getJarEntry(webInfResourceName);\n                if (jarEntry != null) {\n                    try {\n                        URL jarLocation = jarLocationByJarName.get(jarFile.getName());\n                        if (jarLocation == null) jarLocation = new File(jarFile.getName()).toURI().toURL();\n                        URL resourceUrl = new URL(\"jar:\" + jarLocation.toExternalForm() + \"!/\" + jarEntry.getName());\n                        resourceCache.put(resourceName, resourceUrl);\n                        return resourceUrl;\n                    } catch (MalformedURLException e) {\n                        System.out.println(\"Error making URL for [\" + resourceName + \"] in jar [\" + jarFile + \"] in war file [\" + wrapperUrl + \"]: \" + e.toString());\n                    }\n                }\n            }\n            return super.findResource(resourceName);\n        }\n\n        /** @see java.lang.ClassLoader#findResources(java.lang.String) */\n        @Override\n        public Enumeration<URL> findResources(String resourceName) throws IOException {\n            String webInfResourceName = \"WEB-INF/classes/\" + resourceName;\n            List<URL> urlList = new ArrayList<>();\n            int jarFileListSize = jarFileList.size();\n            for (int i = 0; i < jarFileListSize; i++) {\n                JarFile jarFile = jarFileList.get(i);\n                JarEntry jarEntry = jarFile.getJarEntry(resourceName);\n                if (reportJarsUnused && jarEntry != null) jarsUnused.remove(jarFile.getName());\n                // to better support war format, look for the resourceName in the WEB-INF/classes directory\n                if (loadWebInf && jarEntry == null) jarEntry = jarFile.getJarEntry(webInfResourceName);\n                if (jarEntry != null) {\n                    try {\n                        URL jarLocation = jarLocationByJarName.get(jarFile.getName());\n                        if (jarLocation == null) jarLocation = new File(jarFile.getName()).toURI().toURL();\n                        urlList.add(new URL(\"jar:\" + jarLocation.toExternalForm() + \"!/\" + jarEntry.getName()));\n                    } catch (MalformedURLException e) {\n                        System.out.println(\"Error making URL for [\" + resourceName + \"] in jar [\" + jarFile + \"] in war file [\" + wrapperUrl + \"]: \" + e.toString());\n                    }\n                }\n            }\n            // add all resources found in parent loader too\n            Enumeration<URL> superResources = super.findResources(resourceName);\n            while (superResources.hasMoreElements()) urlList.add(superResources.nextElement());\n            return Collections.enumeration(urlList);\n        }\n\n        @Override\n        protected synchronized Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {\n            Class<?> c = null;\n            try {\n                try {\n                    ClassLoader cl = getParent();\n                    c = cl.loadClass(className);\n                    if (c != null) return c;\n                } catch (ClassNotFoundException e) { /* let the next one handle this */ }\n\n                try {\n                    c = findJarClass(className);\n                    if (c != null) return c;\n                } catch (Exception e) {\n                    System.out.println(\"Error loading class [\" + className + \"] from jars in war file [\" + wrapperUrl + \"]: \" + e.toString());\n                    e.printStackTrace();\n                }\n\n                throw new ClassNotFoundException(\"Class [\" + className + \"] not found\");\n            } finally {\n                if (c != null  &&  resolve) {\n                    resolveClass(c);\n                }\n            }\n        }\n\n        private Class<?> findJarClass(String className) throws IOException, ClassFormatError {\n            if (classCache.containsKey(className)) return classCache.get(className);\n\n            Class<?> c = null;\n            String classFileName = className.replace('.', '/') + \".class\";\n            String webInfFileName = \"WEB-INF/classes/\" + classFileName;\n            int jarFileListSize = jarFileList.size();\n            for (int i = 0; i < jarFileListSize; i++) {\n                JarFile jarFile = jarFileList.get(i);\n                // System.out.println(\"Finding Class [\" + className + \"] in jarFile [\" + jarFile.getName() + \"]\");\n                JarEntry jarEntry = jarFile.getJarEntry(classFileName);\n                if (reportJarsUnused && jarEntry != null) jarsUnused.remove(jarFile.getName());\n\n                // to better support war format, look for the resourceName in the WEB-INF/classes directory\n                if (loadWebInf && jarEntry == null) jarEntry = jarFile.getJarEntry(webInfFileName);\n                if (jarEntry != null) {\n                    definePackage(className, jarFile);\n                    byte[] jeBytes = getJarEntryBytes(jarFile, jarEntry);\n                    if (jeBytes == null) {\n                        System.out.println(\"Could not get bytes for [\" + jarEntry.getName() + \"] in [\" + jarFile.getName() + \"]\");\n                        continue;\n                    }\n                    // System.out.println(\"Class [\" + classFileName + \"] FOUND in jarFile [\" + jarFile.getName() + \"], size is \" + (jeBytes == null ? \"null\" : jeBytes.length));\n                    URL jarLocation = jarLocationByJarName.get(jarFile.getName());\n                    c = defineClass(className, jeBytes, 0, jeBytes.length, jarLocation != null ? getProtectionDomain(jarLocation) : pd);\n                    break;\n                }\n            }\n            classCache.put(className, c);\n            return c;\n        }\n\n        private void definePackage(String className, JarFile jarFile) throws IllegalArgumentException {\n            Manifest mf = null;\n            try {\n                mf = jarFile.getManifest();\n            } catch (IOException e) {\n                System.out.println(\"Error getting manifest from \" + jarFile.getName() + \": \" + e.toString());\n            }\n            // if no manifest use default\n            if (mf == null) mf = new Manifest();\n\n            int dotIndex = className.lastIndexOf('.');\n            String packageName = dotIndex > 0 ? className.substring(0, dotIndex) : \"\";\n            // NOTE: for Java 11 changed getPackage() to getDefinedPackage(), can't do before because getDefinedPackage() doesn't exist in Java 8\n            if (getDefinedPackage(packageName) == null) {\n                definePackage(packageName,\n                        mf.getMainAttributes().getValue(Attributes.Name.SPECIFICATION_TITLE),\n                        mf.getMainAttributes().getValue(Attributes.Name.SPECIFICATION_VERSION),\n                        mf.getMainAttributes().getValue(Attributes.Name.SPECIFICATION_VENDOR),\n                        mf.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_TITLE),\n                        mf.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VERSION),\n                        mf.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VENDOR),\n                        getSealURL(mf));\n            }\n        }\n\n        private URL getSealURL(Manifest mf) {\n            String seal = mf.getMainAttributes().getValue(Attributes.Name.SEALED);\n            if (seal == null) return null;\n            try {\n                return new URL(seal);\n            } catch (MalformedURLException e) {\n                return null;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "framework/src/test/groovy/CacheFacadeTests.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\n\nimport org.moqui.Moqui\nimport org.moqui.context.ExecutionContext\nimport org.moqui.jcache.MCache\nimport spock.lang.*\n\nclass CacheFacadeTests extends Specification {\n    @Shared\n    ExecutionContext ec\n    @Shared\n    MCache testCache\n\n    def setupSpec() {\n        // init the framework, get the ec\n        ec = Moqui.getExecutionContext()\n        testCache = ec.cache.getLocalCache(\"CacheFacadeTests\")\n    }\n\n    def cleanupSpec() {\n        ec.destroy()\n    }\n\n    def \"add cache element\"() {\n        when:\n        testCache.put(\"key1\", \"value1\")\n        int hitCountBefore = testCache.stats.getCacheHits()\n\n        then:\n        testCache.get(\"key1\") == \"value1\"\n        testCache.stats.getCacheHits() == hitCountBefore + 1\n\n        cleanup:\n        testCache.clear()\n    }\n\n    /* New caches doesn't support this (local/MCache doesn't support size limit, distributed/Hazelcast can't be changed on the fly like this:\n    def \"overflow cache size limit\"() {\n        when:\n        testCache.setMaxElements(3, Cache.LEAST_RECENTLY_ADDED)\n        testCache.put(\"key1\", \"value1\")\n        testCache.put(\"key2\", \"value2\")\n        testCache.put(\"key3\", \"value3\")\n        testCache.put(\"key4\", \"value4\")\n        int hitCountBefore = testCache.getHitCount()\n        int removeCountBefore = testCache.getRemoveCount()\n        int missCountBefore = testCache.getMissCountTotal()\n\n        then:\n        testCache.getEvictionStrategy() == Cache.LEAST_RECENTLY_ADDED\n        testCache.getMaxElements() == 3\n        testCache.size() == 3\n        testCache.getRemoveCount() == removeCountBefore\n        testCache.get(\"key1\") == null\n        !testCache.containsKey(\"key1\")\n        testCache.getMissCountTotal() == missCountBefore + 1\n        testCache.get(\"key2\") == \"value2\"\n        testCache.getHitCount() == hitCountBefore + 1\n\n        cleanup:\n        testCache.clear()\n        // go back to size limit defaults\n        testCache.setMaxElements(10000, Cache.LEAST_RECENTLY_USED)\n    }\n    */\n\n    def \"get cache concurrently\"() {\n        def getCache = {\n            ec.cache.getLocalCache(\"CacheFacadeConcurrencyTests\")\n        }\n        when:\n        def caches = ConcurrentExecution.executeConcurrently(10, getCache)\n\n        then:\n        caches.size() == 10\n        // all elements must be instances of the Cache class, no exceptions or nulls\n        caches.every { item ->\n            item instanceof MCache\n        }\n        // all elements must be references to the same object\n        caches.every { item ->\n            item.equals(caches[0])\n        }\n    }\n\n    // TODO: test cache expire time\n}\n"
  },
  {
    "path": "framework/src/test/groovy/ConcurrentExecution.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\n\nimport groovy.transform.CompileStatic\n\nimport java.util.concurrent.Callable\nimport java.util.concurrent.CyclicBarrier\nimport java.util.concurrent.ExecutionException\nimport java.util.concurrent.Executors\nimport java.util.concurrent.ExecutorService\nimport java.util.concurrent.Future\n\n@CompileStatic\nclass ConcurrentExecution {\n    def static executeConcurrently(int threads, Closure closure) {\n        ExecutorService executor = Executors.newFixedThreadPool(threads)\n        CyclicBarrier barrier = new CyclicBarrier(threads)\n\n        def futures = new LinkedList<Future>()\n        for (int i = 0; i < threads; i++) {\n            futures.add((Future) executor.submit(new Callable() {\n                def call() throws Exception {\n                    barrier.await()\n                    closure.call()\n                }\n            }))\n        }\n\n        def values = new LinkedList<Object>()\n        for (Future future: futures) {\n            try {\n                def value = future.get()\n                values << value\n            } catch (ExecutionException e) {\n                values << e.cause\n            }\n        }\n\n        return values\n    }\n}\n"
  },
  {
    "path": "framework/src/test/groovy/EntityCrud.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\n\nimport org.moqui.entity.EntityException\nimport org.moqui.entity.EntityList\nimport spock.lang.*\n\nimport org.moqui.context.ExecutionContext\nimport org.moqui.entity.EntityValue\nimport org.moqui.Moqui\n\nimport java.sql.Timestamp\n\nclass EntityCrud extends Specification {\n    @Shared\n    ExecutionContext ec\n\n    def setupSpec() {\n        // init the framework, get the ec\n        ec = Moqui.getExecutionContext()\n    }\n\n    def cleanupSpec() {\n        ec.destroy()\n    }\n\n    def setup() {\n        ec.artifactExecution.disableAuthz()\n        ec.transaction.begin(null)\n    }\n\n    def cleanup() {\n        ec.artifactExecution.enableAuthz()\n        ec.transaction.commit()\n    }\n\n    def \"create and find TestEntity CRDTST1\"() {\n        when:\n        ec.entity.makeValue(\"moqui.test.TestEntity\")\n                .setAll([testId:\"CRDTST1\", testMedium:\"Test Name\", lastUpdatedStamp:ec.user.nowTimestamp])\n                .create()\n        EntityValue testEntity = ec.entity.find(\"moqui.test.TestEntity\").condition(\"testId\", \"CRDTST1\").one()\n\n        then:\n        testEntity.testMedium == \"Test Name\"\n    }\n\n    def \"update TestEntity CRDTST1\"() {\n        when:\n        EntityValue testEntity = ec.entity.find(\"moqui.test.TestEntity\").condition(\"testId\", \"CRDTST1\").one()\n        testEntity.testMedium = \"Test Name 2\"\n        testEntity.update()\n        EntityValue testEntityCheck = ec.entity.find(\"moqui.test.TestEntity\").condition([testId:\"CRDTST1\"]).one()\n\n        then:\n        testEntityCheck.testMedium == \"Test Name 2\"\n    }\n\n    def \"update TestEntity CRDTST1 through cache\"() {\n        when:\n        Exception immutableError = null\n        EntityValue testEntity = ec.entity.find(\"moqui.test.TestEntity\").condition(\"testId\", \"CRDTST1\").useCache(true).one()\n        try {\n            testEntity.testMedium = \"Test Name Cache\"\n        } catch (EntityException e) {\n            immutableError = e\n        }\n\n        then:\n        immutableError != null\n    }\n\n    def \"update TestEntity from list through cache\"() {\n        when:\n        Exception immutableError = null\n        EntityList testEntityList = ec.entity.find(\"moqui.test.TestEntity\").condition(\"testId\", \"CRDTST1\").useCache(true).list()\n        EntityValue testEntity = testEntityList.first()\n        try {\n            testEntity.testMedium = \"Test Name List Cache\"\n        } catch (EntityException e) {\n            immutableError = e\n        }\n\n        then:\n        immutableError != null\n    }\n\n    def \"delete TestEntity CRDTST1\"() {\n        when:\n        ec.entity.find(\"moqui.test.TestEntity\").condition([testId:\"CRDTST1\"]).one().delete()\n        EntityValue testEntityCheck = ec.entity.find(\"moqui.test.TestEntity\").condition([testId:\"CRDTST1\"]).one()\n\n        then:\n        testEntityCheck == null\n    }\n\n    def \"delete EnumerationType cascade\"() {\n        when:\n        ec.entity.makeValue(\"moqui.basic.EnumerationType\").setAll([enumTypeId:\"TEST_DEL_ET\", description:\"Test delete enum type\"]).create()\n        ec.entity.makeValue(\"moqui.basic.Enumeration\").setAll([enumId:\"TDELEN1\", enumTypeId:\"TEST_DEL_ET\", description:\"Test delete enum 1\"]).create()\n        ec.entity.makeValue(\"moqui.basic.Enumeration\").setAll([enumId:\"TDELEN2\", enumTypeId:\"TEST_DEL_ET\", description:\"Test delete enum 2\"]).create()\n\n        EntityValue enumType = ec.entity.find(\"moqui.basic.EnumerationType\").condition(\"enumTypeId\", \"TEST_DEL_ET\").one()\n        EntityList enumsBefore = enumType.findRelatedFk(null)\n        boolean gotExpectedError = false\n        try {\n            enumType.deleteWithCascade(null, new HashSet<String>())\n        } catch (EntityException e) {\n            gotExpectedError = true\n        }\n        EntityList enumsBetween = enumType.findRelatedFk(null)\n        enumType.deleteWithCascade(null, null)\n        EntityValue enumTypeAfter = ec.entity.find(\"moqui.basic.EnumerationType\").condition(\"enumTypeId\", \"TEST_DEL_ET\").one()\n        EntityList enumsAfter = enumType.findRelatedFk(null)\n\n        then:\n        enumsBefore.size() == 2\n        gotExpectedError\n        enumsBetween.size() == 2\n        enumTypeAfter == null\n        enumsAfter.size() == 0\n    }\n\n    def \"serialize And Deserialize\"() {\n        when:\n        Timestamp nowStamp = new Timestamp(System.currentTimeMillis())\n        EntityValue origVal = ec.entity.makeValue(\"moqui.test.TestEntity\").setAll([testId:\"AnId\", testMedium:\"testMediumVal\",\n                testNumberInteger:123, testNumberDecimal:12.34, testDateTime:nowStamp])\n\n        ByteArrayOutputStream baos = new ByteArrayOutputStream()\n        ObjectOutputStream oos = new ObjectOutputStream(baos)\n        try {\n            oos.writeObject(origVal)\n        } catch (Throwable t) {\n            t.println()\n            t.printStackTrace()\n        }\n        oos.flush()\n\n        ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray())\n        ObjectInputStream ois = new ObjectInputStream(bais)\n        EntityValue deSerVal = (EntityValue) ois.readObject()\n\n        then:\n        deSerVal.testMedium == \"testMediumVal\"\n        deSerVal.testNumberInteger == 123\n        deSerVal.testNumberDecimal == 12.34\n        deSerVal.testDateTime == nowStamp\n    }\n}\n"
  },
  {
    "path": "framework/src/test/groovy/EntityFindTests.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\n\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\nimport spock.lang.*\n\nimport org.moqui.context.ExecutionContext\nimport org.moqui.entity.EntityValue\nimport org.moqui.Moqui\nimport java.sql.Timestamp\nimport org.moqui.entity.EntityCondition\nimport org.moqui.entity.EntityList\n\nclass EntityFindTests extends Specification {\n    protected final static Logger logger = LoggerFactory.getLogger(EntityFindTests.class)\n\n    @Shared\n    ExecutionContext ec\n    @Shared\n    Timestamp timestamp\n\n    def setupSpec() {\n        // init the framework, get the ec\n        ec = Moqui.getExecutionContext()\n        timestamp = ec.user.nowTimestamp\n    }\n\n    def cleanupSpec() {\n        ec.destroy()\n    }\n\n    def setup() {\n        ec.artifactExecution.disableAuthz()\n        ec.transaction.begin(null)\n        ec.entity.makeValue(\"moqui.test.TestEntity\").setAll([testId:\"EXTST1\", testIndicator:null,\n                testLong:\"\", testMedium:\"Test Name\",\n                testNumberInteger:4321, testDateTime:timestamp]).createOrUpdate()\n    }\n\n    def cleanup() {\n        ec.entity.makeValue(\"moqui.test.TestEntity\").set(\"testId\", \"EXTST1\").delete()\n        ec.artifactExecution.enableAuthz()\n        ec.transaction.commit()\n    }\n\n    @Unroll\n    def \"find TestEntity by single condition (#fieldName = #value)\"() {\n        expect:\n        EntityValue testEntity = ec.entity.find(\"moqui.test.TestEntity\").condition(fieldName, value).one()\n        testEntity != null\n        testEntity.testId == \"EXTST1\"\n\n        where:\n        fieldName | value\n        \"testId\" | \"EXTST1\"\n        // fails on some DBs without pre-JDBC type conversion: \"testNumberInteger\" | \"4321\"\n        \"testNumberInteger\" | 4321\n        // fails on some DBs without pre-JDBC type conversion: \"testDateTime\" | ec.l10n.format(timestamp, \"yyyy-MM-dd HH:mm:ss.SSS\")\n        \"testDateTime\" | timestamp\n    }\n\n    def \"find TestEntity and GeoAndType by null PK\"() {\n        when:\n        EntityValue testEntity = ec.entity.find(\"moqui.test.TestEntity\").condition(\"testId\", null).one()\n        EntityValue geoAndType = ec.entity.find(\"moqui.basic.GeoAndType\").condition(\"geoId\", null).one()\n        then:\n        testEntity == null\n        geoAndType == null\n    }\n\n    @Unroll\n    def \"find TestEntity by operator condition (#fieldName #operator #value)\"() {\n        expect:\n        EntityValue testEntity = ec.entity.find(\"moqui.test.TestEntity\").condition(fieldName, operator, value).one()\n        testEntity != null\n        testEntity.testId == \"EXTST1\"\n\n        where:\n        fieldName | operator | value\n        \"testId\" | EntityCondition.BETWEEN | [\"EXTST0\", \"EXTST2\"]\n        \"testId\" | EntityCondition.EQUALS | \"EXTST1\"\n        \"testId\" | EntityCondition.IN | [\"EXTST1\"]\n        \"testId\" | EntityCondition.LIKE | \"%XTST%\"\n    }\n\n    @Unroll\n    def \"find TestEntity by searchFormMap (#inputsMap #resultId)\"() {\n        expect:\n        EntityValue testEntity = ec.entity.find(\"moqui.test.TestEntity\").searchFormMap(inputsMap, null, null, \"\", false).one()\n        resultId ? testEntity != null && testEntity.testId == resultId : testEntity == null\n\n        where:\n        inputsMap | resultId\n        [testId: \"EXTST1\", testId_op: \"equals\"] | \"EXTST1\"\n        [testId: \"%XTST%\", testId_op: \"like\"] | \"EXTST1\"\n        [testId: \"XTST\", testId_op: \"contains\"] | \"EXTST1\"\n        [testMedium:\"Test Name\", testIndicator_op: \"empty\"] | \"EXTST1\"\n        [testMedium:\"Test Name\", testLong_op: \"empty\"] | \"EXTST1\"\n        [testMedium:\"Test Name\", testDateTime_from: \"\", testDateTime_thru: \"\"] | \"EXTST1\"\n        [testMedium:\"Test Name\", testDateTime_from: timestamp, testDateTime_thru: timestamp - 1] | null\n        [testMedium:\"Test Name\", testDateTime_from: timestamp, testDateTime_thru: timestamp + 1] | \"EXTST1\"\n        [testNumberInteger:4321, testMedium_not: \"Y\", testMedium_op: \"equals\", testMedium: \"\"] | \"EXTST1\"\n        [testNumberInteger:4321, testMedium_not: \"Y\", testMedium_op: \"empty\"] | \"EXTST1\"\n    }\n\n    def \"find EnumerationType related FK\"() {\n        when:\n        EntityValue enumType = ec.entity.find(\"moqui.basic.EnumerationType\").condition(\"enumTypeId\", \"DataSourceType\").one()\n        EntityList enums = enumType.findRelatedFk(null)\n        // for (EntityValue val in enums) logger.warn(\"DST Enum ${val.resolveEntityName()} ${val}\")\n\n        EntityList noEnums = enumType.findRelatedFk(new HashSet([\"moqui.basic.Enumeration\"]))\n\n        then:\n        enums.size() >= 4\n        noEnums.size() == 0\n    }\n\n    def \"auto cache clear for list\"() {\n        // update the testMedium and make sure we get the new value\n        when:\n        ec.entity.find(\"moqui.test.TestEntity\").condition(\"testNumberInteger\", 4321).useCache(true).list()\n        ec.entity.makeValue(\"moqui.test.TestEntity\").setAll([testId:\"EXTST1\", testMedium:\"Test Name 2\"]).update()\n        EntityList testEntityList = ec.entity.find(\"moqui.test.TestEntity\")\n                .condition(\"testNumberInteger\", 4321).useCache(true).list()\n\n        then:\n        testEntityList.size() == 1\n        testEntityList.first.testMedium == \"Test Name 2\"\n    }\n\n    def \"auto cache clear for one by primary key\"() {\n        when:\n        ec.entity.find(\"moqui.test.TestEntity\").condition(\"testId\", \"EXTST1\").useCache(true).one()\n        ec.entity.makeValue(\"moqui.test.TestEntity\").setAll([testId:\"EXTST1\", testMedium:\"Test Name 3\"]).update()\n        EntityValue testEntity = ec.entity.find(\"moqui.test.TestEntity\").condition(\"testId\", \"EXTST1\").useCache(true).one()\n\n        then:\n        testEntity.testMedium == \"Test Name 3\"\n    }\n\n    def \"auto cache clear for one by non-primary key\"() {\n        when:\n        ec.entity.find(\"moqui.test.TestEntity\").condition([testNumberInteger:4321, testDateTime:timestamp]).useCache(true).one()\n        ec.entity.makeValue(\"moqui.test.TestEntity\").setAll([testId:\"EXTST1\", testMedium:\"Test Name 4\"]).update()\n        EntityValue testEntity = ec.entity.find(\"moqui.test.TestEntity\")\n                .condition([testNumberInteger:4321, testDateTime:timestamp]).useCache(true).one()\n\n        then:\n        testEntity.testMedium == \"Test Name 4\"\n    }\n\n    def \"auto cache clear for one by non-pk and initially no result\"() {\n        when:\n        EntityValue testEntity1 = ec.entity.find(\"moqui.test.TestEntity\").condition([testMedium:\"Test Name 5\"]).useCache(true).one()\n        ec.entity.makeValue(\"moqui.test.TestEntity\").setAll([testId:\"EXTST1\", testMedium:\"Test Name 5\"]).update()\n        EntityValue testEntity2 = ec.entity.find(\"moqui.test.TestEntity\").condition([testMedium:\"Test Name 5\"]).useCache(true).one()\n\n        then:\n        testEntity1 == null\n        testEntity2 != null\n        testEntity2.testMedium == \"Test Name 5\"\n    }\n\n    def \"auto cache clear for list on update of record not included\"() {\n        // update the testMedium and make sure we get the new value\n        when:\n        ec.entity.find(\"moqui.test.TestEntity\").condition(\"testNumberInteger\", 1234).useCache(true).list()\n        ec.entity.makeValue(\"moqui.test.TestEntity\").setAll([testId:\"EXTST1\", testNumberInteger:1234]).update()\n        EntityList testEntityList = ec.entity.find(\"moqui.test.TestEntity\")\n                .condition(\"testNumberInteger\", 1234).useCache(true).list()\n\n        then:\n        testEntityList.size() == 1\n        testEntityList.first.testNumberInteger == 1234\n    }\n\n\n    def \"auto cache clear for view list on create of record not included\"() {\n        // this is similar to what happens with authz checking with changes after startup\n        when:\n        EntityList beforeList = ec.entity.find(\"moqui.security.ArtifactAuthzCheckView\")\n                .condition(\"userGroupId\", \"ADMIN\").useCache(true).list()\n        // this record exists for ALL_USERS, but not for ADMIN (redundant for ADMIN, but a good test)\n        ec.entity.makeValue(\"moqui.security.ArtifactAuthz\")\n                .setAll([artifactAuthzId:\"SCREEN_TREE_ADMIN\", userGroupId:\"ADMIN\", artifactGroupId:\"SCREEN_TREE\",\n                         authzTypeEnumId:\"AUTHZT_ALWAYS\", authzActionEnumId:\"AUTHZA_VIEW\"]).create()\n        EntityList afterList = ec.entity.find(\"moqui.security.ArtifactAuthzCheckView\")\n                .condition(\"userGroupId\", \"ADMIN\").useCache(true).list()\n\n        // logger.info(\"ArtifactAuthzCheckView before (${beforeList.size()}):\\n${beforeList}\\n after (${afterList.size()}):\\n${afterList}\")\n\n        then:\n        // afterList will have 2 more records because SCREEN_TREE artifact group has 2 records\n        afterList.size() == beforeList.size() + 2\n        afterList.filterByAnd([artifactGroupId:\"SCREEN_TREE\"]).size() == 2\n    }\n    def \"auto cache clear for view list on create of related record not included\"() {\n        // this is similar to what happens with authz checking with changes after startup\n        when:\n        EntityList beforeList = ec.entity.find(\"moqui.security.ArtifactAuthzCheckView\")\n                .condition(\"userGroupId\", \"ADMIN\").useCache(true).list()\n        EntityValue ev = ec.entity.makeValue(\"moqui.security.ArtifactGroupMember\")\n                .setAll([artifactGroupId:\"SCREEN_TREE\", artifactName: \"TEST\",\n                         artifactTypeEnumId:\"AT_XML_SCREEN\"]).create()\n        EntityList afterList = ec.entity.find(\"moqui.security.ArtifactAuthzCheckView\")\n                .condition(\"userGroupId\", \"ADMIN\").useCache(true).list()\n        ev.delete()\n        // logger.info(\"ArtifactAuthzCheckView before (${beforeList.size()}):\\n${beforeList}\\n after (${afterList.size()}):\\n${afterList}\")\n\n        then:\n        afterList.size() == beforeList.size() + 1\n        afterList.filterByAnd([artifactGroupId:\"SCREEN_TREE\"]).size() == 3\n    }\n\n    def \"auto cache clear for view one after update of member\"() {\n        when:\n        EntityValue before = ec.entity.find(\"moqui.basic.GeoAndType\").condition(\"geoId\", \"USA\").useCache(true).one()\n        ec.entity.makeValue(\"moqui.basic.Enumeration\").setAll([enumId:\"GEOT_COUNTRY\", description:\"Country2\"]).update()\n        EntityValue after = ec.entity.find(\"moqui.basic.GeoAndType\").condition(\"geoId\", \"USA\").useCache(true).one()\n\n        // set it back so data isn't funny after tests\n        ec.entity.makeValue(\"moqui.basic.Enumeration\").setAll([enumId:\"GEOT_COUNTRY\", description:\"Country\"]).update()\n        EntityValue reset = ec.entity.find(\"moqui.basic.GeoAndType\").condition(\"geoId\", \"USA\").useCache(true).one()\n\n        then:\n        before.typeDescription == \"Country\"\n        after.typeDescription == \"Country2\"\n        reset.typeDescription == \"Country\"\n    }\n\n    def \"auto cache clear for count by is not null after update\"() {\n        when:\n        long before = ec.entity.find(\"moqui.basic.Enumeration\").condition(\"enumCode\", \"is-not-null\", null).useCache(true).count()\n        EntityValue enumVal = ec.entity.find(\"moqui.basic.Enumeration\").condition(\"enumId\", \"DST_PURCHASED_DATA\").useCache(false).one()\n        enumVal.enumCode = \"TEST\"\n        enumVal.update()\n        long after = ec.entity.find(\"moqui.basic.Enumeration\").condition(\"enumCode\", \"is-not-null\", null).useCache(true).count()\n\n        // set it back so data isn't funny after tests, and test clear after reset to null\n        enumVal.enumCode = null\n        enumVal.update()\n        long reset = ec.entity.find(\"moqui.basic.Enumeration\").condition(\"enumCode\", \"is-not-null\", null).useCache(true).count()\n        // logger.warn(\"count before ${before} after ${after} reset ${reset}\")\n\n        then:\n        before + 1 == after\n        reset == before\n    }\n\n    def \"no cache with for update\"() {\n        when:\n        // do query on Geo which has cache=true, with for-update it should not use the cache\n        EntityValue geo = ec.entity.find(\"moqui.basic.Geo\").condition(\"geoId\", \"USA\").forUpdate(true).one()\n\n        then:\n        geo.isMutable()\n    }\n}\n"
  },
  {
    "path": "framework/src/test/groovy/EntityNoSqlCrud.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\n\nimport org.moqui.Moqui\nimport org.moqui.context.ExecutionContext\nimport org.moqui.entity.EntityList\nimport org.moqui.entity.EntityListIterator\nimport org.moqui.entity.EntityValue\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\nimport spock.lang.Shared\nimport spock.lang.Specification\n\nimport java.sql.Time\nimport java.sql.Timestamp\n\nclass EntityNoSqlCrud extends Specification {\n    protected final static Logger logger = LoggerFactory.getLogger(EntityNoSqlCrud.class)\n\n    @Shared\n    ExecutionContext ec\n\n    def setupSpec() {\n        // init the framework, get the ec\n        ec = Moqui.getExecutionContext()\n    }\n\n    def cleanupSpec() {\n        ec.destroy()\n    }\n\n    def setup() {\n        ec.artifactExecution.disableAuthz()\n        ec.transaction.begin(null)\n    }\n\n    def cleanup() {\n        ec.artifactExecution.enableAuthz()\n        ec.transaction.commit()\n    }\n\n    def \"create and find TestNoSqlEntity TEST1\"() {\n        when:\n        long curTime = System.currentTimeMillis()\n        ec.entity.makeValue(\"moqui.test.TestNoSqlEntity\")\n                .setAll([testId:\"TEST1\", testMedium:\"Test Name\", testLong:\"Very Long \".repeat(200), testIndicator:\"N\",\n                        testDate:new java.sql.Date(curTime), testDateTime:new Timestamp(curTime),\n                        testTime:new Time(curTime), testNumberInteger:Long.MAX_VALUE, testNumberDecimal:BigDecimal.ZERO,\n                        testNumberFloat:Double.MAX_VALUE, testCurrencyAmount:1111.12, testCurrencyPrecise:2222.12345])\n                .createOrUpdate()\n        EntityValue testCheck = ec.entity.find(\"moqui.test.TestNoSqlEntity\").condition(\"testId\", \"TEST1\").one()\n\n        // logger.warn(\"testCheck.testTime ${testCheck.testTime} ${testCheck.testTime.getTime()} type ${testCheck.testTime?.class} new Time(curTime) ${new Time(curTime)} ${curTime}\")\n\n        then:\n        testCheck.testMedium == \"Test Name\"\n        testCheck.testLong.toString().startsWith(\"Very Long Very Long\")\n        testCheck.testDate == new java.sql.Date(curTime)\n        testCheck.testDateTime == new Timestamp(curTime)\n        // compare time strings because object compare with original and truncated long millis are not considered the same, even if the time is the same\n        testCheck.testTime.toString() == new Time(curTime).toString()\n        testCheck.testNumberInteger == Long.MAX_VALUE\n        testCheck.testNumberDecimal == BigDecimal.ZERO\n        testCheck.testNumberFloat == Double.MAX_VALUE\n        testCheck.testCurrencyAmount == 1111.12\n        testCheck.testCurrencyPrecise == 2222.12345\n    }\n\n    def \"update TestNoSqlEntity TEST1\"() {\n        when:\n        EntityValue testValue = ec.entity.find(\"moqui.test.TestNoSqlEntity\").condition(\"testId\", \"TEST1\").one()\n        testValue.testMedium = \"Test Name 2\"\n        testValue.update()\n        EntityValue testCheck = ec.entity.find(\"moqui.test.TestNoSqlEntity\").condition([testId:\"TEST1\"]).one()\n\n        then:\n        testCheck.testMedium == \"Test Name 2\"\n    }\n\n    def \"delete TestNoSqlEntity TEST1\"() {\n        when:\n        ec.entity.find(\"moqui.test.TestNoSqlEntity\").condition([testId:\"TEST1\"]).one().delete()\n        EntityValue testCheck = ec.entity.find(\"moqui.test.TestNoSqlEntity\").condition([testId:\"TEST1\"]).one()\n\n        then:\n        testCheck == null\n    }\n\n    def \"createBulk TestNoSqlEntity\"() {\n        when:\n        long beforeCount = ec.entity.find(\"moqui.test.TestNoSqlEntity\").count()\n        int recordCount = 200\n\n\n        List<EntityValue> createList = new ArrayList<>(recordCount)\n        for (int i = 0; i < recordCount; i++) {\n            EntityValue newValue = ec.entity.makeValue(\"moqui.test.TestNoSqlEntity\")\n            newValue.setAll([testId:\"BULK\" + i, testMedium:\"Test Name ${i}\", testNumberInteger:i])\n            createList.add(newValue)\n        }\n        ec.entity.createBulk(createList)\n\n        long afterCount = ec.entity.find(\"moqui.test.TestNoSqlEntity\").count()\n        // logger.warn(\"beforeCount ${beforeCount} recordCount ${recordCount} afterCount ${afterCount}\")\n\n        then:\n        afterCount == beforeCount + recordCount\n    }\n\n    def \"ELI find TestNoSqlEntity\"() {\n        when:\n        EntityList partialEl = null\n        EntityValue first = null\n        try (EntityListIterator eli = ec.entity.find(\"moqui.test.TestNoSqlEntity\")\n                .orderBy(\"-testNumberInteger\").iterator()) {\n\n\n            partialEl = eli.getPartialList(0, 100, false)\n\n            eli.beforeFirst()\n            first = eli.next()\n        } catch (Exception e) {\n            logger.error(\"partialEl error\", e)\n        }\n        // logger.warn(\"partialEl.size() ${partialEl.size()} first value ${first}\")\n\n        then:\n        partialEl?.size() == 100\n        first?.testNumberInteger == 199\n    }\n}\n"
  },
  {
    "path": "framework/src/test/groovy/L10nFacadeTests.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\nimport spock.lang.*\n\nimport org.moqui.context.ExecutionContext\nimport org.moqui.Moqui\nimport org.moqui.entity.EntityValue\nimport java.sql.Timestamp\n\nclass L10nFacadeTests extends Specification {\n    @Shared\n    ExecutionContext ec\n\n    def setupSpec() {\n        // init the framework, get the ec\n        ec = Moqui.getExecutionContext()\n    }\n\n    def cleanupSpec() {\n        ec.destroy()\n    }\n\n    @Unroll\n    def \"get Localized Message (#original - #language #country)\"() {\n        // NOTE: this relies on a LocalizedMessage records in CommonL10nData.xml\n        expect:\n        ec.user.setLocale(new Locale(language, country))\n        localized == ec.l10n.localize(original)\n\n        cleanup:\n        ec.user.setLocale(Locale.US)\n\n        where:\n        original | language | country | localized\n        \"Create\" | \"en\" | \"\"  | \"Create\"\n        \"Create\" | \"es\" | \"\"  | \"Crear\"\n        \"Create\" | \"es\" | \"ES\"  | \"Crear\"\n        \"Create\" | \"es\" | \"MX\"  | \"Crear\"\n        \"Create\" | \"fr\" | \"\"  | \"Cr\\u00E9er\"\n        \"Create\" | \"zh\" | \"\"  | \"\\u65B0\\u5EFA\" // for XML: &#26032;&#24314;\n        \"Not Localized\" | \"en\" | \"\"  | \"Not Localized\"\n        \"Not Localized\" | \"es\" | \"\"  | \"Not Localized\"\n        \"Not Localized\" | \"zh\" | \"\"  | \"Not Localized\"\n    }\n\n    @Unroll\n    def \"LocalizedEntityField with Enumeration.description (#enumId - #language #country)\"() {\n        // NOTE: this relies on a LocalizedEntityField records in CommonL10nData.xml\n        setup:\n        ec.artifactExecution.disableAuthz()\n\n        expect:\n        ec.user.setLocale(new Locale(language, country))\n        EntityValue enumValue = ec.entity.find(\"Enumeration\").condition(\"enumId\", enumId).one()\n        localized == enumValue.get(\"description\")\n\n        cleanup:\n        ec.artifactExecution.enableAuthz()\n        ec.user.setLocale(Locale.US)\n\n        where:\n        enumId | language | country | localized\n        \"GEOT_CITY\" | \"en\" | \"\" | \"City\"\n        \"GEOT_CITY\" | \"es\" | \"\"  | \"Ciudad\"\n        \"GEOT_CITY\" | \"es\" | \"ES\"  | \"Ciudad\"\n        \"GEOT_CITY\" | \"es\" | \"MX\"  | \"Ciudad\"\n        \"GEOT_CITY\" | \"zh\" | \"\"  | \"\\u5E02\" // for XML: &#24066;\n        \"GEOT_STATE\" | \"en\" | \"\"  | \"State\"\n        \"GEOT_STATE\" | \"es\" | \"\"  | \"Estado\"\n        \"GEOT_COUNTRY\" | \"es\" | \"\"  | \"Pa\\u00EDs\"\n    }\n\n    /* TODO alternative for example\n    def \"localized message with variable expansion\"() {\n        // test localized message with variable expansion (ensure translate then expand)\n        // NOTE: this relies on a LocalizedMessage record in ExampleL10nData.xml\n        expect:\n        ec.l10n.localize(\"Test expansion \\${ec.user.locale} original\") == \"Test expansion \\${ec.tenantId} localized\"\n        ec.resource.expand(\"Test expansion \\${ec.user.locale} original\", \"\") == \"Test expansion DEFAULT localized\"\n    }\n    */\n\n    def \"format USD and GBP currency in US and UK locales\"() {\n        expect:\n        ec.user.setLocale(Locale.US)\n        ec.l10n.formatCurrency(new BigDecimal(\"12.34\"), \"USD\", 2) == '$12.34'\n        ec.l10n.formatCurrency(new BigDecimal(\"43.21\"), \"GBP\", 2) in [\"GBP43.21\", \"£43.21\"]\n        ec.user.setLocale(Locale.UK)\n        ec.l10n.formatCurrency(new BigDecimal(\"12.34\"), \"USD\", 2) in [\"USD12.34\", '$12.34']\n        ec.l10n.formatCurrency(new BigDecimal(\"43.21\"), \"GBP\", 2) == \"\\u00A343.21\"\n\n        cleanup:\n        // back to the default\n        ec.user.setLocale(Locale.US)\n    }\n\n    @Unroll\n    def \"format output value (#value - #format)\"() {\n        expect:\n        result == ec.l10n.format(value, format)\n\n        where:\n        value | format | result\n        new BigDecimal(\"5\") | \"##.#\" | \"5\"\n        new BigDecimal(\"5\") | \"##.00\" | \"5.00\"\n        Timestamp.valueOf(\"2010-01-02 12:34:56.789\") | \"yyyy-MM-dd\" | \"2010-01-02\"\n        Timestamp.valueOf(\"2010-01-02 12:34:56.789\") | \"d MMM yyyy\" | \"2 Jan 2010\"\n        Timestamp.valueOf(\"2010-01-02 12:34:56.789\") | \"hh:mm:ss\" | \"12:34:56\"\n    }\n\n    def \"parse time\"() {\n        expect:\n        java.sql.Time.valueOf(\"12:34:56\") == ec.l10n.parseTime(\"12:34:56\", \"HH:mm:ss\")\n        java.sql.Time.valueOf(\"00:34:56\") == ec.l10n.parseTime(\"12:34:56 AM\", \"hh:mm:ss a\")\n        java.sql.Time.valueOf(\"12:34:56\") == ec.l10n.parseTime(\"12:34:56 PM\", \"hh:mm:ss a\")\n    }\n\n    def \"parse date\"() {\n        expect:\n        java.sql.Date.valueOf(\"2010-01-02\") == ec.l10n.parseDate(\"2010-01-02\", \"yyyy-MM-dd\")\n        java.sql.Date.valueOf(\"2010-01-02\") == ec.l10n.parseDate(\"2 Jan 2010\", \"d MMM yyyy\")\n    }\n\n    def \"parse timestamp\"() {\n        expect:\n        Timestamp.valueOf(\"2010-01-02 12:34:56.000\") == ec.l10n.parseTimestamp(\"2010-01-02 12:34:56\", \"yyyy-MM-dd HH:mm:ss\")\n    }\n\n    // TODO test parseDateTime\n    // TODO test parseNumber\n}\n"
  },
  {
    "path": "framework/src/test/groovy/MessageFacadeTests.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\nimport spock.lang.*\n\nimport org.moqui.context.ExecutionContext\nimport org.moqui.Moqui\nimport org.moqui.entity.EntityValue\nimport java.sql.Timestamp\n\nclass MessageFacadeTests extends Specification {\n    @Shared\n    ExecutionContext ec\n\n    def setupSpec() {\n        // init the framework, get the ec\n        ec = Moqui.getExecutionContext()\n    }\n\n    def cleanupSpec() {\n        ec.destroy()\n    }\n\n    def \"add non-error message\"() {\n        when:\n        String testMessage = \"This is a test message\"\n        ec.message.addMessage(testMessage)\n\n        then:\n        ec.message.messages.contains(testMessage)\n        ec.message.messagesString.contains(testMessage)\n        !ec.message.hasError()\n\n        cleanup:\n        ec.message.clearAll()\n    }\n\n    def \"add public message\"() {\n        when:\n        String testMessage = \"This is a test public message\"\n        ec.message.addPublic(testMessage, 'warning')\n\n        then:\n        ec.message.messages.contains(testMessage)\n        ec.message.messageInfos[0].typeString == 'warning'\n        ec.message.messagesString.contains(testMessage)\n        ec.message.publicMessages.contains(testMessage)\n        ec.message.publicMessageInfos[0].typeString == 'warning'\n        !ec.message.hasError()\n\n        cleanup:\n        ec.message.clearAll()\n    }\n\n    def \"add error message\"() {\n        when:\n        String testMessage = \"This is a test error message\"\n        ec.message.addError(testMessage)\n\n        then:\n        ec.message.errors.contains(testMessage)\n        ec.message.errorsString.contains(testMessage)\n        ec.message.hasError()\n\n        cleanup:\n        ec.message.clearErrors()\n    }\n\n    def \"add validation error\"() {\n        when:\n        String errorMessage = \"This is a test validation error\"\n        ec.message.addValidationError(\"form\", \"field\", \"service\", errorMessage, new Exception(\"validation error location\"))\n\n        then:\n        ec.message.validationErrors[0].message == errorMessage\n        ec.message.validationErrors[0].form == \"form\"\n        ec.message.validationErrors[0].field == \"field\"\n        ec.message.validationErrors[0].serviceName == \"service\"\n        ec.message.errorsString.contains(errorMessage)\n        ec.message.hasError()\n\n        cleanup:\n        ec.message.clearErrors()\n    }\n}\n"
  },
  {
    "path": "framework/src/test/groovy/MoquiSuite.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a \n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\nimport org.junit.jupiter.api.AfterAll\nimport org.junit.platform.suite.api.SelectClasses\nimport org.junit.platform.suite.api.Suite\nimport org.moqui.Moqui\n\n// for JUnit Platform Suite annotations see: https://junit.org/junit5/docs/current/api/org.junit.platform.suite.api/org/junit/platform/suite/api/package-summary.html\n// for JUnit 5 Jupiter annotations see: https://junit.org/junit5/docs/current/user-guide/index.html#writing-tests-annotations\n\n@Suite\n@SelectClasses([ CacheFacadeTests.class, EntityCrud.class, EntityFindTests.class, EntityNoSqlCrud.class,\n        L10nFacadeTests.class, MessageFacadeTests.class, ResourceFacadeTests.class, ServiceCrudImplicit.class,\n        ServiceFacadeTests.class, SubSelectTests.class, TransactionFacadeTests.class, UserFacadeTests.class,\n        SystemScreenRenderTests.class, ToolsRestApiTests.class, ToolsScreenRenderTests.class])\nclass MoquiSuite {\n    @AfterAll\n    static void destroyMoqui() {\n        Moqui.destroyActiveExecutionContextFactory()\n    }\n}\n"
  },
  {
    "path": "framework/src/test/groovy/ResourceFacadeTests.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\nimport spock.lang.*\n\nimport org.moqui.context.ExecutionContext\nimport org.moqui.Moqui\nimport org.moqui.resource.ResourceReference\n\nclass ResourceFacadeTests extends Specification {\n    @Shared\n    ExecutionContext ec\n\n    def setupSpec() {\n        // init the framework, get the ec\n        ec = Moqui.getExecutionContext()\n    }\n\n    def cleanupSpec() {\n        ec.destroy()\n    }\n\n    @Unroll\n    def \"get Location ResourceReference (#location)\"() {\n        expect:\n        ResourceReference rr = ec.resource.getLocationReference(location)\n        // the resolved location is different for some of these tests, so don't test for that: rr.location == location\n        rr.uri.scheme == scheme\n        rr.uri.host == host\n        rr.fileName == fileName\n        rr.contentType == contentType\n        (!rr.supportsExists() || rr.exists) == exists\n        (!rr.supportsDirectory() || rr.file) == isFile\n        (!rr.supportsDirectory() || rr.directory) == isDirectory\n\n        where:\n        location | scheme | host | fileName | contentType | exists | isFile | isDirectory\n        \"component://tools/screen/Tools.xml\" | \"file\" | null | \"Tools.xml\" | \"text/xml\" | true | true | false\n        \"component://tools/screen/ToolsFoo.xml\" | \"file\" | null | \"ToolsFoo.xml\" | \"text/xml\" | false | false | false\n        \"classpath://entity/BasicEntities.xml\" | \"file\" | null | \"BasicEntities.xml\" | \"text/xml\" | true | true | false\n        \"classpath://bitronix-default-config.properties\" | \"file\" | null | \"bitronix-default-config.properties\" | \"text/x-java-properties\" | true | true | false\n        \"classpath://shiro.ini\" | \"file\" | null | \"shiro.ini\" | \"text/plain\" | true | true | false\n        \"template/screen-macro/ScreenHtmlMacros.ftl\" | \"file\" | null | \"ScreenHtmlMacros.ftl\" | \"text/x-freemarker\" | true | true | false\n        \"template/screen-macro\" | \"file\" | null | \"screen-macro\" | \"application/octet-stream\" | true | false | true\n    }\n\n    @Unroll\n    def \"get Location Text (#location)\"() {\n        expect:\n        String text = ec.resource.getLocationText(location, true)\n        text.contains(contents)\n\n        where:\n        location | contents\n        \"component://tools/screen/Tools.xml\" | \"<subscreens default-item=\\\"dashboard\\\">\"\n        \"classpath://shiro.ini\" | \"org.moqui.impl.util.MoquiShiroRealm\"\n    }\n\n    // TODO: add tests for template() and script()\n\n    @Unroll\n    def \"groovy evaluate Condition (#expression)\"() {\n        expect:\n        result == ec.resource.condition(expression, \"\")\n\n        where:\n        expression | result\n        \"true\" | true\n        \"false\" | false\n        \"ec.context instanceof org.moqui.util.ContextStack\" | true\n    }\n\n    @Unroll\n    def \"groovy evaluate Context Field (#expression)\"() {\n        expect:\n        result == ec.resource.expression(expression, \"\")\n\n        where:\n        expression | result\n        \"ec.factory.moquiVersion\" | ec.factory.moquiVersion\n        \"null\" | null\n        \"undefinedVariable\" | null\n    }\n\n    @Unroll\n    def \"groovy evaluate String Expand (#inputString)\"() {\n        expect:\n        result == ec.resource.expand(inputString, \"\")\n\n        where:\n        inputString | result\n        'Version: ${ec.factory.moquiVersion}' | \"Version: ${ec.factory.moquiVersion}\"\n        \"plain string\" | \"plain string\"\n    }\n}\n"
  },
  {
    "path": "framework/src/test/groovy/ServiceCrudImplicit.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\nimport spock.lang.*\n\nimport org.moqui.context.ExecutionContext\nimport org.moqui.entity.EntityValue\nimport org.moqui.Moqui\n\nclass ServiceCrudImplicit extends Specification {\n    @Shared\n    ExecutionContext ec\n\n    def setupSpec() {\n        // init the framework, get the ec\n        ec = Moqui.getExecutionContext()\n    }\n\n    def cleanupSpec() {\n        ec.destroy()\n    }\n\n    def setup() {\n        ec.artifactExecution.disableAuthz()\n    }\n\n    def cleanup() {\n        ec.artifactExecution.enableAuthz()\n    }\n\n    def \"create and find TestEntity SVCTST1 with service\"() {\n        when:\n        ec.service.sync().name(\"create#moqui.test.TestEntity\").parameters([testId:\"SVCTST1\", testMedium:\"Test Name\"]).call()\n        EntityValue testEntity = ec.entity.find(\"moqui.test.TestEntity\").condition([testId:\"SVCTST1\"]).one()\n\n        then:\n        testEntity.testMedium == \"Test Name\"\n    }\n\n    def \"update TestEntity SVCTST1 with service\"() {\n        when:\n        ec.service.sync().name(\"update#moqui.test.TestEntity\").parameters([testId:\"SVCTST1\", testMedium:\"Test Name 2\"]).call()\n        EntityValue testEntityCheck = ec.entity.find(\"moqui.test.TestEntity\").condition([testId:\"SVCTST1\"]).one()\n\n        then:\n        testEntityCheck.testMedium == \"Test Name 2\"\n    }\n\n    def \"store update TestEntity SVCTST1 with service\"() {\n        when:\n        ec.service.sync().name(\"store#moqui.test.TestEntity\").parameters([testId:\"SVCTST1\", testMedium:\"Test Name 3\"]).call()\n        EntityValue testEntityCheck = ec.entity.find(\"moqui.test.TestEntity\").condition([testId:\"SVCTST1\"]).one()\n\n        then:\n        testEntityCheck.testMedium == \"Test Name 3\"\n    }\n\n    def \"delete TestEntity SVCTST1 with service\"() {\n        when:\n        ec.service.sync().name(\"delete#moqui.test.TestEntity\").parameters([testId:\"SVCTST1\"]).call()\n        EntityValue testEntityCheck = ec.entity.find(\"moqui.test.TestEntity\").condition([testId:\"SVCTST1\"]).one()\n\n        then:\n        testEntityCheck == null\n    }\n\n    def \"store create TestEntity TEST_A with service\"() {\n        when:\n        ec.service.sync().name(\"store#moqui.test.TestEntity\").parameters([testId:\"SVCTSTA\", testMedium:\"Test Name A\"]).call()\n        EntityValue testEntityCheck = ec.entity.find(\"moqui.test.TestEntity\").condition([testId:\"SVCTSTA\"]).one()\n\n        then:\n        testEntityCheck.testMedium == \"Test Name A\"\n    }\n\n    def \"create and find TestIntPk 123 with service\"() {\n        when:\n        // create with String for ID though is type number-integer, test single PK type conversion\n        ec.service.sync().name(\"create#moqui.test.TestIntPk\").parameters([intId:\"123\", testMedium:\"Test Name\"]).call()\n        EntityValue testString = ec.entity.find(\"moqui.test.TestIntPk\").condition([intId:\"123\"]).one()\n        EntityValue testInt = ec.entity.find(\"moqui.test.TestIntPk\").condition([intId:123]).one()\n\n        then:\n        testString?.testMedium == \"Test Name\"\n        testInt?.testMedium == \"Test Name\"\n    }\n\n}\n"
  },
  {
    "path": "framework/src/test/groovy/ServiceFacadeTests.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\n\nimport org.moqui.impl.service.ServiceFacadeImpl\nimport org.moqui.service.ServiceCallback\nimport spock.lang.*\n\nimport org.moqui.context.ExecutionContext\nimport org.moqui.Moqui\n\nclass ServiceFacadeTests extends Specification {\n    @Shared\n    ExecutionContext ec\n\n    def setupSpec() {\n        // init the framework, get the ec\n        ec = Moqui.getExecutionContext()\n    }\n\n    def cleanupSpec() {\n        ec.destroy()\n    }\n\n    def \"register callback concurrently\"() {\n        def sfi = (ServiceFacadeImpl)ec.service\n        ServiceCallback scb = Mock(ServiceCallback)\n\n        when:\n        ConcurrentExecution.executeConcurrently(10, { sfi.registerCallback(\"foo\", scb) })\n        sfi.callRegisteredCallbacks(\"foo\", null, null)\n\n        then:\n        10 * scb.receiveEvent(null, null)\n    }\n}\n"
  },
  {
    "path": "framework/src/test/groovy/SubSelectTests.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\nimport org.moqui.Moqui\nimport org.moqui.context.ExecutionContext\nimport org.moqui.entity.EntityFind\nimport org.moqui.entity.EntityList\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\nimport spock.lang.Shared\nimport spock.lang.Specification\n\nimport java.sql.Timestamp\n\nclass SubSelectTests extends Specification {\n    protected final static Logger logger = LoggerFactory.getLogger(SubSelectTests.class)\n\n    @Shared\n    ExecutionContext ec\n    @Shared\n    Timestamp timestamp\n\n    def setupSpec() {\n        // init the framework, get the ec\n        ec = Moqui.getExecutionContext()\n        timestamp = ec.user.nowTimestamp\n    }\n\n    def cleanupSpec() {\n        ec.destroy()\n    }\n\n    def setup() {\n        ec.artifactExecution.disableAuthz()\n        ec.transaction.begin(null)\n        // create some entity to trigger the table creation.\n        ec.entity.makeValue(\"moqui.test.Foo\").setAll([fooId:\"EXTST1\"]).createOrUpdate()\n        ec.entity.makeValue(\"moqui.test.Bar\").setAll([barId:\"EXTST1\"]).createOrUpdate()\n        ec.entity.makeValue(\"moqui.test.Foo\").setAll([fooId:\"EXTST1\"]).delete()\n        ec.entity.makeValue(\"moqui.test.Bar\").setAll([barId:\"EXTST1\"]).delete()\n    }\n\n    def cleanup() {\n        ec.artifactExecution.enableAuthz()\n        ec.transaction.commit()\n    }\n\n    def \"find subselect search form equal\"() {\n        when:\n        EntityFind find =  ec.entity.find(\"moqui.test.FooBar\").searchFormMap([\"barRank\":100], null,null,null,true)\n        EntityList list = find.list()\n\n        then:\n        list.isEmpty()\n        find.getQueryTextList()[0].contains(\" BAR_RANK = ? \")\n    }\n\n    def \"find subselect search form range\"() {\n        when:\n        EntityFind find = ec.entity.find(\"moqui.test.FooBar\").searchFormMap([\"barRank_from\":100], null,null,null,true)\n        EntityList list = find.list()\n\n        then:\n        list.isEmpty()\n        find.getQueryTextList()[0].contains(\" BAR_RANK >= ? \")\n    }\n}\n"
  },
  {
    "path": "framework/src/test/groovy/SystemScreenRenderTests.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\nimport org.moqui.Moqui\nimport org.moqui.context.ExecutionContext\nimport org.moqui.screen.ScreenTest\nimport org.moqui.screen.ScreenTest.ScreenTestRender\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\nimport spock.lang.Shared\nimport spock.lang.Specification\nimport spock.lang.Unroll\n\nclass SystemScreenRenderTests extends Specification {\n    protected final static Logger logger = LoggerFactory.getLogger(SystemScreenRenderTests.class)\n\n    @Shared\n    ExecutionContext ec\n    @Shared\n    ScreenTest screenTest\n\n    def setupSpec() {\n        ec = Moqui.getExecutionContext()\n        ec.user.loginUser(\"john.doe\", \"moqui\")\n        screenTest = ec.screen.makeTest().baseScreenPath(\"apps/system\")\n    }\n\n    def cleanupSpec() {\n        long totalTime = System.currentTimeMillis() - screenTest.startTime\n        logger.info(\"Rendered ${screenTest.renderCount} screens (${screenTest.errorCount} errors) in ${ec.l10n.format(totalTime/1000, \"0.000\")}s, output ${ec.l10n.format(screenTest.renderTotalChars/1000, \"#,##0\")}k chars\")\n\n        ec.destroy()\n    }\n\n    def setup() {\n        ec.artifactExecution.disableAuthz()\n    }\n\n    def cleanup() {\n        ec.artifactExecution.enableAuthz()\n    }\n\n    @Unroll\n    def \"render system screen #screenPath (#containsText1, #containsText2)\"() {\n        setup:\n        ScreenTestRender str = screenTest.render(screenPath, [lastStandalone:\"-2\"], null)\n        // logger.info(\"Rendered ${screenPath} in ${str.getRenderTime()}ms\")\n        boolean contains1 = containsText1 ? str.assertContains(containsText1) : true\n        boolean contains2 = containsText2 ? str.assertContains(containsText2) : true\n        if (!contains1) logger.info(\"In ${screenPath} text 1 [${containsText1}] not found:\\n${str.output}\")\n        if (!contains2) logger.info(\"In ${screenPath} text 2 [${containsText2}] not found:\\n${str.output}\")\n\n        expect:\n        !str.errorMessages\n        contains1\n        contains2\n\n        where:\n        screenPath | containsText1 | containsText2\n        \"dashboard\" | \"\" | \"\"\n\n        // NOTE: see AuditLog, DataDocument, EntitySync, SystemMessage, Visit screen tests in SystemScreenRenderTests in the example component\n\n        // ArtifactHit screens\n        \"ArtifactHitSummary?artifactName=basic&artifactName_op=contains\" | \"moqui.basic.Enumeration\" | \"entity\"\n        \"ArtifactHitBins?artifactName=basic&artifactName_op=contains\" | \"moqui.basic.Enumeration\" | \"create\"\n        // Cache screens\n        \"Cache/CacheList\" | \"entity.definition\" | \"artifact.tarpit.hits\"\n        \"Cache/CacheElements?orderByField=key&cacheName=l10n.message\" | '${artifactName}::en_US' | \"evictionStrategy\"\n\n        // Localization screens\n        \"Localization/Messages\" | \"Add\" | \"Añadir\"\n        \"Localization/EntityFields?entityName=moqui.basic.Enumeration&pkValue=GEOT_STATE\" |\n                \"moqui.basic.Enumeration\" | \"GEOT_STATE\"\n\n        // Print screens\n        // NOTE: without real printers setup and jobs sent through service calls not much to test in Print/* screens\n        \"Print/PrintJob/PrintJobList\" | \"\" | \"\"\n        \"Print/Printer/PrinterList\" | \"\" | \"\"\n\n        // Resource screen\n        // NOTE: without a real browser client not much to test in ElFinder\n        \"Resource/ElFinder\" | \"\" | \"\"\n\n        // Security screens\n        \"Security/UserAccount/UserAccountList?username=john.doe\" | \"john.doe\" | \"John Doe\"\n        \"Security/UserAccount/UserAccountDetail?userId=EX_JOHN_DOE\" |\n                \"john.doe@moqui.org\" | \"Administrators (full access)\"\n        \"Security/UserGroup/UserGroupList\" | \"Administrators (full access)\" | \"\"\n        \"Security/UserGroup/UserGroupDetail?userGroupId=ADMIN\" |\n                \"\" | \"System App (via root screen)\"\n        \"Security/UserGroup/GroupUsers?userGroupId=ADMIN\" | \"john.doe - John Doe\" | \"\"\n        \"Security/ArtifactGroup/ArtifactGroupList\" | \"All Screens\" | \"\"\n        \"Security/ArtifactGroup/ArtifactGroupDetail?artifactGroupId=SYSTEM_APP\" |\n                \"component://tools/screen/System.xml\" | \"Administrators (full access)\"\n    }\n}\n"
  },
  {
    "path": "framework/src/test/groovy/TimezoneTest.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\n\nimport org.moqui.Moqui\nimport org.moqui.context.ExecutionContext\nimport org.moqui.entity.EntityValue\nimport spock.lang.Shared\nimport spock.lang.Specification\n\nimport java.sql.Date\nimport java.sql.Time\nimport java.sql.Timestamp\n\nclass TimezoneTest extends Specification {\n    @Shared\n    ExecutionContext ec\n\n    @Shared\n    String oldTz\n\n    @Shared\n    String oldDbTz\n\n    def setupSpec() {\n        // init the framework, get the ec\n        oldTz = System.setProperty(\"default_time_zone\", 'Pacific/Kiritimati')\n        oldDbTz = System.setProperty(\"database_time_zone\", 'US/Samoa')\n\n        ec = Moqui.getExecutionContext()\n    }\n\n    def cleanupSpec() {\n        ec.destroy()\n        if (oldTz == null) {\n            System.clearProperty(\"default_time_zone\")\n        } else {\n            System.setProperty(\"default_time_zone\", oldTz)\n        }\n        if (oldDbTz == null) {\n            System.clearProperty(\"database_time_zone\")\n        } else {\n            System.setProperty(\"database_time_zone\", oldDbTz)\n        }\n    }\n\n    def setup() {\n        ec.artifactExecution.disableAuthz()\n    }\n\n    def cleanup() {\n        ec.artifactExecution.enableAuthz()\n    }\n\n    def \"test timestamp with timezone\"() {\n        given:\n        Timestamp ts = new Timestamp(0)\n\n        when:\n        EntityValue testValue = ec.entity.makeValue('moqui.test.TestEntity')\n\n        testValue.set('testId', 'TIMEZONE1')\n        testValue.set('testDateTime', ts)\n\n        testValue.create()\n        testValue.refresh()\n        testValue.delete()\n\n        then:\n        testValue.testDateTime.time == ts.time\n\n\n    }\n\n    def \"test time with timezone\"() {\n        given:\n        Time t = new Time(0)\n\n        when:\n        EntityValue testValue = ec.entity.makeValue('moqui.test.TestEntity')\n\n        testValue.set('testId', 'TIMEZONE1')\n        testValue.set('testTime', t)\n\n        testValue.create()\n        testValue.refresh()\n        testValue.delete()\n\n        then:\n        testValue.testTime.toLocalTime() == t.toLocalTime()\n\n    }\n\n    def \"test date with timezone\"() {\n        given:\n        Date d = new Date(0)\n\n        when:\n        EntityValue testValue = ec.entity.makeValue('moqui.test.TestEntity')\n\n        testValue.set('testId', 'TIMEZONE1')\n        testValue.set('testDate', d)\n\n        testValue.create()\n        testValue.refresh()\n        testValue.delete()\n\n        then:\n        testValue.testDate.toLocalDate() == d.toLocalDate()\n\n    }\n}\n"
  },
  {
    "path": "framework/src/test/groovy/ToolsRestApiTests.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\nimport org.moqui.Moqui\nimport org.moqui.context.ExecutionContext\nimport org.moqui.screen.ScreenTest\nimport org.moqui.screen.ScreenTest.ScreenTestRender\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\nimport spock.lang.Shared\nimport spock.lang.Specification\nimport spock.lang.Unroll\n\nclass ToolsRestApiTests extends Specification {\n    protected final static Logger logger = LoggerFactory.getLogger(ToolsRestApiTests.class)\n\n    @Shared\n    ExecutionContext ec\n    @Shared\n    ScreenTest screenTest\n\n    def setupSpec() {\n        ec = Moqui.getExecutionContext()\n        ec.user.loginUser(\"john.doe\", \"moqui\")\n        screenTest = ec.screen.makeTest().baseScreenPath(\"rest\")\n    }\n\n    def cleanupSpec() {\n        long totalTime = System.currentTimeMillis() - screenTest.startTime\n        logger.info(\"Rendered ${screenTest.renderCount} screens (${screenTest.errorCount} errors) in ${ec.l10n.format(totalTime/1000, \"0.000\")}s, output ${ec.l10n.format(screenTest.renderTotalChars/1000, \"#,##0\")}k chars\")\n\n        ec.destroy()\n    }\n\n    def setup() {\n        ec.artifactExecution.disableAuthz()\n    }\n\n    def cleanup() {\n        ec.artifactExecution.enableAuthz()\n    }\n\n    @Unroll\n    def \"call Moqui Tools REST API #screenPath (#containsText1, #containsText2)\"() {\n        expect:\n        ScreenTestRender str = screenTest.render(screenPath, null, null)\n        // logger.info(\"Rendered ${screenPath} in ${str.getRenderTime()}ms\")\n        boolean contains1 = containsText1 ? str.assertContains(containsText1) : true\n        boolean contains2 = containsText2 ? str.assertContains(containsText2) : true\n        if (!contains1) logger.info(\"In ${screenPath} text 1 [${containsText1}] not found:\\n${str.output}\")\n        if (!contains2) logger.info(\"In ${screenPath} text 2 [${containsText2}] not found:\\n${str.output}\")\n        // assertions\n        !str.errorMessages\n        contains1\n        contains2\n\n        where:\n        screenPath | containsText1 | containsText2\n        \"s1/moqui/artifacts/hitSummary?artifactType=AT_ENTITY&artifactSubType=create&artifactName=moqui.basic&artifactName_op=contains\" |\n                \"moqui.basic.StatusType\" | '\"artifactSubType\" : \"create\"'\n        \"s1/moqui/basic/geos/USA\" | \"United States\" | \"Country\"\n        \"s1/moqui/basic/geos/USA/regions\" | \"\" | \"\"\n        \"s1/moqui/email/templates\" | \"PASSWORD_RESET\" | \"Default Password Reset\"\n        // TODO add more... current are enough to make sure Service REST API working generally, but more would be nice\n    }\n}\n"
  },
  {
    "path": "framework/src/test/groovy/ToolsScreenRenderTests.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\nimport org.moqui.Moqui\nimport org.moqui.context.ExecutionContext\nimport org.moqui.screen.ScreenTest\nimport org.moqui.screen.ScreenTest.ScreenTestRender\nimport org.slf4j.Logger\nimport org.slf4j.LoggerFactory\nimport spock.lang.Shared\nimport spock.lang.Specification\nimport spock.lang.Unroll\n\nclass ToolsScreenRenderTests extends Specification {\n    protected final static Logger logger = LoggerFactory.getLogger(ToolsScreenRenderTests.class)\n\n    @Shared\n    ExecutionContext ec\n    @Shared\n    ScreenTest screenTest\n\n    def setupSpec() {\n        ec = Moqui.getExecutionContext()\n        ec.user.loginUser(\"john.doe\", \"moqui\")\n        screenTest = ec.screen.makeTest().baseScreenPath(\"apps/tools\")\n    }\n\n    def cleanupSpec() {\n        long totalTime = System.currentTimeMillis() - screenTest.startTime\n        logger.info(\"Rendered ${screenTest.renderCount} screens (${screenTest.errorCount} errors) in ${ec.l10n.format(totalTime/1000, \"0.000\")}s, output ${ec.l10n.format(screenTest.renderTotalChars/1000, \"#,##0\")}k chars\")\n\n        ec.destroy()\n    }\n\n    def setup() {\n        ec.artifactExecution.disableAuthz()\n    }\n\n    def cleanup() {\n        ec.artifactExecution.enableAuthz()\n    }\n\n    @Unroll\n    def \"render tools screen #screenPath (#containsText1, #containsText2)\"() {\n        setup:\n        ScreenTestRender str = screenTest.render(screenPath, [lastStandalone:\"-2\"], null)\n        // logger.info(\"Rendered ${screenPath} in ${str.getRenderTime()}ms\")\n        boolean contains1 = containsText1 ? str.assertContains(containsText1) : true\n        boolean contains2 = containsText2 ? str.assertContains(containsText2) : true\n        if (!contains1) logger.info(\"In ${screenPath} text 1 [${containsText1}] not found:\\n${str.output}\")\n        if (!contains2) logger.info(\"In ${screenPath} text 2 [${containsText2}] not found:\\n${str.output}\")\n\n        expect:\n        !str.errorMessages\n        contains1\n        contains2\n\n        where:\n        screenPath | containsText1 | containsText2\n        \"dashboard\" | \"\" | \"\"\n\n        // AutoScreen screens\n        \"AutoScreen/MainEntityList\" | \"\" | \"\"\n        \"AutoScreen/AutoFind?aen=moqui.test.TestEntity&testMedium=Test&testMedium_op=begins\" | \"Test Name A\" | \"\"\n        \"AutoScreen/AutoEdit/AutoEditMaster?testId=SVCTSTA&aen=moqui.test.TestEntity\" | \"Test Name A\" | \"\"\n        // TODO \"AutoScreen/AutoEdit/AutoEditDetail?exampleId=TEST1&aen=moqui.example.Example&den=moqui.example.ExampleItem\" | \"Amount Uom ID\" | \"Test 1 Item 1\"\n        // test moqui.test.TestEntity create through transition, then view it\n        \"AutoScreen/AutoFind/create?aen=moqui.test.TestEntity&testId=TEST_SCR&testMedium=Screen Test Example\" | \"\" | \"\"\n        \"AutoScreen/AutoEdit/AutoEditMaster?testId=TEST_SCR&aen=moqui.test.TestEntity\" | \"Screen Test Example\" | \"\"\n\n        // ArtifactStats screen\n        // don't run, takes too long: \"ArtifactStats\" | \"\" | \"\"\n\n        // DataView screens\n        // see \"render DataView screens\"\n\n        // Entity/DataEdit screens\n        \"Entity/DataEdit/EntityList?filterRegexp=basic\" | \"Enumeration\" | \"moqui.basic\"\n        \"Entity/DataEdit/EntityDetail?selectedEntity=moqui.test.TestEntity\" | \"text-medium\" | \"date-time\"\n        \"Entity/DataEdit/EntityDataFind?selectedEntity=moqui.test.TestEntity\" | \"Test Name A\" | \"\"\n        \"Entity/DataEdit/EntityDataEdit?testId=SVCTSTA&selectedEntity=moqui.test.TestEntity\" | \"Test Name A\" | \"\"\n\n        // Other Entity screens\n        \"Entity/DataExport\" | \"moqui.test.TestEntity\" | \"\"\n        // test export JSON and XML for moqui.test.TestEntity\n        \"Entity/DataExport/EntityExport?entityNames=moqui.test.TestEntity&dependentLevels=1&fileType=JSON&output=browser\" | \"Test Name A\" | \"testMedium\"\n        \"Entity/DataExport/EntityExport?entityNames=moqui.test.TestEntity&dependentLevels=1&fileType=XML&output=browser\" | \"Test Name A\" | \"testMedium\"\n        \"Entity/DataImport\" | \"\" | \"\"\n        // test admin user no longer has access to this by default: \"Entity/SqlRunner?groupName=transactional&sql=SELECT * FROM TEST_ENTITY\" | \"Test Name A\" | \"\"\n        // run with very few baseCalls so it doesn't take too long\n        \"Entity/SpeedTest?baseCalls=10\" | \"\" | \"\"\n\n        // Service screens\n        \"Service/ServiceReference?serviceName=UserServices\" |\n                \"org.moqui.impl.UserServices.create#UserAccount\" | \"Service Detail\"\n        \"Service/ServiceDetail?serviceName=org.moqui.impl.UserServices.create#UserAccount\" |\n                \"moqui.security.UserAccount.username\" | \"\"\"ec.service.sync().name(&quot;create#moqui.security.UserAccount&quot;)\"\"\"\n        \"Service/ServiceRun?serviceName=org.moqui.impl.UserServices.create#UserAccount\" |\n                \"User Full Name\" | \"Run Service\"\n        // run the service, then make sure it ran\n        \"Service/ServiceRun/run?serviceName=org.moqui.impl.UserServices.create#UserAccount&username=ScreenTest&newPassword=moqui1!!&newPasswordVerify=moqui1!!&userFullName=Screen Test User&emailAddress=screen@test.com\" | \"\" | \"\"\n        \"Entity/DataEdit/EntityDataFind?username=ScreenTest&selectedEntity=moqui.security.UserAccount\" |\n                \"Screen Test User\" | \"screen@test.com\"\n    }\n\n    def \"render DataView screens\"() {\n        // create a DbViewEntity, set MASTER and fields, view it\n        when:\n        ScreenTestRender createStr = screenTest.render(\"DataView/FindDbView/create\",\n                [dbViewEntityName: 'UomDbView', packageName: 'test.basic', isDataView: 'Y'], null)\n        logger.info(\"Called FindDbView/create in ${createStr.getRenderTime()}ms\")\n\n        ScreenTestRender fdvStr = screenTest.render(\"DataView/FindDbView\", [lastStandalone:\"-2\"], null)\n        logger.info(\"Rendered DataView/FindDbView in ${fdvStr.getRenderTime()}ms, ${fdvStr.output?.length()} characters\")\n\n        ScreenTestRender setMeStr = screenTest.render(\"DataView/EditDbView/setMasterEntity\",\n                [dbViewEntityName: 'UomDbView', entityAlias: 'MASTER', entityName: 'moqui.basic.Uom'], null)\n        logger.info(\"Called EditDbView/setMasterEntity in ${setMeStr.getRenderTime()}ms\")\n\n        ScreenTestRender setMfStr = screenTest.render(\"DataView/EditDbView/setMasterFields\",\n                [dbViewEntityName_0: 'UomDbView', field_0: 'moqui.basic.Uom.description',\n                 dbViewEntityName_1: 'UomDbView', field_1: 'UomType#moqui.basic.Enumeration.description',\n                 dbViewEntityName_2: 'UomDbView', field_2: 'UomType#moqui.basic.Enumeration.enumTypeId'], null)\n        logger.info(\"Called EditDbView/setMasterFields in ${setMfStr.getRenderTime()}ms\")\n\n        ScreenTestRender vdvStr = screenTest.render(\"DataView/ViewDbView?dbViewEntityName=UomDbView&orderByField=description\", [lastStandalone:\"-2\"], null)\n        logger.info(\"Rendered DataView/FindDbView in ${vdvStr.getRenderTime()}ms, ${vdvStr.output?.length()} characters\")\n\n        then:\n        !createStr.errorMessages\n        !fdvStr.errorMessages\n        fdvStr.assertContains(\"UomDbView\")\n        !setMeStr.errorMessages\n        !setMfStr.errorMessages\n        !vdvStr.errorMessages\n        vdvStr.assertContains(\"Afghani\")\n        vdvStr.assertContains(\"Area\") // for Acre\n    }\n}\n"
  },
  {
    "path": "framework/src/test/groovy/TransactionFacadeTests.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n *\n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n *\n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\nimport java.sql.Connection\nimport java.sql.Statement\n\nimport org.moqui.Moqui\nimport org.moqui.context.ExecutionContext\n\nimport spock.lang.Shared\nimport spock.lang.Specification\n\nclass TransactionFacadeTests extends Specification {\n    @Shared\n    ExecutionContext ec\n\n    def setupSpec() {\n        // init the framework, get the ec\n        ec = Moqui.getExecutionContext()\n    }\n\n    def cleanupSpec() {\n        ec.destroy()\n    }\n\n    def \"test connection bind to tx\"() {\n        when:\n        boolean beganTransaction = false\n        Connection rawCon1, rawCon2, rawCon3\n        try {\n            beganTransaction = ec.transaction.begin(null)\n            Connection conn1 = ec.entity.getConnection(\"transactional\")\n            Statement st = conn1.createStatement()\n            rawCon1 = conn1.unwrap(Connection.class)\n            conn1.close()\n\n            Connection conn2 = ec.entity.getConnection(\"transactional\")\n            conn2.createStatement()\n            rawCon2 = conn2.unwrap(Connection.class)\n            conn2.close()\n\n            Connection conn3 = ec.entity.getConnection(\"transactional\")\n            conn3.createStatement()\n            rawCon3 = conn3.unwrap(Connection.class)\n            conn3.close()\n        }  finally {\n            ec.transaction.commit(beganTransaction)\n        }\n\n        then:\n        noExceptionThrown()\n        rawCon1 == rawCon2\n        rawCon1 == rawCon3\n    }\n\n    def \"test connection bind to tx atomikos bug\"() {\n        when:\n        boolean beganTransaction = false\n        Connection rawCon1, rawCon2, rawCon3\n        try {\n            beganTransaction = ec.transaction.begin(null)\n            Connection conn1 = ec.entity.getConnection(\"transactional\")\n            Statement st = conn1.createStatement()\n\n            Connection conn2 = ec.entity.getConnection(\"transactional\")\n            conn2.createStatement()\n            rawCon2 = conn2.unwrap(Connection.class)\n            conn2.close()\n\n            rawCon1 = conn1.unwrap(Connection.class)\n            conn1.close()\n\n            Connection conn3 = ec.entity.getConnection(\"transactional\")\n            conn3.createStatement()\n            rawCon3 = conn3.unwrap(Connection.class)\n            conn3.close()\n        }  finally {\n            ec.transaction.commit(beganTransaction)\n        }\n\n        then:\n        noExceptionThrown()\n        rawCon1 == rawCon2\n        rawCon1 == rawCon3\n    }\n\n    def \"test suspend resume\"() {\n        when:\n        boolean beganTransaction = false\n        Connection rawCon1, rawCon2, rawCon3\n        try {\n            beganTransaction = ec.transaction.begin(null)\n            Connection conn1 = ec.entity.getConnection(\"transactional\")\n            Statement st = conn1.createStatement()\n            rawCon1 = conn1.unwrap(Connection.class)\n            conn1.close()\n            ec.transaction.suspend()\n\n            ec.transaction.begin(null)\n            Connection conn2 = ec.entity.getConnection(\"transactional\")\n            conn2.createStatement()\n            rawCon2 = conn2.unwrap(Connection.class)\n            conn2.close()\n            ec.transaction.commit()\n\n            ec.transaction.resume()\n            Connection conn3 = ec.entity.getConnection(\"transactional\")\n            conn3.createStatement()\n            rawCon3 = conn3.unwrap(Connection.class)\n            conn3.close()\n        }  finally {\n            ec.transaction.commit(beganTransaction)\n        }\n\n        then:\n        noExceptionThrown()\n        rawCon1 != rawCon2\n        rawCon1 == rawCon3\n    }\n\n    def \"test atomikos bug\"() {\n        when:\n        // This bug cause runtime add missing not work\n        boolean beganTransaction = false\n        Connection rawCon1, rawCon2, rawCon3\n        try {\n            beganTransaction = ec.transaction.begin(null)\n            Connection conn1 = ec.entity.getConnection(\"transactional\")\n            Statement st = conn1.createStatement()\n            rawCon1 = conn1.unwrap(Connection.class)\n            conn1.close()\n            //A connection close without create statement cause atomikos mark\n            //a previouse to delisted XAResource to terminate state.\n            Connection conn2 = ec.entity.getConnection(\"transactional\")\n            rawCon2 = conn2.unwrap(Connection.class)\n            conn2.close()\n            // A new connection other than conn1 will return.\n            Connection conn3 = ec.entity.getConnection(\"transactional\")\n            // Call createStatement cause enlist, will throw Exception.\n            conn3.createStatement()\n            rawCon3 = conn3.unwrap(Connection.class)\n            conn3.close()\n        }  finally {\n            ec.transaction.commit(beganTransaction)\n        }\n\n        then:\n        noExceptionThrown()\n        rawCon1 == rawCon2\n        rawCon1 == rawCon3\n    }\n}\n"
  },
  {
    "path": "framework/src/test/groovy/UserFacadeTests.groovy",
    "content": "/*\n * This software is in the public domain under CC0 1.0 Universal plus a\n * Grant of Patent License.\n * \n * To the extent possible under law, the author(s) have dedicated all\n * copyright and related and neighboring rights to this software to the\n * public domain worldwide. This software is distributed without any\n * warranty.\n * \n * You should have received a copy of the CC0 Public Domain Dedication\n * along with this software (see the LICENSE.md file). If not, see\n * <http://creativecommons.org/publicdomain/zero/1.0/>.\n */\n\nimport spock.lang.*\n\nimport org.moqui.context.ExecutionContext\nimport org.moqui.Moqui\n\nclass UserFacadeTests extends Specification {\n    @Shared\n    ExecutionContext ec\n\n    def setupSpec() {\n        // init the framework, get the ec\n        ec = Moqui.getExecutionContext()\n    }\n\n    def cleanupSpec() {\n        ec.destroy()\n    }\n\n    def \"login user john.doe\"() {\n        expect:\n        ec.user.loginUser(\"john.doe\", \"moqui\")\n    }\n\n    def \"check userId username currencyUomId locale userAccount.userFullName defaults\"() {\n        expect:\n        ec.user.userId == \"EX_JOHN_DOE\"\n        ec.user.username == \"john.doe\"\n        ec.user.locale.toString() == \"en_US\"\n        ec.user.timeZone.ID == \"US/Central\"\n        ec.user.currencyUomId == \"USD\"\n        ec.user.userAccount.userFullName == \"John Doe\"\n    }\n\n    def \"set and get Locale UK\"() {\n        when:\n        ec.user.setLocale(Locale.UK)\n        then:\n        ec.user.getLocale() == Locale.UK\n        ec.user.getLocale().toString() == \"en_GB\"\n    }\n    def \"set and get Locale US\"() {\n        when:\n        // set back to en_us\n        ec.user.setLocale(Locale.US)\n        then:\n        ec.user.locale.toString() == \"en_US\"\n    }\n\n    def \"set and get TimeZone US/Pacific\"() {\n        when:\n        ec.user.setTimeZone(TimeZone.getTimeZone(\"US/Pacific\"))\n        then:\n        ec.user.getTimeZone() == TimeZone.getTimeZone(\"US/Pacific\")\n        ec.user.getTimeZone().getID() == \"US/Pacific\"\n        ec.user.getTimeZone().getRawOffset() == -28800000\n    }\n\n    def \"set and get TimeZone US/Central\"() {\n        when:\n        // set TimeZone back to default US/Central\n        ec.user.setTimeZone(TimeZone.getTimeZone(\"US/Central\"))\n        then:\n        ec.user.getTimeZone().getID() == \"US/Central\"\n    }\n\n    def \"set and get currencyUomId GBP\"() {\n        when:\n        ec.user.setCurrencyUomId(\"GBP\")\n        then:\n        ec.user.getCurrencyUomId() == \"GBP\"\n    }\n\n    def \"set and get currencyUomId USD\"() {\n        when:\n        // reset to the default USD\n        ec.user.setCurrencyUomId(\"USD\")\n        then:\n        ec.user.getCurrencyUomId() == \"USD\"\n    }\n\n    def \"check userGroupIdSet and isInGroup for ALL_USERS and ADMIN\"() {\n        expect:\n        ec.user.userGroupIdSet.contains(\"ALL_USERS\")\n        ec.user.isInGroup(\"ALL_USERS\")\n        ec.user.userGroupIdSet.contains(\"ADMIN\")\n        ec.user.isInGroup(\"ADMIN\")\n    }\n\n    /* TODO replacement for this\n    def \"check default admin group permission ExamplePerm\"() {\n        expect:\n        ec.user.hasPermission(\"ExamplePerm\")\n        !ec.user.hasPermission(\"BogusPerm\")\n    }\n    */\n\n    def \"not in web context so no visit\"() {\n        expect:\n        ec.user.visitId == null\n    }\n\n    def \"set and get Preference\"() {\n        when:\n        ec.user.setPreference(\"testPref1\", \"prefValue1\")\n        then:\n        ec.user.getPreference(\"testPref1\") == \"prefValue1\"\n    }\n\n    def \"logout user\"() {\n        expect:\n        ec.user.logoutUser()\n    }\n}\n"
  },
  {
    "path": "framework/template/XmlActions.groovy.ftl",
    "content": "<#--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\nimport static org.moqui.util.ObjectUtilities.*\nimport static org.moqui.util.CollectionUtilities.*\nimport static org.moqui.util.StringUtilities.*\nimport java.sql.Timestamp\nimport java.sql.Time\nimport java.time.*\n// these are in the context by default: ExecutionContext ec, Map<String, Object> context, Map<String, Object> result\n<#visit xmlActionsRoot/>\n\n<#macro actions>\n<#recurse/>\n// make sure the last statement is not considered the return value\nreturn;\n</#macro>\n<#macro \"always-actions\">\n<#recurse/>\n// make sure the last statement is not considered the return value\nreturn;\n</#macro>\n<#macro \"pre-actions\">\n<#recurse/>\n// make sure the last statement is not considered the return value\nreturn;\n</#macro>\n<#macro \"row-actions\">\n<#recurse/>\n// make sure the last statement is not considered the return value\nreturn;\n</#macro>\n\n<#-- NOTE should we handle out-map?has_content and async!=false with a ServiceResultWaiter? -->\n<#macro \"service-call\">\n    <#assign handleResult = (.node[\"@out-map\"]?has_content && (!.node[\"@async\"]?has_content || .node[\"@async\"] == \"false\"))>\n    <#assign outAapAddToExisting = !.node[\"@out-map-add-to-existing\"]?has_content || .node[\"@out-map-add-to-existing\"] == \"true\">\n    <#assign isAsync = .node.@async?has_content && .node.@async != \"false\">\n    if (true) {\n        <#if handleResult>def call_service_result = </#if>ec.service.<#if isAsync>async()<#else>sync()</#if><#rt>\n            <#t>.name(\"${.node.@name}\")<#if .node[\"@async\"]?if_exists == \"distribute\">.distribute(true)</#if>\n            <#t><#if !isAsync && .node[\"@disable-authz\"]?if_exists == \"true\">.disableAuthz()</#if>\n            <#t><#if !isAsync && .node[\"@multi\"]?if_exists == \"true\">.multi(true)</#if><#if !isAsync && .node[\"@multi\"]?if_exists == \"parameter\">.multi(ec.web?.requestParameters?._isMulti == \"true\")</#if>\n            <#t><#if !isAsync && .node[\"@transaction\"]?has_content><#if .node[\"@transaction\"] == \"ignore\">.ignoreTransaction(true)<#elseif .node[\"@transaction\"] == \"force-new\" || .node[\"@transaction\"] == \"force-cache\">.requireNewTransaction(true)</#if>\n            <#t><#if !isAsync && .node[\"@transaction-timeout\"]?has_content>.transactionTimeout(${.node[\"@transaction-timeout\"]})</#if>\n            <#t><#if !isAsync && (.node[\"@transaction\"] == \"cache\" || .node[\"@transaction\"] == \"force-cache\")>.useTransactionCache(true)<#else>.useTransactionCache(false)</#if></#if>\n            <#if .node[\"@in-map\"]?if_exists == \"true\">.parameters(context)<#elseif .node[\"@in-map\"]?has_content && .node[\"@in-map\"] != \"false\">.parameters(${.node[\"@in-map\"]})</#if><#list .node[\"field-map\"] as fieldMap>.parameter(\"${fieldMap[\"@field-name\"]}\",<#if fieldMap[\"@from\"]?has_content>${fieldMap[\"@from\"]}<#elseif fieldMap[\"@value\"]?has_content>\"\"\"${fieldMap[\"@value\"]}\"\"\"<#else>${fieldMap[\"@field-name\"]}</#if>)</#list>.call()\n        <#if handleResult><#if outAapAddToExisting>if (${.node[\"@out-map\"]} != null) { if (call_service_result) ${.node[\"@out-map\"]}.putAll(call_service_result) } else {</#if> ${.node[\"@out-map\"]} = call_service_result <#if outAapAddToExisting>}</#if></#if>\n        <#if (.node[\"@web-send-json-response\"]?if_exists == \"true\")>\n        ec.web.sendJsonResponse(call_service_result)\n        <#elseif (.node[\"@web-send-json-response\"]?has_content && .node[\"@web-send-json-response\"] != \"false\")>\n        ec.web.sendJsonResponse(ec.resource.expression(\"${.node[\"@web-send-json-response\"]}\", \"\", call_service_result))\n        </#if>\n        <#if (.node[\"@ignore-error\"]?if_exists == \"true\")>\n        if (ec.message.hasError()) {\n            ec.logger.warn(\"Ignoring error running service ${.node.@name}: \" + ec.message.getErrorsString())\n            ec.message.clearErrors()\n        }\n        <#else>\n        if (ec.message.hasError()) return\n        </#if><#t>\n    }\n</#macro>\n\n<#macro \"script\"><#if .node[\"@location\"]?has_content>ec.resource.script(\"${.node[\"@location\"]}\", null)</#if>\n// begin inline script\n${.node}\n// end inline script\n</#macro>\n\n<#macro set>\n    <#if .node[\"@set-if-empty\"]?has_content && .node[\"@set-if-empty\"] == \"false\">\n    _temp_internal = <#if .node[\"@type\"]?has_content>basicConvert</#if>(<#if .node[\"@from\"]?has_content>${.node[\"@from\"]}<#elseif .node[\"@value\"]?has_content>\"\"\"${.node[\"@value\"]}\"\"\"<#else>null</#if><#if .node[\"@default-value\"]?has_content> ?: \"\"\"${.node[\"@default-value\"]}\"\"\"</#if><#if .node[\"@type\"]?has_content>, \"${.node[\"@type\"]}\"</#if>)\n    if (!isEmpty(_temp_internal)) ${.node[\"@field\"]} = _temp_internal\n    <#else>\n    ${.node[\"@field\"]} = <#if .node[\"@type\"]?has_content>basicConvert</#if>(<#if .node[\"@from\"]?has_content>${.node[\"@from\"]}<#elseif .node[\"@value\"]?has_content>\"\"\"${.node[\"@value\"]}\"\"\"<#else>null</#if><#if .node[\"@default-value\"]?has_content> ?: \"\"\"${.node[\"@default-value\"]}\"\"\"</#if><#if .node[\"@type\"]?has_content>, \"${.node[\"@type\"]}\"</#if>)\n    </#if>\n</#macro>\n\n<#macro \"order-map-list\">\n    orderMapList(${.node[\"@list\"]}, [<#list .node[\"order-by\"] as ob>\"${ob[\"@field-name\"]}\"<#if ob_has_next>, </#if></#list>])\n</#macro>\n<#macro \"filter-map-list\">\n    if (${.node[\"@list\"]} != null) {\n    <#if .node[\"@to-list\"]?has_content>\n        ${.node[\"@to-list\"]} = new ArrayList(${.node[\"@list\"]})\n        def _listToFilter = ${.node[\"@to-list\"]}\n    <#else>\n        def _listToFilter = ${.node[\"@list\"]}\n    </#if>\n    <#if .node[\"field-map\"]?has_content>\n        filterMapList(_listToFilter, [<#list .node[\"field-map\"] as fm>\"${fm[\"@field-name\"]}\":<#if fm[\"@value\"]?has_content>\"\"\"${fm[\"@value\"]}\"\"\"<#elseif fm[\"@from\"]?has_content>${fm[\"@from\"]}<#else>${fm[\"@field-name\"]}</#if><#if fm_has_next>, </#if></#list>])\n    </#if>\n    <#list .node[\"date-filter\"] as df>\n        filterMapListByDate(_listToFilter, \"${df[\"@from-field-name\"]?default(\"fromDate\")}\", \"${df[\"@thru-field-name\"]?default(\"thruDate\")}\", <#if df[\"@valid-date\"]?has_content>${df[\"@valid-date\"]} ?: ec.user.nowTimestamp<#else>null</#if>, ${df[\"@ignore-if-empty\"]?default(\"false\")})\n    </#list>\n    }\n</#macro>\n\n<#macro \"entity-sequenced-id-primary\">\n    ${.node[\"@value-field\"]}.setSequencedIdPrimary()\n</#macro>\n<#macro \"entity-sequenced-id-secondary\">\n    ${.node[\"@value-field\"]}.setSequencedIdSecondary()\n</#macro>\n<#macro \"entity-data\">\n    // TODO impl entity-data\n</#macro>\n\n<#-- =================== entity-find elements =================== -->\n\n<#macro \"entity-find-one\">\n    <#assign autoFieldMap = .node[\"@auto-field-map\"]?if_exists>\n    if (true) {\n        org.moqui.entity.EntityValue find_one_result = ec.entity.find(\"${.node[\"@entity-name\"]}\")<#if .node[\"@cache\"]?has_content>.useCache(${.node[\"@cache\"]})</#if><#if .node[\"@for-update\"]?has_content>.forUpdate(${.node[\"@for-update\"]})</#if><#if .node[\"@use-clone\"]?has_content>.useClone(${.node[\"@use-clone\"]})</#if>\n                <#if autoFieldMap?has_content><#if autoFieldMap == \"true\">.condition(context)<#elseif autoFieldMap != \"false\">.condition(${autoFieldMap})</#if><#elseif !.node[\"field-map\"]?has_content>.condition(context)</#if><#list .node[\"field-map\"] as fieldMap>.condition(\"${fieldMap[\"@field-name\"]}\", <#if fieldMap[\"@from\"]?has_content>${fieldMap[\"@from\"]}<#elseif fieldMap[\"@value\"]?has_content>\"\"\"${fieldMap[\"@value\"]}\"\"\"<#else>${fieldMap[\"@field-name\"]}</#if>)</#list><#list .node[\"select-field\"] as sf>.selectField(\"${sf[\"@field-name\"]}\")</#list>.one()\n        if (${.node[\"@value-field\"]} instanceof Map && !(${.node[\"@value-field\"]} instanceof org.moqui.entity.EntityValue)) { if (find_one_result) ${.node[\"@value-field\"]}.putAll(find_one_result) } else { ${.node[\"@value-field\"]} = find_one_result }\n    }\n</#macro>\n<#macro \"entity-find\">\n    <#assign useCache = (.node[\"@cache\"]?if_exists == \"true\")>\n    <#assign listName = .node[\"@list\"]>\n    <#assign doPaginate = .node[\"search-form-inputs\"]?has_content && !(.node[\"search-form-inputs\"][0][\"@paginate\"]?if_exists == \"false\")>\n    ${listName}_xafind = ec.entity.find(\"${.node[\"@entity-name\"]}\")<#if .node[\"@cache\"]?has_content>.useCache(${.node[\"@cache\"]})</#if><#if .node[\"@for-update\"]?has_content>.forUpdate(${.node[\"@for-update\"]})</#if><#if .node[\"@distinct\"]?has_content>.distinct(${.node[\"@distinct\"]})</#if><#if .node[\"@use-clone\"]?has_content>.useClone(${.node[\"@use-clone\"]})</#if><#if .node[\"@offset\"]?has_content>.offset(${.node[\"@offset\"]})</#if><#if .node[\"@limit\"]?has_content>.limit(${.node[\"@limit\"]})</#if><#list .node[\"select-field\"] as sf>.selectField(\"${sf[\"@field-name\"]}\")</#list><#list .node[\"order-by\"] as ob>.orderBy(\"${ob[\"@field-name\"]}\")</#list>\n            <#if !useCache><#list .node[\"date-filter\"] as df>.condition(<#visit df/>)</#list></#if><#list .node[\"econdition\"] as ecn>.condition(<#visit ecn/>)</#list><#list .node[\"econditions\"] as ecs>.condition(<#visit ecs/>)</#list>\n    <#list .node[\"econdition-object\"] as eco><#if eco[\"@field\"]?has_content>\n        if (${eco[\"@field\"]} != null) { ${listName}_xafind.condition(${eco[\"@field\"]}) }\n    </#if></#list>\n    <#-- do having-econditions first, if present will disable cached query, used in search-form-inputs -->\n    <#if .node[\"having-econditions\"]?has_content>${listName}_xafind<#list .node[\"having-econditions\"][0]?children as havingCond>.havingCondition(<#visit havingCond/>)</#list>\n    </#if>\n    <#if .node[\"search-form-inputs\"]?has_content><#assign sfiNode = .node[\"search-form-inputs\"][0]>\n    if (true) {\n        <#if sfiNode[\"default-parameters\"]?has_content><#assign sfiDpNode = sfiNode[\"default-parameters\"][0]>\n        Map efSfiDefParms = [<#list sfiDpNode?keys as dpName>${dpName}:\"\"\"${sfiDpNode[\"@\" + dpName]}\"\"\"<#if dpName_has_next>, </#if></#list>]\n        <#else>\n        Map efSfiDefParms = null\n        </#if>\n        <#if sfiNode[\"@require-parameters\"]?has_content>${listName}_xafind.requireSearchFormParameters(ec.resource.expand('''${sfiNode[\"@require-parameters\"]}''', \"\") == \"true\")</#if>\n        ${listName}_xafind.searchFormMap(${sfiNode[\"@input-fields-map\"]!\"ec.context\"}, efSfiDefParms, \"${sfiNode[\"@skip-fields\"]!(\"\")}\", \"${sfiNode[\"@default-order-by\"]!(\"\")}\", ${sfiNode[\"@paginate\"]!(\"true\")})\n    }\n    </#if>\n    <#if .node[\"limit-range\"]?has_content && !useCache>\n        org.moqui.entity.EntityListIterator ${listName}_xafind_eli = ${listName}_xafind.iterator()\n        ${listName} = ${listName}_xafind_eli.getPartialList(${.node[\"limit-range\"][0][\"@start\"]}, ${.node[\"limit-range\"][0][\"@size\"]}, true)\n    <#elseif .node[\"limit-view\"]?has_content && !useCache>\n        org.moqui.entity.EntityListIterator ${listName}_xafind_eli = ${listName}_xafind.iterator()\n        ${listName} = ${listName}_xafind_eli.getPartialList((${.node[\"limit-view\"][0][\"@view-index\"]} - 1) * ${.node[\"limit-view\"][0][\"@view-size\"]}, ${.node[\"limit-view\"][0][\"@view-size\"]}, true)\n    <#elseif .node[\"use-iterator\"]?has_content && !useCache>\n        ${listName} = ${listName}_xafind.iterator()\n    <#else>\n        ${listName} = ${listName}_xafind.list()\n        <#if useCache>\n            <#list .node[\"date-filter\"] as df>\n                ${listName} = ${listName}.filterByDate(\"${df[\"@from-field-name\"]?default(\"fromDate\")}\", \"${df[\"@thru-field-name\"]?default(\"thruDate\")}\", <#if df[\"@valid-date\"]?has_content>${df[\"@valid-date\"]} as java.sql.Timestamp<#else>null</#if>, ${df[\"@ignore-if-empty\"]!\"false\"})\n            </#list>\n            <#if doPaginate>\n                <#-- get the Count after the date-filter, but before the limit/pagination filter -->\n                ${listName}Count = ${listName}.size()\n                ${listName} = ${listName}.filterByLimit(\"${sfiNode[\"@input-fields-map\"]!\"\"}\", true)\n                <#-- get the PageIndex and PageSize after date-filter AND after limit filter -->\n                ${listName}PageIndex = ${listName}.pageIndex\n                ${listName}PageSize = ${listName}.pageSize\n            </#if>\n        </#if>\n    </#if>\n    <#if doPaginate>\n        <#if !useCache>\n            if (${listName}_xafind.getLimit() == null) {\n                ${listName}Count = ${listName}.size()\n                ${listName}PageIndex = ${listName}.getPageIndex()\n                ${listName}PageSize = ${listName}Count > 20 ? ${listName}Count : 20\n            } else {\n                ${listName}PageIndex = ${listName}_xafind.getPageIndex()\n                ${listName}PageSize = ${listName}_xafind.getPageSize()\n                if (${listName}.size() < ${listName}PageSize) { ${listName}Count = ${listName}.size() + ${listName}PageIndex * ${listName}PageSize }\n                else { ${listName}Count = ${listName}_xafind.count() }\n            }\n        </#if>\n        ${listName}PageMaxIndex = ((BigDecimal) (${listName}Count - 1)).divide(${listName}PageSize ?: (${listName}Count - 1), 0, java.math.RoundingMode.DOWN) as int\n        ${listName}PageRangeLow = ${listName}PageIndex * ${listName}PageSize + 1\n        ${listName}PageRangeHigh = (${listName}PageIndex * ${listName}PageSize) + ${listName}PageSize\n        if (${listName}PageRangeHigh > ${listName}Count) ${listName}PageRangeHigh = ${listName}Count\n    </#if>\n</#macro>\n<#macro \"entity-find-count\">\n    <#if .node[\"search-form-inputs\"]?has_content>\n        <#assign sfiNode = .node[\"search-form-inputs\"][0]>\n        <#if sfiNode[\"default-parameters\"]?has_content><#assign sfiDpNode = sfiNode[\"default-parameters\"][0]>\n            Map efSfiDefParams = [<#list sfiDpNode?keys as dpName>${dpName}:\"\"\"${sfiDpNode[\"@\" + dpName]}\"\"\"<#if dpName_has_next>, </#if></#list>]\n        <#else>\n            Map efSfiDefParams = null\n        </#if>\n    </#if>\n    ${.node[\"@count-field\"]} = ec.entity.find(\"${.node[\"@entity-name\"]}\")\n        <#t><#if .node[\"@cache\"]?has_content>.useCache(${.node[\"@cache\"]})</#if>\n        <#t><#if .node[\"@distinct\"]?has_content>.distinct(${.node[\"@distinct\"]})</#if>\n        <#t><#if .node[\"search-form-inputs\"]?has_content>.searchFormMap(${\"ec.context\"}, efSfiDefParams, \"${sfiNode[\"@skip-fields\"]!(\"\")}\", null, false)</#if>\n        <#t><#list .node[\"select-field\"] as sf>.selectField(\"${sf[\"@field-name\"]}\")</#list>\n        <#t><#list .node[\"date-filter\"] as df>.condition(<#visit df/>)</#list>\n        <#t><#list .node[\"econdition\"] as econd>.condition(<#visit econd/>)</#list>\n        <#t><#list .node[\"econditions\"] as ecs>.condition(<#visit ecs/>)</#list>\n        <#t><#list .node[\"econdition-object\"] as eco>.condition(<#visit eco/>)</#list>\n        <#t><#if .node[\"having-econditions\"]?has_content><#list .node[\"having-econditions\"][\"*\"] as havingCond>.havingCondition(<#visit havingCond/>)</#list></#if>\n        <#lt>.count()\n</#macro>\n<#-- =================== entity-find sub-elements =================== -->\n<#macro \"date-filter\">(org.moqui.entity.EntityCondition) ec.entity.conditionFactory.makeConditionDate(\"${.node[\"@from-field-name\"]!(\"fromDate\")}\", \"${.node[\"@thru-field-name\"]!(\"thruDate\")}\", <#if .node[\"@valid-date\"]?has_content>${.node[\"@valid-date\"]} as java.sql.Timestamp<#else>null</#if>, ${.node[\"@ignore-if-empty\"]!(\"false\")}, \"${.node[\"@ignore\"]!\"false\"}\")</#macro>\n<#macro \"econdition\">(org.moqui.entity.EntityCondition) ec.entity.conditionFactory.makeActionConditionDirect(\"${.node[\"@field-name\"]}\", \"${.node[\"@operator\"]!\"equals\"}\", ${.node[\"@from\"]?default(.node[\"@field-name\"])}, <#if .node[\"@value\"]?has_content>\"${.node[\"@value\"]}\"<#else>null</#if>, <#if .node[\"@to-field-name\"]?has_content>\"${.node[\"@to-field-name\"]}\"<#else>null</#if>, ${.node[\"@ignore-case\"]!\"false\"}, ${.node[\"@ignore-if-empty\"]!\"false\"}, ${.node[\"@or-null\"]!\"false\"}, \"${.node[\"@ignore\"]!\"false\"}\")</#macro>\n<#macro \"econditions\">(org.moqui.entity.EntityCondition) ec.entity.conditionFactory.makeCondition([<#list .node?children as subCond><#visit subCond/><#if subCond_has_next>, </#if></#list>], org.moqui.impl.entity.EntityConditionFactoryImpl.getJoinOperator(\"${.node[\"@combine\"]!\"and\"}\"))</#macro>\n<#macro \"econdition-object\">${.node[\"@field\"]}</#macro>\n\n<#-- =================== entity other elements =================== -->\n\n<#macro \"entity-find-related-one\">    ${.node[\"@to-value-field\"]} = ${.node[\"@value-field\"]}?.findRelatedOne(\"${.node[\"@relationship-name\"]}\", ${.node[\"@cache\"]!\"null\"}, ${.node[\"@for-update\"]!\"null\"})\n</#macro>\n<#macro \"entity-find-related\">    ${.node[\"@list\"]} = ${.node[\"@value-field\"]}?.findRelated(\"${.node[\"@relationship-name\"]}\", ${.node[\"@map\"]!\"null\"}, ${.node[\"@order-by-list\"]!\"null\"}, ${.node[\"@cache\"]!\"null\"}, ${.node[\"@for-update\"]!\"null\"})\n</#macro>\n\n<#macro \"entity-make-value\">    ${.node[\"@value-field\"]} = ec.entity.makeValue(\"${.node[\"@entity-name\"]}\")<#if .node[\"@map\"]?has_content>\n    ${.node[\"@value-field\"]}.setFields(${.node[\"@map\"]}, true, null, null)</#if>\n</#macro>\n<#macro \"entity-create\">    ${.node[\"@value-field\"]}<#if .node[\"@or-update\"]?has_content && .node[\"@or-update\"] == \"true\">.createOrUpdate()<#else>.create()</#if>\n</#macro>\n<#macro \"entity-update\">    ${.node[\"@value-field\"]}.update()\n</#macro>\n<#macro \"entity-delete\">    ${.node[\"@value-field\"]}.delete()\n</#macro>\n<#macro \"entity-delete-related\">    ${.node[\"@value-field\"]}.deleteRelated(\"${.node[\"@relationship-name\"]}\")\n</#macro>\n<#macro \"entity-delete-by-condition\">    ec.entity.find(\"${.node[\"@entity-name\"]}\")\n            <#list .node[\"date-filter\"] as df>.condition(<#visit df/>)</#list><#list .node[\"econdition\"] as econd>.condition(<#visit econd/>)</#list><#list .node[\"econditions\"] as ecs>.condition(<#visit ecs/>)</#list><#list .node[\"econdition-object\"] as eco>.condition(<#visit eco/>)</#list>.deleteAll()\n</#macro>\n<#macro \"entity-set\">    ${.node[\"@value-field\"]}.setFields(${.node[\"@map\"]!\"context\"}, ${.node[\"@set-if-empty\"]!\"false\"}, ${.node[\"@prefix\"]!\"null\"}, <#if .node[\"@include\"]?has_content && .node[\"@include\"] == \"pk\">true<#elseif .node[\"@include\"]?has_content && .node[\"@include\"] == \"nonpk\"/>false<#else>null</#if>)\n</#macro>\n\n<#macro break>    break\n</#macro>\n<#macro continue>    continue\n</#macro>\n<#macro iterate>\n    <#if .node[\"@key\"]?has_content>\n    if (${.node[\"@list\"]} instanceof Map) {\n        ${.node[\"@entry\"]}_index = 0\n        def _${.node[\"@entry\"]}Iterator = ${.node[\"@list\"]}.entrySet().iterator()\n        while (_${.node[\"@entry\"]}Iterator.hasNext()) {\n            def ${.node[\"@entry\"]}Entry = _${.node[\"@entry\"]}Iterator.next()\n            ${.node[\"@entry\"]}_has_next = _${.node[\"@entry\"]}Iterator.hasNext()\n            ${.node[\"@entry\"]} = ${.node[\"@entry\"]}Entry.getValue()\n            ${.node[\"@key\"]} = ${.node[\"@entry\"]}Entry.getKey()\n            <#recurse/>\n            ${.node[\"@entry\"]}_index++\n        }\n    } else if (${.node[\"@list\"]} instanceof Collection) {\n        ${.node[\"@entry\"]}_index = 0\n        def _${.node[\"@entry\"]}Iterator = ${.node[\"@list\"]}.iterator()\n        while (_${.node[\"@entry\"]}Iterator.hasNext()) {\n            def ${.node[\"@entry\"]}Entry = _${.node[\"@entry\"]}Iterator.next()\n            ${.node[\"@entry\"]}_has_next = _${.node[\"@entry\"]}Iterator.hasNext()\n            ${.node[\"@entry\"]} = ${.node[\"@entry\"]}Entry.getValue()\n            ${.node[\"@key\"]} = ${.node[\"@entry\"]}Entry.getKey()\n            <#recurse/>\n            ${.node[\"@entry\"]}_index++\n        }\n    } else <#-- note no opening curly brace, will turn into \"else if\" with if below -->\n    </#if>\n    if (true) {\n        int ${.node[\"@entry\"]}_index = 0\n        Iterator _${.node[\"@entry\"]}Iterator = ${.node[\"@list\"]}.iterator()\n        // behave differently for EntityListIterator, avoid using hasNext()\n        boolean ${.node[\"@entry\"]}IsEli = (_${.node[\"@entry\"]}Iterator instanceof org.moqui.entity.EntityListIterator)\n        while (${.node[\"@entry\"]}IsEli || _${.node[\"@entry\"]}Iterator.hasNext()) {\n            ${.node[\"@entry\"]} = _${.node[\"@entry\"]}Iterator.next()\n            if (${.node[\"@entry\"]}IsEli && ${.node[\"@entry\"]} == null) break\n            if (!${.node[\"@entry\"]}IsEli) ${.node[\"@entry\"]}_has_next = _${.node[\"@entry\"]}Iterator.hasNext()\n\n            // begin iterator internal block\n            <#recurse/>\n            // end iterator internal block for list ${.node[\"@list\"]}\n\n            ${.node[\"@entry\"]}_index++\n        }\n        if(${.node[\"@entry\"]}IsEli) _${.node[\"@entry\"]}Iterator.close()\n    }\n</#macro>\n<#macro message>\n    <#if .node[\"@error\"]?has_content && .node[\"@error\"] == \"true\">\n        ec.message.addError(ec.resource.expand('''${.node?trim}''',''))\n    <#elseif .node[\"@public\"]?has_content && .node[\"@public\"] == \"true\">\n        ec.message.addPublic(ec.resource.expand('''${.node?trim}''',''), \"${.node[\"@type\"]!\"info\"}\")\n    <#else>\n        ec.message.addMessage(ec.resource.expand('''${.node?trim}''',''), \"${.node[\"@type\"]!\"info\"}\")\n    </#if>\n</#macro>\n<#macro \"check-errors\">    if (ec.message.errors) return\n</#macro>\n\n<#-- NOTE: if there is an error message (in ec.messages.errors) then the actions result is an error, otherwise it is not, so we need a default error message here -->\n<#macro return>\n    <#assign returnMessage = .node[\"@message\"]!\"\"/>\n    <#if returnMessage?has_content><#if .node[\"@error\"]?has_content && .node[\"@error\"] == \"true\">\n        ec.message.addError(ec.resource.expand('''${returnMessage?trim}''' ?: \"Error in actions\",''))\n    <#elseif .node[\"@public\"]?has_content && .node[\"@public\"] == \"true\">\n        ec.message.addPublic(ec.resource.expand('''${returnMessage?trim}''',''), \"${.node[\"@type\"]!\"info\"}\")\n    <#else>\n        ec.message.addMessage(ec.resource.expand('''${returnMessage?trim}''',''), \"${.node[\"@type\"]!\"info\"}\")\n    </#if></#if>\n    return;\n</#macro>\n<#macro assert><#list .node[\"*\"] as childCond>\n    if (!(<#visit childCond/>)) ec.message.addError(ec.resource.expand('''<#if .node[\"@title\"]?has_content>[${.node[\"@title\"]}] </#if> Assert failed: <#visit childCond/>''',''))</#list>\n</#macro>\n\n<#macro if>    if (<#if .node[\"@condition\"]?has_content>${.node[\"@condition\"]}</#if><#if .node[\"@condition\"]?has_content && .node[\"condition\"]?has_content> && </#if><#if .node[\"condition\"]?has_content><#recurse .node[\"condition\"][0]/></#if>) {\n        <#recurse .node/><#if .node[\"then\"]?has_content>\n        <#recurse .node[\"then\"][0]/></#if>\n    }<#if .node[\"else-if\"]?has_content><#list .node[\"else-if\"] as elseIf> else if (<#if elseIf[\"@condition\"]?has_content>${elseIf[\"@condition\"]}</#if><#if elseIf[\"@condition\"]?has_content && elseIf[\"condition\"]?has_content> && </#if><#if elseIf[\"condition\"]?has_content><#recurse elseIf[\"condition\"][0]/></#if>) {\n        <#recurse elseIf/><#if elseIf.then?has_content>\n        <#recurse elseIf[\"then\"][0]/></#if>\n    }</#list></#if><#if .node[\"else\"]?has_content> else {\n        <#recurse .node[\"else\"][0]/>\n    }</#if>\n\n</#macro>\n\n<#macro while>    while (<#if .node.@condition?has_content>${.node.@condition}</#if><#if .node[\"@condition\"]?has_content && .node[\"condition\"]?has_content> && </#if><#if .node[\"condition\"]?has_content><#recurse .node[\"condition\"][0]/></#if>) {\n        <#recurse .node/>\n    }\n\n</#macro>\n\n<#-- =================== if/when sub-elements =================== -->\n\n<#macro condition><#-- do nothing when visiting, only used explicitly inline --></#macro>\n<#macro then><#-- do nothing when visiting, only used explicitly inline --></#macro>\n<#macro \"else-if\"><#-- do nothing when visiting, only used explicitly inline --></#macro>\n<#macro else><#-- do nothing when visiting, only used explicitly inline --></#macro>\n\n<#macro or>(<#list .node.children as childNode><#visit childNode/><#if childNode_has_next> || </#if></#list>)</#macro>\n<#macro and>(<#list .node.children as childNode><#visit childNode/><#if childNode_has_next> && </#if></#list>)</#macro>\n<#macro not>!<#visit .node.children[0]/></#macro>\n\n<#macro compare>    <#if (.node?size > 0)>if (compare(${.node[\"@field\"]}, <#if .node[\"@operator\"]?has_content>\"${.node[\"@operator\"]}\"<#else>\"equals\"</#if>, <#if .node[\"@value\"]?has_content>\"\"\"${.node[\"@value\"]}\"\"\"<#else>null</#if>, <#if .node[\"@to-field\"]?has_content>${.node[\"@to-field\"]}<#else>null</#if>, <#if .node[\"@format\"]?has_content>\"${.node[\"@format\"]}\"<#else>null</#if>, <#if .node[\"@type\"]?has_content>\"${.node[\"@type\"]}\"<#else>\"Object\"</#if>)) {\n        <#recurse .node/>\n    }<#if .node.else?has_content> else {\n        <#recurse .node.else[0]/>\n    }</#if>\n    <#else>compare(${.node[\"@field\"]}, <#if .node[\"@operator\"]?has_content>\"${.node[\"@operator\"]}\"<#else>\"equals\"</#if>, <#if .node[\"@value\"]?has_content>\"\"\"${.node[\"@value\"]}\"\"\"<#else>null</#if>, <#if .node[\"@to-field\"]?has_content>${.node[\"@to-field\"]}<#else>null</#if>, <#if .node[\"@format\"]?has_content>\"${.node[\"@format\"]}\"<#else>null</#if>, <#if .node[\"@type\"]?has_content>\"${.node[\"@type\"]}\"<#else>\"Object\"</#if>)</#if>\n</#macro>\n<#macro expression>${.node}\n</#macro>\n\n<#-- =================== other elements =================== -->\n\n<#macro \"log\">    ec.logger.log(<#if .node[\"@level\"]?has_content>\"${.node[\"@level\"]}\"<#else>\"info\"</#if>, \"\"\"${.node[\"@message\"]}\"\"\", null)\n</#macro>\n"
  },
  {
    "path": "framework/xsd/common-types-3.xsd",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" elementFormDefault=\"qualified\">\n    <!-- Reusable artifacts (abstract elements, groups, attributeGroups -->\n    <xs:element name=\"description\" type=\"xs:string\"/>\n\n    <!-- Plain name, simple letters and digits for greatest compatibility with various tools -->\n    <xs:simpleType name=\"name-plain\"><xs:restriction base=\"xs:string\">\n        <xs:pattern value=\"[a-zA-Z][_a-zA-Z0-9]*\"/></xs:restriction></xs:simpleType>\n    <!-- Plain name starting with upper case letter, for entities, service nouns, screens, forms, etc -->\n    <xs:simpleType name=\"name-upper\"><xs:restriction base=\"xs:string\">\n        <xs:pattern value=\"[A-Z][a-zA-Z0-9]*\"/></xs:restriction></xs:simpleType>\n    <!-- Plain name starting with lower case letter, for entity fields, service verbs -->\n    <xs:simpleType name=\"name-field\"><xs:restriction base=\"xs:string\">\n        <xs:pattern value=\"[a-z][a-zA-Z0-9]*\"/></xs:restriction></xs:simpleType>\n    <!-- Plain name with underscore, digits letters; for parameters and form fields -->\n    <xs:simpleType name=\"name-parameter\"><xs:restriction base=\"xs:string\">\n        <xs:pattern value=\"[_a-z][_a-zA-Z0-9]*\"/></xs:restriction></xs:simpleType>\n    <!-- Patterned name for fully qualified entity, service, etc names -->\n    <xs:simpleType name=\"name-package\"><xs:restriction base=\"xs:string\">\n        <xs:pattern value=\"[a-zA-Z][_\\.a-zA-Z0-9]*\"/></xs:restriction></xs:simpleType>\n    <!-- Patterned name for fully qualified entity, service, etc names -->\n    <xs:simpleType name=\"name-full\"><xs:restriction base=\"xs:string\">\n        <xs:pattern value=\"[a-zA-Z$][$\\{\\}\\.#a-zA-Z0-9]*\"/></xs:restriction></xs:simpleType>\n    <!-- Plain name, simple letters and digits plus has for name segmentation -->\n    <xs:simpleType name=\"name-segmented\"><xs:restriction base=\"xs:string\">\n        <xs:pattern value=\"[a-zA-Z][#_a-zA-Z0-9]*\"/></xs:restriction></xs:simpleType>\n\n    <xs:simpleType name=\"boolean\"><xs:restriction base=\"xs:token\"><xs:enumeration value=\"true\"/><xs:enumeration value=\"false\"/></xs:restriction></xs:simpleType>\n    <xs:simpleType name=\"boolean-expandable\"><xs:union>\n        <xs:simpleType><xs:restriction base=\"xs:token\"><xs:enumeration value=\"true\"/><xs:enumeration value=\"false\"/></xs:restriction></xs:simpleType>\n        <xs:simpleType><xs:restriction base=\"xs:string\"><xs:pattern value=\"\\$\\{.*\"/></xs:restriction></xs:simpleType>\n    </xs:union></xs:simpleType>\n    <xs:simpleType name=\"non-neg-int-expandable\"><xs:union>\n        <xs:simpleType><xs:restriction base=\"xs:nonNegativeInteger\"/></xs:simpleType>\n        <xs:simpleType><xs:restriction base=\"xs:string\"><xs:pattern value=\"\\$\\{.*\"/></xs:restriction></xs:simpleType>\n    </xs:union></xs:simpleType>\n\n    <xs:simpleType name=\"object-type\"><xs:restriction base=\"xs:token\">\n        <xs:enumeration value=\"String\"/>\n        <xs:enumeration value=\"BigDecimal\"/>\n        <xs:enumeration value=\"Float\"/>\n        <xs:enumeration value=\"Integer\"/>\n        <xs:enumeration value=\"Date\"/>\n        <xs:enumeration value=\"Time\"/>\n        <xs:enumeration value=\"Timestamp\"/>\n        <xs:enumeration value=\"Boolean\"/>\n        <xs:enumeration value=\"Object\"/>\n    </xs:restriction></xs:simpleType>\n    <xs:simpleType name=\"object-type-new\"><xs:restriction base=\"xs:token\">\n        <xs:enumeration value=\"String\"/>\n        <xs:enumeration value=\"BigDecimal\"/>\n        <xs:enumeration value=\"Double\"/>\n        <xs:enumeration value=\"Float\"/>\n        <xs:enumeration value=\"List\"/>\n        <xs:enumeration value=\"Long\"/>\n        <xs:enumeration value=\"Integer\"/>\n        <xs:enumeration value=\"Date\"/>\n        <xs:enumeration value=\"Time\"/>\n        <xs:enumeration value=\"Timestamp\"/>\n        <xs:enumeration value=\"Boolean\"/>\n        <xs:enumeration value=\"Object\"/>\n        <xs:enumeration value=\"NewList\"/>\n        <xs:enumeration value=\"NewMap\"/>\n    </xs:restriction></xs:simpleType>\n\n    <xs:simpleType name=\"operator\"><xs:restriction base=\"xs:token\">\n        <xs:enumeration value=\"less\"/>\n        <xs:enumeration value=\"greater\"/>\n        <xs:enumeration value=\"less-equals\"/>\n        <xs:enumeration value=\"greater-equals\"/>\n        <xs:enumeration value=\"not-equals\"/>\n        <xs:enumeration value=\"not-contains\"/>\n        <xs:enumeration value=\"not-empty\"/>\n        <xs:enumeration value=\"not-matches\"/>\n        <xs:enumeration value=\"equals\"/>\n        <xs:enumeration value=\"contains\"/>\n        <xs:enumeration value=\"empty\"/>\n        <xs:enumeration value=\"matches\"><xs:annotation><xs:documentation>Match against the regular expression in the comparison value.</xs:documentation></xs:annotation></xs:enumeration>\n    </xs:restriction></xs:simpleType>\n    <xs:simpleType name=\"operator-entity\"><xs:restriction base=\"xs:token\">\n        <xs:enumeration value=\"less\"/>\n        <xs:enumeration value=\"greater\"/>\n        <xs:enumeration value=\"less-equals\"/>\n        <xs:enumeration value=\"greater-equals\"/>\n        <xs:enumeration value=\"equals\"/>\n        <xs:enumeration value=\"not-equals\"/>\n        <xs:enumeration value=\"in\"/>\n        <xs:enumeration value=\"not-in\"/>\n        <xs:enumeration value=\"between\"/>\n        <xs:enumeration value=\"not-between\"/>\n        <xs:enumeration value=\"like\"/>\n        <xs:enumeration value=\"not-like\"/>\n        <xs:enumeration value=\"is-null\"/>\n        <xs:enumeration value=\"is-not-null\"/>\n    </xs:restriction></xs:simpleType>\n\n    <xs:simpleType name=\"url-type\"><xs:restriction base=\"xs:token\">\n        <xs:enumeration value=\"transition\"><xs:annotation><xs:documentation>The name of a transition in the current screen.\n            URL will be built basedon the transition definition. Technically the same as 'screen' as both are evaluated as a screen path.\n        </xs:documentation></xs:annotation></xs:enumeration>\n        <xs:enumeration value=\"screen\"><xs:annotation><xs:documentation>The path of a screen relative to the current screen\n            (or the root screen if begins with '/' or '//' for a sparse path).</xs:documentation></xs:annotation></xs:enumeration>\n        <xs:enumeration value=\"content\"><xs:annotation><xs:documentation>A content location (without the content://).\n            URL will be one that can access that content.</xs:documentation></xs:annotation></xs:enumeration>\n        <xs:enumeration value=\"plain\"><xs:annotation><xs:documentation>A plain URL to be used literally (may be relative or\n            start with http:// or https://).</xs:documentation></xs:annotation></xs:enumeration>\n    </xs:restriction></xs:simpleType>\n    <xs:simpleType name=\"authc-options\"><xs:restriction base=\"xs:token\">\n        <xs:enumeration value=\"true\"><xs:annotation><xs:documentation>Authentication and authorization are required and checked\n        </xs:documentation></xs:annotation></xs:enumeration>\n        <xs:enumeration value=\"false\"><xs:annotation><xs:documentation>Authentication and authorization are NOT required and\n            not checked</xs:documentation></xs:annotation></xs:enumeration>\n        <xs:enumeration value=\"anonymous-all\"><xs:annotation><xs:documentation>When used an anonymous user is effectively logged\n            in and granted ALLOW authorization for view and update operations for the artifact and all artifacts below it.\n            For a screen this grants an ALLOW authorization for all sub-screens, and for a service any services/entities/etc it\n            uses. If an actual user is authenticated already no anonymous user is effectively logged in, but the ALLOW\n            authorization is still added.\n        </xs:documentation></xs:annotation></xs:enumeration>\n        <xs:enumeration value=\"anonymous-view\"><xs:annotation><xs:documentation>Like anonymous-all but authorization is only\n            granted for view (find) operations.</xs:documentation></xs:annotation></xs:enumeration>\n    </xs:restriction></xs:simpleType>\n    <xs:simpleType name=\"color-context\"><xs:restriction base=\"xs:token\">\n        <xs:enumeration value=\"default\"/><xs:enumeration value=\"primary\"/><xs:enumeration value=\"success\"/>\n        <xs:enumeration value=\"info\"/><xs:enumeration value=\"warning\"/><xs:enumeration value=\"danger\"/>\n    </xs:restriction></xs:simpleType>\n    <xs:simpleType name=\"message-type\"><xs:restriction base=\"xs:token\">\n        <xs:enumeration value=\"success\"/><xs:enumeration value=\"info\"/><xs:enumeration value=\"warning\"/><xs:enumeration value=\"danger\"/>\n    </xs:restriction></xs:simpleType>\n\n    <xs:simpleType name=\"aggregate-function\"><xs:restriction base=\"xs:token\">\n        <!-- aggregate functions -->\n        <xs:enumeration value=\"min\"/><xs:enumeration value=\"max\"/>\n        <xs:enumeration value=\"sum\"/><xs:enumeration value=\"avg\"/>\n        <xs:enumeration value=\"count\"/><xs:enumeration value=\"count-distinct\"/>\n        <!-- non-aggregate functions -->\n        <xs:enumeration value=\"round\"/>\n        <xs:enumeration value=\"upper\"/><xs:enumeration value=\"lower\"/>\n        <xs:enumeration value=\"concat\"/><xs:enumeration value=\"concat_ws\"/>\n    </xs:restriction></xs:simpleType>\n    <xs:simpleType name=\"isolation-level\"><xs:restriction base=\"xs:token\">\n        <xs:enumeration value=\"None\"/>\n        <xs:enumeration value=\"ReadCommitted\"/>\n        <xs:enumeration value=\"ReadUncommitted\"/>\n        <xs:enumeration value=\"RepeatableRead\"/>\n        <xs:enumeration value=\"Serializable\"/>\n    </xs:restriction></xs:simpleType>\n    <xs:simpleType name=\"transaction-options\"><xs:restriction base=\"xs:token\">\n        <xs:enumeration value=\"ignore\"><xs:annotation><xs:documentation>Don't do anything with\n            transactions (if one is in place use it, if no transaction in place don't begin one).</xs:documentation></xs:annotation></xs:enumeration>\n        <xs:enumeration value=\"use-or-begin\"><xs:annotation><xs:documentation>Use active transaction or\n            if no active transaction begin one. This is the default.</xs:documentation></xs:annotation></xs:enumeration>\n        <xs:enumeration value=\"force-new\"><xs:annotation><xs:documentation>Always begin a new\n            transaction, pausing/resuming the active transaction if there is one.</xs:documentation></xs:annotation></xs:enumeration>\n        <xs:enumeration value=\"cache\"><xs:annotation><xs:documentation>Like use-or-begin but with a\n            write-through per-transaction cache in place (works even if active TX is in place). See notes and\n            warnings in the JavaDoc comments of the TransactionCache class for details.</xs:documentation></xs:annotation></xs:enumeration>\n        <xs:enumeration value=\"force-cache\"><xs:annotation><xs:documentation>Like force-new with a\n            transaction cache in place like the cache option.</xs:documentation></xs:annotation></xs:enumeration>\n    </xs:restriction></xs:simpleType>\n</xs:schema>\n"
  },
  {
    "path": "framework/xsd/email-eca-3.xsd",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" elementFormDefault=\"qualified\">\n    <xs:include schemaLocation=\"xml-actions-3.xsd\"/>\n\n    <xs:element name=\"emecas\">\n        <xs:complexType><xs:sequence><xs:element maxOccurs=\"unbounded\" ref=\"emeca\"/></xs:sequence></xs:complexType>\n    </xs:element>\n    <xs:element name=\"emeca\">\n        <xs:annotation>\n            <xs:documentation>\n                Whenever an email message is received the actions will be run if the condition is met.\n\n                The context for the condition and actions will include a \"headers\" Map with all of the\n                email headers in it (either String, or List of String if there are more than one of the header), a\n                \"fields\" Map with the following: toList, ccList, bccList, from, subject, sentDate, and receivedDate,\n                a \"flags Map, and a bodyPartList which is a List of Map with info for each body part.\n\n                For a full description of the structure see the org.moqui.EmailServices.process#EmailEca service\n                interface. If a single service is used to process the email it should implement this interface.\n            </xs:documentation>\n        </xs:annotation>\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" ref=\"condition\"/>\n                <xs:element ref=\"actions\"/>\n            </xs:sequence>\n            <xs:attribute name=\"rule-name\" type=\"xs:string\" use=\"required\"/>\n        </xs:complexType>\n    </xs:element>\n</xs:schema>\n"
  },
  {
    "path": "framework/xsd/entity-definition-3.xsd",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" elementFormDefault=\"qualified\">\n    <xs:include schemaLocation=\"common-types-3.xsd\"/>\n\n    <xs:simpleType name=\"cache-options\">\n        <xs:restriction base=\"xs:token\">\n            <xs:enumeration value=\"true\"><xs:annotation><xs:documentation>Use cache during queries by default (code may override this).</xs:documentation></xs:annotation></xs:enumeration>\n            <xs:enumeration value=\"false\"><xs:annotation><xs:documentation>Do not use cache during queries by default (code may override this).</xs:documentation></xs:annotation></xs:enumeration>\n            <xs:enumeration value=\"never\"><xs:annotation><xs:documentation>Do not use cache during queries ever(code may NOT override this).</xs:documentation></xs:annotation></xs:enumeration>\n        </xs:restriction>\n    </xs:simpleType>\n    <xs:simpleType name=\"authorize-skip-options\">\n        <xs:restriction base=\"xs:token\">\n            <xs:enumeration value=\"true\"/>\n            <xs:enumeration value=\"false\"/>\n            <xs:enumeration value=\"create\"/>\n            <xs:enumeration value=\"view\"/>\n            <xs:enumeration value=\"view-create\"/>\n        </xs:restriction>\n    </xs:simpleType>\n    <xs:simpleType name=\"audit-log-options\">\n        <xs:restriction base=\"xs:token\">\n            <xs:enumeration value=\"true\"/>\n            <xs:enumeration value=\"false\"/>\n            <xs:enumeration value=\"update\"/>\n        </xs:restriction>\n    </xs:simpleType>\n    <xs:simpleType name=\"field-type-options\">\n        <xs:restriction base=\"xs:token\">\n            <xs:enumeration value=\"id\"/>\n            <xs:enumeration value=\"id-long\"/>\n            <xs:enumeration value=\"date\"/>\n            <xs:enumeration value=\"time\"/>\n            <xs:enumeration value=\"date-time\"/>\n            <xs:enumeration value=\"number-integer\"/>\n            <xs:enumeration value=\"number-decimal\"/>\n            <xs:enumeration value=\"number-float\"/>\n            <xs:enumeration value=\"currency-amount\"/>\n            <xs:enumeration value=\"currency-precise\"/>\n            <xs:enumeration value=\"text-indicator\"/>\n            <xs:enumeration value=\"text-short\"/>\n            <xs:enumeration value=\"text-medium\"/>\n            <xs:enumeration value=\"text-intermediate\"/>\n            <xs:enumeration value=\"text-long\"/>\n            <xs:enumeration value=\"text-very-long\"/>\n            <xs:enumeration value=\"binary-very-long\"/>\n        </xs:restriction>\n    </xs:simpleType>\n\n    <!-- ====================== Root Element ======================= -->\n    <xs:element name=\"entities\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n                    <xs:element ref=\"entity\"/>\n                    <xs:element ref=\"view-entity\"/>\n                    <xs:element ref=\"extend-entity\"/>\n                </xs:choice>\n            </xs:sequence>\n        </xs:complexType>\n    </xs:element>\n\n    <!-- ================== entity and extend-entity ===================== -->\n\n    <xs:element name=\"entity\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" ref=\"description\"/>\n                <xs:element maxOccurs=\"unbounded\" ref=\"field\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"relationship\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"index\"/>\n                <!-- TABLED not to be part of 1.0: <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"change-set\"/> -->\n                <xs:element minOccurs=\"0\" ref=\"seed-data\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"master\"/>\n            </xs:sequence>\n            <xs:attribute name=\"entity-name\" type=\"name-upper\" use=\"required\"/>\n            <xs:attribute name=\"package\" type=\"name-package\" use=\"required\"/>\n            <xs:attribute name=\"table-name\" type=\"xs:string\"/>\n            <xs:attribute name=\"group\" type=\"name-plain\" use=\"optional\"/>\n            <xs:attribute name=\"use\" default=\"transactional\">\n                <xs:annotation><xs:documentation>The intended use of an entity.</xs:documentation></xs:annotation>\n                <xs:simpleType><xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"transactional\"><xs:annotation><xs:documentation>transactional business\n                        entities that need atomic operations, immediate consistency, etc; typically never cached;\n                        includes things like work efforts, orders, shipments, inventory/assets, invoices, accounting\n                        and financial transactions</xs:documentation></xs:annotation></xs:enumeration>\n                    <xs:enumeration value=\"nontransactional\"><xs:annotation><xs:documentation>non-transactional business\n                        entities; eventual consistency is adequate; may be cached; non-transactional does not mean\n                        transactions are not used, but that strict consistency is not important; includes things like\n                        parties, facilities, products, history/tracking data (master data, meta-data)</xs:documentation></xs:annotation></xs:enumeration>\n                    <xs:enumeration value=\"configuration\"><xs:annotation><xs:documentation>framework and application\n                        configuration data; eventual consistency is okay; typically cached</xs:documentation></xs:annotation></xs:enumeration>\n                    <xs:enumeration value=\"analytical\"><xs:annotation><xs:documentation>analytical entities with data\n                        typically derived from transactional data</xs:documentation></xs:annotation></xs:enumeration>\n                    <xs:enumeration value=\"logging\"><xs:annotation><xs:documentation>entities used for logging with\n                        non-transactional data that is generated in high volumes and used mostly for auditing and analytics</xs:documentation></xs:annotation></xs:enumeration>\n                </xs:restriction></xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"sequence-primary-use-uuid\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>Uses java.util.UUID.randomUUID() to get sequenced IDs for this entity.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"sequence-bank-size\" type=\"xs:nonNegativeInteger\" default=\"50\"/>\n            <xs:attribute name=\"sequence-primary-stagger\" type=\"xs:nonNegativeInteger\" default=\"1\">\n                <xs:annotation><xs:documentation>The maximum amount to stagger the sequenced ID, if 1 the sequence will\n                    be incremented by 1, otherwise the current sequence ID will be incremented by a value between 1 and\n                    staggerMax.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"sequence-primary-prefix\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Prefix to apply to primary sequenced ID values for this entity.\n                    Can be an expression (string expansion) with the current value added to the context.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"sequence-secondary-padded-length\" type=\"xs:nonNegativeInteger\" default=\"2\">\n                <xs:annotation><xs:documentation>If specified front-pads the secondary sequenced value with zeroes\n                    until it is this length. Defaults to 2.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"optimistic-lock\" type=\"boolean\" default=\"false\"/>\n            <xs:attribute name=\"no-update-stamp\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>\n                    The Entity Facade by default adds a single field (lastUpdatedStamp) to each entity for use in\n                    optimistic locking and data synchronization. If you do not want it to create that stamp for\n                    this entity then set this attribute to false.\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"cache\" type=\"cache-options\" default=\"false\"/>\n            <xs:attribute name=\"authorize-skip\" type=\"authorize-skip-options\" default=\"false\"/>\n            <xs:attribute name=\"create-only\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>If true values are immutable, can only be created and not updated\n                    or deleted.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"enable-audit-log\" type=\"audit-log-options\">\n                <xs:annotation><xs:documentation>\n                    No default (effectively defaults to false). If set this value will be used for @enable-audit-log\n                    attribute on all fields without a value set (the field level value overrides this value).\n\n                    Note that this also sets the value for the automatically added field lastUpdatedStamp so if enabled\n                    there will be a record of all updates.\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"short-alias\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>A short alias for the entity, mainly meant for REST URLs but entities\n                    may be referenced by this. Must be unique across all entities defined. If a duplicate is found the\n                    later loading entity will be used. Should be short, start with a lowercase character, and be plural\n                    (ie products, not product).</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"extend-entity\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"field\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"relationship\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"index\"/>\n                <xs:element minOccurs=\"0\" ref=\"seed-data\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"master\"/>\n            </xs:sequence>\n            <xs:attribute name=\"entity-name\" type=\"name-upper\" use=\"required\"/>\n            <xs:attribute name=\"package\" type=\"name-package\" use=\"required\"/>\n            <xs:attribute name=\"package-name\" use=\"prohibited\"><xs:annotation><xs:documentation>\n                Deprecated, use package attribute</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"table-name\" type=\"xs:string\"/>\n            <xs:attribute name=\"group\" type=\"name-plain\"/>\n            <xs:attribute name=\"group-name\" use=\"prohibited\"><xs:annotation><xs:documentation>\n                Deprecated, use group attribute</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"sequence-bank-size\" type=\"xs:string\"/>\n            <xs:attribute name=\"sequence-primary-prefix\" type=\"xs:string\"/>\n            <xs:attribute name=\"optimistic-lock\" default=\"false\" type=\"boolean\"/>\n            <xs:attribute name=\"no-update-stamp\" default=\"false\" type=\"boolean\"/>\n            <xs:attribute name=\"cache\" default=\"false\" type=\"cache-options\"/>\n            <xs:attribute name=\"authorize-skip\" type=\"authorize-skip-options\" default=\"false\"/>\n            <xs:attribute name=\"enable-audit-log\" type=\"audit-log-options\"/>\n        </xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"field\">\n        <xs:complexType>\n            <xs:sequence><xs:element minOccurs=\"0\" ref=\"description\"/></xs:sequence>\n            <xs:attribute name=\"name\" type=\"name-field\" use=\"required\"/>\n            <xs:attribute name=\"column-name\" type=\"xs:string\"/>\n            <xs:attribute name=\"type\" type=\"field-type-options\" use=\"required\"/>\n            <xs:attribute name=\"is-pk\" default=\"false\" type=\"boolean\"/>\n            <xs:attribute name=\"not-null\" default=\"false\" type=\"boolean\"/>\n            <xs:attribute name=\"encrypt\" default=\"false\" type=\"boolean\"/>\n            <xs:attribute name=\"enable-audit-log\" default=\"false\" type=\"audit-log-options\">\n                <xs:annotation><xs:documentation>\n                    Defaults to false. If true whenever the value for this field on a record changes the Entity Facade\n                    will record the change (create or update) in the moqui.entity.EntityAuditLog entity. If set to\n                    update will not create an audit record for the initial create, only for updates.\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"enable-localization\" default=\"false\" type=\"boolean\">\n                <xs:annotation><xs:documentation>\n                    If true gets on this field will be looked up with the moqui.basic.LocalizedEntityField entity and if there is\n                    a matching record the localized value there will be returned instead of the actual record's value.\n                    Defaults to false for performance reasons, only set to true for fields that will have translations.\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"default\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>\n                    A Groovy expression with the default value of the field. It can be derived from other fields on the\n                    same record, set to a constant, etc. Set during create and update operations, after EECA before\n                    rules are run, and only if the field value is null or an empty String.\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"create-only\" type=\"boolean\">\n                <xs:annotation><xs:documentation>If true values are immutable, can only be set on create and not update.\n                    Overrides entity.@create-only value, set to false explicitly to allow update of certain fields on\n                    create-only entities.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"relationship\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" ref=\"description\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"key-map\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" name=\"key-value\">\n                    <xs:annotation><xs:documentation>Constant values for looking up related records, should only be used with type 'many'</xs:documentation></xs:annotation>\n                    <xs:complexType>\n                        <xs:attribute name=\"related\" type=\"name-field\" use=\"required\"/>\n                        <xs:attribute name=\"value\" type=\"xs:string\" use=\"required\"/>\n                    </xs:complexType>\n                </xs:element>\n            </xs:sequence>\n            <xs:attribute name=\"type\" use=\"required\">\n                <xs:simpleType><xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"one\"/>\n                    <xs:enumeration value=\"many\"/>\n                    <xs:enumeration value=\"one-nofk\"/>\n                </xs:restriction></xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"title\" type=\"name-upper\"/>\n            <xs:attribute name=\"related\" type=\"name-full\" use=\"required\"/>\n            <xs:attribute name=\"related-entity-name\" type=\"name-full\" use=\"prohibited\"><xs:annotation><xs:documentation>\n                Deprecated, use the related attribute</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"fk-name\" type=\"xs:string\"/>\n            <xs:attribute name=\"short-alias\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>A short alias for the relationship, mainly meant for REST URLs but\n                    relationships may be referenced by this. Must be unique for relationships within an entity (ie only\n                    has meaning in the context of a particular entity). Should be short, start with a lowercase\n                    character, and be plural (ie products, not product).</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"mutable\" type=\"boolean\" default=\"false\"><xs:annotation><xs:documentation>\n                If true related record may be modified through create/update auto-service calls with child records.\n                If false relationship is read-only for aggregated records (using master definition or dependent levels).\n                Defaults to false for type one* relationships, to true for type many (many are generally detail or join entities).\n            </xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"key-map\">\n        <xs:complexType>\n            <xs:attribute name=\"field-name\" type=\"name-field\" use=\"required\"/>\n            <xs:attribute name=\"related\" type=\"name-field\"/>\n            <xs:attribute name=\"related-field-name\" use=\"prohibited\"><xs:annotation><xs:documentation>\n                Deprecated, use the related attribute</xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"index\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" ref=\"description\"/>\n                <xs:element maxOccurs=\"unbounded\" ref=\"index-field\"/>\n            </xs:sequence>\n            <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"unique\" default=\"false\" type=\"boolean\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"index-field\">\n        <xs:complexType><xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/></xs:complexType>\n    </xs:element>\n\n    <!-- TABLED not to be part of 1.0:\n    <xs:element name=\"change-set\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:choice maxOccurs=\"unbounded\">\n                    <xs:element name=\"drop-table\">\n                        <xs:complexType>\n                            <xs:attribute name=\"table-name\"/>\n                        </xs:complexType>\n                    </xs:element>\n                    <xs:element name=\"rename-table\">\n                        <xs:complexType>\n                            <xs:attribute name=\"old-table-name\"/>\n                        </xs:complexType>\n                    </xs:element>\n                    <xs:element name=\"rename-column\">\n                        <xs:complexType>\n                            <xs:attribute name=\"old-column-name\"/>\n                            <xs:attribute name=\"field-name\"/>\n                        </xs:complexType>\n                    </xs:element>\n                    <xs:element name=\"drop-column\">\n                        <xs:complexType>\n                            <xs:attribute name=\"column-name\"/>\n                        </xs:complexType>\n                    </xs:element>\n                    <xs:element name=\"merge-columns\">\n                        <xs:complexType>\n                            <xs:attribute name=\"column-1-name\"/>\n                            <xs:attribute name=\"column-2-name\"/>\n                            <xs:attribute name=\"field-name\"/>\n                        </xs:complexType>\n                    </xs:element>\n                    <xs:element name=\"modify-data-type\">\n                        <xs:complexType>\n                            <xs:attribute name=\"field-name\"/>\n                        </xs:complexType>\n                    </xs:element>\n                    <xs:element name=\"drop-index\">\n                        <xs:complexType>\n                            <xs:attribute name=\"index-name\"/>\n                        </xs:complexType>\n                    </xs:element>\n                    <xs:element name=\"drop-foreign-key-constraint\">\n                        <xs:complexType>\n                            <xs:attribute name=\"constraint-name\"/>\n                        </xs:complexType>\n                    </xs:element>\n                </xs:choice>\n            </xs:sequence>\n            <xs:attribute name=\"id\" type=\"xs:string\" use=\"required\"/>\n        </xs:complexType>\n    </xs:element>\n    -->\n\n    <xs:element name=\"seed-data\">\n        <xs:complexType><xs:sequence><xs:any minOccurs=\"0\" maxOccurs=\"unbounded\" processContents=\"skip\"/></xs:sequence></xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"master\">\n        <xs:annotation><xs:documentation>Define the structure of this entity as a master entity for outgoing messages\n            and definitions for incoming messages, though all relationships are supported in incoming/imported data. Also\n            useful for extended query to get all data associated with a master record.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"detail\"/>\n            </xs:sequence>\n            <xs:attribute name=\"name\" type=\"name-plain\" default=\"default\"><xs:annotation><xs:documentation>\n                A name to distinguish multiple master definitions for an entity. Required when there is more than one\n                master definition for an entity.\n            </xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"detail\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"detail\"/>\n            </xs:sequence>\n            <xs:attribute name=\"relationship\" type=\"name-full\" use=\"required\"><xs:annotation><xs:documentation>\n                The relationship linking the master or parent detail to the detail. May be either short-alias or\n                full relationship name (${title}#${related-entity-name} or just related-entity-name if no title).\n            </xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"use-master\" type=\"name-plain\"><xs:annotation><xs:documentation>\n                The name of a master definition in the related entity to include all detail under this master.\n            </xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n\n    <!-- ================== view-entity ===================== -->\n\n    <xs:element name=\"view-entity\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" ref=\"description\"/>\n                <xs:element ref=\"member-entity\"/>\n                <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n                    <xs:element ref=\"member-entity\"/>\n                    <xs:element ref=\"member-relationship\"/>\n                </xs:choice>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"alias-all\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"alias\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"relationship\"/>\n                <xs:element minOccurs=\"0\" ref=\"entity-condition\"/>\n            </xs:sequence>\n            <xs:attribute name=\"entity-name\" type=\"name-upper\" use=\"required\"/>\n            <xs:attribute name=\"package\" type=\"name-package\" use=\"required\"/>\n            <xs:attribute name=\"cache\" default=\"false\" type=\"cache-options\"/>\n            <xs:attribute name=\"auto-clear-cache\" default=\"true\" type=\"boolean\"/>\n            <xs:attribute name=\"authorize-skip\" type=\"authorize-skip-options\" default=\"false\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"member-entity\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" ref=\"description\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"key-map\"/>\n                <xs:element minOccurs=\"0\" ref=\"entity-condition\"/>\n            </xs:sequence>\n            <xs:attribute name=\"entity-alias\" type=\"name-plain\" use=\"required\"/>\n            <xs:attribute name=\"entity-name\" type=\"name-full\" use=\"required\"/>\n            <xs:attribute name=\"join-from-alias\" type=\"name-plain\" use=\"optional\"/>\n            <xs:attribute name=\"join-optional\" type=\"boolean\" default=\"false\"/>\n            <xs:attribute name=\"sub-select\" default=\"false\">\n                <xs:simpleType><xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"true\"><xs:annotation><xs:documentation>\n                        Correlated sub-select with ON conditions moved to WHERE clause of sub-select (far more efficient for\n                        what would be a large temporary table from the less constrained sub-select, common for Moqui view entities)\n                    </xs:documentation></xs:annotation></xs:enumeration>\n                    <xs:enumeration value=\"non-lateral\"><xs:annotation>\n                        <xs:documentation>Simple non-correlated sub-select (full sub-select run in temp table)</xs:documentation>\n                    </xs:annotation></xs:enumeration>\n                    <xs:enumeration value=\"false\"><xs:annotation>\n                        <xs:documentation>Not a sub-select</xs:documentation></xs:annotation></xs:enumeration>\n                </xs:restriction></xs:simpleType>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"member-relationship\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" ref=\"description\"/>\n                <xs:element minOccurs=\"0\" ref=\"entity-condition\"/>\n            </xs:sequence>\n            <xs:attribute name=\"entity-alias\" type=\"name-plain\" use=\"required\"/>\n            <xs:attribute name=\"join-from-alias\" type=\"name-plain\" use=\"required\"/>\n            <xs:attribute name=\"relationship\" type=\"name-full\" use=\"required\"/>\n            <xs:attribute name=\"join-optional\" type=\"boolean\" default=\"false\"/>\n            <xs:attribute name=\"sub-select\" type=\"boolean\" default=\"false\"/>\n        </xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"alias-all\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" ref=\"description\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"exclude\"/>\n            </xs:sequence>\n            <xs:attribute name=\"entity-alias\" type=\"name-plain\" use=\"required\"/>\n            <xs:attribute name=\"prefix\" type=\"name-plain\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"exclude\">\n        <xs:complexType><xs:attribute name=\"field\" type=\"xs:string\" use=\"required\"/></xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"alias\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" ref=\"description\"/>\n                <xs:choice minOccurs=\"0\">\n                    <xs:element ref=\"complex-alias\"/>\n                    <xs:element name=\"case\">\n                        <xs:complexType>\n                            <xs:sequence>\n                                <xs:element maxOccurs=\"unbounded\" name=\"when\">\n                                    <xs:complexType>\n                                        <xs:sequence><xs:element ref=\"complex-alias\"/></xs:sequence>\n                                        <xs:attribute name=\"expression\" type=\"xs:string\" use=\"required\"/>\n                                    </xs:complexType>\n                                </xs:element>\n                                <xs:element minOccurs=\"0\" name=\"else\">\n                                    <xs:complexType><xs:sequence><xs:element ref=\"complex-alias\"/></xs:sequence></xs:complexType>\n                                </xs:element>\n                            </xs:sequence>\n                            <xs:attribute name=\"expression\" type=\"xs:string\"/>\n                        </xs:complexType>\n                    </xs:element>\n                </xs:choice>\n            </xs:sequence>\n            <xs:attribute name=\"entity-alias\" type=\"name-plain\"/>\n            <xs:attribute name=\"name\" type=\"name-field\" use=\"required\"/>\n            <xs:attribute name=\"field\" type=\"name-field\"/>\n            <xs:attribute name=\"function\" type=\"aggregate-function\"/>\n            <xs:attribute name=\"is-aggregate\" type=\"boolean\">\n                <xs:annotation><xs:documentation>Specify if the function is an aggregate function.\n                    If unspecified determined automatically from function attribute.\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"type\" type=\"field-type-options\">\n                <xs:annotation><xs:documentation>Normally determined automatically from the entity field it is based on\n                    but needs to be specified for complex-alias with a function if you want something other than\n                    number-decimal.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"default-display\" type=\"boolean\"><xs:annotation><xs:documentation>\n                Mostly used internally for auto form fields from entity fields. If not set uses default behavior\n                (in form-list display all types except text-long, text-very-long, binary-very-long).\n            </xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"pq-expression\" type=\"xs:string\"><xs:annotation><xs:documentation>\n                Post-query Groovy expression evaluated using the values of other aliased fields on this view-entity. Meant primarily\n                for use in EntityDynamicView instances created from DataDocument definitions. If specified the alias is not queried\n                from the database and other attributes such as entity-alias, field, and function are ignored.\n            </xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"complex-alias\">\n        <xs:annotation><xs:documentation>\n            In every SELECT statement, the fields that are normally used are really defined to be expressions.\n            This means for example that you can supply an expression like (discountPercent * 100) in place of  just a field name.\n            The complex-alias tag is the way to do this.\n\n            The argument to the right of operator = can be any operator valid for that data type on the database system you are using.\n            For example *, +, -, and / are commonly available mathematical operators.\n            You can also use any operator on any data type supported on the underlying database system including string and date operators.\n            complex-alias can be as complex as you need by adding nested complex-alias statements and complex-alias-field\n            can use the same functions (min, max, count, count-distinct, sum, avg, upper, and lower) as the alias tag.\n        </xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n                <xs:element ref=\"complex-alias\"/>\n                <xs:element ref=\"complex-alias-field\"/>\n            </xs:choice>\n            <xs:attribute name=\"operator\" type=\"xs:string\" default=\"+\"/>\n            <xs:attribute name=\"function\" type=\"xs:string\"><xs:annotation>\n                <xs:documentation>If specified operator is ignored and child elements are treated as function parameters</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"expression\" type=\"xs:string\"><xs:annotation><xs:documentation>If specified all else is ignored and\n                only this is included in the SQL test. Expression may use ${} for field expansion</xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"complex-alias-field\">\n        <xs:complexType>\n            <xs:attribute name=\"entity-alias\" type=\"name-plain\" use=\"required\"/>\n            <xs:attribute name=\"field\" type=\"name-field\" use=\"required\"/>\n            <xs:attribute name=\"default-value\" type=\"xs:string\"/>\n            <xs:attribute name=\"function\" type=\"aggregate-function\"/>\n        </xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"entity-condition\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n                    <xs:element ref=\"date-filter\"/>\n                    <xs:element ref=\"econdition\"/>\n                    <xs:element ref=\"econditions\"/>\n                </xs:choice>\n                <xs:element minOccurs=\"0\" ref=\"having-econditions\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"order-by\"/>\n            </xs:sequence>\n            <xs:attribute name=\"distinct\" default=\"false\" type=\"boolean\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"date-filter\">\n        <xs:annotation><xs:documentation>Adds a econdition to find to filter by the from and thru dates in each record, comparing them to the valid-date value.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute type=\"xs:string\" name=\"valid-date\">\n                <xs:annotation><xs:documentation>The name of a field in the context to compare each value to. Defaults to now.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"entity-alias\" type=\"name-plain\"><xs:annotation><xs:documentation>\n                The member-entity alias for the from and thru field names, if entity-condition under a member-entity (for join conditions)\n                defaults to current member-entity alias. If no entity alias specified field names are treated as view-entity aliased fields.\n            </xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute type=\"xs:string\" name=\"from-field-name\" default=\"fromDate\">\n                <xs:annotation><xs:documentation>The name of the entity field to use as the from/beginning effective date. Defaults to fromDate.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute type=\"xs:string\" name=\"thru-field-name\" default=\"thruDate\">\n                <xs:annotation><xs:documentation>The name of the entity field to use as the thru/ending effective date.Defaults to thruDate.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"econdition\">\n        <xs:complexType>\n            <xs:attribute name=\"entity-alias\" type=\"name-plain\"><xs:annotation><xs:documentation>\n                The member-entity alias for field-name, if entity-condition under a member-entity (for join conditions) defaults to\n                current member-entity alias. If no entity alias specified field names are treated as view-entity aliased fields.\n            </xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"field-name\" type=\"name-field\" use=\"required\"/>\n            <xs:attribute name=\"operator\" default=\"equals\" type=\"operator-entity\"/>\n            <xs:attribute name=\"to-entity-alias\" type=\"name-plain\"><xs:annotation><xs:documentation>\n                The member-entity alias for to-field-name, if entity-condition under a member-entity (for join conditions) defaults to\n                current member-entity alias. If no entity alias specified field names are treated as view-entity aliased fields.\n            </xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"to-field-name\" type=\"name-field\"/>\n            <xs:attribute name=\"value\" type=\"xs:string\"/>\n            <xs:attribute name=\"ignore-case\" default=\"false\" type=\"boolean\"/>\n            <xs:attribute name=\"or-null\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>If true make a condition specified value or null as valid matches.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"econditions\">\n        <xs:complexType>\n            <xs:choice maxOccurs=\"unbounded\">\n                <xs:element ref=\"date-filter\"/>\n                <xs:element ref=\"econdition\"/>\n                <xs:element ref=\"econditions\"/>\n            </xs:choice>\n            <xs:attribute name=\"combine\" default=\"and\">\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"and\"/>\n                        <xs:enumeration value=\"or\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"having-econditions\">\n        <xs:complexType>\n            <xs:choice maxOccurs=\"unbounded\">\n                <xs:element ref=\"date-filter\"/>\n                <xs:element ref=\"econdition\"/>\n                <xs:element ref=\"econditions\"/>\n            </xs:choice>\n            <xs:attribute name=\"combine\" default=\"and\">\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"and\"/>\n                        <xs:enumeration value=\"or\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"order-by\">\n        <!-- NOTE: note a more constrained name-field or something as can have |+|-|^|,| etc characters -->\n        <xs:complexType><xs:attribute name=\"field-name\" type=\"xs:string\" use=\"required\"/></xs:complexType>\n    </xs:element>\n</xs:schema>\n"
  },
  {
    "path": "framework/xsd/entity-eca-3.xsd",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" elementFormDefault=\"qualified\">\n    <xs:include schemaLocation=\"xml-actions-3.xsd\"/>\n\n    <xs:element name=\"eecas\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"eeca\"/>\n            </xs:sequence>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"eeca\">\n        <xs:annotation><xs:documentation>\n            Triggered by entity operations such a create, update, delete, and find. If condition (optional) evaluates\n            to true then the actions are run.\n\n            Entity ECAs are meant for maintenance of data derived from other entities. Entity ECAs should NOT generally\n            be used for triggering business processes, Service ECA rules are a much better tool for that.\n\n            For create, update, and delete operations the context coming in will be the current context plus the entity\n            value's fields added to the context for convenience in reading, and a \"entityValue\" variable for the actual\n            EntityValue object.\n        </xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" ref=\"description\"/>\n                <xs:element minOccurs=\"0\" ref=\"condition\"/>\n                <xs:element ref=\"actions\"/>\n            </xs:sequence>\n            <xs:attribute name=\"id\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Optional but recommended. If another EECA rule has the same id it will override\n                    any previously found with that id to change behavior or disable by override with empty actions.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"entity\" type=\"name-full\" use=\"required\"/>\n            <xs:attribute name=\"on-create\" default=\"false\" type=\"boolean\"/>\n            <xs:attribute name=\"on-update\" default=\"false\" type=\"boolean\"/>\n            <xs:attribute name=\"on-delete\" default=\"false\" type=\"boolean\"/>\n            <!-- Find EECA rules are deprecated because never used and have a substantial performance hit\n            <xs:attribute name=\"on-find-one\" default=\"false\" type=\"boolean\"/>\n            <xs:attribute name=\"on-find-list\" default=\"false\" type=\"boolean\"/>\n            <xs:attribute name=\"on-find-iterator\" default=\"false\" type=\"boolean\"/>\n            <xs:attribute name=\"on-find-count\" default=\"false\" type=\"boolean\"/>\n            -->\n            <xs:attribute name=\"run-before\" default=\"false\" type=\"boolean\">\n                <xs:annotation><xs:documentation>If false (default) runs after the entity operation. If true runs before\n                    the operation.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"run-on-error\" default=\"false\" type=\"boolean\"/>\n            <xs:attribute name=\"get-entire-entity\" default=\"false\" type=\"boolean\">\n                <xs:annotation><xs:documentation>Get the entire entity before running the actions for update and delete\n                    operations and add unset values to field values from the operation.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"get-original-value\" default=\"false\" type=\"boolean\">\n                <xs:annotation><xs:documentation>Adds an 'originalValue' field to the context with the value from the\n                    database if called before the entity operation and is a update or delete.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"set-results\" default=\"false\" type=\"boolean\">\n                <xs:annotation><xs:documentation>If true loop through field names and set on the entity values any values added\n                    to the context in the actions or in a Map returned from actions.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n</xs:schema>\n"
  },
  {
    "path": "framework/xsd/framework-catalog.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE catalog PUBLIC \"-//OASIS//DTD Entity Resolution XML Catalog V1.0//EN\"\n                         \"http://www.oasis-open.org/committees/entity/release/1.0/catalog.dtd\">\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n\n<catalog xmlns=\"urn:oasis:names:tc:entity:xmlns:xml:catalog\">\n    <!-- <public publicId=\"-//W3C//DTD SVG 1.0//EN\" uri=\"svg10.dtd\"/> -->\n\n    <system systemId=\"http://moqui.org/xsd/email-eca-3.xsd\" uri=\"email-eca-3.xsd\"/>\n    <system systemId=\"http://moqui.org/xsd/entity-definition-3.xsd\" uri=\"entity-definition-3.xsd\"/>\n    <system systemId=\"http://moqui.org/xsd/entity-eca-3.xsd\" uri=\"entity-eca-3.xsd\"/>\n\n    <system systemId=\"http://moqui.org/xsd/moqui-conf-3.xsd\" uri=\"moqui-conf-3.xsd\"/>\n\n    <system systemId=\"http://moqui.org/xsd/rest-api-3.xsd\" uri=\"rest-api-3.xsd\"/>\n    <system systemId=\"http://moqui.org/xsd/service-definition-3.xsd\" uri=\"service-definition-3.xsd\"/>\n    <system systemId=\"http://moqui.org/xsd/service-eca-3.xsd\" uri=\"service-eca-3.xsd\"/>\n\n    <system systemId=\"http://moqui.org/xsd/xml-actions-3.xsd\" uri=\"xml-actions-3.xsd\"/>\n    <system systemId=\"http://moqui.org/xsd/xml-form-3.xsd\" uri=\"xml-form-3.xsd\"/>\n    <system systemId=\"http://moqui.org/xsd/xml-screen-3.xsd\" uri=\"xml-screen-3.xsd\"/>\n</catalog>\n"
  },
  {
    "path": "framework/xsd/moqui-conf-3.xsd",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" elementFormDefault=\"qualified\">\n    <xs:include schemaLocation=\"common-types-3.xsd\"/>\n    <xs:include schemaLocation=\"xml-actions-3.xsd\"/>\n\n    <xs:element name=\"moqui-conf\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"default-property\"/>\n                <xs:element minOccurs=\"0\" ref=\"tools\"/>\n                <xs:element minOccurs=\"0\" ref=\"cache-list\"/>\n                <xs:element minOccurs=\"0\" ref=\"server-stats\"/>\n                <xs:element minOccurs=\"0\" ref=\"webapp-list\"/>\n                <xs:element minOccurs=\"0\" ref=\"artifact-execution-facade\"/>\n                <xs:element minOccurs=\"0\" ref=\"user-facade\"/>\n                <xs:element minOccurs=\"0\" ref=\"transaction-facade\"/>\n                <xs:element minOccurs=\"0\" ref=\"resource-facade\"/>\n                <xs:element minOccurs=\"0\" ref=\"screen-facade\"/>\n                <xs:element minOccurs=\"0\" ref=\"service-facade\"/>\n                <xs:element minOccurs=\"0\" ref=\"elastic-facade\"/>\n                <xs:element minOccurs=\"0\" ref=\"entity-facade\"/>\n                <xs:element minOccurs=\"0\" ref=\"database-list\"/>\n                <xs:element minOccurs=\"0\" ref=\"repository-list\"/>\n                <xs:element minOccurs=\"0\" ref=\"component-list\"/>\n            </xs:sequence>\n        </xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"init-param\">\n        <xs:complexType>\n            <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"value\" type=\"xs:string\" use=\"required\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"default-property\">\n        <xs:complexType>\n            <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"value\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"is-secret\" type=\"boolean\" default=\"false\"/>\n        </xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"tools\">\n        <xs:complexType>\n            <xs:sequence><xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"tool-factory\"/></xs:sequence>\n            <xs:attribute name=\"empty-db-load\" type=\"xs:string\" default=\"seed\"><xs:annotation><xs:documentation>\n                Comma-separated list of data file types to load if database is empty (if there are no records in the\n                table for moqui.basic.Enumeration). Empty or 'none' means load nothing, use 'all' to load all found\n                data files regardless of type.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"on-start-load-types\" type=\"xs:string\"><xs:annotation><xs:documentation>\n                Comma-separated list of data file types to load on start. Empty or 'none' means load nothing.\n                Does not run if empty-db-load runs.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"on-start-load-components\" type=\"xs:string\"><xs:annotation><xs:documentation>\n                Comma-separated list of component names to load on start, used with on-start-load-types.\n                Does not run if empty-db-load runs.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"worker-queue\" type=\"xs:integer\"><xs:annotation><xs:documentation>\n                The maximum size of the worker queue.</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"worker-pool-core\" type=\"xs:integer\"><xs:annotation><xs:documentation>\n                The core (minimum) size of the worker thread pool.</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"worker-pool-max\" type=\"xs:integer\"><xs:annotation><xs:documentation>\n                The maximum size of the worker thread pool.</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"worker-pool-alive\" type=\"xs:integer\"><xs:annotation><xs:documentation>\n                The amount of time, in seconds, to keep idle worker threads alive (beyond core pool size).</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"notification-topic-factory\" type=\"xs:string\"><xs:annotation><xs:documentation>\n                The ToolFactory to use to get a SimpleTopic for distributed NotificationMessage</xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"tool-factory\">\n        <xs:complexType>\n            <xs:attribute name=\"class\" type=\"xs:string\" use=\"required\"><xs:annotation><xs:documentation>\n                Must implement the org.moqui.context.ToolFactory interface.</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"init-priority\" type=\"xs:integer\" default=\"10\"/>\n            <xs:attribute name=\"disabled\" type=\"boolean\" default=\"false\"/>\n        </xs:complexType>\n    </xs:element>\n    <!-- ===================== Cache Conf Root ===================== -->\n    <xs:element name=\"cache-list\">\n        <xs:complexType>\n            <xs:sequence><xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"cache\"/></xs:sequence>\n            <xs:attribute name=\"warm-on-start\" type=\"boolean\" default=\"true\"/>\n            <xs:attribute name=\"local-factory\" type=\"xs:string\"><xs:annotation><xs:documentation>\n                The name of the ToolFactory to use for the local CacheManager implementation.</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"distributed-factory\" type=\"xs:string\"><xs:annotation><xs:documentation>\n                The name of the ToolFactory to use for the distributed CacheManager implementation.</xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"cache\">\n        <xs:complexType>\n            <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"key-type\" type=\"xs:string\" default=\"String\"/>\n            <xs:attribute name=\"value-type\" type=\"xs:string\" default=\"Object\"/>\n\n            <xs:attribute name=\"expire-time-idle\" type=\"xs:nonNegativeInteger\" use=\"optional\">\n                <xs:annotation><xs:documentation>Idle expire time in seconds.</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"expire-time-live\" type=\"xs:nonNegativeInteger\" use=\"optional\">\n                <xs:annotation><xs:documentation>Live expire time in seconds.</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"max-elements\" type=\"xs:nonNegativeInteger\" use=\"optional\"/>\n            <xs:attribute name=\"eviction-strategy\" default=\"least-frequently-used\">\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"least-recently-used\"/>\n                        <xs:enumeration value=\"least-frequently-used\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"type\" default=\"local\">\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"local\">\n                            <xs:annotation><xs:documentation>Local only cache (MCache)</xs:documentation></xs:annotation></xs:enumeration>\n                        <xs:enumeration value=\"distributed\">\n                            <xs:annotation><xs:documentation>Distributed cache; keys and values must be serializable</xs:documentation></xs:annotation></xs:enumeration>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"server-stats\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"artifact-stats\"/>\n            </xs:sequence>\n            <xs:attribute name=\"bin-length-seconds\" type=\"xs:positiveInteger\" default=\"900\">\n                <xs:annotation><xs:documentation>The bin length should be less than or equal to one hour and evenly\n                    divisible into an hour, the default is 900 seconds (15 minutes)</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"visit-enabled\" type=\"boolean\" default=\"true\"/>\n            <xs:attribute name=\"visit-ip-info-on-login\" type=\"boolean\" default=\"true\"/>\n            <xs:attribute name=\"visitor-enabled\" type=\"boolean\" default=\"true\"/>\n            <xs:attribute name=\"stats-skip-condition\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>If evaluates to true skips creating visit and visitor, and doesn't\n                    track ArtifactHit for screens.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"artifact-stats\">\n        <xs:complexType>\n            <xs:attribute name=\"type\" use=\"required\"><xs:simpleType><xs:restriction base=\"xs:token\">\n                <xs:enumeration value=\"AT_XML_SCREEN\"/>\n                <xs:enumeration value=\"AT_XML_SCREEN_CONTENT\"/>\n                <xs:enumeration value=\"AT_XML_SCREEN_TRANS\"/>\n                <xs:enumeration value=\"AT_SERVICE\"/>\n                <xs:enumeration value=\"AT_ENTITY\"/>\n            </xs:restriction></xs:simpleType></xs:attribute>\n            <!-- Removed, note that entity-auto and entity-implicit service calls never have hits persisted and that is\n                mainly what this was used for: <xs:attribute name=\"sub-type\" type=\"xs:string\"/> -->\n            <xs:attribute name=\"persist-hit\" type=\"boolean\" default=\"false\"/>\n            <xs:attribute name=\"persist-bin\" type=\"boolean\" default=\"false\"/>\n        </xs:complexType>\n    </xs:element>\n\n    <!-- ===================== Webapp Conf Root ===================== -->\n    <xs:element name=\"webapp-list\">\n        <xs:complexType><xs:sequence><xs:element maxOccurs=\"unbounded\" ref=\"webapp\"/></xs:sequence></xs:complexType>\n    </xs:element>\n    <xs:element name=\"webapp\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" name=\"root-screen\"><xs:complexType>\n                    <xs:attribute name=\"host\" type=\"xs:string\" use=\"required\">\n                        <xs:annotation><xs:documentation>A pattern to match the host name against (from ServletRequest.getServerName())</xs:documentation></xs:annotation></xs:attribute>\n                    <xs:attribute name=\"location\" type=\"xs:string\" use=\"required\">\n                        <xs:annotation><xs:documentation>The location of the root screen XML file</xs:documentation></xs:annotation></xs:attribute>\n                </xs:complexType></xs:element>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" name=\"error-screen\"><xs:complexType>\n                    <xs:attribute name=\"error\" use=\"required\"><xs:simpleType><xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"unauthorized\"><xs:annotation><xs:documentation>401 - authentication required</xs:documentation></xs:annotation></xs:enumeration>\n                        <xs:enumeration value=\"forbidden\"><xs:annotation><xs:documentation>403 - authorization failed</xs:documentation></xs:annotation></xs:enumeration>\n                        <xs:enumeration value=\"not-found\"><xs:annotation><xs:documentation>404 - screen/resource not found</xs:documentation></xs:annotation></xs:enumeration>\n                        <xs:enumeration value=\"too-many\"><xs:annotation><xs:documentation>429 - tarpit limit reached</xs:documentation></xs:annotation></xs:enumeration>\n                        <xs:enumeration value=\"internal-error\"><xs:annotation><xs:documentation>500 - general error</xs:documentation></xs:annotation></xs:enumeration>\n                    </xs:restriction></xs:simpleType></xs:attribute>\n                    <xs:attribute name=\"screen-path\" type=\"xs:string\" use=\"required\">\n                        <xs:annotation><xs:documentation>The path to the screen to render on error</xs:documentation></xs:annotation></xs:attribute>\n                </xs:complexType></xs:element>\n                <xs:element minOccurs=\"0\" name=\"first-hit-in-visit\"><xs:complexType><xs:sequence><xs:element ref=\"actions\"/></xs:sequence></xs:complexType></xs:element>\n                <xs:element minOccurs=\"0\" name=\"before-request\"><xs:complexType><xs:sequence><xs:element ref=\"actions\"/></xs:sequence></xs:complexType></xs:element>\n                <xs:element minOccurs=\"0\" name=\"after-request\"><xs:complexType><xs:sequence><xs:element ref=\"actions\"/></xs:sequence></xs:complexType></xs:element>\n                <xs:element minOccurs=\"0\" name=\"after-login\"><xs:complexType><xs:sequence><xs:element ref=\"actions\"/></xs:sequence></xs:complexType></xs:element>\n                <xs:element minOccurs=\"0\" name=\"before-logout\"><xs:complexType><xs:sequence><xs:element ref=\"actions\"/></xs:sequence></xs:complexType></xs:element>\n                <xs:element minOccurs=\"0\" name=\"after-startup\"><xs:complexType><xs:sequence><xs:element ref=\"actions\"/></xs:sequence></xs:complexType></xs:element>\n                <xs:element minOccurs=\"0\" name=\"before-shutdown\"><xs:complexType><xs:sequence><xs:element ref=\"actions\"/></xs:sequence></xs:complexType></xs:element>\n\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" name=\"context-param\"><xs:complexType>\n                    <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n                    <xs:attribute name=\"value\" type=\"xs:string\" use=\"required\"/>\n                </xs:complexType></xs:element>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" name=\"filter\"><xs:complexType>\n                    <xs:sequence>\n                        <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"init-param\"/>\n                        <xs:element minOccurs=\"1\" maxOccurs=\"unbounded\" name=\"url-pattern\" type=\"xs:string\"/>\n                        <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" name=\"dispatcher\" type=\"xs:string\"/>\n                    </xs:sequence>\n                    <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n                    <xs:attribute name=\"class\" type=\"xs:string\" use=\"required\"/>\n                    <xs:attribute name=\"async-supported\" type=\"boolean\" default=\"false\"/>\n                    <xs:attribute name=\"enabled\" type=\"boolean\" default=\"true\"/>\n                    <xs:attribute name=\"priority\" type=\"xs:nonNegativeInteger\" default=\"5\"/>\n                </xs:complexType></xs:element>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" name=\"listener\">\n                    <xs:annotation><xs:documentation>\n                        Note that for Jetty, and other servlet containers, these can only be implementations of\n                        HttpSessionListener, HttpSessionIdListener, and HttpSessionAttributeListener; other listeners such\n                        as ServletContextListener implementations must be in the web.xml file (cannot be loaded when\n                        MoquiContextListener initializes).\n                    </xs:documentation></xs:annotation>\n                    <xs:complexType>\n                        <xs:attribute name=\"class\" type=\"xs:string\" use=\"required\"/>\n                        <xs:attribute name=\"enabled\" type=\"boolean\" default=\"true\"/>\n                    </xs:complexType>\n                </xs:element>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" name=\"servlet\"><xs:complexType>\n                    <xs:sequence>\n                        <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"init-param\"/>\n                        <xs:element minOccurs=\"1\" maxOccurs=\"unbounded\" name=\"url-pattern\" type=\"xs:string\"/>\n                    </xs:sequence>\n                    <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n                    <xs:attribute name=\"class\" type=\"xs:string\" use=\"required\"/>\n                    <xs:attribute name=\"load-on-startup\" type=\"xs:nonNegativeInteger\" default=\"1\"/>\n                    <xs:attribute name=\"async-supported\" type=\"boolean\" default=\"false\"/>\n                    <xs:attribute name=\"enabled\" type=\"boolean\" default=\"true\"/>\n                </xs:complexType></xs:element>\n                <xs:element minOccurs=\"0\" name=\"session-config\"><xs:complexType>\n                    <xs:attribute name=\"timeout\" type=\"xs:nonNegativeInteger\" use=\"required\"/>\n                </xs:complexType></xs:element>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" name=\"endpoint\">\n                    <xs:annotation><xs:documentation>Add a WebSocket Endpoint</xs:documentation></xs:annotation>\n                    <xs:complexType>\n                        <xs:attribute name=\"path\" type=\"xs:string\" use=\"required\"><xs:annotation><xs:documentation>\n                            The path for the endpoint, relative to webapp, must start with forward slash ('/')</xs:documentation></xs:annotation></xs:attribute>\n                        <xs:attribute name=\"class\" type=\"xs:string\" use=\"required\"><xs:annotation><xs:documentation>\n                            Must extend javax.websocket.Endpoint. For Moqui-specific features including setting up an ExecutionContext\n                            extend org.moqui.impl.webapp.MoquiAbstractEndpoint. Even if not using MoquiAbstractEndpoint it will try\n                            to add the following ServerEndpointConfig user properties: handshakeRequest, httpSession,\n                            executionContextFactory, and maxIdleTimeout (set to value of @timeout).</xs:documentation></xs:annotation></xs:attribute>\n                        <xs:attribute name=\"timeout\" type=\"xs:integer\"/>\n                        <xs:attribute name=\"enabled\" type=\"boolean\" default=\"true\"/>\n                    </xs:complexType>\n                </xs:element>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" name=\"response-header\"><xs:complexType>\n                    <xs:attribute name=\"type\" use=\"required\"><xs:simpleType><xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"web-resource-inline\"/>\n                        <xs:enumeration value=\"screen-resource-binary\"/>\n                        <xs:enumeration value=\"screen-resource-text\"/>\n                        <xs:enumeration value=\"screen-resource-template\"/>\n                        <xs:enumeration value=\"screen-render\"/>\n                        <xs:enumeration value=\"screen-secure\"/>\n                        <xs:enumeration value=\"screen-server-static\"/>\n                        <xs:enumeration value=\"screen-transition\"/>\n                        <xs:enumeration value=\"cors-actual\"/>\n                        <xs:enumeration value=\"cors-preflight\"/>\n                    </xs:restriction></xs:simpleType></xs:attribute>\n                    <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n                    <xs:attribute name=\"value\" type=\"xs:string\" use=\"required\"/>\n                    <xs:attribute name=\"add\" type=\"boolean\" default=\"false\"/>\n                </xs:complexType></xs:element>\n            </xs:sequence>\n            <xs:attribute name=\"name\" type=\"name-plain\" use=\"required\"><xs:annotation><xs:documentation>\n                Matched against value of a context-param.param-value element in the web.xml file where the param-name is \"moqui-name\".</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"https-enabled\" default=\"false\" type=\"xs:string\"/>\n            <xs:attribute name=\"https-port\" type=\"xs:string\"/>\n            <xs:attribute name=\"https-host\" type=\"xs:string\"/>\n            <xs:attribute name=\"http-port\" type=\"xs:string\"/>\n            <xs:attribute name=\"http-host\" type=\"xs:string\"/>\n            <xs:attribute name=\"handle-cors\" type=\"boolean-expandable\" default=\"true\"><xs:annotation><xs:documentation>\n                Set to false to disable CORS handling globally in MoquiServlet including Origin validation and adding cors-preflight and cors-actual configured response headers\n            </xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"allow-origins\" type=\"xs:string\"><xs:annotation><xs:documentation>\n                Comma separated list of host name or protocol plus host name to match against Origin request header; can be '*' to allow all</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"require-session-token\" type=\"boolean-expandable\" default=\"true\"><xs:annotation><xs:documentation>\n                If not false (default true) moquiSessionToken (from ec.web.sessionToken) must be passed to all\n                screen/transition requests in a session after the first.</xs:documentation></xs:annotation></xs:attribute>\n            <!-- Needed? Not yet implemented: <xs:attribute name=\"cookie-domain\" type=\"xs:string\"/> -->\n            <xs:attribute name=\"websocket-timeout\" type=\"xs:integer\"><xs:annotation><xs:documentation>\n                The default WebSocket session max idle timeout for the whole server.</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"upload-executable-allow\" type=\"boolean-expandable\" default=\"false\"/>\n            <xs:attribute name=\"client-ip-header\" type=\"xs:string\"><xs:annotation><xs:documentation>\n                Set this if there is a reverse proxy in front of the Moqui server.\n\n                Using X-Forwarded-For is risky because clients can set a value for the header to spoof a client IP\n                address; if the outer-most proxy handles X-Forwarded-For by always setting it to its client IP address\n                instead of the common default behavior of appending to an existing X-Forwarded-For header then it is fine,\n                but should generally be set to a more reliable header for the reverse-proxy used.\n            </xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n\n    <!-- ====================== Artifact Execution Facade Conf Root ======================= -->\n    <xs:element name=\"artifact-execution-facade\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"artifact-execution\"/>\n            </xs:sequence>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"artifact-execution\">\n        <xs:complexType>\n            <xs:attribute name=\"type\" use=\"required\">\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"AT_XML_SCREEN\"/>\n                        <xs:enumeration value=\"AT_XML_SCREEN_TRANS\"/>\n                        <xs:enumeration value=\"AT_SERVICE\"/>\n                        <xs:enumeration value=\"AT_ENTITY\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"authz-enabled\" type=\"boolean\" default=\"true\"/>\n            <xs:attribute name=\"tarpit-enabled\" type=\"boolean\" default=\"true\"/>\n        </xs:complexType>\n    </xs:element>\n\n    <!-- ====================== User Facade Conf Root ======================= -->\n    <xs:element name=\"user-facade\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" ref=\"password\"/>\n                <xs:element minOccurs=\"0\" ref=\"login-key\"/>\n                <xs:element minOccurs=\"0\" ref=\"login\"/>\n            </xs:sequence>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"password\">\n        <xs:complexType>\n            <xs:attribute name=\"encrypt-hash-type\" default=\"SHA-256\">\n                <xs:simpleType><xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"SHA-256\"/>\n                    <xs:enumeration value=\"SHA-384\"/>\n                    <xs:enumeration value=\"SHA-512\"/>\n                    <xs:enumeration value=\"SHA\"/>\n                    <xs:enumeration value=\"MD5\"/>\n                </xs:restriction></xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"min-length\" type=\"xs:nonNegativeInteger\" default=\"8\">\n                <xs:annotation><xs:documentation>Minimum length of password</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"min-digits\" type=\"xs:nonNegativeInteger\" default=\"1\">\n                <xs:annotation><xs:documentation>Minimum number of digits (numeric characters) in the password</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"min-others\" type=\"xs:nonNegativeInteger\" default=\"1\">\n                <xs:annotation><xs:documentation>Minimum number of other (non-alpha/numeric, not letters or digits) characters in the password</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"history-limit\" type=\"xs:nonNegativeInteger\" default=\"5\">\n                <xs:annotation><xs:documentation>Number of old passwords to save that cannot be reused (0 means don't save any history)</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"change-weeks\" type=\"xs:decimal\" default=\"12\">\n                <xs:annotation><xs:documentation>Require password change after this many months (0 means don't ever require change)</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"email-require-change\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>Require password change after a password reset email?</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"email-expire-hours\" type=\"xs:nonNegativeInteger\" default=\"48\">\n                <!-- TODO use in impl -->\n                <xs:annotation><xs:documentation>How long will the new password be valid from the password reset email?</xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"login-key\">\n        <xs:complexType>\n            <xs:attribute name=\"encrypt-hash-type\" default=\"SHA-256\">\n                <xs:simpleType><xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"SHA-256\"/>\n                    <xs:enumeration value=\"SHA-384\"/>\n                    <xs:enumeration value=\"SHA-512\"/>\n                    <xs:enumeration value=\"SHA\"/>\n                    <xs:enumeration value=\"MD5\"/>\n                </xs:restriction></xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"expire-hours\" type=\"xs:nonNegativeInteger\" default=\"144\">\n                <xs:annotation><xs:documentation>Expire key after this many hours</xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"login\">\n        <xs:complexType>\n            <xs:attribute name=\"max-failures\" type=\"xs:nonNegativeInteger\" default=\"3\">\n                <xs:annotation><xs:documentation>Account is disabled after max failures</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"disable-minutes\" type=\"xs:nonNegativeInteger\" default=\"30\">\n                <xs:annotation><xs:documentation>How long to disable the account (0 means no limit, ie forever)</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"history-store\" type=\"boolean\" default=\"true\">\n                <xs:annotation><xs:documentation>Store records of login attempts?</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"history-incorrect-password\" type=\"boolean\" default=\"true\">\n                <xs:annotation><xs:documentation>Store incorrect passwords in login attempt history?</xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <!-- Future security settings:\n    # NOTE: LDAP auth settings\n\n    # - - allow x509 certificate login\n    login.cert.allow=true\n\n    # - - HTTP header based ID (for integrations; make sure client can't send this with some sort of filter)\n    #login.http.header=REMOTE_USER\n\n    # - - HttpServletRequest.getRemoteUser() based ID (for integration; uncomment to enable)\n    # Use for external authentication solutions like CAS which overload the getRemoteUser method.\n    #login.http.servlet.getRemoteUser.allow=true\n    -->\n\n    <!-- ====================== Transaction Facade Conf Root ======================= -->\n    <xs:element name=\"transaction-facade\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" ref=\"server-jndi\"><xs:annotation><xs:documentation>\n                    If not present the default JNDI server will be used.</xs:documentation></xs:annotation></xs:element>\n                <xs:choice minOccurs=\"0\">\n                    <xs:element minOccurs=\"0\" ref=\"transaction-jndi\"/>\n                    <xs:element minOccurs=\"0\" ref=\"transaction-internal\"/>\n                </xs:choice>\n            </xs:sequence>\n            <xs:attribute name=\"use-transaction-cache\" default=\"true\" type=\"boolean\"/>\n            <xs:attribute name=\"use-connection-stash\" default=\"true\" type=\"boolean\"/>\n            <xs:attribute name=\"use-lock-track\" default=\"false\" type=\"boolean-expandable\">\n                <xs:annotation><xs:documentation>If true track locks from create, update, and delete plus FK locks and find for-update,\n                    use that data to warn about possible lock conflicts</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"use-statement-timeout\" default=\"false\" type=\"boolean-expandable\">\n                <xs:annotation><xs:documentation>If true runs all JDBC statements in a separate Thread in order to enforce a timeout,\n                    this has significant overhead but protects against long held locks, etc</xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"transaction-jndi\">\n        <xs:complexType>\n            <xs:attribute name=\"user-transaction-jndi-name\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"transaction-manager-jndi-name\" type=\"xs:string\" use=\"required\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"transaction-internal\">\n        <xs:complexType>\n            <xs:attribute name=\"class\" type=\"xs:string\" use=\"required\"/>\n        </xs:complexType>\n    </xs:element>\n\n    <!-- ====================== Resource Facade Conf Root ================= -->\n    <xs:element name=\"resource-facade\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"resource-reference\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"template-renderer\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"script-runner\"/>\n            </xs:sequence>\n            <xs:attribute name=\"xml-actions-template-location\" type=\"xs:string\"/>\n            <xs:attribute name=\"xsl-fo-handler-factory\" type=\"xs:string\"><xs:annotation><xs:documentation>\n                Name of the ToolFactory to use for XSL-FO transformation in the ResourceFacade.xslFoTransform() method.\n            </xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"resource-reference\">\n        <xs:complexType>\n            <xs:attribute name=\"scheme\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"class\" type=\"xs:string\" use=\"required\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"template-renderer\">\n        <xs:complexType>\n            <xs:attribute name=\"extension\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"class\" type=\"xs:string\" use=\"required\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"script-runner\">\n        <xs:complexType>\n            <xs:attribute name=\"extension\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"class\" type=\"xs:string\" use=\"optional\">\n                <xs:annotation><xs:documentation>If specified should point to a class that implements the\n                    org.moqui.context.ScriptRunner interface.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"engine\" type=\"xs:string\" use=\"optional\">\n                <xs:annotation><xs:documentation>The JSR-223 engine name, i.e. the name passed to the\n                    javax.script.ScriptEngine.getEngineByName() method. If you use this attribute do not use the class\n                    attribute as that will override this setting.\n\n                    NOTE: If you use a default extension supported in JSR-223 for the desired scripting language you do\n                    not need a script-runner element. The ScriptEngine will be looked up using the\n                    ScriptEngineManager.getEngineByExtension() method and the script (pre-compiled if supported) will\n                    be cached in the \"resource.script${extension}.location\" cache.\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n\n    <!-- ====================== Screen Facade Conf Root =================== -->\n    <xs:element name=\"screen-facade\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"screen-text-output\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"screen-output\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" name=\"screen\"><xs:complexType>\n                    <xs:sequence>\n                        <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" name=\"subscreens-item\"><xs:annotation><xs:documentation>\n                            See the screen.subscreens.subscreens-item element in xml-screen-{version}.xsd for more details\n                        </xs:documentation></xs:annotation><xs:complexType>\n                            <xs:attribute name=\"name\" type=\"name-plain\" use=\"required\"/>\n                            <xs:attribute name=\"location\" type=\"xs:string\"/>\n                            <xs:attribute name=\"menu-title\" type=\"xs:string\"/>\n                            <xs:attribute name=\"menu-index\" type=\"xs:positiveInteger\"/>\n                            <xs:attribute name=\"menu-include\" type=\"boolean\" default=\"true\"/>\n                            <xs:attribute name=\"no-sub-path\" type=\"boolean\" default=\"false\"/>\n                        </xs:complexType></xs:element>\n                    </xs:sequence>\n                    <xs:attribute name=\"location\" type=\"xs:string\" use=\"required\"/>\n                    <xs:attribute name=\"default-subscreen\" type=\"xs:string\"/>\n                </xs:complexType></xs:element>\n            </xs:sequence>\n            <xs:attribute name=\"boundary-comments\" default=\"false\" type=\"boolean\"/>\n            <xs:attribute name=\"default-paginate-rows\" type=\"xs:string\" default=\"20\"/>\n            <xs:attribute name=\"default-autocomplete-rows\" type=\"xs:string\" default=\"10\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"screen-text-output\"><xs:complexType>\n        <xs:attribute name=\"type\" type=\"xs:string\" use=\"required\"><xs:annotation>\n            <xs:documentation>Can be anything. Default supported values include: text, html, xsl-fo, xml, and csv.</xs:documentation>\n        </xs:annotation></xs:attribute>\n        <xs:attribute name=\"mime-type\" type=\"xs:string\" use=\"optional\"/>\n        <xs:attribute name=\"always-standalone\" type=\"boolean\" default=\"false\"/>\n        <xs:attribute name=\"skip-actions\" type=\"boolean\" default=\"false\"/>\n        <xs:attribute name=\"macro-template-location\" type=\"xs:string\" use=\"required\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"screen-output\"><xs:complexType>\n        <xs:attribute name=\"type\" type=\"xs:string\" use=\"required\"><xs:annotation>\n            <xs:documentation>Can be anything. Default supported values include: text, html, xsl-fo, xml, and csv.</xs:documentation>\n        </xs:annotation></xs:attribute>\n        <xs:attribute name=\"mime-type\" type=\"xs:string\" use=\"optional\"/>\n        <xs:attribute name=\"always-standalone\" type=\"boolean\" default=\"false\"/>\n        <xs:attribute name=\"skip-actions\" type=\"boolean\" default=\"false\"/>\n        <xs:attribute name=\"widget-render-class\" type=\"xs:string\" use=\"required\"/>\n    </xs:complexType></xs:element>\n\n    <!-- ====================== Service Facade Conf Root ======================= -->\n    <xs:element name=\"service-facade\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"service-location\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"service-type\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"service-file\"/>\n                <!-- leaving this out for now, not easily supported by Quartz Scheduler: <xs:element minOccurs=\"0\" ref=\"thread-pool\"/> -->\n                <!-- TABLED: not to include in 1.0: <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"jms-service\"/> -->\n            </xs:sequence>\n            <xs:attribute name=\"distributed-factory\" type=\"xs:string\"><xs:annotation><xs:documentation>\n                The name of the ToolFactory to use for the distributed async service ExecutorService implementation.\n            </xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"scheduled-job-check-time\" type=\"non-neg-int-expandable\"><xs:annotation><xs:documentation>\n                How often to check for and run scheduled service jobs in seconds. Set to 0 (zero) to disable.\n            </xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"job-queue-max\" type=\"xs:integer\"><xs:annotation><xs:documentation>\n                The maximum number of jobs to queue when job-pool-max is reached.</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"job-pool-core\" type=\"xs:integer\"><xs:annotation><xs:documentation>\n                The core (minimum) size of the service job thread pool.</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"job-pool-max\" type=\"xs:integer\"><xs:annotation><xs:documentation>\n                The maximum size of the service job thread pool.</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"job-pool-alive\" type=\"xs:integer\"><xs:annotation><xs:documentation>\n                The amount of time, in seconds, to keep idle worker threads alive (beyond core pool size).</xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"service-location\">\n        <xs:complexType>\n            <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"location\" type=\"xs:string\" use=\"required\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"service-type\">\n        <xs:complexType>\n            <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"runner-class\" type=\"xs:string\" use=\"required\"><xs:annotation><xs:documentation>\n                Fully qualified name of class that implements the org.moqui.impl.service.ServiceRunner interface.\n            </xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"service-file\">\n        <xs:complexType>\n            <xs:attribute name=\"location\" type=\"xs:string\" use=\"required\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"server-jndi\">\n        <xs:complexType>\n            <xs:attribute name=\"context-provider-url\" type=\"xs:string\"/>\n            <xs:attribute name=\"initial-context-factory\" type=\"xs:string\"/>\n            <xs:attribute name=\"url-pkg-prefixes\" type=\"xs:string\"/>\n            <xs:attribute name=\"security-principal\" type=\"xs:string\"><xs:annotation><xs:documentation>The username</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"security-credentials\" type=\"xs:string\"><xs:annotation><xs:documentation>The password</xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n\n    <!-- ===================== Elastic Facade Conf Root ===================== -->\n\n    <xs:element name=\"elastic-facade\"><xs:complexType>\n        <xs:sequence>\n            <xs:element name=\"cluster\" minOccurs=\"0\" maxOccurs=\"unbounded\"><xs:complexType>\n                <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n                <xs:attribute name=\"url\" type=\"xs:string\" use=\"required\"/>\n                <xs:attribute name=\"user\" type=\"xs:string\"/>\n                <xs:attribute name=\"password\" type=\"xs:string\"/>\n                <xs:attribute name=\"index-prefix\" type=\"xs:string\"><xs:annotation><xs:documentation>\n                    Prefix added to all ES index names just before requests\n                    Must follow ES index name requirements (lower case, etc)\n                    No separator character is added, recommend ending with underscore (_)\n                </xs:documentation></xs:annotation></xs:attribute>\n                <xs:attribute name=\"pool-max\" type=\"xs:positiveInteger\"/>\n                <xs:attribute name=\"queue-size\" type=\"xs:positiveInteger\"/>\n            </xs:complexType></xs:element>\n        </xs:sequence>\n    </xs:complexType></xs:element>\n\n    <!-- ===================== Entity Facade Conf Root ===================== -->\n    <xs:element name=\"entity-facade\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element name=\"decrypt-alt\" minOccurs=\"0\" maxOccurs=\"unbounded\"><xs:complexType>\n                    <xs:annotation><xs:documentation>Alternate settings for decryption only; useful for migrating keys, supporting data imported from other systems, etc</xs:documentation></xs:annotation>\n                    <xs:attribute name=\"crypt-pass\" type=\"xs:string\"/>\n                    <xs:attribute name=\"crypt-salt\" type=\"xs:string\"/>\n                    <xs:attribute name=\"crypt-iter\" type=\"xs:string\"/>\n                    <xs:attribute name=\"crypt-algo\" type=\"xs:string\"/>\n                </xs:complexType></xs:element>\n                <xs:element minOccurs=\"0\" ref=\"server-jndi\">\n                    <xs:annotation><xs:documentation>If not present the default JNDI server will be used.</xs:documentation></xs:annotation>\n                </xs:element>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"datasource\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"load-entity\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"load-data\"/>\n            </xs:sequence>\n            <xs:attribute name=\"entity-eca-enabled\" default=\"true\" type=\"boolean-expandable\"/>\n            <xs:attribute name=\"distributed-cache-invalidate\" default=\"false\" type=\"boolean\">\n                <xs:annotation><xs:documentation>Enable distributed cache invalidate by distributed Topic</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"dci-topic-factory\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Topic factory for distributed cache invalidate</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"sequenced-id-prefix\" type=\"xs:string\"/>\n            <xs:attribute name=\"default-group-name\" type=\"name-plain\"/>\n            <xs:attribute name=\"database-time-zone\" type=\"xs:string\"/>\n            <xs:attribute name=\"database-locale\" type=\"xs:string\"/>\n            <xs:attribute name=\"crypt-pass\" type=\"xs:string\"/>\n            <xs:attribute name=\"crypt-salt\" type=\"xs:string\"/>\n            <xs:attribute name=\"crypt-iter\" type=\"xs:string\"/>\n            <xs:attribute name=\"crypt-algo\" type=\"xs:string\"/>\n            <xs:attribute name=\"query-stats\" default=\"false\" type=\"boolean-expandable\"/>\n        </xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"datasource\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:choice minOccurs=\"1\" maxOccurs=\"1\">\n                    <xs:element minOccurs=\"0\" maxOccurs=\"1\" ref=\"jndi-jdbc\"/>\n                    <xs:element minOccurs=\"0\" maxOccurs=\"1\" ref=\"inline-jdbc\"/>\n                    <xs:element minOccurs=\"0\" maxOccurs=\"1\" ref=\"inline-other\"/>\n                </xs:choice>\n            </xs:sequence>\n            <xs:attribute name=\"group-name\" type=\"name-segmented\" use=\"required\"/>\n            <xs:attribute name=\"database-conf-name\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>This is only required for JDBC/SQL datasources.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"schema-name\" type=\"xs:string\"/>\n            <xs:attribute name=\"startup-add-missing\" default=\"false\" type=\"boolean-expandable\"/>\n            <xs:attribute name=\"runtime-add-missing\" default=\"true\" type=\"boolean-expandable\"/>\n            <xs:attribute name=\"runtime-add-fks\" default=\"true\" type=\"boolean-expandable\"/>\n            <xs:attribute name=\"object-factory\" type=\"xs:string\" default=\"org.moqui.impl.entity.EntityDatasourceFactoryImpl\">\n                <xs:annotation><xs:documentation>The references class must implement the\n                    org.moqui.entity.EntityDatasourceFactory interface.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"sequence-primary-use-uuid\" type=\"boolean-expandable\" default=\"false\">\n                <xs:annotation><xs:documentation>Uses java.util.UUID.randomUUID() to get sequenced IDs for all entities in this datasource.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"start-server-args\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Currently only for the H2 database. Start a remote access server for\n                    the embedded DB using these arguments. See the main() method at\n                    http://www.h2database.com/javadoc/org/h2/tools/Server.html for details.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"disabled\" default=\"false\" type=\"boolean-expandable\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"inline-jdbc\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"1\" ref=\"xa-properties\"/>\n            </xs:sequence>\n            <xs:attribute name=\"jdbc-uri\" type=\"xs:string\" use=\"optional\"/>\n            <xs:attribute name=\"jdbc-username\" type=\"xs:string\" use=\"optional\"/>\n            <xs:attribute name=\"jdbc-password\" type=\"xs:string\" use=\"optional\"/>\n            <xs:attribute name=\"jdbc-driver\" type=\"xs:string\" use=\"optional\"/>\n            <xs:attribute name=\"xa-ds-class\" type=\"xs:string\" use=\"optional\"/>\n            <xs:attribute name=\"isolation-level\" type=\"isolation-level\"/>\n            <xs:attribute name=\"pool-maxsize\" type=\"xs:nonNegativeInteger\" default=\"50\"/>\n            <xs:attribute name=\"pool-minsize\" type=\"xs:nonNegativeInteger\" default=\"5\"/>\n            <xs:attribute name=\"pool-time-idle\" type=\"xs:nonNegativeInteger\">\n                <xs:annotation><xs:documentation>Maximum time in seconds that unused excess connections should stay in\n                    the pool.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"pool-time-reap\" type=\"xs:nonNegativeInteger\">\n                <xs:annotation><xs:documentation>Time in seconds that the connection pool will allow a connection to be\n                    in use, before claiming it back. Defaults to no limit.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"pool-time-maint\" type=\"xs:nonNegativeInteger\">\n                <xs:annotation><xs:documentation>Running interval in seconds for the pool maintenance thread.\n                    Defaults to 60 seconds.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"pool-time-wait\" type=\"xs:nonNegativeInteger\">\n                <xs:annotation><xs:documentation>Sets the maximum amount of time in seconds the pool will block\n                    waiting for a connection to become available in the pool when it is empty. Defaults to 30 seconds.\n                    Zero means no waiting.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"pool-test-query\" type=\"xs:string\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"jndi-jdbc\">\n        <xs:complexType>\n            <xs:attribute name=\"jndi-name\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"isolation-level\" type=\"isolation-level\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"xa-properties\">\n        <xs:complexType><xs:anyAttribute processContents=\"skip\"/></xs:complexType>\n    </xs:element>\n    <xs:element name=\"inline-other\">\n        <xs:complexType><xs:anyAttribute processContents=\"skip\"/></xs:complexType>\n    </xs:element>\n    <xs:element name=\"load-entity\">\n        <xs:annotation><xs:documentation>Most resources should be loaded by directory convention (convention over\n            configuration) within a component so this should only be used rarely. Sometimes that is not possible such\n            as remote locations or on a classpath that is inside a war or ear file, or that is from a special\n            ClassLoader.</xs:documentation></xs:annotation>\n        <xs:complexType><xs:attribute name=\"location\" type=\"xs:string\" use=\"required\"/></xs:complexType>\n    </xs:element>\n    <xs:element name=\"load-data\">\n        <xs:annotation><xs:documentation>Most resources should be loaded by directory convention (convention over\n            configuration) within a component so this should only be used rarely. Sometimes that is not possible such\n            as remote locations or on a classpath that is inside a war or ear file, or that is from a special\n            ClassLoader.</xs:documentation></xs:annotation>\n        <xs:complexType><xs:attribute name=\"location\" type=\"xs:string\" use=\"required\"/></xs:complexType>\n    </xs:element>\n\n    <!-- ====================== Database Conf Root ======================= -->\n    <xs:element name=\"database-list\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"dictionary-type\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"database\"/>\n            </xs:sequence>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"database\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"database-type\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" name=\"name-replace\"><xs:complexType>\n                    <xs:attribute name=\"original\" type=\"name-plain\" use=\"required\"/>\n                    <xs:attribute name=\"replace\" type=\"name-plain\" use=\"required\"/>\n                </xs:complexType></xs:element>\n                <xs:element minOccurs=\"0\" maxOccurs=\"1\" ref=\"inline-jdbc\"/>\n            </xs:sequence>\n\n            <xs:attribute name=\"name\" type=\"name-plain\" use=\"required\"/>\n            <xs:attribute name=\"lb-name\" type=\"name-plain\"><xs:annotation>\n                <xs:documentation>Name of the database for Liquibase, defaults to name attribute, see http://www.liquibase.org/databases.html</xs:documentation></xs:annotation></xs:attribute>\n\n            <!-- TODO use in code -->\n            <xs:attribute name=\"use-schemas\" default=\"true\" type=\"boolean\"/>\n            <xs:attribute name=\"use-pk-constraint-names\" default=\"true\" type=\"boolean\"/>\n            <xs:attribute name=\"constraint-name-clip-length\" default=\"30\" type=\"xs:nonNegativeInteger\"/>\n            <!-- TODO use in code -->\n            <xs:attribute name=\"result-fetch-size\" default=\"-1\" type=\"xs:integer\"/>\n            <xs:attribute name=\"use-foreign-keys\" default=\"true\" type=\"boolean\"/>\n            <xs:attribute name=\"use-foreign-key-indexes\" default=\"true\" type=\"boolean\"/>\n            <xs:attribute name=\"fk-style\" default=\"name_constraint\">\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"name_constraint\"/>\n                        <xs:enumeration value=\"name_fk\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"use-fk-initially-deferred\" default=\"false\" type=\"boolean\"/>\n            <xs:attribute name=\"use-indexes\" default=\"true\" type=\"boolean\">\n                <xs:annotation><xs:documentation>Use manually declared indexes?</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"use-indexes-unique\" default=\"true\" type=\"boolean\">\n                <xs:annotation><xs:documentation>For manually declared indexes (if used), use the unique constraint?</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"use-indexes-unique-where-not-null\" default=\"false\" type=\"boolean\">\n                <xs:annotation><xs:documentation>For manually declared indexes (if used), unique constraints should disregard null values?</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"join-style\" default=\"ansi\">\n                <xs:simpleType><xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"ansi\"/>\n                    <xs:enumeration value=\"ansi-no-parenthesis\"/>\n                </xs:restriction></xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"offset-style\" default=\"fetch\">\n                <xs:simpleType><xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"fetch\"><xs:annotation>\n                        <xs:documentation>Use the SQL:2008 syntax (OFFSET ? ROWS FETCH FIRST ? ROWS ONLY)</xs:documentation>\n                    </xs:annotation></xs:enumeration>\n                    <xs:enumeration value=\"limit\"><xs:annotation>\n                        <xs:documentation>Use the basic limit/offset syntax (LIMIT ? OFFSET ?)</xs:documentation>\n                    </xs:annotation></xs:enumeration>\n                    <xs:enumeration value=\"cursor\"><xs:annotation>\n                        <xs:documentation>Don't use an SQL syntax, use a database cursor through the\n                            EntityListIterator.getPartialList() method. Note that there may be different behavior when\n                            calling EntityFind.iterator() as it only seeks to the offset but doesn't restrict by the\n                            limit.</xs:documentation>\n                    </xs:annotation></xs:enumeration>\n                </xs:restriction></xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"from-lateral-style\" default=\"none\">\n                <xs:annotation><xs:documentation>\n                    The SQL style for correlated sub-selects for joins in a FROM clause for sub-select=true; setting this to 'none'\n                    results in the same SQL that would be generated for sub-select=non-lateral</xs:documentation></xs:annotation>\n                <xs:simpleType><xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"lateral\"><xs:annotation>\n                        <xs:documentation>Use the SQL:1999 syntax ([INNER|OUTER LEFT] JOIN LATERAL)</xs:documentation>\n                    </xs:annotation></xs:enumeration>\n                    <xs:enumeration value=\"apply\"><xs:annotation>\n                        <xs:documentation>Use the apply syntax (CROSS APPLY or OUTER APPLY for join-optional=true)</xs:documentation>\n                    </xs:annotation></xs:enumeration>\n                    <xs:enumeration value=\"none\"><xs:annotation>\n                        <xs:documentation>No sort of lateral join supported, use non-correlated sub-selects</xs:documentation>\n                    </xs:annotation></xs:enumeration>\n                </xs:restriction></xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"add-unique-as\" default=\"false\" type=\"boolean\"/>\n            <xs:attribute name=\"always-use-constraint-keyword\" default=\"false\" type=\"boolean\"/>\n            <xs:attribute name=\"use-schema-for-all\" default=\"false\" type=\"boolean\">\n                <xs:annotation><xs:documentation>Set to true to include the schema name for primary keys, foreign keys, and indexes.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"never-nulls\" default=\"false\" type=\"boolean\">\n                <xs:annotation><xs:documentation>Never use NULLS FIRST/LAST in ORDER BY clause</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"never-try-insert\" default=\"false\" type=\"boolean\">\n                <xs:annotation><xs:documentation>Never use try insert feature when storing a record</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"use-binary-type-for-blob\" default=\"false\" type=\"boolean\"/>\n            <xs:attribute name=\"table-engine\" type=\"xs:string\"/>\n            <xs:attribute name=\"character-set\" type=\"xs:string\"/>\n            <xs:attribute name=\"collate\" type=\"xs:string\"/>\n            <xs:attribute name=\"default-isolation-level\">\n                <xs:simpleType><xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"None\"/>\n                    <xs:enumeration value=\"ReadCommitted\"/>\n                    <xs:enumeration value=\"ReadUncommitted\"/>\n                    <xs:enumeration value=\"RepeatableRead\"/>\n                    <xs:enumeration value=\"Serializable\"/>\n                </xs:restriction></xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"for-update\" type=\"xs:string\" default=\"FOR UPDATE\"/>\n            <xs:attribute name=\"use-tm-join\" default=\"true\" type=\"boolean\">\n                <xs:annotation><xs:documentation>For Bitronix set this to false to not use tm join (for Atomikos this is\n                set in the serial_jta_transactions property in jta.properties)</xs:documentation></xs:annotation></xs:attribute>\n\n            <!-- Defaults for inline-jdbc element -->\n            <xs:attribute name=\"default-jdbc-driver\" type=\"xs:string\"/>\n            <xs:attribute name=\"default-xa-ds-class\" type=\"xs:string\"/>\n            <xs:attribute name=\"default-test-query\" type=\"xs:string\"/>\n\n            <!-- Defaults for datasource element -->\n            <xs:attribute name=\"default-startup-add-missing\" type=\"boolean\"/>\n            <xs:attribute name=\"default-runtime-add-missing\" type=\"boolean\"/>\n            <xs:attribute name=\"default-runtime-add-fks\" type=\"boolean\"/>\n            <xs:attribute name=\"default-start-server-args\" type=\"xs:string\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"dictionary-type\">\n        <xs:complexType>\n            <xs:attribute name=\"type\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"java-type\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"default-sql-type\" type=\"xs:string\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"database-type\">\n        <xs:complexType>\n            <xs:attribute name=\"type\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"sql-type\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"sql-type-alias\" type=\"xs:string\"/>\n            <xs:attribute name=\"java-type\" type=\"xs:string\"/>\n        </xs:complexType>\n    </xs:element>\n\n    <!-- ================== Repository (JCR) Conf Root ==================== -->\n    <xs:element name=\"repository-list\">\n        <xs:complexType><xs:sequence><xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"repository\"/></xs:sequence></xs:complexType>\n    </xs:element>\n    <xs:element name=\"repository\">\n        <xs:annotation><xs:documentation>Configuration for a javax.jcr.Repository retrieved through a\n            javax.jcr.RepositoryFactory using parameter sub-elements (with name and value attributes), and a\n            javax.jcr.Session using the workspace, username, and password attributes.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element ref=\"init-param\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n            </xs:sequence>\n            <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"workspace\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Defaults to the repository's default workspace.</xs:documentation></xs:annotation>\n            </xs:attribute>\n\n            <xs:attribute name=\"username\" type=\"xs:string\"/>\n            <xs:attribute name=\"password\" type=\"xs:string\"/>\n        </xs:complexType>\n    </xs:element>\n\n    <!-- ================= Additional Component Locations ================= -->\n    <xs:element name=\"component-list\">\n        <xs:annotation><xs:documentation>Use this to specify components to load in addition to those in the\n            runtime/component directory. This is useful for components in JCR repositories or wherever.\n            The location needs to use a Resource Facade protocol/schema that supports looking at sub-directories, etc\n            (like content:, file:, etc).</xs:documentation></xs:annotation>\n        <xs:complexType><xs:sequence>\n            <xs:choice maxOccurs=\"unbounded\">\n                <xs:element ref=\"component-dir\"/>\n                <xs:element ref=\"component\"/>\n            </xs:choice>\n        </xs:sequence></xs:complexType>\n    </xs:element>\n    <xs:element name=\"component-dir\">\n        <xs:complexType>\n            <xs:attribute name=\"location\" type=\"xs:string\" use=\"required\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"component\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element ref=\"depends-on\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n            </xs:sequence>\n            <xs:attribute name=\"name\" type=\"xs:string\" use=\"optional\"/>\n            <xs:attribute name=\"location\" type=\"xs:string\" use=\"optional\"><xs:annotation><xs:documentation>\n                Required when under component-list (in Moqui Conf XML file or a components.xml file), optional\n                when component element is in a component.xml file within a component.\n            </xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"version\" type=\"xs:string\" use=\"optional\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"depends-on\">\n        <xs:complexType>\n            <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"version\" type=\"xs:string\" use=\"optional\"/>\n        </xs:complexType>\n    </xs:element>\n</xs:schema>\n"
  },
  {
    "path": "framework/xsd/rest-api-3.xsd",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n\n<!-- NOTE: files using this schema are found in the service directory in a component when named *.rest.xml -->\n<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" elementFormDefault=\"qualified\">\n    <xs:include schemaLocation=\"common-types-3.xsd\"/>\n\n    <xs:element name=\"resource\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"method\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"1\" ref=\"id\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"resource\"/>\n            </xs:sequence>\n            <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"displayName\" type=\"xs:string\"/>\n            <xs:attribute name=\"description\" type=\"xs:string\"/>\n            <xs:attribute name=\"version\" type=\"xs:string\"/>\n            <xs:attribute name=\"require-authentication\" type=\"authc-options\" default=\"true\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"id\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"method\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"1\" ref=\"id\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"resource\"/>\n            </xs:sequence>\n            <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"require-authentication\" type=\"authc-options\" default=\"true\"/>\n            <xs:attribute name=\"allow-extra-path\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>If set to true arbitrary path elements following this screen's path are allowed.\n                    Default is false and an exception will be thrown if there is an extra path element that does not match a\n                    resource below the id element, or it has a child id element.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"method\">\n        <xs:complexType>\n            <xs:choice minOccurs=\"1\" maxOccurs=\"1\">\n                <xs:element ref=\"service\"/>\n                <xs:element ref=\"entity\"/>\n            </xs:choice>\n            <xs:attribute name=\"type\" use=\"required\">\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"get\"/>\n                        <xs:enumeration value=\"patch\"/>\n                        <xs:enumeration value=\"put\"/>\n                        <xs:enumeration value=\"post\"/>\n                        <xs:enumeration value=\"delete\"/>\n                        <xs:enumeration value=\"options\"/>\n                        <xs:enumeration value=\"head\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"require-authentication\" type=\"authc-options\" default=\"true\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"service\">\n        <xs:complexType>\n            <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"entity\">\n        <xs:complexType>\n            <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"masterName\" type=\"xs:string\"/>\n            <xs:attribute name=\"operation\" use=\"required\">\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"one\"/>\n                        <xs:enumeration value=\"list\"/>\n                        <xs:enumeration value=\"count\"/>\n                        <xs:enumeration value=\"create\"/>\n                        <xs:enumeration value=\"update\"/>\n                        <xs:enumeration value=\"store\"/>\n                        <xs:enumeration value=\"delete\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n</xs:schema>\n"
  },
  {
    "path": "framework/xsd/service-definition-3.xsd",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" elementFormDefault=\"qualified\">\n    <xs:include schemaLocation=\"common-types-3.xsd\"/>\n    <xs:include schemaLocation=\"xml-actions-3.xsd\"/>\n\n    <!-- root element -->\n    <xs:element name=\"services\">\n        <xs:complexType>\n            <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n                <xs:element ref=\"service-include\"/>\n                <xs:element ref=\"service\"/>\n            </xs:choice>\n        </xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"service-include\">\n        <xs:complexType>\n            <xs:attribute name=\"verb\" type=\"name-field\" use=\"required\"/>\n            <xs:attribute name=\"noun\" type=\"name-upper\"/>\n            <xs:attribute name=\"location\" type=\"xs:string\" use=\"required\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"service\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" ref=\"description\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"implements\"/>\n                <xs:element minOccurs=\"0\" ref=\"in-parameters\"/>\n                <xs:element minOccurs=\"0\" ref=\"out-parameters\"/>\n                <xs:element minOccurs=\"0\" ref=\"actions\"/>\n            </xs:sequence>\n            <xs:attribute name=\"verb\" type=\"name-field\" use=\"required\">\n                <xs:annotation><xs:documentation>\n                    This can be any verb, and will often be one of: create, update, store, delete, or find. The full name of\n                    the service will be: \"${path}.${verb}#${noun}\". The verb is required and the noun is optional so if\n                    there is no noun the service name will be just the verb.\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"noun\" type=\"name-upper\" use=\"optional\">\n                <xs:annotation><xs:documentation>\n                    For entity-auto services this should be a valid entity name. In many other cases an entity name is\n                    the best way to describe what is being acted on, but this can really be anything.\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"displayName\" type=\"xs:string\" use=\"optional\"/>\n            <xs:attribute name=\"type\" default=\"inline\">\n                <xs:annotation><xs:documentation>\n                    The service type specifies how the service is implemented. Additional types can be added by\n                    implementing the org.moqui.impl.service.ServiceRunner interface and adding an\n                    service-facade.service-type element in the Moqui Conf XML file. The default value is inline\n                    meaning the service implementation is under the service.actions element.\n                </xs:documentation></xs:annotation>\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"inline\"/>\n                        <xs:enumeration value=\"entity-auto\"/>\n                        <xs:enumeration value=\"script\"/>\n                        <xs:enumeration value=\"java\"/>\n                        <xs:enumeration value=\"interface\"/>\n                        <xs:enumeration value=\"remote-json-rpc\"/>\n                        <xs:enumeration value=\"remote-rest\"/>\n                        <xs:enumeration value=\"camel\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"location\" type=\"xs:string\" use=\"optional\">\n                <xs:annotation><xs:documentation>The location of the service. For scripts this is the Resource Facade\n                    location of the file. For Java class methods this is the full class name. For remote services this\n                    is the URL of the remote service. Instead of an actual location can also refer to a pre-defined\n                    location from the service-facade.service-location element in the Moqui Conf XML file. This is\n                    especially useful for remote service URLs.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"method\" type=\"xs:string\" use=\"optional\">\n                <xs:annotation><xs:documentation>The method within the location, if applicable to the service type.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"authenticate\" default=\"true\">\n                <xs:annotation><xs:documentation>\n                    If not set to false (true by default) a user must be logged in to run this service. If the\n                    service is running in an ExecutionContext with a user logged in that will qualify. If not then\n                    either a \"authUserAccount\" parameter or the \"authUsername\" AND \"authPassword\" parameters must be\n                    specified and must contain valid values for a user of the system.\n\n                    If the \"authUserAccount\" parameter or the \"authUsername\" AND \"authPassword\" parameters are passed\n                    in they will be used for the service call even if a user is logged in to the ExecutionContext that\n                    the service is running in.\n\n                    If set to anonymous-all or anonymous-view then not only will authentication not be required, but this\n                    service will run as if authorized (using the _NA_ UserAccount) for all actions or for view-only.\n                </xs:documentation></xs:annotation>\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"true\"/>\n                        <xs:enumeration value=\"false\"/>\n                        <xs:enumeration value=\"anonymous-all\"/>\n                        <xs:enumeration value=\"anonymous-view\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"authz-action\">\n                <xs:annotation><xs:documentation>\n                    The authz action to use when checking authorization for this service (using ArtifactAuthz records).\n                    If not specified defaults to all unless the verb corresponds to an authz action.\n                </xs:documentation></xs:annotation>\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"view\"/>\n                        <xs:enumeration value=\"create\"/>\n                        <xs:enumeration value=\"update\"/>\n                        <xs:enumeration value=\"delete\"/>\n                        <xs:enumeration value=\"all\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"allow-remote\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>Defaults to false meaning this service cannot be called through remote\n                    interfaces such as JSON-RPC and XML-RPC. If set to true it can be. Before settings to true make sure\n                    the service is adequately secured (for authentication and authorization).</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"validate\" type=\"boolean\" default=\"true\">\n                <xs:annotation><xs:documentation>Defaults to true. Set to false to not validate input parameters, and\n                    not automatically remove unspecified parameters.</xs:documentation></xs:annotation>\n            </xs:attribute>\n\n            <xs:attribute name=\"no-remember-parameters\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>\n                    If true do not remember parameters in ArtifactExecutionFacade history and stack, important for service calls\n                    with large parameters that should be de-referenced for GC before ExecutionContext is destroyed.\n\n                    Note that this attribute can be used on interface service definitions (service.@type=interface) and if true\n                    will be cause all services that implement the interface to have this set to true to disable parameter remember.\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n\n            <xs:attribute name=\"transaction\" default=\"use-or-begin\" type=\"transaction-options\"/>\n            <xs:attribute name=\"transaction-timeout\" type=\"xs:int\">\n                <xs:annotation><xs:documentation>\n                    The timeout for the transaction, in seconds. Defaults to global transaction timeout default (usually 60s).\n                    This value is only used if this service begins a transaction (force-new, force-cache, or\n                    use-or-begin or cache and there is no other transaction already in place).\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"no-tx-cache\" type=\"boolean\" default=\"false\"><xs:annotation><xs:documentation>\n                If true and a TransactionCache is active flush and remove it before calling the service.</xs:documentation></xs:annotation></xs:attribute>\n\n            <!-- not supported by Atomikos/etc right now, consider for later:\n            <xs:attribute name=\"transaction-isolation\" type=\"isolation-level\" use=\"optional\">\n                <xs:annotation><xs:documentation>\n                    The transaction isolation level to use if a transaction is begun when calling this service.\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            -->\n            <!-- Tabled for now, not to be part of 1.0: <xs:attribute name=\"max-retry\" type=\"xs:int\" default=\"-1\"/> -->\n            <xs:attribute name=\"semaphore\" default=\"none\">\n                <xs:annotation><xs:documentation>\n                    Intended for use in long-running services (usually scheduled). This uses a record in the database\n                    to \"lock\" the service so that only one instance of it can run against a given database at any\n                    given time.\n                </xs:documentation></xs:annotation>\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"none\"/>\n                        <xs:enumeration value=\"fail\"/>\n                        <xs:enumeration value=\"wait\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"semaphore-name\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Defaults to the service name, use the same name on multiple services to share a semaphore</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"semaphore-timeout\" type=\"xs:int\" default=\"120\">\n                <xs:annotation><xs:documentation>When waiting how long before timing out, in seconds. Defaults to 120s.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"semaphore-sleep\" type=\"xs:int\" default=\"5\">\n                <xs:annotation><xs:documentation>When waiting how long to sleep between checking the semaphore, in seconds. Defaults to 5s.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"semaphore-ignore\" type=\"xs:int\" default=\"3600\">\n                <xs:annotation><xs:documentation>Ignore existing semaphores after this time, in seconds. Defaults to 3600s (1 hour).</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"semaphore-parameter\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>\n                    The name of a parameter to use for distinct semaphores for the same services.\n                    The parameter should be required in the service, though a single null semaphore is supported.\n                    This should not be used for IDs of transactional records, better to lock directly on those records (find with for update).\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"in-parameters\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n                    <xs:element ref=\"auto-parameters\"/>\n                    <xs:element ref=\"parameter\"/>\n                </xs:choice>\n            </xs:sequence>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"out-parameters\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n                    <xs:element ref=\"auto-parameters\"/>\n                    <xs:element ref=\"parameter\"/>\n                </xs:choice>\n            </xs:sequence>\n        </xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"implements\">\n        <xs:complexType>\n            <xs:attribute name=\"service\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"required\" type=\"boolean\" use=\"optional\">\n                <xs:annotation><xs:documentation>If set to true or false all parameters inherited have that value for\n                    the required attribute.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"auto-parameters\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element name=\"exclude\" minOccurs=\"0\" maxOccurs=\"unbounded\"><xs:complexType>\n                    <xs:attribute name=\"field-name\" type=\"name-field\" use=\"required\"/></xs:complexType></xs:element>\n            </xs:sequence>\n            <xs:attribute name=\"entity-name\" type=\"name-full\"/>\n            <xs:attribute name=\"include\" default=\"all\">\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"pk\"/>\n                        <xs:enumeration value=\"nonpk\"/>\n                        <xs:enumeration value=\"all\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n            <xs:attributeGroup ref=\"attlist.parameter-general\"/>\n        </xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"ParameterValidations\" abstract=\"true\"/>\n    <xs:element name=\"parameter\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" ref=\"description\"/>\n                <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n                    <xs:element ref=\"auto-parameters\"/>\n                    <xs:element ref=\"parameter\">\n                        <xs:annotation><xs:documentation>Nested parameters are for List, Map, Node, etc type parameters.</xs:documentation></xs:annotation>\n                    </xs:element>\n                </xs:choice>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"ParameterValidations\">\n                    <xs:annotation><xs:documentation>To override the default message for each just add the message\n                        inside the element.</xs:documentation></xs:annotation>\n                </xs:element>\n            </xs:sequence>\n            <xs:attribute name=\"name\" type=\"name-parameter\" use=\"required\">\n                <xs:annotation><xs:documentation>The name of the parameter, matches against the key of an entry in the\n                    parameters Map passed into or returned from the service.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"type\" type=\"xs:string\" default=\"String\">\n                <xs:annotation><xs:documentation>The type of the attribute, a full Java class name or one of the common\n                    Java API classes (including String, Timestamp, Time, Date, Integer, Long, Float, Double, BigDecimal,\n                    BigInteger, Boolean, Object, Blob, Clob, Collection, List, Map, Set, Node).</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"format\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>\n                    Used only when the parameter is passed in as a String but the type is\n                    something other than String to convert to that type.\n\n                    For date/time uses standard Java format strings described here:\n                    http://download.oracle.com/javase/6/docs/api/java/text/SimpleDateFormat.html\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"default\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>The field or expression specified will be used for the parameter if\n                    no value is passed in (only used if required=false). Like default-value but is an field name or\n                    expression instead of a text value. If both this and default-value are specified this will be\n                    evaluated first and only if empty will default-value be used.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"default-value\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>The text value specified will be used for the parameter if no value is\n                    passed in (only used if required=false). If both this and default are specified default will be\n                    evaluated first and this will only be used if default evaluates to an empty value.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"entity-name\" type=\"name-full\">\n                <xs:annotation><xs:documentation>Optional name of an entity with a field that this parameter is\n                    associated with.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"field-name\" type=\"name-field\">\n                <xs:annotation><xs:documentation>Optional field name within the named entity that this parameter is\n                    associated with. Most useful for form fields defined automatically from the service parameter.\n                    This is automatically populated when parameters are defined automatically with the auto-parameters\n                    element.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attributeGroup ref=\"attlist.parameter-general\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:attributeGroup name=\"attlist.parameter-general\">\n        <xs:attribute name=\"required\" default=\"false\">\n            <xs:simpleType>\n                <xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"true\"/>\n                    <xs:enumeration value=\"false\"/>\n                    <xs:enumeration value=\"disabled\">\n                        <xs:annotation><xs:documentation>Behave the same as if the parameter did not exist, useful when\n                            overriding a previously defined parameter.</xs:documentation></xs:annotation>\n                    </xs:enumeration>\n                </xs:restriction>\n            </xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"allow-html\" default=\"none\">\n            <xs:annotation><xs:documentation>\n                Applies only to String fields. Only checked for incoming parameters (meant for validating input from\n                users, other systems, etc). Defaults to \"none\" meaning no HTML is allowed (will result in an error\n                message).\n\n                If some HTML is desired then use \"safe\" which will follow the rules in the antisamy-esapi.xml file.\n                This should be safe for both internal and public users.\n\n                In rare cases when users are trusted or it is not a sensitive field the \"any\" option may be used to not\n                check the HTML content at all.\n            </xs:documentation></xs:annotation>\n            <xs:simpleType>\n                <xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"any\"/>\n                    <xs:enumeration value=\"safe\"/>\n                    <xs:enumeration value=\"none\"/>\n                </xs:restriction>\n            </xs:simpleType>\n        </xs:attribute>\n    </xs:attributeGroup>\n\n    <xs:element name=\"val-or\" substitutionGroup=\"ParameterValidations\">\n        <xs:complexType>\n            <xs:sequence><xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"ParameterValidations\"/></xs:sequence>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"val-and\" substitutionGroup=\"ParameterValidations\">\n        <xs:complexType>\n            <xs:sequence><xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"ParameterValidations\"/></xs:sequence>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"val-not\" substitutionGroup=\"ParameterValidations\">\n        <xs:complexType>\n            <xs:sequence><xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"ParameterValidations\"/></xs:sequence>\n        </xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"matches\" substitutionGroup=\"ParameterValidations\">\n        <xs:annotation><xs:documentation>Validate the current parameter against the regular expression specified in the\n            regexp attribute.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"regexp\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"message\" type=\"xs:string\" use=\"required\"/>\n        </xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"number-range\" substitutionGroup=\"ParameterValidations\">\n        <xs:annotation><xs:documentation>Validate the number within the min and max range.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"min\" type=\"xs:decimal\">\n                <xs:annotation><xs:documentation>To pass number must be greater than or equal to this value.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"min-include-equals\" type=\"boolean\" default=\"true\">\n                <xs:annotation><xs:documentation>Should the range include equal to the min number? Defaults to true.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"max\" type=\"xs:decimal\">\n                <xs:annotation><xs:documentation>To pass number must be less than this value.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"max-include-equals\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>Should the range include equal to the max number? Defaults to false (exclusive).</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"message\" type=\"xs:string\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"number-integer\" substitutionGroup=\"ParameterValidations\"/>\n    <xs:element name=\"number-decimal\" substitutionGroup=\"ParameterValidations\"/>\n\n    <xs:element name=\"text-length\" substitutionGroup=\"ParameterValidations\">\n        <xs:annotation><xs:documentation>Validate that the length of the text is within the min and max range.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"min\" type=\"xs:nonNegativeInteger\"/>\n            <xs:attribute name=\"max\" type=\"xs:nonNegativeInteger\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"text-email\" substitutionGroup=\"ParameterValidations\">\n        <xs:annotation><xs:documentation>Validate that the text is a valid email address.</xs:documentation></xs:annotation></xs:element>\n    <xs:element name=\"text-url\" substitutionGroup=\"ParameterValidations\">\n        <xs:annotation><xs:documentation>Validate that the text is a valid URL.</xs:documentation></xs:annotation></xs:element>\n    <xs:element name=\"text-letters\" substitutionGroup=\"ParameterValidations\">\n        <xs:annotation><xs:documentation>Validate that the text contains only letters.</xs:documentation></xs:annotation></xs:element>\n    <xs:element name=\"text-digits\" substitutionGroup=\"ParameterValidations\">\n        <xs:annotation><xs:documentation>Validate that the text contains only digits.</xs:documentation></xs:annotation></xs:element>\n\n    <xs:element name=\"time-range\" substitutionGroup=\"ParameterValidations\">\n        <xs:annotation><xs:documentation>Validate that the date/time is within the before and after range, using the specified format.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"after\" type=\"xs:string\"><xs:annotation><xs:documentation>Can be date/time string, or\n                \"now\" to compare to the current time.</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"before\" type=\"xs:string\"><xs:annotation><xs:documentation>Can be date/time string, or\n                \"now\" to compare to the current time.</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"format\" type=\"xs:string\"><xs:annotation><xs:documentation>If the value is a String\n                instead of Date/Time/Timestamp, specify the format for conversion here.</xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"credit-card\" substitutionGroup=\"ParameterValidations\">\n        <xs:annotation><xs:documentation>Validate that the text is a valid credit card number using Luhn MOD-10 and if\n            specified for the given card types.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"types\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>\n                    A comma-separated list of the types of credit card to allow. The available options include:\n                        visa,mastercard,amex,discover,dinersclub\n\n                    If empty defaults to allow any type of card (ie doesn't check the card type, just checks the number\n                    using the Luhn MOD-10 checksum).\n\n                    NOTE: removed with updated for Validator 1.4.0: enroute, jcb, solo, switch, visaelectron\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n</xs:schema>\n"
  },
  {
    "path": "framework/xsd/service-eca-3.xsd",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" elementFormDefault=\"qualified\">\n    <xs:include schemaLocation=\"xml-actions-3.xsd\"/>\n    \n    <xs:element name=\"secas\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"seca\"/>\n            </xs:sequence>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"seca\">\n        <xs:annotation><xs:documentation>\n            Triggered by service calls with various options about when during the service call to trigger the event.\n            If condition (optional) evaluates to true then the actions are run.\n\n            Service ECAs are meant for triggering business processes and for extending the functionality of existing\n            services that you don't want to, or can't, modify.\n            Service ECAs should NOT generally be used for maintenance of data derived from other entities, Entity ECA\n            rules are a much better tool for that.\n\n            When this runs the context will be whatever context the service was run in, plus the individual parameters\n            for convenience in reading the values. If when is before the service itself is run there will be a context\n            field called parameters with the input parameters Map in it that you can modify as needed in the ECA actions.\n            If when is after the service itself the parameters field will contain the input parameters and a results\n            field will contain the output parameters (results) that also may be modified.\n        </xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" ref=\"description\"/>\n                <xs:element minOccurs=\"0\" ref=\"condition\"/>\n                <xs:element ref=\"actions\"/>\n            </xs:sequence>\n            <xs:attribute name=\"id\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Optional but recommended. If another SECA rule has the same id it will override\n                    any previously found with that id to change behavior or disable by override with empty actions.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"service\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>\n                    The combined service name, like: \"${path}.${verb}${noun}\", or a pattern to match multiple service\n                    names, like: \"${path}\\.(.*)\". To explicitly separate the verb and noun put a hash (#) between them,\n                    like: \"${path}.${verb}#${noun}\".\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"when\" use=\"required\">\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"pre-validate\">\n                            <xs:annotation><xs:documentation>Runs before input parameters are validated; useful for\n                                adding or modifying parameters before validation and data type conversion</xs:documentation></xs:annotation>\n                        </xs:enumeration>\n                        <xs:enumeration value=\"pre-auth\">\n                            <xs:annotation><xs:documentation>Runs before authentication and authorization checks, but\n                                after the authUsername and authPassword parameters are used and specified\n                                user logged in; useful for any custom behavior related to authc or authz</xs:documentation></xs:annotation>\n                        </xs:enumeration>\n                        <xs:enumeration value=\"pre-service\">\n                            <xs:annotation><xs:documentation>Runs before the service itself is run; best place for\n                                general things to be done before running the service</xs:documentation></xs:annotation>\n                        </xs:enumeration>\n                        <xs:enumeration value=\"post-service\">\n                            <xs:annotation><xs:documentation>Runs just after the service is run; best place for general\n                                things to be done after the service is run and independent of the transaction</xs:documentation></xs:annotation>\n                        </xs:enumeration>\n                        <xs:enumeration value=\"post-commit\">\n                            <xs:annotation><xs:documentation>Runs just after the commit would be done, whether it is\n                                actually done or not (depending on service settings and existing TX in place, etc); to\n                                run something on the actual commit use the tx-commit option</xs:documentation></xs:annotation>\n                        </xs:enumeration>\n                        <xs:enumeration value=\"tx-commit\">\n                            <xs:annotation><xs:documentation>Runs when the transaction the service is running in is\n                                successfully committed. Gets its data after the run of the service so will\n                                have the output/results of the service run as well as the input parameters.</xs:documentation></xs:annotation>\n                        </xs:enumeration>\n                        <xs:enumeration value=\"tx-rollback\">\n                            <xs:annotation><xs:documentation>Runs when the transaction the service is running in is\n                                rolled back. Gets its data after the run of the service so will\n                                have the output/results of the service run as well as the input parameters.</xs:documentation></xs:annotation>\n                        </xs:enumeration>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"name-is-pattern\" type=\"boolean\" default=\"false\"/>\n            <xs:attribute name=\"run-on-error\" type=\"boolean\" default=\"false\"/>\n            <xs:attribute name=\"priority\" type=\"xs:positiveInteger\" default=\"5\"/>\n        </xs:complexType>\n    </xs:element>\n</xs:schema>\n"
  },
  {
    "path": "framework/xsd/xml-actions-3.xsd",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" elementFormDefault=\"qualified\">\n    <xs:include schemaLocation=\"common-types-3.xsd\"/>\n\n    <xs:element name=\"CallOperations\" abstract=\"true\"/>\n    <xs:element name=\"EnvOperations\" abstract=\"true\"/>\n    <xs:element name=\"EntityMiscOperations\" abstract=\"true\"/>\n    <xs:element name=\"EntityFindOperations\" abstract=\"true\"/>\n    <xs:element name=\"EntityValueOperations\" abstract=\"true\"/>\n    <xs:element name=\"EntityListOperations\" abstract=\"true\"/>\n    <xs:element name=\"ControlOperations\" abstract=\"true\"/>\n    <xs:element name=\"XmlOperations\" abstract=\"true\"/>\n    <xs:element name=\"IfCombineConditions\" abstract=\"true\"/>\n    <xs:element name=\"IfBasicOperations\" abstract=\"true\"/>\n    <xs:element name=\"IfOtherOperations\" abstract=\"true\"/>\n    <xs:element name=\"OtherOperations\" abstract=\"true\"/>\n    <xs:group name=\"AllOperations\">\n        <xs:choice>\n            <xs:element ref=\"CallOperations\"/>\n            <xs:element ref=\"EnvOperations\"/>\n            <xs:element ref=\"EntityMiscOperations\"/>\n            <xs:element ref=\"EntityFindOperations\"/>\n            <xs:element ref=\"EntityValueOperations\"/>\n            <xs:element ref=\"EntityListOperations\"/>\n            <xs:element ref=\"ControlOperations\"/>\n            <xs:element ref=\"IfBasicOperations\"/>\n            <xs:element ref=\"IfOtherOperations\"/>\n            <xs:element ref=\"OtherOperations\"/>\n            <!-- allow additional elements without validation to facilitate extension by adding only FTL macros -->\n            <xs:any minOccurs=\"0\" maxOccurs=\"unbounded\" processContents=\"skip\"/>\n        </xs:choice>\n    </xs:group>\n\n    <!-- ============================================== -->\n    <!-- Root Level Elements (for files, to be included elsewhere, etc) -->\n    <xs:element name=\"actions\">\n        <xs:annotation><xs:documentation>XML Actions can be embedded in various files, or put in a file of their own and\n            run like a script. Like a script the parameters passed into the XML Actions will already be defined in the\n            context.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:group minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"AllOperations\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"condition\">\n        <xs:annotation><xs:documentation>Contains a single condition of any sort and evaluates to a boolean value. To\n            combine the other if operations the and, or, and xor elements can be used.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:group ref=\"IfConditions\"/>\n        </xs:complexType>\n    </xs:element>\n\n    <!-- Call Operations -->\n    <xs:element name=\"service-call\" substitutionGroup=\"CallOperations\">\n        <xs:annotation><xs:documentation>Call a service.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"field-map\"/>\n            </xs:sequence>\n            <xs:attribute name=\"name\" type=\"name-full\" use=\"required\">\n                <xs:annotation><xs:documentation>\n                    The combined service name, like: \"${path}.${verb}${noun}\". To explicitly separate the verb and noun\n                    put a hash (#) between them, like: \"${path}.${verb}#${noun}\".\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"in-map\" type=\"xs:string\" default=\"false\">\n                <xs:annotation><xs:documentation>\n                    Creates an in parameters with variables matching the names of service in-parameters elements, doing\n                    type conversions as needed.\n\n                    If false (default) does nothing. If true constructs an in-map from the context.\n                    Otherwise is the name of a Map in the context uses it as the source Map for the service context.\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"out-map\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Optional name in the method environment to use for the output (results)\n                    map. If empty then the output map will be ignored.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"out-map-add-to-existing\" type=\"boolean\" default=\"true\">\n                <xs:annotation><xs:documentation>If true (default) out-map is added to an existing Map with the same\n                    name. If false replaces existing Map in the context.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"async\" default=\"false\">\n                <xs:annotation><xs:documentation>If true runs the service asynchronously. Use distribute to run async\n                    on any node in a cluster.</xs:documentation></xs:annotation>\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"true\"/>\n                        <xs:enumeration value=\"false\"/>\n                        <xs:enumeration value=\"distribute\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"include-user-login\" type=\"boolean\" default=\"true\">\n                <xs:annotation><xs:documentation>Include the current user in the service call. If you don't want to\n                    pass that in set to false. Defaults to true.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"transaction\" default=\"use-or-begin\" type=\"transaction-options\"/>\n            <xs:attribute name=\"transaction-timeout\" type=\"xs:int\" default=\"0\">\n                <xs:annotation><xs:documentation>\n                    Defines the timeout for the transaction, in seconds.\n                    This value is only used if this service begins a transaction (either require-new, or\n                    use-or-begin and there is no other transaction already in place).\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <!-- Not supported for 1.0\n            <xs:attribute name=\"transaction-isolation\" type=\"isolation-level\" use=\"optional\">\n                <xs:annotation><xs:documentation>\n                    The transaction isolation level to use if a transaction is begun when calling this service.\n                    For definitions see the javax.sql.Connection.\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            -->\n            <xs:attribute name=\"ignore-error\" type=\"boolean\" default=\"false\"/>\n            <xs:attribute name=\"multi\" default=\"false\">\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"true\"/>\n                        <xs:enumeration value=\"false\"/>\n                        <xs:enumeration value=\"parameter\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"web-send-json-response\" type=\"xs:string\" default=\"false\">\n                <xs:annotation><xs:documentation>Can be false to do nothing, true to send the service result or an\n                    expression to run on the service result to get the object to send.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"disable-authz\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>Disables checking authorization for this service call.\n                    Ignored for async service calls.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <!-- tabled, adds little value, not likely to be implemented:\n    <xs:element name=\"service-group\" substitutionGroup=\"CallOperations\">\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element maxOccurs=\"unbounded\" ref=\"service-invoke\"/>\n            </xs:sequence>\n            <xs:attribute name=\"send-mode\" default=\"all\">\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"none\"/>\n                        <xs:enumeration value=\"all\"/>\n                        <xs:enumeration value=\"first-available\"/>\n                        <xs:enumeration value=\"random\"/>\n                        <xs:enumeration value=\"round-robin\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"service-invoke\">\n        <xs:complexType>\n            <xs:attribute name=\"name\" type=\"name-full\" use=\"required\">\n                <xs:annotation><xs:documentation>\n                    The combined service name, like: \"${path}.${verb}${noun}\". To explicitly separate the verb and noun\n                    put a hash (#) between them, like: \"${path}.${verb}#${noun}\".\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"async\" default=\"false\">\n                <xs:annotation><xs:documentation>If true runs the service asynchronously. Use distribute to run async\n                    on any node in a cluster.</xs:documentation></xs:annotation>\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"true\"/>\n                        <xs:enumeration value=\"false\"/>\n                        <xs:enumeration value=\"distribute\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"result-to-context\" default=\"false\" type=\"boolean\"/>\n        </xs:complexType>\n    </xs:element>\n    -->\n\n    <xs:element name=\"script\" substitutionGroup=\"CallOperations\">\n        <xs:annotation><xs:documentation>\n            Runs the script at the specified location. You can also put a Groovy script inline under this element.\n            If a location is specified the file can be a Groovy script, a xml-actions script, or any script setup to\n            run through the Resource Facade. The script will run in the same context as the current operation.\n        </xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:simpleContent>\n                <xs:extension base=\"xs:string\">\n                    <xs:attribute name=\"location\" type=\"xs:string\" use=\"optional\"/>\n                </xs:extension>\n            </xs:simpleContent>\n        </xs:complexType>\n    </xs:element>\n\n    <!-- Environment specific operations -->\n    <xs:element name=\"set\" substitutionGroup=\"EnvOperations\">\n        <xs:annotation><xs:documentation>Set a field from another field (from) or an inline value, or a\n            default-value.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"field\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>Name of the field to set a value in.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"from\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Name of a field to copy from. Can also be an expression that evaluates\n                    to something to put into the field.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"value\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Inline value to copy in field. May include variables using the ${}\n                    syntax.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"default-value\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Default value to set in field if an empty String or null value is\n                    found.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"type\" type=\"object-type-new\">\n                <xs:annotation><xs:documentation>Type to convert to. NewList will create a new List, NewMap will create\n                    a new Map.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"set-if-empty\" default=\"true\" type=\"boolean\">\n                <xs:annotation><xs:documentation>If an empty String or null value is found, set that in the field.\n                    Defaults to true, set to false to do nothing to the field.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"order-map-list\" substitutionGroup=\"EnvOperations\">\n        <xs:annotation><xs:documentation>Sort a List of Maps by field names given in order-by sub-element.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:sequence><xs:element minOccurs=\"1\" maxOccurs=\"unbounded\" ref=\"order-by\"/></xs:sequence>\n            <xs:attribute type=\"xs:string\" name=\"list\" use=\"required\">\n                <xs:annotation><xs:documentation>Name of the list to be sorted.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"filter-map-list\" substitutionGroup=\"EnvOperations\">\n        <xs:annotation><xs:documentation>Filters the given List of Maps by the field-maps specified.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"field-map\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"date-filter\"/>\n            </xs:sequence>\n            <xs:attribute type=\"xs:string\" name=\"list\" use=\"required\">\n                <xs:annotation><xs:documentation>The name of the field that contains a List of Map objects.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute type=\"xs:string\" name=\"to-list\">\n                <xs:annotation><xs:documentation>Optional name of the output list. If empty filter the input list in-place.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"date-filter\">\n        <xs:annotation><xs:documentation>Adds a constraint to find to filter by the from and thru dates in each record,\n            comparing them to the valid-date value.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute type=\"xs:string\" name=\"valid-date\">\n                <xs:annotation><xs:documentation>The name of a field in the context to compare each value to.\n                    Defaults to now.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute type=\"xs:string\" name=\"from-field-name\" default=\"fromDate\">\n                <xs:annotation><xs:documentation>The name of the entity field to use as the from/beginning effective\n                    date. Defaults to fromDate.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute type=\"xs:string\" name=\"thru-field-name\" default=\"thruDate\">\n                <xs:annotation><xs:documentation>The name of the entity field to use as the thru/ending effective date.\n                    Defaults to thruDate.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"ignore-if-empty\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>Leave out the constraint if valid-date is empty or null. Defaults to false.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"ignore\" type=\"xs:string\" default=\"false\">\n                <xs:annotation><xs:documentation>Ignore the econdition (leave out of the find) if set to true or expression evaluates to true.\n                    Defaults to false.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n\n    <!-- Entity Misc Operations -->\n    <xs:element name=\"entity-data\" substitutionGroup=\"EntityMiscOperations\">\n        <xs:annotation><xs:documentation>Load or assert each record in an entity-facade-xml file.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"location\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>Location of an XML file to load in database or verify in assert mode.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"timeout\" type=\"xs:integer\" default=\"-1\">\n                <xs:annotation><xs:documentation>Start a new transaction and load the data with a longer timeout.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"mode\" default=\"load\">\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"load\">\n                            <xs:annotation><xs:documentation>Load the file into the datasource.</xs:documentation></xs:annotation>\n                        </xs:enumeration>\n                        <xs:enumeration value=\"assert\">\n                            <xs:annotation>\n                                <xs:documentation>\n                                    Compare each record in the file to the corresponding record in the datasource\n                                    and add an error for each difference, or if no record is found in the datasource.\n                                </xs:documentation>\n                            </xs:annotation>\n                        </xs:enumeration>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"entity-find-one\" substitutionGroup=\"EntityFindOperations\">\n        <xs:annotation><xs:documentation>Does a find by primary key. If no value is found does nothing to the\n            value-field.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"field-map\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"select-field\"/>\n            </xs:sequence>\n            <xs:attribute name=\"entity-name\" type=\"name-full\" use=\"required\">\n                <xs:annotation><xs:documentation>Name of the entity to find an instance of.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"value-field\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>Field to put result resulting EntityValue object in.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"auto-field-map\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>\n                    If true looks for all primary key fields by name in the context. If empty defaults to true (context)\n                    unless field-map sub-element is found this will do nothing.\n                    If something other than true or false looks for fields in the given Map (an expression run in the current context).\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"cache\" type=\"boolean\">\n                <xs:annotation><xs:documentation>Look in the cache before finding in the datasource. The default for\n                    this comes from the cache attribute on the entity definition.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"for-update\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>\n                    Lock the selected record so only this transaction can change it until it is ended (committed or\n                    rolled back). This does not have to be set to true in order to update the record, it just keeps\n                    other transactions from updating it. In SQL this does a select for update.\n\n                    If this is true the cache will not be used, regardless of the cache attribute here and on the\n                    entity definition.\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"use-clone\" default=\"false\" type=\"boolean\">\n                <xs:annotation><xs:documentation>Use a datasource clone, if one is configured</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"entity-find\" substitutionGroup=\"EntityFindOperations\">\n        <xs:annotation><xs:documentation>\n            Like entity-and returns a list of entity values if any are found, otherwise returns an empty list.\n            Use any combination of constraint, constraints and constraint-object.\n        </xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"1\" ref=\"search-form-inputs\"/>\n\n                <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n                    <xs:element ref=\"date-filter\"/>\n                    <xs:element ref=\"econdition\"/>\n                    <xs:element ref=\"econditions\"/>\n                    <xs:element ref=\"econdition-object\"/>\n                </xs:choice>\n\n                <xs:element minOccurs=\"0\" ref=\"having-econditions\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"select-field\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"order-by\"/>\n                <xs:choice minOccurs=\"0\">\n                    <xs:element ref=\"limit-range\"/>\n                    <xs:element ref=\"limit-view\"/>\n                    <xs:element ref=\"use-iterator\"/>\n                </xs:choice>\n            </xs:sequence>\n            <xs:attribute name=\"entity-name\" type=\"name-full\" use=\"required\">\n                <xs:annotation><xs:documentation>Name of entity to find instances of.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"list\" type=\"xs:string\" use=\"optional\">\n                <xs:annotation><xs:documentation>Name of the list to put results in. Required unless the entity-find\n                is used under the entity-options element in a XML Screen Form.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"cache\" type=\"boolean\">\n                <xs:annotation><xs:documentation>Look in the cache before finding in the datasource. The default for\n                    this comes from the cache attribute on the entity definition.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"for-update\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>\n                    Lock the selected record so only this transaction can change it until it is ended (committed or\n                    rolled back). This does not have to be set to true in order to update the record, it just keeps\n                    other transactions from updating it. In SQL this does a select for update.\n\n                    If this is true the cache will not be used, regardless of the cache attribute here and on the\n                    entity definition.\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"distinct\" default=\"false\" type=\"boolean\">\n                <xs:annotation><xs:documentation>Get only distinct results, based on the combination of all fields\n                    selected. Defaults to false.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"use-clone\" default=\"false\" type=\"boolean\">\n                <xs:annotation><xs:documentation>Use a datasource clone, if one is configured</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"offset\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Get back results starting at this offset.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"limit\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Get back only this many results.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"search-form-inputs\">\n        <xs:annotation>\n            <xs:documentation>\n                Adds econditions for the fields found in the input-fields-map.\n\n                The fields and special fields with suffixes supported are the same as the *-find fields in the XML\n                Forms. This means that you can use this to process the data from the various inputs generated by XML\n                Forms. The suffixes include things like *_op for operators and *_ic for ignore case.  \n\n                For historical reference, this does basically what the Apache OFBiz prepareFind service does.\n            </xs:documentation>\n        </xs:annotation>\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element name=\"default-parameters\" minOccurs=\"0\">\n                    <xs:annotation><xs:documentation>Attributes of this element are default parameters if there are no constraints in the search parameters.</xs:documentation></xs:annotation>\n                    <xs:complexType><xs:anyAttribute processContents=\"skip\"/></xs:complexType>\n                </xs:element>\n            </xs:sequence>\n            <xs:attribute name=\"input-fields-map\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>The map to get form fields from. If empty will look at the\n                    ec.web.parameters map if the web facade is available, otherwise the current context (ec.context).</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"default-order-by\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>If no orderByField parameter, order by this.</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"skip-fields\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Comma separate list of entity field names to skip when processing input fields</xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"paginate\" type=\"xs:string\" default=\"true\">\n                <xs:annotation><xs:documentation>Indicate if this find should set pagination options even if there are\n                    no pageSize and pageIndex parameters. Also adds a context field called \"${entity-find.@list}Count\"\n                    with a count of the total possible results (ie without the offset/limit). Defaults to true.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"require-parameters\" default=\"false\" type=\"boolean\">\n                <xs:annotation><xs:documentation>If true only do find if there is at least one parameter</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"econditions\">\n        <xs:annotation>\n            <xs:documentation>\n                The econditions element contains a list of econditions that are combined with either and or or.\n                The default is and.\n\n                You can have econditions under econditions, for building fairly complex econdition trees,\n                and you can also drop in econdition-objects at any point.\n            </xs:documentation>\n        </xs:annotation>\n        <xs:complexType>\n            <xs:choice maxOccurs=\"unbounded\">\n                <xs:element ref=\"date-filter\"/>\n                <xs:element ref=\"econdition\"/>\n                <xs:element ref=\"econditions\"/>\n                <xs:element ref=\"econdition-object\"/>\n            </xs:choice>\n            <xs:attribute name=\"combine\" default=\"and\">\n                <xs:annotation><xs:documentation>Operator to use to combine econditions in the list.</xs:documentation></xs:annotation>\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"and\"/>\n                        <xs:enumeration value=\"or\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"having-econditions\">\n        <xs:annotation><xs:documentation>Similar to econditions but runs after the grouping and functions are done.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:choice maxOccurs=\"unbounded\">\n                <xs:element ref=\"date-filter\"/>\n                <xs:element ref=\"econdition\"/>\n                <xs:element ref=\"econditions\"/>\n                <xs:element ref=\"econdition-object\"/>\n            </xs:choice>\n            <xs:attribute name=\"combine\" default=\"and\">\n                <xs:annotation><xs:documentation>Operator to use to combine econditions in the list.</xs:documentation></xs:annotation>\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"and\"/>\n                        <xs:enumeration value=\"or\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"econdition\">\n        <xs:annotation><xs:documentation>Adds a econdition to the query to compare the field-name field to a context\n            field, a String value, or another field on the entity.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"field-name\" type=\"name-field\" use=\"required\">\n                <xs:annotation><xs:documentation>The field on the entity to constrain on. If from, value and\n                    to-field-name are all empty this is also used as the name of the context field to compare to.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"operator\" default=\"equals\" type=\"operator-entity\">\n                <xs:annotation>\n                    <xs:documentation>\n                        Operator to apply to field-name on one side, and from, value, or to-field-name on the other side.\n\n                        For the between operator the from should be a Collection with exactly 2 values in it.\n\n                        For the in operator the from should be a Collection with 1 to many values in it.\n\n                        For the like operator use the standard SQL wildcards, including \"%\" for any number of\n                        characters (like *) and \"_\" for a single character (like ?), and escape them with a \"!\" in\n                        from of each character to escape).\n\n                        Defaults to equals.\n                    </xs:documentation>\n                </xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"from\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Field expression in the context to compare the entity field to.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"value\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Comparison value, use ${} syntax to expand variables.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"to-field-name\" type=\"name-field\">\n                <xs:annotation><xs:documentation>Compare the field-name field to another field on the entity.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"ignore-case\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>Ignore case when doing the compare. Defaults to false.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"ignore-if-empty\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>Leave out the constraint if the comparison value is empty or null.\n                    Defaults to false.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"or-null\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>If true make a condition specified value or null as valid matches.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"ignore\" type=\"xs:string\" default=\"false\">\n                <xs:annotation><xs:documentation>Ignore the econdition (leave out of the find) if set to true or expression evaluates to true.\n                    Defaults to false.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"econdition-object\">\n        <xs:annotation><xs:documentation>Add a condition that has been defined elsewhere and is available in the\n            current context. Can also be a Map and it will add conditions where the entries are ANDed together and each\n            key/value are compared with equal.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"field\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>Field in the current context that implements the EntityCondition\n                    interface or the Map interface.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"select-field\">\n        <xs:annotation><xs:documentation>Used to specify fields to select. If there are none of these elements all\n            fields will be selected.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"field-name\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>Name of a field to select. May be more than one field, comma-separated.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"order-by\">\n        <xs:annotation><xs:documentation>Defines a field to order the results by.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"field-name\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>Name of field to order list by. May be more than one field, comma-separated.\n                    Each field name may be prefixed with +/- for ascending/descending, optionally follow by a carat (^)\n                    for case insensitive order.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"limit-range\">\n        <xs:annotation><xs:documentation>Limit the results by a start index and a size.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"start\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>The start/beginning index of results to include.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"size\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>The number of results to include beyond the start.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"limit-view\">\n        <xs:annotation><xs:documentation>Limit the results using parameters like those used to paginate results in a\n            user interface.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"view-index\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>Index of records to view, depends on view-size.</xs:documentation>\n                </xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"view-size\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>Number of records to view, like the number of results per-screen.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"use-iterator\">\n        <xs:annotation><xs:documentation>\n            Specifies whether or not to use the EntityListIterator when doing the query. This is much more efficient\n            for large data sets because the results are read incrementally instead of all at once. Note that when using\n            this the use-cache setting will be ignored. Also note that an EntityListIterator must be closed when you\n            are finished, but this is done automatically by the iterate operation. Must be true or false, defaults to\n            false.\n        </xs:documentation></xs:annotation>\n        <xs:complexType/>\n    </xs:element>\n    <xs:element name=\"field-map\">\n        <xs:annotation><xs:documentation>A name/value pair. If from and value are empty will look in the context\n            for a field matching the field-name.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"field-name\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>Name of the entity field.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"from\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Name of the field (variable) in the context.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"value\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Literal string or use ${} syntax to expand variables.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"entity-find-count\" substitutionGroup=\"EntityFindOperations\">\n        <xs:annotation><xs:documentation>\n            Find the count of the number of records that match the given conditions.\n            Conditions and other application options follow the same structure as in the entity-find operation.\n        </xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" maxOccurs=\"1\" ref=\"search-form-inputs\"/>\n                <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n                    <xs:element ref=\"date-filter\"/>\n                    <xs:element ref=\"econdition\"/>\n                    <xs:element ref=\"econditions\"/>\n                    <xs:element ref=\"econdition-object\"/>\n                </xs:choice>\n                <xs:element minOccurs=\"0\" ref=\"having-econditions\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"select-field\"/>\n            </xs:sequence>\n            <xs:attribute name=\"entity-name\" type=\"name-full\" use=\"required\">\n                <xs:annotation><xs:documentation>Name of entity to search in.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"count-field\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>Name of the field (variable) to put result of the count in.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"cache\" type=\"boolean\">\n                <xs:annotation><xs:documentation>Look in the cache before finding in the datasource. The default for\n                    this comes from the cache attribute on the entity definition.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"distinct\" default=\"false\" type=\"boolean\">\n                <xs:annotation><xs:documentation>Get only distinct results, based on the combination of all fields\n                    selected. Defaults to false.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"entity-find-related-one\" substitutionGroup=\"EntityFindOperations\">\n        <xs:annotation><xs:documentation>Find a single value related to an existing value.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"value-field\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>Name of the existing entity value in the context.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"relationship-name\" type=\"name-full\" use=\"required\">\n                <xs:annotation><xs:documentation>Name of the relationship to use, consists of the relationship title\n                    and the related entity name, like: ${title}${related-entity-name}.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"cache\" type=\"boolean\">\n                <xs:annotation><xs:documentation>Look in the cache before finding in the datasource. The default for\n                    this comes from the cache attribute on the entity definition.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"for-update\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>\n                    Lock the selected record so only this transaction can change it until it is ended (committed or\n                    rolled back). This does not have to be set to true in order to update the record, it just keeps\n                    other transactions from updating it. In SQL this does a select for update. If this is true the\n                    cache will not be used, regardless of the cache attribute here and on the entity definition.\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"to-value-field\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>Name of field to put the entity value result in.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"entity-find-related\" substitutionGroup=\"EntityFindOperations\">\n        <xs:annotation><xs:documentation>Find a list of values related to a specific value.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"value-field\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>Name of the existing entity value in the context.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"relationship-name\" type=\"name-full\" use=\"required\">\n                <xs:annotation><xs:documentation>Name of the relationship to use, consists of the relationship title\n                    and the related entity name,like: ${title}${related-entity-name}.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"map\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>A map containing extra constraints for the find.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"order-by-list\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>A list of field names to order the results by.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"cache\" type=\"boolean\">\n                <xs:annotation><xs:documentation>Look in the cache before finding in the datasource. The default for\n                    this comes from the cache attribute on the entity definition.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"for-update\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>\n                    Lock the selected record so only this transaction can change it until it is ended (committed or\n                    rolled back). This does not have to be set to true in order to update the record, it just keeps\n                    other transactions from updating it. In SQL this does a select for update. If this is true the\n                    cache will not be used, regardless of the cache attribute here and on the entity definition.\n                </xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"list\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>Name of the list to put the entity list result in.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n\n    <xs:element name=\"entity-make-value\" substitutionGroup=\"EntityValueOperations\">\n        <xs:annotation><xs:documentation>\n            The make-value tag uses the delegator to construct an entity value. The resulting value will not exist in\n            the database, but will simply be assembled using the entity-name and fields map. The resulting EntityValue\n            object will be placed in the method environment using the specified value-field.\n        </xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"entity-name\" type=\"name-full\" use=\"required\">\n                <xs:annotation><xs:documentation>The name of the entity to construct an instance of.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"map\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>The name of a map in the method environment that will be used for the\n                    entity fields.If the map is an EntityValue object then this will clone the value.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"value-field\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>The name of the field where the EntityValue object will be put.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"entity-create\" substitutionGroup=\"EntityValueOperations\">\n        <xs:annotation><xs:documentation>The create-value tag persists the specified EntityValue object by creating a\n            new instance of the entity in the datasource. An error will result if an instance of the entity exists in\n            the datasource with the same primary key.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"value-field\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>The name of the field that contains the EntityValue object.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"or-update\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>Update value if already exists instead of returning an error,\n                    defaults to false.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"entity-update\" substitutionGroup=\"EntityValueOperations\">\n        <xs:annotation><xs:documentation>Updates the specified EntityValue object in the datasource. An error will\n            result if the record is not found in the datasource.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"value-field\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>The name of the field that contains the EntityValue object.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"entity-delete\" substitutionGroup=\"EntityValueOperations\">\n        <xs:annotation><xs:documentation>Deletes the specified EntityValue object from the datasource. An error will\n            result if the record is not found in the datasource.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"value-field\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>The name of the field that contains the EntityValue object.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"entity-delete-related\" substitutionGroup=\"EntityValueOperations\">\n        <xs:annotation><xs:documentation>\n            Given a value-field and a relationship-name, follows the relationship and deletes all related records.\n\n            For a type one relationship it will remove a single record if it exists, and for a type many\n            relationship it will remove all the records that are related to it.\n\n            Instead of using cascading deletes you should have your code delete all related data with foreign keys\n            pointing the the value-field record, and then delete the value-field.\n        </xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"value-field\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>Field that contains an EntityValue object to delete related records\n                    from.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"relationship-name\" type=\"name-full\" use=\"required\">\n                <xs:annotation><xs:documentation>Name of a relationship to use to delete related records.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"entity-delete-by-condition\" substitutionGroup=\"EntityValueOperations\">\n        <xs:annotation><xs:documentation>Deletes entity values that match the econditions.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:sequence>\n                <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n                    <xs:element ref=\"date-filter\"/>\n                    <xs:element ref=\"econdition\"/>\n                    <xs:element ref=\"econditions\"/>\n                    <xs:element ref=\"econdition-object\"/>\n                </xs:choice>\n            </xs:sequence>\n            <xs:attribute name=\"entity-name\" type=\"name-full\" use=\"required\">\n                <xs:annotation><xs:documentation>The name of the entity to remove instances of.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"entity-set\" substitutionGroup=\"EntityValueOperations\">\n        <xs:annotation><xs:documentation>Looks for each field (pk, nonpk, or all) in the named map and if it exists\n            there it will copy it into the named value object.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"value-field\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>Field that contains an EntityValue object.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"map\" type=\"xs:string\" default=\"context\">\n                <xs:annotation><xs:documentation>The name of a map in the method environment that will be used for the\n                    entity fields. Defaults to the context root, which is where incoming parameters go by default.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"prefix\" type=\"xs:string\" use=\"optional\">\n                <xs:annotation><xs:documentation>If not null or empty will be pre-pended to each field name\n                    (upper-casing the first letter of the field name first), and that will be used as the fields Map\n                    lookup name instead of the field-name.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"include\" default=\"all\">\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"pk\"/>\n                        <xs:enumeration value=\"nonpk\"/>\n                        <xs:enumeration value=\"all\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"set-if-empty\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>Specifies whether or not to set fields that are null or empty.\n                    Defaults to false.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"entity-sequenced-id-primary\" substitutionGroup=\"EntityMiscOperations\">\n        <xs:annotation><xs:documentation>Get the next guaranteed unique seq id for this entity, and set it in the\n            primary key field. This will set it in the first primary key field in the entity definition, but it really\n            should be used for entities with only one primary key field.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"value-field\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>The EntityValue object to work on.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"entity-sequenced-id-secondary\" substitutionGroup=\"EntityMiscOperations\">\n        <xs:annotation>\n            <xs:documentation>\n                Given an entity value object with all primary key fields except one already set will generate an ID for\n                the remaining primary key field by looking at all records with the partial primary key and then adding\n                increment-by to the highest value.\n            </xs:documentation>\n        </xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"value-field\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>The EntityValue object to work on.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n\n    <!-- =================== CONTROL OPERATIONS =================== -->\n    <xs:element name=\"break\" substitutionGroup=\"ControlOperations\">\n        <xs:annotation><xs:documentation>Break from an iterate or while loop (will result in an error elsewhere).</xs:documentation></xs:annotation>\n    </xs:element>\n    <xs:element name=\"continue\" substitutionGroup=\"ControlOperations\">\n        <xs:annotation><xs:documentation>Continue in an iterate or while loop (will result in an error elsewhere).</xs:documentation></xs:annotation>\n    </xs:element>\n    <xs:element name=\"iterate\" substitutionGroup=\"ControlOperations\">\n        <xs:annotation><xs:documentation>\n            The operations contained by the iterate tag will be executed for each of the entries in the list,\n            and will make the current entry available in the method environment by the entry specified.\n            This tag can contain any of the xml-action operations, including the conditional/if operations.\n\n            Any xml-action operation can be nested under the iterate tag.\n        </xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:group minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"AllOperations\"/>\n            <xs:attribute name=\"list\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>The name of the field that contains the list to iterate over.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"entry\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>The name of the field that will contain each entry as we iterate\n                    through the list.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"key\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>If list points to a Map or Collection of Map.Entry the key will be put\n                    where this refers to, the value where the entry attribute refers to.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"message\" substitutionGroup=\"ControlOperations\">\n        <xs:annotation><xs:documentation>Adds the message (sub-element text) to the ExecutionContext MessageFacade,\n            either the errors list if error=true or the messages list otherwise.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:simpleContent>\n                <xs:extension base=\"xs:string\">\n                    <xs:attribute name=\"type\" type=\"message-type\" default=\"info\"/>\n                    <xs:attribute name=\"public\" type=\"boolean\" default=\"false\">\n                        <xs:annotation><xs:documentation>If true make it a public message, not compatible with error (overrides this)</xs:documentation></xs:annotation>\n                    </xs:attribute>\n                    <xs:attribute name=\"error\" type=\"boolean\" default=\"false\">\n                        <xs:annotation><xs:documentation>If true will be considered caused by an error, meaning\n                            transaction will be rolled back, etc.</xs:documentation></xs:annotation>\n                    </xs:attribute>\n                </xs:extension>\n            </xs:simpleContent>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"check-errors\" substitutionGroup=\"ControlOperations\">\n        <xs:annotation><xs:documentation>Checks the Message Facade error message list (ec.message.errors) and if it is\n            not empty returns with an error, otherwise does nothing.</xs:documentation></xs:annotation>\n    </xs:element>\n    <xs:element name=\"return\" substitutionGroup=\"ControlOperations\">\n        <xs:annotation><xs:documentation>Returns immediately.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"message\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Adds a message to the errors list (ec.message.errors) if error=true,\n                    or the messages list (ec.message.messages) otherwise.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"type\" type=\"message-type\" default=\"info\"/>\n            <xs:attribute name=\"public\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>If true make it a public message, not compatible with error (overrides this)</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"error\" type=\"boolean\" default=\"false\">\n                <xs:annotation><xs:documentation>If true will be considered caused by an error, meaning transaction\n                    will be rolled back, etc.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"assert\" substitutionGroup=\"IfOtherOperations\">\n        <xs:annotation><xs:documentation>\n            Each condition under the assert element will be checked and if it fails an error will be added to the given\n            error list. Note that while the definitions for the if operation is used, the tags should be empty\n            because of the differing semantics.\n\n            This is mainly used for testing, and for writing xml-actions that are meant to be used as part of a test\n            suite. This is mostly useful for testing because the messages are targeted at a programmer, and not really\n            at an end user.\n        </xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:group maxOccurs=\"unbounded\" ref=\"IfConditions\"/>\n            <xs:attribute name=\"title\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>A title that can be used in reports for testing.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n\n\n    <xs:group name=\"IfConditions\">\n        <xs:choice>\n            <xs:element ref=\"IfCombineConditions\"/>\n            <xs:element ref=\"IfBasicOperations\"/>\n        </xs:choice>\n    </xs:group>\n    <xs:element name=\"if\" substitutionGroup=\"IfOtherOperations\">\n        <xs:annotation><xs:documentation>\n            The if operation offers a flexible way of specifying combinations of conditions, alternate conditions,\n            and operations to run on true evaluation of the conditions or to run otherwise.\n\n            The other if operations are meant for a specific, simple condition when used outside of the condition\n            sub-element of this operation. The attributes of the other if operations are the same when used inside this\n            operation.\n\n            Note that while the definitions for the if-* operations are used, the tags should be empty because of the\n            differing semantics.\n        </xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" ref=\"condition\"/>\n                <xs:element minOccurs=\"0\" ref=\"then\"/>\n                <xs:group minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"AllOperations\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"else-if\"/>\n                <xs:element minOccurs=\"0\" ref=\"else\"/>\n            </xs:sequence>\n            <xs:attribute name=\"condition\" type=\"xs:string\" use=\"optional\">\n                <xs:annotation><xs:documentation>A boolean expression in Groovy. Will be AND combined with other\n                    conditions if present.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"while\" substitutionGroup=\"IfOtherOperations\">\n        <xs:annotation><xs:documentation>While loop operation, uses the same condition element as the if operation.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" ref=\"condition\"/>\n                <xs:element minOccurs=\"0\" ref=\"then\"/>\n                <xs:group minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"AllOperations\"/>\n            </xs:sequence>\n            <xs:attribute name=\"condition\" type=\"xs:string\" use=\"optional\">\n                <xs:annotation><xs:documentation>A boolean expression in Groovy. Will be AND combined with other\n                    conditions if present.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"then\">\n        <xs:annotation><xs:documentation>Operations to run if the corresponding condition evaluate to true.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:group minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"AllOperations\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"else-if\">\n        <xs:annotation><xs:documentation>\n            The else-if element can be used to specify alternate conditional execution blocks.\n            Each else-if element must contain two sub-elements: condition and then.\n\n            If the condition of the parent is evaluated to false, each condition of the else-if sub-elements will be\n            evaluated, and the operations under the element corresponding to the first condition that evaluates to true\n            will be run.\n        </xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:sequence>\n                <xs:element minOccurs=\"0\" ref=\"condition\"/>\n                <xs:element minOccurs=\"0\" ref=\"then\"/>\n                <xs:group minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"AllOperations\"/>\n            </xs:sequence>\n            <xs:attribute name=\"condition\" type=\"xs:string\" use=\"optional\">\n                <xs:annotation><xs:documentation>A boolean expression in Groovy. Will be AND combined with other\n                    conditions if present.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"else\">\n        <xs:annotation><xs:documentation>\n            The else element can be used to contain operations that will run if the condition evaluates to false,\n            and when under an if element when no else-if sub-conditions evaluate to true.\n            It can contain any xml-actions operation.\n        </xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:group minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"AllOperations\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"or\" substitutionGroup=\"IfCombineConditions\">\n        <xs:annotation><xs:documentation>\n            To be true just one of the conditions underneath needs to be true.\n            Will return true as soon as a condition is true, not evaluating remaining conditions.\n        </xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:group maxOccurs=\"unbounded\" ref=\"IfConditions\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"and\" substitutionGroup=\"IfCombineConditions\">\n        <xs:annotation><xs:documentation>\n            To be true all of the conditions underneath need to be true.\n            Will return false as soon as a condition evaluates to false, not evaluating remaining conditions.\n            If no conditions evaluate to false will return true.\n        </xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:group maxOccurs=\"unbounded\" ref=\"IfConditions\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"not\" substitutionGroup=\"IfCombineConditions\">\n        <xs:annotation><xs:documentation>\n            Can only have one condition underneath and simply reverse the boolean value of this condition.\n        </xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:group ref=\"IfConditions\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"compare\" substitutionGroup=\"IfBasicOperations\">\n        <xs:annotation><xs:documentation>\n            The operations contained by the if-compare tag will only be executed if the comparison returns true.\n            This tag can contain any of the xml-action operations, including the conditional/if operations.\n        </xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:sequence>\n                <xs:group minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"AllOperations\"/>\n                <xs:element minOccurs=\"0\" ref=\"else\"/>\n            </xs:sequence>\n            <xs:attribute name=\"field\" type=\"xs:string\" use=\"required\">\n                <xs:annotation><xs:documentation>The name of the field in the context (environment) that will be\n                    compared.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"operator\" type=\"operator\" default=\"equals\"/>\n            <xs:attribute name=\"value\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>The value that the field will compared to. Will evaluate to a String\n                    but can be converted to other types.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"to-field\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>The name of the context field that the main field will be compared to.\n                    If left empty will default to the field attribute's value.</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"format\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>Format string based on the type of the object (date, number, etc).</xs:documentation></xs:annotation>\n            </xs:attribute>\n            <xs:attribute name=\"type\" type=\"object-type\" default=\"Object\"/>\n        </xs:complexType>\n    </xs:element>\n    <xs:element name=\"expression\" substitutionGroup=\"IfBasicOperations\" type=\"xs:string\">\n        <xs:annotation><xs:documentation>A boolean expression should be inline under this element (to avoid problems\n            with character encoding, etc). When not under a condition element any xml-action operation can be nested\n            under this tag, and will only be run if it evaluates to true.</xs:documentation></xs:annotation>\n    </xs:element>\n\n    <!-- \"Other\" Operations -->\n    <xs:element name=\"log\" substitutionGroup=\"OtherOperations\">\n        <xs:annotation><xs:documentation>Logs a message using Log4J.</xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"level\" default=\"info\">\n                <xs:annotation><xs:documentation>The logging/debug level to use.</xs:documentation></xs:annotation>\n                <xs:simpleType>\n                    <xs:restriction base=\"xs:token\">\n                        <xs:enumeration value=\"trace\"/>\n                        <xs:enumeration value=\"debug\"/>\n                        <xs:enumeration value=\"info\"/>\n                        <xs:enumeration value=\"warn\"/>\n                        <xs:enumeration value=\"error\"/>\n                        <xs:enumeration value=\"off\"/>\n                    </xs:restriction>\n                </xs:simpleType>\n            </xs:attribute>\n            <xs:attribute name=\"message\" type=\"xs:string\">\n                <xs:annotation><xs:documentation>The message to log. Can insert variables using the ${} syntax.</xs:documentation></xs:annotation>\n            </xs:attribute>\n        </xs:complexType>\n    </xs:element>\n</xs:schema>\n"
  },
  {
    "path": "framework/xsd/xml-form-3.xsd",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" elementFormDefault=\"qualified\">\n    <xs:include schemaLocation=\"common-types-3.xsd\"/>\n    <xs:include schemaLocation=\"xml-actions-3.xsd\"/>\n\n    <xs:element name=\"AllWidgets\" abstract=\"true\"/>\n    <xs:element name=\"StandaloneFields\" abstract=\"true\"/>\n\n    <xs:simpleType name=\"auto-field-type\"><xs:restriction base=\"xs:token\">\n        <xs:enumeration value=\"edit\"/>\n        <xs:enumeration value=\"find\"/>\n        <xs:enumeration value=\"display\"/>\n        <xs:enumeration value=\"find-display\"/>\n        <xs:enumeration value=\"hidden\"/>\n    </xs:restriction></xs:simpleType>\n\n    <xs:element name=\"parameter\"><xs:complexType>\n        <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"from\" type=\"xs:string\"/>\n        <xs:attribute name=\"value\" type=\"xs:string\"/>\n        <xs:attribute name=\"required\" default=\"false\" type=\"boolean\"><xs:annotation><xs:documentation>The parameter\n            element is used in many places, only some (such as screen.parameter) use this attribute.</xs:documentation></xs:annotation></xs:attribute>\n    </xs:complexType></xs:element>\n\n    <!-- ================== Widget Templates ==================== -->\n\n    <xs:element name=\"widget-templates\"><xs:complexType>\n        <xs:sequence><xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"widget-template\"/></xs:sequence>\n    </xs:complexType></xs:element>\n    <xs:element name=\"widget-template\"><xs:complexType>\n        <xs:sequence>\n            <xs:choice>\n                <xs:element minOccurs=\"0\" ref=\"SubFields\"/>\n                <xs:element minOccurs=\"0\" ref=\"AllWidgets\"/>\n            </xs:choice>\n            <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"StandaloneFields\"/>\n        </xs:sequence>\n        <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"/>\n    </xs:complexType></xs:element>\n\n    <!-- ================== form ==================== -->\n    <xs:element name=\"form-single\" substitutionGroup=\"AllWidgets\"><xs:annotation>\n        <xs:documentation>A single form is used to view or edit fields of a single map/hash/record/etc.</xs:documentation>\n    </xs:annotation><xs:complexType>\n        <xs:sequence>\n            <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n                <xs:element minOccurs=\"0\" ref=\"auto-fields-service\"/>\n                <xs:element minOccurs=\"0\" ref=\"auto-fields-entity\"/>\n                <xs:element minOccurs=\"0\" name=\"field\" type=\"form-field-single\"/>\n            </xs:choice>\n            <xs:element minOccurs=\"0\" ref=\"field-layout\"/>\n        </xs:sequence>\n        <xs:attribute name=\"name\" type=\"name-upper\" use=\"required\">\n            <xs:annotation><xs:documentation>The name of the form. Used to reference the form along with the XML Screen\n                file location. For HTML output this is the form name and id, and for other output may also be used to identify the\n                part of the output corresponding to the form.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"extends\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The location and name separated by a hash/pound sign (#) of the form to\n                extend. If there is no location it is treated as a form name in the current screen.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"owner-form\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The name (HTML id) of a form that will own the fields in this form. When used no\n                form elements are generated, only fields, and each will have the form attribute populated with the value of this\n                attribute.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"transition\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The transition in the current screen to submit the form to.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"map\" type=\"xs:string\" default=\"fieldValues\">\n            <xs:annotation><xs:documentation>The Map to get field values from. Is often a EntityValue object or a\n                Map with data pulled from various places to populate in the form. Map keys are matched against field\n                names. This is ignored if the field.@from attribute is used, that is evaluated against the\n                context in place at the time each field is rendered. Defaults to \"fieldValues\".</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"focus-field\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The name of the field to focus on when the form is rendered.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"skip-start\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Skip the starting rendered elements of the form. When used after a form\n                with skip-end=true this will effectively combine the forms into one.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"skip-end\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Skip the ending rendered elements of the form. Use this to leave a form\n                open so that additional forms can be combined with it.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"dynamic\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>If true this form will be considered dynamic and the internal\n                definition will be built up each time it is used instead of only when first referred to. This is\n                necessary when auto-fields-* elements have ${} string expansion for service or entity names.</xs:documentation></xs:annotation>\n        </xs:attribute>\n\n        <xs:attribute name=\"background-submit\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Submit the form in the background without reloading the screen.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"background-reload-id\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>After the form is submitted in the background reload the\n                dynamic-container with this id.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"background-hide-id\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>After the form is submitted in the background hide the element (usually\n                a dialog) with the specified id.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"background-message\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>After the form is submitted in the background show this message in a dialog.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"server-static\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>See details for the screen.@server-static attribute. Defaults to the screen's value.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"body-parameters\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>Comma separated list of field names (no spaces) to make sure are passed as body\n                parameters, for vuet mode only and forms that have a non-transition target where form-link is used.</xs:documentation></xs:annotation>\n        </xs:attribute>\n\n        <xs:attribute name=\"pass-through-parameters\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Add hidden input elements for all parameters (for companion form-list find options, etc)</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"exclude-empty-fields\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>If true exclude empty fields when the form is submitted instead of including (as zero length string); NOTE: currently only supported in qvt and vuet render modes</xs:documentation></xs:annotation>\n        </xs:attribute>\n    </xs:complexType></xs:element>\n\n    <xs:element name=\"form-list\" substitutionGroup=\"AllWidgets\"><xs:annotation>\n        <xs:documentation>\n            A list form is a list of individual forms in a table (could be called a tabular form), it has a list of\n            sets of values and creates one form for each list element.\n\n            A variation on the list form is the multi form (set the attribute multi=true). In the multi mode all\n            list elements will be put into a single large form with suffixes on each field for each row, with a\n            single submit button at the bottom instead of a submit button on each row.\n        </xs:documentation>\n    </xs:annotation><xs:complexType>\n        <xs:sequence>\n            <xs:element minOccurs=\"0\" ref=\"entity-find\"/>\n            <xs:element minOccurs=\"0\" ref=\"row-actions\"/>\n            <xs:element minOccurs=\"0\" name=\"row-selection\"><xs:complexType>\n                <xs:annotation><xs:documentation>Configuration for selected row action forms. NOTE: currently only supported in 'qvt' render mode</xs:documentation></xs:annotation>\n                <xs:sequence>\n                    <xs:element name=\"action\" maxOccurs=\"unbounded\"><xs:complexType>\n                        <xs:sequence>\n                            <xs:element name=\"dialog\" minOccurs=\"0\"><xs:complexType>\n                                <xs:annotation><xs:documentation>Put the action widget (form-single) in a dialog, supports standalone widgets displayed in dialog above the action widget</xs:documentation></xs:annotation>\n                                <xs:sequence><xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"StandaloneFields\"/></xs:sequence>\n                                <xs:attribute name=\"button-text\" type=\"xs:string\" use=\"required\"/>\n                                <xs:attribute name=\"button-type\" type=\"color-context\" default=\"primary\"/>\n                                <xs:attribute name=\"button-icon\" type=\"xs:string\"/>\n                                <xs:attribute name=\"title\" type=\"xs:string\"/>\n                            </xs:complexType></xs:element>\n                            <xs:choice>\n                                <!-- action widgets - for now just form-single, may support others in the future -->\n                                <xs:element ref=\"form-single\"/>\n                            </xs:choice>\n                        </xs:sequence>\n                        <!-- consider these in the future, not needed now (and more flexible to use conditional-field in the form-single)\n                        <xs:attribute name=\"disable-condition\" type=\"xs:string\"/>\n                        <xs:attribute name=\"disable-message\" type=\"xs:string\"/>\n                        -->\n                    </xs:complexType></xs:element>\n                </xs:sequence>\n                <xs:attribute name=\"id-field\" type=\"xs:string\" use=\"required\">\n                    <xs:annotation><xs:documentation>Form field name for the ID field to include in row-selection action\n                        requests following the multi-submit pattern (_{rowNumber} suffix)</xs:documentation></xs:annotation></xs:attribute>\n                <xs:attribute name=\"parameter\" type=\"xs:string\">\n                    <xs:annotation><xs:documentation>If specified use instead of the id-field value for the parameter name</xs:documentation></xs:annotation></xs:attribute>\n                <xs:attribute name=\"list-mode\" type=\"boolean\" default=\"false\">\n                    <xs:annotation><xs:documentation>If true pass values as a comma-separated list in a single parameter instead of a list of parameters with underscore suffixes, also does not set _isMulti=true parameter</xs:documentation></xs:annotation></xs:attribute>\n            </xs:complexType></xs:element>\n\n            <xs:element minOccurs=\"0\" name=\"hidden-parameters\"><xs:complexType>\n                <xs:annotation><xs:documentation>\n                    Parameters specific here will be passed through hidden inputs for all forms generated by form-list including\n                    header, first/second/last row, and multi or per-row forms. Meant to be used for context pass through parameters.\n\n                    For multi=true these will be global, not per row (named without row number extension, expressions evaluated without row data).\n                </xs:documentation></xs:annotation>\n                <xs:sequence><xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"parameter\"/></xs:sequence>\n                <xs:attribute name=\"parameter-map\" type=\"xs:string\"><xs:annotation>\n                    <xs:documentation>A Map to get parameter names and values from in addition to the parameter sub-elements.</xs:documentation></xs:annotation></xs:attribute>\n            </xs:complexType></xs:element>\n            <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n                <xs:element minOccurs=\"0\" ref=\"auto-fields-service\"/>\n                <xs:element minOccurs=\"0\" ref=\"auto-fields-entity\"/>\n                <xs:element minOccurs=\"0\" name=\"field\" type=\"form-field-list\"/>\n            </xs:choice>\n            <xs:element name=\"form-list-column\" minOccurs=\"0\" maxOccurs=\"unbounded\"><xs:annotation>\n                <xs:documentation>Fields in this set will be in the same column in the list form table.</xs:documentation>\n            </xs:annotation><xs:complexType>\n                <xs:sequence><xs:element maxOccurs=\"unbounded\" ref=\"field-ref\"/></xs:sequence>\n            </xs:complexType></xs:element>\n\n            <xs:element name=\"columns\" minOccurs=\"0\" maxOccurs=\"unbounded\"><xs:annotation>\n                <xs:documentation>Configuration for default columns and fields in each</xs:documentation>\n            </xs:annotation><xs:complexType>\n                <xs:sequence>\n                    <xs:element name=\"column\" minOccurs=\"0\" maxOccurs=\"unbounded\"><xs:annotation>\n                        <xs:documentation>Fields in this set will be in the same column in the list form table (alternative to old form-list-column element).</xs:documentation>\n                    </xs:annotation><xs:complexType>\n                        <xs:sequence><xs:element maxOccurs=\"unbounded\" ref=\"field-ref\"/></xs:sequence>\n                    </xs:complexType></xs:element>\n                </xs:sequence>\n                <xs:attribute name=\"type\" type=\"xs:string\"><xs:annotation><xs:documentation>\n                    Used to distinguish multiple columns configurations, can be anything used in the '_uiType' parameter (or context field).\n                    If type not specified used as a default for any _uiType value not specified in another columns elements.\n                    Initially 'desktop' and 'mobile' are supported.\n                    Corresponds to FormConfig.configTypeEnumId for per-user and other saved form configurations.\n                </xs:documentation></xs:annotation></xs:attribute>\n            </xs:complexType></xs:element>\n\n        </xs:sequence>\n        <xs:attribute name=\"name\" type=\"name-upper\" use=\"required\">\n            <xs:annotation><xs:documentation>The name of the form. Used to reference the form along with the XML Screen\n                file location. For HTML output this is the form name and id, and for other output may also be used to identify the\n                part of the output corresponding to the form.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"extends\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The location and name separated by a hash/pound sign (#) of the form to\n                extend. If there is no location it is treated as a form name in the current screen.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"transition\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The transition in the current screen to submit the main form(s) to.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"transition-first-row\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The transition in the current screen to submit the first row form to.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"transition-second-row\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The transition in the current screen to submit the second row form to.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"transition-last-row\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The transition in the current screen to submit the last row form to.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"map-first-row\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The Map to use for field values in the first-row fields, like form-single.@map.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"map-second-row\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The Map to use for field values in the second-row fields, like form-single.@map.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"map-last-row\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The Map to use for field values in the last-row fields, like form-single.@map.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"multi\" default=\"false\" type=\"boolean\">\n            <xs:annotation><xs:documentation>Make the form a multi-submit form where all rows on a page are\n                submitted together in a single request with a \"_${rowNumber}\" suffix on each field. Also passes a\n                _isMulti=true parameter so the Service Facade knows to run the service (for a single service-call in a\n                transition) for each row. Defaults to false, so set to true to enable this behavior and have a\n                separate form (submitted separately) for each row.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"list\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>A Groovy expression that evaluates to a list to iterate over.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"list-entry\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>If specified each list entry will be put in the context with this\n                name, otherwise the list entry must be a Map and the entries in the Map will be put into the context\n                for each row.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"style\" type=\"xs:string\"/>\n        <xs:attribute name=\"paginate\" type=\"xs:string\" default=\"true\">\n            <xs:annotation><xs:documentation>Indicate if this form should paginate or not. Defaults to true.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"paginate-always-show\" type=\"xs:string\" default=\"true\">\n            <xs:annotation><xs:documentation>Always show the pagination control with count of rows, even when there\n                is only one page? Defaults to true.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"focus-field\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The name of the field to focus on in the first row when the form is rendered.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"skip-start\" type=\"boolean\" default=\"false\"/>\n        <xs:attribute name=\"skip-end\" type=\"boolean\" default=\"false\"/>\n        <xs:attribute name=\"skip-form\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Make the output a plain table, not submittable (in HTML don't generate\n                'form' elements). Useful for view-only list forms to minimize output.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"skip-header\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Skip the table header element.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"header-dialog\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Put header-field widgets in a dialog instead of the table header.\n                Includes all fields with header widgets, not just those displayed.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"select-columns\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Enable per-user selection of which columns to display.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"saved-finds\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Enable saved finds (query parameters, order by).</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"show-csv-button\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Show a button to export as CSV (renderMode=csv), if the pagination header is displayed</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"show-xlsx-button\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Show a button to export as XLS (renderMode=xlsx), if the pagination header is displayed</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"show-text-button\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Show a button to export as plain text (renderMode=text), if the pagination header\n                is displayed. Because fixed-width text output is limited by nature and the default of evenly sizing all columns\n                is not very useful in most cases, this should only be set to true when the field.@print-width and @align\n                attributes are used for the form-list.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"show-pdf-button\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Show a button to render the screen as XSL-FO (renderMode=xsl-fo) and convert to\n                PDF, if the pagination header is displayed. Because fixed-width text output is limited by nature and the default\n                of evenly sizing all columns is not very useful in most cases, this should only be set to true when the\n                field.@print-width and @align attributes are used for the form-list.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"show-all-button\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Show a button to display all results (pageNoLimit=true), if the pagination header is displayed</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"show-page-size\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Show a drop-down to select different page sizes, if the pagination header is displayed</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"dynamic\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>If true then this form will be considered dynamic and the internal\n                definition will be built up each time it is used instead of only when first referred to.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"server-static\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>See details for the screen.@server-static attribute. Defaults to the screen's value.</xs:documentation></xs:annotation>\n        </xs:attribute>\n    </xs:complexType></xs:element>\n    <xs:element name=\"row-actions\"><xs:complexType>\n        <xs:sequence><xs:group minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"AllOperations\"/></xs:sequence>\n    </xs:complexType></xs:element>\n    <xs:element name=\"auto-fields-service\"><xs:complexType>\n        <xs:sequence>\n            <xs:element name=\"exclude\" minOccurs=\"0\" maxOccurs=\"unbounded\"><xs:complexType>\n                <xs:attribute name=\"parameter-name\" type=\"name-field\" use=\"required\"/></xs:complexType></xs:element>\n        </xs:sequence>\n        <xs:attribute name=\"service-name\" type=\"name-full\" use=\"required\"/>\n        <xs:attribute name=\"field-type\" default=\"edit\" type=\"auto-field-type\"/>\n        <xs:attribute name=\"include\" default=\"in\">\n            <xs:simpleType>\n                <xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"in\"/>\n                    <xs:enumeration value=\"out\"/>\n                    <xs:enumeration value=\"all\"/>\n                </xs:restriction>\n            </xs:simpleType>\n        </xs:attribute>\n    </xs:complexType></xs:element>\n    <xs:element name=\"auto-fields-entity\"><xs:complexType>\n        <xs:sequence>\n            <xs:element name=\"exclude\" minOccurs=\"0\" maxOccurs=\"unbounded\"><xs:complexType>\n                <xs:attribute name=\"field-name\" type=\"name-field\" use=\"required\"/></xs:complexType></xs:element>\n        </xs:sequence>\n        <xs:attribute name=\"entity-name\" type=\"name-full\" use=\"required\"/>\n        <xs:attribute name=\"include\" default=\"all\">\n            <xs:simpleType>\n                <xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"pk\"/>\n                    <xs:enumeration value=\"nonpk\"/>\n                    <xs:enumeration value=\"all\"/>\n                </xs:restriction>\n            </xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"field-type\" default=\"find-display\" type=\"auto-field-type\"/>\n        <xs:attribute name=\"auto-columns\" default=\"true\" type=\"boolean\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"field-layout\"><xs:complexType>\n        <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n            <xs:element ref=\"field-group\"/>\n            <xs:element name=\"field-accordion\"><xs:complexType>\n                <xs:sequence><xs:element maxOccurs=\"unbounded\" ref=\"field-group\"/></xs:sequence>\n                <xs:attribute name=\"active\" type=\"xs:string\"><xs:annotation><xs:documentation>\n                    Use this to specify the accordion section index to be open, or false for all to be closed.</xs:documentation></xs:annotation></xs:attribute>\n            </xs:complexType></xs:element>\n            <xs:element ref=\"field-ref\"/>\n            <xs:element ref=\"fields-not-referenced\"/>\n            <xs:element ref=\"field-row\"/>\n            <xs:element ref=\"field-row-big\"/>\n            <xs:element name=\"field-col-row\"><xs:complexType>\n                <xs:sequence><xs:element maxOccurs=\"unbounded\" name=\"field-col\"><xs:complexType>\n                    <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n                        <xs:element ref=\"field-group\"/>\n                        <xs:element name=\"field-accordion\"><xs:complexType>\n                            <xs:sequence><xs:element maxOccurs=\"unbounded\" ref=\"field-group\"/></xs:sequence>\n                            <xs:attribute name=\"active\" type=\"xs:string\"/>\n                        </xs:complexType></xs:element>\n                        <xs:element ref=\"field-ref\"/>\n                        <xs:element ref=\"fields-not-referenced\"/>\n                        <xs:element ref=\"field-row\"/>\n                        <xs:element ref=\"field-row-big\"/>\n                    </xs:choice>\n                    <xs:attribute name=\"lg\" type=\"xs:positiveInteger\"/>\n                    <xs:attribute name=\"md\" type=\"xs:positiveInteger\"/>\n                    <xs:attribute name=\"sm\" type=\"xs:positiveInteger\"/>\n                    <xs:attribute name=\"xs\" type=\"xs:positiveInteger\"/>\n                    <xs:attribute name=\"label-cols\" type=\"xs:positiveInteger\"/>\n                    <xs:attribute name=\"style\" type=\"xs:string\"/>\n                </xs:complexType></xs:element></xs:sequence>\n                <xs:attribute name=\"style\" type=\"xs:string\"/>\n            </xs:complexType></xs:element>\n        </xs:choice>\n        <xs:attribute name=\"id\" type=\"xs:string\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"field-group\"><xs:complexType>\n        <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n            <xs:element ref=\"field-ref\"/>\n            <xs:element ref=\"fields-not-referenced\"/>\n            <xs:element ref=\"field-row\"/>\n            <xs:element ref=\"field-row-big\"/>\n        </xs:choice>\n        <xs:attribute name=\"title\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"style\" type=\"xs:string\"/>\n        <xs:attribute name=\"box\" type=\"boolean\" default=\"false\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"field-row\"><xs:annotation>\n        <xs:documentation>Fields in the field-row will be split into two columns. If you want more than\n            two fields in a row, use field-row-big.</xs:documentation>\n    </xs:annotation><xs:complexType>\n        <xs:sequence><xs:element minOccurs=\"0\" maxOccurs=\"2\" ref=\"field-ref\"/></xs:sequence>\n    </xs:complexType></xs:element>\n    <xs:element name=\"field-row-big\"><xs:annotation>\n        <xs:documentation>All fields go in a single column, with an optional title column first. Fields\n            will be \"floated\" left so that they stack up on a single line as long as them will fit, and then will\n            overflow to the next line, etc.</xs:documentation>\n    </xs:annotation><xs:complexType>\n        <xs:sequence><xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"field-ref\"/></xs:sequence>\n        <xs:attribute name=\"title\" type=\"xs:string\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"field-ref\"><xs:complexType>\n        <xs:attribute name=\"name\" type=\"name-parameter\" use=\"required\"/></xs:complexType></xs:element>\n    <xs:element name=\"fields-not-referenced\">\n        <xs:annotation><xs:documentation>Fields not explicitly referenced will be inserted where this element is. If\n            this element is left out the non-referenced fields will not be displayed.</xs:documentation></xs:annotation>\n    </xs:element>\n\n    <!-- ================== Standalone Fields ==================== -->\n    <xs:element name=\"link\" substitutionGroup=\"StandaloneFields\"><xs:complexType>\n        <xs:sequence>\n            <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"parameter\"/>\n            <xs:element minOccurs=\"0\" ref=\"image\"/>\n        </xs:sequence>\n\n        <xs:attribute name=\"id\" type=\"xs:string\"/>\n        <xs:attribute name=\"link-type\" default=\"auto\">\n            <xs:simpleType>\n                <xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"auto\"><xs:annotation><xs:documentation>\n                        If selected the hidden-form type will be used if the url-mode is transition and the\n                        transition has actions, otherwise the anchor-button type will be used.\n                    </xs:documentation></xs:annotation></xs:enumeration>\n                    <xs:enumeration value=\"anchor\"/>\n                    <xs:enumeration value=\"anchor-button\"/>\n                    <xs:enumeration value=\"hidden-form\"/>\n                    <xs:enumeration value=\"hidden-form-link\"/>\n                </xs:restriction>\n            </xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"url\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"url-type\" type=\"url-type\" default=\"transition\"><xs:annotation><xs:documentation>\n            The type for the url attribute. Defaults to transition (on this screen).\n        </xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"url-noparam\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>If true don't add parameters to the URL (mainly for anchors).</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"text\" type=\"xs:string\"/>\n        <xs:attribute name=\"text-map\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>An expression that evaluates to a Map in the context that will be used\n                in addition to the context when expanding the @text value.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"encode\" type=\"boolean\" default=\"true\">\n            <xs:annotation><xs:documentation>\n                If true text will be encoded so that it does not interfere with markup of the target output.\n\n                For example, if output is HTML then data presented will be HTML encoded so that all\n                HTML-specific characters are escaped.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"icon\" type=\"xs:string\"><xs:annotation>\n            <xs:documentation>Icon name, actually an icon style used in an 'i' element in HTML output.</xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"badge\" type=\"xs:string\"><xs:annotation>\n            <xs:documentation>Text to put in a badge on the right side</xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"tooltip\" type=\"xs:string\"/>\n        <xs:attribute name=\"target-window\" type=\"xs:string\"/>\n        <xs:attribute name=\"confirmation\" type=\"xs:string\"><xs:annotation><xs:documentation>\n            If there is a message here it will show in a confirmation box when the link is clicked on.\n        </xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"parameter-map\" type=\"xs:string\"><xs:annotation><xs:documentation>\n            A Map to get parameter names and values from in addition to the parameter sub-elements.\n        </xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"pass-through-parameters\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Add URL parameters or hidden input elements for all parameters (for companion form-list find options, etc)</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"expand-transition-url\" default=\"true\" type=\"boolean\">\n            <xs:annotation><xs:documentation>If this target transition has no condition, no actions, no\n                conditional responses and the default-response type is \"url\" and url-type is \"screen-path\" then\n                URLs to this transition may be expanded. Set this to true to expand them to what the\n                default-response points to instead of a URL to this transition.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"style\" type=\"xs:string\"/>\n        <xs:attribute name=\"btn-type\" type=\"color-context\" default=\"primary\"/>\n        <xs:attribute name=\"dynamic-load-id\" type=\"xs:string\"><xs:annotation><xs:documentation>\n            If specified URL will be loaded into the dynamic-container with this ID.\n        </xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"condition\" type=\"xs:string\"><xs:annotation><xs:documentation>\n            If specified and evaluates to false link is not rendered.</xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"entity-name\" type=\"xs:string\"/>\n        <xs:attribute name=\"entity-key-name\" type=\"xs:string\"/>\n        <xs:attribute name=\"entity-use-cache\" default=\"true\" type=\"boolean\"/>\n    </xs:complexType></xs:element>\n\n    <xs:element name=\"image\" substitutionGroup=\"StandaloneFields\"><xs:complexType>\n        <xs:sequence>\n            <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"parameter\"/>\n        </xs:sequence>\n        <xs:attribute name=\"id\" type=\"xs:string\"/>\n        <xs:attribute name=\"url\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"url-type\" type=\"url-type\" default=\"content\"/>\n        <xs:attribute name=\"width\" type=\"xs:string\"/>\n        <xs:attribute name=\"height\" type=\"xs:string\"/>\n        <xs:attribute name=\"alt\" type=\"xs:string\"/>\n        <xs:attribute name=\"style\" type=\"xs:string\"/>\n        <xs:attribute name=\"hover\" type=\"boolean\" default=\"false\"/>\n        <xs:attribute name=\"parameter-map\" type=\"xs:string\"><xs:annotation><xs:documentation>\n            A Map to get parameter names and values from in addition to the parameter sub-elements.\n        </xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"condition\" type=\"xs:string\"><xs:annotation><xs:documentation>\n            If specified and evaluates to false image is not rendered.</xs:documentation></xs:annotation></xs:attribute>\n    </xs:complexType></xs:element>\n\n    <xs:element name=\"label\" substitutionGroup=\"StandaloneFields\"><xs:complexType>\n        <xs:attribute name=\"text\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"text-map\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>An expression that evaluates to a Map in the context that will be used\n                in addition to the context when expanding the @text value.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"type\" default=\"span\">\n            <xs:simpleType><xs:restriction base=\"xs:token\">\n                <xs:enumeration value=\"p\"/>\n                <xs:enumeration value=\"pre\"/><xs:enumeration value=\"code\"/>\n                <xs:enumeration value=\"div\"/><xs:enumeration value=\"span\"/>\n                <xs:enumeration value=\"strong\"/><xs:enumeration value=\"i\"/>\n                <xs:enumeration value=\"h1\"/><xs:enumeration value=\"h2\"/><xs:enumeration value=\"h3\"/>\n                <xs:enumeration value=\"h4\"/><xs:enumeration value=\"h5\"/><xs:enumeration value=\"h6\"/>\n                <xs:enumeration value=\"dt\"/><xs:enumeration value=\"dd\"/>\n            </xs:restriction></xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"id\" type=\"xs:string\"/>\n        <xs:attribute name=\"encode\" type=\"boolean\" default=\"true\">\n            <xs:annotation><xs:documentation>\n                If true text will be encoded so that it does not interfere with markup of the target output.\n\n                For example, if output is HTML then data presented will be HTML encoded so that all\n                HTML-specific characters are escaped.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"display-if-empty\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Generate text container even if text value is empty (or just whitespace)?\n                Defaults to false.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"style\" type=\"xs:string\"/>\n        <xs:attribute name=\"condition\" type=\"xs:string\"><xs:annotation><xs:documentation>\n            If specified and evaluates to false label is not rendered.\n        </xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"tooltip\" type=\"xs:string\"/>\n    </xs:complexType></xs:element>\n\n    <xs:element name=\"editable\" substitutionGroup=\"StandaloneFields\"><xs:complexType>\n        <xs:sequence>\n            <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"parameter\"/>\n            <xs:element minOccurs=\"0\" ref=\"editable-load\"/>\n        </xs:sequence>\n        <xs:attribute name=\"id\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"text\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"type\" default=\"span\">\n            <xs:simpleType><xs:restriction base=\"xs:token\">\n                <xs:enumeration value=\"p\"/><xs:enumeration value=\"pre\"/>\n                <xs:enumeration value=\"span\"/><xs:enumeration value=\"div\"/><xs:enumeration value=\"strong\"/>\n                <xs:enumeration value=\"h1\"/><xs:enumeration value=\"h2\"/><xs:enumeration value=\"h3\"/>\n                <xs:enumeration value=\"h4\"/><xs:enumeration value=\"h5\"/>\n            </xs:restriction></xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"widget-type\" default=\"textarea\">\n            <xs:simpleType><xs:restriction base=\"xs:token\">\n                <xs:enumeration value=\"textarea\"/>\n                <xs:enumeration value=\"select\"/>\n            </xs:restriction></xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"encode\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>\n                If true text will be encoded so that it does not interfere with markup of the target output.\n\n                For example, if output is HTML then data presented will be HTML encoded so that all\n                HTML-specific characters are escaped.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"display-if-empty\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Generate text container even if text value is empty (or just whitespace)?\n                Defaults to false.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"transition\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"parameter-name\" type=\"xs:string\" default=\"value\"><xs:annotation><xs:documentation>The name of the\n            parameter to pass the edited value in.</xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"parameter-map\" type=\"xs:string\"><xs:annotation><xs:documentation>A Map to get parameter\n            names and values from in addition to the parameter sub-elements.</xs:documentation></xs:annotation></xs:attribute>\n    </xs:complexType></xs:element>\n    <xs:element name=\"editable-load\"><xs:complexType>\n        <xs:sequence><xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"parameter\"/></xs:sequence>\n        <xs:attribute name=\"transition\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"parameter-map\" type=\"xs:string\"><xs:annotation><xs:documentation>A Map to get parameter\n            names and values from in addition to the parameter sub-elements.</xs:documentation></xs:annotation></xs:attribute>\n    </xs:complexType></xs:element>\n\n  <!-- ================== Input Fields ==================== -->\n    <xs:element name=\"SubFields\" abstract=\"true\"/>\n\n    <xs:complexType name=\"form-field-base\">\n        <xs:attribute name=\"name\" type=\"name-parameter\" use=\"required\">\n            <xs:annotation><xs:documentation>A unique name for this field. Used for the parameter name, referencing\n                the field in other places, etc.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"from\" type=\"xs:string\"><!-- was 'entry-name' -->\n            <xs:annotation><xs:documentation>A Groovy expression to get the value of the field from the context; may be a simple\n                context field name, arithmetic expression, include function calls, etc.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"hide\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>If false field will always be visible (at least the title if nothing\n                else). If true will always be hidden regardless of title and widgets defined. If empty (default)\n                will guess based on definition of field.\n\n                Note that for form-list fields this governs the entire column for the field, and not just a single\n                row.</xs:documentation></xs:annotation>\n        </xs:attribute>\n    </xs:complexType>\n    <xs:complexType name=\"form-field-single\"><xs:complexContent><xs:extension base=\"form-field-base\">\n        <xs:sequence>\n            <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"conditional-field\"/>\n            <xs:element minOccurs=\"0\" ref=\"default-field\"/>\n            <!-- tabled, not to be part of 1.0: <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"visible-when\"/> -->\n        </xs:sequence>\n    </xs:extension></xs:complexContent></xs:complexType>\n    <xs:complexType name=\"form-field-list\"><xs:complexContent><xs:extension base=\"form-field-base\">\n        <xs:sequence>\n            <xs:element minOccurs=\"0\" ref=\"header-field\"/>\n            <xs:element minOccurs=\"0\" ref=\"first-row-field\"/>\n            <xs:element minOccurs=\"0\" ref=\"second-row-field\"/>\n            <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"conditional-field\"/>\n            <xs:element minOccurs=\"0\" ref=\"default-field\"/>\n            <xs:element minOccurs=\"0\" ref=\"last-row-field\"/>\n        </xs:sequence>\n        <xs:attribute name=\"print-width\" type=\"xs:nonNegativeInteger\"><xs:annotation><xs:documentation>\n            Column width when printing (formatted text, xsl-fo render modes); when there are multiple fields in a\n            column the highest width is used; not on form-list-column because that is dynamic with select-columns.\n\n            By default display, display-entity, are printed and link is printed if it is an anchor (not a button).\n            Set to zero (0) to not print this field.\n        </xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"print-width-type\" default=\"percent\"><xs:annotation><xs:documentation>The type of print\n            width, default to percent and can be characters.</xs:documentation></xs:annotation>\n            <xs:simpleType><xs:restriction base=\"xs:token\">\n                <xs:enumeration value=\"percent\"/>\n                <xs:enumeration value=\"characters\"/>\n            </xs:restriction></xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"align\" default=\"left\">\n            <xs:simpleType><xs:restriction base=\"xs:token\">\n                <xs:enumeration value=\"left\"/>\n                <xs:enumeration value=\"center\"/>\n                <xs:enumeration value=\"right\"/>\n            </xs:restriction></xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"aggregate\">\n            <xs:annotation><xs:documentation>\n                For form-list only. Use to combine rows during render with summary fields (where this is a function), or in\n                a sub-list under a row with values common among the combined rows.\n\n                To use this at least one field must have at least one field with aggregate=group-by for efficiently finding\n                rows to combine.\n            </xs:documentation></xs:annotation>\n            <xs:simpleType><xs:restriction base=\"xs:token\">\n                <xs:enumeration value=\"min\"/><xs:enumeration value=\"max\"/><xs:enumeration value=\"sum\"/>\n                <xs:enumeration value=\"avg\"/><xs:enumeration value=\"count\"/>\n                <xs:enumeration value=\"group-by\"/>\n                <xs:enumeration value=\"sub-list\"/>\n            </xs:restriction></xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"show-total\" default=\"false\">\n            <xs:annotation><xs:documentation>\n                For form-list only. If anything but false for any field a totals row is added to the output based on the\n                rows currently displayed. The 'true' option is the same as 'sum'.\n            </xs:documentation></xs:annotation>\n            <xs:simpleType><xs:restriction base=\"xs:token\">\n                <xs:enumeration value=\"true\"/><xs:enumeration value=\"false\"/><xs:enumeration value=\"sum\"/>\n                <xs:enumeration value=\"min\"/><xs:enumeration value=\"max\"/>\n                <xs:enumeration value=\"avg\"/><xs:enumeration value=\"count\"/>\n                <xs:enumeration value=\"first\"/><xs:enumeration value=\"last\"/>\n            </xs:restriction></xs:simpleType>\n        </xs:attribute>\n    </xs:extension></xs:complexContent></xs:complexType>\n\n    <xs:element name=\"header-field\"><xs:annotation>\n        <xs:documentation>\n            Only applicable to fields until a form-list element.\n            Used to show a field to filter the results by (instead of a separate search form), and/or to show the\n            order-by option in the header.\n        </xs:documentation>\n    </xs:annotation><xs:complexType>\n        <xs:sequence>\n            <xs:choice>\n                <xs:element minOccurs=\"0\" ref=\"SubFields\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"AllWidgets\"/>\n            </xs:choice>\n            <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"StandaloneFields\"/>\n            <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"set\"/>\n        </xs:sequence>\n        <xs:attribute name=\"title\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The name of this field that will be shown to the user; can use the ${}\n                and map.key (dot) syntax to insert values from the context.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"show-order-by\" default=\"false\">\n            <xs:annotation><xs:documentation>Only applicable to multi and list type forms. If true header links for\n                ordering by this field will be displayed.</xs:documentation></xs:annotation>\n            <xs:simpleType>\n                <xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"true\"/>\n                    <xs:enumeration value=\"false\"/>\n                    <xs:enumeration value=\"case-insensitive\"/>\n                </xs:restriction>\n            </xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"container-style\" type=\"xs:string\"><xs:annotation><xs:documentation>A style (HTML class)\n            for the container around the field (table cell, etc).</xs:documentation></xs:annotation></xs:attribute>\n    </xs:complexType></xs:element>\n    <xs:element name=\"conditional-field\" type=\"form-list-conditional-field\"/>\n    <xs:element name=\"default-field\" type=\"form-list-default-field\"/>\n    <xs:element name=\"first-row-field\" type=\"form-list-default-field\"/>\n    <xs:element name=\"second-row-field\" type=\"form-list-default-field\"/>\n    <xs:element name=\"last-row-field\" type=\"form-list-default-field\"/>\n    <xs:complexType name=\"form-list-default-field\">\n        <xs:sequence>\n            <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"StandaloneFields\"/>\n            <xs:choice>\n                <xs:element minOccurs=\"0\" ref=\"SubFields\"/>\n                <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"AllWidgets\"/>\n            </xs:choice>\n            <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"StandaloneFields\"/>\n            <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"set\"/>\n            <xs:element minOccurs=\"0\" ref=\"visible-when\"/>\n        </xs:sequence>\n        <xs:attribute name=\"title\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The name of this field that will be shown to the user; can use the ${}\n                and map.key (dot) syntax to insert values from the context.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"tooltip\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The text to show on mouse over or help for more information; can use\n                the ${} and map.key (dot) syntax to insert values from the context.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"red-when\" default=\"by-name\">\n            <xs:annotation><xs:documentation>The widget/interaction part will be red if the date value is before-now\n                (for thruDate), after-now (for fromDate), or by-name (if the field's @name, @from, fromDate,\n                or thruDate the corresponding action will be done); only applicable when the field is a timestamp.</xs:documentation></xs:annotation>\n            <xs:simpleType>\n                <xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"never\"/>\n                    <xs:enumeration value=\"before-now\"/>\n                    <xs:enumeration value=\"after-now\"/>\n                    <xs:enumeration value=\"by-name\"/>\n                </xs:restriction>\n            </xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"container-style\" type=\"xs:string\"><xs:annotation><xs:documentation>A style (HTML class) for\n            the container around the field (table cell, etc).</xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"validate-service\" type=\"xs:string\"/>\n        <xs:attribute name=\"validate-parameter\" type=\"xs:string\"/>\n        <xs:attribute name=\"validate-entity\" type=\"xs:string\"/>\n        <xs:attribute name=\"validate-field\" type=\"xs:string\"/>\n    </xs:complexType>\n    <xs:complexType name=\"form-list-conditional-field\"><xs:complexContent><xs:extension base=\"form-list-default-field\">\n        <xs:attribute name=\"condition\" type=\"xs:string\" use=\"required\">\n            <xs:annotation><xs:documentation>A boolean expression in Groovy.</xs:documentation></xs:annotation>\n        </xs:attribute>\n    </xs:extension></xs:complexContent></xs:complexType>\n\n    <xs:element name=\"visible-when\">\n        <xs:annotation><xs:documentation>\n            Used to dynamically show or hide a field based on the value of another field (usually a drop-down).\n            If this element is present the field is hidden unless the value of the named field matches\n                the value/from String or is in the value/from Collection.\n            NOTE: currently implemented for qvt only (qapps and other Quasar-Vue Templates)\n        </xs:documentation></xs:annotation>\n        <xs:complexType>\n            <xs:attribute name=\"field\" type=\"xs:string\" use=\"required\"><xs:annotation><xs:documentation>\n                The name of the field (usually a drop-down) used to determine if this field should be visible or not\n            </xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"value\" type=\"xs:string\"><xs:annotation><xs:documentation>\n                The value the field should match in order for this to be visible, may be a list of comma-separated values\n            </xs:documentation></xs:annotation></xs:attribute>\n            <xs:attribute name=\"from\" type=\"xs:string\"><xs:annotation><xs:documentation>\n                Groovy expression that evaluates to a String or Collection\n            </xs:documentation></xs:annotation></xs:attribute>\n        </xs:complexType>\n    </xs:element>\n\n  <!-- ================== Field Sub-Elements ==================== -->\n    <xs:element name=\"auto-widget-service\" substitutionGroup=\"SubFields\"><xs:complexType>\n        <xs:attribute name=\"service-name\" type=\"name-full\" use=\"required\"/>\n        <xs:attribute name=\"parameter-name\" type=\"xs:string\"/>\n        <xs:attribute name=\"field-type\" default=\"edit\" type=\"auto-field-type\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"auto-widget-entity\" substitutionGroup=\"SubFields\"><xs:complexType>\n        <xs:attribute name=\"entity-name\" type=\"name-full\" use=\"required\"/>\n        <xs:attribute name=\"field-name\" type=\"xs:string\"/>\n        <xs:attribute name=\"field-type\" default=\"find-display\" type=\"auto-field-type\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"widget-template-include\" substitutionGroup=\"SubFields\"><xs:complexType>\n        <xs:sequence>\n            <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"set\"/>\n        </xs:sequence>\n        <xs:attribute name=\"location\" type=\"xs:string\" use=\"required\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"check\" substitutionGroup=\"SubFields\"><xs:complexType>\n        <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n            <xs:element ref=\"entity-options\"/>\n            <xs:element ref=\"list-options\"/>\n            <xs:element ref=\"option\"/>\n        </xs:choice>\n        <xs:attribute name=\"no-current-selected-key\" type=\"xs:string\"/>\n        <xs:attribute name=\"all-checked\" type=\"xs:string\"/>\n        <xs:attribute name=\"container-style\" type=\"xs:string\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"date-find\" substitutionGroup=\"SubFields\"><xs:complexType>\n        <xs:attribute name=\"type\" default=\"timestamp\">\n            <xs:simpleType>\n                <xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"timestamp\"/>\n                    <xs:enumeration value=\"date-time\"/>\n                    <xs:enumeration value=\"date\"/>\n                    <xs:enumeration value=\"time\"/>\n                </xs:restriction>\n            </xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"format\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>Used to format the output of Time/Date/Timestamp objects. With\n                auto-fields-service will inherit from service parameter.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"default-value-from\" type=\"xs:string\"/>\n        <xs:attribute name=\"default-value-thru\" type=\"xs:string\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"date-period\" substitutionGroup=\"SubFields\"><xs:complexType>\n        <xs:attribute name=\"allow-empty\" default=\"true\" type=\"boolean\"/>\n        <xs:attribute name=\"time\" default=\"false\" type=\"boolean\">\n            <xs:annotation><xs:documentation>In explicit from/thru date mode allow time entry?</xs:documentation></xs:annotation></xs:attribute>\n    </xs:complexType></xs:element>\n    <xs:element name=\"date-time\" substitutionGroup=\"SubFields\"><xs:complexType>\n        <xs:attribute name=\"type\" default=\"timestamp\">\n            <xs:simpleType>\n                <xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"timestamp\"/>\n                    <xs:enumeration value=\"date-time\"/>\n                    <xs:enumeration value=\"date\"/>\n                    <xs:enumeration value=\"time\"/>\n                </xs:restriction>\n            </xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"format\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>Used to format the output of Time/Date/Timestamp objects. With\n                auto-fields-service will inherit from service parameter.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"default-value\" type=\"xs:string\"/>\n        <xs:attribute name=\"size\" type=\"xs:positiveInteger\"/>\n        <xs:attribute name=\"max-length\" type=\"xs:positiveInteger\"/>\n        <xs:attribute name=\"auto-year\" default=\"true\" type=\"boolean\"/>\n        <xs:attribute name=\"minute-stepping\" type=\"xs:positiveInteger\" default=\"5\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"display\" substitutionGroup=\"SubFields\"><xs:complexType>\n        <xs:sequence>\n            <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"depends-on\">\n                <xs:annotation><xs:documentation>For dynamic values only (see dynamic-transition). When getting\n                    data from the server the value of this field will be passed in the request. When this field\n                    changes the value in the dynamic display will be updated.</xs:documentation></xs:annotation>\n            </xs:element>\n        </xs:sequence>\n        <xs:attribute name=\"also-hidden\" default=\"true\" type=\"boolean\">\n            <xs:annotation><xs:documentation>If set to true, a hidden form field is also rendered, with the name of\n                the field and its value.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"text\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>Specifies the string to display, can use the ${} syntax to insert\n                context values; if empty the value of the field will be printed for a default.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"text-map\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>An expression that evaluates to a Map in the context that will be used\n                in addition to the context when expanding the @text value.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"style\" type=\"xs:string\"/>\n        <xs:attribute name=\"currency-unit-field\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>Specifies the currency uomId (ISO code) used to format the value.\n            Will only format as currency if this is specified.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"currency-hide-symbol\" default=\"false\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>When currency-unit-field has value, defines whether currency symbol will be\n            hidden or displayed normally.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"format\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>Used to format the output of Number/Time/Date/Timestamp/etc objects.\n                With auto-fields-service will inherit from service parameter.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"text-format\" type=\"xs:string\"><xs:annotation><xs:documentation>\n            Used to format the output of Number/Time/Date/Timestamp/etc objects for text output.</xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"encode\" default=\"true\" type=\"boolean\">\n            <xs:annotation><xs:documentation>\n                If true the text will be encoded so that it does not interfere with markup of the target output.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"dynamic-transition\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>If specified the text-line will get a default dynamically using depends-on fields\n                and parameters in the parameter-map attribute.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"parameter-map\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>Used only with dynamic-transition. A Map to get parameter names and values from for dynamic value requests.\n                This is named to follow the pattern for parameters used elsewhere.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"depends-optional\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Used only with dynamic-transition. Set to true to get dynamic value even if a depends-on field is empty.</xs:documentation></xs:annotation>\n        </xs:attribute>\n    </xs:complexType></xs:element>\n    <xs:element name=\"display-entity\" substitutionGroup=\"SubFields\"><xs:annotation>\n        <xs:documentation>This is just like display but looks up a description using the Entity Facade;\n            note that if also-hidden is true then it uses the key as the value, not the shown description.</xs:documentation>\n    </xs:annotation><xs:complexType>\n        <xs:attribute name=\"entity-name\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"key-field-name\" type=\"xs:string\"/>\n        <xs:attribute name=\"use-cache\" default=\"true\" type=\"boolean\"/>\n        <xs:attribute name=\"text\" type=\"xs:string\" default=\"${description}\"/>\n        <xs:attribute name=\"default-text\" type=\"xs:string\"><xs:annotation><xs:documentation>\n            Displayed if no record found.</xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"style\" type=\"xs:string\"/>\n        <xs:attribute name=\"also-hidden\" default=\"true\" type=\"boolean\"/>\n        <xs:attribute name=\"encode\" default=\"true\" type=\"boolean\"><xs:annotation><xs:documentation>\n            If true text will be encoded so that it does not interfere with markup of the target output.</xs:documentation></xs:annotation></xs:attribute>\n    </xs:complexType></xs:element>\n    <xs:element name=\"drop-down\" substitutionGroup=\"SubFields\"><xs:complexType>\n        <xs:sequence>\n            <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n                <xs:element ref=\"entity-options\"/>\n                <xs:element ref=\"list-options\"/>\n                <xs:element ref=\"option\"/>\n            </xs:choice>\n            <xs:element minOccurs=\"0\" ref=\"dynamic-options\"/>\n        </xs:sequence>\n        <xs:attribute name=\"allow-empty\" type=\"boolean-expandable\" default=\"false\"/>\n        <xs:attribute name=\"allow-multiple\" type=\"boolean-expandable\" default=\"false\"/>\n        <xs:attribute name=\"size\" type=\"xs:integer\" default=\"1\"/>\n        <xs:attribute name=\"current\" default=\"selected\"><xs:simpleType><xs:restriction base=\"xs:token\">\n            <xs:enumeration value=\"first-in-list\"/><xs:enumeration value=\"selected\"/></xs:restriction></xs:simpleType></xs:attribute>\n        <xs:attribute name=\"no-current-selected-key\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The key to mark as selected when there is no current entry value.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"required-manual-select\" type=\"boolean\">\n            <xs:annotation><xs:documentation>If true and allow-empty is not true (required, no empty option) don't auto-select the first option,\n                require user to manually select unless there is only 1 option (qvt only as of 2020-12-10)</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"submit-on-select\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>If true submit the form when an option is selected (qvt only as of 2022-02-23)</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"current-description\" type=\"xs:string\"/>\n        <!-- currently not supported (allow text entry or select option): <xs:attribute name=\"combo-box\" type=\"boolean\" default=\"false\"/> -->\n        <xs:attribute name=\"show-not\" type=\"xs:string\" default=\"false\"/>\n        <xs:attribute name=\"style\" type=\"xs:string\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"file\" substitutionGroup=\"SubFields\"><xs:complexType>\n        <xs:attribute name=\"size\" type=\"xs:positiveInteger\" default=\"30\"/>\n        <xs:attribute name=\"maxlength\" type=\"xs:positiveInteger\"/>\n        <xs:attribute name=\"default-value\" type=\"xs:string\"/>\n        <xs:attribute name=\"multiple\" type=\"xs:string\" default=\"false\"/>\n        <xs:attribute name=\"accept\" type=\"xs:string\" />\n    </xs:complexType></xs:element>\n    <xs:element name=\"hidden\" substitutionGroup=\"SubFields\"><xs:complexType>\n        <xs:attribute name=\"default-value\" type=\"xs:string\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"ignored\" substitutionGroup=\"SubFields\"><xs:complexType/></xs:element>\n    <!-- TABLED, not to be part of 1.0:\n    <xs:element name=\"lookup\" substitutionGroup=\"SubFields\">\n        <xs:complexType>\n            <xs:attribute name=\"target-screen\" type=\"xs:string\" use=\"required\"/>\n            <xs:attribute name=\"size\" type=\"xs:positiveInteger\" default=\"30\"/>\n            <xs:attribute name=\"maxlength\" type=\"xs:positiveInteger\"/>\n            <xs:attribute name=\"default-value\" type=\"xs:string\"/>\n            <xs:attribute name=\"disabled\" default=\"false\" type=\"boolean\"/>\n            <xs:attribute name=\"secondary-field\" type=\"xs:string\"/>\n        </xs:complexType>\n    </xs:element>\n    -->\n    <xs:element name=\"password\" substitutionGroup=\"SubFields\"><xs:complexType>\n        <xs:attribute name=\"size\" type=\"xs:positiveInteger\" default=\"30\"/>\n        <xs:attribute name=\"maxlength\" type=\"xs:positiveInteger\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"radio\" substitutionGroup=\"SubFields\"><xs:complexType>\n        <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n            <xs:element ref=\"entity-options\"/>\n            <xs:element ref=\"list-options\"/>\n            <xs:element ref=\"option\"/>\n        </xs:choice>\n        <xs:attribute name=\"no-current-selected-key\" type=\"xs:string\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"range-find\" substitutionGroup=\"SubFields\"><xs:complexType>\n        <xs:attribute name=\"size\" type=\"xs:positiveInteger\" default=\"10\"/>\n        <xs:attribute name=\"maxlength\" type=\"xs:positiveInteger\"/>\n        <xs:attribute name=\"default-value-from\" type=\"xs:string\"/>\n        <xs:attribute name=\"default-value-thru\" type=\"xs:string\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"reset\" substitutionGroup=\"SubFields\"><xs:complexType/></xs:element>\n    <xs:element name=\"submit\" substitutionGroup=\"SubFields\"><xs:complexType>\n        <xs:sequence>\n            <xs:element minOccurs=\"0\" ref=\"image\"/>\n        </xs:sequence>\n        <xs:attribute name=\"text\" type=\"xs:string\"><xs:annotation><xs:documentation>\n            Optional text for submit button. If not specified the field title is used.\n        </xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"confirmation\" type=\"xs:string\"><xs:annotation><xs:documentation>\n            If there is a message here it will show in a confirmation box when the button is clicked on.\n        </xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"type\" type=\"color-context\" default=\"primary\"/>\n        <xs:attribute name=\"icon\" type=\"xs:string\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"text-line\" substitutionGroup=\"SubFields\"><xs:complexType>\n        <xs:sequence>\n            <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"depends-on\">\n                <xs:annotation><xs:documentation>For auto-complete only (at least ac-transition set). When getting\n                    data from the server the value of this field will be passed in the request. When this field\n                    changes the value in the autocomplete text-line will be cleared.</xs:documentation></xs:annotation>\n            </xs:element>\n        </xs:sequence>\n        <xs:attribute name=\"size\" type=\"xs:positiveInteger\" default=\"30\"/>\n        <xs:attribute name=\"maxlength\" type=\"xs:positiveInteger\"/>\n        <xs:attribute name=\"default-value\" type=\"xs:string\"/>\n        <xs:attribute name=\"disabled\" default=\"false\" type=\"boolean\"/>\n        <xs:attribute name=\"format\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>Used to parse and format the output of Number/Time/Date/Timestamp/etc objects.\n                With auto-fields-service will inherit from service parameter.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"mask\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>A mask for guiding and filtering input, uses RobinHerbots/Inputmask.\n                For a simple mask use '9' for digits, 'a' for letters, and '*' for digits or letters.\n                This may also be an alias for a predefined mask.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"input-type\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>Can be any valid HTML input type, but use with caution as some types don't make sense\n                for use with text-line and all except 'text' result in native browser behavior that varies by browser and is\n                sometimes unexpected and problematic. If not specified defaults to 'email' or 'url' when applicable based on\n                validations configured (in target service in parameters) or 'text' otherwise.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"prefix\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>Text displayed to the left of the input box, expanded and localized</xs:documentation></xs:annotation>\n        </xs:attribute>\n\n        <xs:attribute name=\"ac-transition\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>If specified the text-line will have auto-complete added to it with\n                this transition as the source of the auto-complete options.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"ac-delay\" type=\"xs:positiveInteger\" default=\"300\"/>\n        <xs:attribute name=\"ac-min-length\" type=\"xs:nonNegativeInteger\" default=\"1\"/>\n        <xs:attribute name=\"ac-show-value\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>If true the value of the currently selected option will show next to\n                the autocomplete input box.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"ac-initial-text\" type=\"xs:string\"/>\n        <xs:attribute name=\"ac-use-actual\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>If true the value enterred in the input box will be used even if no\n                autocomplete option is selected.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"default-transition\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>If specified the text-line will get a default dynamically using depends-on fields\n                and parameters in the parameter-map attribute.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"parameter-map\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>A Map to get parameter names and values from for autocomplete and dynamic default requests.\n                This is named to follow the pattern for parameters used elsewhere.</xs:documentation></xs:annotation>\n        </xs:attribute>\n    </xs:complexType></xs:element>\n    <xs:element name=\"text-area\" substitutionGroup=\"SubFields\"><xs:complexType>\n        <xs:attribute name=\"cols\" type=\"xs:positiveInteger\" default=\"60\"/>\n        <xs:attribute name=\"rows\" type=\"xs:positiveInteger\" default=\"3\"/>\n        <xs:attribute name=\"maxlength\" type=\"xs:positiveInteger\"/>\n        <xs:attribute name=\"default-value\" type=\"xs:string\"/>\n        <xs:attribute name=\"read-only\" default=\"false\" type=\"boolean\"/>\n        <xs:attribute name=\"editor-type\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>Use a WYSIWYG editor, can be an expanded string expression (with ${}),\n                such as 'html' or 'md', support depends on what the active text-area macro does.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"editor-theme\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>If applicable for the editor-type a screenThemeId for the CSS\n                (resourceTypeEnumId=STRT_STYLESHEET) files to use for the editor context, defaults to active screen theme.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"autogrow\" default=\"false\" type=\"boolean\">\n            <xs:annotation><xs:documentation>Auto-grow the text area to fit the contents;\n                currently only supported in qvt mode (Vue + Quasar)</xs:documentation></xs:annotation>\n        </xs:attribute>\n    </xs:complexType></xs:element>\n    <xs:element name=\"text-find\" substitutionGroup=\"SubFields\"><xs:complexType>\n        <xs:attribute name=\"size\" type=\"xs:positiveInteger\" default=\"30\"/>\n        <xs:attribute name=\"maxlength\" type=\"xs:positiveInteger\"/>\n        <xs:attribute name=\"default-value\" type=\"xs:string\"/>\n        <xs:attribute name=\"ignore-case\" default=\"true\" type=\"boolean\"/>\n        <xs:attribute name=\"default-operator\" default=\"contains\">\n            <xs:simpleType>\n                <xs:restriction base=\"xs:string\">\n                    <xs:enumeration value=\"equals\"/>\n                    <xs:enumeration value=\"like\"/>\n                    <xs:enumeration value=\"contains\"/>\n                    <xs:enumeration value=\"begins\"/>\n                    <xs:enumeration value=\"empty\"/>\n                </xs:restriction>\n            </xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"hide-options\" default=\"false\">\n            <xs:simpleType>\n                <xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"true\"/>\n                    <xs:enumeration value=\"false\"/>\n                    <xs:enumeration value=\"ignore-case\"/>\n                    <xs:enumeration value=\"operator\"/>\n                </xs:restriction>\n            </xs:simpleType>\n        </xs:attribute>\n    </xs:complexType></xs:element>\n\n    <!-- ================== Field Sub-Sub-Elements ==================== -->\n    <xs:element name=\"entity-options\"><xs:annotation>\n        <xs:documentation>Look up options for the field using the named entity.</xs:documentation>\n    </xs:annotation><xs:complexType>\n        <xs:sequence>\n            <xs:element minOccurs=\"0\" ref=\"entity-find\"/>\n        </xs:sequence>\n        <xs:attribute name=\"key\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The text representing the key. Use the ${} syntax to insert entries\n                from the entity value or from the context. If empty will use the first primary key field name to\n                lookup a value in the context.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"text\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>Actual text shown to the user. Use the ${} syntax to insert variables.\n                If empty defaults to the value of the key.</xs:documentation></xs:annotation>\n        </xs:attribute>\n    </xs:complexType></xs:element>\n    <xs:element name=\"list-options\"><xs:annotation>\n        <xs:documentation>Create options based on data in a List of Maps.</xs:documentation>\n    </xs:annotation><xs:complexType>\n        <xs:attribute name=\"list\" type=\"xs:string\" use=\"required\">\n            <xs:annotation><xs:documentation>The name of the list to iterate through to get values.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"key\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The text representing the key. Use the ${} syntax to insert entries\n                from a Map in the list or from the context. If empty and the List contains EntityValue instances\n                then will use the first primary key field name to lookup a value in the context, otherwise will\n                use the name of the field to lookup a value in the context.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"text\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>Actual text shown to the user. Use the ${} syntax to insert entries\n                from a Map in the list or from the context. If empty defaults to the value of the key.</xs:documentation></xs:annotation>\n        </xs:attribute>\n    </xs:complexType></xs:element>\n    <xs:element name=\"option\"><xs:complexType>\n        <xs:attribute name=\"key\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"text\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>What the user will see in the widget; defaults to the value of the key\n                attribute.</xs:documentation></xs:annotation>\n        </xs:attribute>\n    </xs:complexType></xs:element>\n    <xs:element name=\"dynamic-options\"><xs:annotation>\n        <xs:documentation>Look up options for the field using a JSON over HTTP call to a server.</xs:documentation>\n    </xs:annotation><xs:complexType>\n        <xs:sequence>\n            <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"depends-on\">\n                <xs:annotation><xs:documentation>When getting data from the server the value of this field will be\n                    passed in the request. When the field named here changes these options will be updated.</xs:documentation></xs:annotation>\n            </xs:element>\n        </xs:sequence>\n        <xs:attribute name=\"transition\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>The transition in this screen to get the option list from.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"value-field\" type=\"xs:string\" default=\"value\">\n            <xs:annotation><xs:documentation>The field in the result that represents the value (key) for the option.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"label-field\" type=\"xs:string\" default=\"label\">\n            <xs:annotation><xs:documentation>The field in the result that represents the label for the option.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"depends-optional\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Set to true to get options even if a depends-on field is empty.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"server-search\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>For drop-down if true pass search term to server to filter results.\n                Transition that processes results should limit options. Sort of like an autocomplete and supports the same\n                transitions as text-line with @ac-transition unless pagination (infinite scroll) is needed then transition must\n                return object with options, pageSize, and count properties.</xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"delay\" type=\"xs:positiveInteger\" default=\"300\"><xs:annotation><xs:documentation>\n            Key press debounce delay for server-search.</xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"min-length\" type=\"xs:nonNegativeInteger\" default=\"1\"><xs:annotation><xs:documentation>\n            Min term length to search for server-search, can be zero to populate immediately.</xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"parameter-map\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>A Map to get parameter names and values from for search and dynamic default requests.\n                This is named to follow the pattern for parameters used elsewhere.</xs:documentation></xs:annotation>\n        </xs:attribute>\n    </xs:complexType></xs:element>\n    <xs:element name=\"depends-on\"><xs:complexType>\n        <xs:attribute name=\"field\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"parameter\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>If specified used as the parameter to pass instead of the field name.</xs:documentation></xs:annotation>\n        </xs:attribute>\n    </xs:complexType></xs:element>\n</xs:schema>\n"
  },
  {
    "path": "framework/xsd/xml-screen-3.xsd",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!--\nThis software is in the public domain under CC0 1.0 Universal plus a\nGrant of Patent License.\n\nTo the extent possible under law, the author(s) have dedicated all\ncopyright and related and neighboring rights to this software to the\npublic domain worldwide. This software is distributed without any\nwarranty.\n\nYou should have received a copy of the CC0 Public Domain Dedication\nalong with this software (see the LICENSE.md file). If not, see\n<http://creativecommons.org/publicdomain/zero/1.0/>.\n-->\n<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\" elementFormDefault=\"qualified\">\n    <xs:include schemaLocation=\"common-types-3.xsd\"/>\n    <xs:include schemaLocation=\"xml-actions-3.xsd\"/>\n    <xs:include schemaLocation=\"xml-form-3.xsd\"/>\n\n    <!-- ================ Shared Elements ================ -->\n    <xs:group name=\"section-elements\"><xs:sequence>\n        <xs:element ref=\"condition\" minOccurs=\"0\"/>\n        <xs:element ref=\"actions\" minOccurs=\"0\"/>\n        <xs:element ref=\"widgets\"/>\n        <xs:element ref=\"fail-widgets\" minOccurs=\"0\"/>\n    </xs:sequence></xs:group>\n    <xs:group name=\"widget-elements\"><xs:choice>\n        <xs:element ref=\"AllWidgets\"/>\n        <xs:element ref=\"StandaloneFields\"/>\n        <!-- allow additional elements without validation to facilitate extension by adding only FTL macros -->\n        <xs:any minOccurs=\"0\" maxOccurs=\"unbounded\" processContents=\"skip\"/>\n    </xs:choice></xs:group>\n\n    <!-- ================ Screen - root elements ================ -->\n    <xs:complexType name=\"screen-base\">\n        <xs:attribute name=\"standalone\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>\n                If set to true this screen will be rendered without rendering any parent screens. It can still be\n                referred to as a subscreen of its parent, but when rendered the parent will not run, the rendering\n                will start at this screen. Any non-standalone children will still be treated as normal subscreens.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"default-menu-title\" type=\"xs:string\"/>\n        <xs:attribute name=\"default-menu-index\" type=\"xs:nonNegativeInteger\"/>\n        <xs:attribute name=\"default-menu-include\" type=\"boolean\" default=\"true\">\n            <xs:annotation><xs:documentation>\n                Set this to false to not automatically appear in the parent's subscreens menu based on the directory\n                it is in. If true this screen will automatically be included in the parent's subscreens menu.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"menu-image\" type=\"xs:string\"/>\n        <xs:attribute name=\"menu-image-type\" default=\"url-screen\">\n            <xs:simpleType>\n                <xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"url-screen\"/>\n                    <xs:enumeration value=\"url-plain\"/>\n                    <xs:enumeration value=\"icon\"><xs:annotation><xs:documentation>Icon name, actually an icon\n                        style used in an 'i' element in HTML output.</xs:documentation></xs:annotation></xs:enumeration>\n                </xs:restriction>\n            </xs:simpleType>\n        </xs:attribute>\n\n        <xs:attribute name=\"require-authentication\" type=\"authc-options\" default=\"true\"/>\n        <!-- TABLED, not to be part of 1.0: <xs:attribute name=\"require-certificate\" default=\"false\" type=\"boolean\"/> -->\n        <xs:attribute name=\"begin-transaction\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>\n                Begin a transaction for the screen render if there is not one already in place.\n                Most screens don't need this, but it is useful for greater data consistency in certain cases.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"transaction-timeout\" type=\"xs:int\">\n            <xs:annotation><xs:documentation>\n                The timeout for the screen render transaction, in seconds. Defaults to global transaction timeout default (usually 60s).\n\n                This value is only used if a screen in the rendered screen path begins a transaction. If multiple screens in the\n                render path have a timeout the highest will be used.\n\n                This is not intended for interactive screen rendering as the HTTP request or gateway will generally timeout first\n                anyway but is useful for background rendered screens for large reports and such.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"include-child-content\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>\n                False by default, meaning that child content is sent to the client as they are and nothing else with\n                it. If true then the child content is included in this screen as if it were a subscreen.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"screen-theme-type-enum-id\" type=\"xs:string\" default=\"STT_INTERNAL\"/>\n        <xs:attribute name=\"track-artifact-hit\" type=\"boolean\" default=\"true\">\n            <xs:annotation><xs:documentation>If set to false no ArtifactHit or ArtifactHitBin data will be kept for\n                this screen and for any content or transitions under the screen.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"history\" type=\"boolean\" default=\"true\">\n            <xs:annotation><xs:documentation>If set to false this screen will not be saved in the screen/URL history.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"login-path\" type=\"xs:string\" default=\"/Login\">\n            <xs:annotation><xs:documentation>If specified will be used as the login screen path for this screen and\n                any subscreens, otherwise defaults to \"/Login\".</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"allow-extra-path\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>If set to true arbitrary path elements following this screen's path are allowed.\n                Default is false and an exception will be thrown if there is an extra path element that does not match a\n                subscreen, transition, or content (file) below the screen.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"render-modes\" type=\"xs:string\" default=\"all\">\n            <xs:annotation><xs:documentation>\n                The render mode(s) this screen supports. Can be any valid render mode. The default value of \"all\" will allow all\n                render modes, so specify one or more specific render modes to limit how the screen may be rendered. Default supported\n                modes include: text, html, js, vuet, xsl-fo, xml, and csv. Values can be comma separated to apply to multiple render modes.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"server-static\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>\n                The render modes where the results of this screen are always the same and the screen render results may be cached (on server and/or client).\n                This applies to all screens that are designed for client rendering when the screen is written to fully support it (filling in data with additional requests).\n                Placeholder screens are automatically server static, ie with just subscreens-panel, subscreens-active, and subscreens-menu elements.\n                Can be any valid render mode. Default supported modes include: text, html, js, vuet, xsl-fo, xml, and csv.\n                Values can be comma separated to apply to multiple render modes. A value of \"all\" will apply to all render modes.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n    </xs:complexType>\n\n    <xs:element name=\"screen\"><xs:annotation>\n        <xs:documentation>\n            The screen is the basic unit of a user interface defines how data, logic, and visual elements fit together.\n\n            Screen filenames should be camel-cased and start with an upper-case letter (whereas transitions should\n            start with a lower-case letter).\n        </xs:documentation>\n    </xs:annotation><xs:complexType><xs:complexContent><xs:extension base=\"screen-base\">\n        <!-- NOTE: screen element attributes come from screen-base that this is an extension of -->\n        <xs:sequence>\n            <xs:element ref=\"macro-template\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n            <xs:element ref=\"web-settings\" minOccurs=\"0\"/>\n            <xs:element ref=\"parameter\" minOccurs=\"0\" maxOccurs=\"unbounded\">\n                <xs:annotation><xs:documentation>\n                    These are the parameters the screen expects or allows to be passed in, and where the calling\n                    screen can get them from by default (usually just default to the same from, but can be a\n                    static value for default or whatever).\n\n                    Individual transition, transition.*-response and other elements can override where the parameter\n                    comes from with their own parameter sub-elements.\n                </xs:documentation></xs:annotation>\n            </xs:element>\n            <xs:element ref=\"always-actions\" minOccurs=\"0\"/>\n            <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n                <xs:element ref=\"transition\"/>\n                <xs:element ref=\"transition-include\"/>\n            </xs:choice>\n            <xs:element ref=\"subscreens\" minOccurs=\"0\"/>\n            <xs:element ref=\"pre-actions\" minOccurs=\"0\"/>\n            <xs:group ref=\"section-elements\"/>\n        </xs:sequence>\n    </xs:extension></xs:complexContent></xs:complexType></xs:element>\n\n    <xs:element name=\"screen-extend\"><xs:annotation>\n        <xs:documentation>\n            The root element of a file under the 'screen-extend' directory in a component using a path and filename that matches\n            the path and filename of the screen to extend. The path can match by the path under the 'screen' directory or by the\n            full path following the protocol/scheme of the location URI (ie everything after ://).\n        </xs:documentation>\n    </xs:annotation><xs:complexType><xs:complexContent><xs:extension base=\"screen-base\">\n        <!-- NOTE: screen element attributes come from screen-base that this is an extension of -->\n        <xs:sequence>\n            <xs:element ref=\"parameter\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n\n            <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n                <xs:element ref=\"transition\"/>\n                <xs:element ref=\"transition-include\"/>\n            </xs:choice>\n            <xs:element ref=\"subscreens\" minOccurs=\"0\"/>\n\n            <xs:element name=\"actions-extend\" minOccurs=\"0\" maxOccurs=\"unbounded\"><xs:complexType>\n                <xs:group minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"AllOperations\"/>\n                <xs:attribute name=\"type\" default=\"actions\"><xs:simpleType><xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"always-actions\"/>\n                    <xs:enumeration value=\"pre-actions\"/>\n                    <xs:enumeration value=\"actions\"/>\n                </xs:restriction></xs:simpleType></xs:attribute>\n                <xs:attribute name=\"when\" default=\"after\"><xs:simpleType><xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"before\"/>\n                    <xs:enumeration value=\"after\"/>\n                    <xs:enumeration value=\"replace\"/>\n                </xs:restriction></xs:simpleType></xs:attribute>\n            </xs:complexType></xs:element>\n            <xs:element name=\"widgets-extend\" minOccurs=\"0\" maxOccurs=\"unbounded\"><xs:annotation><xs:documentation>\n                This is a simple, generic way to insert widget elements into various places in existing screens.\n                Add widgets before or after all elements where name here matches the name or id attributes match.\n            </xs:documentation></xs:annotation><xs:complexType>\n                <xs:group ref=\"widget-elements\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n                <xs:attribute name=\"name\" type=\"xs:string\" use=\"required\"><xs:annotation><xs:documentation>\n                    Match against name or id attributes on elements in the base screen (being extended).\n                </xs:documentation></xs:annotation></xs:attribute>\n                <xs:attribute name=\"where\" default=\"before\"><xs:simpleType><xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"before\"/>\n                    <!-- FUTURE: semantics are more complex, vary by element: <xs:enumeration value=\"prepend\"/><xs:enumeration value=\"append\"/> -->\n                    <xs:enumeration value=\"after\"/>\n                </xs:restriction></xs:simpleType></xs:attribute>\n            </xs:complexType></xs:element>\n\n            <xs:choice minOccurs=\"0\" maxOccurs=\"unbounded\">\n                <!-- forms merged by name -->\n                <xs:element ref=\"form-single\"/>\n                <xs:element ref=\"form-list\"/>\n\n                <!-- section, section-iterate override by name -->\n                <xs:element ref=\"section\"/>\n                <xs:element ref=\"section-iterate\"/>\n                <!-- FUTURE container, container-box 'slots' like toolbar, body, body-nopad and before, prepend, append, after - or support somehow in widgets-extend? -->\n            </xs:choice>\n        </xs:sequence>\n    </xs:extension></xs:complexContent></xs:complexType></xs:element>\n\n    <xs:element name=\"macro-template\"><xs:annotation>\n        <xs:documentation>\n            A location here will override the settings in the moqui-conf.screen-facade.screen-text-output, but will be\n            overridden by a value set with the ScreenRender.macroTemplate() method.\n        </xs:documentation>\n    </xs:annotation><xs:complexType>\n        <xs:attribute name=\"type\" type=\"xs:string\" use=\"required\">\n            <xs:annotation><xs:documentation>\n                Can be anything. Default supported values include: text, html, xsl-fo, xml, and csv.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"location\" type=\"xs:string\" use=\"required\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"web-settings\"><xs:complexType>\n        <xs:attribute name=\"allow-web-request\" default=\"true\" type=\"boolean\"/>\n        <xs:attribute name=\"require-encryption\" default=\"true\" type=\"boolean\"/>\n        <xs:attribute name=\"mime-type\" type=\"xs:string\" default=\"text/html\"/>\n        <xs:attribute name=\"character-encoding\" type=\"xs:string\" default=\"UTF-8\"/>\n        <!-- TABLED, not to be part of 1.0: <xs:attribute name=\"http-no-cache\" default=\"false\" type=\"boolean\"/> -->\n        <!-- NOTE: Right now we send some cache headers, could add config for cache headers for screen,\n             static screen content, template screen content -->\n    </xs:complexType></xs:element>\n    <xs:element name=\"always-actions\"><xs:annotation>\n        <xs:documentation>These actions always run when this screen appears in a screen path, including\n            both screen rendering and transition running. One difference between this and the pre-actions element is\n            that this runs before transitions are processed while pre-actions do not. The always-actions also run for\n            all screens in a path while the pre-actions only run for screens that will be rendered.</xs:documentation>\n    </xs:annotation><xs:complexType>\n        <xs:group minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"AllOperations\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"pre-actions\"><xs:annotation>\n        <xs:documentation>These actions run before any of the screens (this screen or any parent screens)\n            are rendered, allowing you to set parameters used by parent screens or other general reasons.</xs:documentation>\n    </xs:annotation><xs:complexType>\n        <xs:group minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"AllOperations\"/>\n    </xs:complexType></xs:element>\n\n    <!-- ================ Transition ================ -->\n    <xs:element name=\"transition\"><xs:complexType>\n        <xs:sequence>\n            <xs:element ref=\"parameter\" minOccurs=\"0\" maxOccurs=\"unbounded\">\n                <xs:annotation><xs:documentation>\n                    These are the parameters the transition expects or allows to be passed in, and where the calling\n                    screen can get them from by default (usually just default to the same from, but can be a\n                    static value for default or whatever).\n\n                    These are in addition to the screen.parameter values. Individual transition.*-response and other\n                    elements can override where the parameter comes from with their own parameter sub-elements.\n                </xs:documentation></xs:annotation>\n            </xs:element>\n            <xs:element ref=\"path-parameter\" minOccurs=\"0\" maxOccurs=\"unbounded\">\n                <xs:annotation><xs:documentation>\n                    These are additional path elements after the transition's path element. The values will be added\n                    to the web parameters based on the order of these path-elements.\n                </xs:documentation></xs:annotation>\n            </xs:element>\n            <xs:element ref=\"condition\" minOccurs=\"0\">\n                <xs:annotation><xs:documentation>\n                    This condition is run wherever this transition is referenced in the screen to see if the\n                    transition is available (otherwise the button/link/etc is disabled).\n                </xs:documentation></xs:annotation>\n            </xs:element>\n            <xs:element ref=\"service-call\" minOccurs=\"0\">\n                <xs:annotation><xs:documentation>\n                    In most cases the best way to handle input for a transition is with a single service. To do\n                    that use this element instead of an actions element.\n\n                    If an actions element is also specified the actions will be run after the service-call.\n\n                    This will automatically have an in-map=true. To get the same effect inside the actions\n                    element just use in-map=true.\n                </xs:documentation></xs:annotation>\n            </xs:element>\n            <xs:element ref=\"actions\" minOccurs=\"0\">\n                <xs:annotation><xs:documentation>\n                    When this transition is followed these actions are run.\n                    If a service-call element is also used that will be run before these actions.\n\n                    After the actions are run it goes to the url that this transition goes to (through\n                    client-side redirect, dynamic update of a screen area, etc).\n                </xs:documentation></xs:annotation>\n            </xs:element>\n            <xs:element ref=\"conditional-response\" minOccurs=\"0\" maxOccurs=\"unbounded\">\n                <xs:annotation><xs:documentation>\n                    If there are multiple transition-response sub-elements the first one whose condition evaluates\n                    to true will be the one used. If no conditional responses match, the default-response will be\n                    used.\n                </xs:documentation></xs:annotation>\n            </xs:element>\n            <xs:element ref=\"default-response\">\n                <xs:annotation><xs:documentation>\n                    This response must always be defined and is the response that will be used if there is no error\n                    in the actions, and if none of the conditions in conditional responses evaluate to true.\n                </xs:documentation></xs:annotation>\n            </xs:element>\n            <xs:element ref=\"error-response\" minOccurs=\"0\">\n                <xs:annotation><xs:documentation>\n                    If there is an error in evaluating the actions on this transition then the error-response will\n                    be used and the transition-response element(s) will be ignored.\n\n                    If there are actions and there is no error-response defined then the default error response\n                    will be used.\n                </xs:documentation></xs:annotation>\n            </xs:element>\n        </xs:sequence>\n        <xs:attribute name=\"name\" type=\"name-package\" use=\"required\">\n            <xs:annotation><xs:documentation>\n                Transition names should be camel-cased and start with a lower-case letter (whereas screen filenames\n                and subscreens-item names start with a upper-case letter).\n\n                The transition name is used in link and other elements in place of URLs when going to another\n                screen within this application. The transition name will appear briefly as the URL before the\n                redirect is done for the transition response.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"method\" default=\"any\">\n            <xs:simpleType>\n                <xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"any\"/>\n                    <xs:enumeration value=\"get\"/>\n                    <xs:enumeration value=\"put\"/>\n                    <xs:enumeration value=\"post\"/>\n                    <xs:enumeration value=\"delete\"/>\n                </xs:restriction>\n            </xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"begin-transaction\" type=\"boolean\" default=\"true\">\n            <xs:annotation><xs:documentation>Begin a transaction for the screen transition action run if there is\n                not one already in place.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"read-only\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Declare that this transition does only read operations to skip the\n                check for insecure parameters.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"require-session-token\" default=\"true\" type=\"boolean\"><xs:annotation><xs:documentation>\n            If not false (default true) moquiSessionToken (from ec.web.sessionToken) must be passed to this\n            transition for all requests in a session after the first.</xs:documentation></xs:annotation></xs:attribute>\n    </xs:complexType></xs:element>\n    <xs:element name=\"path-parameter\"><xs:complexType>\n        <xs:attribute name=\"name\" type=\"name-parameter\" use=\"required\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"conditional-response\"><xs:complexType>\n        <xs:sequence>\n            <xs:element ref=\"condition\" minOccurs=\"0\"/>\n            <xs:element ref=\"parameter\" minOccurs=\"0\" maxOccurs=\"unbounded\">\n                <xs:annotation><xs:documentation>\n                    These parameters will be used when redirecting to the url or other activating of the target\n                    screen.\n\n                    Each screen has a list of expected parameters so this is only necessary when you need to\n                    override where the parameter value comes from (default defined in the parameter tag under the\n                    screen) or to pass additional parameters.\n                </xs:documentation></xs:annotation>\n            </xs:element>\n        </xs:sequence>\n        <xs:attributeGroup ref=\"attlist.response\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"default-response\"><xs:complexType>\n        <xs:sequence>\n            <xs:element ref=\"parameter\" minOccurs=\"0\" maxOccurs=\"unbounded\">\n                <xs:annotation><xs:documentation>\n                    These parameters will be used when redirecting to the url or other activating of the target\n                    screen.\n\n                    Each screen has a list of expected parameters so this is only necessary when you need to\n                    override where the parameter value comes from (default defined in the parameter tag under the\n                    screen) or to pass additional parameters.\n                </xs:documentation></xs:annotation>\n            </xs:element>\n        </xs:sequence>\n        <xs:attributeGroup ref=\"attlist.response\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"error-response\"><xs:complexType>\n        <xs:sequence>\n            <xs:element ref=\"parameter\" minOccurs=\"0\" maxOccurs=\"unbounded\">\n                <xs:annotation><xs:documentation>\n                    These parameters will be used when redirecting to the url or other activating of the target\n                    screen.\n\n                    Each screen has a list of expected parameters so this is only necessary when you need to\n                    override where the parameter value comes from (default defined in the parameter tag under the\n                    screen) or to pass additional parameters.\n                </xs:documentation></xs:annotation>\n            </xs:element>\n        </xs:sequence>\n        <xs:attributeGroup ref=\"attlist.response\"/>\n    </xs:complexType></xs:element>\n    <xs:attributeGroup name=\"attlist.response\">\n        <xs:attribute name=\"type\" default=\"url\">\n            <xs:simpleType>\n                <xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"none\"/>\n                    <xs:enumeration value=\"screen-last\">\n                        <xs:annotation><xs:documentation>\n                            Go to the screen from the last request (via screen history) unless there is a saved one\n                            from some previous request (using the save-current-screen attribute, done automatically for\n                            login). If neither available will go to the default screen (just to root with whatever\n                            defaults are setup for each subscreen).\n                        </xs:documentation></xs:annotation>\n                    </xs:enumeration>\n                    <xs:enumeration value=\"screen-last-noparam\"/>\n                    <xs:enumeration value=\"url\"/>\n                </xs:restriction>\n            </xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"url\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>\n                The URL to follow in response, based on the url-type. The default url-type is \"screen-path\" which means\n                the value here is a path from the current screen to the desired screen, transition, or sub-screen content.\n\n                Use \".\" to represent the current screen, and \"..\" to represent the parent screen on the runtime\n                screen path. The \"..\" can be used multiple times, such as \"../..\" to get to the parent screen of the\n                parent screen (the grand-parent screen).\n\n                If the screen-path type url starts with a \"/\" it will be relative to the root screen instead of relative\n                to the current screen. If it starts with a \"//\" it will be relative to the root screen and a sparse\n                path, meaning that each path item specified will be searched for under the previous and does not have\n                to be a direct subscreen.\n\n                If the url-type is \"plain\" then this can be any valid URL (relative on current domain or absolute).\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"url-type\" default=\"screen-path\">\n            <xs:simpleType>\n                <xs:restriction base=\"xs:token\">\n                    <xs:enumeration value=\"screen-path\">\n                        <xs:annotation><xs:documentation>URI to another screen, either relative or from server root.\n                            See documentation on url attribute for more details.</xs:documentation></xs:annotation>\n                    </xs:enumeration>\n                    <xs:enumeration value=\"plain\"/>\n                </xs:restriction>\n            </xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"parameter-map\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>Just like the parameter subelement can be used to specify parameters to\n                pass with the redirect.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <!-- deferred for future version, pending finding a good reason that it is needed:\n        <xs:attribute name=\"save-last-screen\" default=\"false\" type=\"boolean\">\n            <xs:annotation><xs:documentation>\n                Saves the last (previous) screen's path and parameters for future use, generally with the screen-last type of\n                response.\n            </xs:documentation></xs:annotation>\n        </xs:attribute> -->\n        <xs:attribute name=\"save-current-screen\" default=\"false\" type=\"boolean\">\n            <xs:annotation><xs:documentation>Save the current screen's path and parameters for future use, generally\n                with the screen-last type of response.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"save-parameters\" default=\"false\" type=\"boolean\">\n            <xs:annotation><xs:documentation>Save the current parameters (and request attributes) before doing a\n                redirect so that the screen rendered after the redirect renders in a context similar to the original\n                request to the transition.</xs:documentation></xs:annotation>\n        </xs:attribute>\n    </xs:attributeGroup>\n\n    <xs:element name=\"transition-include\"><xs:complexType>\n        <xs:attribute name=\"name\" type=\"name-package\" use=\"required\"/>\n        <xs:attribute name=\"location\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"method\" type=\"xs:string\"/>\n    </xs:complexType></xs:element>\n\n    <!-- ================ Subscreens ================ -->\n    <xs:element name=\"subscreens\"><xs:annotation>\n        <xs:documentation>\n            Declare subscreens for this screen. One subscreen at a time is active, based on the \"screen path\" used to\n            access this screen. The parent screen (this screen) will be the current element in the screen path and the\n            next screen path element will be the name of the subscreen of this screen to use.\n\n            If there is no additional element in the screen path or the next element is not a valid subscreen-item.name\n            then the default-item will be the active subscreen. \n\n            There are four ways to add subscreens to a screen (in order of override):\n\n            1. for screens within a single application by directory structure:\n                create a directory in the directory where the parent screen is named the same as\n                the parent screen's filename and put XML Screen files in that directory (name=filename up to .xml,\n                title=screen.default-title, location=parent screen minus filename plus directory and filename for\n                subscreen)\n            2. for including screens that are part of another application, or shared and not in any application:\n                subscreens-item elements below the screen.subscreens element (this element)\n            3. for adding screens or changing order and title of screens to an existing application:\n                screen-facade.screen and subscreens-item elements in the Moqui Conf XML file including MoquiConf.xml in\n                a component; this subscreens-item element is much like the subscreens.subscreens-item element\n            4. for adding screens, removing screens, or changing order and title of screens to an existing application:\n                a record in the moqui.screen.SubscreensItem entity\n\n            There are two visual elements (widgets) that come from the subscreens, a menu and the active subscreen.\n            Those are included with the widgets using the \"subscreens-menu\" and \"subscreens-active\" elements, or the\n            \"subscreens-panel\" element.\n        </xs:documentation>\n    </xs:annotation><xs:complexType>\n        <xs:sequence>\n            <xs:element ref=\"conditional-default\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n            <xs:element ref=\"subscreens-item\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n        </xs:sequence>\n        <xs:attribute name=\"default-item\" type=\"xs:string\"><xs:annotation><xs:documentation>\n            The name of the default subscreen-item. Used when then screen-path ends on this screen so we\n            know which subscreen-item to activate.\n\n            If empty the first subscreen-item will be the default.\n        </xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"always-use-full-path\" type=\"boolean\" default=\"false\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"conditional-default\"><xs:complexType>\n        <xs:attribute name=\"condition\" type=\"xs:string\" use=\"required\">\n            <xs:annotation><xs:documentation>Groovy condition expression (evaluates to a boolean) used to determine\n                if the specified subscreens item is the one to use by default instead of the on specified in the\n                subscreens.@default-item attribute.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"item\" type=\"xs:string\" use=\"required\">\n            <xs:annotation><xs:documentation>The subscreens item to make the default.</xs:documentation></xs:annotation>\n        </xs:attribute>\n    </xs:complexType></xs:element>\n    <xs:element name=\"subscreens-item\"><xs:annotation><xs:documentation>\n        One way to add a subscreen. This is most commonly used to refer to a subscreen that is located in another\n        application, another part of this application, that is not in any application and is meant to be shared,\n        or is in a different type of location than the parent screen.\n\n        One subscreens-item is active at a time, meaning that screen is shown and the tab/etc for that screen is\n        highlighted.\n    </xs:documentation></xs:annotation><xs:complexType>\n        <xs:attribute name=\"name\" type=\"name-plain\" use=\"required\"><xs:annotation><xs:documentation>\n            The name of the subscreens item for use in the screen path. The screen path element following the\n            one for the parent screen of the item will match on this name.\n\n            Subscreen Item names should be camel-cased and start with a upper-case letter (just like screen\n            filenames start with a upper-case letter).\n        </xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"location\" type=\"xs:string\"><xs:annotation><xs:documentation>\n            Subscreen location can include various prefixes to support including from a file, http, component,\n            or a content repository.\n\n            If empty defaults to the value of the name attribute under the current screen (in the directory\n            with the same name as the current screen), and can be a screen or sub-content.\n        </xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"menu-title\" type=\"xs:string\"/>\n        <xs:attribute name=\"menu-index\" type=\"xs:positiveInteger\"><xs:annotation><xs:documentation>\n            If specified this item will be inserted in existing list of subscreens at this index (1-based).\n            If empty this item will be added to the end of the list (after the directory load, before the\n            entity load).\n        </xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"menu-include\" type=\"boolean\" default=\"true\"/>\n        <xs:attribute name=\"no-sub-path\" type=\"boolean\" default=\"false\"><xs:annotation><xs:documentation>\n            If true the sub-screens of the sub-screen may be referenced directly under this screen, skipping the screen path element\n            for the sub-screen.\n        </xs:documentation></xs:annotation></xs:attribute>\n        <!-- no longer supported, may support again in the future (see ScreenDefinition.SubscreensItem.getDisable(), not used anywhere)\n        <xs:attribute name=\"disable-when\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>This condition is run the subscreens menu is rendered to see if the\n                item is available (otherwise the button/link/etc is disabled).</xs:documentation></xs:annotation>\n        </xs:attribute>\n        -->\n    </xs:complexType></xs:element>\n    <xs:element name=\"subscreens-menu\" substitutionGroup=\"AllWidgets\"><xs:complexType>\n        <xs:attribute name=\"type\" default=\"tab\">\n            <xs:simpleType><xs:restriction base=\"xs:token\">\n                <xs:enumeration value=\"tab\"/>\n                <xs:enumeration value=\"popup\"/>\n            </xs:restriction></xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"id\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"title\" type=\"xs:string\"/>\n        <xs:attribute name=\"width\" type=\"xs:string\"/>\n        <xs:attribute name=\"header-menus-id\" type=\"xs:string\" default=\"header-menus\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"subscreens-active\" substitutionGroup=\"AllWidgets\"><xs:complexType>\n        <xs:attribute name=\"id\" type=\"xs:string\" use=\"optional\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"subscreens-panel\" substitutionGroup=\"AllWidgets\"><xs:complexType>\n        <xs:attribute name=\"type\" default=\"tab\">\n            <xs:simpleType><xs:restriction base=\"xs:token\">\n                <xs:enumeration value=\"tab\"/>\n                <xs:enumeration value=\"popup\"/>\n                <!-- TABLED, not to be part of 1.0: <xs:enumeration value=\"stack\"/> -->\n                <!-- TABLED, not to be part of 1.0: <xs:enumeration value=\"wizard\"/> -->\n            </xs:restriction></xs:simpleType>\n        </xs:attribute>\n        <xs:attribute name=\"id\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"title\" type=\"xs:string\"/>\n        <xs:attribute name=\"header-menus-id\" type=\"xs:string\" default=\"header-menus\"/>\n    </xs:complexType></xs:element>\n\n    <!-- ================ Sections ================ -->\n    <xs:element name=\"section\" substitutionGroup=\"AllWidgets\"><xs:complexType>\n        <xs:group ref=\"section-elements\"/>\n        <xs:attribute name=\"name\" type=\"name-plain\" use=\"required\">\n            <xs:annotation><xs:documentation>A name for the section, used for reference within the screen.\n                Must be specified and must be unique within the screen.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"condition\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>A condition expression, just like the section.condition.expression\n                element but more concise.</xs:documentation></xs:annotation>\n        </xs:attribute>\n    </xs:complexType></xs:element>\n    <xs:element name=\"section-iterate\" substitutionGroup=\"AllWidgets\"><xs:complexType>\n        <xs:group ref=\"section-elements\"/>\n        <xs:attribute name=\"name\" type=\"name-plain\" use=\"required\">\n            <xs:annotation><xs:documentation>\n                A name for the section, used for external reference within the screen.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"list\" type=\"xs:string\" use=\"required\">\n            <xs:annotation><xs:documentation>\n                The name of the field that contains the list to iterate over.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"entry\" type=\"xs:string\" use=\"required\">\n            <xs:annotation><xs:documentation>\n                The name of the field that will contain each entry as we iterate through the list.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"key\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>\n                If list points to a Map or List of MapEntry the key will be put where this refers to, the value\n                where the entry attribute refers to.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"condition\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>A condition expression, just like the section-iterate.condition.expression\n                element but more concise.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"paginate\" type=\"boolean\" default=\"false\">\n            <xs:annotation><xs:documentation>Indicate if this section is paginated or not, false by default.</xs:documentation></xs:annotation>\n        </xs:attribute>\n    </xs:complexType></xs:element>\n\n    <xs:element name=\"widgets\"><xs:complexType>\n        <xs:group ref=\"widget-elements\" minOccurs=\"0\" maxOccurs=\"unbounded\"/></xs:complexType></xs:element>\n    <xs:element name=\"fail-widgets\"><xs:complexType>\n        <xs:group ref=\"widget-elements\" minOccurs=\"0\" maxOccurs=\"unbounded\"/></xs:complexType></xs:element>\n\n    <xs:element name=\"section-include\" substitutionGroup=\"AllWidgets\"><xs:complexType>\n        <xs:attribute name=\"name\" type=\"name-plain\" use=\"required\">\n            <xs:annotation><xs:documentation>\n                A name for the section, used for reference within the screen.\n                Must be specified and must be unique within the screen.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"location\" type=\"xs:string\" use=\"required\">\n            <xs:annotation><xs:documentation>\n                Location of the screen containing the section to include.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n    </xs:complexType></xs:element>\n\n    <!-- ================ Containers ================ -->\n    <xs:element name=\"container\" substitutionGroup=\"AllWidgets\"><xs:complexType>\n        <xs:group ref=\"widget-elements\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n        <xs:attribute name=\"id\" type=\"xs:string\"/>\n        <xs:attribute name=\"style\" type=\"xs:string\"/>\n        <xs:attribute name=\"type\" default=\"div\"><xs:simpleType><xs:restriction base=\"xs:token\">\n            <xs:enumeration value=\"div\"/><xs:enumeration value=\"span\"/>\n            <xs:enumeration value=\"ul\"/><xs:enumeration value=\"ol\"/><xs:enumeration value=\"li\"/>\n            <xs:enumeration value=\"dl\"/><xs:enumeration value=\"dd\"/>\n            <xs:enumeration value=\"header\"/><xs:enumeration value=\"footer\"/>\n            <xs:enumeration value=\"code\"/><xs:enumeration value=\"pre\"/>\n            <xs:enumeration value=\"hr\"/><xs:enumeration value=\"i\"/>\n        </xs:restriction></xs:simpleType></xs:attribute>\n    </xs:complexType></xs:element>\n\n    <xs:element name=\"container-box\" substitutionGroup=\"AllWidgets\"><xs:complexType>\n        <xs:sequence>\n            <xs:element name=\"box-header\"><xs:complexType>\n                <xs:group ref=\"widget-elements\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n                <xs:attribute name=\"title\" type=\"xs:string\"/>\n            </xs:complexType></xs:element>\n            <xs:element name=\"box-toolbar\" minOccurs=\"0\"><xs:complexType>\n                <xs:group ref=\"widget-elements\" minOccurs=\"0\" maxOccurs=\"unbounded\"/></xs:complexType></xs:element>\n            <xs:element name=\"box-body\" minOccurs=\"0\"><xs:complexType>\n                <xs:group ref=\"widget-elements\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n                <xs:attribute name=\"height\" type=\"xs:string\"/></xs:complexType></xs:element>\n            <xs:element name=\"box-body-nopad\" minOccurs=\"0\"><xs:complexType>\n                <xs:group ref=\"widget-elements\" minOccurs=\"0\" maxOccurs=\"unbounded\"/></xs:complexType></xs:element>\n        </xs:sequence>\n        <xs:attribute name=\"id\" type=\"xs:string\"/>\n        <xs:attribute name=\"type\" type=\"color-context\" default=\"default\"/>\n        <xs:attribute name=\"initial\" default=\"open\"><xs:simpleType><xs:restriction base=\"xs:token\">\n            <xs:enumeration value=\"open\"/><xs:enumeration value=\"closed\"/>\n            <!-- for future, save state in UserPreference: <xs:enumeration value=\"user\"/> -->\n        </xs:restriction></xs:simpleType></xs:attribute>\n    </xs:complexType></xs:element>\n\n    <xs:element name=\"container-row\" substitutionGroup=\"AllWidgets\"><xs:annotation>\n        <xs:documentation>A responsive 12-column grid row. For the concept and one possible\n            implementation see http://getbootstrap.com/css/#grid</xs:documentation>\n    </xs:annotation><xs:complexType>\n        <xs:sequence><xs:element ref=\"row-col\" maxOccurs=\"unbounded\"/></xs:sequence>\n        <xs:attribute name=\"id\" type=\"xs:string\"/>\n        <xs:attribute name=\"style\" type=\"xs:string\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"row-col\"><xs:complexType>\n        <xs:group ref=\"widget-elements\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n        <xs:attribute name=\"lg\" type=\"xs:nonNegativeInteger\"/>\n        <xs:attribute name=\"md\" type=\"xs:nonNegativeInteger\"/>\n        <xs:attribute name=\"sm\" type=\"xs:nonNegativeInteger\"/>\n        <xs:attribute name=\"xs\" type=\"xs:nonNegativeInteger\"/>\n        <xs:attribute name=\"style\" type=\"xs:string\"/>\n    </xs:complexType></xs:element>\n\n    <xs:element name=\"container-panel\" substitutionGroup=\"AllWidgets\"><xs:annotation>\n        <xs:documentation>\n            This panel can have up to five areas: header, left, center, right, footer. Only the center area is required.\n            This can be re-used within the different areas as well, usually just the center area but could be used to\n            split up even the header and footer.\n\n            If there is an id for the outer container, and each area will have an automatic id as well (with a suffix\n            of: _header, _left, _center, _right, _footer).\n        </xs:documentation>\n    </xs:annotation><xs:complexType>\n        <xs:sequence>\n            <xs:element ref=\"panel-header\" minOccurs=\"0\"/>\n            <xs:element ref=\"panel-left\" minOccurs=\"0\"/>\n            <xs:element ref=\"panel-center\"/>\n            <xs:element ref=\"panel-right\" minOccurs=\"0\"/>\n            <xs:element ref=\"panel-footer\" minOccurs=\"0\"/>\n        </xs:sequence>\n        <xs:attribute name=\"id\" type=\"xs:string\"/>\n    </xs:complexType></xs:element>\n    <xs:attributeGroup name=\"attlist.panel.header-footer\">\n        <xs:attribute name=\"closable\" default=\"true\" type=\"boolean\"/>\n        <xs:attribute name=\"resizable\" default=\"false\" type=\"boolean\"/>\n        <xs:attribute name=\"spacing\" type=\"xs:string\" default=\"5\"/>\n        <xs:attribute name=\"size\" type=\"xs:string\" default=\"auto\"/>\n        <xs:attribute name=\"size-min\" type=\"xs:float\"/>\n        <xs:attribute name=\"size-max\" type=\"xs:float\"/>\n    </xs:attributeGroup>\n    <xs:attributeGroup name=\"attlist.panel.left-right\">\n        <xs:attribute name=\"closable\" default=\"true\" type=\"boolean\"/>\n        <xs:attribute name=\"resizable\" default=\"true\" type=\"boolean\"/>\n        <xs:attribute name=\"spacing\" type=\"xs:string\" default=\"5\"/>\n        <xs:attribute name=\"size\" type=\"xs:string\" default=\"180\"/>\n        <xs:attribute name=\"size-min\" type=\"xs:float\"/>\n        <xs:attribute name=\"size-max\" type=\"xs:float\"/>\n    </xs:attributeGroup>\n    <xs:element name=\"panel-header\"><xs:complexType>\n        <xs:group ref=\"widget-elements\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n        <xs:attributeGroup ref=\"attlist.panel.header-footer\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"panel-left\"><xs:complexType>\n        <xs:group ref=\"widget-elements\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n        <xs:attributeGroup ref=\"attlist.panel.left-right\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"panel-center\"><xs:complexType>\n        <xs:group ref=\"widget-elements\" minOccurs=\"0\" maxOccurs=\"unbounded\"/></xs:complexType></xs:element>\n    <xs:element name=\"panel-right\"><xs:complexType>\n        <xs:group ref=\"widget-elements\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n        <xs:attributeGroup ref=\"attlist.panel.left-right\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"panel-footer\"><xs:complexType>\n        <xs:group ref=\"widget-elements\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n        <xs:attributeGroup ref=\"attlist.panel.header-footer\"/>\n    </xs:complexType></xs:element>\n\n    <xs:element name=\"container-dialog\" substitutionGroup=\"AllWidgets\"><xs:annotation>\n        <xs:documentation>The contents start out hidden with only a button with the button-text on it.\n            When the button is clicked on a dialog opens to show the contents.</xs:documentation>\n    </xs:annotation><xs:complexType>\n        <xs:group ref=\"widget-elements\" minOccurs=\"0\" maxOccurs=\"unbounded\"/>\n        <xs:attribute name=\"id\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"button-text\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"button-style\" type=\"xs:string\"/>\n        <xs:attribute name=\"title\" type=\"xs:string\"/>\n        <xs:attribute name=\"width\" type=\"xs:string\" default=\"760\"/>\n        <xs:attribute name=\"height\" type=\"xs:string\" default=\"600\"/>\n        <xs:attribute name=\"condition\" type=\"xs:string\"/>\n        <xs:attribute name=\"type\" type=\"color-context\" default=\"primary\"/>\n        <xs:attribute name=\"icon\" type=\"xs:string\"/>\n    </xs:complexType></xs:element>\n\n    <xs:element name=\"dynamic-dialog\" substitutionGroup=\"StandaloneFields\"><xs:annotation>\n        <xs:documentation>When the button is pressed the dialog contents are loaded from the server at\n            the given transition.</xs:documentation>\n    </xs:annotation><xs:complexType>\n        <xs:sequence><xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"parameter\"/></xs:sequence>\n        <xs:attribute name=\"id\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"button-text\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"button-style\" type=\"xs:string\"/>\n        <xs:attribute name=\"title\" type=\"xs:string\"/>\n        <xs:attribute name=\"width\" type=\"xs:string\" default=\"760\"/>\n        <xs:attribute name=\"height\" type=\"xs:string\" default=\"600\"/>\n        <xs:attribute name=\"transition\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"parameter-map\" type=\"xs:string\"><xs:annotation><xs:documentation>A Map to get parameter\n            names and values from in addition to the parameter sub-elements.</xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"condition\" type=\"xs:string\"/>\n        <xs:attribute name=\"type\" type=\"color-context\" default=\"primary\"/>\n        <xs:attribute name=\"icon\" type=\"xs:string\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"dynamic-container\" substitutionGroup=\"StandaloneFields\"><xs:annotation>\n        <xs:documentation>Container contents are immediately loaded from the server at the given\n            transition. Contents may be reloaded based on other actions such as background form submissions.</xs:documentation>\n    </xs:annotation><xs:complexType>\n        <xs:sequence><xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"parameter\"/></xs:sequence>\n        <xs:attribute name=\"id\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"transition\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"parameter-map\" type=\"xs:string\"><xs:annotation><xs:documentation>A Map to get parameter\n            names and values from in addition to the parameter sub-elements.</xs:documentation></xs:annotation></xs:attribute>\n    </xs:complexType></xs:element>\n\n    <xs:element name=\"button-menu\" substitutionGroup=\"StandaloneFields\"><xs:complexType>\n        <xs:sequence>\n            <xs:choice maxOccurs=\"unbounded\">\n                <xs:element ref=\"link\"/>\n                <xs:element ref=\"label\"/>\n                <xs:element ref=\"dynamic-dialog\"/>\n                <xs:element ref=\"container-dialog\"/>\n            </xs:choice>\n        </xs:sequence>\n        <xs:attribute name=\"text\" type=\"xs:string\"/>\n        <xs:attribute name=\"text-map\" type=\"xs:string\">\n            <xs:annotation><xs:documentation>An expression that evaluates to a Map in the context that will be used\n                in addition to the context when expanding the @text value.</xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"encode\" type=\"boolean\" default=\"true\">\n            <xs:annotation><xs:documentation>\n                If true text will be encoded so that it does not interfere with markup of the target output.\n\n                For example, if output is HTML then data presented will be HTML encoded so that all\n                HTML-specific characters are escaped.\n            </xs:documentation></xs:annotation>\n        </xs:attribute>\n        <xs:attribute name=\"icon\" type=\"xs:string\"><xs:annotation>\n            <xs:documentation>Icon name, actually an icon style used in an 'i' element in HTML output.</xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"badge\" type=\"xs:string\"><xs:annotation>\n            <xs:documentation>Text to put in a badge on the right side</xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute name=\"tooltip\" type=\"xs:string\"/>\n        <xs:attribute name=\"btn-type\" type=\"color-context\" default=\"primary\"/>\n        <xs:attribute name=\"condition\" type=\"xs:string\"><xs:annotation><xs:documentation>\n            If specified and evaluates to false link is not rendered.</xs:documentation></xs:annotation></xs:attribute>\n    </xs:complexType></xs:element>\n\n\n    <!-- ================ Includes ================ -->\n    <!-- possible good idea, but not implementing in 1.0; should use render-mode.text instead\n    <xs:element name=\"include-content\" substitutionGroup=\"AllWidgets\">\n        <xs:complexType>\n            <xs:attribute name=\"content-path\" type=\"xs:string\"/>\n            <xs:attribute name=\"sub-content-key\" type=\"xs:string\" use=\"optional\"/>\n            <xs:attribute name=\"xml-escape\" default=\"false\" type=\"boolean\"/>\n        </xs:complexType>\n    </xs:element>\n    -->\n    <xs:element name=\"include-screen\" substitutionGroup=\"AllWidgets\"><xs:complexType>\n        <xs:attribute name=\"location\" type=\"xs:string\" use=\"required\"/>\n        <xs:attribute name=\"share-scope\" default=\"false\" type=\"boolean\"/>\n    </xs:complexType></xs:element>\n\n    <!-- ============== Tree ============== -->\n    <!-- Based on jstree, see http://www.jstree.com/docs/json/ -->\n    <xs:element name=\"tree\" substitutionGroup=\"AllWidgets\"><xs:complexType>\n        <xs:sequence>\n            <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"parameter\"><xs:annotation><xs:documentation>\n                The parameters are for remote request of sub-nodes for a node when lazy loading and are in the\n                context of the initial rendering of the tree in the screen.</xs:documentation></xs:annotation></xs:element>\n            <xs:element maxOccurs=\"unbounded\" ref=\"tree-node\"/>\n            <xs:element maxOccurs=\"unbounded\" ref=\"tree-sub-node\"/>\n        </xs:sequence>\n        <xs:attribute type=\"xs:string\" name=\"name\" use=\"required\"/>\n        <xs:attribute type=\"xs:string\" name=\"transition\">\n            <xs:annotation><xs:documentation>If not specified uses actions/${tree.@name}</xs:documentation></xs:annotation></xs:attribute>\n        <xs:attribute type=\"xs:string\" name=\"open-path\" use=\"optional\"/>\n        <!-- <xs:attribute type=\"xs:string\" name=\"open-depth\" default=\"0\"/> -->\n    </xs:complexType></xs:element>\n    <xs:element name=\"tree-node\"><xs:complexType>\n        <xs:sequence>\n            <xs:element minOccurs=\"0\" ref=\"condition\"/>\n            <xs:element minOccurs=\"0\" ref=\"actions\"/>\n            <xs:choice>\n                <xs:element ref=\"link\"/>\n                <xs:element ref=\"label\"/>\n            </xs:choice>\n            <xs:element minOccurs=\"0\" maxOccurs=\"unbounded\" ref=\"tree-sub-node\"/>\n        </xs:sequence>\n        <xs:attribute type=\"xs:string\" name=\"name\" use=\"required\"/>\n    </xs:complexType></xs:element>\n    <xs:element name=\"tree-sub-node\"><xs:complexType>\n        <xs:sequence>\n            <xs:element minOccurs=\"0\" ref=\"actions\"/>\n        </xs:sequence>\n        <xs:attribute type=\"xs:string\" name=\"node-name\" use=\"required\"/>\n        <xs:attribute type=\"xs:string\" name=\"list\" use=\"required\"/>\n    </xs:complexType></xs:element>\n\n    <!-- ============== Render Mode Elements =============== -->\n    <xs:element name=\"render-mode\" substitutionGroup=\"AllWidgets\"><xs:complexType>\n        <xs:choice maxOccurs=\"unbounded\">\n            <xs:element ref=\"text\"/>\n            <!-- the gwt and swing elements are just placeholders for now, won't be implemented in this version -->\n            <!-- <xs:element ref=\"gwt\"/> -->\n            <!-- <xs:element ref=\"swing\"/> -->\n        </xs:choice>\n    </xs:complexType></xs:element>\n    \n    <!-- ============== Text Specific Elements =============== -->\n    <xs:element name=\"text\" substitutionGroup=\"AllWidgets\"><xs:complexType>\n        <xs:simpleContent>\n            <xs:extension base=\"xs:string\">\n                <xs:attribute name=\"type\" type=\"xs:string\" default=\"any\">\n                    <xs:annotation><xs:documentation>\n                        Can be any valid render mode. Default supported modes include: text, html, vuet, xsl-fo, xml, and csv.\n                        Values can be comma separated to apply to multiple render modes.\n                        A value of \"any\" will cause it to be used if no other element matches the current output type.\n                    </xs:documentation></xs:annotation>\n                </xs:attribute>\n                <xs:attribute name=\"location\" type=\"xs:string\">\n                    <xs:annotation><xs:documentation>\n                        This is the template or text file location and can be any location supported by the Resource\n                        Facade including file, http, component, content, etc.\n                    </xs:documentation></xs:annotation>\n                </xs:attribute>\n                <xs:attribute name=\"template\" type=\"boolean\" default=\"true\">\n                    <xs:annotation><xs:documentation>\n                        Interpret the text at the location as an FTL or other template?\n                        Supports any template type supported by the Resource Facade.\n                        Defaults to true, set to false if you want the text included literally.\n                    </xs:documentation></xs:annotation>\n                </xs:attribute>\n                <xs:attribute name=\"encode\" default=\"false\" type=\"boolean\">\n                    <xs:annotation><xs:documentation>\n                        If true the text will be encoded so that it does not interfere with markup of the target output.\n                        Templates ignore this setting and are never encoded.\n\n                        For example, if output is HTML then data presented will be HTML encoded so that all\n                        HTML-specific characters are escaped.\n                    </xs:documentation></xs:annotation>\n                </xs:attribute>\n                <xs:attribute name=\"no-boundary-comment\" type=\"boolean\" default=\"false\">\n                    <xs:annotation><xs:documentation>\n                        Defaults to false. If true won't ever put boundary comments before this (for opening ?xml tag, etc).\n                    </xs:documentation></xs:annotation>\n                </xs:attribute>\n            </xs:extension>\n        </xs:simpleContent>\n    </xs:complexType></xs:element>\n</xs:schema>\n"
  },
  {
    "path": "gradle/wrapper/gradle-wrapper.properties",
    "content": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-9.2.0-bin.zip\nnetworkTimeout=10000\nvalidateDistributionUrl=true\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n"
  },
  {
    "path": "gradle.properties",
    "content": "# for options see https://docs.gradle.org/5.6.4/userguide/build_environment.html#sec:gradle_configuration_properties\norg.gradle.warning.mode=none\norg.gradle.configuration-cache=false\n"
  },
  {
    "path": "gradlew",
    "content": "#!/bin/sh\n\n#\n# Copyright © 2015 the original authors.\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this file except in compliance with the License.\n# You may obtain a copy of the License at\n#\n#      https://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n# SPDX-License-Identifier: Apache-2.0\n#\n\n##############################################################################\n#\n#   Gradle start up script for POSIX generated by Gradle.\n#\n#   Important for running:\n#\n#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is\n#       noncompliant, but you have some other compliant shell such as ksh or\n#       bash, then to run this script, type that shell name before the whole\n#       command line, like:\n#\n#           ksh Gradle\n#\n#       Busybox and similar reduced shells will NOT work, because this script\n#       requires all of these POSIX shell features:\n#         * functions;\n#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,\n#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;\n#         * compound commands having a testable exit status, especially «case»;\n#         * various built-in commands including «command», «set», and «ulimit».\n#\n#   Important for patching:\n#\n#   (2) This script targets any POSIX shell, so it avoids extensions provided\n#       by Bash, Ksh, etc; in particular arrays are avoided.\n#\n#       The \"traditional\" practice of packing multiple parameters into a\n#       space-separated string is a well documented source of bugs and security\n#       problems, so this is (mostly) avoided, by progressively accumulating\n#       options in \"$@\", and eventually passing that to Java.\n#\n#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,\n#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;\n#       see the in-line comments for details.\n#\n#       There are tweaks for specific operating systems such as AIX, CygWin,\n#       Darwin, MinGW, and NonStop.\n#\n#   (3) This script is generated from the Groovy template\n#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt\n#       within the Gradle project.\n#\n#       You can find Gradle at https://github.com/gradle/gradle/.\n#\n##############################################################################\n\n# Attempt to set APP_HOME\n\n# Resolve links: $0 may be a link\napp_path=$0\n\n# Need this for daisy-chained symlinks.\nwhile\n    APP_HOME=${app_path%\"${app_path##*/}\"}  # leaves a trailing /; empty if no leading path\n    [ -h \"$app_path\" ]\ndo\n    ls=$( ls -ld \"$app_path\" )\n    link=${ls#*' -> '}\n    case $link in             #(\n      /*)   app_path=$link ;; #(\n      *)    app_path=$APP_HOME$link ;;\n    esac\ndone\n\n# This is normally unused\n# shellcheck disable=SC2034\nAPP_BASE_NAME=${0##*/}\n# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)\nAPP_HOME=$( cd -P \"${APP_HOME:-./}\" > /dev/null && printf '%s\\n' \"$PWD\" ) || exit\n\n# Use the maximum available, or set MAX_FD != -1 to use that value.\nMAX_FD=maximum\n\nwarn () {\n    echo \"$*\"\n} >&2\n\ndie () {\n    echo\n    echo \"$*\"\n    echo\n    exit 1\n} >&2\n\n# OS specific support (must be 'true' or 'false').\ncygwin=false\nmsys=false\ndarwin=false\nnonstop=false\ncase \"$( uname )\" in                #(\n  CYGWIN* )         cygwin=true  ;; #(\n  Darwin* )         darwin=true  ;; #(\n  MSYS* | MINGW* )  msys=true    ;; #(\n  NONSTOP* )        nonstop=true ;;\nesac\n\n\n\n# Determine the Java command to use to start the JVM.\nif [ -n \"$JAVA_HOME\" ] ; then\n    if [ -x \"$JAVA_HOME/jre/sh/java\" ] ; then\n        # IBM's JDK on AIX uses strange locations for the executables\n        JAVACMD=$JAVA_HOME/jre/sh/java\n    else\n        JAVACMD=$JAVA_HOME/bin/java\n    fi\n    if [ ! -x \"$JAVACMD\" ] ; then\n        die \"ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nelse\n    JAVACMD=java\n    if ! command -v java >/dev/null 2>&1\n    then\n        die \"ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.\n\nPlease set the JAVA_HOME variable in your environment to match the\nlocation of your Java installation.\"\n    fi\nfi\n\n# Increase the maximum file descriptors if we can.\nif ! \"$cygwin\" && ! \"$darwin\" && ! \"$nonstop\" ; then\n    case $MAX_FD in #(\n      max*)\n        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        MAX_FD=$( ulimit -H -n ) ||\n            warn \"Could not query maximum file descriptor limit\"\n    esac\n    case $MAX_FD in  #(\n      '' | soft) :;; #(\n      *)\n        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.\n        # shellcheck disable=SC2039,SC3045\n        ulimit -n \"$MAX_FD\" ||\n            warn \"Could not set maximum file descriptor limit to $MAX_FD\"\n    esac\nfi\n\n# Collect all arguments for the java command, stacking in reverse order:\n#   * args from the command line\n#   * the main class name\n#   * -classpath\n#   * -D...appname settings\n#   * --module-path (only if needed)\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.\n\n# For Cygwin or MSYS, switch paths to Windows format before running java\nif \"$cygwin\" || \"$msys\" ; then\n    APP_HOME=$( cygpath --path --mixed \"$APP_HOME\" )\n\n    JAVACMD=$( cygpath --unix \"$JAVACMD\" )\n\n    # Now convert the arguments - kludge to limit ourselves to /bin/sh\n    for arg do\n        if\n            case $arg in                                #(\n              -*)   false ;;                            # don't mess with options #(\n              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath\n                    [ -e \"$t\" ] ;;                      #(\n              *)    false ;;\n            esac\n        then\n            arg=$( cygpath --path --ignore --mixed \"$arg\" )\n        fi\n        # Roll the args list around exactly as many times as the number of\n        # args, so each arg winds up back in the position where it started, but\n        # possibly modified.\n        #\n        # NB: a `for` loop captures its iteration list before it begins, so\n        # changing the positional parameters here affects neither the number of\n        # iterations, nor the values presented in `arg`.\n        shift                   # remove old arg\n        set -- \"$@\" \"$arg\"      # push replacement arg\n    done\nfi\n\n\n# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\nDEFAULT_JVM_OPTS='\"-Xmx64m\" \"-Xms64m\"'\n\n# Collect all arguments for the java command:\n#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,\n#     and any embedded shellness will be escaped.\n#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be\n#     treated as '${Hostname}' itself on the command line.\n\nset -- \\\n        \"-Dorg.gradle.appname=$APP_BASE_NAME\" \\\n        -jar \"$APP_HOME/gradle/wrapper/gradle-wrapper.jar\" \\\n        \"$@\"\n\n# Stop when \"xargs\" is not available.\nif ! command -v xargs >/dev/null 2>&1\nthen\n    die \"xargs is not available\"\nfi\n\n# Use \"xargs\" to parse quoted args.\n#\n# With -n1 it outputs one arg per line, with the quotes and backslashes removed.\n#\n# In Bash we could simply go:\n#\n#   readarray ARGS < <( xargs -n1 <<<\"$var\" ) &&\n#   set -- \"${ARGS[@]}\" \"$@\"\n#\n# but POSIX shell has neither arrays nor command substitution, so instead we\n# post-process each arg (as a line of input to sed) to backslash-escape any\n# character that might be a shell metacharacter, then use eval to reverse\n# that process (while maintaining the separation between arguments), and wrap\n# the whole thing up as a single \"set\" statement.\n#\n# This will of course break if any of these variables contains a newline or\n# an unmatched quote.\n#\n\neval \"set -- $(\n        printf '%s\\n' \"$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS\" |\n        xargs -n1 |\n        sed ' s~[^-[:alnum:]+,./:=@_]~\\\\&~g; ' |\n        tr '\\n' ' '\n    )\" '\"$@\"'\n\nexec \"$JAVACMD\" \"$@\"\n"
  },
  {
    "path": "gradlew.bat",
    "content": "@rem\r\n@rem Copyright 2015 the original author or authors.\r\n@rem\r\n@rem Licensed under the Apache License, Version 2.0 (the \"License\");\r\n@rem you may not use this file except in compliance with the License.\r\n@rem You may obtain a copy of the License at\r\n@rem\r\n@rem      https://www.apache.org/licenses/LICENSE-2.0\r\n@rem\r\n@rem Unless required by applicable law or agreed to in writing, software\r\n@rem distributed under the License is distributed on an \"AS IS\" BASIS,\r\n@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n@rem See the License for the specific language governing permissions and\r\n@rem limitations under the License.\r\n@rem\r\n@rem SPDX-License-Identifier: Apache-2.0\r\n@rem\r\n\r\n@if \"%DEBUG%\"==\"\" @echo off\r\n@rem ##########################################################################\r\n@rem\r\n@rem  Gradle startup script for Windows\r\n@rem\r\n@rem ##########################################################################\r\n\r\n@rem Set local scope for the variables with windows NT shell\r\nif \"%OS%\"==\"Windows_NT\" setlocal\r\n\r\nset DIRNAME=%~dp0\r\nif \"%DIRNAME%\"==\"\" set DIRNAME=.\r\n@rem This is normally unused\r\nset APP_BASE_NAME=%~n0\r\nset APP_HOME=%DIRNAME%\r\n\r\n@rem Resolve any \".\" and \"..\" in APP_HOME to make it shorter.\r\nfor %%i in (\"%APP_HOME%\") do set APP_HOME=%%~fi\r\n\r\n@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.\r\nset DEFAULT_JVM_OPTS=\"-Xmx64m\" \"-Xms64m\"\r\n\r\n@rem Find java.exe\r\nif defined JAVA_HOME goto findJavaFromJavaHome\r\n\r\nset JAVA_EXE=java.exe\r\n%JAVA_EXE% -version >NUL 2>&1\r\nif %ERRORLEVEL% equ 0 goto execute\r\n\r\necho. 1>&2\r\necho ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2\r\necho. 1>&2\r\necho Please set the JAVA_HOME variable in your environment to match the 1>&2\r\necho location of your Java installation. 1>&2\r\n\r\ngoto fail\r\n\r\n:findJavaFromJavaHome\r\nset JAVA_HOME=%JAVA_HOME:\"=%\r\nset JAVA_EXE=%JAVA_HOME%/bin/java.exe\r\n\r\nif exist \"%JAVA_EXE%\" goto execute\r\n\r\necho. 1>&2\r\necho ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2\r\necho. 1>&2\r\necho Please set the JAVA_HOME variable in your environment to match the 1>&2\r\necho location of your Java installation. 1>&2\r\n\r\ngoto fail\r\n\r\n:execute\r\n@rem Setup the command line\r\n\r\n\r\n\r\n@rem Execute Gradle\r\n\"%JAVA_EXE%\" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% \"-Dorg.gradle.appname=%APP_BASE_NAME%\" -jar \"%APP_HOME%\\gradle\\wrapper\\gradle-wrapper.jar\" %*\r\n\r\n:end\r\n@rem End local scope for the variables with windows NT shell\r\nif %ERRORLEVEL% equ 0 goto mainEnd\r\n\r\n:fail\r\nrem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of\r\nrem the _cmd.exe /c_ return code!\r\nset EXIT_CODE=%ERRORLEVEL%\r\nif %EXIT_CODE% equ 0 set EXIT_CODE=1\r\nif not \"\"==\"%GRADLE_EXIT_CONSOLE%\" exit %EXIT_CODE%\r\nexit /b %EXIT_CODE%\r\n\r\n:mainEnd\r\nif \"%OS%\"==\"Windows_NT\" endlocal\r\n\r\n:omega\r\n"
  },
  {
    "path": "settings.gradle",
    "content": "\nString[] getDirectoryProjects(String relativePath) {\n    File runtimeDir = file('runtime')\n    if (!runtimeDir.exists()) return new String[0]\n    File compsDir = file('runtime/' + relativePath)\n    if (!compsDir.exists()) return new String[0]\n    return compsDir.listFiles().findAll { it.isDirectory() && it.listFiles().find { it.name == 'build.gradle' } }\n            .collect { \"runtime:${relativePath}:${it.getName()}\" } as String[]\n}\n\ninclude 'framework'\ninclude getDirectoryProjects('base-component')\ninclude getDirectoryProjects('mantle')\ninclude getDirectoryProjects('component')\ninclude getDirectoryProjects('ecomponent')\n"
  }
]