diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff6a1fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/data/drawio + +/src/__phutil_library_init__.php +/src/__phutil_library_map__.php diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b26de0 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +draw.io integration in phorge +============================= + +Installation +============ +1) Extract the content of this repository into /src/extensions +2) /bin/arc liberate +3) /bin/storage upgrade +4) CD to /src/extensions/drawio/data +5) git clone https://github.com/jgraph/drawio.git +6) Diagrams application is available under "More Applications" in Phorge. + You may add it to your navigator menu via "Edit Menu" + +You may need to set the following rule in your httpd.conf for your Phorge's VirtualHost: + + Header set Content-Security-Policy "default-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data:; style-src 'self' 'unsafe-inline';" + + +Usage +===== +When you start the Diagrams application, you will see the embedded version of [https://app.diagrams.net/](draw.io). +You can reference the diagrams in your wiki pages or maniphest tasks by means of the DIAG token. +E.g. {DIAG123} +When you doubleclick on a diagram, the editor will open the corresponding diagram in new browser tab. + +You can create diagrams with multiple pages, but only the first one will be visualized. + +When you modify an existing diagram, a new version will be created. +You can select older versions in the editor by means of the dropdown in the topright corner. +If you don't see a dropdown, your diagram has only 1 version. + +Extra info +========== +[https://github.com/jgraph/drawio-integration] + diff --git a/data/phorge_extension.js b/data/phorge_extension.js new file mode 100644 index 0000000..d2e417b --- /dev/null +++ b/data/phorge_extension.js @@ -0,0 +1,429 @@ +var phorge_extension = {}; + +function copySettingsToIframe() { + var iframe = document.querySelector('iframe'); + const script = document.createElement('script'); + script.textContent = ` + const phorge_extension = JSON.parse('` + JSON.stringify(phorge_extension) + `'); + `; + iframe.contentDocument.body.appendChild(script); + iframe.contentDocument.body.wawa = 1; +} + +function edit(image) { + var iframe = document.createElement('iframe'); + iframe.setAttribute('title', 'diagrams.net editor'); + iframe.setAttribute('frameborder', '0'); + iframe.style.width = "100%"; + iframe.style.height = "calc(100vh - 91px)"; + iframe.style.marginTop = "30px"; + iframe.style.marginBottom = "-16px"; + image.style.display = 'none'; + + var receive = function (evt) { + if (evt.data.length > 0) { + var msg = JSON.parse(evt.data); + if (msg.event == 'init') { + if (phorge_extension.diagramBase64) { + image.src = 'data:image/png;base64,' + phorge_extension.diagramBase64; + + var diagramName = document.querySelector('.diagramName'); + diagramName.style.display = 'inline-block'; + diagramName.querySelector('a').innerText = phorge_extension.diagramName; + if (phorge_extension.diagramVersion != '') { + diagramName.querySelector('.version').innerText = '(#' + + phorge_extension.diagramVersion + + ')'; + } + } + + iframe.contentWindow.postMessage(JSON.stringify({ + action: 'load', + autosave: 1, + xmlpng: image.getAttribute('src') + }), '*'); + } + else if (msg.event == 'load') { + // enable Mathematical Typesettings by default + iframe.contentWindow.sb.editorUi.setMathEnabled(true); + + copySettingsToIframe(); + + setupButtonsInMenuToolbar(); + + loadingtext.style.display = 'none'; + } + else if (msg.event == 'export') { + saveFlowchart(name, msg.data, iframe); + } + else if (msg.event == 'exit') { + var originURL = sessionStorage['originURL']; + if (originURL) { + sessionStorage.removeItem('originURL'); + document.location.href = originURL; + } + } + else if (msg.event == 'save') { + iframe.contentWindow.postMessage(JSON.stringify({ + action: 'export', + format: 'xmlpng', + xml: msg.xml, + spin: 'Updating page' + }), '*'); + } + } + }; + + window.addEventListener('message', receive); + iframe.setAttribute('src', phorge_extension.editor); + document.querySelector("#mainScreen").appendChild(iframe); + iframe.contentWindow.RESOURCES_PATH = document.baseURI + "iframe/resources"; + iframe.contentWindow.STENCIL_PATH = document.baseURI + "iframe/stencils"; + iframe.contentWindow.IMAGE_PATH = document.baseURI + "iframe/images"; + iframe.contentWindow.STYLE_PATH = document.baseURI + "iframe/styles"; + iframe.contentWindow.CSS_PATH = document.baseURI + "iframe/styles"; +}; + +function loadJsExtension(diagramName, diagramVersion, diagramBase64) { + var baseURI = document.baseURI; + if (diagramName != '' && diagramBase64 != '') { + baseURI = baseURI.substr(0, baseURI.length - diagramName.length - 1); + + if (diagramVersion != '') { + baseURI = baseURI.substr(0, baseURI.length - diagramVersion.length - 1); + } + } + phorge_extension.baseURI = baseURI; + phorge_extension.editor = baseURI + '/iframe/?embed=1&spin=1&proto=json&noExitBtn=1'; + phorge_extension.name = null; + phorge_extension.editor += '&lang=en'; + phorge_extension.editor += '&ui=min'; + phorge_extension.diagramName = diagramName; + phorge_extension.diagramVersion = diagramVersion; + phorge_extension.diagramBase64 = diagramBase64; + + document.addEventListener('DOMContentLoaded', function () { + edit(document.querySelector('img.drawio')); + }, false); +} + +function saveFlowchart(name, flowchartData, iframe) { + var diagramID = document.querySelector(".diagramName a").innerText.replace(/^DIAG/, ""); + var csrf = document.querySelector('input[name="__csrf__"]')?.value; + var data = new URLSearchParams(); + data.append('data', flowchartData); + data.append('diagramID', diagramID); + data.append('__csrf__', csrf); + data.append('__form__', '1'); + + var xmlhttp = new XMLHttpRequest(); + xmlhttp.overrideMimeType("application/json"); + xmlhttp.open('POST', "save/", true); + xmlhttp.onload = function () { + if (xmlhttp.readyState == 4) { + var errorMessage = null; + try { + var result = JSON.parse(xmlhttp.responseText); + if (result.Status != "OK") { + errorMessage = result.Error; + } else { + // make sure we don't show messagebox about redirection in browser + iframe.parentNode.removeChild(iframe); + + if (!phorge_extension.diagramVersion || !phorge_extension.diagramVersion.trim()) { + if (!phorge_extension.diagramName || !phorge_extension.diagramName.trim()) { + // load new diagram + window.location = window.location + "/DIAG" + result.DiagramID; + } else { + // reload actual page (so versioned diagrams info is also updated) + window.location.reload(); + } + } else { + // cut off version id from url + var url = document.baseURI + .substring(0, + document.baseURI + .length + - phorge_extension.diagramVersion + .length + - 1 + ) + // redirect to latest version of diagram + window.location = url; + } + } + } catch (exc) { + errorMessage = exc.message; + } + } + }; + + xmlhttp.setRequestHeader('Content-type', 'application/x-www-form-urlencoded;'); + xmlhttp.send(data); +} + +function setupButtonsInMenuToolbar() { + var iframe = document.querySelector('iframe'); + var btnSave = Array.prototype.slice.call( + iframe.contentDocument + .querySelector('.geMenubarContainer') + .querySelectorAll('button'), 0 + ).reverse()[0]; + + // identify Exit Button + btnSave.classList.add('btnSave'); + + // generate 'Select Version' button + // create the dropdown element + const dropdown = document.createElement('div'); + dropdown.classList.add('dropdown'); + + // create the dropdown toggle button + const toggle = document.createElement('button'); + toggle.classList.add('dropdown-toggle'); + toggle.textContent = 'Select Version'; + dropdown.appendChild(toggle); + + // create the dropdown menu + const menu = document.createElement('div'); + menu.classList.add('dropdown-menu'); + dropdown.appendChild(menu); + + // create the version list + const versionList = document.createElement('ul'); + versionList.classList.add('version-list'); + + menu.appendChild(versionList); + + // create the prev and next buttons + const prevBtn = document.createElement('button'); + prevBtn.classList.add('prev-btn'); + prevBtn.textContent = '<'; + + const nextBtn = document.createElement('button'); + nextBtn.classList.add('next-btn'); + nextBtn.textContent = '>'; + + menu.appendChild(prevBtn); + menu.appendChild(nextBtn); + + // place dropdown next to btnSave + btnSave.parentNode.insertBefore(dropdown, btnSave.nextSibling); + + // add the corresponding CSS + const style = document.createElement('style'); + style.textContent = ` + .dropdown { + position: fixed; + right: 0; + white-space: nowrap; + } + + .hasversions .btnSave { + margin-right: 104px; + } + + .dropdown { + display: none; + font-family: -apple-system, BlinkMacSystemFont, + "Segoe UI Variable", "Segoe UI", system-ui, + ui-sans-serif, Helvetica, Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji"; + } + + .hasversions .dropdown { + display: block; + } + + .dropdown-toggle { + background: #eee; + border: 1px solid #d8d8d8; + border-radius: 4px; + margin-left: 8px; + margin-right: 4px; + padding: 6px; + } + + .dropdown.open { + z-index: 99999; + } + + .dropdown.open .dropdown-toggle { + box-shadow: inset 1px 1px 3px #0008; + } + + .dropdown-toggle:hover:not([disabled]) { + background: #e5e5e5; + } + + .dropdown-menu { + display: none; + position: absolute; + top: 100%; + right: 0; + left: auto; + margin-top: -2px; + padding: 10px; + border: solid 1px #444; + background: #eee; + border-radius: 5px 0px 0px 5px; + } + + .dropdown.open .dropdown-menu { + display: block; + z-index: 3; + } + + .dropdown .dropdown-menu .menu-item { + padding: 2px; + } + + .dropdown .dropdown-menu .menu-item a { + line-height: 20px; + } + + .dropdown .dropdown-menu .menu-item:hover { + background: #29b6f2; + color: #fff; + margin-left: -2px; + margin-right: 2px; + } + + .dropdown .dropdown-menu .menu-item:hover a { + color: #fff; + } + + .version-list { + list-style: none; + margin: 0; + padding: 0; + margin-bottom: 8px; + } +`; + iframe.contentDocument.head.appendChild(style); + + // add the corresponding javascript + const script = document.createElement('script'); + script.textContent = ` + const dropdown = document.querySelector('.dropdown'); + const toggle = document.querySelector('.dropdown-toggle'); + const menu = document.querySelector('.dropdown-menu'); + const prevBtn = document.querySelector('.prev-btn'); + const nextBtn = document.querySelector('.next-btn'); + const versionList = document.querySelector('.version-list'); + + let currentPage = 1; + const itemsPerPage = 5; + + function goToPhorgeUrl(url) { + parent.location.href = url; + } + + function requestVersionInfo(page) { + var xmlhttp = new XMLHttpRequest(); + var url = phorge_extension.baseURI + + '/version/' + + phorge_extension.diagramName + + '/' + + page; + + xmlhttp.onreadystatechange = function () { + if (this.readyState == 4 && this.status == 200) { + try { + var json = JSON.parse(this.responseText); + if (page == 1 && json.data.length > 1) { + document.body.classList.add('hasversions'); + } + + // overwrite version info dropdown + var versionList = document.querySelector('.version-list'); + versionList.innerHTML = ''; + json.data.forEach(function(v) { + var anchor = document.createElement('a'); + anchor.innerText = '#' + + v.id + + ': ' + + v.datetime + + ' (' + + v.author + + ')'; + + var versionedUrl = phorge_extension.baseURI + '/' + + phorge_extension.diagramName ; + if (page != 1 || v.id != json.data[0].id) { + // do not add version to anchor if we have the latest version + versionedUrl += '/' + v.id; + } + + anchor.href = 'javascript:goToPhorgeUrl("' + versionedUrl + '")'; + var listitem = document.createElement('li'); + listitem.classList.add('menu-item') + listitem.appendChild(anchor); + versionList.appendChild(listitem); + + versionList.dataset.pagecount = json.pagecount; + + if (json.nopager) { + prevBtn.style.display = "none"; + nextBtn.style.display = "none"; + } else { + prevBtn.style.display = "inline-block"; + nextBtn.style.display = "inline-block"; + } + }) + } + catch { + } + } + }; + xmlhttp.open("GET", url, true); + xmlhttp.send(); + } + + if (phorge_extension.diagramName != "") { + // update the version list when the page loads + requestVersionInfo(1); + } + + toggle.addEventListener('click', () => { + dropdown.classList.toggle('open'); + }); + + prevBtn.addEventListener('click', (event) => { + event.stopPropagation(); + + var versionList = document.querySelector('.version-list'); + var pagecount = parseInt(versionList.dataset.pagecount); + + if (currentPage > 1) { + currentPage--; + requestVersionInfo(currentPage); + } + + prevBtn.disabled = (currentPage <= 1); + nextBtn.disabled = (currentPage >= pagecount); + }); + + nextBtn.addEventListener('click', (event) => { + event.stopPropagation(); + + var versionList = document.querySelector('.version-list'); + var pagecount = parseInt(versionList.dataset.pagecount); + + if (currentPage <= pagecount) { + currentPage++; + requestVersionInfo(currentPage); + } + + prevBtn.disabled = (currentPage <= 1); + nextBtn.disabled = (currentPage >= pagecount); + }); + + document.addEventListener('click', (event) => { + if (!dropdown.contains(event.target)) { + dropdown.classList.remove('open'); + } + }); + `; + iframe.contentDocument.body.appendChild(script); +} \ No newline at end of file diff --git a/resources/sql/20230626.DiagramCreateTables.sql b/resources/sql/20230626.DiagramCreateTables.sql new file mode 100644 index 0000000..a0653e5 --- /dev/null +++ b/resources/sql/20230626.DiagramCreateTables.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS {$NAMESPACE}_diagram.diagram ( + id int(10) unsigned NOT NULL AUTO_INCREMENT, + + PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS {$NAMESPACE}_diagram.diagram_version ( + id int(10) unsigned NOT NULL AUTO_INCREMENT, + diagramID int(10) unsigned NOT NULL, + version int(10) unsigned NOT NULL, + phid varbinary(64) NOT NULL, + authorPHID varbinary(64) NOT NULL, + dateCreated int(10) unsigned NOT NULL, + dateModified int(10) unsigned NOT NULL, + byteSize bigint(20) unsigned NOT NULL, + data longblob NOT NULL, + viewPolicy varbinary(64) NOT NULL, + editPolicy varbinary(64) NOT NULL, + + PRIMARY KEY (id), + UNIQUE KEY key_diagramID_version (diagramID, version), + KEY key_authorPHID (authorPHID) +); \ No newline at end of file diff --git a/src/.phutil_module_cache b/src/.phutil_module_cache new file mode 100644 index 0000000..bc9d6e2 --- /dev/null +++ b/src/.phutil_module_cache @@ -0,0 +1 @@ +{"__symbol_cache_version__":11,"3e1aef3fd93a3c689644ae239df4b2e5":{"have":{"class":{"DiagramApplication":18}},"need":{"function":{"pht":108},"class":{"PhabricatorApplication":45}},"xmap":{"DiagramApplication":["PhabricatorApplication"]}},"d099bb1c91140ff4a0dc595017eeae10":{"have":{"class":{"UnsafeWebpageResponse":19}},"need":{"function":{"phutil_nonempty_string":567,"phutil_tag":935,"hsprintf":1122},"class":{"AphrontHTMLResponse":49}},"xmap":{"UnsafeWebpageResponse":["AphrontHTMLResponse"]}},"b9da8fb011ef341de7b9362b362f4ad4":{"have":{"class":{"PhabricatorRemarkupDiagramRule":19}},"need":{"function":{"id":278,"phutil_escape_html":1580,"phutil_tag":1654},"class":{"PhabricatorObjectRemarkupRule":58,"PhabricatorDiagramQuery":285},"class\/interface":{"PhabricatorObjectHandle":532}},"xmap":{"PhabricatorRemarkupDiagramRule":["PhabricatorObjectRemarkupRule"]}},"726b951bddd81b2e31ac733a1ac6e047":{"have":[],"need":[],"xmap":[]},"b37e401a0f7d897abef8f31ac9f562e8":{"have":{"class":{"DiagramPatchList":19}},"need":{"function":{"phutil_get_library_root":186},"class":{"PhabricatorSQLPatchList":44}},"xmap":{"DiagramPatchList":["PhabricatorSQLPatchList"]}},"70623bab70378fedfb0911949f0ac425":{"have":{"class":{"DiagramDAO":22}},"need":{"class":{"PhabricatorLiskDAO":41}},"xmap":{"DiagramDAO":["PhabricatorLiskDAO"]}},"73009923d74464970c779f30e0ff630b":{"have":{"class":{"PhabricatorDiagramQuery":19}},"need":{"function":{"queryfx_all":365,"qsprintf":1103},"class":{"PhabricatorCursorPagedPolicyAwareQuery":51,"DiagramVersion":286,"DiagramApplication":1314},"class\/interface":{"AphrontDatabaseConnection":988}},"xmap":{"PhabricatorDiagramQuery":["PhabricatorCursorPagedPolicyAwareQuery"]}},"79775b0350f1b39bb4bad0feb922b459":{"have":{"class":{"Diagram":19}},"need":{"function":{"queryfx":3447},"class":{"DiagramDAO":35,"PhabricatorPolicyCapability":921,"PhabricatorPolicies":2379},"class\/interface":{"PhabricatorDestructionEngine":525,"PhabricatorUser":2775},"interface":{"PhabricatorPolicyInterface":59,"PhabricatorSubscribableInterface":89,"PhabricatorDestructibleInterface":125}},"xmap":{"Diagram":["DiagramDAO","PhabricatorPolicyInterface","PhabricatorSubscribableInterface","PhabricatorDestructibleInterface"]}},"d16b913b4e9d67f4ca6e121837f46414":{"have":{"class":{"DiagramVersion":19}},"need":{"function":{"pht":1432,"id":3327,"idx":3686,"queryfx_one":4900,"queryfx":8015},"class":{"DiagramDAO":42,"PhabricatorDiagramQuery":5841,"Diagram":6186,"PhabricatorPolicyCapability":744,"PhabricatorPolicies":1218,"PhabricatorUser":5888},"class\/interface":{"PhabricatorUser":3192,"PhabricatorDestructionEngine":4451},"interface":{"PhabricatorDestructibleInterface":66,"PhabricatorPolicyInterface":102}},"xmap":{"DiagramVersion":["DiagramDAO","PhabricatorDestructibleInterface","PhabricatorPolicyInterface"]}},"a7879a2f6d74e461baa1c8e2e05d003c":{"have":{"class":{"DiagramController":18}},"need":{"function":{"id":680,"phutil_get_library_root":1037,"phutil_tag":5335,"phutil_safe_html":7820},"class":{"PhabricatorController":44,"DiagramVersion":687,"AphrontFileResponse":794,"UnsafeWebpageResponse":2396,"AphrontJSONResponse":4726,"PhabricatorStandardPageView":9305,"AphrontWebpageResponse":9507,"PhabricatorPolicies":4420},"class\/interface":{"AphrontRequest":100}},"xmap":{"DiagramController":["PhabricatorController"]}}} \ No newline at end of file diff --git a/src/application/DiagramApplication.php b/src/application/DiagramApplication.php new file mode 100644 index 0000000..cfd40af --- /dev/null +++ b/src/application/DiagramApplication.php @@ -0,0 +1,76 @@ + array( + // url to image data + "data/(?PPHID-DGVN-[a-z0-9]{20})" + . "(?:/(?P[^/]+))?" + . "(?:/|\?|$).*" + => "DiagramController", + + // version info requests + "version/DIAG(?P(\d+))" + . "/(?P(\d+))" + . "(?:/|\?|$).*" + => "DiagramController", + + // draw.io iframe urls + "(?:(?PDIAG(\d+))/(?P\d+)/?)" + . "(?Piframe)" + . "(?:/|\?|$).*" + => "DiagramController", + + // draw.io iframe urls + "(?:(?PDIAG(\d+))/?)?" + . "(?Piframe)" + . "(?:/|\?|$).*" + => "DiagramController", + + // url with diagram id and version in it + "(?PDIAG(\d+))" + . "/(?P\d+)" + . "(?:/|\?|$).*" + => "DiagramController", + + // url with diagram id in it + "(?PDIAG(\d+))" + . "(?:/|\?|$).*" + => "DiagramController", + + // other urls + ".*" + => "DiagramController", + ) + ); + } +} \ No newline at end of file diff --git a/src/controller/DiagramController.php b/src/controller/DiagramController.php new file mode 100644 index 0000000..ac466e8 --- /dev/null +++ b/src/controller/DiagramController.php @@ -0,0 +1,509 @@ +setRequest($request); + + // detetermine if GET or POST HTTP call + if ($request->isHTTPPost()) { + // process POST calls (like save action) + return $this->handleHttpPostCall($request); + } + + // determine type of URL by means of DiagramApplication route parameters + $diagramphid = $request->getURIData('diagramphid'); + $diagramid = $request->getURIData('diagramid'); + $versioneddiagramid = $request->getURIData('versioneddiagramid'); + $version = $request->getURIData('version'); + $route = $request->getURIData('route'); + $versioninfoDiagramID = $request->getURIData('versioninfodiagram'); + $versioninfoPage = $request->getURIData('versioninfopage'); + + if (isset($diagramphid) && !empty(trim($diagramphid))) { + // return PNG image data + $diagram = id(new DiagramVersion())->loadByDiagramPHID($diagramphid); + if ($diagram !== null) { + $response = new AphrontFileResponse(); + $response->setMimeType('image/png'); + $response->setContent($diagram->getData()); + return $response; + } + } + + if (isset($versioninfoDiagramID) && !empty(trim($versioninfoDiagramID))) { + // return diagram version info + if (!isset($versioninfoPage) || empty(trim($versioninfoPage))) { + // versioninfoPage was dismissed -> initialize to 1 + $versioninfoPage = "1"; + } + + $diagramVersions = id(new DiagramVersion())->loadByDiagramID($versioninfoDiagramID); + if ($diagramVersions !== null) { + $result = []; + $viewer = $request->getViewer(); + + // determine total count of versions + $totalcount = count($diagramVersions); + + // filter out some of the versions we want to show + $pageSize = 10; + $diagramVersions = array_slice($diagramVersions, + ($versioninfoPage - 1) * $pageSize, + $pageSize + ); + + // calculate number of pages + $totalpages = ceil($totalcount / $pageSize); + + // create menu-items + foreach ($diagramVersions as $diagramVersion) { + $author = $diagramVersion->getAuthorPHID(); + + $user = id(new PhabricatorPeopleQuery()) + ->setViewer($viewer) + ->withPHIDs(array( + $author + )) + ->executeOne(); + + $dateModified = $diagramVersion->getDateModified(); + + $result[] = array( + "id" => $diagramVersion->getVersion(), + "datetime" => phabricator_datetime($dateModified, $viewer), + "author" => $user->getUsername() + ); + } + + // reply back + $response = id(new AphrontJSONResponse())->setAddJSONShield(false) + ->setContent(array( + "data" => $result, + "pagecount" => $totalpages, + "nopager" => $totalcount <= $pageSize + )); + return $response; + } else { + // version info requested for inexistant diagram + $response = id(new AphrontJSONResponse())->setAddJSONShield(false) + ->setContent(array( + "data" => array(), + "pagecount" => 0 + )); + return $response; + } + } + + $root = ''; + $file = rtrim($request->getPath(), '/'); + $root = dirname(phutil_get_library_root('diagram')); + + // determine from which application area the file should be loaded: + // 1) Phorge extension source + // or 2) drawio source + if ($route == 'iframe') { + // load from drawio source + if ($file == '/diagram/iframe') + $file .= '/index.html'; + if ($versioneddiagramid != null && $version != null) { + $file = preg_replace("/^\/diagram\/" . $versioneddiagramid . "\/" . $version ."iframe\//", + "data/drawio/src/main/webapp/", + $file); + } else { + $file = preg_replace("/^\/diagram\/(" . $diagramid . "\/?)?iframe\//", + "data/drawio/src/main/webapp/", + $file); + } + } else { + // load from extension source + if (rtrim($file, '/') == '/diagram') { + return $this->showApplication($request); + } + + if ($versioneddiagramid !== null && $version !== null) { + $file = preg_replace('/^\/diagram\/' . $versioneddiagramid . '\/'. $version . '\/?/', + 'data/', + $file + ); + $file = rtrim($file, '/') . '/' . $versioneddiagramid; + } else { + $file = preg_replace('/^\/diagram\/(' . $diagramid . '\/)?/', + 'data/', + $file + ); + } + } + + // determine full path + $path = $root . '/' . $file; + + if (file_exists($path) == false || is_readable($path) == false) { + if (preg_match('/^data\/DIAG(\d+)$/', $file, $matches)) { + $diagram_id = (int) $matches[1]; + if ($version === null) { + $diagram = id(new DiagramVersion())->loadLatestByDiagramID($diagram_id); + } else { + $diagram = id(new DiagramVersion())->loadByVersionedDiagramID($diagram_id, $version); + } + if ($diagram) { + $data = $diagram->getData(); + $base64_data = base64_encode($data); + + return $this->showApplication( + $request, + 'DIAG' . $diagram_id, + $version ?? "", + $base64_data + ); + } + } + + // Invalid URL + $response = id(new Aphront404Response()); + return $response; + } else { + // process Iframe content + switch (pathinfo($file, PATHINFO_EXTENSION)) { + case 'html': + $response = id(new PlainHtmlWebpageResponse()) + ->setFrameable(true) + ->setContent(file_get_contents($path)); + break; + case 'js': + $response = new AphrontFileResponse(); + $response->setMimeType('application/javascript'); + break; + case 'css': + $response = new AphrontFileResponse(); + $response->setMimeType('text/css'); + break; + case 'txt': + $response = new AphrontFileResponse(); + $response->setMimeType('text/plain'); + break; + case 'png': + $response = new AphrontFileResponse(); + $response->setMimeType('image/png'); + break; + case 'gif': + $response = new AphrontFileResponse(); + $response->setMimeType('image/gif'); + break; + case 'jpg': + case 'jpeg': + $response = new AphrontFileResponse(); + $response->setMimeType('image/jpeg'); + break; + default: + $response = new AphrontFileResponse(); + $response->setMimeType('application/octet-stream'); + break; + } + try { + $response->setContent(file_get_contents($path)); + } catch (Exception $e) { + $response->setContent($route); + } + return $response; + } + } + + /** + * Compares the draw.io tEXt metadata from 2 PNG base64 strings. + * The content looks like this: + * + * + * + * + * ... + * + * + * + * + * + * + * The modified and etag attributes of mxfile will always be different. + * They are cut out before the 2 strings are compared. + */ + private function equalPngMetaData($base64_1, $base64_2) { + $base64 = array($base64_1, $base64_2); + $textData = array(); + for ($i = 0; $i < 2; $i++) { + $data = base64_decode($base64[$i]); + $fp = fopen('data://text/plain;base64,' . base64_encode($data), 'rb'); + $sig = fread($fp, 8); + if ($sig != "\x89PNG\x0d\x0a\x1a\x0a") { + fclose($fp); + return false; + } + $textData[$i] = array(); + while (!feof($fp)) { + $chunk = unpack('Nlength/a4type', fread($fp, 8)); + if ($chunk['type'] == 'IEND') break; + if ($chunk['type'] == 'tEXt') { + list($key, $val) = explode("\0", fread($fp, $chunk['length'])); + if ($key == 'mxfile') { + // Decode the URL-encoded XML data + $decodedVal = urldecode($val); + // Load the XML and remove the modified and etag attributes + $xml = simplexml_load_string($decodedVal); + unset($xml->attributes()->modified); + unset($xml->attributes()->etag); + // Save the modified XML as the value + $val = $xml->asXML(); + } + $textData[$i][$key] = $val; + fseek($fp, 4, SEEK_CUR); + } else { + fseek($fp, $chunk['length'] + 4, SEEK_CUR); + } + } + fclose($fp); + } + + if (isset($textData[0]['mxfile']) && isset($textData[1]['mxfile'])) { + // Both arrays contain the mxfile key, compare their values + return $textData[0]['mxfile'] == $textData[1]['mxfile']; + } else { + // At least one of the arrays does not contain the mxfile key, return false + return false; + } + } + + /** + * Processes HTTP POST calls from Diagram application, like 'Save' action + */ + private function handleHttpPostCall(AphrontRequest $request) { + $base64_data = $request->getStr("data"); + $diagram_id = $request->getStr("diagramID"); + + // cut off "data:image/png;base64," + $base64_data = substr($base64_data, strpos($base64_data, ',') + 1); + + if ($diagram_id != "") { + // check if we are trying to save the same data as the current data + $diagram = id(new DiagramVersion())->loadLatestByDiagramID($diagram_id); + if ($diagram !== null) { + $data = $diagram->getData(); + $old_base64_data = base64_encode($data); + + if ($this->equalPngMetaData($base64_data, $old_base64_data)) { + // data hasn't been modified + // => do not create new version + $response = id(new AphrontJSONResponse())->setAddJSONShield(false) + ->setContent(array( + "Status" => "OK", + "DiagramID" => $diagram->getDiagramID(), + "Version" => $diagram->getVersion() + )); + return $response; + } + } + } + + // Set the options for the new file + $options = array( + 'name' => 'diagram.png', + 'viewPolicy' => PhabricatorPolicies::POLICY_USER, + 'mime-type' => 'image/png', + 'actor' => $this->getViewer(), + 'diagramID' => $diagram_id + ); + + try { + // Create the new file object + $diagram = DiagramVersion::newFromFileData($base64_data, $options); + + $response = id(new AphrontJSONResponse())->setAddJSONShield(false) + ->setContent(array( + "Status" => "OK", + "DiagramID" => $diagram->getDiagramID(), + "Version" => $diagram->getVersion() + )); + return $response; + } catch (Exception $e) { + $response = id(new AphrontJSONResponse())->setAddJSONShield(false) + ->setContent(array( + "Status" => "ERROR", + "Error" => $e->getMessage(), + )); + return $response; + } + } + + /** + * Shows the draw.io application integrated in Phorge's layout + */ + private function showApplication( + AphrontRequest $request, + string $diagramName = null, + string $diagramVersion = null, + string $diagramBase64 = null + ) { + $content = phutil_tag( + 'div', + array(), + array( + phutil_tag( + 'div', + array( + 'id' => 'mainScreen', + )), + phutil_tag('div', + array(), + array( + phutil_tag( + 'img', + array( + 'class' => 'drawio', + )), + phutil_tag('div', + array( + 'id' => 'loadingtext', + 'class' => 'geBlock', + 'style' => 'margin-top:80px;' + . 'text-align:center;' + . 'min-width:50%;' + . 'height:100vh;', + ), + array( + phutil_tag('h1', + array(), + 'Flowchart Maker and Online Diagram Software' + ), + phutil_tag('p', + array( + 'style' => 'width: 800px;' + . 'position: sticky;' + . 'left: calc(50% - 400px);', + ), + 'draw.io is free online diagram software. ' + . 'You can use it as a flowchart maker, network diagram ' + . 'software, to create UML online, as an ER diagram tool, ' + . 'to design database schema, to build BPMN online, as a ' + . 'circuit diagram maker, and more. draw.io can import ' + . '.vsdx, Gliffy™ and Lucidchart™ files . ' + ), + phutil_tag( + 'h2', + array( + 'id' => 'geStatus', + ), + 'Loading...' + ), + phutil_tag( + 'div', + array( + 'id' => 'spinnerLoading', + )), + phutil_tag( + 'script', + array(), + 'var spinnerOpts = {' + . 'hwaccel: false,' + . 'length: 24,' + . 'radius: 12,' + . 'shadow: false,' + . 'speed: 1.5,' + . 'trail: 60,' + . 'width: 8};' + ) + )) + )), + phutil_tag( + 'script', + array( + 'src' => 'phorge_extension.js' + ), + '' + ), + phutil_tag( + 'script', + array(), + phutil_safe_html('loadJsExtension("' + . $diagramName + . '", "' + . $diagramVersion + . '", "' + . $diagramBase64 + . '");') + ), + phutil_tag( + 'div', + array( + 'class' => 'crumbs', + 'style' => 'top:48px;' + . 'margin-left: 4px;' + . 'position: fixed;' + . 'font-weight: bold;' + ), + array( + phutil_tag( + 'a', + array( + 'href' => '.' + ), + array( + phutil_tag( + 'span', + array( + 'class' => 'phui-font-fa fa-sitemap', + 'style' => 'padding-right:5px;' + )) + )), + phutil_tag( + 'a', + array( + 'href' => '.' + ), + 'Diagram' + ), + phutil_tag( + 'span', + array( + 'class' => 'diagramName', + 'style' => 'display:none' + ), + array( + phutil_tag( + 'span', + array( + 'style' => 'margin: 5px;' + . 'opacity: .5;' + ), + '>' + ), + phutil_tag( + 'a', + array(), + '' + ), + phutil_tag( + 'span', + array( + 'class' => 'version', + 'style' => 'margin-left: 8px;' + . 'color: #999;' + ), + '', + ), + )) + )) + )); + + $view = id(new PhabricatorStandardPageView()) + ->setRequest($request) + ->setController($this) + ->setDeviceReady(true) + ->setTitle("Diagrams") + ->appendChild($content); + + $response = id(new AphrontWebpageResponse()) + ->setContent($view->render()); + + return $response; + } +} \ No newline at end of file diff --git a/src/query/PhabricatorDiagramQuery.php b/src/query/PhabricatorDiagramQuery.php new file mode 100644 index 0000000..8302219 --- /dev/null +++ b/src/query/PhabricatorDiagramQuery.php @@ -0,0 +1,73 @@ +diagramIDs = $diagram_ids; + return $this; + } + + protected function loadPage() { + $table = new DiagramVersion(); + $conn_r = $table->establishConnection('r'); + + // we return a DiagramVersion object which has a different id + // than the one we mention in the Remarkup code. + // E.g. {DIAG1} may point to the 2nd version of the object. + // Diagram's id is 1, but DiagramVersion's id is 2. + // Because of this we abuse the id in the resultset a little bit + $data = queryfx_all( + $conn_r, + "SELECT result.diagramID AS id, /* abuse */ + result.diagramID, + result.version, + result.phid, + result.authorPHID, + result.dateCreated, + result.dateModified, + result.byteSize, + result.data, + result.viewPolicy, + result.editPolicy + FROM ( + SELECT data.* + FROM %T data + INNER JOIN ( + SELECT MAX(id) AS id, + diagramid + FROM %T + GROUP BY diagramid HAVING MAX(id) + ) filter + ON data.id = filter.id + ) result %Q %Q %Q", + $table->getTableName(), + $table->getTableName(), + $this->buildWhereClause($conn_r), + $this->buildOrderClause($conn_r), + $this->buildLimitClause($conn_r)); + + return $table->loadAllFromArray($data); + } + + protected function buildWhereClause(AphrontDatabaseConnection $conn_r) { + $where = array(); + + if ($this->diagramIDs !== null) { + $where[] = qsprintf( + $conn_r, + 'diagramID IN (%Ld)', + $this->diagramIDs); + } + + return $this->formatWhereClause($conn_r, $where); + } + + public function getQueryApplicationClass() { + return DiagramApplication::class; + } + +} + diff --git a/src/remarkup/PhabricatorRemarkupDiagramRule.php b/src/remarkup/PhabricatorRemarkupDiagramRule.php new file mode 100644 index 0000000..45c8c90 --- /dev/null +++ b/src/remarkup/PhabricatorRemarkupDiagramRule.php @@ -0,0 +1,102 @@ +getEngine()->getConfig('viewer'); + + $objects = id(new PhabricatorDiagramQuery()) + ->setViewer($viewer) + ->withDiagramIDs($ids) + ->execute(); + + return $objects; + } + + protected function renderObjectEmbed( + $diagram, + PhabricatorObjectHandle $handle, + $options) { + + if ($options) { + $params = explode(',', $options); + $params = array_map('trim', $params); + } else { + $params = array(); + } + + // Get the file PHID for the Diagram object. + $file_phid = $diagram->getPHID(); + + // Generate the appropriate HTML using the data from the Diagram and + // file objects. + $style = ''; + $class = 'diagram-content'; + $alt = ''; + + $has_layout = false; + foreach ($params as $param) { + if (strpos($param, '=') !== false) { + list($key, $value) = explode('=', $param, 2); + } else { + $key = $param; + $value = null; + } + switch ($key) { + case 'layout': + $has_layout = true; + if ($value === 'left') { + $class .= ' phabricator-remarkup-embed-layout-left'; + } else if ($value === 'right') { + $class .= ' phabricator-remarkup-embed-layout-right'; + } + break; + case 'float': + $class .= ' phabricator-remarkup-embed-float-left'; + break; + case 'size': + if ($value === 'full') { + $style .= 'width: 100%;'; + } + break; + case 'alt': + $alt = phutil_escape_html($value); + break; + } + } + + if ($has_layout == false) { + $class .= ' phabricator-remarkup-embed-layout-left'; + } + + $output = phutil_tag( + 'div', + array( + 'class' => 'diagram-container', + ), + phutil_tag( + 'img', + array( + 'style' => $style, + 'class' => $class, + 'src' => $diagram->getViewURI(), + 'alt' => $alt, + 'ondblclick' => 'window.open("/diagram/DIAG' + . $diagram->getDiagramID() + . '", "_blank")', + ) + ) + ); + + return $output; + } +} \ No newline at end of file diff --git a/src/response/PlainHtmlWebpageResponse.php b/src/response/PlainHtmlWebpageResponse.php new file mode 100644 index 0000000..4272260 --- /dev/null +++ b/src/response/PlainHtmlWebpageResponse.php @@ -0,0 +1,58 @@ +content = $content; + return $this; + } + + public function setUnexpectedOutput($unexpected_output) { + $this->unexpectedOutput = $unexpected_output; + return $this; + } + + public function getUnexpectedOutput() { + return $this->unexpectedOutput; + } + + public function buildResponseString() { + $unexpected_output = $this->getUnexpectedOutput(); + if (phutil_nonempty_string($unexpected_output)) { + // in case we get some unexpected output (e.g. a stacktraced error) + // the output is shown on top of the screen in a red banner + $style = array( + 'background: linear-gradient(180deg, #eeddff, #ddbbff);', + 'white-space: pre-wrap;', + 'z-index: 200000;', + 'position: relative;', + 'padding: 16px;', + 'font-family: monospace;', + 'text-shadow: 1px 1px 1px white;' + ); + + $unexpected_header = phutil_tag( + 'div', + array( + 'style' => implode(' ', $style), + ), + $unexpected_output); + } else { + $unexpected_header = ''; + } + + // print output as is + return hsprintf('%s', $unexpected_header) . $this->content; + } + +} diff --git a/src/storage/Diagram.php b/src/storage/Diagram.php new file mode 100644 index 0000000..0432782 --- /dev/null +++ b/src/storage/Diagram.php @@ -0,0 +1,142 @@ +openTransaction(); + $this->delete(); + $this->saveTransaction(); + } + + /** + * Return an array of capabilities that this object type supports. + * See PhabricatorPolicyCapability for a list of available capabilities. + * + * Interface: PhabricatorPolicyInterface + */ + public function getCapabilities() { + return array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + ); + } + + /** + * Configures application-wide storage settings. + * This creates a mapping of the corresponding database table. + */ + public function getConfiguration() + { + return array( + self::CONFIG_COLUMN_SCHEMA => array( + 'id' => 'auto', + ), + self::CONFIG_KEY_SCHEMA => array( + 'PRIMARY' => array( + 'columns' => array('id'), + 'unique' => true, + ), + ), + ); + } + + /** + * Return a unique identifier for an object. + * In Phabricator, a monogram is a unique identifier for an object, such + * as a task or event. + * For example, Maniphest tasks are identified with monograms like "T123". + */ + public function getMonogram() { + return 'DIAG'.$this->getID(); + } + + /** + * Return a string that uniquely identifies the PHID type for this object + * type. This is used by the PHID system to generate and manage PHIDs for + * this object type. + */ + public function getPHIDType() + { + return 'DIAG'; + } + + /** + * Return the policy for the given capability. + * + * Interface: PhabricatorPolicyInterface + */ + public function getPolicy($capability) { + switch ($capability) { + case PhabricatorPolicyCapability::CAN_VIEW: + return $this->viewPolicy; + case PhabricatorPolicyCapability::CAN_EDIT: + return $this->editPolicy; + default: + return PhabricatorPolicies::POLICY_NOONE; + } + } + + /** + * Return true if the given user has the given capability automatically, + * without needing to check the object's policy. For example, you might + * return true here if the user is an administrator or if they own the + * object. + * + * Interface: PhabricatorPolicyInterface + */ + public function hasAutomaticCapability( + $capability, + PhabricatorUser $viewer + ) { + return false; + } + + /** + * Return true if the given user is automatically subscribed to this + * object. For example, you might return true here if the user is the + * author of the object or if they are mentioned in the object's content. + */ + public function isAutomaticallySubscribed($phid) + { + return true; + } + + /** + * Create a new record in the diagram table and returns the generated record + */ + public function createNewDiagram() { + $conn_w = $this->establishConnection('w'); + $table_name = $this->getTableName(); + $record = array( + // no other columns defined but the primary key (id) + ); + + queryfx( + $conn_w, + 'INSERT INTO %T () VALUES ()', + $table_name + ); + + $this->id = $conn_w->getInsertID(); + + return $this; + } +} \ No newline at end of file diff --git a/src/storage/DiagramDAO.php b/src/storage/DiagramDAO.php new file mode 100644 index 0000000..cc26e3a --- /dev/null +++ b/src/storage/DiagramDAO.php @@ -0,0 +1,14 @@ +viewPolicy; + case PhabricatorPolicyCapability::CAN_EDIT: + return $this->editPolicy; + default: + return PhabricatorPolicies::POLICY_NOONE; + } + } + + /** + * Return the URL which links to the diagram PNG image + */ + public function getViewURI() { + if (!$this->getPHID()) { + throw new Exception( + pht('You must save a diagram before you can generate a view URI.') + ); + } + + $uri = '/diagram/data/' + . $this->getPHID(); + + + return $uri; + } + + /** + * Configures application-wide storage settings. + * This creates a mapping of the corresponding database table. + */ + public function getConfiguration() { + return array( + self::CONFIG_AUX_PHID => true, + self::CONFIG_COLUMN_SCHEMA => array( + 'diagramID' => 'uint32', + 'version' => 'uint32', + 'authorPHID' => 'phid', + 'byteSize' => 'uint64', + 'data' => 'bytes', + 'viewPolicy' => 'policy', + 'editPolicy' => 'policy', + ), + self::CONFIG_KEY_SCHEMA => array( + 'key_phid' => null, + 'key_diagramID_version' => array( + 'columns' => array('diagramID', 'version'), + 'unique' => true, + ), + 'key_authorPHID' => array( + 'columns' => array('authorPHID'), + ), + ), + ) + + array( + self::CONFIG_IDS => self::IDS_AUTOINCREMENT, + self::CONFIG_TIMESTAMPS => true, + ); + } + + /** + * Return the name of the database table that is represented by this class + */ + public function getTableName() { + return 'diagram_version'; + } + + /** + * Return a string that uniquely identifies the PHID type for this object + * type. This is used by the PHID system to generate and manage PHIDs for + * this object type. + */ + public function getPHIDType() { + return 'DGVN'; + } + + /** + * Return true if the given user has the given capability automatically, + * without needing to check the object's policy. For example, you might + * return true here if the user is an administrator or if they own the + * object. + * + * Interface: PhabricatorPolicyInterface + */ + public function hasAutomaticCapability( + $capability, + PhabricatorUser $viewer + ) { + return false; + } + + public static function initializeNewDiagram(PhabricatorUser $actor) { + return id(new DiagramVersion()) + ->setViewPolicy(PhabricatorPolicies::getMostOpenPolicy()) + ->setEditPolicy($actor->getPHID()) + ->setAuthorPHID($actor->getPHID()) + ->setVersion(1) + ->setDateCreated(time()) + ->setDateModified(time()); + } + + public static function newFromFileData( + $base64_data, + array $params = array() + ) { + $actor = idx($params, 'actor'); + if (!$actor) { + throw new Exception(pht('Missing required actor for new file data.')); + } + + $diagramID = idx($params, 'diagramID'); + if (!is_numeric($diagramID)) { + $diagramID = null; + } + + $data = base64_decode($base64_data); + + $diagram = self::initializeNewDiagram($actor); + $diagram->setByteSize(strlen($data)); + $diagram->setData($data); + $diagram->setDiagramID($diagramID); + $diagram->save(); + + return $diagram; + } + + public function attachProjectPHIDs(array $phids) { + // Attach an array of project PHIDs to this object. This is used by the + // project system to manage project membership and visibility for this + // object. + } + + public function destroyObjectPermanently( + PhabricatorDestructionEngine $engine + ) { + // Permanently destroy this object. This is used by the destructible + // interface to allow administrators to permanently delete objects from + // the system. + $this->openTransaction(); + $this->delete(); + $this->saveTransaction(); + } + + public function generateDiagramID() { + $conn_r = $this->establishConnection('r'); + $table_name = $this->getTableName(); + $max_diagram_id = queryfx_one( + $conn_r, + 'SELECT MAX(diagramID) max_diagram_id FROM %T', + $table_name + )['max_diagram_id']; + return (int) $max_diagram_id + 1; + } + + public function loadByDiagramID($diagramID) { + if (is_object($diagramID)) { + $diagramID = (string) $diagramID; + } + + if (!$diagramID || (!is_int($diagramID) && !ctype_digit($diagramID))) { + return null; + } + + return $this->loadAllWhere( + 'diagramID = %d ORDER BY version DESC', + $diagramID + ); + } + + public function loadLatestByDiagramID($diagramID) { + if (is_object($diagramID)) { + $diagramID = (string) $diagramID; + } + + if (!$diagramID || (!is_int($diagramID) && !ctype_digit($diagramID))) { + return null; + } + + return $this->loadOneWhere( + 'diagramID = %d ORDER BY version DESC LIMIT 1', + $diagramID + ); + } + + public function loadByDiagramPHID($diagramPHID) { + if (is_object($diagramPHID)) { + $diagramPHID = (string) $diagramPHID; + } + + return $this->loadOneWhere( + 'phid = %s ORDER BY version DESC LIMIT 1', + $diagramPHID + ); + } + + public function loadByVersionedDiagramID($diagramID, $version) { + if (is_object($diagramID)) { + $diagramID = (string) $diagramID; + } + if (is_object($version)) { + $version = (string) $version; + } + + if (!$diagramID || (!is_int($diagramID) && !ctype_digit($diagramID))) { + return null; + } + + if (!$version || (!is_int($version) && !ctype_digit($version))) { + return null; + } + + return $this->loadOneWhere( + 'diagramID = %d AND version = %d', + $diagramID, + $version + ); + } + + public function save() { + // Load the last record with the same PHID. + $last_record = null; + if ($this->getDiagramID() !== null) { + $last_record = id(new PhabricatorDiagramQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withDiagramIDs(array($this->getDiagramID())) + ->setLimit(1) + ->executeOne(); + } + + if ($last_record === null) { + // If there is no last record, this is a new diagram object. + $this->setVersion(1); + $newDiagram = new Diagram(); + $newDiagram->createNewDiagram(); + $this->setDiagramID($newDiagram->getID()); + } else { + // If there is a last record, this is a new version of an existing + // diagram object. + $this->setVersion($last_record->getVersion() + 1); + $this->setDateCreated($last_record->getDateCreated()); + } + + // Check if a row with the same PHID and version already exists + $existing_record = null; + if ($this->getPHID() !== null && $this->getVersion() !== null) { + $existing_record = id(new PhabricatorDiagramQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withPHIDs(array($this->getPHID())) + ->withWhere( + array( + array('version', '=', $this->getVersion()), + ) + ) + ->setLimit(1) + ->executeOne(); + } + + if ($existing_record === null) { + // If there is no existing record, create a new row in the table. + $conn_w = $this->establishConnection('w'); + $table_name = $this->getTableName(); + $this->phid = $this->generatePHID(); + if ($this->diagramID === null) { + $this->diagramID = $this->generateDiagramID(); + } + + $record = array( + 'phid' => $this->getPHID(), + 'diagramID' => $this->getDiagramID(), + 'version' => $this->getVersion(), + 'authorPHID' => $this->getAuthorPHID(), + 'dateCreated' => $this->getDateCreated(), + 'dateModified' => $this->getDateModified(), + 'byteSize' => $this->getByteSize(), + 'viewPolicy' => $this->getViewPolicy(), + 'editPolicy' => $this->getEditPolicy(), + 'data' => $this->getData(), + ); + if ($this->getID() !== null) { + // If the ID property is set, include it in the data to insert. + $record['id'] = $this->getID(); + } + queryfx( + $conn_w, + 'INSERT INTO %T (%Q) VALUES (%Ls, %B)', + $table_name, + implode(', ', array_keys($record)), + array_values(array_slice($record, 0, -1)), + end($record) + ); + + $this->id = $conn_w->getInsertID(); + } else { + // If there is an existing record, throw an exception. + throw new Exception( + pht('A diagram with PHID "%s" and version "%s" already exists.', + $this->getPHID(), + $this->getVersion() + ) + ); + } + + return $this; + } +} \ No newline at end of file diff --git a/src/storage/patch/DiagramPatchList.php b/src/storage/patch/DiagramPatchList.php new file mode 100644 index 0000000..8e14cde --- /dev/null +++ b/src/storage/patch/DiagramPatchList.php @@ -0,0 +1,27 @@ + array( + 'name' => 'diagram', + 'type' => 'db', + 'after' => array(), + ), + ); + + + $auto = $this->buildPatchesFromDirectory($root.'/resources/sql/'); + $result = $db_patches + $auto; + return $result; + } + +} \ No newline at end of file