diff --git a/cmd/memexd/main.go b/cmd/memexd/main.go index 3ff66c1..94ab81b 100644 --- a/cmd/memexd/main.go +++ b/cmd/memexd/main.go @@ -7,50 +7,62 @@ import ( "html/template" "log" "net/http" + "os" "path/filepath" - "github.com/systemshift/memex/internal/memex/storage" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/systemshift/memex/internal/memex/core" + "github.com/systemshift/memex/internal/memex/repository" ) +// Server handles HTTP requests and manages the repository type Server struct { - memex *storage.MXStore + repo core.Repository template *template.Template } -type GraphData struct { - Nodes []NodeData `json:"nodes"` - Edges []EdgeData `json:"edges"` +// GraphResponse represents the graph visualization data +type GraphResponse struct { + Nodes []NodeResponse `json:"nodes"` + Links []LinkResponse `json:"links"` } -type NodeData struct { - ID string `json:"id"` - Type string `json:"type"` - Meta map[string]any `json:"meta"` - Created string `json:"created"` +// NodeResponse represents a node in the graph visualization +type NodeResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Meta map[string]interface{} `json:"meta"` + Created string `json:"created"` + Modified string `json:"modified"` } -type EdgeData struct { - Source string `json:"source"` - Target string `json:"target"` - Type string `json:"type"` - Meta map[string]any `json:"meta"` +// LinkResponse represents a link in the graph visualization +type LinkResponse struct { + Source string `json:"source"` + Target string `json:"target"` + Type string `json:"type"` + Meta map[string]interface{} `json:"meta"` + Created string `json:"created"` + Modified string `json:"modified"` } func main() { + // Parse command line flags addr := flag.String("addr", ":3000", "HTTP service address") - repo := flag.String("repo", "", "Repository path") + repoPath := flag.String("repo", "", "Repository path") flag.Parse() - if *repo == "" { + if *repoPath == "" { log.Fatal("Repository path required") } - // Open repository - store, err := storage.OpenMX(*repo) + // Initialize repository + repo, err := repository.Open(*repoPath) if err != nil { log.Fatalf("Error opening repository: %v", err) } - defer store.Close() + defer repo.Close() // Parse templates tmpl, err := template.ParseGlob("cmd/memexd/templates/*.html") @@ -60,25 +72,37 @@ func main() { // Create server server := &Server{ - memex: store, + repo: repo, template: tmpl, } - // Setup routes - http.HandleFunc("/", server.handleIndex) - http.HandleFunc("/api/graph", server.handleGraph) - http.HandleFunc("/api/content/", server.handleContent) - http.HandleFunc("/node/", server.handleNode) + // Create router + r := chi.NewRouter() - // Serve static files - fs := http.FileServer(http.Dir("cmd/memexd/static")) - http.Handle("/static/", http.StripPrefix("/static/", fs)) + // Middleware + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.Compress(5)) + + // Routes + r.Get("/", server.handleIndex) + r.Route("/api", func(r chi.Router) { + r.Get("/graph", server.handleGraph) + r.Get("/nodes/{id}", server.handleGetNode) + r.Get("/nodes/{id}/content", server.handleGetContent) + }) + + // Static files + workDir, _ := os.Getwd() + filesDir := http.Dir(filepath.Join(workDir, "cmd/memexd/static")) + r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(filesDir))) // Start server log.Printf("Starting server on %s", *addr) - log.Fatal(http.ListenAndServe(*addr, nil)) + log.Fatal(http.ListenAndServe(*addr, r)) } +// handleIndex serves the main page func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { if err := s.template.ExecuteTemplate(w, "index.html", nil); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -86,95 +110,135 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { } } +// handleGraph returns the graph data for visualization func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) { // Get all nodes - var graph GraphData - for _, entry := range s.memex.Nodes() { - node, err := s.memex.GetNode(fmt.Sprintf("%x", entry.ID[:])) + nodeIDs, err := s.repo.ListNodes() + if err != nil { + http.Error(w, fmt.Sprintf("Error listing nodes: %v", err), http.StatusInternalServerError) + return + } + + response := GraphResponse{ + Nodes: make([]NodeResponse, 0, len(nodeIDs)), + Links: make([]LinkResponse, 0), + } + + // Process each node + for _, id := range nodeIDs { + node, err := s.repo.GetNode(id) if err != nil { + log.Printf("Error getting node %s: %v", id, err) continue } - // Add node - graph.Nodes = append(graph.Nodes, NodeData{ - ID: node.ID, - Type: node.Type, - Meta: node.Meta, - Created: node.Created.Format("2006-01-02 15:04:05"), + // Add node to response + response.Nodes = append(response.Nodes, NodeResponse{ + ID: node.ID, + Type: node.Type, + Meta: node.Meta, + Created: node.Created.Format("2006-01-02 15:04:05"), + Modified: node.Modified.Format("2006-01-02 15:04:05"), }) - // Get links - links, err := s.memex.GetLinks(node.ID) + // Get and process links + links, err := s.repo.GetLinks(node.ID) if err != nil { + log.Printf("Error getting links for node %s: %v", id, err) continue } - // Add edges for _, link := range links { - graph.Edges = append(graph.Edges, EdgeData{ - Source: node.ID, - Target: link.Target, - Type: link.Type, - Meta: link.Meta, + response.Links = append(response.Links, LinkResponse{ + Source: link.Source, + Target: link.Target, + Type: link.Type, + Meta: link.Meta, + Created: link.Created.Format("2006-01-02 15:04:05"), + Modified: link.Modified.Format("2006-01-02 15:04:05"), }) } } - // Write response + // Send response w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(graph); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + if err := json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError) return } } -func (s *Server) handleContent(w http.ResponseWriter, r *http.Request) { - // Get content hash from URL - hash := filepath.Base(r.URL.Path) +// handleGetNode returns information about a specific node +func (s *Server) handleGetNode(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + http.Error(w, "Node ID required", http.StatusBadRequest) + return + } - // Reconstruct content from chunks - content, err := s.memex.ReconstructContent(hash) + node, err := s.repo.GetNode(id) if err != nil { - http.Error(w, "Content not found", http.StatusNotFound) + http.Error(w, fmt.Sprintf("Error getting node: %v", err), http.StatusNotFound) return } - // Write response - w.Header().Set("Content-Type", "text/plain") - w.Write(content) + // Create response with formatted timestamps + response := struct { + ID string `json:"id"` + Type string `json:"type"` + Content string `json:"content"` + Meta map[string]interface{} `json:"meta"` + Created string `json:"created"` + Modified string `json:"modified"` + }{ + ID: node.ID, + Type: node.Type, + Content: string(node.Content), + Meta: node.Meta, + Created: node.Created.Format("2006-01-02 15:04:05"), + Modified: node.Modified.Format("2006-01-02 15:04:05"), + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError) + return + } } -func (s *Server) handleNode(w http.ResponseWriter, r *http.Request) { - // Get node ID from URL - id := filepath.Base(r.URL.Path) +// handleGetContent returns the content of a node +func (s *Server) handleGetContent(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + http.Error(w, "Node ID required", http.StatusBadRequest) + return + } - // Get node - node, err := s.memex.GetNode(id) + // Get node first to check type and get metadata + node, err := s.repo.GetNode(id) if err != nil { - http.Error(w, "Node not found", http.StatusNotFound) + http.Error(w, fmt.Sprintf("Error getting node: %v", err), http.StatusNotFound) return } - // If file, serve content - if node.Type == "file" { - if contentHash, ok := node.Meta["content"].(string); ok { - content, err := s.memex.ReconstructContent(contentHash) - if err != nil { - http.Error(w, "Content not found", http.StatusNotFound) - return - } + // Get content + content, err := s.repo.GetContent(id) + if err != nil { + http.Error(w, fmt.Sprintf("Error getting content: %v", err), http.StatusNotFound) + return + } - // Set filename for download - if filename, ok := node.Meta["filename"].(string); ok { - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) - } + // Set content type if available in metadata + if contentType, ok := node.Meta["content-type"].(string); ok { + w.Header().Set("Content-Type", contentType) + } else { + w.Header().Set("Content-Type", "application/octet-stream") + } - w.Write(content) - return - } + // Set filename for download if available + if filename, ok := node.Meta["filename"].(string); ok { + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) } - // Otherwise show node info - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(node) + w.Write(content) } diff --git a/cmd/memexd/static/css/graph.css b/cmd/memexd/static/css/graph.css index e4b05a0..ee2294e 100644 --- a/cmd/memexd/static/css/graph.css +++ b/cmd/memexd/static/css/graph.css @@ -1,148 +1,104 @@ -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - margin: 0; - padding: 20px; - background: #f5f5f5; +/* Graph styles */ +#graph-container { display: flex; -} - -.main-content { - flex: 1; - margin-right: 20px; + width: 100%; + height: 100vh; } #graph { - width: 100%; - height: calc(100vh - 100px); - background: white; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + flex: 1; + background-color: #fff; } -.toolbar { - background: white; - padding: 10px; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); - margin-bottom: 10px; - display: flex; - align-items: center; - gap: 15px; +#node-details { + width: 300px; + padding: 20px; + background-color: #f5f5f5; + border-left: 1px solid #ddd; + overflow-y: auto; + display: none; } -.toolbar button { - padding: 8px 12px; - border: none; - border-radius: 4px; - background: #f0f0f0; +/* Node and link styles */ +.node circle { + stroke: #fff; + stroke-width: 1.5px; cursor: pointer; - font-size: 14px; - display: flex; - align-items: center; - gap: 5px; -} - -.toolbar button:hover { - background: #e0e0e0; } -.toolbar button.active { - background: #1976d2; - color: white; +.node text { + fill: #333; + font-family: Arial, sans-serif; + pointer-events: none; } -.legend { - background: white; - padding: 15px; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); - margin-bottom: 10px; +.link { + stroke: #999; + stroke-opacity: 0.6; } -.legend h3 { - margin: 0 0 10px 0; - font-size: 14px; - color: #666; -} - -.legend-item { - display: flex; - align-items: center; - margin-bottom: 8px; - font-size: 13px; -} - -.legend-color { - width: 20px; - height: 20px; - border-radius: 50%; - margin-right: 8px; - border: 2px solid; +/* Tooltip styles */ +.tooltip { + position: absolute; + padding: 8px; + font: 12px sans-serif; + background: rgba(0, 0, 0, 0.8); + color: #fff; + border-radius: 4px; + pointer-events: none; + z-index: 1000; } -.node-info { - position: fixed; - right: 20px; - top: 20px; - width: 300px; - background: white; - padding: 20px; - border-radius: 8px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); - display: none; - max-height: calc(100vh - 80px); - overflow-y: auto; +/* General styles */ +body { + margin: 0; + padding: 0; + font-family: Arial, sans-serif; + overflow: hidden; } -.node-info h3 { - margin: 0 0 10px 0; +h3 { + margin-top: 0; color: #333; - display: flex; - justify-content: space-between; - align-items: center; } -.node-info .close { - cursor: pointer; - color: #666; - font-size: 20px; +a { + color: #1f77b4; + text-decoration: none; } -.node-info p { - margin: 0 0 15px 0; - color: #666; - line-height: 1.4; +a:hover { + text-decoration: underline; } -.node-info .label { - font-weight: 500; - color: #333; - margin-bottom: 5px; +/* Node details panel */ +#node-details { + padding: 20px; + background: #fff; + box-shadow: -2px 0 5px rgba(0,0,0,0.1); } -.node-info .content { - background: #f8f8f8; - padding: 10px; - border-radius: 4px; - font-family: monospace; - white-space: pre-wrap; +#node-details h3 { margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid #eee; } -.node-info .links { - border-top: 1px solid #eee; - padding-top: 15px; +#node-details p { + margin: 8px 0; + line-height: 1.4; } -.node-info .links div { - margin-bottom: 8px; - padding: 8px; - background: #f8f8f8; - border-radius: 4px; - cursor: pointer; - transition: background 0.2s; +#node-details strong { + color: #555; } -.node-info .links div:hover { - background: #eee; +/* Loading state */ +.loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 16px; + color: #666; } diff --git a/cmd/memexd/static/js/graph.js b/cmd/memexd/static/js/graph.js index a604b7d..4e82403 100644 --- a/cmd/memexd/static/js/graph.js +++ b/cmd/memexd/static/js/graph.js @@ -1,164 +1,228 @@ -// Create a network -var container = document.getElementById('graph'); -var nodeInfo = document.getElementById('nodeInfo'); -var nodeTitle = document.getElementById('nodeTitle'); -var nodeType = document.getElementById('nodeType'); -var nodeContent = document.getElementById('nodeContent'); -var nodeLinks = document.getElementById('nodeLinks'); - -// Load graph data from API -fetch('/api/graph') - .then(response => response.json()) - .then(data => { - // Create nodes with different colors based on type - var nodes = new vis.DataSet(data.nodes.map(node => ({ - id: node.id, - label: node.meta.filename || 'Note', - color: { - background: node.type === 'file' ? '#e3f2fd' : '#fce4ec', - border: node.type === 'file' ? '#1976d2' : '#e91e63', - highlight: { - background: node.type === 'file' ? '#bbdefb' : '#f8bbd0', - border: node.type === 'file' ? '#1565c0' : '#c2185b' - } - }, - font: { - color: '#333', - size: 14 - }, - data: node - }))); - - // Create edges with labels - var edges = new vis.DataSet(data.edges.map(edge => ({ - from: edge.source, - to: edge.target, - label: edge.type, - arrows: 'to', - color: { - color: '#666', - highlight: '#333' - }, - font: { - size: 12, - color: '#666', - strokeWidth: 0, - background: 'white' - }, - data: edge - }))); - - // Create network - var network = new vis.Network(container, { - nodes: nodes, - edges: edges - }, { - nodes: { - shape: 'dot', - size: 20, - borderWidth: 2, - shadow: true - }, - edges: { - width: 2, - smooth: { - type: 'continuous' - }, - shadow: true - }, - physics: { - enabled: true, - barnesHut: { - gravitationalConstant: -2000, - centralGravity: 0.3, - springLength: 150, - springConstant: 0.04, - damping: 0.09 - }, - stabilization: { - iterations: 100, - updateInterval: 50 - } - }, - interaction: { - hover: true, - tooltipDelay: 200, - zoomView: true, - dragView: true - }, - layout: { - improvedLayout: true, - hierarchical: { - enabled: false - } - } - }); - - // Show node info on click - network.on('click', function(params) { - if (params.nodes.length > 0) { - var nodeId = params.nodes[0]; - var node = nodes.get(nodeId); - - // Show node info panel - nodeTitle.textContent = node.label; - nodeType.textContent = node.data.type; - - // Load content if available - if (node.data.meta.content) { - fetch(`/api/content/${node.data.meta.content}`) - .then(response => response.text()) - .then(content => { - nodeContent.textContent = content; - }); - } else { - nodeContent.textContent = 'No content available'; +// Graph visualization using D3.js +const width = 900; +const height = 600; + +// Create SVG container +const svg = d3.select('#graph') + .attr('width', width) + .attr('height', height); + +// Create forces for graph layout +const simulation = d3.forceSimulation() + .force('link', d3.forceLink().id(d => d.id).distance(100)) + .force('charge', d3.forceManyBody().strength(-300)) + .force('center', d3.forceCenter(width / 2, height / 2)) + .force('collision', d3.forceCollide().radius(50)); + +// Load and render graph data +async function loadGraph() { + try { + // Show loading state + svg.append('text') + .attr('class', 'loading') + .attr('x', width / 2) + .attr('y', height / 2) + .attr('text-anchor', 'middle') + .text('Loading graph...'); + + const response = await fetch('/api/graph'); + if (!response.ok) throw new Error('Failed to load graph data'); + const graph = await response.json(); + + // Clear loading state + svg.selectAll('.loading').remove(); + + // Create arrow marker for directed links + svg.append('defs').selectAll('marker') + .data(['arrow']) + .join('marker') + .attr('id', d => d) + .attr('viewBox', '0 -5 10 10') + .attr('refX', 20) + .attr('refY', 0) + .attr('markerWidth', 6) + .attr('markerHeight', 6) + .attr('orient', 'auto') + .append('path') + .attr('d', 'M0,-5L10,0L0,5') + .attr('fill', '#999'); + + // Create links + const link = svg.append('g') + .attr('class', 'links') + .selectAll('line') + .data(graph.links) + .join('line') + .attr('stroke', '#999') + .attr('stroke-opacity', 0.6) + .attr('stroke-width', 1) + .attr('marker-end', 'url(#arrow)') + .on('mouseover', function(event, d) { + showTooltip(event, `Type: ${d.type}\nCreated: ${d.created}\nModified: ${d.modified}`); + }) + .on('mouseout', hideTooltip); + + // Create nodes + const node = svg.append('g') + .attr('class', 'nodes') + .selectAll('g') + .data(graph.nodes) + .join('g') + .call(drag(simulation)); + + // Add circles to nodes + node.append('circle') + .attr('r', 10) + .attr('fill', d => getNodeColor(d.type)) + .on('mouseover', function(event, d) { + const tooltip = `Type: ${d.type}\nCreated: ${d.created}\nModified: ${d.modified}`; + showTooltip(event, tooltip); + }) + .on('mouseout', hideTooltip) + .on('click', async function(event, d) { + try { + const response = await fetch(`/api/nodes/${d.id}`); + if (!response.ok) throw new Error('Failed to load node data'); + const nodeData = await response.json(); + showNodeDetails(nodeData); + } catch (error) { + console.error('Error loading node details:', error); } + }); - // Show links - var nodeEdges = edges.get({ - filter: edge => edge.from === nodeId || edge.to === nodeId - }); - nodeLinks.innerHTML = nodeEdges.map(edge => { - var direction = edge.from === nodeId ? 'to' : 'from'; - var otherNode = nodes.get(direction === 'to' ? edge.to : edge.from); - var note = edge.data.meta.note ? ` (${edge.data.meta.note})` : ''; - return `
ID: ${node.id}
+Type: ${node.type}
+Created: ${node.created}
+Modified: ${node.modified}
+ `; + + if (node.meta) { + content += '${key}: ${value}
`; } - }); - - // Stabilize the network - network.once('stabilizationIterationsDone', function() { - network.setOptions({ physics: false }); - document.getElementById('physics').classList.remove('active'); - }); - - // Toolbar actions - document.getElementById('zoomIn').onclick = function() { - network.zoomIn(); - }; - document.getElementById('zoomOut').onclick = function() { - network.zoomOut(); - }; - document.getElementById('fitGraph').onclick = function() { - network.fit(); - }; - document.getElementById('physics').onclick = function() { - var physics = !network.physics.options.enabled; - network.setOptions({ physics: { enabled: physics } }); - this.classList.toggle('active', physics); - }; - }); + } + } + + if (node.content) { + content += '${node.content}`; + } + + if (node.type === 'file') { + content += ``; + } + + panel.innerHTML = content; + panel.style.display = 'block'; +} + +// Load graph on page load +document.addEventListener('DOMContentLoaded', loadGraph); diff --git a/cmd/memexd/templates/index.html b/cmd/memexd/templates/index.html index d69d4da..eb2f8eb 100644 --- a/cmd/memexd/templates/index.html +++ b/cmd/memexd/templates/index.html @@ -1,66 +1,17 @@ - + -