From 1619b1741dfd70c8a7079d1bf51f8ce5306d47d5 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 7 May 2026 20:42:27 +0800 Subject: [PATCH 01/37] feat: initialize Logseq CLI with core functionality - Add go.mod and go.sum for dependency management. - Implement main entry point for the CLI with command options for Logseq API. - Create logseq package with client and API methods for interacting with Logseq. - Implement page and block operations including create, delete, update, and query functionalities. - Add types for pages, blocks, and search results to facilitate API responses. --- .gitignore | 5 +- cmds/block.go | 174 +++++++++++++++++++ cmds/cmds.go | 30 ++++ cmds/graph.go | 88 ++++++++++ cmds/page.go | 134 +++++++++++++++ docs/ANALYSIS.md | 401 +++++++++++++++++++++++++++++++++++++++++++ go.mod | 21 +++ go.sum | 31 ++++ main.go | 60 +++++++ pkg/logseq/app.go | 44 +++++ pkg/logseq/client.go | 134 +++++++++++++++ pkg/logseq/db.go | 18 ++ pkg/logseq/editor.go | 249 +++++++++++++++++++++++++++ pkg/logseq/types.go | 100 +++++++++++ 14 files changed, 1487 insertions(+), 2 deletions(-) create mode 100644 cmds/block.go create mode 100644 cmds/cmds.go create mode 100644 cmds/graph.go create mode 100644 cmds/page.go create mode 100644 docs/ANALYSIS.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 pkg/logseq/app.go create mode 100644 pkg/logseq/client.go create mode 100644 pkg/logseq/db.go create mode 100644 pkg/logseq/editor.go create mode 100644 pkg/logseq/types.go diff --git a/.gitignore b/.gitignore index aaadf73..8bca83a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,5 +28,6 @@ go.work.sum .env # Editor/IDE -# .idea/ -# .vscode/ +.idea/ +.vscode/ +.local/ diff --git a/cmds/block.go b/cmds/block.go new file mode 100644 index 0000000..b8a3e07 --- /dev/null +++ b/cmds/block.go @@ -0,0 +1,174 @@ +package cmds + +import ( + "context" + "fmt" + + "github.com/pubgo/logseq-cli/pkg/logseq" + "github.com/pubgo/redant" +) + +func BlockCmd() *redant.Command { + return &redant.Command{ + Use: "block", + Short: "Block management", + Children: []*redant.Command{ + blockGetCmd(), + blockInsertCmd(), + blockUpdateCmd(), + blockRemoveCmd(), + blockMoveCmd(), + blockPrependCmd(), + blockAppendCmd(), + }, + } +} + +func blockGetCmd() *redant.Command { + var includeChildren bool + return &redant.Command{ + Use: "get ", + Short: "Get a block by UUID", + Options: redant.OptionSet{ + { + Flag: "children", + Shorthand: "c", + Description: "Include child blocks", + Value: redant.BoolOf(&includeChildren), + }, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + if len(inv.Args) == 0 { + return fmt.Errorf("block UUID required") + } + client := NewClient() + block, err := client.GetBlock(ctx, inv.Args[0], includeChildren) + if err != nil { + return err + } + if block == nil { + return fmt.Errorf("block '%s' not found", inv.Args[0]) + } + return PrintJSON(block) + }, + } +} + +func blockInsertCmd() *redant.Command { + var sibling bool + return &redant.Command{ + Use: "insert ", + Short: "Insert a block", + Options: redant.OptionSet{ + { + Flag: "sibling", + Shorthand: "s", + Description: "Insert as sibling (default: child)", + Value: redant.BoolOf(&sibling), + }, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + if len(inv.Args) < 2 { + return fmt.Errorf("target UUID and content required") + } + client := NewClient() + block, err := client.InsertBlock(ctx, inv.Args[0], inv.Args[1], &logseq.InsertBlockOptions{ + Sibling: sibling, + }) + if err != nil { + return err + } + return PrintJSON(block) + }, + } +} + +func blockUpdateCmd() *redant.Command { + return &redant.Command{ + Use: "update ", + Short: "Update block content", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + if len(inv.Args) < 2 { + return fmt.Errorf("block UUID and content required") + } + client := NewClient() + if err := client.UpdateBlock(ctx, inv.Args[0], inv.Args[1]); err != nil { + return err + } + fmt.Fprintf(inv.Stdout, "updated block: %s\n", inv.Args[0]) + return nil + }, + } +} + +func blockRemoveCmd() *redant.Command { + return &redant.Command{ + Use: "remove ", + Short: "Remove a block", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + if len(inv.Args) == 0 { + return fmt.Errorf("block UUID required") + } + client := NewClient() + if err := client.RemoveBlock(ctx, inv.Args[0]); err != nil { + return err + } + fmt.Fprintf(inv.Stdout, "removed block: %s\n", inv.Args[0]) + return nil + }, + } +} + +func blockMoveCmd() *redant.Command { + return &redant.Command{ + Use: "move ", + Short: "Move a block", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + if len(inv.Args) < 2 { + return fmt.Errorf("source UUID and target UUID required") + } + client := NewClient() + if err := client.MoveBlock(ctx, inv.Args[0], inv.Args[1], nil); err != nil { + return err + } + fmt.Fprintf(inv.Stdout, "moved block: %s -> %s\n", inv.Args[0], inv.Args[1]) + return nil + }, + } +} + +func blockPrependCmd() *redant.Command { + return &redant.Command{ + Use: "prepend ", + Short: "Prepend block to page", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + if len(inv.Args) < 2 { + return fmt.Errorf("page name and content required") + } + client := NewClient() + block, err := client.PrependBlockInPage(ctx, inv.Args[0], inv.Args[1]) + if err != nil { + return err + } + return PrintJSON(block) + }, + } +} + +func blockAppendCmd() *redant.Command { + return &redant.Command{ + Use: "append ", + Short: "Append block to page", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + if len(inv.Args) < 2 { + return fmt.Errorf("page name and content required") + } + client := NewClient() + block, err := client.AppendBlockInPage(ctx, inv.Args[0], inv.Args[1]) + if err != nil { + return err + } + return PrintJSON(block) + }, + } +} diff --git a/cmds/cmds.go b/cmds/cmds.go new file mode 100644 index 0000000..527e38e --- /dev/null +++ b/cmds/cmds.go @@ -0,0 +1,30 @@ +package cmds + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/pubgo/logseq-cli/pkg/logseq" +) + +var ( + Token string + Host string + Port string + Output string +) + +func NewClient() *logseq.Client { + baseURL := fmt.Sprintf("http://%s:%s", Host, Port) + return logseq.NewClient( + logseq.WithBaseURL(baseURL), + logseq.WithToken(Token), + ) +} + +func PrintJSON(v any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(v) +} diff --git a/cmds/graph.go b/cmds/graph.go new file mode 100644 index 0000000..ae1660c --- /dev/null +++ b/cmds/graph.go @@ -0,0 +1,88 @@ +package cmds + +import ( + "context" + "fmt" + + "github.com/pubgo/redant" +) + +func GraphCmd() *redant.Command { + return &redant.Command{ + Use: "graph", + Short: "Graph operations", + Children: []*redant.Command{ + { + Use: "info", + Short: "Get current graph info", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + client := NewClient() + info, err := client.GetCurrentGraph(ctx) + if err != nil { + return err + } + return PrintJSON(info) + }, + }, + }, + } +} + +func QueryCmd() *redant.Command { + return &redant.Command{ + Use: "query", + Short: "Query operations", + Children: []*redant.Command{ + { + Use: "datalog ", + Short: "Execute Datalog query", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + if len(inv.Args) == 0 { + return fmt.Errorf("datalog query required") + } + client := NewClient() + result, err := client.DatascriptQuery(ctx, inv.Args[0]) + if err != nil { + return err + } + fmt.Fprintln(inv.Stdout, string(result)) + return nil + }, + }, + { + Use: "dsl ", + Short: "Execute Logseq DSL query", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + if len(inv.Args) == 0 { + return fmt.Errorf("DSL query required") + } + client := NewClient() + result, err := client.DSLQuery(ctx, inv.Args[0]) + if err != nil { + return err + } + fmt.Fprintln(inv.Stdout, string(result)) + return nil + }, + }, + }, + } +} + +func SearchCmd() *redant.Command { + return &redant.Command{ + Use: "search ", + Short: "Full-text search", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + if len(inv.Args) == 0 { + return fmt.Errorf("search query required") + } + client := NewClient() + result, err := client.Search(ctx, inv.Args[0]) + if err != nil { + return err + } + return PrintJSON(result) + }, + } +} diff --git a/cmds/page.go b/cmds/page.go new file mode 100644 index 0000000..e1d6de9 --- /dev/null +++ b/cmds/page.go @@ -0,0 +1,134 @@ +package cmds + +import ( + "context" + "fmt" + + "github.com/pubgo/logseq-cli/pkg/logseq" + "github.com/pubgo/redant" +) + +func PageCmd() *redant.Command { + return &redant.Command{ + Use: "page", + Short: "Page management", + Children: []*redant.Command{ + pageListCmd(), + pageGetCmd(), + pageCreateCmd(), + pageDeleteCmd(), + pageRenameCmd(), + }, + } +} + +func pageListCmd() *redant.Command { + return &redant.Command{ + Use: "list", + Short: "List all pages", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + client := NewClient() + pages, err := client.GetAllPages(ctx) + if err != nil { + return err + } + return PrintJSON(pages) + }, + } +} + +func pageGetCmd() *redant.Command { + var withBlocks bool + return &redant.Command{ + Use: "get ", + Short: "Get page info", + Options: redant.OptionSet{ + { + Flag: "blocks", + Shorthand: "b", + Description: "Include page block tree", + Value: redant.BoolOf(&withBlocks), + }, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + if len(inv.Args) == 0 { + return fmt.Errorf("page name required") + } + client := NewClient() + name := inv.Args[0] + + if withBlocks { + blocks, err := client.GetPageBlocksTree(ctx, name) + if err != nil { + return err + } + return PrintJSON(blocks) + } + + page, err := client.GetPage(ctx, name) + if err != nil { + return err + } + if page == nil { + return fmt.Errorf("page '%s' not found", name) + } + return PrintJSON(page) + }, + } +} + +func pageCreateCmd() *redant.Command { + return &redant.Command{ + Use: "create ", + Short: "Create a page", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + if len(inv.Args) == 0 { + return fmt.Errorf("page name required") + } + client := NewClient() + page, err := client.CreatePage(ctx, inv.Args[0], nil, &logseq.CreatePageOptions{ + CreateFirstBlock: true, + }) + if err != nil { + return err + } + return PrintJSON(page) + }, + } +} + +func pageDeleteCmd() *redant.Command { + return &redant.Command{ + Use: "delete ", + Short: "Delete a page", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + if len(inv.Args) == 0 { + return fmt.Errorf("page name required") + } + client := NewClient() + if err := client.DeletePage(ctx, inv.Args[0]); err != nil { + return err + } + fmt.Fprintf(inv.Stdout, "deleted page: %s\n", inv.Args[0]) + return nil + }, + } +} + +func pageRenameCmd() *redant.Command { + return &redant.Command{ + Use: "rename ", + Short: "Rename a page", + Handler: func(ctx context.Context, inv *redant.Invocation) error { + if len(inv.Args) < 2 { + return fmt.Errorf("old name and new name required") + } + client := NewClient() + if err := client.RenamePage(ctx, inv.Args[0], inv.Args[1]); err != nil { + return err + } + fmt.Fprintf(inv.Stdout, "renamed: %s -> %s\n", inv.Args[0], inv.Args[1]) + return nil + }, + } +} diff --git a/docs/ANALYSIS.md b/docs/ANALYSIS.md new file mode 100644 index 0000000..431b31b --- /dev/null +++ b/docs/ANALYSIS.md @@ -0,0 +1,401 @@ +# Logseq CLI 项目分析文档 + +## 1. Logseq HTTP API 概述 + +### 1.1 通信协议 + +Logseq 桌面版内建了 HTTP API Server,通过 JSON-RPC 风格调用暴露插件 API 给外部应用。 + +| 项目 | 说明 | +| ------------ | ------------------------------- | +| 基础 URL | `http://127.0.0.1:12315/api` | +| 请求方法 | `POST` | +| 认证方式 | `Authorization: Bearer ` | +| Content-Type | `application/json` | + +### 1.2 请求格式 + +```json +{ + "method": "logseq.Namespace.methodName", + "args": [arg1, arg2, ...] +} +``` + +### 1.3 响应格式 + +成功时返回 JSON 结果(具体数据结构取决于方法),失败时返回: + +```json +{ + "error": "MethodNotExist: method_name" +} +``` + +--- + +## 2. 已验证的 API 方法清单 + +### 2.1 Editor 命名空间 (`logseq.Editor.*`) + +#### Page 相关 + +| 方法 | 参数 | 返回 | 说明 | +| --------------------------- | ------------------------------- | --------- | --------------------------------------------- | +| `getAllPages` | `[]` | `[]Page` | 获取所有页面列表 | +| `getPage` | `[nameOrUUID]` | `Page` | 按名称或 UUID 获取单页元数据 | +| `createPage` | `[name, properties?, options?]` | `Page` | 创建页面。options: `{createFirstBlock: true}` | +| `deletePage` | `[name]` | `null` | 删除页面 | +| `renamePage` | `[oldName, newName]` | `null` | 重命名页面并更新引用 | +| `getPageBlocksTree` | `[nameOrUUID]` | `[]Block` | 获取页面完整 Block 树 | +| `getPageLinkedReferences` | `[name]` | `[]` | 获取页面反向链接 | +| `getPagesFromNamespace` | `[namespace]` | `[]Page` | 获取命名空间下所有页面(平铺) | +| `getPagesTreeFromNamespace` | `[namespace]` | `[]` | 获取命名空间下页面树结构 | +| `setPageProperties` | `[name, properties]` | — | 设置页面级属性 | + +#### Block 相关 + +| 方法 | 参数 | 返回 | 说明 | +| --------------------- | --------------------------------- | ------------- | ------------------------------------------------------ | +| `getBlock` | `[uuid, {includeChildren: bool}]` | `Block` | 获取单个 Block | +| `insertBlock` | `[targetUUID, content, options?]` | `Block` | 插入 Block。options: `{sibling: bool, properties: {}}` | +| `insertBatchBlock` | `[srcUUID, blocks[], options?]` | `[]Block` | 批量插入 Block 树。options: `{sibling: bool}` | +| `updateBlock` | `[uuid, content]` | `Block\|null` | 更新 Block 内容 | +| `removeBlock` | `[uuid]` | `null` | 删除 Block | +| `moveBlock` | `[srcUUID, targetUUID, options?]` | — | 移动 Block | +| `prependBlockInPage` | `[page, content]` | `Block` | 在页面头部插入 Block | +| `appendBlockInPage` | `[page, content, options?]` | `Block` | 在页面尾部追加 Block | +| `setBlockCollapsed` | `[uuid, {flag: bool}]` | `null` | 设置 Block 折叠状态 | +| `upsertBlockProperty` | `[uuid, key, value]` | `null` | 设置 Block 属性 | +| `removeBlockProperty` | `[uuid, key]` | `null` | 删除 Block 属性 | +| `getBlockProperties` | `[uuid]` | `dict` | 获取 Block 属性 | +| `getCurrentBlock` | `[]` | `Block` | 获取当前焦点 Block | +| `getCurrentPage` | `[]` | `Page` | 获取当前焦点页面 | + +### 2.2 App 命名空间 (`logseq.App.*`) + +| 方法 | 参数 | 返回 | 说明 | +| ------------------- | ------------------- | -------------- | -------------------------------- | +| `getCurrentGraph` | `[]` | `GraphInfo` | 获取当前图谱信息 | +| `getStateFromStore` | `[key]` | `any` | 获取应用状态 | +| `getUserConfigs` | `[]` | `Config` | 获取用户配置 | +| `search` | `[query, options?]` | `SearchResult` | 全文搜索(⚠️ 部分版本可能不可用) | + +### 2.3 DB 命名空间 (`logseq.DB.*`) + +| 方法 | 参数 | 返回 | 说明 | +| ----------------- | --------------------- | --------- | -------------------- | +| `datascriptQuery` | `[query, ...inputs?]` | `[][]any` | 执行 Datalog 查询 | +| `q` | `[dslQuery]` | `any` | 执行 Logseq DSL 查询 | + +### 2.4 已确认不可用的方法 + +| 方法 | 说明 | +| --------------------------------- | --------------------------------------------------------------- | +| `logseq.Editor.appendBlock` | 返回 `MethodNotExist` | +| `logseq.Editor.getPageProperties` | 返回 `MethodNotExist`(需用 getPageBlocksTree 的首 block 代替) | +| `logseq.Editor.createJournalPage` | 返回 `MethodNotExist`(需用 createPage + 日期格式名代替) | + +--- + +## 3. 数据模型 + +### 3.1 Page + +```go +type Page struct { + Name string `json:"name"` + OriginalName string `json:"originalName"` + UUID string `json:"uuid"` + Properties map[string]any `json:"properties,omitempty"` + IsJournal bool `json:"journal?"` + JournalDay int `json:"journalDay,omitempty"` + Namespace *Namespace `json:"namespace,omitempty"` + CreatedAt int64 `json:"createdAt,omitempty"` + UpdatedAt int64 `json:"updatedAt,omitempty"` +} +``` + +### 3.2 Block + +```go +type Block struct { + UUID string `json:"uuid"` + Content string `json:"content"` + Page *PageRef `json:"page,omitempty"` // 可能是 int 或 {id: int} + Properties map[string]any `json:"properties,omitempty"` + Children []Block `json:"children,omitempty"` + Level int `json:"level,omitempty"` + Format string `json:"format,omitempty"` // "markdown" 或 "org" + Marker string `json:"marker,omitempty"` // "TODO","DOING","DONE" 等 + Priority string `json:"priority,omitempty"` +} + +type PageRef struct { + ID int64 `json:"id"` +} +``` + +### 3.3 GraphInfo + +```go +type GraphInfo struct { + Name string `json:"name"` + Path string `json:"path"` + URL string `json:"url"` +} +``` + +--- + +## 4. Go SDK 设计方案 + +### 4.1 目录结构 + +``` +logseq-cli/ +├── go.mod +├── main.go # CLI 入口 +├── pkg/ +│ └── logseq/ # Logseq Go SDK +│ ├── client.go # HTTP 客户端(底层 RPC 调用) +│ ├── types.go # 数据模型定义 +│ ├── editor.go # Editor 命名空间 API +│ ├── app.go # App 命名空间 API +│ ├── db.go # DB 命名空间 API(Datalog/DSL 查询) +│ └── options.go # 客户端选项(地址、token、超时等) +├── cmd/ # CLI 子命令 +│ ├── root.go # 根命令(全局 flags) +│ ├── page.go # page list/get/create/delete/rename +│ ├── block.go # block get/insert/update/remove/move +│ ├── graph.go # graph info +│ └── query.go # query run(Datalog 查询) +└── docs/ + └── ANALYSIS.md # 本文档 +``` + +### 4.2 SDK Client 核心接口 + +```go +package logseq + +import ( + "context" + "net/http" +) + +// Client 是 Logseq HTTP API 的 Go 封装 +type Client struct { + baseURL string + token string + httpClient *http.Client +} + +// NewClient 创建 Logseq API 客户端 +func NewClient(opts ...Option) *Client + +// callAPI 是底层 JSON-RPC 调用 +func (c *Client) callAPI(ctx context.Context, method string, args []any) (json.RawMessage, error) +``` + +### 4.3 Editor API 封装 + +```go +// === Page 操作 === +func (c *Client) GetAllPages(ctx context.Context) ([]Page, error) +func (c *Client) GetPage(ctx context.Context, nameOrUUID string) (*Page, error) +func (c *Client) CreatePage(ctx context.Context, name string, properties map[string]any, opts *CreatePageOptions) (*Page, error) +func (c *Client) DeletePage(ctx context.Context, name string) error +func (c *Client) RenamePage(ctx context.Context, oldName, newName string) error +func (c *Client) GetPageBlocksTree(ctx context.Context, nameOrUUID string) ([]Block, error) +func (c *Client) GetPageLinkedReferences(ctx context.Context, name string) ([]any, error) +func (c *Client) GetPagesFromNamespace(ctx context.Context, ns string) ([]Page, error) +func (c *Client) SetPageProperties(ctx context.Context, name string, props map[string]any) error + +// === Block 操作 === +func (c *Client) GetBlock(ctx context.Context, uuid string, includeChildren bool) (*Block, error) +func (c *Client) InsertBlock(ctx context.Context, targetUUID, content string, opts *InsertBlockOptions) (*Block, error) +func (c *Client) InsertBatchBlock(ctx context.Context, srcUUID string, blocks []BatchBlock, opts *BatchBlockOptions) error +func (c *Client) UpdateBlock(ctx context.Context, uuid, content string) error +func (c *Client) RemoveBlock(ctx context.Context, uuid string) error +func (c *Client) MoveBlock(ctx context.Context, srcUUID, targetUUID string, opts *MoveBlockOptions) error +func (c *Client) PrependBlockInPage(ctx context.Context, page, content string) (*Block, error) +func (c *Client) AppendBlockInPage(ctx context.Context, page, content string) (*Block, error) +func (c *Client) UpsertBlockProperty(ctx context.Context, uuid, key string, value any) error +func (c *Client) RemoveBlockProperty(ctx context.Context, uuid, key string) error +func (c *Client) GetBlockProperties(ctx context.Context, uuid string) (map[string]any, error) +func (c *Client) SetBlockCollapsed(ctx context.Context, uuid string, collapsed bool) error + +// === 当前焦点 === +func (c *Client) GetCurrentPage(ctx context.Context) (*Page, error) +func (c *Client) GetCurrentBlock(ctx context.Context) (*Block, error) +``` + +### 4.4 App API 封装 + +```go +func (c *Client) GetCurrentGraph(ctx context.Context) (*GraphInfo, error) +func (c *Client) GetUserConfigs(ctx context.Context) (map[string]any, error) +func (c *Client) Search(ctx context.Context, query string, opts *SearchOptions) (*SearchResult, error) +``` + +### 4.5 DB API 封装 + +```go +func (c *Client) DatascriptQuery(ctx context.Context, query string, inputs ...any) ([][]any, error) +func (c *Client) DSLQuery(ctx context.Context, query string) (any, error) +``` + +--- + +## 5. CLI 命令设计(基于 redant 框架) + +### 5.1 全局 Flags + +| Flag | 环境变量 | 默认值 | 说明 | +| ---------- | ------------------ | ----------- | ------------------------- | +| `--token` | `LOGSEQ_API_TOKEN` | — | API 认证 token(必须) | +| `--host` | `LOGSEQ_HOST` | `127.0.0.1` | Logseq 主机地址 | +| `--port` | `LOGSEQ_PORT` | `12315` | Logseq 端口 | +| `--output` | — | `json` | 输出格式:`json` / `text` | + +### 5.2 命令树 + +``` +logseq +├── page # 页面管理 +│ ├── list # 列出所有页面 +│ ├── get # 获取页面内容 +│ ├── create # 创建页面 +│ ├── delete # 删除页面 +│ ├── rename # 重命名页面 +│ └── properties # 获取页面属性 +├── block # Block 管理 +│ ├── get # 获取 Block +│ ├── insert # 插入 Block +│ ├── update # 更新 Block +│ ├── remove # 删除 Block +│ ├── move # 移动 Block +│ ├── prepend # 页面头部插入 +│ └── append # 页面尾部追加 +├── graph # 图谱信息 +│ └── info # 当前图谱信息 +├── query # 查询 +│ ├── datalog # Datascript/Datalog 查询 +│ └── dsl # Logseq DSL 查询 +└── search # 全文搜索 +``` + +### 5.3 redant 框架核心用法 + +```go +package main + +import ( + "context" + "fmt" + "os" + + "github.com/pubgo/redant" +) + +func main() { + var token string + var host string + var port int + + root := redant.Command{ + Use: "logseq", + Short: "Logseq CLI - 命令行操作 Logseq 图谱", + Options: redant.OptionSet{ + { + Flag: "token", + Description: "Logseq API token", + Envs: []string{"LOGSEQ_API_TOKEN"}, + Required: true, + Value: redant.StringOf(&token), + }, + { + Flag: "host", + Description: "Logseq host", + Envs: []string{"LOGSEQ_HOST"}, + Default: "127.0.0.1", + Value: redant.StringOf(&host), + }, + { + Flag: "port", + Description: "Logseq port", + Envs: []string{"LOGSEQ_PORT"}, + Default: "12315", + Value: redant.IntOf(&port), + }, + }, + Children: []*redant.Command{ + pageCmd(), // page 子命令 + blockCmd(), // block 子命令 + graphCmd(), // graph 子命令 + queryCmd(), // query 子命令 + searchCmd(), // search 命令 + }, + } + + if err := root.Invoke().WithOS().Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} +``` + +--- + +## 6. redant 框架关键特性(用于本项目) + +| 特性 | 说明 | +| ------------ | ------------------------------------------------------- | +| 命令树 | `Children []*Command` 嵌套子命令 | +| 选项声明 | `OptionSet` 支持 flag/env/default 三来源 | +| Handler | `func(ctx, *Invocation) error` 标准签名 | +| 中间件 | `Middleware: Chain(...)` 链式编排 | +| MCP 集成 | `app mcp serve` 暴露命令为 MCP Tools | +| 结构化输出 | `ResponseHandler` / `ResponseStreamHandler` 支持 NDJSON | +| 位置参数 | `inv.Args` 获取位置参数 | +| Stdin/Stdout | `inv.Stdin` / `inv.Stdout` / `inv.Stderr` | +| Web 控制台 | 可视化命令调用 | +| 文档生成 | `llms-txt` 生成 LLM 友好文档 | + +--- + +## 7. 关键实现注意事项 + +1. **Block 追加的 Workaround**: `logseq.Editor.appendBlock` 不存在,需通过 `getPageBlocksTree` + `insertBlock(lastBlockUUID, content, {sibling: true})` 实现。 + +2. **页面属性获取**: `getPageProperties` 不可用,需从 `getPageBlocksTree` 返回的首个 Block 的 `properties` 字段读取。 + +3. **Journal 创建**: `createJournalPage` 不可用,需调用 `createPage("YYYY_MM_DD")` + 通过 `journalDay` 查找确认。 + +4. **Page 字段中的 `page` 引用**: Block 中的 `page` 字段可能是 `int` 或 `{id: int}` 两种格式,Go SDK 需做兼容解析。 + +5. **Search 方法**: `logseq.App.search` 在部分版本可用、部分不可用;可用 Datascript 查询做降级搜索方案。 + +6. **Token 安全**: Token 应优先从环境变量读取,避免暴露在命令行参数中。 + +--- + +## 8. 参考项目对比 + +| 项目 | 语言 | 定位 | 关键实现 | +| ------------------------ | ------------ | ---------- | -------------------------------------- | +| logseq-cli (Python) | Python/Typer | 命令行工具 | asyncio + httpx,NDJSON 输出,管道组合 | +| logseq-mcp-server (Rust) | Rust | MCP 服务器 | reqwest,强类型数据模型,MCP 工具暴露 | +| mcp-logseq (Python) | Python | MCP 服务器 | requests 同步,Markdown 解析,批量写入 | +| **本项目 (Go)** | Go/redant | CLI + MCP | net/http,redant 命令树,MCP 内建 | + +--- + +## 9. 下一步计划 + +1. 初始化 Go 模块 (`go mod init github.com/pubgo/logseq-cli`) +2. 实现 `pkg/logseq` SDK(client + types + editor/app/db) +3. 基于 redant 构建 CLI 命令树 +4. 编写集成测试(需要运行 Logseq 实例) +5. 利用 redant 的 MCP 集成能力暴露为 MCP 服务器 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9de4c8d --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module github.com/pubgo/logseq-cli + +go 1.26.1 + +replace github.com/pubgo/redant => ./.local/redant + +require github.com/pubgo/redant v0.3.0 + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ad3c4d5 --- /dev/null +++ b/go.sum @@ -0,0 +1,31 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..470145e --- /dev/null +++ b/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "os" + + "github.com/pubgo/logseq-cli/cmds" + "github.com/pubgo/redant" +) + +func main() { + root := redant.Command{ + Use: "logseq", + Short: "Logseq CLI - command line tool for Logseq", + Options: redant.OptionSet{ + { + Flag: "token", + Shorthand: "t", + Description: "Logseq API token", + Envs: []string{"LOGSEQ_API_TOKEN"}, + Required: true, + Value: redant.StringOf(&cmds.Token), + }, + { + Flag: "host", + Description: "Logseq API host", + Envs: []string{"LOGSEQ_HOST"}, + Default: "127.0.0.1", + Value: redant.StringOf(&cmds.Host), + }, + { + Flag: "port", + Shorthand: "p", + Description: "Logseq API port", + Envs: []string{"LOGSEQ_PORT"}, + Default: "12315", + Value: redant.StringOf(&cmds.Port), + }, + { + Flag: "output", + Shorthand: "o", + Description: "Output format: json, text", + Default: "json", + Value: redant.EnumOf(&cmds.Output, "json", "text"), + }, + }, + Children: []*redant.Command{ + cmds.PageCmd(), + cmds.BlockCmd(), + cmds.GraphCmd(), + cmds.QueryCmd(), + cmds.SearchCmd(), + }, + } + + if err := root.Invoke().WithOS().Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/pkg/logseq/app.go b/pkg/logseq/app.go new file mode 100644 index 0000000..09ab69b --- /dev/null +++ b/pkg/logseq/app.go @@ -0,0 +1,44 @@ +package logseq + +import ( + "context" + "encoding/json" +) + +// GetCurrentGraph returns metadata about the current graph. +func (c *Client) GetCurrentGraph(ctx context.Context) (*GraphInfo, error) { + raw, err := c.CallAPI(ctx, "logseq.App.getCurrentGraph") + if err != nil { + return nil, err + } + if len(raw) == 0 || string(raw) == "null" { + return nil, nil + } + var info GraphInfo + if err := json.Unmarshal(raw, &info); err != nil { + return nil, err + } + return &info, nil +} + +// GetUserConfigs returns the user's Logseq configuration. +func (c *Client) GetUserConfigs(ctx context.Context) (map[string]any, error) { + return decode[map[string]any](c.CallAPI(ctx, "logseq.App.getUserConfigs")) +} + +// Search performs a full-text search across the graph. +// Note: This method may not be available in all Logseq versions. +func (c *Client) Search(ctx context.Context, query string) (*SearchResult, error) { + raw, err := c.CallAPI(ctx, "logseq.App.search", query) + if err != nil { + return nil, err + } + if len(raw) == 0 || string(raw) == "null" { + return nil, nil + } + var result SearchResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, err + } + return &result, nil +} diff --git a/pkg/logseq/client.go b/pkg/logseq/client.go new file mode 100644 index 0000000..cfcf08a --- /dev/null +++ b/pkg/logseq/client.go @@ -0,0 +1,134 @@ +package logseq + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Client is the Logseq HTTP API client. +type Client struct { + baseURL string + token string + httpClient *http.Client +} + +// Option configures the Client. +type Option func(*Client) + +// WithBaseURL sets the API base URL (default: http://127.0.0.1:12315). +func WithBaseURL(url string) Option { + return func(c *Client) { + c.baseURL = url + } +} + +// WithToken sets the Bearer token for authentication. +func WithToken(token string) Option { + return func(c *Client) { + c.token = token + } +} + +// WithHTTPClient sets a custom http.Client. +func WithHTTPClient(hc *http.Client) Option { + return func(c *Client) { + c.httpClient = hc + } +} + +// NewClient creates a new Logseq API client. +func NewClient(opts ...Option) *Client { + c := &Client{ + baseURL: "http://127.0.0.1:12315", + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } + for _, opt := range opts { + opt(c) + } + return c +} + +// apiRequest is the JSON-RPC request body. +type apiRequest struct { + Method string `json:"method"` + Args []any `json:"args"` +} + +// apiError represents an error response from Logseq. +type apiError struct { + Error string `json:"error"` +} + +// CallAPI makes a raw API call and returns the response body as json.RawMessage. +func (c *Client) CallAPI(ctx context.Context, method string, args ...any) (json.RawMessage, error) { + if args == nil { + args = []any{} + } + reqBody := apiRequest{ + Method: method, + Args: args, + } + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("send request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var apiErr apiError + if json.Unmarshal(respBody, &apiErr) == nil && apiErr.Error != "" { + return nil, fmt.Errorf("logseq api: %s", apiErr.Error) + } + return nil, fmt.Errorf("logseq api: HTTP %d: %s", resp.StatusCode, string(respBody)) + } + + // Check for error in response body + var apiErr apiError + if json.Unmarshal(respBody, &apiErr) == nil && apiErr.Error != "" { + return nil, fmt.Errorf("logseq api: %s", apiErr.Error) + } + + return json.RawMessage(respBody), nil +} + +// decode is a helper to unmarshal a json.RawMessage into a typed result. +func decode[T any](raw json.RawMessage, err error) (T, error) { + var zero T + if err != nil { + return zero, err + } + if len(raw) == 0 || string(raw) == "null" { + return zero, nil + } + var result T + if err := json.Unmarshal(raw, &result); err != nil { + return zero, fmt.Errorf("decode response: %w", err) + } + return result, nil +} diff --git a/pkg/logseq/db.go b/pkg/logseq/db.go new file mode 100644 index 0000000..8159f55 --- /dev/null +++ b/pkg/logseq/db.go @@ -0,0 +1,18 @@ +package logseq + +import ( + "context" + "encoding/json" +) + +// DatascriptQuery executes a Datalog query against the Logseq database. +func (c *Client) DatascriptQuery(ctx context.Context, query string, inputs ...any) (json.RawMessage, error) { + args := []any{query} + args = append(args, inputs...) + return c.CallAPI(ctx, "logseq.DB.datascriptQuery", args...) +} + +// DSLQuery executes a Logseq DSL query. +func (c *Client) DSLQuery(ctx context.Context, query string) (json.RawMessage, error) { + return c.CallAPI(ctx, "logseq.DB.q", query) +} diff --git a/pkg/logseq/editor.go b/pkg/logseq/editor.go new file mode 100644 index 0000000..210ab97 --- /dev/null +++ b/pkg/logseq/editor.go @@ -0,0 +1,249 @@ +package logseq + +import ( + "context" + "encoding/json" +) + +// === Page Operations === + +// GetAllPages returns all pages in the graph. +func (c *Client) GetAllPages(ctx context.Context) ([]Page, error) { + return decode[[]Page](c.CallAPI(ctx, "logseq.Editor.getAllPages")) +} + +// GetPage returns a page by name or UUID. +func (c *Client) GetPage(ctx context.Context, nameOrUUID string) (*Page, error) { + raw, err := c.CallAPI(ctx, "logseq.Editor.getPage", nameOrUUID) + if err != nil { + return nil, err + } + if len(raw) == 0 || string(raw) == "null" { + return nil, nil + } + var page Page + if err := json.Unmarshal(raw, &page); err != nil { + return nil, err + } + return &page, nil +} + +// CreatePage creates a new page. +func (c *Client) CreatePage(ctx context.Context, name string, properties map[string]any, opts *CreatePageOptions) (*Page, error) { + args := []any{name} + if properties != nil { + args = append(args, properties) + } else { + args = append(args, map[string]any{}) + } + if opts != nil { + args = append(args, opts) + } + raw, err := c.CallAPI(ctx, "logseq.Editor.createPage", args...) + if err != nil { + return nil, err + } + if len(raw) == 0 || string(raw) == "null" { + return nil, nil + } + var page Page + if err := json.Unmarshal(raw, &page); err != nil { + return nil, err + } + return &page, nil +} + +// DeletePage deletes a page by name. +func (c *Client) DeletePage(ctx context.Context, name string) error { + _, err := c.CallAPI(ctx, "logseq.Editor.deletePage", name) + return err +} + +// RenamePage renames a page. +func (c *Client) RenamePage(ctx context.Context, oldName, newName string) error { + _, err := c.CallAPI(ctx, "logseq.Editor.renamePage", oldName, newName) + return err +} + +// GetPageBlocksTree returns the full block tree for a page. +func (c *Client) GetPageBlocksTree(ctx context.Context, nameOrUUID string) ([]Block, error) { + return decode[[]Block](c.CallAPI(ctx, "logseq.Editor.getPageBlocksTree", nameOrUUID)) +} + +// GetPageLinkedReferences returns backlinks for a page. +func (c *Client) GetPageLinkedReferences(ctx context.Context, name string) (json.RawMessage, error) { + return c.CallAPI(ctx, "logseq.Editor.getPageLinkedReferences", name) +} + +// GetPagesFromNamespace returns all pages in a namespace (flat). +func (c *Client) GetPagesFromNamespace(ctx context.Context, namespace string) ([]Page, error) { + return decode[[]Page](c.CallAPI(ctx, "logseq.Editor.getPagesFromNamespace", namespace)) +} + +// GetPagesTreeFromNamespace returns pages in a namespace as a tree. +func (c *Client) GetPagesTreeFromNamespace(ctx context.Context, namespace string) (json.RawMessage, error) { + return c.CallAPI(ctx, "logseq.Editor.getPagesTreeFromNamespace", namespace) +} + +// SetPageProperties sets page-level properties. +func (c *Client) SetPageProperties(ctx context.Context, name string, properties map[string]any) error { + _, err := c.CallAPI(ctx, "logseq.Editor.setPageProperties", name, properties) + return err +} + +// === Block Operations === + +// GetBlock returns a block by UUID with optional children. +func (c *Client) GetBlock(ctx context.Context, uuid string, includeChildren bool) (*Block, error) { + opts := map[string]any{"includeChildren": includeChildren} + raw, err := c.CallAPI(ctx, "logseq.Editor.getBlock", uuid, opts) + if err != nil { + return nil, err + } + if len(raw) == 0 || string(raw) == "null" { + return nil, nil + } + var block Block + if err := json.Unmarshal(raw, &block); err != nil { + return nil, err + } + return &block, nil +} + +// InsertBlock inserts a block relative to a target block. +func (c *Client) InsertBlock(ctx context.Context, targetUUID, content string, opts *InsertBlockOptions) (*Block, error) { + args := []any{targetUUID, content} + if opts != nil { + args = append(args, opts) + } + raw, err := c.CallAPI(ctx, "logseq.Editor.insertBlock", args...) + if err != nil { + return nil, err + } + if len(raw) == 0 || string(raw) == "null" { + return nil, nil + } + var block Block + if err := json.Unmarshal(raw, &block); err != nil { + return nil, err + } + return &block, nil +} + +// InsertBatchBlock inserts multiple blocks at once. +func (c *Client) InsertBatchBlock(ctx context.Context, srcUUID string, blocks []BatchBlock, sibling bool) error { + opts := map[string]any{"sibling": sibling} + _, err := c.CallAPI(ctx, "logseq.Editor.insertBatchBlock", srcUUID, blocks, opts) + return err +} + +// UpdateBlock updates a block's content. +func (c *Client) UpdateBlock(ctx context.Context, uuid, content string) error { + _, err := c.CallAPI(ctx, "logseq.Editor.updateBlock", uuid, content) + return err +} + +// RemoveBlock deletes a block by UUID. +func (c *Client) RemoveBlock(ctx context.Context, uuid string) error { + _, err := c.CallAPI(ctx, "logseq.Editor.removeBlock", uuid) + return err +} + +// MoveBlock moves a block to a new position. +func (c *Client) MoveBlock(ctx context.Context, srcUUID, targetUUID string, opts map[string]any) error { + args := []any{srcUUID, targetUUID} + if opts != nil { + args = append(args, opts) + } + _, err := c.CallAPI(ctx, "logseq.Editor.moveBlock", args...) + return err +} + +// PrependBlockInPage inserts a block at the beginning of a page. +func (c *Client) PrependBlockInPage(ctx context.Context, page, content string) (*Block, error) { + raw, err := c.CallAPI(ctx, "logseq.Editor.prependBlockInPage", page, content) + if err != nil { + return nil, err + } + if len(raw) == 0 || string(raw) == "null" { + return nil, nil + } + var block Block + if err := json.Unmarshal(raw, &block); err != nil { + return nil, err + } + return &block, nil +} + +// AppendBlockInPage appends a block to the end of a page. +// Note: logseq.Editor.appendBlock does not exist. This uses appendBlockInPage. +func (c *Client) AppendBlockInPage(ctx context.Context, page, content string) (*Block, error) { + raw, err := c.CallAPI(ctx, "logseq.Editor.appendBlockInPage", page, content) + if err != nil { + return nil, err + } + if len(raw) == 0 || string(raw) == "null" { + return nil, nil + } + var block Block + if err := json.Unmarshal(raw, &block); err != nil { + return nil, err + } + return &block, nil +} + +// UpsertBlockProperty sets a block property. +func (c *Client) UpsertBlockProperty(ctx context.Context, uuid, key string, value any) error { + _, err := c.CallAPI(ctx, "logseq.Editor.upsertBlockProperty", uuid, key, value) + return err +} + +// RemoveBlockProperty removes a block property. +func (c *Client) RemoveBlockProperty(ctx context.Context, uuid, key string) error { + _, err := c.CallAPI(ctx, "logseq.Editor.removeBlockProperty", uuid, key) + return err +} + +// GetBlockProperties returns all properties of a block. +func (c *Client) GetBlockProperties(ctx context.Context, uuid string) (map[string]any, error) { + return decode[map[string]any](c.CallAPI(ctx, "logseq.Editor.getBlockProperties", uuid)) +} + +// SetBlockCollapsed sets whether a block is collapsed. +func (c *Client) SetBlockCollapsed(ctx context.Context, uuid string, collapsed bool) error { + opts := map[string]any{"flag": collapsed} + _, err := c.CallAPI(ctx, "logseq.Editor.setBlockCollapsed", uuid, opts) + return err +} + +// GetCurrentPage returns the currently focused page. +func (c *Client) GetCurrentPage(ctx context.Context) (*Page, error) { + raw, err := c.CallAPI(ctx, "logseq.Editor.getCurrentPage") + if err != nil { + return nil, err + } + if len(raw) == 0 || string(raw) == "null" { + return nil, nil + } + var page Page + if err := json.Unmarshal(raw, &page); err != nil { + return nil, err + } + return &page, nil +} + +// GetCurrentBlock returns the currently focused block. +func (c *Client) GetCurrentBlock(ctx context.Context) (*Block, error) { + raw, err := c.CallAPI(ctx, "logseq.Editor.getCurrentBlock") + if err != nil { + return nil, err + } + if len(raw) == 0 || string(raw) == "null" { + return nil, nil + } + var block Block + if err := json.Unmarshal(raw, &block); err != nil { + return nil, err + } + return &block, nil +} diff --git a/pkg/logseq/types.go b/pkg/logseq/types.go new file mode 100644 index 0000000..f00d879 --- /dev/null +++ b/pkg/logseq/types.go @@ -0,0 +1,100 @@ +package logseq + +import "encoding/json" + +// Page represents a Logseq page entity. +type Page struct { + Name string `json:"name,omitempty"` + OriginalName string `json:"originalName,omitempty"` + UUID string `json:"uuid,omitempty"` + Properties map[string]any `json:"properties,omitempty"` + IsJournal bool `json:"journal?,omitempty"` + JournalDay int `json:"journalDay,omitempty"` + CreatedAt int64 `json:"createdAt,omitempty"` + UpdatedAt int64 `json:"updatedAt,omitempty"` +} + +// DisplayName returns the best available name for the page. +func (p *Page) DisplayName() string { + if p.OriginalName != "" { + return p.OriginalName + } + return p.Name +} + +// Block represents a Logseq block entity. +type Block struct { + UUID string `json:"uuid,omitempty"` + Content string `json:"content,omitempty"` + Page *PageRef `json:"page,omitempty"` + Properties map[string]any `json:"properties,omitempty"` + Children []Block `json:"children,omitempty"` + Level int `json:"level,omitempty"` + Format string `json:"format,omitempty"` + Marker string `json:"marker,omitempty"` + Priority string `json:"priority,omitempty"` +} + +// PageRef is a reference to a page, which can be either an integer ID or {id: int}. +type PageRef struct { + ID int64 `json:"id"` +} + +// UnmarshalJSON handles both integer and object forms of page reference. +func (r *PageRef) UnmarshalJSON(data []byte) error { + // Try integer first + var id int64 + if err := json.Unmarshal(data, &id); err == nil { + r.ID = id + return nil + } + // Try object form {id: int} + var obj struct { + ID int64 `json:"id"` + } + if err := json.Unmarshal(data, &obj); err != nil { + return err + } + r.ID = obj.ID + return nil +} + +// GraphInfo represents current graph metadata. +type GraphInfo struct { + Name string `json:"name,omitempty"` + Path string `json:"path,omitempty"` + URL string `json:"url,omitempty"` +} + +// BatchBlock is the structure for insertBatchBlock API. +type BatchBlock struct { + Content string `json:"content"` + Children []BatchBlock `json:"children,omitempty"` + Properties map[string]any `json:"properties,omitempty"` +} + +// InsertBlockOptions configures insertBlock behavior. +type InsertBlockOptions struct { + Sibling bool `json:"sibling,omitempty"` + Before bool `json:"before,omitempty"` + Properties map[string]any `json:"properties,omitempty"` +} + +// CreatePageOptions configures createPage behavior. +type CreatePageOptions struct { + CreateFirstBlock bool `json:"createFirstBlock,omitempty"` +} + +// SearchResult represents a search result from logseq.App.search. +type SearchResult struct { + Blocks []SearchBlock `json:"blocks,omitempty"` + Pages []string `json:"pages,omitempty"` + Files []string `json:"files,omitempty"` +} + +// SearchBlock is a block entry from search results. +type SearchBlock struct { + UUID string `json:"uuid,omitempty"` + Content string `json:"content,omitempty"` + Page string `json:"page,omitempty"` +} From a6a72fdab729e57f7b18dfd80ff4adfa0a676dac Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 7 May 2026 20:46:47 +0800 Subject: [PATCH 02/37] feat: update dependencies and enhance command structure in Logseq CLI --- go.mod | 10 ++++++++-- go.sum | 24 ++++++++++++++++++++++++ main.go | 10 ++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 9de4c8d..9c9247d 100644 --- a/go.mod +++ b/go.mod @@ -2,19 +2,25 @@ module github.com/pubgo/logseq-cli go 1.26.1 -replace github.com/pubgo/redant => ./.local/redant - require github.com/pubgo/redant v0.3.0 require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/creack/pty v1.1.24 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/modelcontextprotocol/go-sdk v1.4.1 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/term v0.41.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index ad3c4d5..ac5cc74 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,16 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -11,19 +21,33 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= +github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pubgo/redant v0.3.0 h1:H7TxDDHxVOuXAbUKcb/wyObYSnlNes8LngBeXoVYzjY= +github.com/pubgo/redant v0.3.0/go.mod h1:pJXH/Im4+1yrUO7AmmQ5lspYzjfKWyW4bCiLg6B41pk= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/main.go b/main.go index 470145e..ed4a9b8 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,11 @@ import ( "github.com/pubgo/logseq-cli/cmds" "github.com/pubgo/redant" + "github.com/pubgo/redant/cmds/completioncmd" + "github.com/pubgo/redant/cmds/doccmd" + "github.com/pubgo/redant/cmds/llmstxtcmd" + "github.com/pubgo/redant/cmds/mcpcmd" + "github.com/pubgo/redant/cmds/webcmd" ) func main() { @@ -50,9 +55,14 @@ func main() { cmds.GraphCmd(), cmds.QueryCmd(), cmds.SearchCmd(), + llmstxtcmd.New(), + doccmd.New(), }, } + webcmd.AddWebCommand(&root) + mcpcmd.AddMCPCommand(&root) + completioncmd.AddCompletionCommand(&root) if err := root.Invoke().WithOS().Run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) From ebdaacf57d08f0a5094ef3b453d8f218645f5147 Mon Sep 17 00:00:00 2001 From: barry Date: Thu, 7 May 2026 22:32:19 +0800 Subject: [PATCH 03/37] feat: enhance command structure and error handling in Logseq CLI --- cmds/block.go | 114 +++++++++++++++++++++++--------------------------- cmds/cmds.go | 13 +++--- cmds/graph.go | 53 +++++++++++------------ cmds/page.go | 71 +++++++++++++++---------------- go.mod | 2 + go.sum | 2 - 6 files changed, 117 insertions(+), 138 deletions(-) diff --git a/cmds/block.go b/cmds/block.go index b8a3e07..3f76c61 100644 --- a/cmds/block.go +++ b/cmds/block.go @@ -29,6 +29,9 @@ func blockGetCmd() *redant.Command { return &redant.Command{ Use: "get ", Short: "Get a block by UUID", + Args: redant.ArgSet{ + {Name: "uuid", Required: true, Value: redant.StringOf(new(string)), Description: "Block UUID"}, + }, Options: redant.OptionSet{ { Flag: "children", @@ -37,20 +40,17 @@ func blockGetCmd() *redant.Command { Value: redant.BoolOf(&includeChildren), }, }, - Handler: func(ctx context.Context, inv *redant.Invocation) error { - if len(inv.Args) == 0 { - return fmt.Errorf("block UUID required") - } + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*logseq.Block, error) { client := NewClient() block, err := client.GetBlock(ctx, inv.Args[0], includeChildren) if err != nil { - return err + return nil, err } if block == nil { - return fmt.Errorf("block '%s' not found", inv.Args[0]) + return nil, fmt.Errorf("block '%s' not found", inv.Args[0]) } - return PrintJSON(block) - }, + return block, nil + }), } } @@ -59,6 +59,10 @@ func blockInsertCmd() *redant.Command { return &redant.Command{ Use: "insert ", Short: "Insert a block", + Args: redant.ArgSet{ + {Name: "target-uuid", Required: true, Value: redant.StringOf(new(string)), Description: "Target block UUID"}, + {Name: "content", Required: true, Value: redant.StringOf(new(string)), Description: "Block content"}, + }, Options: redant.OptionSet{ { Flag: "sibling", @@ -67,19 +71,12 @@ func blockInsertCmd() *redant.Command { Value: redant.BoolOf(&sibling), }, }, - Handler: func(ctx context.Context, inv *redant.Invocation) error { - if len(inv.Args) < 2 { - return fmt.Errorf("target UUID and content required") - } + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*logseq.Block, error) { client := NewClient() - block, err := client.InsertBlock(ctx, inv.Args[0], inv.Args[1], &logseq.InsertBlockOptions{ + return client.InsertBlock(ctx, inv.Args[0], inv.Args[1], &logseq.InsertBlockOptions{ Sibling: sibling, }) - if err != nil { - return err - } - return PrintJSON(block) - }, + }), } } @@ -87,17 +84,17 @@ func blockUpdateCmd() *redant.Command { return &redant.Command{ Use: "update ", Short: "Update block content", - Handler: func(ctx context.Context, inv *redant.Invocation) error { - if len(inv.Args) < 2 { - return fmt.Errorf("block UUID and content required") - } + Args: redant.ArgSet{ + {Name: "uuid", Required: true, Value: redant.StringOf(new(string)), Description: "Block UUID"}, + {Name: "content", Required: true, Value: redant.StringOf(new(string)), Description: "New block content"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { client := NewClient() if err := client.UpdateBlock(ctx, inv.Args[0], inv.Args[1]); err != nil { - return err + return StatusResult{}, err } - fmt.Fprintf(inv.Stdout, "updated block: %s\n", inv.Args[0]) - return nil - }, + return StatusResult{OK: true, Message: "updated block: " + inv.Args[0]}, nil + }), } } @@ -105,17 +102,16 @@ func blockRemoveCmd() *redant.Command { return &redant.Command{ Use: "remove ", Short: "Remove a block", - Handler: func(ctx context.Context, inv *redant.Invocation) error { - if len(inv.Args) == 0 { - return fmt.Errorf("block UUID required") - } + Args: redant.ArgSet{ + {Name: "uuid", Required: true, Value: redant.StringOf(new(string)), Description: "Block UUID"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { client := NewClient() if err := client.RemoveBlock(ctx, inv.Args[0]); err != nil { - return err + return StatusResult{}, err } - fmt.Fprintf(inv.Stdout, "removed block: %s\n", inv.Args[0]) - return nil - }, + return StatusResult{OK: true, Message: "removed block: " + inv.Args[0]}, nil + }), } } @@ -123,17 +119,17 @@ func blockMoveCmd() *redant.Command { return &redant.Command{ Use: "move ", Short: "Move a block", - Handler: func(ctx context.Context, inv *redant.Invocation) error { - if len(inv.Args) < 2 { - return fmt.Errorf("source UUID and target UUID required") - } + Args: redant.ArgSet{ + {Name: "src-uuid", Required: true, Value: redant.StringOf(new(string)), Description: "Source block UUID"}, + {Name: "target-uuid", Required: true, Value: redant.StringOf(new(string)), Description: "Target block UUID"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { client := NewClient() if err := client.MoveBlock(ctx, inv.Args[0], inv.Args[1], nil); err != nil { - return err + return StatusResult{}, err } - fmt.Fprintf(inv.Stdout, "moved block: %s -> %s\n", inv.Args[0], inv.Args[1]) - return nil - }, + return StatusResult{OK: true, Message: "moved block: " + inv.Args[0] + " -> " + inv.Args[1]}, nil + }), } } @@ -141,17 +137,14 @@ func blockPrependCmd() *redant.Command { return &redant.Command{ Use: "prepend ", Short: "Prepend block to page", - Handler: func(ctx context.Context, inv *redant.Invocation) error { - if len(inv.Args) < 2 { - return fmt.Errorf("page name and content required") - } - client := NewClient() - block, err := client.PrependBlockInPage(ctx, inv.Args[0], inv.Args[1]) - if err != nil { - return err - } - return PrintJSON(block) + Args: redant.ArgSet{ + {Name: "page", Required: true, Value: redant.StringOf(new(string)), Description: "Page name"}, + {Name: "content", Required: true, Value: redant.StringOf(new(string)), Description: "Block content"}, }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*logseq.Block, error) { + client := NewClient() + return client.PrependBlockInPage(ctx, inv.Args[0], inv.Args[1]) + }), } } @@ -159,16 +152,13 @@ func blockAppendCmd() *redant.Command { return &redant.Command{ Use: "append ", Short: "Append block to page", - Handler: func(ctx context.Context, inv *redant.Invocation) error { - if len(inv.Args) < 2 { - return fmt.Errorf("page name and content required") - } - client := NewClient() - block, err := client.AppendBlockInPage(ctx, inv.Args[0], inv.Args[1]) - if err != nil { - return err - } - return PrintJSON(block) + Args: redant.ArgSet{ + {Name: "page", Required: true, Value: redant.StringOf(new(string)), Description: "Page name"}, + {Name: "content", Required: true, Value: redant.StringOf(new(string)), Description: "Block content"}, }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*logseq.Block, error) { + client := NewClient() + return client.AppendBlockInPage(ctx, inv.Args[0], inv.Args[1]) + }), } } diff --git a/cmds/cmds.go b/cmds/cmds.go index 527e38e..f1e6bb3 100644 --- a/cmds/cmds.go +++ b/cmds/cmds.go @@ -1,9 +1,7 @@ package cmds import ( - "encoding/json" "fmt" - "os" "github.com/pubgo/logseq-cli/pkg/logseq" ) @@ -15,6 +13,11 @@ var ( Output string ) +type StatusResult struct { + OK bool `json:"ok"` + Message string `json:"message,omitempty"` +} + func NewClient() *logseq.Client { baseURL := fmt.Sprintf("http://%s:%s", Host, Port) return logseq.NewClient( @@ -22,9 +25,3 @@ func NewClient() *logseq.Client { logseq.WithToken(Token), ) } - -func PrintJSON(v any) error { - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(v) -} diff --git a/cmds/graph.go b/cmds/graph.go index ae1660c..cc07978 100644 --- a/cmds/graph.go +++ b/cmds/graph.go @@ -2,8 +2,9 @@ package cmds import ( "context" - "fmt" + "encoding/json" + "github.com/pubgo/logseq-cli/pkg/logseq" "github.com/pubgo/redant" ) @@ -15,14 +16,10 @@ func GraphCmd() *redant.Command { { Use: "info", Short: "Get current graph info", - Handler: func(ctx context.Context, inv *redant.Invocation) error { + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*logseq.GraphInfo, error) { client := NewClient() - info, err := client.GetCurrentGraph(ctx) - if err != nil { - return err - } - return PrintJSON(info) - }, + return client.GetCurrentGraph(ctx) + }), }, }, } @@ -36,33 +33,37 @@ func QueryCmd() *redant.Command { { Use: "datalog ", Short: "Execute Datalog query", + Args: redant.ArgSet{ + {Name: "query", Required: true, Value: redant.StringOf(new(string)), Description: "Datalog query string"}, + }, Handler: func(ctx context.Context, inv *redant.Invocation) error { - if len(inv.Args) == 0 { - return fmt.Errorf("datalog query required") - } client := NewClient() result, err := client.DatascriptQuery(ctx, inv.Args[0]) if err != nil { return err } - fmt.Fprintln(inv.Stdout, string(result)) - return nil + var buf json.RawMessage = result + enc := json.NewEncoder(inv.Stdout) + enc.SetIndent("", " ") + return enc.Encode(buf) }, }, { Use: "dsl ", Short: "Execute Logseq DSL query", + Args: redant.ArgSet{ + {Name: "query", Required: true, Value: redant.StringOf(new(string)), Description: "DSL query string"}, + }, Handler: func(ctx context.Context, inv *redant.Invocation) error { - if len(inv.Args) == 0 { - return fmt.Errorf("DSL query required") - } client := NewClient() result, err := client.DSLQuery(ctx, inv.Args[0]) if err != nil { return err } - fmt.Fprintln(inv.Stdout, string(result)) - return nil + var buf json.RawMessage = result + enc := json.NewEncoder(inv.Stdout) + enc.SetIndent("", " ") + return enc.Encode(buf) }, }, }, @@ -73,16 +74,12 @@ func SearchCmd() *redant.Command { return &redant.Command{ Use: "search ", Short: "Full-text search", - Handler: func(ctx context.Context, inv *redant.Invocation) error { - if len(inv.Args) == 0 { - return fmt.Errorf("search query required") - } - client := NewClient() - result, err := client.Search(ctx, inv.Args[0]) - if err != nil { - return err - } - return PrintJSON(result) + Args: redant.ArgSet{ + {Name: "query", Required: true, Value: redant.StringOf(new(string)), Description: "Search query"}, }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*logseq.SearchResult, error) { + client := NewClient() + return client.Search(ctx, inv.Args[0]) + }), } } diff --git a/cmds/page.go b/cmds/page.go index e1d6de9..f56b9fe 100644 --- a/cmds/page.go +++ b/cmds/page.go @@ -2,6 +2,7 @@ package cmds import ( "context" + "encoding/json" "fmt" "github.com/pubgo/logseq-cli/pkg/logseq" @@ -26,14 +27,10 @@ func pageListCmd() *redant.Command { return &redant.Command{ Use: "list", Short: "List all pages", - Handler: func(ctx context.Context, inv *redant.Invocation) error { + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) ([]logseq.Page, error) { client := NewClient() - pages, err := client.GetAllPages(ctx) - if err != nil { - return err - } - return PrintJSON(pages) - }, + return client.GetAllPages(ctx) + }), } } @@ -42,6 +39,9 @@ func pageGetCmd() *redant.Command { return &redant.Command{ Use: "get ", Short: "Get page info", + Args: redant.ArgSet{ + {Name: "name", Required: true, Value: redant.StringOf(new(string)), Description: "Page name"}, + }, Options: redant.OptionSet{ { Flag: "blocks", @@ -51,18 +51,18 @@ func pageGetCmd() *redant.Command { }, }, Handler: func(ctx context.Context, inv *redant.Invocation) error { - if len(inv.Args) == 0 { - return fmt.Errorf("page name required") - } client := NewClient() name := inv.Args[0] + enc := json.NewEncoder(inv.Stdout) + enc.SetIndent("", " ") + if withBlocks { blocks, err := client.GetPageBlocksTree(ctx, name) if err != nil { return err } - return PrintJSON(blocks) + return enc.Encode(blocks) } page, err := client.GetPage(ctx, name) @@ -72,7 +72,7 @@ func pageGetCmd() *redant.Command { if page == nil { return fmt.Errorf("page '%s' not found", name) } - return PrintJSON(page) + return enc.Encode(page) }, } } @@ -81,19 +81,15 @@ func pageCreateCmd() *redant.Command { return &redant.Command{ Use: "create ", Short: "Create a page", - Handler: func(ctx context.Context, inv *redant.Invocation) error { - if len(inv.Args) == 0 { - return fmt.Errorf("page name required") - } + Args: redant.ArgSet{ + {Name: "name", Required: true, Value: redant.StringOf(new(string)), Description: "Page name"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*logseq.Page, error) { client := NewClient() - page, err := client.CreatePage(ctx, inv.Args[0], nil, &logseq.CreatePageOptions{ + return client.CreatePage(ctx, inv.Args[0], nil, &logseq.CreatePageOptions{ CreateFirstBlock: true, }) - if err != nil { - return err - } - return PrintJSON(page) - }, + }), } } @@ -101,17 +97,16 @@ func pageDeleteCmd() *redant.Command { return &redant.Command{ Use: "delete ", Short: "Delete a page", - Handler: func(ctx context.Context, inv *redant.Invocation) error { - if len(inv.Args) == 0 { - return fmt.Errorf("page name required") - } + Args: redant.ArgSet{ + {Name: "name", Required: true, Value: redant.StringOf(new(string)), Description: "Page name"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { client := NewClient() if err := client.DeletePage(ctx, inv.Args[0]); err != nil { - return err + return StatusResult{}, err } - fmt.Fprintf(inv.Stdout, "deleted page: %s\n", inv.Args[0]) - return nil - }, + return StatusResult{OK: true, Message: "deleted page: " + inv.Args[0]}, nil + }), } } @@ -119,16 +114,16 @@ func pageRenameCmd() *redant.Command { return &redant.Command{ Use: "rename ", Short: "Rename a page", - Handler: func(ctx context.Context, inv *redant.Invocation) error { - if len(inv.Args) < 2 { - return fmt.Errorf("old name and new name required") - } + Args: redant.ArgSet{ + {Name: "old-name", Required: true, Value: redant.StringOf(new(string)), Description: "Current page name"}, + {Name: "new-name", Required: true, Value: redant.StringOf(new(string)), Description: "New page name"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { client := NewClient() if err := client.RenamePage(ctx, inv.Args[0], inv.Args[1]); err != nil { - return err + return StatusResult{}, err } - fmt.Fprintf(inv.Stdout, "renamed: %s -> %s\n", inv.Args[0], inv.Args[1]) - return nil - }, + return StatusResult{OK: true, Message: "renamed: " + inv.Args[0] + " -> " + inv.Args[1]}, nil + }), } } diff --git a/go.mod b/go.mod index 9c9247d..959257b 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/pubgo/logseq-cli go 1.26.1 +replace github.com/pubgo/redant v0.3.0 => /Users/barry/git/redant + require github.com/pubgo/redant v0.3.0 require ( diff --git a/go.sum b/go.sum index ac5cc74..9c08e77 100644 --- a/go.sum +++ b/go.sum @@ -25,8 +25,6 @@ github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+7 github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/pubgo/redant v0.3.0 h1:H7TxDDHxVOuXAbUKcb/wyObYSnlNes8LngBeXoVYzjY= -github.com/pubgo/redant v0.3.0/go.mod h1:pJXH/Im4+1yrUO7AmmQ5lspYzjfKWyW4bCiLg6B41pk= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= From ac13f06922c20d1c9633d8a783c7942e2744a054 Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 8 May 2026 14:26:25 +0800 Subject: [PATCH 04/37] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20README=20?= =?UTF-8?q?=E5=92=8C=E5=88=86=E6=9E=90=E6=96=87=E6=A1=A3=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=91=BD=E4=BB=A4=E5=8F=82=E8=80=83=E6=96=87=E6=A1=A3?= =?UTF-8?q?=EF=BC=8C=E5=AE=8C=E5=96=84=E5=8A=9F=E8=83=BD=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 166 +++++++++++++++++++++++++++++- docs/ANALYSIS.md | 34 ++++--- docs/COMMANDS.md | 257 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 440 insertions(+), 17 deletions(-) create mode 100644 docs/COMMANDS.md diff --git a/README.md b/README.md index 983212e..a99a525 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,166 @@ # logseq-cli -logseq cli + +一个用于操作 Logseq HTTP API 的命令行工具(CLI),支持页面、块、查询、搜索,并内置文档站、Web 可视化入口与 MCP 集成能力。 + +> 当前仓库主命令名为 `logseq`。 + +## 功能特性 + +- 页面管理:列出、获取、创建、删除、重命名页面 +- 块管理:获取、插入、更新、删除、移动、页面头尾追加 +- 查询能力:Datalog / Logseq DSL +- 图谱信息:查看当前 Graph 元数据 +- 全文搜索:调用 `logseq.App.search` +- 开发者增强: + - `doc`:启动交互式命令文档站 + - `web`:打开可视化命令执行页面 + - `mcp`:以 MCP 方式暴露命令树 + - `completion`:生成 shell 自动补全 + +## 环境要求 + +- Go `1.26.1` 或更高版本 +- 已运行 Logseq 桌面应用 +- 已开启 Logseq HTTP API,并获取 API Token + +## 安装方式 + +### 方式一:源码运行(推荐开发调试) + +在仓库根目录执行: + +- `go run . --help` + +### 方式二:安装为本地命令 + +- `go install ./...` + +安装后可直接使用: + +- `logseq --help` + +## 快速开始 + +### 1) 配置环境变量 + +- `LOGSEQ_API_TOKEN`:必填,Logseq API token +- `LOGSEQ_HOST`:可选,默认 `127.0.0.1` +- `LOGSEQ_PORT`:可选,默认 `12315` + +示例: + +- `export LOGSEQ_API_TOKEN='your-token'` +- `export LOGSEQ_HOST='127.0.0.1'` +- `export LOGSEQ_PORT='12315'` + +### 2) 验证连通性 + +- `logseq graph info` + +如果返回当前图谱信息(名称、路径、URL),说明配置成功。 + +## 全局参数 + +| 参数 | 环境变量 | 默认值 | 说明 | +| ----------------- | ------------------ | ----------- | -------------------------- | +| `-t, --token` | `LOGSEQ_API_TOKEN` | 无(必填) | Logseq API token | +| `--host` | `LOGSEQ_HOST` | `127.0.0.1` | Logseq API 主机 | +| `-p, --port` | `LOGSEQ_PORT` | `12315` | Logseq API 端口 | +| `-o, --output` | - | `json` | 输出格式:`json` / `text` | +| `--raw-envelope` | - | `false` | 输出结构化 NDJSON envelope | +| `--list-commands` | - | `false` | 列出全部命令(含子命令) | +| `--list-flags` | - | `false` | 列出全部参数 | + +## 命令总览 + +### 页面(page) + +- `logseq page list` +- `logseq page get [-b|--blocks]` +- `logseq page create ` +- `logseq page delete ` +- `logseq page rename ` + +### 块(block) + +- `logseq block get [-c|--children]` +- `logseq block insert [-s|--sibling]` +- `logseq block update ` +- `logseq block remove ` +- `logseq block move ` +- `logseq block prepend ` +- `logseq block append ` + +### 图谱(graph) + +- `logseq graph info` + +### 查询(query) + +- `logseq query datalog ` +- `logseq query dsl ` + +### 搜索(search) + +- `logseq search ` + +### 其他能力 + +- `logseq completion ` +- `logseq doc [--addr 127.0.0.1:18081] [--open true|false]` +- `logseq web [--addr 127.0.0.1:18080] [--open true|false]` +- `logseq mcp list [--format json|text]` +- `logseq mcp serve [--transport stdio]` +- `logseq llms-txt [-f markdown|json|skill] [-d ] [-o ]` + +## 常用示例 + +- 查看所有页面:`logseq page list` +- 获取页面及其块树:`logseq page get "Daily Notes" --blocks` +- 新建页面:`logseq page create "项目规划"` +- 在页面末尾追加块:`logseq block append "项目规划" "- [ ] 第一阶段完成"` +- 更新块内容:`logseq block update "- [x] 第一阶段完成"` +- 执行 Datalog 查询:`logseq query datalog '[:find ?p :where [?b :block/name ?p]]'` +- 全文搜索:`logseq search "Go SDK"` + +## 输出说明 + +- 默认输出格式为 `json` +- 可通过 `--output text` 切换为文本输出 +- 对接自动化流程时建议使用默认 `json` 或 `--raw-envelope` + +## 常见问题排查 + +### 提示缺少 token + +请确认已设置 `LOGSEQ_API_TOKEN`,或在命令中显式传入 `--token`。 + +### 无法连接到 `127.0.0.1:12315` + +- 确认 Logseq 正在运行 +- 确认 HTTP API 服务已开启 +- 确认 `LOGSEQ_HOST` / `LOGSEQ_PORT` 与 Logseq 配置一致 + +### 某些 API 返回 `MethodNotExist` + +不同 Logseq 版本的 API 可用性存在差异。建议: + +- 升级 Logseq 到较新稳定版 +- 优先使用本项目已封装并在代码中使用的方法 +- 对搜索等能力在你的版本上先做小样本验证 + +## 项目结构 + +- `main.go`:CLI 入口与全局参数 +- `cmds/`:命令定义(page / block / graph / query / search) +- `pkg/logseq/`:Logseq API 客户端与数据类型 +- `docs/`:分析文档与补充资料 + +## 参考文档 + +- 命令速查与详细参数说明:`docs/COMMANDS.md` +- API 与实现分析:`docs/ANALYSIS.md` + +## License + +本项目使用 `LICENSE` 中声明的开源协议。 diff --git a/docs/ANALYSIS.md b/docs/ANALYSIS.md index 431b31b..4c979c6 100644 --- a/docs/ANALYSIS.md +++ b/docs/ANALYSIS.md @@ -155,21 +155,19 @@ type GraphInfo struct { ``` logseq-cli/ ├── go.mod -├── main.go # CLI 入口 +├── main.go # CLI 入口(根命令与全局参数) +├── cmds/ # CLI 子命令定义 +│ ├── cmds.go # 共享配置与客户端创建 +│ ├── page.go # page list/get/create/delete/rename +│ ├── block.go # block get/insert/update/remove/move/prepend/append +│ └── graph.go # graph/query/search 命令 ├── pkg/ │ └── logseq/ # Logseq Go SDK │ ├── client.go # HTTP 客户端(底层 RPC 调用) │ ├── types.go # 数据模型定义 │ ├── editor.go # Editor 命名空间 API │ ├── app.go # App 命名空间 API -│ ├── db.go # DB 命名空间 API(Datalog/DSL 查询) -│ └── options.go # 客户端选项(地址、token、超时等) -├── cmd/ # CLI 子命令 -│ ├── root.go # 根命令(全局 flags) -│ ├── page.go # page list/get/create/delete/rename -│ ├── block.go # block get/insert/update/remove/move -│ ├── graph.go # graph info -│ └── query.go # query run(Datalog 查询) +│ └── db.go # DB 命名空间 API(Datalog/DSL 查询) └── docs/ └── ANALYSIS.md # 本文档 ``` @@ -269,7 +267,6 @@ logseq │ ├── create # 创建页面 │ ├── delete # 删除页面 │ ├── rename # 重命名页面 -│ └── properties # 获取页面属性 ├── block # Block 管理 │ ├── get # 获取 Block │ ├── insert # 插入 Block @@ -283,7 +280,12 @@ logseq ├── query # 查询 │ ├── datalog # Datascript/Datalog 查询 │ └── dsl # Logseq DSL 查询 -└── search # 全文搜索 +├── search # 全文搜索 +├── completion # shell 自动补全 +├── doc # 交互式命令文档站 +├── web # 可视化命令执行页面 +├── mcp # MCP 集成命令 +└── llms-txt # LLM 友好文档导出 ``` ### 5.3 redant 框架核心用法 @@ -394,8 +396,8 @@ func main() { ## 9. 下一步计划 -1. 初始化 Go 模块 (`go mod init github.com/pubgo/logseq-cli`) -2. 实现 `pkg/logseq` SDK(client + types + editor/app/db) -3. 基于 redant 构建 CLI 命令树 -4. 编写集成测试(需要运行 Logseq 实例) -5. 利用 redant 的 MCP 集成能力暴露为 MCP 服务器 +1. 增补与维护中文文档(README / 命令参考 / API 分析) +2. 增加集成测试与示例脚本(需可连接 Logseq 实例) +3. 补充错误码与常见故障排查说明 +4. 评估 API 版本差异并提供能力矩阵 +5. 持续完善 MCP 使用场景与示例配置 diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md new file mode 100644 index 0000000..922fa43 --- /dev/null +++ b/docs/COMMANDS.md @@ -0,0 +1,257 @@ +# logseq-cli 命令参考(中文) + +本文档提供 `logseq` 命令的中文速查与参数说明,内容基于当前代码与命令帮助输出整理。 + +## 根命令 + +- 命令:`logseq` +- 描述:Logseq CLI - command line tool for Logseq + +### 全局参数 + +| 参数 | 类型 | 默认值 | 环境变量 | 说明 | +| ----------------- | ------------------- | ----------- | ------------------ | -------------------------- | +| `-t, --token` | string | 无(必填) | `LOGSEQ_API_TOKEN` | Logseq API token | +| `--host` | string | `127.0.0.1` | `LOGSEQ_HOST` | Logseq API host | +| `-p, --port` | string | `12315` | `LOGSEQ_PORT` | Logseq API port | +| `-o, --output` | enum(`json`,`text`) | `json` | - | 输出格式 | +| `--raw-envelope` | bool | `false` | - | 输出结构化 NDJSON envelope | +| `--list-commands` | bool | `false` | - | 列出全部命令(含子命令) | +| `--list-flags` | bool | `false` | - | 列出全部参数 | +| `-h, --help` | bool | `false` | - | 显示帮助 | + +--- + +## page:页面管理 + +### `logseq page list` + +列出所有页面。 + +### `logseq page get ` + +获取页面信息。 + +参数: + +- `name`(string,必填):页面名称 + +选项: + +- `-b, --blocks`(bool):包含页面块树 + +### `logseq page create ` + +创建页面。 + +参数: + +- `name`(string,必填):页面名称 + +### `logseq page delete ` + +删除页面。 + +参数: + +- `name`(string,必填):页面名称 + +### `logseq page rename ` + +重命名页面。 + +参数: + +- `old-name`(string,必填):旧名称 +- `new-name`(string,必填):新名称 + +--- + +## block:块管理 + +### `logseq block get ` + +通过 UUID 获取块。 + +参数: + +- `uuid`(string,必填):块 UUID + +选项: + +- `-c, --children`(bool):包含子块 + +### `logseq block insert ` + +向目标块插入新块。 + +参数: + +- `target-uuid`(string,必填):目标块 UUID +- `content`(string,必填):块内容 + +选项: + +- `-s, --sibling`(bool):作为同级块插入(默认作为子块) + +### `logseq block update ` + +更新块内容。 + +参数: + +- `uuid`(string,必填):块 UUID +- `content`(string,必填):新内容 + +### `logseq block remove ` + +删除块。 + +参数: + +- `uuid`(string,必填):块 UUID + +### `logseq block move ` + +移动块。 + +参数: + +- `src-uuid`(string,必填):源块 UUID +- `target-uuid`(string,必填):目标块 UUID + +### `logseq block prepend ` + +在页面顶部插入块。 + +参数: + +- `page`(string,必填):页面名称 +- `content`(string,必填):块内容 + +### `logseq block append ` + +在页面底部追加块。 + +参数: + +- `page`(string,必填):页面名称 +- `content`(string,必填):块内容 + +--- + +## graph:图谱信息 + +### `logseq graph info` + +获取当前图谱信息。 + +--- + +## query:查询 + +### `logseq query datalog ` + +执行 Datalog 查询。 + +参数: + +- `query`(string,必填):Datalog 查询字符串 + +### `logseq query dsl ` + +执行 Logseq DSL 查询。 + +参数: + +- `query`(string,必填):DSL 查询字符串 + +--- + +## search:全文搜索 + +### `logseq search ` + +参数: + +- `query`(string,必填):搜索关键词 + +--- + +## completion:自动补全 + +### `logseq completion ` + +生成 shell 自动补全脚本。 + +参数: + +- `shell`(必填):`bash` / `zsh` / `fish` + +--- + +## doc:交互式文档站 + +### `logseq doc` + +启动交互式命令文档站。 + +选项: + +- `--addr`(string,默认 `127.0.0.1:18081`):监听地址 +- `--open`(bool,默认 `true`):启动后自动打开浏览器 + +--- + +## web:可视化命令执行页面 + +### `logseq web` + +启动可视化命令执行页面。 + +选项: + +- `--addr`(string,默认 `127.0.0.1:18080`):监听地址 +- `--open`(bool,默认 `true`):启动后自动打开浏览器 + +--- + +## mcp:MCP 集成 + +### `logseq mcp list` + +列出 MCP 工具元数据。 + +选项: + +- `--format`(enum: `json` / `text`,默认 `json`) + +### `logseq mcp serve` + +启动 MCP 服务。 + +选项: + +- `--transport`(当前默认 `stdio`) + +--- + +## llms-txt:LLM 文档导出 + +### `logseq llms-txt` + +导出命令树文档,便于 LLM 消费。 + +选项: + +- `-f, --format`:`markdown` / `json` / `skill`(默认 `markdown`) +- `-d, --depth`:命令树最大深度(`0` 表示不限制) +- `-o, --output-dir`:skill 模式输出目录 + +--- + +## 推荐用法 + +- 优先使用环境变量传递 token,避免在 shell 历史中泄露敏感信息。 +- 自动化脚本建议使用默认 JSON 输出。 +- 若遇到 API 版本差异导致的方法不可用,先用小范围命令验证(如 `graph info`、`page list`)。 From 44523be546b02154b2dec916a6364c666fe9e093 Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 8 May 2026 15:37:05 +0800 Subject: [PATCH 05/37] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=AB=AF?= =?UTF-8?q?=E5=88=B0=E7=AB=AF=E9=9B=86=E6=88=90=E6=B5=8B=E8=AF=95=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=8C=E6=94=AF=E6=8C=81=20Logseq=20CLI=20=E7=9A=84?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8C=96=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 32 +++++ cmd/e2e/main.go | 376 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 408 insertions(+) create mode 100644 cmd/e2e/main.go diff --git a/README.md b/README.md index a99a525..14f0bd7 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,38 @@ 如果返回当前图谱信息(名称、路径、URL),说明配置成功。 +## 端到端集成测试(E2E) + +项目提供**独立 E2E 可执行模块**:`cmd/e2e`,可直接二进制运行,不依赖 `go test`。 + +执行方式(需本地 Logseq 已开启 API): + +- 直接运行:`LOGSEQ_API_TOKEN='your-token' go run ./cmd/e2e` +- 编译后二进制运行:`go build -o ./bin/logseq-e2e ./cmd/e2e && LOGSEQ_API_TOKEN='your-token' ./bin/logseq-e2e` +- 指定已有 CLI 二进制:`go run ./cmd/e2e --cli-bin ./logseq --token your-token` + +常用参数: + +- `--token`:API Token(默认读 `LOGSEQ_API_TOKEN`) +- `--host` / `--port`:默认 `127.0.0.1:12315` +- `--page-prefix`:临时页面名前缀 +- `--keep-page`:保留测试页面用于排查 +- `--timeout`:单条命令超时时间(默认 `60s`) + +覆盖链路: + +- `graph info` 连通性检查 +- `page create` +- `block append/get/update/get/remove` +- `page delete` +- `query datalog` 清理校验 + +说明: + +- 若未显式提供 `--cli-bin`,运行器会自动构建当前仓库 CLI 再执行 +- 运行器会优先读取环境变量,也会自动加载项目根目录 `.env` +- 默认会自动清理临时页面,避免污染现有笔记 + ## 全局参数 | 参数 | 环境变量 | 默认值 | 说明 | diff --git a/cmd/e2e/main.go b/cmd/e2e/main.go new file mode 100644 index 0000000..393e40f --- /dev/null +++ b/cmd/e2e/main.go @@ -0,0 +1,376 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +type statusResp struct { + OK bool `json:"ok"` + Message string `json:"message"` +} + +type pageResp struct { + Name string `json:"name"` + UUID string `json:"uuid"` +} + +type blockResp struct { + UUID string `json:"uuid"` + Content string `json:"content"` +} + +type config struct { + cliBin string + token string + host string + port string + pagePrefix string + keepPage bool + timeout time.Duration +} + +type runner struct { + globalArgs []string + bin string + env []string + timeout time.Duration +} + +func main() { + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "❌ e2e failed: %v\n", err) + os.Exit(1) + } + fmt.Println("✅ e2e finished successfully") +} + +func run() error { + loadEnvFromDotEnv() + + cfg := parseFlags() + if strings.TrimSpace(cfg.token) == "" { + return errors.New("LOGSEQ_API_TOKEN is required (or pass --token)") + } + + workDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working dir: %w", err) + } + + binPath := cfg.cliBin + cleanupBin := func() {} + if strings.TrimSpace(binPath) == "" { + var cleanup func() + binPath, cleanup, err = buildCLIBinary(workDir) + if err != nil { + return err + } + cleanupBin = cleanup + } + defer cleanupBin() + + r := &runner{ + globalArgs: []string{"--token", cfg.token, "--host", cfg.host, "--port", cfg.port}, + bin: binPath, + env: mergeEnv(map[string]string{ + "LOGSEQ_API_TOKEN": cfg.token, + "LOGSEQ_HOST": cfg.host, + "LOGSEQ_PORT": cfg.port, + }), + timeout: cfg.timeout, + } + + pageName := fmt.Sprintf("%s_%d", cfg.pagePrefix, time.Now().UnixNano()) + shouldCleanup := !cfg.keepPage + + if shouldCleanup { + defer func() { + _, _, _ = r.run("page", "delete", pageName) + }() + } + + if err := step("graph info", func() error { + var graph map[string]any + if err := r.runJSON(&graph, "graph", "info"); err != nil { + return err + } + if len(graph) == 0 { + return errors.New("graph info is empty") + } + return nil + }); err != nil { + return err + } + + if err := step("page create", func() error { + var page pageResp + if err := r.runJSON(&page, "page", "create", pageName); err != nil { + return err + } + if page.UUID == "" { + return fmt.Errorf("empty page uuid, response=%+v", page) + } + return nil + }); err != nil { + return err + } + + const v1 = "copilot e2e block v1" + const v2 = "copilot e2e block v2" + + var blockUUID string + + if err := step("block append", func() error { + var block blockResp + if err := r.runJSON(&block, "block", "append", pageName, v1); err != nil { + return err + } + if block.UUID == "" { + return fmt.Errorf("empty block uuid, response=%+v", block) + } + blockUUID = block.UUID + return nil + }); err != nil { + return err + } + + if err := step("block get(v1)", func() error { + var block blockResp + if err := r.runJSON(&block, "block", "get", blockUUID); err != nil { + return err + } + if block.Content != v1 { + return fmt.Errorf("unexpected content, want=%q got=%q", v1, block.Content) + } + return nil + }); err != nil { + return err + } + + if err := step("block update", func() error { + var st statusResp + if err := r.runJSON(&st, "block", "update", blockUUID, v2); err != nil { + return err + } + if !st.OK { + return fmt.Errorf("update failed: %+v", st) + } + return nil + }); err != nil { + return err + } + + if err := step("block get(v2)", func() error { + var block blockResp + if err := r.runJSON(&block, "block", "get", blockUUID); err != nil { + return err + } + if block.Content != v2 { + return fmt.Errorf("unexpected content, want=%q got=%q", v2, block.Content) + } + return nil + }); err != nil { + return err + } + + if err := step("block remove", func() error { + var st statusResp + if err := r.runJSON(&st, "block", "remove", blockUUID); err != nil { + return err + } + if !st.OK { + return fmt.Errorf("remove failed: %+v", st) + } + return nil + }); err != nil { + return err + } + + if shouldCleanup { + if err := step("page delete", func() error { + var st statusResp + if err := r.runJSON(&st, "page", "delete", pageName); err != nil { + return err + } + if !st.OK { + return fmt.Errorf("delete failed: %+v", st) + } + return nil + }); err != nil { + return err + } + + if err := step("query verify page removed", func() error { + query := fmt.Sprintf(`[:find ?e :where [?e :block/name %q]]`, pageName) + var result []any + if err := r.runJSON(&result, "query", "datalog", query); err != nil { + return err + } + if len(result) != 0 { + return fmt.Errorf("expected empty query result, got=%v", result) + } + return nil + }); err != nil { + return err + } + } + + if cfg.keepPage { + fmt.Printf("ℹ️ keep page enabled, left test page: %s\n", pageName) + } + + return nil +} + +func parseFlags() config { + var cfg config + + flag.StringVar(&cfg.cliBin, "cli-bin", "", "Path to prebuilt logseq CLI binary (empty means auto-build current repo)") + flag.StringVar(&cfg.token, "token", envOrDefault("LOGSEQ_API_TOKEN", ""), "Logseq API token") + flag.StringVar(&cfg.host, "host", envOrDefault("LOGSEQ_HOST", "127.0.0.1"), "Logseq API host") + flag.StringVar(&cfg.port, "port", envOrDefault("LOGSEQ_PORT", "12315"), "Logseq API port") + flag.StringVar(&cfg.pagePrefix, "page-prefix", "__copilot_e2e", "Temporary page name prefix") + flag.BoolVar(&cfg.keepPage, "keep-page", false, "Keep created page for debugging") + flag.DurationVar(&cfg.timeout, "timeout", 60*time.Second, "Per command timeout") + + flag.Parse() + return cfg +} + +func envOrDefault(key, def string) string { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return def + } + return v +} + +func mergeEnv(overrides map[string]string) []string { + env := os.Environ() + used := make(map[string]struct{}, len(overrides)) + + filtered := make([]string, 0, len(env)+len(overrides)) + for _, item := range env { + idx := strings.Index(item, "=") + if idx <= 0 { + filtered = append(filtered, item) + continue + } + k := item[:idx] + if _, ok := overrides[k]; ok { + continue + } + filtered = append(filtered, item) + } + + for k, v := range overrides { + if _, ok := used[k]; ok { + continue + } + filtered = append(filtered, k+"="+v) + used[k] = struct{}{} + } + + return filtered +} + +func buildCLIBinary(workDir string) (string, func(), error) { + tmpDir, err := os.MkdirTemp("", "logseq-e2e-*") + if err != nil { + return "", nil, fmt.Errorf("create temp dir: %w", err) + } + + cleanup := func() { _ = os.RemoveAll(tmpDir) } + binPath := filepath.Join(tmpDir, "logseq-cli") + + cmd := exec.Command("go", "build", "-o", binPath, ".") + cmd.Dir = workDir + cmd.Env = os.Environ() + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + cleanup() + return "", nil, fmt.Errorf("build cli: %w\n%s", err, strings.TrimSpace(stderr.String())) + } + + return binPath, cleanup, nil +} + +func (r *runner) runJSON(dst any, args ...string) error { + out, errOut, err := r.run(args...) + if err != nil { + return fmt.Errorf("cmd failed (%s): %w, stdout=%s, stderr=%s", strings.Join(args, " "), err, out, errOut) + } + if err := json.Unmarshal([]byte(out), dst); err != nil { + return fmt.Errorf("decode json failed (%s): %w, stdout=%s, stderr=%s", strings.Join(args, " "), err, out, errOut) + } + return nil +} + +func (r *runner) run(args ...string) (string, string, error) { + ctx, cancel := context.WithTimeout(context.Background(), r.timeout) + defer cancel() + + fullArgs := append(append([]string{}, r.globalArgs...), args...) + cmd := exec.CommandContext(ctx, r.bin, fullArgs...) + cmd.Env = r.env + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + return strings.TrimSpace(stdout.String()), strings.TrimSpace(stderr.String()), err +} + +func step(name string, fn func() error) error { + start := time.Now() + fmt.Printf("▶ %s\n", name) + if err := fn(); err != nil { + return fmt.Errorf("%s: %w", name, err) + } + fmt.Printf("✔ %s (%s)\n", name, time.Since(start).Round(time.Millisecond)) + return nil +} + +func loadEnvFromDotEnv() { + wd, err := os.Getwd() + if err != nil { + return + } + data, err := os.ReadFile(filepath.Join(wd, ".env")) + if err != nil { + return + } + + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.Index(line, "=") + if idx <= 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + value := strings.Trim(strings.TrimSpace(line[idx+1:]), `"'`) + if key == "" || value == "" { + continue + } + if os.Getenv(key) == "" { + _ = os.Setenv(key, value) + } + } +} From 7f1bb20324ddaa7f63a4e52dcfc881d4bc2c3d15 Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 8 May 2026 15:46:25 +0800 Subject: [PATCH 06/37] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20LLM/MCP=20?= =?UTF-8?q?=E9=9B=86=E6=88=90=E6=8C=87=E5=8D=97=E5=92=8C=E7=A4=BA=E4=BE=8B?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=96=87=E4=BB=B6=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20Logseq=20CLI=20=E7=9A=84=E7=9B=B4=E6=8E=A5=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 ++++ docs/LLM_MCP.md | 89 ++++++++++++++++++++ docs/examples/claude_desktop_logseq_mcp.json | 18 ++++ 3 files changed, 122 insertions(+) create mode 100644 docs/LLM_MCP.md create mode 100644 docs/examples/claude_desktop_logseq_mcp.json diff --git a/README.md b/README.md index 14f0bd7..aa93345 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,20 @@ 如果返回当前图谱信息(名称、路径、URL),说明配置成功。 +## LLM / MCP 集成 + +如果你希望让 LLM 直接操作 Logseq,可使用内置 MCP 服务: + +- `logseq mcp serve --transport stdio` + +推荐先阅读:`docs/LLM_MCP.md` + +其中包含: + +- 完整接入步骤 +- Claude Desktop 配置示例 +- 常见报错与排查 + ## 端到端集成测试(E2E) 项目提供**独立 E2E 可执行模块**:`cmd/e2e`,可直接二进制运行,不依赖 `go test`。 @@ -192,6 +206,7 @@ - 命令速查与详细参数说明:`docs/COMMANDS.md` - API 与实现分析:`docs/ANALYSIS.md` +- LLM/MCP 接入指南:`docs/LLM_MCP.md` ## License diff --git a/docs/LLM_MCP.md b/docs/LLM_MCP.md new file mode 100644 index 0000000..d4bf35f --- /dev/null +++ b/docs/LLM_MCP.md @@ -0,0 +1,89 @@ +# LLM 通过 MCP 操作 Logseq(实战指南) + +本文说明如何把 `logseq-cli` 暴露给支持 MCP 的 LLM 客户端,让模型可直接执行页面/块/查询等操作。 + +## 1. 前提条件 + +- Logseq 桌面端已启动 +- Logseq API 可用(默认 `127.0.0.1:12315`) +- 已拿到 token(`LOGSEQ_API_TOKEN`) +- 本项目可正常运行 `logseq` 命令 + +可先做连通性检查: + +- `logseq --token --host 127.0.0.1 --port 12315 graph info` + +## 2. 启动 MCP Server + +`logseq-cli` 已内置 MCP 子命令,服务模式为 `stdio`: + +- `logseq --token --host 127.0.0.1 --port 12315 mcp serve --transport stdio` + +> 说明:MCP 客户端会接管 stdio,所以该命令通常不是你手工长期运行,而是由客户端按需拉起。 + +## 3. 推荐先构建本地二进制 + +避免客户端每次 `go run` 带来的编译开销,建议先构建: + +- `go build -o ./bin/logseq .` + +后续 MCP 客户端统一调用 `./bin/logseq`。 + +## 4. Claude Desktop 配置示例(macOS) + +参考文件:`docs/examples/claude_desktop_logseq_mcp.json` + +将 `mcpServers.logseq` 合并进 Claude 的配置(`claude_desktop_config.json`): + +- `command`:`/绝对路径/logseq-cli/bin/logseq` +- `args`:`["mcp", "serve", "--transport", "stdio"]` +- `env`:设置 `LOGSEQ_API_TOKEN`、`LOGSEQ_HOST`、`LOGSEQ_PORT` + +## 5. 通用 MCP 客户端配置要点 + +大多数 MCP 客户端配置结构相似,通常是: + +- `command`: 可执行文件路径 +- `args`: 启动参数 +- `env`: 环境变量(token/host/port) + +可直接套用以下参数: + +- command: `.../bin/logseq` +- args: `mcp serve --transport stdio` +- env: + - `LOGSEQ_API_TOKEN=<你的token>` + - `LOGSEQ_HOST=127.0.0.1` + - `LOGSEQ_PORT=12315` + +## 6. 给 LLM 的提示词建议 + +为了降低误操作,建议你在系统提示里明确: + +- 优先读操作:`graph info`、`page get`、`query datalog` +- 写操作先确认目标页面 +- 批量改写前先备份或在测试页验证 +- 删除操作必须二次确认 + +## 7. 故障排查 + +### 7.1 `Post "http:///api": http: no Host in request URL` + +通常是 `LOGSEQ_HOST/LOGSEQ_PORT` 未生效。请优先用**显式参数**验证: + +- `logseq --token --host 127.0.0.1 --port 12315 graph info` + +确认可用后再写进 MCP 客户端的 `env`。 + +### 7.2 客户端能连上 MCP,但工具调用失败 + +- 确认 Logseq 桌面端仍在运行 +- 确认 token 未过期/未变更 +- 优先执行一次 `graph info` 作为健康检查 + +### 7.3 `mcp list` 构建异常(本地 replace 依赖场景) + +当前仓库使用了本地 `replace github.com/pubgo/redant => /Users/.../redant`。若出现本地依赖编译异常: + +- 先使用 `mcp serve` 验证服务可启动 +- 再检查本地 `redant` 代码版本是否与当前仓库兼容 diff --git a/docs/examples/claude_desktop_logseq_mcp.json b/docs/examples/claude_desktop_logseq_mcp.json new file mode 100644 index 0000000..88ce309 --- /dev/null +++ b/docs/examples/claude_desktop_logseq_mcp.json @@ -0,0 +1,18 @@ +{ + "mcpServers": { + "logseq": { + "command": "/ABSOLUTE/PATH/TO/logseq-cli/bin/logseq", + "args": [ + "mcp", + "serve", + "--transport", + "stdio" + ], + "env": { + "LOGSEQ_API_TOKEN": "YOUR_LOGSEQ_TOKEN", + "LOGSEQ_HOST": "127.0.0.1", + "LOGSEQ_PORT": "12315" + } + } + } +} \ No newline at end of file From 0f711ea9272cebd1697936429ac0ca61881ec5fd Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 8 May 2026 16:29:05 +0800 Subject: [PATCH 07/37] feat: add webui command for simplified Logseq operations - Introduced `webui` command to start a simple web UI for Logseq data operations. - Updated README.md to include `webui` command details. - Implemented web UI server with endpoints for health check, connection info, page management, block operations, and querying. - Added HTML interface for user interactions with Logseq data. - Integrated automatic browser opening option when starting the web UI. --- README.md | 2 + cmds/cmds.go | 77 +++- cmds/webui.go | 40 ++ internal/webui/assets.go | 6 + internal/webui/server.go | 465 ++++++++++++++++++++++++ internal/webui/static/index.html | 603 +++++++++++++++++++++++++++++++ main.go | 1 + 7 files changed, 1191 insertions(+), 3 deletions(-) create mode 100644 cmds/webui.go create mode 100644 internal/webui/assets.go create mode 100644 internal/webui/server.go create mode 100644 internal/webui/static/index.html diff --git a/README.md b/README.md index aa93345..14f0005 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ - 开发者增强: - `doc`:启动交互式命令文档站 - `web`:打开可视化命令执行页面 + - `webui`:启动简化 Logseq 操作页面(页面/块/搜索/查询 + 最近操作回放 + 连接信息诊断) - `mcp`:以 MCP 方式暴露命令树 - `completion`:生成 shell 自动补全 @@ -155,6 +156,7 @@ - `logseq completion ` - `logseq doc [--addr 127.0.0.1:18081] [--open true|false]` - `logseq web [--addr 127.0.0.1:18080] [--open true|false]` +- `logseq webui [--addr 127.0.0.1:18090] [--open true|false]` - `logseq mcp list [--format json|text]` - `logseq mcp serve [--transport stdio]` - `logseq llms-txt [-f markdown|json|skill] [-d ] [-o ]` diff --git a/cmds/cmds.go b/cmds/cmds.go index f1e6bb3..625c09b 100644 --- a/cmds/cmds.go +++ b/cmds/cmds.go @@ -2,6 +2,8 @@ package cmds import ( "fmt" + "os" + "strings" "github.com/pubgo/logseq-cli/pkg/logseq" ) @@ -18,10 +20,79 @@ type StatusResult struct { Message string `json:"message,omitempty"` } +// ClientConnectionInfo describes the effective Logseq connection configuration. +// Token value itself is intentionally not exposed. +type ClientConnectionInfo struct { + BaseURL string `json:"baseURL"` + Host string `json:"host"` + Port string `json:"port"` + HostSource string `json:"hostSource"` + PortSource string `json:"portSource"` + TokenConfigured bool `json:"tokenConfigured"` + TokenSource string `json:"tokenSource"` +} + +type resolvedClient struct { + info ClientConnectionInfo + token string +} + +func resolveClient() resolvedClient { + host := strings.TrimSpace(Host) + port := strings.TrimSpace(Port) + token := strings.TrimSpace(Token) + hostSource := "option" + portSource := "option" + tokenSource := "option" + + if host == "" { + host = strings.TrimSpace(os.Getenv("LOGSEQ_HOST")) + hostSource = "env" + } + if port == "" { + port = strings.TrimSpace(os.Getenv("LOGSEQ_PORT")) + portSource = "env" + } + if token == "" { + token = strings.TrimSpace(os.Getenv("LOGSEQ_API_TOKEN")) + tokenSource = "env" + } + + if host == "" { + host = "127.0.0.1" + hostSource = "default" + } + if port == "" { + port = "12315" + portSource = "default" + } + if token == "" { + tokenSource = "missing" + } + + baseURL := fmt.Sprintf("http://%s:%s", host, port) + return resolvedClient{ + info: ClientConnectionInfo{ + BaseURL: baseURL, + Host: host, + Port: port, + HostSource: hostSource, + PortSource: portSource, + TokenConfigured: token != "", + TokenSource: tokenSource, + }, + token: token, + } +} + +func CurrentClientConnectionInfo() ClientConnectionInfo { + return resolveClient().info +} + func NewClient() *logseq.Client { - baseURL := fmt.Sprintf("http://%s:%s", Host, Port) + resolved := resolveClient() return logseq.NewClient( - logseq.WithBaseURL(baseURL), - logseq.WithToken(Token), + logseq.WithBaseURL(resolved.info.BaseURL), + logseq.WithToken(resolved.token), ) } diff --git a/cmds/webui.go b/cmds/webui.go new file mode 100644 index 0000000..3ffcb40 --- /dev/null +++ b/cmds/webui.go @@ -0,0 +1,40 @@ +package cmds + +import ( + "context" + "fmt" + + "github.com/pubgo/logseq-cli/internal/webui" + "github.com/pubgo/redant" +) + +func WebUICmd() *redant.Command { + var addr string + var open bool + + return &redant.Command{ + Use: "webui", + Short: "Start a simple web UI for Logseq data operations", + Options: redant.OptionSet{ + { + Flag: "addr", + Description: "HTTP listen address", + Default: "127.0.0.1:18090", + Value: redant.StringOf(&addr), + }, + { + Flag: "open", + Description: "Open browser automatically", + Default: "true", + Value: redant.BoolOf(&open), + }, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + connInfo := CurrentClientConnectionInfo() + fmt.Fprintf(inv.Stdout, "[webui] listening on http://%s\n", addr) + fmt.Fprintf(inv.Stdout, "[webui] logseq api: %s (host=%s, port=%s)\n", connInfo.BaseURL, connInfo.HostSource, connInfo.PortSource) + fmt.Fprintln(inv.Stdout, "[webui] press Ctrl+C to stop") + return webui.Run(ctx, addr, open, NewClient(), connInfo) + }, + } +} diff --git a/internal/webui/assets.go b/internal/webui/assets.go new file mode 100644 index 0000000..7b45f32 --- /dev/null +++ b/internal/webui/assets.go @@ -0,0 +1,6 @@ +package webui + +import _ "embed" + +//go:embed static/index.html +var indexHTML string diff --git a/internal/webui/server.go b/internal/webui/server.go new file mode 100644 index 0000000..88c73bd --- /dev/null +++ b/internal/webui/server.go @@ -0,0 +1,465 @@ +package webui + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/pubgo/logseq-cli/pkg/logseq" +) + +type Server struct { + client *logseq.Client + connectionInfo any +} + +type apiResponse struct { + OK bool `json:"ok"` + Data any `json:"data,omitempty"` + Error any `json:"error,omitempty"` +} + +func New(client *logseq.Client, connectionInfo any) *Server { + return &Server{client: client, connectionInfo: connectionInfo} +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", s.handleIndex) + mux.HandleFunc("/api/health", s.handleHealth) + mux.HandleFunc("/api/connection", s.handleConnection) + mux.HandleFunc("/api/graph", s.handleGraph) + mux.HandleFunc("/api/pages", s.handlePages) + mux.HandleFunc("/api/page", s.handlePage) + mux.HandleFunc("/api/block/append", s.handleBlockAppend) + mux.HandleFunc("/api/block/update", s.handleBlockUpdate) + mux.HandleFunc("/api/block/remove", s.handleBlockRemove) + mux.HandleFunc("/api/query/datalog", s.handleQueryDatalog) + mux.HandleFunc("/api/query/dsl", s.handleQueryDSL) + mux.HandleFunc("/api/search", s.handleSearch) + return mux +} + +func OpenBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + default: + cmd = exec.Command("xdg-open", url) + } + return cmd.Start() +} + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(indexHTML)) +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + writeOK(w, map[string]any{"status": "ok"}) +} + +func (s *Server) handleConnection(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + writeOK(w, s.connectionInfo) +} + +func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + info, err := s.client.GetCurrentGraph(ctx) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, info) +} + +func (s *Server) handlePages(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + pages, err := s.client.GetAllPages(ctx) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, pages) +} + +type createPageRequest struct { + Name string `json:"name"` +} + +func (s *Server) handlePage(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.handlePageGet(w, r) + case http.MethodPost: + s.handlePageCreate(w, r) + case http.MethodDelete: + s.handlePageDelete(w, r) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (s *Server) handlePageGet(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(r.URL.Query().Get("name")) + if name == "" { + writeError(w, http.StatusBadRequest, "missing query: name") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + page, err := s.client.GetPage(ctx, name) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + if page == nil { + writeError(w, http.StatusNotFound, fmt.Sprintf("page '%s' not found", name)) + return + } + + blocks, err := s.client.GetPageBlocksTree(ctx, name) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + + writeOK(w, map[string]any{ + "page": page, + "blocks": blocks, + }) +} + +func (s *Server) handlePageCreate(w http.ResponseWriter, r *http.Request) { + var req createPageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + name := strings.TrimSpace(req.Name) + if name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + page, err := s.client.CreatePage(ctx, name, nil, &logseq.CreatePageOptions{CreateFirstBlock: true}) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, page) +} + +func (s *Server) handlePageDelete(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(r.URL.Query().Get("name")) + if name == "" { + writeError(w, http.StatusBadRequest, "missing query: name") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + if err := s.client.DeletePage(ctx, name); err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + + writeOK(w, map[string]any{ + "message": "deleted page: " + name, + }) +} + +type appendBlockRequest struct { + Page string `json:"page"` + Content string `json:"content"` +} + +func (s *Server) handleBlockAppend(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req appendBlockRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + req.Page = strings.TrimSpace(req.Page) + req.Content = strings.TrimSpace(req.Content) + if req.Page == "" || req.Content == "" { + writeError(w, http.StatusBadRequest, "page and content are required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + block, err := s.client.AppendBlockInPage(ctx, req.Page, req.Content) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, block) +} + +type updateBlockRequest struct { + UUID string `json:"uuid"` + Content string `json:"content"` +} + +func (s *Server) handleBlockUpdate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req updateBlockRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + req.UUID = strings.TrimSpace(req.UUID) + req.Content = strings.TrimSpace(req.Content) + if req.UUID == "" || req.Content == "" { + writeError(w, http.StatusBadRequest, "uuid and content are required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + if err := s.client.UpdateBlock(ctx, req.UUID, req.Content); err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + + writeOK(w, map[string]any{ + "message": "updated block: " + req.UUID, + }) +} + +type removeBlockRequest struct { + UUID string `json:"uuid"` +} + +func (s *Server) handleBlockRemove(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req removeBlockRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + req.UUID = strings.TrimSpace(req.UUID) + if req.UUID == "" { + writeError(w, http.StatusBadRequest, "uuid is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + if err := s.client.RemoveBlock(ctx, req.UUID); err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + + writeOK(w, map[string]any{ + "message": "removed block: " + req.UUID, + }) +} + +func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + q := strings.TrimSpace(r.URL.Query().Get("q")) + if q == "" { + writeError(w, http.StatusBadRequest, "missing query: q") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + result, err := s.client.Search(ctx, q) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + + writeOK(w, result) +} + +type queryRequest struct { + Query string `json:"query"` +} + +func (s *Server) handleQueryDatalog(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + query, ok := parseQueryRequest(w, r) + if !ok { + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + + result, err := s.client.DatascriptQuery(ctx, query) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + + writeRawJSONOrFallback(w, result) +} + +func (s *Server) handleQueryDSL(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + query, ok := parseQueryRequest(w, r) + if !ok { + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + + result, err := s.client.DSLQuery(ctx, query) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + + writeRawJSONOrFallback(w, result) +} + +func parseQueryRequest(w http.ResponseWriter, r *http.Request) (string, bool) { + var req queryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return "", false + } + query := strings.TrimSpace(req.Query) + if query == "" { + writeError(w, http.StatusBadRequest, "query is required") + return "", false + } + return query, true +} + +func writeRawJSONOrFallback(w http.ResponseWriter, raw json.RawMessage) { + if len(raw) == 0 || string(raw) == "null" { + writeOK(w, nil) + return + } + + var data any + if err := json.Unmarshal(raw, &data); err != nil { + writeOK(w, string(raw)) + return + } + writeOK(w, data) +} + +func writeOK(w http.ResponseWriter, data any) { + writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data}) +} + +func writeError(w http.ResponseWriter, status int, err any) { + writeJSON(w, status, apiResponse{OK: false, Error: err}) +} + +func writeJSON(w http.ResponseWriter, status int, resp apiResponse) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(resp) +} + +func Run(ctx context.Context, addr string, open bool, client *logseq.Client, connectionInfo any) error { + srv := New(client, connectionInfo) + httpSrv := &http.Server{ + Addr: addr, + Handler: srv.Handler(), + ReadHeaderTimeout: 5 * time.Second, + } + + if open { + go func() { + time.Sleep(250 * time.Millisecond) + _ = OpenBrowser("http://" + addr) + }() + } + + stop := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = httpSrv.Shutdown(shutdownCtx) + case <-stop: + } + }() + + err := httpSrv.ListenAndServe() + close(stop) + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} diff --git a/internal/webui/static/index.html b/internal/webui/static/index.html new file mode 100644 index 0000000..3f6e162 --- /dev/null +++ b/internal/webui/static/index.html @@ -0,0 +1,603 @@ + + + + + + + logseq-cli 简易 Web 验证台 + + + + +
+
+

