From cd1c528ed5bd188d8e11d28f920e976293d8fa41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 1 Jul 2024 21:29:51 +0200 Subject: [PATCH] Add Mindmap explorations --- package.json | 1 + .../meta/src/mindmap/v1/fetch-mindmap-data.js | 229 ++++++++ packages/meta/src/mindmap/v1/index.html | 13 + packages/meta/src/mindmap/v1/script.js | 216 ++++++++ packages/meta/src/mindmap/v1/style.css | 81 +++ .../meta/src/mindmap/v2/fetch-mindmap-data.js | 230 ++++++++ packages/meta/src/mindmap/v2/index.html | 489 ++++++++++++++++++ .../project_board_automation/GitHubApi.php | 216 ++++---- 8 files changed, 1383 insertions(+), 92 deletions(-) create mode 100644 packages/meta/src/mindmap/v1/fetch-mindmap-data.js create mode 100644 packages/meta/src/mindmap/v1/index.html create mode 100644 packages/meta/src/mindmap/v1/script.js create mode 100644 packages/meta/src/mindmap/v1/style.css create mode 100644 packages/meta/src/mindmap/v2/fetch-mindmap-data.js create mode 100644 packages/meta/src/mindmap/v2/index.html diff --git a/package.json b/package.json index fd09f7e75f..959f2f7a82 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "format:uncommitted": "nx format --fix --parallel --uncommitted", "lint": "nx run-many --all --target=lint", "prepublishOnly": "npm run build", + "mindmap": "cd packages/meta/src/mindmap/v2 && php -S 127.0.0.1:5269", "preview": "nx preview playground-website", "recompile:php:web": "nx recompile-php:light:all php-wasm-web && nx recompile-php:kitchen-sink:all php-wasm-web ", "recompile:php:web:light": "nx recompile-php:light:all php-wasm-web ", diff --git a/packages/meta/src/mindmap/v1/fetch-mindmap-data.js b/packages/meta/src/mindmap/v1/fetch-mindmap-data.js new file mode 100644 index 0000000000..910b22b32a --- /dev/null +++ b/packages/meta/src/mindmap/v1/fetch-mindmap-data.js @@ -0,0 +1,229 @@ +const shouldRebuild = + new URLSearchParams(window.location.search).get('rebuild') === 'true'; + +let __moduleGithubToken = localStorage.getItem('GITHUB_TOKEN'); +const repos = [ + 'wordpress/wordpress-playground', + 'wordpress/playground-tools', + 'wordpress/blueprints', + 'wordpress/blueprints-library', + 'adamziel/playground-docs-workflow', + 'adamziel/site-transfer-protocol', +]; + +const comparableKey = (str) => str.toLowerCase(); + +const graphqlQuery = async (query, variables) => { + const headers = { + 'Content-Type': 'application/json', + }; + if (__moduleGithubToken) { + headers['Authorization'] = `Bearer ${__moduleGithubToken}`; + } + const response = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers, + body: JSON.stringify({ query, variables }), + }); + return response.json(); +}; + +async function* iterateIssuesPRs(repo, labels = []) { + const query = ` + query GetProjects($cursor: String!, $query: String!) { + search(query: $query, first: 100, type: ISSUE, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + ... on Issue { + number + title + url + body + state + repository { + nameWithOwner + } + labels(first: 10) { + nodes { + name + } + } + } + ... on PullRequest { + number + title + url + body + state + repository { + nameWithOwner + } + labels(first: 10) { + nodes { + name + } + } + } + } + } + } + } + `; + + let cursor = ''; + do { + const labelQuery = labels.map((label) => `"${label}"`).join(','); + const variables = { + cursor, + query: + `repo:${repo}` + (labels.length ? ` label:${labelQuery}` : ''), + }; + + const response = await graphqlQuery(query, variables); + const edges = response.data.search.edges; + for (const edge of edges) { + if (edge.node) { + const item = edge.node; + const key = comparableKey( + `${item.repository.nameWithOwner}#${item.number}` + ); + item.key = key; + item.title = item.title.trim().replace(/^Tracking: /, ''); + yield edge.node; + } + } + if (!response.data.search.pageInfo.hasNextPage) { + break; + } + cursor = response.data.search.pageInfo.endCursor; + } while (true); +} + +const nodesCacheKey = 'nodes_cache'; + +const fetchData = async () => { + let allNodes = {}; + const allNodesArray = []; + for (const repo of repos) { + for await (const item of iterateIssuesPRs(repo)) { + allNodes[item.key] = item; + } + for await (const item of iterateIssuesPRs(repo, [ + '[Type] Mindmap Node', + '[Type] Mindmap Tree', + ])) { + allNodes[item.key] = item; + } + } + return allNodes; +}; + +const getConnectedNodes = (issue) => { + const currentRepo = issue.repository.nameWithOwner; + let connections = []; + + const regex1 = + /\bhttps:\/\/github.com\/([^\/]+)\/([^\/]+)\/(?:issues|pull)\/(\d+)\b/g; + const regex2 = /\b#(\d+)\b/g; + let match; + + while ((match = regex1.exec(issue.body)) !== null) { + connections.push(`${match[1]}/${match[2]}#${match[3]}`); + } + + while ((match = regex2.exec(issue.body)) !== null) { + connections.push(`${currentRepo}#${match[1]}`); + } + + return connections.map(comparableKey); +}; + +const buildEdges = ({ allNodes, rootKey, isEdge }) => { + const seen = {}; + const allEdges = {}; + + const preorderTraversal = (currentNode) => { + const relatedIssueKeys = getConnectedNodes(currentNode).filter( + (edgeKey) => isEdge(currentNode.key, edgeKey) + ); + + for (const relatedKey of relatedIssueKeys) { + if (!allNodes[relatedKey]) continue; + if (seen[relatedKey]) continue; + seen[relatedKey] = true; + + if (!allEdges[currentNode.key]) { + allEdges[currentNode.key] = []; + } + + allEdges[currentNode.key].push(relatedKey); + preorderTraversal(allNodes[relatedKey]); + } + }; + + preorderTraversal(allNodes[rootKey]); + return allEdges; +}; + +const buildTree = (allNodes, allEdges, rootKey) => { + const node = { ...allNodes[rootKey] }; + const childrenKeys = allEdges[rootKey] || []; + node.children = childrenKeys.map((childKey) => + buildTree(allNodes, allEdges, childKey) + ); + return node; +}; + +export const fetchMindmapData = async ({ githubToken } = {}) => { + if (githubToken) { + __moduleGithubToken = githubToken; + } + + const allNodes = await fetchData(); + const isEdge = (fromKey, toKey) => { + if (mindmapTrees[fromKey]) { + return true; + } + if (mindmapNodes[fromKey] && mindmapNodes[toKey]) { + return true; + } + return false; + }; + + const mindmapTrees = {}; + const mindmapNodes = {}; + for (const key in allNodes) { + if ( + allNodes[key].labels.nodes.some( + (label) => label.name === '[Type] Mindmap Tree' + ) + ) { + mindmapTrees[key] = allNodes[key]; + mindmapNodes[key] = allNodes[key]; + } else if ( + allNodes[key].labels.nodes.some( + (label) => label.name === '[Type] Mindmap Node' + ) + ) { + mindmapNodes[key] = allNodes[key]; + } + } + + const rootKey = 'wordpress/wordpress-playground#525'; + const allEdges = buildEdges({ + allNodes, + rootKey, + isEdge, + }); + const tree = buildTree(allNodes, allEdges, rootKey); + console.log({ + allNodes, + tree, + }); + + return tree; +}; diff --git a/packages/meta/src/mindmap/v1/index.html b/packages/meta/src/mindmap/v1/index.html new file mode 100644 index 0000000000..e2b03cfda3 --- /dev/null +++ b/packages/meta/src/mindmap/v1/index.html @@ -0,0 +1,13 @@ + + + + + Mind Map + + + +
+ + + + diff --git a/packages/meta/src/mindmap/v1/script.js b/packages/meta/src/mindmap/v1/script.js new file mode 100644 index 0000000000..6f1a2339f1 --- /dev/null +++ b/packages/meta/src/mindmap/v1/script.js @@ -0,0 +1,216 @@ +async function boot() { + const { fetchMindmapData } = await import('./fetch-mindmap-data.js'); + const isLocalhost = + window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1'; + let mindmapData = null; + if (localStorage.getItem('mindmapData')) { + mindmapData = JSON.parse(localStorage.getItem('mindmapData')); + } else { + mindmapData = await fetchMindmapData(); + localStorage.setItem('mindmapData', JSON.stringify(mindmapData)); + } + + const width = window.innerWidth; + const height = window.innerHeight; + + const svg = d3 + .select('#mindmap') + .append('svg') + .attr( + 'xmlns:mydata', + 'https://playground.wordpress.net/svg-namespaces/mydata' + ) + .attr('width', width) + .attr('height', height) + .style('background', 'white'); + + const g = svg + .append('g') + .attr('transform', `translate(${width / 2},${height / 2})`); // Centering the root node + + const zoom = d3 + .zoom() + .scaleExtent([0.1, 4]) + .on('zoom', function (event) { + g.attr('transform', event.transform); + }); + + svg.call(zoom).call( + zoom.transform, + d3.zoomIdentity.translate(width / 2, height / 2) + ); + + const root = d3.hierarchy(mindmapData); + const treeLayout = d3 + .tree() + .size([2 * Math.PI, width / 2 - 100]) + .separation((a, b) => (a.parent === b.parent ? 1 : 2) / a.depth); + + treeLayout(root); + + const toClassName = (str) => { + if (!str) { + return ''; + } + return str.toLowerCase().replace(/[^a-zA-Z0-9\-]/g, '-'); + }; + + const link = g + .selectAll('.link') + .data(root.links()) + .enter() + .append('line') + .attr('class', 'link') + .attr('x1', (d) => radialPoint(d.source.x, d.source.y)[0]) + .attr('y1', (d) => radialPoint(d.source.x, d.source.y)[1]) + .attr('x2', (d) => radialPoint(d.target.x, d.target.y)[0]) + .attr('y2', (d) => radialPoint(d.target.x, d.target.y)[1]); + + const node = g + .selectAll('.node') + .data(root.descendants()) + .enter() + .append('g') + .attr('class', (d) => + [ + 'node', + toClassName(d.data.key), + d.data.state === 'OPEN' ? 'open' : 'closed', + ].join(' ') + ) + .attr( + 'transform', + (d) => + `translate(${radialPoint(d.x, d.y)[0]},${ + radialPoint(d.x, d.y)[1] + })` + ); + + const maxTitleLength = 30; + let asTitle = (title) => + title.substring(0, maxTitleLength) + + (title.length > maxTitleLength ? '...' : ''); + node.append('rect') + .attr('width', (d) => asTitle(d.data.title).length * 8 + 20) // Dynamically set width based on text length + .attr('height', 30) + .attr('x', (d) => -(asTitle(d.data.title).length * 8 + 20) / 2) // Center the rectangle + .attr('y', -15) + .on('click', handleHighlight); + + node.append('a') + .attr('xlink:href', (d) => d.data.url) // Assuming href is based on node name + .attr('target', '_blank') + .append('text') + .attr('dy', 5) + .attr('text-anchor', 'middle') + .text((d) => asTitle(d.data.title)); + + // ==== Details popup ==== + // const visiblePopups = []; + // function handleClick(event, d) { + // const clickedNode = d3.select(this); + // if (clickedNode.select("foreignObject").size() > 0) { + // const child = clickedNode.select("foreignObject"); + // child.remove(); + // if(visiblePopups.includes(child)) { + // visiblePopups.splice(visiblePopups.indexOf(child), 1); + // } + // return; + // } + + // while (visiblePopups.length > 0) { + // visiblePopups.pop().remove(); + // } + + // const foreignContent = clickedNode.append("foreignObject") + // .attr("width", d => d.data.name.length * 8 + 20) // Dynamically set width based on text length + // .attr("height", 30) + // .attr("x", d => -(d.data.name.length * 8 + 20) / 2) // Center the rectangle + // .attr("y", 15); // Position below the current rect + + // foreignContent.append("xhtml:div") + // .style("width", "100%") + // .style("height", "100%") + // .html("

Foreign Content

"); + // visiblePopups.push(foreignContent); + // } + + // ==== Highlight part of the mindmap ==== + function* iterNodes(node) { + yield node; + if (node.children) { + for (const child of node.children) { + yield child; + yield* iterNodes(child); + } + } + } + function handleHighlight(event, node) { + if ( + d3 + .selectAll(`.node.${toClassName(node.data.key)}`) + .classed('highlighted-main') + ) { + clearHighlights(); + return; + } + clearHighlights(); + + d3.selectAll(`.node.${toClassName(node.data.key)}`).classed( + 'highlighted-main', + true + ); + + const tree = new Set(iterNodes(mindmapData)); + const subtree = new Set(iterNodes(node.data)); + if (subtree.size) { + const subTreeClasses = [...subtree].map((d) => toClassName(d.key)); + const subTreeSelector = subTreeClasses + .map((d) => `.node.${d}`) + .join(', '); + d3.selectAll(subTreeSelector).classed('highlighted', true); + } + + const restOfTree = new Set([...tree].filter((x) => !subtree.has(x))); + if (restOfTree.size) { + const restOfTreeClasses = [...restOfTree].map((d) => + toClassName(d.key) + ); + const restOfTreeSelector = restOfTreeClasses + .map((c) => `.node.${c}`) + .join(', '); + d3.selectAll(restOfTreeSelector).classed('dimmed', true); + } + } + + function clearHighlights() { + d3.selectAll(`.node`) + .classed('dimmed', false) + .classed('highlighted', false) + .classed('highlighted-main', false); + } + + let currentTransform = d3.zoomIdentity; + + svg.call(zoom).on('zoom', function (event) { + svg.attr('transform', event.transform); + currentTransform = event.transform; + }); + + function radialPoint(x, y) { + return [y * Math.cos(x - Math.PI / 2), y * Math.sin(x - Math.PI / 2)]; + } + + const refreshButton = document.createElement('button'); + refreshButton.innerText = 'Refresh Data'; + refreshButton.style.position = 'absolute'; + refreshButton.style.top = '10px'; + refreshButton.style.right = '10px'; + refreshButton.addEventListener('click', () => { + localStorage.removeItem('mindmapData'); + location.reload(); + }); + document.body.appendChild(refreshButton); +} +boot(); diff --git a/packages/meta/src/mindmap/v1/style.css b/packages/meta/src/mindmap/v1/style.css new file mode 100644 index 0000000000..215ae4b7b4 --- /dev/null +++ b/packages/meta/src/mindmap/v1/style.css @@ -0,0 +1,81 @@ +body { + margin: 0; + overflow: hidden; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; +} + +#mindmap { + width: 100vw; + height: 100vh; + background: white; +} + +.node { + cursor: pointer; + rect { + stroke-width: 2; + rx: 5; + ry: 5; + fill: white; + stroke: white; + } + text { + text-overflow: ellipsis; + } +} + +.node.open rect { + fill: white; + stroke: rgb(31, 136, 61); +} + +.node.closed { + rect { + /* fill: rgb(130, 80, 223); */ + stroke: rgb(130, 80, 223); + } + text { + fill: rgb(130, 80, 223); + } +} + +.node:hover rect { + fill: #f0f0f0; /* Lighter color on hover */ +} + +.node.highlighted rect { + stroke: lightgreen; +} +.node.highlighted-main rect { + fill: lightgreen; +} +.node.highlighted-main text { + font-weight: bold; +} + +.node.dimmed rect { + fill: #f0f0f0; /* Dimmed color */ + stroke: #f3f3f3; +} + +.node text { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, sans-serif; + font-size: 12px; + fill: #333; +} + +.link { + stroke: #ccc; + stroke-width: 2; /* Thicker edges */ +} + +.popup { + position: absolute; + background: white; + border: 1px solid #ccc; + padding: 5px; + display: none; + z-index: 10; + pointer-events: none; +} diff --git a/packages/meta/src/mindmap/v2/fetch-mindmap-data.js b/packages/meta/src/mindmap/v2/fetch-mindmap-data.js new file mode 100644 index 0000000000..3b10ec72e9 --- /dev/null +++ b/packages/meta/src/mindmap/v2/fetch-mindmap-data.js @@ -0,0 +1,230 @@ +const shouldRebuild = + new URLSearchParams(window.location.search).get('rebuild') === 'true'; + +let __moduleGithubToken = localStorage.getItem('GITHUB_TOKEN'); +if (!__moduleGithubToken) { + alert( + 'Please save your GitHub token in localStorage.GITHUB_TOKEN before running this script.' + ); +} +const repos = [ + 'wordpress/wordpress-playground', + 'wordpress/playground-tools', + 'wordpress/blueprints', + 'wordpress/blueprints-library', + 'adamziel/playground-docs-workflow', + 'adamziel/site-transfer-protocol', +]; + +const comparableKey = (str) => str.toLowerCase(); + +const graphqlQuery = async (query, variables) => { + const headers = { + 'Content-Type': 'application/json', + }; + if (__moduleGithubToken) { + headers['Authorization'] = `Bearer ${__moduleGithubToken}`; + } + const response = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers, + body: JSON.stringify({ query, variables }), + }); + return response.json(); +}; + +async function* iterateIssuesPRs(repo, labels = []) { + const query = ` + query GetProjects($cursor: String!, $query: String!) { + search(query: $query, first: 100, type: ISSUE, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + ... on Issue { + number + title + url + body + state + repository { + nameWithOwner + } + labels(first: 10) { + nodes { + name + } + } + } + ... on PullRequest { + number + title + url + body + state + repository { + nameWithOwner + } + labels(first: 10) { + nodes { + name + } + } + } + } + } + } + } + `; + + let cursor = ''; + do { + const labelQuery = labels.map((label) => `"${label}"`).join(','); + const variables = { + cursor, + query: + `repo:${repo}` + (labels.length ? ` label:${labelQuery}` : ''), + }; + + const response = await graphqlQuery(query, variables); + const edges = response.data.search.edges; + for (const edge of edges) { + if (edge.node) { + const item = edge.node; + const key = comparableKey( + `${item.repository.nameWithOwner}#${item.number}` + ); + item.key = key; + item.title = item.title.trim().replace(/^Tracking: /, ''); + yield edge.node; + } + } + if (!response.data.search.pageInfo.hasNextPage) { + break; + } + cursor = response.data.search.pageInfo.endCursor; + } while (true); +} + +const nodesCacheKey = 'nodes_cache'; + +const fetchData = async () => { + let allNodes = {}; + const allNodesArray = []; + for (const repo of repos) { + for await (const item of iterateIssuesPRs(repo)) { + allNodes[item.key] = item; + } + for await (const item of iterateIssuesPRs(repo, [ + '[Type] Mindmap Node', + '[Type] Mindmap Tree', + ])) { + allNodes[item.key] = item; + } + } + return allNodes; +}; + +const getConnectedNodes = (issue) => { + const currentRepo = issue.repository.nameWithOwner; + let connections = []; + + const regex1 = + /\bhttps:\/\/github.com\/([^\/]+)\/([^\/]+)\/(?:issues|pull)\/(\d+)\b/g; + const regex2 = /\b#(\d+)\b/g; + let match; + + while ((match = regex1.exec(issue.body)) !== null) { + connections.push(`${match[1]}/${match[2]}#${match[3]}`); + } + + while ((match = regex2.exec(issue.body)) !== null) { + connections.push(`${currentRepo}#${match[1]}`); + } + + return connections.map(comparableKey); +}; + +const buildEdges = ({ allNodes, rootKey, isEdge }) => { + const seen = {}; + const allEdges = {}; + + const preorderTraversal = (currentNode) => { + const relatedIssueKeys = getConnectedNodes(currentNode).filter( + (edgeKey) => isEdge(currentNode.key, edgeKey) + ); + + for (const relatedKey of relatedIssueKeys) { + if (!allNodes[relatedKey]) continue; + if (seen[relatedKey]) continue; + seen[relatedKey] = true; + + if (!allEdges[currentNode.key]) { + allEdges[currentNode.key] = []; + } + + allEdges[currentNode.key].push(relatedKey); + preorderTraversal(allNodes[relatedKey]); + } + }; + + preorderTraversal(allNodes[rootKey]); + return allEdges; +}; + +const buildTree = (allNodes, allEdges, rootKey) => { + const node = { ...allNodes[rootKey] }; + const childrenKeys = allEdges[rootKey] || []; + node.children = childrenKeys.map((childKey) => + buildTree(allNodes, allEdges, childKey) + ); + return node; +}; + +export const fetchMindmapData = async () => { + const allNodes = await fetchData(); + const isEdge = (fromKey, toKey) => { + if (mindmapTrees[fromKey]) { + return true; + } + if (mindmapNodes[fromKey] && mindmapNodes[toKey]) { + return true; + } + return false; + }; + + const mindmapTrees = {}; + const mindmapNodes = {}; + for (const key in allNodes) { + if ( + allNodes[key].labels.nodes.some( + (label) => label.name === '[Type] Mindmap Tree' + ) + ) { + mindmapTrees[key] = allNodes[key]; + mindmapNodes[key] = allNodes[key]; + } else if ( + allNodes[key].labels.nodes.some( + (label) => label.name === '[Type] Mindmap Node' + ) + ) { + mindmapNodes[key] = allNodes[key]; + } + } + + const rootKey = 'wordpress/wordpress-playground#525'; + const allEdges = buildEdges({ + allNodes, + rootKey, + isEdge, + }); + const tree = buildTree(allNodes, allEdges, rootKey); + console.log({ + allNodes, + tree, + }); + + return tree; +}; diff --git a/packages/meta/src/mindmap/v2/index.html b/packages/meta/src/mindmap/v2/index.html new file mode 100644 index 0000000000..a9ddbb3c96 --- /dev/null +++ b/packages/meta/src/mindmap/v2/index.html @@ -0,0 +1,489 @@ + + + + + Mind Map + + + + + + + + + + diff --git a/packages/meta/src/project_board_automation/GitHubApi.php b/packages/meta/src/project_board_automation/GitHubApi.php index c7f63b1bf3..eb334e6fcf 100644 --- a/packages/meta/src/project_board_automation/GitHubApi.php +++ b/packages/meta/src/project_board_automation/GitHubApi.php @@ -10,7 +10,7 @@ public function __construct( $token ) { $this->token = $token; } - public function graphqlQuery( $query, $variables = [] ) { + public function graphqlQuery( $query, $variables = [], $json_assoc = true ) { $ch = curl_init(); curl_setopt( $ch, CURLOPT_URL, "https://api.github.com/graphql" ); curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 ); @@ -23,10 +23,17 @@ public function graphqlQuery( $query, $variables = [] ) { ] ); $response = curl_exec( $ch ); curl_close( $ch ); - $response_data = json_decode( $response, true ); - if ( isset( $response_data['errors'] ) || ( isset( $response['status'] ) && $response['status'] !== 200 ) ) { - var_dump( $response_data ); - throw new Exception( 'GraphQL query failed' ); + $response_data = json_decode( $response, $json_assoc ); + if ($json_assoc) { + if (isset($response_data['errors']) || (isset($response['status']) && $response['status'] !== 200)) { + var_dump($response_data); + throw new Exception('GraphQL query failed'); + } + } else { + if (isset($response_data->errors) || (isset($response->status) && $response->status !== 200)) { + var_dump($response_data); + throw new Exception('GraphQL query failed'); + } } return $response_data; @@ -61,15 +68,16 @@ public function getIssueIdForProjectItemId( $projectItemId ) { public function getProjectItemForIssueId($projectId, $issueId) { - $projectItemSelection = self::PROJECT_ITEM_SELECTION; - $items = $this->graphqlQuery(<<graphqlQuery( + self::FRAGMENT_BASIC_PROJECT_INFO[1] . + <<<'Q' + query($id: ID!) { + node(id: $id) { ... on Issue { id projectItems(first:50) { nodes { - $projectItemSelection + ...BasicProjectInfo project { id } @@ -80,7 +88,7 @@ public function getProjectItemForIssueId($projectId, $issueId) id projectItems(first:50) { nodes { - $projectItemSelection + ...BasicProjectInfo project { id } @@ -133,26 +141,27 @@ static public function extractProjectItemFieldValueById($projectItem, $fieldId) } } - public function iterateProjectItems($projectId) + public function iterateProjectItems($projectId, $fragment=self::FRAGMENT_BASIC_PROJECT_INFO) { $perPage = 100; - $projectItemSelection = self::PROJECT_ITEM_SELECTION; - $query = <<