From eb5ec54cf17bf9c3be7d40feb19ebf2a39764c4f Mon Sep 17 00:00:00 2001 From: Hugo Osvaldo Barrera Date: Mon, 19 Jan 2026 18:29:23 +0100 Subject: [PATCH] Resolve links relative to notebook When running commands like such as the following: zk list --notebook-dir=/path/to/notebook --linked-by inbox/note.md The path is resolved relative to the current working directory. If the current working directory is not the notebook directory of a child of it, resolve the link relative to the notebook root. --- internal/core/fs_test.go | 7 ++- internal/core/notebook.go | 15 +++++- internal/core/notebook_test.go | 68 ++++++++++++++++++++++++++ tests/linked-by-hierarchical-path.tesh | 11 +++++ 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 internal/core/notebook_test.go create mode 100644 tests/linked-by-hierarchical-path.tesh diff --git a/internal/core/fs_test.go b/internal/core/fs_test.go index bf9dda8c..34657331 100644 --- a/internal/core/fs_test.go +++ b/internal/core/fs_test.go @@ -3,6 +3,7 @@ package core import ( "path/filepath" "slices" + "strings" ) // fileStorageMock implements an in-memory FileStorage for testing purposes. @@ -61,7 +62,11 @@ func (fs *fileStorageMock) DirExists(path string) (bool, error) { } func (fs *fileStorageMock) IsDescendantOf(dir string, path string) (bool, error) { - panic("not implemented") + rel, err := filepath.Rel(dir, path) + if err != nil { + return false, err + } + return !filepath.IsAbs(rel) && !strings.HasPrefix(rel, ".."), nil } func (fs *fileStorageMock) Read(path string) ([]byte, error) { diff --git a/internal/core/notebook.go b/internal/core/notebook.go index 04e95524..bb29f458 100644 --- a/internal/core/notebook.go +++ b/internal/core/notebook.go @@ -245,7 +245,20 @@ func (n *Notebook) FindCollections(kind CollectionKind, sorters []CollectionSort // RelPath returns the path relative to the notebook root to the given path. func (n *Notebook) RelPath(originalPath string) (string, error) { - path, err := n.fs.Abs(originalPath) + path := originalPath + if !filepath.IsAbs(path) { + isInsideNotebook, err := n.fs.IsDescendantOf(n.Path, n.fs.WorkingDir()) + if err != nil { + return path, fmt.Errorf("%v: not a valid notebook path: %w", originalPath, err) + } + if isInsideNotebook { + path = filepath.Join(n.fs.WorkingDir(), path) + } else { + path = filepath.Join(n.Path, path) + } + } + + path, err := n.fs.Abs(path) if err != nil { return path, fmt.Errorf("%v: not a valid notebook path: %w", originalPath, err) } diff --git a/internal/core/notebook_test.go b/internal/core/notebook_test.go new file mode 100644 index 00000000..668ce7ed --- /dev/null +++ b/internal/core/notebook_test.go @@ -0,0 +1,68 @@ +package core + +import ( + "testing" + + "github.com/zk-org/zk/internal/util" + "github.com/zk-org/zk/internal/util/test/assert" +) + +// TestRelPathResolvesInsideNotebook tests that Notebook.RelPath() resolves +// relative paths relative to the notebook directory. +func TestRelPathResolvesInsideNotebook(t *testing.T) { + notebookPath := "/notebook" + workingDir := "/notebook" + + fs := newFileStorageMock(workingDir, []string{notebookPath}) + + notebook := NewNotebook(notebookPath, NewDefaultConfig(), NotebookPorts{ + FS: fs, + Logger: &util.NullLogger, + }) + + relPath, err := notebook.RelPath("inbox/note.md") + + assert.Nil(t, err) + assert.Equal(t, relPath, "inbox/note.md") +} + +// TestRelPathResolvesOutsideNotebook tests that Notebook.RelPath() resolves +// relative paths relative to the notebook directory, not the current working +// directory. +func TestRelPathResolvesOutsideNotebook(t *testing.T) { + notebookPath := "/notebook" + workingDir := "/other/dir" + + fs := newFileStorageMock(workingDir, []string{notebookPath}) + + notebook := NewNotebook(notebookPath, NewDefaultConfig(), NotebookPorts{ + FS: fs, + Logger: &util.NullLogger, + }) + + relPath, err := notebook.RelPath("inbox/note.md") + + assert.Nil(t, err) + assert.Equal(t, relPath, "inbox/note.md") +} + +// TestRelPathResolvesInsideNotebookSubdir tests that Notebook.RelPath() resolves +// relative paths relative to the current working directory when it is a child +// of the notebook root. +func TestRelPathResolvesInsideNotebookSubdir(t *testing.T) { + notebookPath := "/notebook" + workingDir := "/notebook/subdir" + + fs := newFileStorageMock(workingDir, []string{notebookPath, workingDir}) + + notebook := NewNotebook(notebookPath, NewDefaultConfig(), NotebookPorts{ + FS: fs, + Logger: &util.NullLogger, + }) + + // Relative path from /notebook/subdir should resolve to /notebook/subdir/note.md + relPath, err := notebook.RelPath("note.md") + + assert.Nil(t, err) + assert.Equal(t, relPath, "subdir/note.md") +} diff --git a/tests/linked-by-hierarchical-path.tesh b/tests/linked-by-hierarchical-path.tesh new file mode 100644 index 00000000..8d7808c6 --- /dev/null +++ b/tests/linked-by-hierarchical-path.tesh @@ -0,0 +1,11 @@ +# Running from notebook directory. +$ cd full-sample +$ zk list -qf\{{title}} --linked-by inbox/er4k.md +>Channel +>Ownership in Rust + +# Running from outside notebook with --notebook-dir. +$ cd .. +$ zk list --notebook-dir=full-sample -qf\{{title}} --linked-by inbox/er4k.md +>Channel +>Ownership in Rust