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 @@ + + +
+ +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 @@ + + + + +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 = <<