From 829952a3c2774c7b9f8382d748ffa502be45e564 Mon Sep 17 00:00:00 2001 From: ptdewey Date: Mon, 9 Dec 2024 10:05:21 -0500 Subject: [PATCH 1/3] feat: serve up graph from backend --- index.html | 15 ------- index.js | 20 --------- internal/api/endpoints.go | 3 +- static/graph.js | 92 +++++++++++++++++++++++++++++++++++++++ static/index.html | 26 +++++++++++ static/styles.css | 65 +++++++++++++++++++++++++++ 6 files changed, 185 insertions(+), 36 deletions(-) delete mode 100644 index.html delete mode 100644 index.js create mode 100644 static/graph.js create mode 100644 static/index.html create mode 100644 static/styles.css diff --git a/index.html b/index.html deleted file mode 100644 index f704b79..0000000 --- a/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - -
- - - diff --git a/index.js b/index.js deleted file mode 100644 index 4ca7969..0000000 --- a/index.js +++ /dev/null @@ -1,20 +0,0 @@ -import SpriteText from "//unpkg.com/three-spritetext/dist/three-spritetext.mjs"; - -fetch("http://localhost:11975/graph") - .then((res) => res.json()) - .then((data) => { - const Graph = ForceGraph3D()(document.getElementById("3d-graph")) - .graphData(data) - .nodeAutoColorBy("group") - .nodeThreeObject((node) => { - const sprite = new SpriteText(node.name); - sprite.material.depthWrite = false; - sprite.color = node.color; - sprite.textHeight = 8; - return sprite; - }) - .linkOpacity(0.8) // NOTE: baseline opacity can be adjusted, but keep high - .linkColor((link) => link.color); - // Spread nodes a little wider - Graph.d3Force("charge").strength(-120); - }); diff --git a/internal/api/endpoints.go b/internal/api/endpoints.go index 02b2de8..b6ac407 100644 --- a/internal/api/endpoints.go +++ b/internal/api/endpoints.go @@ -5,7 +5,7 @@ import ( "net/http" ) -// DOC: +// spawn the oolong api server func SpawnServer() { mux := http.NewServeMux() @@ -13,6 +13,7 @@ func SpawnServer() { // graph endpoints mux.HandleFunc("GET /graph", handleGetGraph) + mux.Handle("/", http.FileServer(http.Dir("./static"))) // config endpoints mux.HandleFunc("GET /config", handleGetConfig) diff --git a/static/graph.js b/static/graph.js new file mode 100644 index 0000000..74bbb3e --- /dev/null +++ b/static/graph.js @@ -0,0 +1,92 @@ +const API_BASE_URL = "http://localhost:11975"; +// TODO: pull colors from css +const noteNodeColor = "#B36B42"; +const keywordNodeColor = "#77824A"; +const loColor = [61, 52, 44]; +const hiColor = [215, 196, 132]; + +function styleGraphData(graphData) { + const { nodes, links } = graphData; + + const newNodes = nodes.map((node) => ({ + ...node, + color: node.group === "note" ? noteNodeColor : keywordNodeColor, + })); + + const newLinks = links.map((link) => { + const strength = link.strength / 0.4; // TODO: defer to go for normalization + const color = [ + Math.round(loColor[0] * (1 - strength)) + hiColor[0] * strength, + Math.round(loColor[1] * (1 - strength)) + hiColor[1] * strength, + Math.round(loColor[2] * (1 - strength)) + hiColor[2] * strength, + ]; + return { ...link, color: `rgb(${color[0]}, ${color[1]}, ${color[2]})` }; + }); + + return { nodes: newNodes, links: newLinks }; +} + +let graphInstance = null; +let mode = "2d"; + +async function loadGraphData() { + try { + const response = await fetch(`${API_BASE_URL}/graph`); + const data = await response.json(); + return styleGraphData(data); + } catch (error) { + console.error("Error loading graph data:", error); + return { nodes: [], links: [] }; + } +} + +// TODO: click handler +function clickHandler(node) { + if (node.group === "note") { + console.log(`Clicked on node: ${node.name}`); + } +} + +async function initGraph() { + const graphContainer = document.getElementById("graph-container"); + + if (graphInstance) { + graphInstance._destructor(); + } + + graphContainer.innerHTML = ""; + + const graphData = await loadGraphData(); + + if (mode === "2d") { + graphInstance = ForceGraph()(graphContainer) + .graphData(graphData) + .backgroundColor("#24211e") + .onNodeClick(clickHandler); + } else if (mode === "3d") { + graphInstance = ForceGraph3D()(graphContainer) + .graphData(graphData) + .backgroundColor("#24211e") + .onNodeClick(clickHandler); + } +} + +document.getElementById("2d-button").addEventListener("click", () => { + if (mode !== "2d") { + mode = "2d"; + document.getElementById("2d-button").classList.add("active"); + document.getElementById("3d-button").classList.remove("active"); + initGraph(); + } +}); + +document.getElementById("3d-button").addEventListener("click", () => { + if (mode !== "3d") { + mode = "3d"; + document.getElementById("3d-button").classList.add("active"); + document.getElementById("2d-button").classList.remove("active"); + initGraph(); + } +}); + +initGraph(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..8266558 --- /dev/null +++ b/static/index.html @@ -0,0 +1,26 @@ + + + + + + + Vanilla JS Graph + + + + +
+
+
+ + +
+
+ + + + + + + + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..5da7d93 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,65 @@ +:root { + --dark-brown: #24211E; + --green: #77824A; + --dark-green: #444A2B; + --tan: #D7C484; + --orange: #BB7844; + --dark-orange: #B36B42; + --teal: #5F865F; + --yellow: #C9A654; + --red: #B3664D; + --dark-brown-alt: #3d342c; + --darker-brown: #1c1a18; + + --color-background: var(--dark-brown); + --color-background-muted: var(--dark-brown-alt); + --color-foreground: var(--tan); + --color-foreground-muted: var(--dark-green); + + --color-neutral: var(--dark-brown-alt); + --color-neutral-alt: var(--yellow); + --color-primary: var(--green); + --color-primary-alt: var(--orange); + + --transition-time: 0.3s; +} + +body { + margin: 0; + font-family: Arial, sans-serif; + color: var(--color-foreground); +} + +canvas { + display: block; +} + +.graph-wrapper { + position: relative; + height: 100vh; + background-color: var(--color-background); +} + +.graph-mode-selector { + position: absolute; + bottom: 1em; + left: 1em; + overflow: hidden; +} + +.graph-mode-selector button { + padding: 0.5em 1em; + font-family: Arial, sans-serif; + font-weight: bold; + background-color: var(--color-background); + transition: background-color var(--transition-time); + color: var(--color-foreground); + margin-right: 0.5em; + border: 0.2em solid var(--color-neutral); + border-radius: 0.5em; +} + +.graph-mode-selector button:hover, +.graph-mode-selector button.active { + background-color: var(--color-neutral); +} From a35e92a6576029039f4fe004f31785c58ec627f3 Mon Sep 17 00:00:00 2001 From: ptdewey Date: Mon, 9 Dec 2024 11:17:16 -0500 Subject: [PATCH 2/3] feat: graph node click opens file in editor --- README.md | 37 ++++++++++++++++++++++++++-------- examples/oolong.toml | 12 ++++++++--- internal/api/endpoints.go | 1 + internal/api/note_handlers.go | 38 ++++++++++++++++++++++++++++++++++- internal/config/config.go | 12 +++++++++-- internal/graph/graph.go | 1 + static/graph.js | 12 +++++++++-- 7 files changed, 97 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 1bbd5c4..4b674d2 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ cd oolongd go build ``` -TODO: Docker image, Nix and Homebrew packages? + ## Usage @@ -27,10 +27,10 @@ oolong ``` The service will run in the background, and the API will be accessible on port 11975. +The graph can be opened by navigating to [http://localhost:11975](http://localhost:11975), and clicking on note nodes will open the file in an editor of your choice (as defined in config). -To view the constructed graph and use the web editor see [oolong-web](https://github.com/oolong-sh/oolong-web). -(Serving up a copy of the graph from the backend is a WIP) +To use the Oolong web editor see [oolong-web](https://github.com/oolong-sh/oolong-web). (The web editor also includes a graph integration) ## Configuration @@ -41,13 +41,29 @@ Oolong looks for a configuration file at `~/.config/oolong.toml` | Option | Description | Recommended | |--------|-------------|---------| -| `ngram_range` | Range of NGram sizes to use for keyword linking | `[1, 2, 3]` | | `note_directories` | List of directories to use with Oolong | `["~/notes"]` | | `ignored_directories` | Subdirectories to exclude from reading and linking | `[".git"]` | | `allowed_extensions` | Whitelist of file extensions to use in linking | `[".md", ".txt", ".mdx", ".tex", ".typ"]` | +| `open_command` | Command to run when clicking a graph node | `["code"]` (See below for more details) | +| `ngram_range` | Range of NGram sizes to use for keyword linking | `[1, 2, 3]` | | `stop_words` | Additional stop words to exclude from keyword extraction | `[]` | +The `open_command` option is used by the graph to allow you to open a clicked note in an editor of your choice. + +For example, to open a note in VSCode use `open_command = ["code"]` + +To use your system default editor: +- Linux: `open_command = ["xdg-open"]` +- MacOS: `open_command = ["open"]` +- Windows: `open_command = ["start"]` + +For more situations where you want to run a more complex command, separate consecutive arguments: +- `open_command = ["tmux", "neww", "-c", "shell", "nvim"]` (opens Neovim in a new tmux window in the active session) + + + + **Graph Settings** (required): | Option | Description | Recommended | @@ -75,9 +91,6 @@ Oolong looks for a configuration file at `~/.config/oolong.toml` ### Example Configuration ```toml -# Range of NGram sizes to use for keyword linking -ngram_range = [ 1, 2, 3 ] - # List of directories to read into Oolong note_directories = [ "~/notes", @@ -99,11 +112,19 @@ allowed_extensions = [ ".typ", ] +# Command to run when open endpoint it called (a note node is clicked on the graph) +# Note: All arguments MUST be separated into separate strings (see config for more details) +open_command = [ "code" ] + + +# Range of NGram sizes to use for keyword linking +ngram_range = [ 1, 2, 3 ] + +# Extra stop words to exclude from NGram generation stop_words = [ "hello", ] - # graph settings (required) [graph] min_node_weight = 8.0 diff --git a/examples/oolong.toml b/examples/oolong.toml index 235ff01..fccd268 100644 --- a/examples/oolong.toml +++ b/examples/oolong.toml @@ -1,6 +1,3 @@ -# Range of NGram sizes to use for keyword linking -ngram_range = [ 1, 2, 3 ] - # List of directories to read into Oolong note_directories = [ "~/notes", @@ -21,6 +18,15 @@ allowed_extensions = [ ".typ", ] +# Command to run when open endpoint it called (a note node is clicked on the graph) +open_command = [ "code" ] +# Note: All arguments MUST be separated into separate strings +# (more complicated commands should likely be written into a script) +# i.e. 'open_command = ["tmux", "neww", "-c", "shell", "nvim"]' + +# Range of NGram sizes to use for keyword linking +ngram_range = [ 1, 2, 3 ] + # Additional stop words stop_words = [ "hello", diff --git a/internal/api/endpoints.go b/internal/api/endpoints.go index b6ac407..a247658 100644 --- a/internal/api/endpoints.go +++ b/internal/api/endpoints.go @@ -26,6 +26,7 @@ func SpawnServer() { mux.HandleFunc("POST /note", handleCreateNote) mux.HandleFunc("PUT /note", handleUpdateNote) mux.HandleFunc("DELETE /note", handleDeleteNote) + mux.HandleFunc("GET /open/note", handleOpenNote) // start server log.Println("Starting server on :11975...") diff --git a/internal/api/note_handlers.go b/internal/api/note_handlers.go index ed275c0..c0048cc 100644 --- a/internal/api/note_handlers.go +++ b/internal/api/note_handlers.go @@ -2,12 +2,15 @@ package api import ( "encoding/json" + "errors" "fmt" "log" "net/http" "os" + "os/exec" "path/filepath" + "github.com/oolong-sh/oolong/internal/config" "github.com/oolong-sh/oolong/internal/state" ) @@ -58,7 +61,7 @@ func handleGetNote(w http.ResponseWriter, r *http.Request) { b, err := os.ReadFile(path) if err != nil { log.Println(err) - http.Error(w, "Could not read file '"+path+"'", 500) + http.Error(w, "Could not read file '"+path+"'", 400) return } @@ -169,3 +172,36 @@ func handleDeleteNote(w http.ResponseWriter, r *http.Request) { // NOTE: this function may need to call the update function due to files no longer existing // - check this case in state, this may require substantial logic missing there } + +// 'GET /open/note?path=/path/to/note.md' opens the specified not file using the command specified in oolong.toml +func handleOpenNote(w http.ResponseWriter, r *http.Request) { + log.Println("Request received:", r.Method, r.URL, r.Host) + + path := r.URL.Query().Get("path") + if path == "" { + http.Error(w, "Path parameter not specified ", http.StatusBadRequest) + return + } + + if e, err := exists(path); err != nil { + log.Println(err) + http.Error(w, "Error checking path", 500) + return + } else if !e { + log.Printf("File %s does not exist.\n", path) + http.Error(w, "Note file not found exists", 400) + return + } + + // open file in editor (use command defined in config) + openCommand := append(config.OpenCommand(), path) + cmd := exec.Command(openCommand[0], openCommand[1:]...) + if errors.Is(cmd.Err, exec.ErrDot) { + cmd.Err = nil + } + + if err := cmd.Run(); err != nil { + log.Println(err) + http.Error(w, "Error opening file in editor.", 500) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 84bc371..f75c09d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,10 +13,13 @@ type OolongSyncConfig sync.SyncConfig type OolongConfig struct { NotesDirPaths []string `toml:"note_directories"` - NGramRange []int `toml:"ngram_range"` AllowedExtensions []string `toml:"allowed_extensions"` IgnoreDirectories []string `toml:"ignored_directories"` - StopWords []string `toml:"stop_words"` + OpenCommand []string `toml:"open_command"` + + // TODO: move these things to a "linker" config section + NGramRange []int `toml:"ngram_range"` + StopWords []string `toml:"stop_words"` PluginsConfig OolongPluginConfig `toml:"plugins"` GraphConfig OolongGraphConfig `toml:"graph"` @@ -33,9 +36,14 @@ type OolongGraphConfig struct { MinLinkWeight float64 `toml:"min_link_weight"` } +type OolongEditorConfig struct { + // TODO: web editor related config (themes?) +} + func Config() *OolongConfig { return &cfg } func NotesDirPaths() []string { return cfg.NotesDirPaths } +func OpenCommand() []string { return cfg.OpenCommand } func NGramRange() []int { return cfg.NGramRange } func AllowedExtensions() []string { return cfg.AllowedExtensions } func PluginPaths() []string { return cfg.PluginsConfig.PluginPaths } diff --git a/internal/graph/graph.go b/internal/graph/graph.go index 469cb07..dec73dc 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -53,6 +53,7 @@ func SerializeGraph(keywordMap map[string]keywords.Keyword, notes []notes.Note) for _, keyword := range keywordMap { // Only add nodes above the minimum threshold if keyword.Weight >= minThresh { + // TODO: clamp weights == min to 1 after filtering clampedWeight := clamp(keyword.Weight, minThresh, upperBound) nodes = append(nodes, NodeJSON{ ID: keyword.Keyword, diff --git a/static/graph.js b/static/graph.js index 74bbb3e..6e98562 100644 --- a/static/graph.js +++ b/static/graph.js @@ -40,10 +40,18 @@ async function loadGraphData() { } } -// TODO: click handler -function clickHandler(node) { +async function clickHandler(node) { if (node.group === "note") { console.log(`Clicked on node: ${node.name}`); + try { + const resp = await fetch(`open/note?path=${encodeURIComponent(node.id)}`); + if (!resp.ok) { + throw new Error("Failed to open file"); + } + alert("Open file in editor."); + } catch (err) { + console.log(err); + } } } From f8f96fd93593b7593f6a4cf567d60706c06037d8 Mon Sep 17 00:00:00 2001 From: ptdewey Date: Mon, 9 Dec 2024 12:53:15 -0500 Subject: [PATCH 3/3] feat: default graph mode config option --- README.md | 2 ++ examples/oolong.toml | 10 ++++++---- internal/api/config_handlers.go | 18 ++++++++++++++++++ internal/api/endpoints.go | 1 + internal/config/config.go | 2 ++ static/graph.js | 13 +++++++++++++ 6 files changed, 42 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4b674d2..d8cb171 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ For more situations where you want to run a more complex command, separate conse | min_node_weight | Minimum NGram weight to allow to show up in the graph | `2.0` (Increase to a larger number for large note directories) | | max_node_weight | Maximum size of a node in the graph (larger values are clamped to this size) | `10.0` | | min_link_weight | The minimum allowed link strength between a note and NGram | `0.1` (Increase to a larger number (0.2-0.3) for larger note directories) | +| default_mode | Default graph mode (2d/3d) | `"3d"` | **Cloud Synchronization Settings** (optional): @@ -130,6 +131,7 @@ stop_words = [ min_node_weight = 8.0 max_node_weight = 12.0 min_link_weight = 0.2 +default_mode = "3d" # optional plugins section (not currently recommended) [plugins] diff --git a/examples/oolong.toml b/examples/oolong.toml index fccd268..cf97e56 100644 --- a/examples/oolong.toml +++ b/examples/oolong.toml @@ -32,14 +32,11 @@ stop_words = [ "hello", ] -# List of plugins (lua files) to load -- not recommended -[plugins] -plugin_paths = [ "./scripts/daily_note.lua" ] - [graph] min_node_weight = 4.0 max_node_weight = 80.0 min_link_weight = 0.1 +default_mode = "3d" # # NOTE: do not include the following section if you do not want to use cloud sync @@ -49,3 +46,8 @@ host = "127.0.0.1" # replace with your server hostname/ip user = "your_username" # replace with your server username port = 22 # server ssh port private_key_path = "/home//.ssh/" # replace with your private key path + + +# List of plugins (lua files) to load -- not recommended +# [plugins] +# plugin_paths = [ "./scripts/daily_note.lua" ] diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go index 7127370..a3568b7 100644 --- a/internal/api/config_handlers.go +++ b/internal/api/config_handlers.go @@ -47,3 +47,21 @@ func handleGetNoteDirsConfig(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(config.NotesDirPaths()) } + +func handleGetGraphView(w http.ResponseWriter, r *http.Request) { + log.Println("Request received:", r.Method, r.URL, r.Host) + w.Header().Set("Content-Type", "application/json") + + if err := checkOrigin(w, r); err != nil { + log.Println(err) + http.Error(w, fmt.Sprintln(err), 500) + return + } + + mode := config.GraphMode() + if mode == "" { + mode = "2d" + } + + json.NewEncoder(w).Encode(mode) +} diff --git a/internal/api/endpoints.go b/internal/api/endpoints.go index a247658..fd5b816 100644 --- a/internal/api/endpoints.go +++ b/internal/api/endpoints.go @@ -19,6 +19,7 @@ func SpawnServer() { mux.HandleFunc("GET /config", handleGetConfig) mux.HandleFunc("GET /config/graph", handleGetGraphConfig) mux.HandleFunc("GET /config/note-dirs", handleGetNoteDirsConfig) + mux.HandleFunc("GET /config/default-graph-mode", handleGetGraphView) // note endpoints mux.HandleFunc("GET /notes", handleGetNotes) diff --git a/internal/config/config.go b/internal/config/config.go index f75c09d..4139b98 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,6 +34,7 @@ type OolongGraphConfig struct { MinNodeWeight float64 `toml:"min_node_weight"` MaxNodeWeight float64 `toml:"max_node_weight"` MinLinkWeight float64 `toml:"min_link_weight"` + DefaultMode string `toml:"default_mode"` } type OolongEditorConfig struct { @@ -50,6 +51,7 @@ func PluginPaths() []string { return cfg.PluginsConfig.PluginPaths func IgnoredDirectories() []string { return cfg.IgnoreDirectories } func StopWords() []string { return cfg.StopWords } func WeightThresholds() OolongGraphConfig { return cfg.GraphConfig } +func GraphMode() string { return cfg.GraphConfig.DefaultMode } func SyncConfig() OolongSyncConfig { return cfg.SyncConfig } // TODO: file watcher for config file, reload on change diff --git a/static/graph.js b/static/graph.js index 6e98562..3c237bc 100644 --- a/static/graph.js +++ b/static/graph.js @@ -55,6 +55,17 @@ async function clickHandler(node) { } } +async function getDefaultMode() { + try { + let response = await fetch(`${API_BASE_URL}/config/default-graph-mode`); + let data = response.json(); + return data; + } catch (error) { + console.error("Error fetching config data:", error); + return "2d"; + } +} + async function initGraph() { const graphContainer = document.getElementById("graph-container"); @@ -62,6 +73,8 @@ async function initGraph() { graphInstance._destructor(); } + mode = await getDefaultMode(); + graphContainer.innerHTML = ""; const graphData = await loadGraphData();