logseq-cli 简易 Web 验证台

+

用于快速操作 Logseq 数据:页面、块、搜索、查询。内置最近操作回放,适合手工联调与回归验证。

+
+ + + + +
+
(连接信息加载中...)
+
+ +
+
+

页面列表

+
点击页面名查看详情(含块树)
+
+
+ +
+

页面操作

+ +
+ + + +
+ +
+ + + +
+ +

块操作

+
+ + + + +
+ + + + + +
+ + +
+
+ +

搜索

+
+ + +
+ +

查询(Datalog / DSL)

+
+ +
+ + +
+
+
+
+ +
+
+

结果输出

+
(暂无)
+
+ +
+
+

最近操作(可回放)

+ +
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/main.go b/main.go index ed4a9b8..b130aa5 100644 --- a/main.go +++ b/main.go @@ -55,6 +55,7 @@ func main() { cmds.GraphCmd(), cmds.QueryCmd(), cmds.SearchCmd(), + cmds.WebUICmd(), llmstxtcmd.New(), doccmd.New(), }, From 0745a6bcf695dd8383b4108b85d4871f907131e9 Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 8 May 2026 17:05:22 +0800 Subject: [PATCH 08/37] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E8=BF=87=E6=BB=A4=E5=92=8C=E6=A0=87=E7=AD=BE=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98=E5=8C=96=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=88=97=E8=A1=A8=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/webui/server.go | 336 ++++++++++++++++++- internal/webui/static/index.html | 538 +++++++++++++++++++++++++------ 2 files changed, 770 insertions(+), 104 deletions(-) diff --git a/internal/webui/server.go b/internal/webui/server.go index 88c73bd..66aa894 100644 --- a/internal/webui/server.go +++ b/internal/webui/server.go @@ -8,6 +8,7 @@ import ( "net/http" "os/exec" "runtime" + "sort" "strings" "time" @@ -36,6 +37,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/api/connection", s.handleConnection) mux.HandleFunc("/api/graph", s.handleGraph) mux.HandleFunc("/api/pages", s.handlePages) + mux.HandleFunc("/api/pages/filter", s.handlePagesFilter) + mux.HandleFunc("/api/tags", s.handleTags) mux.HandleFunc("/api/page", s.handlePage) mux.HandleFunc("/api/block/append", s.handleBlockAppend) mux.HandleFunc("/api/block/update", s.handleBlockUpdate) @@ -107,10 +110,7 @@ func (s *Server) handlePages(w http.ResponseWriter, r *http.Request) { return } - ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) - defer cancel() - - pages, err := s.client.GetAllPages(ctx) + pages, err := s.getAllPages(r.Context()) if err != nil { writeError(w, http.StatusBadGateway, err.Error()) return @@ -118,6 +118,334 @@ func (s *Server) handlePages(w http.ResponseWriter, r *http.Request) { writeOK(w, pages) } +func (s *Server) handlePagesFilter(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + q := r.URL.Query() + propertyKey := strings.TrimSpace(q.Get("property")) + propertyValue := strings.TrimSpace(q.Get("value")) + mode := strings.ToLower(strings.TrimSpace(q.Get("mode"))) + if mode != "equals" { + mode = "contains" + } + tag := strings.TrimSpace(q.Get("tag")) + name := strings.TrimSpace(q.Get("name")) + includeJournal := parseBoolOrDefault(strings.TrimSpace(q.Get("includeJournal")), true) + + pages, err := s.getAllPages(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + + filtered := make([]logseq.Page, 0, len(pages)) + for _, p := range pages { + if !includeJournal && p.IsJournal { + continue + } + if !matchesNameFilter(p, name) { + continue + } + if !matchesTagFilter(p, tag) { + continue + } + if !matchesMetadataFilter(p, propertyKey, propertyValue, mode) { + continue + } + filtered = append(filtered, p) + } + + writeOK(w, map[string]any{ + "items": filtered, + "total": len(pages), + "filteredTotal": len(filtered), + }) +} + +func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + tags, err := s.getAllTags(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + + writeOK(w, tags) +} + +func (s *Server) getAllPages(ctx context.Context) ([]logseq.Page, error) { + ctx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + pages, err := s.client.GetAllPages(ctx) + if err != nil { + return nil, err + } + + return pages, nil +} + +func (s *Server) getAllTags(ctx context.Context) ([]string, error) { + tags := make(map[string]struct{}) + + queryCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + + raw, err := s.client.DatascriptQuery(queryCtx, "[:find ?name :where [?b :block/tags ?t] [?t :block/name ?name]]") + if err == nil { + for _, tag := range parseDatalogSingleColumnStrings(raw) { + if tag == "" { + continue + } + tags[strings.ToLower(tag)] = struct{}{} + } + } + + pages, pagesErr := s.getAllPages(ctx) + if pagesErr != nil && len(tags) == 0 { + return nil, pagesErr + } + + for _, p := range pages { + for _, tag := range extractTagsFromPage(p) { + if tag == "" { + continue + } + tags[strings.ToLower(tag)] = struct{}{} + } + } + + list := make([]string, 0, len(tags)) + for tag := range tags { + list = append(list, tag) + } + sort.Strings(list) + return list, nil +} + +func parseDatalogSingleColumnStrings(raw json.RawMessage) []string { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + + var rows [][]any + if err := json.Unmarshal(raw, &rows); err == nil { + out := make([]string, 0, len(rows)) + for _, row := range rows { + if len(row) == 0 { + continue + } + if v := strings.TrimSpace(fmt.Sprintf("%v", row[0])); v != "" { + out = append(out, v) + } + } + return out + } + + var flat []any + if err := json.Unmarshal(raw, &flat); err == nil { + out := make([]string, 0, len(flat)) + for _, item := range flat { + if v := strings.TrimSpace(fmt.Sprintf("%v", item)); v != "" { + out = append(out, v) + } + } + return out + } + + return nil +} + +func extractTagsFromPage(p logseq.Page) []string { + if p.Properties == nil { + return nil + } + + candidates := make([]string, 0) + for k, v := range p.Properties { + key := strings.ToLower(strings.TrimSpace(k)) + if key != "tags" && key != "tag" { + continue + } + candidates = append(candidates, anyToStringSlice(v)...) + } + + tags := make([]string, 0, len(candidates)) + for _, c := range candidates { + t := normalizeTag(c) + if t != "" { + tags = append(tags, t) + } + } + + return dedupeStrings(tags) +} + +func normalizeTag(s string) string { + v := strings.TrimSpace(strings.ToLower(s)) + v = strings.TrimPrefix(v, "#") + v = strings.TrimSpace(v) + return v +} + +func dedupeStrings(in []string) []string { + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, s := range in { + if s == "" { + continue + } + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + return out +} + +func matchesNameFilter(p logseq.Page, name string) bool { + if strings.TrimSpace(name) == "" { + return true + } + needle := strings.ToLower(strings.TrimSpace(name)) + display := strings.ToLower(strings.TrimSpace(p.DisplayName())) + return strings.Contains(display, needle) +} + +func matchesTagFilter(p logseq.Page, tag string) bool { + tag = normalizeTag(tag) + if tag == "" { + return true + } + for _, t := range extractTagsFromPage(p) { + if normalizeTag(t) == tag { + return true + } + } + return false +} + +func matchesMetadataFilter(p logseq.Page, propertyKey, propertyValue, mode string) bool { + propertyKey = strings.TrimSpace(propertyKey) + propertyValue = strings.TrimSpace(propertyValue) + if propertyKey == "" { + return true + } + + v, ok := getPropertyValueCaseInsensitive(p.Properties, propertyKey) + if !ok { + return false + } + + if propertyValue == "" { + return true + } + + candidates := anyToStringSlice(v) + if len(candidates) == 0 { + return false + } + + needle := strings.ToLower(propertyValue) + for _, c := range candidates { + cv := strings.ToLower(strings.TrimSpace(c)) + if mode == "equals" { + if cv == needle { + return true + } + continue + } + if strings.Contains(cv, needle) { + return true + } + } + + return false +} + +func getPropertyValueCaseInsensitive(m map[string]any, key string) (any, bool) { + if len(m) == 0 { + return nil, false + } + if v, ok := m[key]; ok { + return v, true + } + target := strings.ToLower(strings.TrimSpace(key)) + for k, v := range m { + if strings.ToLower(strings.TrimSpace(k)) == target { + return v, true + } + } + return nil, false +} + +func anyToStringSlice(v any) []string { + switch x := v.(type) { + case nil: + return nil + case string: + parts := strings.Split(x, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + if len(out) > 0 { + return out + } + if strings.TrimSpace(x) == "" { + return nil + } + return []string{strings.TrimSpace(x)} + case []string: + out := make([]string, 0, len(x)) + for _, item := range x { + if s := strings.TrimSpace(item); s != "" { + out = append(out, s) + } + } + return out + case []any: + out := make([]string, 0, len(x)) + for _, item := range x { + out = append(out, anyToStringSlice(item)...) + } + return out + case map[string]any: + b, err := json.Marshal(x) + if err != nil { + return []string{fmt.Sprintf("%v", x)} + } + return []string{string(b)} + default: + return []string{fmt.Sprintf("%v", x)} + } +} + +func parseBoolOrDefault(raw string, def bool) bool { + raw = strings.TrimSpace(strings.ToLower(raw)) + if raw == "" { + return def + } + if raw == "1" || raw == "true" || raw == "yes" || raw == "on" { + return true + } + if raw == "0" || raw == "false" || raw == "no" || raw == "off" { + return false + } + return def +} + type createPageRequest struct { Name string `json:"name"` } diff --git a/internal/webui/static/index.html b/internal/webui/static/index.html index 3f6e162..41b8257 100644 --- a/internal/webui/static/index.html +++ b/internal/webui/static/index.html @@ -11,140 +11,418 @@
-

logseq-cli 简易 Web 验证台

-

用于快速操作 Logseq 数据:页面、块、搜索、查询。内置最近操作回放,适合手工联调与回归验证。

+
+
+

logseq-cli 简易 Web 验证台

+

用于快速操作 Logseq 数据:页面、块、搜索、查询。内置最近操作回放,适合手工联调与回归验证。

+
+
+ + +
+
+
+ +
+ 系统与连接 +

用于检查服务状态、Graph 信息和当前生效的连接配置。

-
(连接信息加载中...)
- +
-
-

页面列表

-
点击页面名查看详情(含块树)
-
-
- -
-

页面操作

- -
- - - -
- -
- - - -
- -

块操作

-
- - - - -
- - - - - -
- - +
+ 页面列表 +
支持按标签与元数据过滤。点击页面名可查看详情(含块树)。
+ +
+
页面过滤
+
+ +
+ + +
+ + + + + + + + + + +
+ + +
+ +
+ + +
+
-

搜索

-
- - -
- -

查询(Datalog / DSL)

-
- -
- - +
+
+ +
+
+ 页面管理 +
+
+
创建、加载、删除当前页面
+ +
+
+ + + +
+ +
+ + + +
-
+ + +
+ 块管理 +
+ + + + +
+ + + + + +
+ + +
+
+
+ +
+ 检索与查询 +
+
+ +
+ + +
+
+ +
+ + + +
+
Datalog 示例(语法较难时可直接套用)
+
+ + + +
+
+
+ +
+ + +
+
+
+
-
-

结果输出

+
+ 结果输出
(暂无)
-
+ -
-
-

最近操作(可回放)

+
+ 最近操作(可回放) +
-
+
From 8d30455a594992855fc7270c2bffb6a421f5a767 Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 8 May 2026 18:28:13 +0800 Subject: [PATCH 09/37] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=20README=20?= =?UTF-8?q?=E5=92=8C=E5=88=86=E6=9E=90=E6=96=87=E6=A1=A3=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=20webui=20=E5=8A=9F=E8=83=BD=E6=8F=8F=E8=BF=B0?= =?UTF-8?q?=EF=BC=8C=E6=B7=BB=E5=8A=A0=E8=BF=87=E6=BB=A4=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E5=92=8C=E5=91=BD=E4=BB=A4=E5=8F=82=E8=80=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 18 +++++++++++++++++- docs/ANALYSIS.md | 43 ++++++++++++++++++++++--------------------- docs/COMMANDS.md | 19 +++++++++++++++++++ 3 files changed, 58 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 14f0005..770e1ed 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ - 开发者增强: - `doc`:启动交互式命令文档站 - `web`:打开可视化命令执行页面 - - `webui`:启动简化 Logseq 操作页面(页面/块/搜索/查询 + 最近操作回放 + 连接信息诊断) + - `webui`:启动简化 Logseq 操作页面(页面/块/搜索/查询 + 标签列表 + 元数据过滤 + 最近操作回放 + 连接信息诊断) - `mcp`:以 MCP 方式暴露命令树 - `completion`:生成 shell 自动补全 @@ -170,6 +170,22 @@ - 更新块内容:`logseq block update "- [x] 第一阶段完成"` - 执行 Datalog 查询:`logseq query datalog '[:find ?p :where [?b :block/name ?p]]'` - 全文搜索:`logseq search "Go SDK"` +- 启动 webui:`logseq webui --addr 127.0.0.1:18090 --open true` + +## WebUI 过滤能力(标签 + 元数据) + +`webui` 页面左侧支持组合过滤,便于快速定位页面: + +- 标签过滤(来自 `/api/tags`) +- 页面名模糊匹配(`name`) +- 元数据键值过滤(`property` + `value`) +- 匹配模式:`contains` / `equals` +- 是否包含 Journal 页面(`includeJournal=true|false`) + +对应后端接口: + +- `GET /api/tags` +- `GET /api/pages/filter?tag=&name=&property=&value=&mode=contains&includeJournal=true` ## 输出说明 diff --git a/docs/ANALYSIS.md b/docs/ANALYSIS.md index 4c979c6..9a73527 100644 --- a/docs/ANALYSIS.md +++ b/docs/ANALYSIS.md @@ -59,7 +59,7 @@ Logseq 桌面版内建了 HTTP API Server,通过 JSON-RPC 风格调用暴露 | --------------------- | --------------------------------- | ------------- | ------------------------------------------------------ | | `getBlock` | `[uuid, {includeChildren: bool}]` | `Block` | 获取单个 Block | | `insertBlock` | `[targetUUID, content, options?]` | `Block` | 插入 Block。options: `{sibling: bool, properties: {}}` | -| `insertBatchBlock` | `[srcUUID, blocks[], options?]` | `[]Block` | 批量插入 Block 树。options: `{sibling: bool}` | +| `insertBatchBlock` | `[srcUUID, blocks[], {sibling}]` | `null` | 批量插入 Block 树(当前 SDK 暴露 `sibling bool` 参数) | | `updateBlock` | `[uuid, content]` | `Block\|null` | 更新 Block 内容 | | `removeBlock` | `[uuid]` | `null` | 删除 Block | | `moveBlock` | `[srcUUID, targetUUID, options?]` | — | 移动 Block | @@ -74,19 +74,19 @@ Logseq 桌面版内建了 HTTP API Server,通过 JSON-RPC 风格调用暴露 ### 2.2 App 命名空间 (`logseq.App.*`) -| 方法 | 参数 | 返回 | 说明 | -| ------------------- | ------------------- | -------------- | -------------------------------- | -| `getCurrentGraph` | `[]` | `GraphInfo` | 获取当前图谱信息 | -| `getStateFromStore` | `[key]` | `any` | 获取应用状态 | -| `getUserConfigs` | `[]` | `Config` | 获取用户配置 | -| `search` | `[query, options?]` | `SearchResult` | 全文搜索(⚠️ 部分版本可能不可用) | +| 方法 | 参数 | 返回 | 说明 | +| ------------------- | --------- | -------------- | -------------------------------- | +| `getCurrentGraph` | `[]` | `GraphInfo` | 获取当前图谱信息 | +| `getStateFromStore` | `[key]` | `any` | 获取应用状态 | +| `getUserConfigs` | `[]` | `Config` | 获取用户配置 | +| `search` | `[query]` | `SearchResult` | 全文搜索(⚠️ 部分版本可能不可用) | ### 2.3 DB 命名空间 (`logseq.DB.*`) -| 方法 | 参数 | 返回 | 说明 | -| ----------------- | --------------------- | --------- | -------------------- | -| `datascriptQuery` | `[query, ...inputs?]` | `[][]any` | 执行 Datalog 查询 | -| `q` | `[dslQuery]` | `any` | 执行 Logseq DSL 查询 | +| 方法 | 参数 | 返回 | 说明 | +| ----------------- | --------------------- | ----------------- | --------------------------------- | +| `datascriptQuery` | `[query, ...inputs?]` | `json.RawMessage` | 执行 Datalog 查询(原始 JSON) | +| `q` | `[dslQuery]` | `json.RawMessage` | 执行 Logseq DSL 查询(原始 JSON) | ### 2.4 已确认不可用的方法 @@ -110,7 +110,6 @@ type Page struct { Properties map[string]any `json:"properties,omitempty"` IsJournal bool `json:"journal?"` JournalDay int `json:"journalDay,omitempty"` - Namespace *Namespace `json:"namespace,omitempty"` CreatedAt int64 `json:"createdAt,omitempty"` UpdatedAt int64 `json:"updatedAt,omitempty"` } @@ -192,8 +191,8 @@ type Client struct { // NewClient 创建 Logseq API 客户端 func NewClient(opts ...Option) *Client -// callAPI 是底层 JSON-RPC 调用 -func (c *Client) callAPI(ctx context.Context, method string, args []any) (json.RawMessage, error) +// CallAPI 是底层 JSON-RPC 调用 +func (c *Client) CallAPI(ctx context.Context, method string, args ...any) (json.RawMessage, error) ``` ### 4.3 Editor API 封装 @@ -206,17 +205,18 @@ func (c *Client) CreatePage(ctx context.Context, name string, properties map[str func (c *Client) DeletePage(ctx context.Context, name string) error func (c *Client) RenamePage(ctx context.Context, oldName, newName string) error func (c *Client) GetPageBlocksTree(ctx context.Context, nameOrUUID string) ([]Block, error) -func (c *Client) GetPageLinkedReferences(ctx context.Context, name string) ([]any, error) +func (c *Client) GetPageLinkedReferences(ctx context.Context, name string) (json.RawMessage, error) func (c *Client) GetPagesFromNamespace(ctx context.Context, ns string) ([]Page, error) -func (c *Client) SetPageProperties(ctx context.Context, name string, props map[string]any) error +func (c *Client) GetPagesTreeFromNamespace(ctx context.Context, namespace string) (json.RawMessage, error) +func (c *Client) SetPageProperties(ctx context.Context, name string, properties map[string]any) error // === Block 操作 === func (c *Client) GetBlock(ctx context.Context, uuid string, includeChildren bool) (*Block, error) func (c *Client) InsertBlock(ctx context.Context, targetUUID, content string, opts *InsertBlockOptions) (*Block, error) -func (c *Client) InsertBatchBlock(ctx context.Context, srcUUID string, blocks []BatchBlock, opts *BatchBlockOptions) error +func (c *Client) InsertBatchBlock(ctx context.Context, srcUUID string, blocks []BatchBlock, sibling bool) error func (c *Client) UpdateBlock(ctx context.Context, uuid, content string) error func (c *Client) RemoveBlock(ctx context.Context, uuid string) error -func (c *Client) MoveBlock(ctx context.Context, srcUUID, targetUUID string, opts *MoveBlockOptions) error +func (c *Client) MoveBlock(ctx context.Context, srcUUID, targetUUID string, opts map[string]any) error func (c *Client) PrependBlockInPage(ctx context.Context, page, content string) (*Block, error) func (c *Client) AppendBlockInPage(ctx context.Context, page, content string) (*Block, error) func (c *Client) UpsertBlockProperty(ctx context.Context, uuid, key string, value any) error @@ -234,14 +234,14 @@ func (c *Client) GetCurrentBlock(ctx context.Context) (*Block, error) ```go func (c *Client) GetCurrentGraph(ctx context.Context) (*GraphInfo, error) func (c *Client) GetUserConfigs(ctx context.Context) (map[string]any, error) -func (c *Client) Search(ctx context.Context, query string, opts *SearchOptions) (*SearchResult, error) +func (c *Client) Search(ctx context.Context, query string) (*SearchResult, error) ``` ### 4.5 DB API 封装 ```go -func (c *Client) DatascriptQuery(ctx context.Context, query string, inputs ...any) ([][]any, error) -func (c *Client) DSLQuery(ctx context.Context, query string) (any, error) +func (c *Client) DatascriptQuery(ctx context.Context, query string, inputs ...any) (json.RawMessage, error) +func (c *Client) DSLQuery(ctx context.Context, query string) (json.RawMessage, error) ``` --- @@ -284,6 +284,7 @@ logseq ├── completion # shell 自动补全 ├── doc # 交互式命令文档站 ├── web # 可视化命令执行页面 +├── webui # 简易 Logseq 操作台(页面/块/搜索/查询/过滤) ├── mcp # MCP 集成命令 └── llms-txt # LLM 友好文档导出 ``` diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 922fa43..a6e3f2f 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -216,6 +216,25 @@ --- +## webui:简易 Logseq 操作台 + +### `logseq webui` + +启动专注于 Logseq 数据验证的轻量页面,支持页面/块操作、搜索、Datalog/DSL 查询、历史回放与连接诊断。 + +选项: + +- `--addr`(string,默认 `127.0.0.1:18090`):监听地址 +- `--open`(bool,默认 `true`):启动后自动打开浏览器 + +页面内置过滤能力(由 webui 后端提供): + +- `GET /api/tags`:获取标签列表 +- `GET /api/pages/filter`:按标签、页面名、元数据键值过滤页面 + - 查询参数:`tag`、`name`、`property`、`value`、`mode(contains|equals)`、`includeJournal(true|false)` + +--- + ## mcp:MCP 集成 ### `logseq mcp list` From 2f2e1e69dbf74ac0e037d4b57ebe3b7c75e996ad Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 8 May 2026 19:08:34 +0800 Subject: [PATCH 10/37] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=88=97=E5=87=BA=E6=89=80=E6=9C=89=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++ cmds/tag.go | 28 +++++++ docs/ANALYSIS.md | 3 + docs/COMMANDS.md | 8 ++ internal/webui/server.go | 35 +-------- main.go | 1 + pkg/logseq/tags.go | 162 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 209 insertions(+), 34 deletions(-) create mode 100644 cmds/tag.go create mode 100644 pkg/logseq/tags.go diff --git a/README.md b/README.md index 770e1ed..6a2845c 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ ## 功能特性 - 页面管理:列出、获取、创建、删除、重命名页面 +- 标签管理:列出全部标签 - 块管理:获取、插入、更新、删除、移动、页面头尾追加 - 查询能力:Datalog / Logseq DSL - 图谱信息:查看当前 Graph 元数据 @@ -151,6 +152,10 @@ - `logseq search ` +### 标签(tag) + +- `logseq tag list` + ### 其他能力 - `logseq completion ` @@ -170,6 +175,7 @@ - 更新块内容:`logseq block update "- [x] 第一阶段完成"` - 执行 Datalog 查询:`logseq query datalog '[:find ?p :where [?b :block/name ?p]]'` - 全文搜索:`logseq search "Go SDK"` +- 查看全部标签:`logseq tag list` - 启动 webui:`logseq webui --addr 127.0.0.1:18090 --open true` ## WebUI 过滤能力(标签 + 元数据) diff --git a/cmds/tag.go b/cmds/tag.go new file mode 100644 index 0000000..fa9ac75 --- /dev/null +++ b/cmds/tag.go @@ -0,0 +1,28 @@ +package cmds + +import ( + "context" + + "github.com/pubgo/redant" +) + +func TagCmd() *redant.Command { + return &redant.Command{ + Use: "tag", + Short: "Tag operations", + Children: []*redant.Command{ + tagListCmd(), + }, + } +} + +func tagListCmd() *redant.Command { + return &redant.Command{ + Use: "list", + Short: "List all tags", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) ([]string, error) { + client := NewClient() + return client.GetAllTags(ctx) + }), + } +} diff --git a/docs/ANALYSIS.md b/docs/ANALYSIS.md index 9a73527..5261b15 100644 --- a/docs/ANALYSIS.md +++ b/docs/ANALYSIS.md @@ -158,6 +158,7 @@ logseq-cli/ ├── cmds/ # CLI 子命令定义 │ ├── cmds.go # 共享配置与客户端创建 │ ├── page.go # page list/get/create/delete/rename +│ ├── tag.go # tag list │ ├── block.go # block get/insert/update/remove/move/prepend/append │ └── graph.go # graph/query/search 命令 ├── pkg/ @@ -281,6 +282,8 @@ logseq │ ├── datalog # Datascript/Datalog 查询 │ └── dsl # Logseq DSL 查询 ├── search # 全文搜索 +├── tag # 标签管理 +│ └── list # 列出所有标签 ├── completion # shell 自动补全 ├── doc # 交互式命令文档站 ├── web # 可视化命令执行页面 diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index a6e3f2f..070f4c8 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -178,6 +178,14 @@ --- +## tag:标签管理 + +### `logseq tag list` + +列出当前图谱中的全部标签。 + +--- + ## completion:自动补全 ### `logseq completion ` diff --git a/internal/webui/server.go b/internal/webui/server.go index 66aa894..ca6d6c9 100644 --- a/internal/webui/server.go +++ b/internal/webui/server.go @@ -8,7 +8,6 @@ import ( "net/http" "os/exec" "runtime" - "sort" "strings" "time" @@ -193,41 +192,9 @@ func (s *Server) getAllPages(ctx context.Context) ([]logseq.Page, error) { } func (s *Server) getAllTags(ctx context.Context) ([]string, error) { - tags := make(map[string]struct{}) - queryCtx, cancel := context.WithTimeout(ctx, 20*time.Second) defer cancel() - - raw, err := s.client.DatascriptQuery(queryCtx, "[:find ?name :where [?b :block/tags ?t] [?t :block/name ?name]]") - if err == nil { - for _, tag := range parseDatalogSingleColumnStrings(raw) { - if tag == "" { - continue - } - tags[strings.ToLower(tag)] = struct{}{} - } - } - - pages, pagesErr := s.getAllPages(ctx) - if pagesErr != nil && len(tags) == 0 { - return nil, pagesErr - } - - for _, p := range pages { - for _, tag := range extractTagsFromPage(p) { - if tag == "" { - continue - } - tags[strings.ToLower(tag)] = struct{}{} - } - } - - list := make([]string, 0, len(tags)) - for tag := range tags { - list = append(list, tag) - } - sort.Strings(list) - return list, nil + return s.client.GetAllTags(queryCtx) } func parseDatalogSingleColumnStrings(raw json.RawMessage) []string { diff --git a/main.go b/main.go index b130aa5..70e1035 100644 --- a/main.go +++ b/main.go @@ -55,6 +55,7 @@ func main() { cmds.GraphCmd(), cmds.QueryCmd(), cmds.SearchCmd(), + cmds.TagCmd(), cmds.WebUICmd(), llmstxtcmd.New(), doccmd.New(), diff --git a/pkg/logseq/tags.go b/pkg/logseq/tags.go new file mode 100644 index 0000000..8efab98 --- /dev/null +++ b/pkg/logseq/tags.go @@ -0,0 +1,162 @@ +package logseq + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" +) + +// GetAllTags collects all tags in current graph. +// Strategy: +// 1) Prefer DatascriptQuery for direct tag extraction. +// 2) Fallback to page properties (tag/tags) to improve compatibility. +func (c *Client) GetAllTags(ctx context.Context) ([]string, error) { + tags := make(map[string]struct{}) + + raw, err := c.DatascriptQuery(ctx, "[:find ?name :where [?b :block/tags ?t] [?t :block/name ?name]]") + if err == nil { + for _, tag := range parseSingleColumnStrings(raw) { + if tag == "" { + continue + } + tags[normalizeTag(tag)] = struct{}{} + } + } + + pages, pagesErr := c.GetAllPages(ctx) + if pagesErr != nil && len(tags) == 0 { + return nil, pagesErr + } + + for _, p := range pages { + for _, tag := range extractTagsFromProperties(p.Properties) { + if tag == "" { + continue + } + tags[normalizeTag(tag)] = struct{}{} + } + } + + list := make([]string, 0, len(tags)) + for tag := range tags { + if tag != "" { + list = append(list, tag) + } + } + sort.Strings(list) + return list, nil +} + +func parseSingleColumnStrings(raw json.RawMessage) []string { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + + var rows [][]any + if err := json.Unmarshal(raw, &rows); err == nil { + out := make([]string, 0, len(rows)) + for _, row := range rows { + if len(row) == 0 { + continue + } + if v := strings.TrimSpace(fmt.Sprintf("%v", row[0])); v != "" { + out = append(out, v) + } + } + return out + } + + var flat []any + if err := json.Unmarshal(raw, &flat); err == nil { + out := make([]string, 0, len(flat)) + for _, item := range flat { + if v := strings.TrimSpace(fmt.Sprintf("%v", item)); v != "" { + out = append(out, v) + } + } + return out + } + + return nil +} + +func extractTagsFromProperties(props map[string]any) []string { + if len(props) == 0 { + return nil + } + + candidates := make([]string, 0) + for k, v := range props { + key := strings.ToLower(strings.TrimSpace(k)) + if key != "tag" && key != "tags" { + continue + } + candidates = append(candidates, anyToStrings(v)...) + } + + return dedupeTagStrings(candidates) +} + +func anyToStrings(v any) []string { + switch x := v.(type) { + case nil: + return nil + case string: + parts := strings.Split(x, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + if len(out) > 0 { + return out + } + if strings.TrimSpace(x) == "" { + return nil + } + return []string{strings.TrimSpace(x)} + case []string: + out := make([]string, 0, len(x)) + for _, item := range x { + if s := strings.TrimSpace(item); s != "" { + out = append(out, s) + } + } + return out + case []any: + out := make([]string, 0, len(x)) + for _, item := range x { + out = append(out, anyToStrings(item)...) + } + return out + default: + return []string{fmt.Sprintf("%v", x)} + } +} + +func normalizeTag(s string) string { + v := strings.TrimSpace(strings.ToLower(s)) + v = strings.TrimPrefix(v, "#") + return strings.TrimSpace(v) +} + +func dedupeTagStrings(in []string) []string { + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, s := range in { + tag := normalizeTag(s) + if tag == "" { + continue + } + if _, ok := seen[tag]; ok { + continue + } + seen[tag] = struct{}{} + out = append(out, tag) + } + return out +} From e21cf64a4989ff6d3bc3bad9022fc2cb0142a63a Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 8 May 2026 19:20:21 +0800 Subject: [PATCH 11/37] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E6=8F=90=E5=8F=96=E9=80=BB=E8=BE=91=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E4=BB=8E=E5=BC=95=E7=94=A8=E4=B8=AD=E6=8F=90=E5=8F=96?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E5=B9=B6=E5=A2=9E=E5=8A=A0=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E5=80=99=E9=80=89=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/webui/server.go | 74 +++++++++++++++++++++++++++++++++++++++- pkg/logseq/tags.go | 57 +++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/internal/webui/server.go b/internal/webui/server.go index ca6d6c9..2717d18 100644 --- a/internal/webui/server.go +++ b/internal/webui/server.go @@ -134,6 +134,8 @@ func (s *Server) handlePagesFilter(w http.ResponseWriter, r *http.Request) { name := strings.TrimSpace(q.Get("name")) includeJournal := parseBoolOrDefault(strings.TrimSpace(q.Get("includeJournal")), true) + taggedPages, hasTagIndex := s.getPageNameSetByTag(r.Context(), tag) + pages, err := s.getAllPages(r.Context()) if err != nil { writeError(w, http.StatusBadGateway, err.Error()) @@ -148,7 +150,7 @@ func (s *Server) handlePagesFilter(w http.ResponseWriter, r *http.Request) { if !matchesNameFilter(p, name) { continue } - if !matchesTagFilter(p, tag) { + if !matchesTagFilterWithIndex(p, tag, taggedPages, hasTagIndex) { continue } if !matchesMetadataFilter(p, propertyKey, propertyValue, mode) { @@ -300,6 +302,76 @@ func matchesTagFilter(p logseq.Page, tag string) bool { return false } +func matchesTagFilterWithIndex(p logseq.Page, tag string, taggedPages map[string]struct{}, hasIndex bool) bool { + tag = normalizeTag(tag) + if tag == "" { + return true + } + + if hasIndex { + if pageInNameSet(p, taggedPages) { + return true + } + } + + return matchesTagFilter(p, tag) +} + +func pageInNameSet(p logseq.Page, set map[string]struct{}) bool { + if len(set) == 0 { + return false + } + + candidates := []string{p.Name, p.OriginalName, p.DisplayName()} + for _, c := range candidates { + k := strings.ToLower(strings.TrimSpace(c)) + if k == "" { + continue + } + if _, ok := set[k]; ok { + return true + } + } + + return false +} + +func (s *Server) getPageNameSetByTag(ctx context.Context, tag string) (map[string]struct{}, bool) { + tag = normalizeTag(tag) + if tag == "" { + return nil, false + } + + queryCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + + query := fmt.Sprintf( + "[:find ?pageName :where [?page :block/name ?pageName] [?b :block/page ?page] [?b :block/refs ?r] [?r :block/name \"%s\"]]", + escapeDatalogString(tag), + ) + raw, err := s.client.DatascriptQuery(queryCtx, query) + if err != nil { + return nil, false + } + + set := make(map[string]struct{}) + for _, name := range parseDatalogSingleColumnStrings(raw) { + k := strings.ToLower(strings.TrimSpace(name)) + if k == "" { + continue + } + set[k] = struct{}{} + } + + return set, true +} + +func escapeDatalogString(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + return s +} + func matchesMetadataFilter(p logseq.Page, propertyKey, propertyValue, mode string) bool { propertyKey = strings.TrimSpace(propertyKey) propertyValue = strings.TrimSpace(propertyValue) diff --git a/pkg/logseq/tags.go b/pkg/logseq/tags.go index 8efab98..f49de9f 100644 --- a/pkg/logseq/tags.go +++ b/pkg/logseq/tags.go @@ -39,6 +39,18 @@ func (c *Client) GetAllTags(ctx context.Context) ([]string, error) { } } + // Fallback for Logseq variants where :block/tags is empty but refs are populated. + // This may include both hashtag-style tags and page-link style tags. + rawRefs, refsErr := c.DatascriptQuery(ctx, "[:find ?name :where [?b :block/refs ?r] [?r :block/name ?name]]") + if refsErr == nil { + for _, name := range parseSingleColumnStrings(rawRefs) { + if !isLikelyTagCandidate(name) { + continue + } + tags[normalizeTag(name)] = struct{}{} + } + } + list := make([]string, 0, len(tags)) for tag := range tags { if tag != "" { @@ -160,3 +172,48 @@ func dedupeTagStrings(in []string) []string { } return out } + +func isLikelyTagCandidate(s string) bool { + v := normalizeTag(s) + if v == "" { + return false + } + + if isJournalName(v) { + return false + } + + r := []rune(v) + if len(r) == 1 && strings.ContainsRune(".,;:!?,。;:!?()[]{}<>/\\'\"`~|+-=", r[0]) { + return false + } + + return true +} + +func isJournalName(v string) bool { + if len(v) == 10 { + if (v[4] == '-' && v[7] == '-') || (v[4] == '_' && v[7] == '_') { + for i := 0; i < len(v); i++ { + if i == 4 || i == 7 { + continue + } + if v[i] < '0' || v[i] > '9' { + return false + } + } + return true + } + } + + if len(v) == 8 { + for i := 0; i < len(v); i++ { + if v[i] < '0' || v[i] > '9' { + return false + } + } + return true + } + + return false +} From 9448392bd6fab89fa8c6825d8742bb19e442a0fd Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 8 May 2026 19:24:25 +0800 Subject: [PATCH 12/37] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E8=BF=87=E6=BB=A4=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=90=9C=E7=B4=A2=E8=AF=B7=E6=B1=82=E5=92=8C=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/webui/server.go | 80 ++++++++++++++++++++++++++++++++ internal/webui/static/index.html | 17 +++++-- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/internal/webui/server.go b/internal/webui/server.go index 2717d18..2ad67bd 100644 --- a/internal/webui/server.go +++ b/internal/webui/server.go @@ -688,6 +688,7 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { } q := strings.TrimSpace(r.URL.Query().Get("q")) + tag := strings.TrimSpace(r.URL.Query().Get("tag")) if q == "" { writeError(w, http.StatusBadRequest, "missing query: q") return @@ -702,9 +703,88 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { return } + if strings.TrimSpace(tag) != "" { + pageSet, err := s.getSearchPageSetByTag(ctx, tag) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + result = filterSearchResultByPageSet(result, pageSet) + } + writeOK(w, result) } +func (s *Server) getSearchPageSetByTag(ctx context.Context, tag string) (map[string]struct{}, error) { + tag = normalizeTag(tag) + if tag == "" { + return nil, nil + } + + if set, ok := s.getPageNameSetByTag(ctx, tag); ok { + return set, nil + } + + pages, err := s.getAllPages(ctx) + if err != nil { + return nil, err + } + + set := make(map[string]struct{}) + for _, p := range pages { + if !matchesTagFilter(p, tag) { + continue + } + for _, name := range []string{p.Name, p.OriginalName, p.DisplayName()} { + k := strings.ToLower(strings.TrimSpace(name)) + if k == "" { + continue + } + set[k] = struct{}{} + } + } + + return set, nil +} + +func filterSearchResultByPageSet(in *logseq.SearchResult, pageSet map[string]struct{}) *logseq.SearchResult { + if in == nil { + return nil + } + if len(pageSet) == 0 { + return &logseq.SearchResult{} + } + + out := &logseq.SearchResult{ + Blocks: make([]logseq.SearchBlock, 0, len(in.Blocks)), + Pages: make([]string, 0, len(in.Pages)), + Files: nil, // files usually lack page metadata; hide them under tag filtering to avoid noise. + } + + for _, b := range in.Blocks { + if pageNameInSet(b.Page, pageSet) { + out.Blocks = append(out.Blocks, b) + } + } + + for _, p := range in.Pages { + if pageNameInSet(p, pageSet) { + out.Pages = append(out.Pages, p) + } + } + + return out +} + +func pageNameInSet(name string, set map[string]struct{}) bool { + k := strings.ToLower(strings.TrimSpace(name)) + if k == "" { + return false + } + _, ok := set[k] + return ok +} + type queryRequest struct { Query string `json:"query"` } diff --git a/internal/webui/static/index.html b/internal/webui/static/index.html index 41b8257..94c6499 100644 --- a/internal/webui/static/index.html +++ b/internal/webui/static/index.html @@ -174,10 +174,12 @@

logseq-cli 简易 Web 验证台

-
+
+
@@ -778,20 +780,25 @@

logseq-cli 简易 Web 验证台

Date: Fri, 8 May 2026 21:12:41 +0800 Subject: [PATCH 13/37] =?UTF-8?q?feat:=20=E7=A7=BB=E9=99=A4=E6=9C=AA?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=9A=84=E8=BE=93=E5=87=BA=E5=8F=98=E9=87=8F?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E5=91=BD=E4=BB=A4=E8=A1=8C=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmds/cmds.go | 7 +++---- main.go | 10 +++------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/cmds/cmds.go b/cmds/cmds.go index 625c09b..deb4441 100644 --- a/cmds/cmds.go +++ b/cmds/cmds.go @@ -9,10 +9,9 @@ import ( ) var ( - Token string - Host string - Port string - Output string + Token string + Host string + Port string ) type StatusResult struct { diff --git a/main.go b/main.go index 70e1035..4e32b15 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ func main() { Envs: []string{"LOGSEQ_API_TOKEN"}, Required: true, Value: redant.StringOf(&cmds.Token), + Inherit: true, }, { Flag: "host", @@ -32,6 +33,7 @@ func main() { Envs: []string{"LOGSEQ_HOST"}, Default: "127.0.0.1", Value: redant.StringOf(&cmds.Host), + Inherit: true, }, { Flag: "port", @@ -40,13 +42,7 @@ func main() { Envs: []string{"LOGSEQ_PORT"}, Default: "12315", Value: redant.StringOf(&cmds.Port), - }, - { - Flag: "output", - Shorthand: "o", - Description: "Output format: json, text", - Default: "json", - Value: redant.EnumOf(&cmds.Output, "json", "text"), + Inherit: true, }, }, Children: []*redant.Command{ From b016636b19d8506c072b50587fad638e629d2251 Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 8 May 2026 21:50:46 +0800 Subject: [PATCH 14/37] Implement code structure updates and clean up unused code segments --- cmds/block.go | 145 ++- cmds/cmds.go | 14 + cmds/graph.go | 8 + cmds/page.go | 133 ++- internal/webui/server.go | 396 +++++++ internal/webui/static/index.html | 1692 ++++++++++++++---------------- 6 files changed, 1447 insertions(+), 941 deletions(-) diff --git a/cmds/block.go b/cmds/block.go index 3f76c61..5c1cc34 100644 --- a/cmds/block.go +++ b/cmds/block.go @@ -2,6 +2,7 @@ package cmds import ( "context" + "encoding/json" "fmt" "github.com/pubgo/logseq-cli/pkg/logseq" @@ -20,6 +21,8 @@ func BlockCmd() *redant.Command { blockMoveCmd(), blockPrependCmd(), blockAppendCmd(), + blockPropertyCmd(), + blockCollapseCmd(), }, } } @@ -58,10 +61,10 @@ func blockInsertCmd() *redant.Command { var sibling bool return &redant.Command{ Use: "insert ", - Short: "Insert a block", + Short: "Insert a block (use '-' as content to read from stdin)", Args: redant.ArgSet{ {Name: "target-uuid", Required: true, Value: redant.StringOf(new(string)), Description: "Target block UUID"}, - {Name: "content", Required: true, Value: redant.StringOf(new(string)), Description: "Block content"}, + {Name: "content", Required: true, Value: redant.StringOf(new(string)), Description: "Block content (use '-' for stdin)"}, }, Options: redant.OptionSet{ { @@ -72,8 +75,12 @@ func blockInsertCmd() *redant.Command { }, }, ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*logseq.Block, error) { + content, err := readContent(inv, inv.Args[1]) + if err != nil { + return nil, err + } client := NewClient() - return client.InsertBlock(ctx, inv.Args[0], inv.Args[1], &logseq.InsertBlockOptions{ + return client.InsertBlock(ctx, inv.Args[0], content, &logseq.InsertBlockOptions{ Sibling: sibling, }) }), @@ -83,14 +90,18 @@ func blockInsertCmd() *redant.Command { func blockUpdateCmd() *redant.Command { return &redant.Command{ Use: "update ", - Short: "Update block content", + Short: "Update block content (use '-' as content to read from stdin)", Args: redant.ArgSet{ {Name: "uuid", Required: true, Value: redant.StringOf(new(string)), Description: "Block UUID"}, - {Name: "content", Required: true, Value: redant.StringOf(new(string)), Description: "New block content"}, + {Name: "content", Required: true, Value: redant.StringOf(new(string)), Description: "New block content (use '-' for stdin)"}, }, ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { + content, err := readContent(inv, inv.Args[1]) + if err != nil { + return StatusResult{}, err + } client := NewClient() - if err := client.UpdateBlock(ctx, inv.Args[0], inv.Args[1]); err != nil { + if err := client.UpdateBlock(ctx, inv.Args[0], content); err != nil { return StatusResult{}, err } return StatusResult{OK: true, Message: "updated block: " + inv.Args[0]}, nil @@ -116,6 +127,7 @@ func blockRemoveCmd() *redant.Command { } func blockMoveCmd() *redant.Command { + var before bool return &redant.Command{ Use: "move ", Short: "Move a block", @@ -123,9 +135,20 @@ func blockMoveCmd() *redant.Command { {Name: "src-uuid", Required: true, Value: redant.StringOf(new(string)), Description: "Source block UUID"}, {Name: "target-uuid", Required: true, Value: redant.StringOf(new(string)), Description: "Target block UUID"}, }, + Options: redant.OptionSet{ + { + Flag: "before", + Description: "Move before the target (default: after)", + Value: redant.BoolOf(&before), + }, + }, ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { + var opts map[string]any + if before { + opts = map[string]any{"before": true} + } client := NewClient() - if err := client.MoveBlock(ctx, inv.Args[0], inv.Args[1], nil); err != nil { + if err := client.MoveBlock(ctx, inv.Args[0], inv.Args[1], opts); err != nil { return StatusResult{}, err } return StatusResult{OK: true, Message: "moved block: " + inv.Args[0] + " -> " + inv.Args[1]}, nil @@ -136,14 +159,18 @@ func blockMoveCmd() *redant.Command { func blockPrependCmd() *redant.Command { return &redant.Command{ Use: "prepend ", - Short: "Prepend block to page", + Short: "Prepend block to page (use '-' as content to read from stdin)", Args: redant.ArgSet{ {Name: "page", Required: true, Value: redant.StringOf(new(string)), Description: "Page name"}, - {Name: "content", Required: true, Value: redant.StringOf(new(string)), Description: "Block content"}, + {Name: "content", Required: true, Value: redant.StringOf(new(string)), Description: "Block content (use '-' for stdin)"}, }, ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*logseq.Block, error) { + content, err := readContent(inv, inv.Args[1]) + if err != nil { + return nil, err + } client := NewClient() - return client.PrependBlockInPage(ctx, inv.Args[0], inv.Args[1]) + return client.PrependBlockInPage(ctx, inv.Args[0], content) }), } } @@ -151,14 +178,106 @@ func blockPrependCmd() *redant.Command { func blockAppendCmd() *redant.Command { return &redant.Command{ Use: "append ", - Short: "Append block to page", + Short: "Append block to page (use '-' as content to read from stdin)", Args: redant.ArgSet{ {Name: "page", Required: true, Value: redant.StringOf(new(string)), Description: "Page name"}, - {Name: "content", Required: true, Value: redant.StringOf(new(string)), Description: "Block content"}, + {Name: "content", Required: true, Value: redant.StringOf(new(string)), Description: "Block content (use '-' for stdin)"}, }, ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*logseq.Block, error) { + content, err := readContent(inv, inv.Args[1]) + if err != nil { + return nil, err + } + client := NewClient() + return client.AppendBlockInPage(ctx, inv.Args[0], content) + }), + } +} + +func blockPropertyCmd() *redant.Command { + return &redant.Command{ + Use: "property", + Short: "Block property operations", + Children: []*redant.Command{ + { + Use: "get ", + Short: "Get all properties of a block", + Args: redant.ArgSet{ + {Name: "uuid", Required: true, Value: redant.StringOf(new(string)), Description: "Block UUID"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (map[string]any, error) { + client := NewClient() + return client.GetBlockProperties(ctx, inv.Args[0]) + }), + }, + { + Use: "set ", + Short: "Set a block property", + Args: redant.ArgSet{ + {Name: "uuid", Required: true, Value: redant.StringOf(new(string)), Description: "Block UUID"}, + {Name: "key", Required: true, Value: redant.StringOf(new(string)), Description: "Property key"}, + {Name: "value", Required: true, Value: redant.StringOf(new(string)), Description: "Property value"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { + client := NewClient() + var value any = inv.Args[2] + // Try to parse as JSON for structured values + var parsed any + if err := json.Unmarshal([]byte(inv.Args[2]), &parsed); err == nil { + value = parsed + } + if err := client.UpsertBlockProperty(ctx, inv.Args[0], inv.Args[1], value); err != nil { + return StatusResult{}, err + } + return StatusResult{OK: true, Message: "set " + inv.Args[1] + " on block " + inv.Args[0]}, nil + }), + }, + { + Use: "remove ", + Short: "Remove a block property", + Args: redant.ArgSet{ + {Name: "uuid", Required: true, Value: redant.StringOf(new(string)), Description: "Block UUID"}, + {Name: "key", Required: true, Value: redant.StringOf(new(string)), Description: "Property key"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { + client := NewClient() + if err := client.RemoveBlockProperty(ctx, inv.Args[0], inv.Args[1]); err != nil { + return StatusResult{}, err + } + return StatusResult{OK: true, Message: "removed " + inv.Args[1] + " from block " + inv.Args[0]}, nil + }), + }, + }, + } +} + +func blockCollapseCmd() *redant.Command { + var expand bool + return &redant.Command{ + Use: "collapse ", + Short: "Collapse or expand a block", + Args: redant.ArgSet{ + {Name: "uuid", Required: true, Value: redant.StringOf(new(string)), Description: "Block UUID"}, + }, + Options: redant.OptionSet{ + { + Flag: "expand", + Shorthand: "e", + Description: "Expand instead of collapse", + Value: redant.BoolOf(&expand), + }, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { client := NewClient() - return client.AppendBlockInPage(ctx, inv.Args[0], inv.Args[1]) + collapsed := !expand + if err := client.SetBlockCollapsed(ctx, inv.Args[0], collapsed); err != nil { + return StatusResult{}, err + } + action := "collapsed" + if expand { + action = "expanded" + } + return StatusResult{OK: true, Message: action + " block: " + inv.Args[0]}, nil }), } } diff --git a/cmds/cmds.go b/cmds/cmds.go index deb4441..6743611 100644 --- a/cmds/cmds.go +++ b/cmds/cmds.go @@ -2,10 +2,12 @@ package cmds import ( "fmt" + "io" "os" "strings" "github.com/pubgo/logseq-cli/pkg/logseq" + "github.com/pubgo/redant" ) var ( @@ -95,3 +97,15 @@ func NewClient() *logseq.Client { logseq.WithToken(resolved.token), ) } + +// readContent returns content from the argument, or reads from stdin if arg is "-". +func readContent(inv *redant.Invocation, arg string) (string, error) { + if arg != "-" { + return arg, nil + } + data, err := io.ReadAll(inv.Stdin) + if err != nil { + return "", fmt.Errorf("reading stdin: %w", err) + } + return strings.TrimRight(string(data), "\n"), nil +} diff --git a/cmds/graph.go b/cmds/graph.go index cc07978..cb2100a 100644 --- a/cmds/graph.go +++ b/cmds/graph.go @@ -21,6 +21,14 @@ func GraphCmd() *redant.Command { return client.GetCurrentGraph(ctx) }), }, + { + Use: "config", + Short: "Get user configuration", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (map[string]any, error) { + client := NewClient() + return client.GetUserConfigs(ctx) + }), + }, }, } } diff --git a/cmds/page.go b/cmds/page.go index f56b9fe..9afc5c9 100644 --- a/cmds/page.go +++ b/cmds/page.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/pubgo/logseq-cli/pkg/logseq" "github.com/pubgo/redant" @@ -19,6 +20,9 @@ func PageCmd() *redant.Command { pageCreateCmd(), pageDeleteCmd(), pageRenameCmd(), + pageRefsCmd(), + pageNamespaceCmd(), + pagePropertiesCmd(), }, } } @@ -78,17 +82,39 @@ func pageGetCmd() *redant.Command { } func pageCreateCmd() *redant.Command { + var content string return &redant.Command{ Use: "create ", Short: "Create a page", Args: redant.ArgSet{ {Name: "name", Required: true, Value: redant.StringOf(new(string)), Description: "Page name"}, }, + Options: redant.OptionSet{ + { + Flag: "content", + Shorthand: "c", + Description: "Initial block content (use '-' to read from stdin)", + Value: redant.StringOf(&content), + }, + }, ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*logseq.Page, error) { client := NewClient() - return client.CreatePage(ctx, inv.Args[0], nil, &logseq.CreatePageOptions{ + page, err := client.CreatePage(ctx, inv.Args[0], nil, &logseq.CreatePageOptions{ CreateFirstBlock: true, }) + if err != nil { + return nil, err + } + if content != "" { + body, err := readContent(inv, content) + if err != nil { + return nil, err + } + if _, err := client.AppendBlockInPage(ctx, inv.Args[0], body); err != nil { + return nil, err + } + } + return page, nil }), } } @@ -127,3 +153,108 @@ func pageRenameCmd() *redant.Command { }), } } + +func pageRefsCmd() *redant.Command { + return &redant.Command{ + Use: "refs ", + Short: "Get backlinks (linked references) for a page", + Args: redant.ArgSet{ + {Name: "name", Required: true, Value: redant.StringOf(new(string)), Description: "Page name"}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + client := NewClient() + result, err := client.GetPageLinkedReferences(ctx, inv.Args[0]) + if err != nil { + return err + } + enc := json.NewEncoder(inv.Stdout) + enc.SetIndent("", " ") + return enc.Encode(json.RawMessage(result)) + }, + } +} + +func pageNamespaceCmd() *redant.Command { + var tree bool + return &redant.Command{ + Use: "namespace ", + Short: "List pages in a namespace", + Args: redant.ArgSet{ + {Name: "name", Required: true, Value: redant.StringOf(new(string)), Description: "Namespace prefix"}, + }, + Options: redant.OptionSet{ + { + Flag: "tree", + Description: "Return as tree structure", + Value: redant.BoolOf(&tree), + }, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + client := NewClient() + enc := json.NewEncoder(inv.Stdout) + enc.SetIndent("", " ") + + if tree { + result, err := client.GetPagesTreeFromNamespace(ctx, inv.Args[0]) + if err != nil { + return err + } + return enc.Encode(json.RawMessage(result)) + } + + pages, err := client.GetPagesFromNamespace(ctx, inv.Args[0]) + if err != nil { + return err + } + return enc.Encode(pages) + }, + } +} + +func pagePropertiesCmd() *redant.Command { + return &redant.Command{ + Use: "properties [key=value ...]", + Short: "Get or set page properties", + Long: "Without key=value args, prints current properties. With args, sets them.", + Args: redant.ArgSet{ + {Name: "name", Required: true, Value: redant.StringOf(new(string)), Description: "Page name"}, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + client := NewClient() + name := inv.Args[0] + + // If extra args provided, treat as key=value pairs to set + if len(inv.Args) > 1 { + props := make(map[string]any) + for _, kv := range inv.Args[1:] { + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid property format %q, expected key=value", kv) + } + props[parts[0]] = parts[1] + } + if err := client.SetPageProperties(ctx, name, props); err != nil { + return err + } + enc := json.NewEncoder(inv.Stdout) + enc.SetIndent("", " ") + return enc.Encode(StatusResult{OK: true, Message: "properties updated"}) + } + + // No extra args: get page and print properties + page, err := client.GetPage(ctx, name) + if err != nil { + return err + } + if page == nil { + return fmt.Errorf("page '%s' not found", name) + } + enc := json.NewEncoder(inv.Stdout) + enc.SetIndent("", " ") + if page.Properties == nil { + return enc.Encode(map[string]any{}) + } + return enc.Encode(page.Properties) + }, + } +} diff --git a/internal/webui/server.go b/internal/webui/server.go index 2ad67bd..6f5fe2f 100644 --- a/internal/webui/server.go +++ b/internal/webui/server.go @@ -35,13 +35,24 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/api/health", s.handleHealth) mux.HandleFunc("/api/connection", s.handleConnection) mux.HandleFunc("/api/graph", s.handleGraph) + mux.HandleFunc("/api/graph/config", s.handleGraphConfig) mux.HandleFunc("/api/pages", s.handlePages) mux.HandleFunc("/api/pages/filter", s.handlePagesFilter) mux.HandleFunc("/api/tags", s.handleTags) mux.HandleFunc("/api/page", s.handlePage) + mux.HandleFunc("/api/page/rename", s.handlePageRename) + mux.HandleFunc("/api/page/refs", s.handlePageRefs) + mux.HandleFunc("/api/page/namespace", s.handlePageNamespace) + mux.HandleFunc("/api/page/properties", s.handlePageProperties) + mux.HandleFunc("/api/block", s.handleBlockGet) + mux.HandleFunc("/api/block/insert", s.handleBlockInsert) mux.HandleFunc("/api/block/append", s.handleBlockAppend) + mux.HandleFunc("/api/block/prepend", s.handleBlockPrepend) mux.HandleFunc("/api/block/update", s.handleBlockUpdate) mux.HandleFunc("/api/block/remove", s.handleBlockRemove) + mux.HandleFunc("/api/block/move", s.handleBlockMove) + mux.HandleFunc("/api/block/property", s.handleBlockProperty) + mux.HandleFunc("/api/block/collapse", s.handleBlockCollapse) mux.HandleFunc("/api/query/datalog", s.handleQueryDatalog) mux.HandleFunc("/api/query/dsl", s.handleQueryDSL) mux.HandleFunc("/api/search", s.handleSearch) @@ -103,6 +114,23 @@ func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) { writeOK(w, info) } +func (s *Server) handleGraphConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + config, err := s.client.GetUserConfigs(ctx) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, config) +} + func (s *Server) handlePages(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method not allowed") @@ -849,6 +877,374 @@ func parseQueryRequest(w http.ResponseWriter, r *http.Request) (string, bool) { return query, true } +// === Page: rename === + +type renamePageRequest struct { + OldName string `json:"oldName"` + NewName string `json:"newName"` +} + +func (s *Server) handlePageRename(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req renamePageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + req.OldName = strings.TrimSpace(req.OldName) + req.NewName = strings.TrimSpace(req.NewName) + if req.OldName == "" || req.NewName == "" { + writeError(w, http.StatusBadRequest, "oldName and newName are required") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + if err := s.client.RenamePage(ctx, req.OldName, req.NewName); err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, map[string]any{"message": "renamed: " + req.OldName + " -> " + req.NewName}) +} + +// === Page: refs (backlinks) === + +func (s *Server) handlePageRefs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + name := strings.TrimSpace(r.URL.Query().Get("name")) + if name == "" { + writeError(w, http.StatusBadRequest, "missing query: name") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + result, err := s.client.GetPageLinkedReferences(ctx, name) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeRawJSONOrFallback(w, result) +} + +// === Page: namespace === + +func (s *Server) handlePageNamespace(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + ns := strings.TrimSpace(r.URL.Query().Get("name")) + if ns == "" { + writeError(w, http.StatusBadRequest, "missing query: name") + return + } + tree := strings.TrimSpace(r.URL.Query().Get("tree")) == "true" + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + if tree { + result, err := s.client.GetPagesTreeFromNamespace(ctx, ns) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeRawJSONOrFallback(w, result) + return + } + + pages, err := s.client.GetPagesFromNamespace(ctx, ns) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, pages) +} + +// === Page: properties === + +type setPagePropertiesRequest struct { + Name string `json:"name"` + Properties map[string]any `json:"properties"` +} + +func (s *Server) handlePageProperties(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + name := strings.TrimSpace(r.URL.Query().Get("name")) + if name == "" { + writeError(w, http.StatusBadRequest, "missing query: name") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + page, err := s.client.GetPage(ctx, name) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + if page == nil { + writeError(w, http.StatusNotFound, "page not found: "+name) + return + } + props := page.Properties + if props == nil { + props = map[string]any{} + } + writeOK(w, props) + case http.MethodPost: + var req setPagePropertiesRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" || len(req.Properties) == 0 { + writeError(w, http.StatusBadRequest, "name and properties are required") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + if err := s.client.SetPageProperties(ctx, req.Name, req.Properties); err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, map[string]any{"message": "properties updated"}) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +// === Block: get === + +func (s *Server) handleBlockGet(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + uuid := strings.TrimSpace(r.URL.Query().Get("uuid")) + if uuid == "" { + writeError(w, http.StatusBadRequest, "missing query: uuid") + return + } + children := strings.TrimSpace(r.URL.Query().Get("children")) == "true" + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + block, err := s.client.GetBlock(ctx, uuid, children) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + if block == nil { + writeError(w, http.StatusNotFound, "block not found: "+uuid) + return + } + writeOK(w, block) +} + +// === Block: insert === + +type insertBlockRequest struct { + TargetUUID string `json:"targetUUID"` + Content string `json:"content"` + Sibling bool `json:"sibling"` +} + +func (s *Server) handleBlockInsert(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req insertBlockRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + req.TargetUUID = strings.TrimSpace(req.TargetUUID) + req.Content = strings.TrimSpace(req.Content) + if req.TargetUUID == "" || req.Content == "" { + writeError(w, http.StatusBadRequest, "targetUUID and content are required") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + block, err := s.client.InsertBlock(ctx, req.TargetUUID, req.Content, &logseq.InsertBlockOptions{Sibling: req.Sibling}) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, block) +} + +// === Block: prepend === + +type prependBlockRequest struct { + Page string `json:"page"` + Content string `json:"content"` +} + +func (s *Server) handleBlockPrepend(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req prependBlockRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + req.Page = strings.TrimSpace(req.Page) + req.Content = strings.TrimSpace(req.Content) + if req.Page == "" || req.Content == "" { + writeError(w, http.StatusBadRequest, "page and content are required") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + block, err := s.client.PrependBlockInPage(ctx, req.Page, req.Content) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, block) +} + +// === Block: move === + +type moveBlockRequest struct { + SrcUUID string `json:"srcUUID"` + TargetUUID string `json:"targetUUID"` + Before bool `json:"before"` +} + +func (s *Server) handleBlockMove(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req moveBlockRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + req.SrcUUID = strings.TrimSpace(req.SrcUUID) + req.TargetUUID = strings.TrimSpace(req.TargetUUID) + if req.SrcUUID == "" || req.TargetUUID == "" { + writeError(w, http.StatusBadRequest, "srcUUID and targetUUID are required") + return + } + var opts map[string]any + if req.Before { + opts = map[string]any{"before": true} + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + if err := s.client.MoveBlock(ctx, req.SrcUUID, req.TargetUUID, opts); err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, map[string]any{"message": "moved block: " + req.SrcUUID + " -> " + req.TargetUUID}) +} + +// === Block: property (get/set/remove) === + +type blockPropertyRequest struct { + UUID string `json:"uuid"` + Key string `json:"key"` + Value any `json:"value"` + Action string `json:"action"` // "get", "set", "remove" +} + +func (s *Server) handleBlockProperty(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + uuid := strings.TrimSpace(r.URL.Query().Get("uuid")) + if uuid == "" { + writeError(w, http.StatusBadRequest, "missing query: uuid") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + props, err := s.client.GetBlockProperties(ctx, uuid) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, props) + return + } + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req blockPropertyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + req.UUID = strings.TrimSpace(req.UUID) + req.Key = strings.TrimSpace(req.Key) + if req.UUID == "" || req.Key == "" { + writeError(w, http.StatusBadRequest, "uuid and key are required") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + switch req.Action { + case "remove": + if err := s.client.RemoveBlockProperty(ctx, req.UUID, req.Key); err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, map[string]any{"message": "removed property: " + req.Key}) + default: // "set" or empty + if err := s.client.UpsertBlockProperty(ctx, req.UUID, req.Key, req.Value); err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, map[string]any{"message": "set property: " + req.Key}) + } +} + +// === Block: collapse/expand === + +type blockCollapseRequest struct { + UUID string `json:"uuid"` + Collapsed bool `json:"collapsed"` +} + +func (s *Server) handleBlockCollapse(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req blockCollapseRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + req.UUID = strings.TrimSpace(req.UUID) + if req.UUID == "" { + writeError(w, http.StatusBadRequest, "uuid is required") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + if err := s.client.SetBlockCollapsed(ctx, req.UUID, req.Collapsed); err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + action := "collapsed" + if !req.Collapsed { + action = "expanded" + } + writeOK(w, map[string]any{"message": action + " block: " + req.UUID}) +} + func writeRawJSONOrFallback(w http.ResponseWriter, raw json.RawMessage) { if len(raw) == 0 || string(raw) == "null" { writeOK(w, nil) diff --git a/internal/webui/static/index.html b/internal/webui/static/index.html index 94c6499..6a7a859 100644 --- a/internal/webui/static/index.html +++ b/internal/webui/static/index.html @@ -2,947 +2,785 @@ - - - logseq-cli 简易 Web 验证台 - + + + logseq-cli WebUI + + -
-
-
-
-

logseq-cli 简易 Web 验证台

-

用于快速操作 Logseq 数据:页面、块、搜索、查询。内置最近操作回放,适合手工联调与回归验证。

-
-
- - -
+ +
+ + +
+
+
+

logseq-cli WebUI

+

页面 · 块 · 搜索 · 查询 · 系统

+
+ +
+
+ + + + + +
+
+ + +
+
+ 页面列表 + +
+ +
+ + +
+
+ 高级过滤 +
+ + +
+ + +
+
+
+
+ + +
+
+
+ +
+
+ + +
+ + +
+ 页面操作 +
+
+ + +
+
+ + +
+
+
+ + + +
+ + +
+ 重命名 +
+ + +
+
+ + +
+ + +
+ + +
+ 命名空间 +
+ + + +
-
- -
- 系统与连接 -

用于检查服务状态、Graph 信息和当前生效的连接配置。

-
- - - + + +
+ 设置页面属性(key=value 每行一个) + + +
+
+ + +
+ 追加内容到页面 + +
+ + +
+
+ +
+
+
+ + +
+
+ + +
+ 获取块 +
+ + + +
+
+ + +
+ 插入块 +
+ + +
+ + +
+
+
+ + +
+ 更新块 +
+ + +
+ + +
+
+
+ + +
+ 移动块 +
+ + +
+ +
-
(连接信息加载中...)
- - -
-
- 页面列表 -
支持按标签与元数据过滤。点击页面名可查看详情(含块树)。
- -
-
页面过滤
-
- -
- - -
- - - - - - - - - - -
- - -
- -
- - -
-
+
+
+ + +
+ 块属性 +
+ +
+ + +
+
+ + + +
+
+
+ + +
+ 折叠/展开块 +
+ + + +
+
+ +
+
+ + +
+
+ 全文搜索 +
+ + + +
+
+
+ + +
+
+ + +
+
+ 标签列表 + +
+ +
+
+
+ +
+
+ + +
+
+
+ + + + + +
+ +
+
-
- - -
-
- 页面管理 -
-
-
创建、加载、删除当前页面
- -
-
- - - -
- -
- - - -
-
-
- -
- 块管理 -
- - - - -
- - - - - -
- - -
-
-
- -
- 检索与查询 -
-
- -
- - - -
-
- -
- - - -
-
Datalog 示例(语法较难时可直接套用)
-
- - - -
-
-
- -
- - -
-
-
-
-
+
+
+ + +
+
+ Datalog / DSL 查询 + + +
+
Datalog 示例
+
+ + + +
+
-
-
- 结果输出 -
(暂无)
-
- -
- 最近操作(可回放) -
- +
+ + +
+
+
+ + +
+
+
+ 连接与状态 +
+ + + + +
+ +
+ +
+
+ 操作历史 + +
+
+ + +
+
+
+ + +
+
+ 输出 + +
+

     
- +
+ + \ No newline at end of file From 775a4b049346cb41384ca10b2ad1b91f4ff8d774 Mon Sep 17 00:00:00 2001 From: barry Date: Fri, 8 May 2026 21:57:15 +0800 Subject: [PATCH 15/37] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E6=8F=90=E7=A4=BA=E3=80=81=E6=B6=88=E6=81=AF=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E5=92=8C=E6=90=9C=E7=B4=A2=E7=BB=93=E6=9E=9C=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=EF=BC=8C=E4=BC=98=E5=8C=96=E7=94=A8=E6=88=B7=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/webui/static/index.html | 337 ++++++++++++++++++++++++++++--- 1 file changed, 304 insertions(+), 33 deletions(-) diff --git a/internal/webui/static/index.html b/internal/webui/static/index.html index 6a7a859..bbc6c27 100644 --- a/internal/webui/static/index.html +++ b/internal/webui/static/index.html @@ -13,12 +13,35 @@
+ +
+ + +
+
+ + + + + +
+
+
-
-

logseq-cli WebUI

-

页面 · 块 · 搜索 · 查询 · 系统

+
+
+
+
+

logseq-cli WebUI

+

⌘K 搜索 · ⌘1-6 切换标签页

+