diff --git a/.github/agents/logseq-mcp-regression.agent.md b/.github/agents/logseq-mcp-regression.agent.md new file mode 100644 index 0000000..ad11c66 --- /dev/null +++ b/.github/agents/logseq-mcp-regression.agent.md @@ -0,0 +1,180 @@ +--- +name: "Logseq MCP Regression" +description: "Use when validating Logseq MCP tools, MCP exposure mismatch, capabilities gate, Phase A/B/C regression flow, tool visibility inconsistency, and read-only vs mixed-write verification." +tools: [todo, execute, read, search, logseq/*] +user-invocable: true +argument-hint: "Describe the regression scope, baseline run, and whether mixed-write phase should execute." +--- + +你是 Logseq MCP 回归测试专用 Agent。目标是: +- 稳定执行 MCP 回归(Phase A 只读 → Phase B 混合 → Phase C 汇总) +- 先探测能力和工具暴露,再动态裁剪测试项 +- 明确区分:未暴露工具 / 能力不支持 / 真异常 + +## 执行规则(必须遵守) + +1. 按下面的分组顺序执行,不要跳步。 +2. 优先并行执行互不依赖的读操作。 +3. 参数必须使用工具要求的字段名(例如 `tag_search` 用 `name`,不是 `query`)。 +4. 同一工具若失败,允许重试 1 次;重试仍失败则记录为最终状态。 +5. 对于“预期限制”要标记为 ⚠️,不要误判为 ❌。 +6. 必须先完成只读测试,再进行混合测试;若只读阶段出现 ❌,混合阶段默认跳过并先排障。 + +## 强约束 + +- 不要在未完成 Phase A 前执行 Phase B。 +- 不要把 `MethodNotExist` 直接判定为实现缺陷;优先归因为能力不支持并标记 ⚠️。 +- 对“本轮未暴露工具”统一标记 ⚠️,且不计入 ❌。 +- 若写策略为 `read-only`,写接口降级 dry-run 记 ⚠️,不要记 ❌。 +- 若执行了写入测试,必须输出残留对象(page / block uuid)。 + +## 前置步骤:工具可用性探测 + +在正式执行前,先快速探测本轮可用 MCP 工具集合,并生成: + +- `available_tools` +- `unavailable_tools` + +执行策略: +- 对 `unavailable_tools` 不做失败判定,不计入 ❌; +- 在总表中标记为 ⚠️,备注统一写“本轮工具集未暴露”; +- 统计时单独给出“有效测试覆盖率”(仅按 `available_tools` 计算)。 + +## 前置步骤:Capabilities 驱动门禁 + +读取 `capabilities get` 后,提取并缓存: + +- `api.app_info.supported` +- `api.tag_search.supported` +- `api.property_list.supported` +- `api.property_get.supported` + +动态执行规则: +- 若上述字段为 `false`,对应测试项直接标记 ⚠️("当前 Logseq 能力不可用"),不再执行且不计入 ❌; +- 若字段缺失(旧版 capabilities 输出),按“未知能力”处理:允许执行 1 次,失败后再判定; +- 若 `connection_info.tokenConfigured=false`,直接判定 Phase A 阻塞。 + +建议映射关系: +- `graph_app-info` ← `api.app_info.supported` +- `tag_search` ← `api.tag_search.supported` +- `property_list` ← `api.property_list.supported` +- `property_get/upsert/remove`(若执行)← `api.property_get.supported` + +## 固定流程 + +1. 读取 `capabilities get`: + - 提取 `connection_info.tokenConfigured` + - 提取 `api.app_info/tag_search/property_list/property_get.supported` +2. 探测本轮工具暴露:生成 `available_tools` / `unavailable_tools` +3. 执行 Phase A(只读)并做门禁判定 +4. 若 Phase A 无 ❌,执行 Phase B(混合) +5. 汇总 Phase C: + - 总表(✅/⚠️/❌) + - 写入链路通过率 + - 有效测试覆盖率 + - 与上轮对比(若有基线) + +## 测试模式(完整流程) + +### Phase A:只读基线测试(必跑) + +- 目标:验证连接、查询、搜索、只读页面/块读取链路是否稳定。 +- 范围:连接与图谱基础、页面操作、搜索与查询、标签与属性、Block 只读。 +- 门禁: + - 若 `❌ = 0`,进入 Phase B。 + - 若 `❌ > 0`,先输出失败归因与修复建议,再决定是否继续。 + +补充: +- 仅对 `available_tools` 做门禁判定; +- 若某关键项不可用(例如 `capabilities_get`),直接判定 Phase A 阻塞; +- 若 Capabilities 明确声明某能力不支持,则相关测试项按 ⚠️ 跳过,不作为失败。 + +### Phase B:混合测试(读 + 安全写) + +- 目标:验证关键写路径与读回一致性(优先安全写接口)。 +- 原则: + - 优先 `dry-run`,再最小写入验证; + - 若写策略为 `read-only` 且无法提升权限,标记 ⚠️ 并跳过写入; + - 测试数据使用固定前缀:`mcp-test-`; + - 若写接口可用但被策略降级为 dry-run,记为“受策略限制通过”(⚠️,非 ❌)。 + +建议最小写入用例(按顺序): +1. `mcp_logseq_page_append-safe`:`dry-run=true`,再 `confirm=true`。 +2. `mcp_logseq_block_append`:在 `mcp-test-*` 页面追加 block。 +3. `mcp_logseq_block_update`:更新刚写入的 block 内容。 +4. `mcp_logseq_block_get`:读回核对内容一致性。 +5. `mcp_logseq_block_remove`:删除测试 block(若支持)。 + +注意: +- 若 `page_append-safe` 目标页不存在,可先用 `block_append` 创建测试页后重试; +- 若 `block_remove` 不可用,允许保留残留并记录 UUID。 + +清理策略(best-effort): +- 优先删除测试 block; +- 无法删除时,至少在备注里记录残留对象(page / block uuid)。 + +### Phase C:完整回归结论 + +- 合并 Phase A + Phase B 结果,输出统一总表与趋势对比。 +- 额外输出: + - 写入链路通过率(写入相关 ✅ / 总写入项) + - 是否存在残留测试数据 + - 有效测试覆盖率(可用工具中已执行项 / 可用工具总数) + +## 状态判定标准 + +- ✅ 正常返回,且结果结构符合预期 +- ⚠️ 预期内限制(例如:未打开页面、未选择块、DSL 有能力限制、需要参数) +- ❌ 异常错误(需排查,可能是 API 版本限制、参数问题、CLI 实现问题或环境问题) + +补充判定: +- 工具未暴露/不可调用:⚠️(不计入失败率) +- 策略限制导致仅 dry-run:⚠️(不计入实现失败) +- capabilities 明确不支持:⚠️(不计入实现失败) + +## 输出格式(必须) + +### 1) 结论摘要 +- 一句话总览:`✅ x / ⚠️ y / ❌ z` +- 是否允许进入/完成混合测试 + +### 2) 当前轮次总表 +| 功能 | 状态 | 备注 | + +并给出: +- 写入链路通过率:`write_pass / write_total`(未执行写入则 N/A) +- 有效测试覆盖率:`executed_available / total_available` + +### 3) 门禁快照 +- tokenConfigured +- app_info/tag_search/property_list/property_get 支持状态 +- 因门禁跳过项 + +### 4) 可用性探测 +- available_tools +- unavailable_tools +- 未暴露数量 + +### 5) 混合测试与清理 +- 写入链路通过率 +- 残留对象列表(如有) + +### 6) 差异对比 +- 新增失败 / 已修复 / 状态变化 + +### 7) 本轮执行轨迹(简版) +- Phase A:通过 / 阻塞(原因) +- Phase B:已执行 / 跳过(原因) +- 清理结果:成功 / 部分成功 / 未执行 + +### 8) 失败归因模板(每个 ❌ 一条) +- 工具: +- 错误原文: +- 归因:`API 版本限制` / `参数问题` / `CLI 实现问题` / `环境问题` +- 建议修复: + +## 失败归因优先级 +1. 工具未暴露(会话层) +2. capability unavailable(API/图模式层) +3. 参数/调用错误(测试脚本层) +4. CLI 实现问题(代码层) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..d379dfb --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,67 @@ +# 项目指南 + +## 架构 + +Logseq HTTP API 的 Go CLI 工具。单模块,三层结构: + +| 层级 | 路径 | 职责 | +|------|------|------| +| CLI 命令 | `cmds/` | 按领域一个文件(`page.go`、`block.go`、`tag.go` 等) | +| SDK 客户端 | `pkg/logseq/` | HTTP JSON-RPC 客户端、类型定义、editor/app/tags/db API | +| WebUI | `internal/webui/` | 内嵌 SPA(`go:embed`),handler 按领域拆分文件 | + +CLI 框架使用 **`github.com/pubgo/redant`**(非 cobra)。命令返回 `*redant.Command` 树。大多数使用 `ResponseHandler: redant.Unary(...)` 返回 `*llmEnvelope` 实现结构化 JSON 输出。 + +## 构建与测试 + +```sh +go build ./... # 编译 +go test ./... -count=1 # 单元测试(不使用缓存) +golangci-lint run ./... # lint(配置:.golangci.yml) +go install -v . # 安装为 `logseq` 二进制 +``` + +本地开发需要 `go.work` 引用 `../redant`。CI 会自动去除本地 replace 指令。 + +E2E 测试(`cmd/e2e/`)需要运行中的 Logseq 实例,不属于常规 `go test` 范围。 + +## 约定 + +### 命令结构 + +每个领域导出 `XxxCmd() *redant.Command`(公开),内部以私有函数定义子命令: + +```go +func PageCmd() *redant.Command { + return &redant.Command{ + Use: "page", Short: "...", + Children: []*redant.Command{pageListCmd(), pageGetCmd(), ...}, + } +} +``` + +参数使用 `redant.ArgSet` / `redant.OptionSet`,不使用 cobra 风格的 flags。 + +### LLM 安全 + +写操作通过 `llm_safety.go` 中的 `ensureWriteAllowed()` 进行保护。相关环境变量: +- `LOGSEQ_LLM_WRITE_MODE`:`read-only`(默认)/ `confirm` / `direct` +- `LOGSEQ_LLM_REQUIRE_CONFIRM_FOR_DELETE`:`true`(默认) + +### WebUI 处理器 + +Handler 文件按领域拆分:`handler_page.go`、`handler_block.go`、`handler_search.go` 等。核心基础设施(Server 结构体、路由、写入辅助函数)保留在 `server.go` 中。 + +### 测试 + +- 单元测试:与源码同包的 `_test.go` 文件 +- 纯函数(解析、过滤、envelope 构造)必须有测试覆盖 +- E2E 冒烟测试通过 `LOGSEQ_E2E=1` 环境变量控制 + +## 关键环境变量 + +| 变量 | 必需 | 默认值 | 用途 | +|------|------|--------|------| +| `LOGSEQ_API_TOKEN` | 是 | — | Logseq API Bearer 令牌 | +| `LOGSEQ_HOST` | 否 | `127.0.0.1` | API 主机地址 | +| `LOGSEQ_PORT` | 否 | `12315` | API 端口 | diff --git a/.github/instructions/cmds.instructions.md b/.github/instructions/cmds.instructions.md new file mode 100644 index 0000000..1fb6bb4 --- /dev/null +++ b/.github/instructions/cmds.instructions.md @@ -0,0 +1,119 @@ +--- +applyTo: "cmds/**" +--- + +# cmds 包编码指南 + +## 命令结构 + +每个领域一个文件,导出 `XxxCmd() *redant.Command`,子命令用私有函数: + +```go +func PageCmd() *redant.Command { + return &redant.Command{ + Use: "page", Short: "页面管理", + Children: []*redant.Command{pageListCmd(), pageGetCmd()}, + } +} + +func pageListCmd() *redant.Command { + return &redant.Command{ + Use: "list", + Short: "列出所有页面", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) ([]logseq.Page, error) { + client := NewClient() + return client.GetAllPages(ctx) + }), + } +} +``` + +小型子命令可直接在 `Children` 内联定义,不必抽独立函数。 + +## 参数定义 + +位置参数用 `ArgSet`,标志参数用 `OptionSet`(闭包外声明变量,指针绑定): + +```go +var sibling bool +return &redant.Command{ + Args: redant.ArgSet{ + {Name: "name", Required: true, Value: redant.StringOf(new(string)), Description: "页面名称"}, + }, + Options: redant.OptionSet{ + {Flag: "sibling", Shorthand: "s", Description: "作为兄弟节点插入", Value: redant.BoolOf(&sibling)}, + }, +} +``` + +Value 构造器:`redant.StringOf(&s)`、`redant.BoolOf(&b)`。 + +## ResponseHandler 选择 + +- **主流**:`ResponseHandler: redant.Unary(...)` — 自动 JSON 序列化,返回结构体/slice/map/`*llmEnvelope` 均可 +- **手动输出**:`Handler: func(ctx, inv) error` — 用于需要 `json.RawMessage` 或自定义格式的场景 + +## Client 获取 + +每个 handler 中调用 `client := NewClient()`,不复用全局实例。 + +## LLM Envelope(结构化输出) + +需要结构化输出的命令返回 `*llmEnvelope`: + +```go +start := time.Now() +// 业务逻辑... +return envelopeSuccess(start, data, + withCapabilityUsed("logseq.Editor.getPage"), + withHints("提示信息"), +), nil +``` + +错误时: + +```go +return envelopeFailure(start, err, "BAD_REQUEST", "请提供页面名", + withCapabilityUsed("logseq.Editor.getPage"), +), nil +``` + +错误码:`SAFETY_BLOCKED`、`BAD_REQUEST`、`RESOURCE_NOT_FOUND`、`CAPABILITY_UNAVAILABLE`、`TIMEOUT`、`UPSTREAM_ERROR`。空字符串由 `classifyEnvelopeErrorCode(err)` 自动分类。 + +Option 函数:`withCapabilityUsed()`、`withHints()`、`withPageMeta(cursor, hasMore)`、`withFallbackUsed()`。 + +## LLM 写操作安全 + +写操作必须调用 `ensureWriteAllowed`,失败时返回 `SAFETY_BLOCKED` envelope: + +```go +if err := ensureWriteAllowed("page.append-safe", dryRun, confirm, false); err != nil { + return envelopeFailure(start, err, "SAFETY_BLOCKED", "可先使用 --dry-run"), nil +} +``` + +- `action` 格式:`"domain.operation"` +- `dangerous=true` 用于删除操作 +- 配合 `--dry-run` 和 `--confirm` OptionSet 标志 + +## 测试规范 + +- 文件:同包 `_test.go`(如 `llm_safety_test.go`) +- 风格:表驱动(table-driven)+ `t.Run` 子测试 +- 范围:测试纯函数(解析、分类、构造、过滤),不测试需要 Logseq 实例的 handler +- 环境变量:用 `t.Setenv()` 隔离 + +## 常用 Import + +```go +import ( + "context" + "fmt" + "time" + "strings" + "encoding/json" + + "github.com/pubgo/redant" + "github.com/pubgo/logseq-cli/pkg/logseq" +) +``` diff --git a/.github/prompts/test-mcp-readonly.prompt.md b/.github/prompts/test-mcp-readonly.prompt.md new file mode 100644 index 0000000..13dc323 --- /dev/null +++ b/.github/prompts/test-mcp-readonly.prompt.md @@ -0,0 +1,25 @@ +--- +description: "运行 Logseq MCP 只读回归(Phase A smoke)" +agent: "Logseq MCP Regression" +--- + +# 测试 Logseq MCP(只读) + +使用专用 Agent `Logseq MCP Regression` 执行只读回归(Phase A)。 + +## 默认参数 + +- scope: `readonly` +- mixed-write: `off` +- baseline: 可选(用于对比) + +## 期望输出 + +1. 当前轮次总表(✅/⚠️/❌) +2. Capabilities 门禁快照与跳过项 +3. 可用性探测(available/unavailable) +4. 有效测试覆盖率 +5. 与上次结果对比(新增失败 / 已修复 / 状态变化) + +> 规则细节统一维护在: +> `.github/agents/logseq-mcp-regression.agent.md` diff --git a/.github/prompts/test-mcp.prompt.md b/.github/prompts/test-mcp.prompt.md new file mode 100644 index 0000000..76d66e0 --- /dev/null +++ b/.github/prompts/test-mcp.prompt.md @@ -0,0 +1,25 @@ +--- +description: "运行 Logseq MCP 回归测试(委托专用 Agent 执行)" +agent: "Logseq MCP Regression" +--- + +# 测试 Logseq MCP + +使用专用 Agent `Logseq MCP Regression` 执行完整回归。 + +## 调用参数建议 + +- scope: `full`(默认,执行 Phase A/B/C)或 `readonly`(仅 Phase A) +- baseline: 可选,上一次结果摘要或日期(用于对比) +- mixed-write: `auto` / `on` / `off` + +## 期望输出 + +1. 当前轮次总表(✅/⚠️/❌) +2. Capabilities 门禁快照与跳过项 +3. 可用性探测(available/unavailable) +4. 写入链路通过率与残留对象 +5. 与上次结果对比(新增失败 / 已修复 / 状态变化) + +> 规则细节已统一维护在 Agent 文件: +> `.github/agents/logseq-mcp-regression.agent.md` diff --git a/.github/workflows/ci-quick.yml b/.github/workflows/ci-quick.yml new file mode 100644 index 0000000..d40c563 --- /dev/null +++ b/.github/workflows/ci-quick.yml @@ -0,0 +1,48 @@ +name: ci-quick + +on: + pull_request: + push: + branches: + - main + - master + +permissions: + contents: read + +concurrency: + group: ci-quick-${{ github.ref }} + cancel-in-progress: true + +jobs: + quick-check: + name: Quick Go Checks + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Normalize go.mod replace for CI + shell: bash + run: | + # Keep CI independent from developer-local absolute replace paths. + if grep -q '^replace .* => /' go.mod; then + cp go.mod go.mod.bak + sed '/^replace .* => \/.*/d' go.mod.bak > go.mod + echo "Removed local absolute replace directives from go.mod for CI run" + fi + + - name: Run unit/build checks + run: go test ./... + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest diff --git a/.github/workflows/e2e-smoke.yml b/.github/workflows/e2e-smoke.yml new file mode 100644 index 0000000..e96e107 --- /dev/null +++ b/.github/workflows/e2e-smoke.yml @@ -0,0 +1,111 @@ +name: e2e-smoke + +on: + workflow_dispatch: + inputs: + host: + description: Logseq API host + required: false + default: "127.0.0.1" + type: string + port: + description: Logseq API port + required: false + default: "12315" + type: string + page_prefix: + description: Temporary page prefix for e2e run + required: false + default: "__copilot_e2e_ci" + type: string + timeout: + description: Per command timeout (Go duration, e.g. 60s) + required: false + default: "60s" + type: string + keep_page: + description: Keep temporary page after run (debug) + required: false + default: false + type: boolean + schedule: + - cron: "0 3 * * *" + +permissions: + contents: read + +concurrency: + group: e2e-smoke-${{ github.ref }} + cancel-in-progress: false + +jobs: + smoke: + name: Logseq E2E Smoke + runs-on: self-hosted + timeout-minutes: 30 + + env: + LOGSEQ_E2E: "1" + LOGSEQ_HOST: "127.0.0.1" + LOGSEQ_PORT: "12315" + LOGSEQ_E2E_PAGE_PREFIX: "__copilot_e2e_ci" + LOGSEQ_E2E_TIMEOUT: "60s" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Apply workflow dispatch inputs + if: ${{ github.event_name == 'workflow_dispatch' }} + shell: bash + env: + INPUT_HOST: ${{ github.event.inputs.host }} + INPUT_PORT: ${{ github.event.inputs.port }} + INPUT_PAGE_PREFIX: ${{ github.event.inputs.page_prefix }} + INPUT_TIMEOUT: ${{ github.event.inputs.timeout }} + INPUT_KEEP_PAGE: ${{ github.event.inputs.keep_page }} + run: | + if [[ -n "${INPUT_HOST}" ]]; then + echo "LOGSEQ_HOST=${INPUT_HOST}" >> "$GITHUB_ENV" + fi + if [[ -n "${INPUT_PORT}" ]]; then + echo "LOGSEQ_PORT=${INPUT_PORT}" >> "$GITHUB_ENV" + fi + if [[ -n "${INPUT_PAGE_PREFIX}" ]]; then + echo "LOGSEQ_E2E_PAGE_PREFIX=${INPUT_PAGE_PREFIX}" >> "$GITHUB_ENV" + fi + if [[ -n "${INPUT_TIMEOUT}" ]]; then + echo "LOGSEQ_E2E_TIMEOUT=${INPUT_TIMEOUT}" >> "$GITHUB_ENV" + fi + if [[ "${INPUT_KEEP_PAGE}" == "true" ]]; then + echo "LOGSEQ_E2E_KEEP_PAGE=1" >> "$GITHUB_ENV" + fi + + - name: Normalize go.mod replace for CI + shell: bash + run: | + # Keep CI independent from developer-local absolute replace paths. + if grep -q '^replace .* => /' go.mod; then + cp go.mod go.mod.bak + sed '/^replace .* => \/.*/d' go.mod.bak > go.mod + echo "Removed local absolute replace directives from go.mod for CI run" + fi + + - name: Verify required token env + shell: bash + run: | + if [[ -z "${LOGSEQ_API_TOKEN}" ]]; then + echo "::error::Missing required runner env: LOGSEQ_API_TOKEN" + exit 1 + fi + + - name: Baseline tests + run: go test ./... + + - name: E2E smoke test + run: go test ./cmd/e2e -run TestE2ESmoke -v diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..80183d5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,33 @@ +name: release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..0194439 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,29 @@ +version: "2" + +linters: + enable: + - errcheck + - govet + - staticcheck + - unused + - ineffassign + - gosec + - bodyclose + - durationcheck + - nilerr + settings: + gosec: + excludes: + - G107 + - G204 + - G304 + - G118 + - G702 + errcheck: + exclude-functions: + - (net/http.ResponseWriter).Write + - (*encoding/json.Encoder).Encode + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..21b885f --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,42 @@ +version: 2 + +builds: + - main: . + binary: logseq-cli + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + +archives: + - formats: + - tar.gz + format_overrides: + - goos: windows + formats: + - zip + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: checksums.txt + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - "^ci:" + +release: + github: + owner: pubgo + name: logseq-cli diff --git a/README.md b/README.md index 983212e..3d75b5e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,411 @@ # logseq-cli -logseq cli + +一个用于操作 Logseq HTTP API 的命令行工具(CLI),支持页面、块、查询、搜索,并内置文档站、Web 可视化入口与 MCP 集成能力。 + +> 当前仓库主命令名为 `logseq`。 + +## 功能特性 + +- 页面管理:列出、获取、创建(日记页)、删除、重命名、命名空间、页面属性、反向引用、当前焦点页 +- 标签管理:列出、查询、创建标签,维护 tag-property / tag-extends / block-tag 关系 +- 块管理:获取、当前选中块、清空选中、生成 UUID、前后兄弟块、插入、批量插入、更新、删除、移动、页面头尾追加、折叠/展开、属性读写、当前焦点块 +- 查询能力:Datalog / Logseq DSL +- 属性管理:属性 schema 的查看、创建/更新、删除 +- 图谱信息:查看 Graph 基础信息、用户配置、应用信息、用户信息、收藏/最近/模板、DB 模式与状态存储键值(读/写) +- 全文搜索:调用 `logseq.App.search` +- 开发者增强: + - `doc`:启动交互式命令文档站 + - `web`:打开可视化命令执行页面 + - `webui`:启动简化 Logseq 操作页面(页面/块/搜索/查询 + 标签/属性 schema 管理 + Graph 状态读写 + 最近操作回放 + 连接信息诊断) + - `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),说明配置成功。 + +## LLM / MCP 集成 + +如果你希望让 LLM 直接操作 Logseq,可使用内置 MCP 服务: + +- `logseq mcp serve --transport stdio` + +推荐先阅读:`docs/LLM_MCP.md` + +若你计划把本项目长期托管给 LLM(而不只是手工调用),建议继续阅读:`docs/LLM_P0_API.md` + +其中包含: + +- 面向 LLM 的 P0 工具契约(能力探测、安全写入、分页) +- 统一 JSON 响应信封与错误码建议 +- 与现有命令树的映射和最小落地顺序 + +其中包含: + +- 完整接入步骤 +- Claude Desktop 配置示例 +- 常见报错与排查 + +建议在 LLM 会话开始时先执行: + +- `logseq capabilities get` + +用于获取当前实例能力矩阵(DSL/Search/Tag 字段可用性等),让模型优先走正确路径并减少试错。 + +随后可优先使用: + +- `logseq search-notes --limit 20 --cursor ` +- `logseq page get-context --max-blocks 200 --max-depth 6 --include-properties false` + +该命令提供 LLM 友好结构化结果与分页游标,避免一次性返回过大结果集。 + +写入场景建议使用: + +- `logseq page append-safe --dry-run` +- `logseq block delete-safe --dry-run` + +并在确认后加 `--confirm` 执行实际写入/删除。 + +可直接复用的系统提示词模板见: + +- `docs/examples/llm_system_prompt_logseq_mcp.txt` +- `docs/examples/llm_first_turn_template.txt`(首轮对话可直接粘贴) + +## 端到端集成测试(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` +- 通过 `go test` 触发 smoke(默认跳过,需显式开启):`LOGSEQ_E2E=1 LOGSEQ_API_TOKEN='your-token' go test ./cmd/e2e -run TestE2ESmoke -v` + +常用参数: + +- `--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` +- 默认会自动清理临时页面,避免污染现有笔记 +- `go test ./...` 默认不会执行该 e2e(`LOGSEQ_E2E` 未设置时会 skip),适合本地/CI 分层运行 + +### CI 快速检查(GitHub Actions) + +仓库已提供 workflow:`.github/workflows/ci-quick.yml`,用于 PR / 主分支快速回归。 + +触发方式: + +- `pull_request` +- `push` 到 `main` / `master` + +执行内容: + +- 标准 `go test ./...` +- 自动移除 `go.mod` 中本地绝对路径 `replace`(仅 CI 进程内生效) + +### CI Smoke(GitHub Actions / self-hosted) + +仓库已提供 workflow:`.github/workflows/e2e-smoke.yml`,用于定时或手动执行 e2e smoke。 + +触发方式: + +- 手动:`workflow_dispatch`(可传入 `host` / `port` / `page_prefix` / `timeout` / `keep_page`) +- 定时:每天 UTC 03:00 + +前置条件: + +- 使用 `self-hosted` runner(需要能访问本地 Logseq API) +- runner 上已安装 Go(workflow 会自动 setup) +- Logseq 桌面端已运行并开启 HTTP API +- runner 环境已配置 `LOGSEQ_API_TOKEN` + +可选配置: + +- `workflow_dispatch` 输入参数: + - `host` / `port`:覆盖默认连接地址(默认 `127.0.0.1:12315`) + - `page_prefix`:临时页面名前缀(默认 `__copilot_e2e_ci`) + - `timeout`:单命令超时(默认 `60s`) + - `keep_page`:是否保留测试页面用于排障(默认 `false`) +- runner 环境变量(可选):`LOGSEQ_HOST` / `LOGSEQ_PORT` / `LOGSEQ_E2E_CLI_BIN` + +## 全局参数 + +| 参数 | 环境变量 | 默认值 | 说明 | +| ----------------- | ------------------ | ----------- | -------------------------------------------------------------- | +| `-t, --token` | `LOGSEQ_API_TOKEN` | 无(必填) | Logseq API token | +| `--host` | `LOGSEQ_HOST` | `127.0.0.1` | Logseq API 主机 | +| `-p, --port` | `LOGSEQ_PORT` | `12315` | Logseq API 端口 | +| `--raw-envelope` | - | `false` | 输出结构化 NDJSON envelope | +| `--list-commands` | - | `false` | 列出全部命令(含子命令) | +| `--list-flags` | - | `false` | 列出全部参数 | +| `--list-format` | - | `text` | `--list-commands` / `--list-flags` 输出格式(`text` / `json`) | + +## 命令总览 + +### 页面(page) + +- `logseq page list` +- `logseq page current` +- `logseq page current-tree` +- `logseq page get [-b|--blocks]` +- `logseq page create [--content ]` +- `logseq page journal [date]` +- `logseq page delete ` +- `logseq page rename ` +- `logseq page refs ` +- `logseq page namespace [--tree]` +- `logseq page properties [key=value ...]` + +### 块(block) + +- `logseq block get [-c|--children]` +- `logseq block current` +- `logseq block selected` +- `logseq block clear-selected` +- `logseq block new-uuid` +- `logseq block prev-sibling ` +- `logseq block next-sibling ` +- `logseq block insert [-s|--sibling]` +- `logseq block insert-batch [-s|--sibling]` +- `logseq block update ` +- `logseq block remove ` +- `logseq block move [--before]` +- `logseq block prepend ` +- `logseq block append ` +- `logseq block property get ` +- `logseq block property set ` +- `logseq block property remove ` +- `logseq block collapse [--expand]` + +### 图谱(graph) + +- `logseq graph info` +- `logseq graph config` +- `logseq graph app-info` +- `logseq graph user-info` +- `logseq graph db-graph` +- `logseq graph graph-config` +- `logseq graph favorites` +- `logseq graph recent` +- `logseq graph templates` +- `logseq graph state ` +- `logseq graph state-set ` + +### 查询(query) + +- `logseq query datalog ` +- `logseq query dsl ` + +### 搜索(search) + +- `logseq search ` + +### 标签(tag) + +- `logseq tag list` +- `logseq tag get ` +- `logseq tag search ` +- `logseq tag create [--uuid ]` +- `logseq tag objects ` +- `logseq tag property add ` +- `logseq tag property remove ` +- `logseq tag extends add ` +- `logseq tag extends remove ` +- `logseq tag block add ` +- `logseq tag block remove ` + +### 属性(property) + +- `logseq property list` +- `logseq property get ` +- `logseq property upsert [schema-json]` +- `logseq property remove ` + +### 其他能力 + +- `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 ]` + +## 常用示例 + +- 查看所有页面:`logseq page list` +- 查看当前焦点页面:`logseq page current` +- 查看当前焦点页块树:`logseq page current-tree` +- 创建今天的 Journal 页面:`logseq page journal` +- 获取页面及其块树:`logseq page get "Daily Notes" --blocks` +- 新建页面并从 stdin 读取首块内容:`echo "- [ ] todo" | logseq page create "项目规划" --content -` +- 获取当前选中块:`logseq block selected` +- 在页面末尾追加块:`logseq block append "项目规划" "- [ ] 第一阶段完成"` +- 查看块的前后兄弟:`logseq block prev-sibling ` / `logseq block next-sibling ` +- 批量插入块(JSON):`echo '[{"content":"- item1"},{"content":"- item2"}]' | logseq block insert-batch -` +- 更新块内容(stdin):`echo "- [x] 第一阶段完成" | logseq block update -` +- 设置块属性:`logseq block property set priority A` +- 读取状态存储:`logseq graph state 'ui/sidebar-open?'` +- 写入状态存储:`logseq graph state-set 'ui/sidebar-open?' true` +- 查看当前图谱收藏:`logseq graph favorites` +- 执行 Datalog 查询:`logseq query datalog '[:find ?p :where [?b :block/name ?p]]'` +- 全文搜索:`logseq search "Go SDK"` +- 查看全部标签:`logseq tag list` +- 新建标签:`logseq tag create project --uuid ` +- 关联标签与属性:`logseq tag property add priority` +- 写入属性 schema:`logseq property upsert priority '{"type":"default","cardinality":"one","public":true}'` +- 启动 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` +- `GET /api/search?q=&tag=`(搜索结果按标签可选过滤) + +## WebUI 增强能力(新增) + +当前 `webui` 还支持以下增强操作: + +- 页面:创建 Journal 页面(可指定日期) +- 块:当前焦点块、当前选中块、清空选中、生成 UUID、查询前后兄弟块 +- 图谱:`app-info/user-info/current-config/favorites/recent/templates`,以及 `state` 读写 +- 标签:按名称/ID 查询、搜索、创建标签,维护 `tag-property` / `tag-extends` / `block-tag` 关系 +- 属性 schema:`list/get/upsert/remove` +- 快捷预设:Graph `state key` 常用键一键填充、Property schema 模板一键填充 +- 一键验证:串行执行轻量 smoke(health/graph/config/tags/property-list)快速检查联通性 + +对应后端接口(节选): + +- `POST /api/page/journal` +- `GET /api/block/current` +- `GET /api/block/selected` +- `POST /api/block/selected/clear` +- `GET /api/block/new-uuid` +- `GET /api/block/prev-sibling?uuid=` +- `GET /api/block/next-sibling?uuid=` +- `GET /api/graph/app-info` +- `GET /api/graph/user-info` +- `GET /api/graph/current-config` +- `GET /api/graph/favorites` +- `GET /api/graph/recent` +- `GET /api/graph/templates` +- `GET /api/graph/state?key=` +- `POST /api/graph/state` +- `GET /api/tag?nameOrID=` +- `GET /api/tag/search?name=` +- `POST /api/tag/create` +- `GET /api/tag/objects?name=` +- `POST /api/tag/property` +- `POST /api/tag/extends` +- `POST /api/tag/block` +- `GET /api/property/list` +- `GET /api/property?key=` +- `POST /api/property/upsert` +- `POST /api/property/remove` + +## 输出说明 + +- 默认输出为 JSON(便于自动化处理) +- 对接流水线建议使用默认 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 / tag / webui) +- `pkg/logseq/`:Logseq API 客户端与数据类型 +- `internal/webui/`:WebUI 服务端与静态前端 +- `docs/`:分析文档与补充资料 + +## 参考文档 + +- 命令速查与详细参数说明:`docs/COMMANDS.md` +- API 与实现分析:`docs/ANALYSIS.md` +- LLM/MCP 接入指南:`docs/LLM_MCP.md` + +## License + +本项目使用 `LICENSE` 中声明的开源协议。 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) + } + } +} diff --git a/cmd/e2e/main_test.go b/cmd/e2e/main_test.go new file mode 100644 index 0000000..4d7cbc8 --- /dev/null +++ b/cmd/e2e/main_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestE2ESmoke(t *testing.T) { + if os.Getenv("LOGSEQ_E2E") != "1" { + t.Skip("skip e2e smoke test: set LOGSEQ_E2E=1 to enable") + } + + token := strings.TrimSpace(os.Getenv("LOGSEQ_API_TOKEN")) + if token == "" { + t.Skip("skip e2e smoke test: LOGSEQ_API_TOKEN is required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + + args := []string{"run", ".", "--token", token} + if cliBin := strings.TrimSpace(os.Getenv("LOGSEQ_E2E_CLI_BIN")); cliBin != "" { + args = append(args, "--cli-bin", cliBin) + } + if pagePrefix := strings.TrimSpace(os.Getenv("LOGSEQ_E2E_PAGE_PREFIX")); pagePrefix != "" { + args = append(args, "--page-prefix", pagePrefix) + } + if timeout := strings.TrimSpace(os.Getenv("LOGSEQ_E2E_TIMEOUT")); timeout != "" { + args = append(args, "--timeout", timeout) + } + if envBool("LOGSEQ_E2E_KEEP_PAGE") { + args = append(args, "--keep-page") + } + if host := strings.TrimSpace(os.Getenv("LOGSEQ_HOST")); host != "" { + args = append(args, "--host", host) + } + if port := strings.TrimSpace(os.Getenv("LOGSEQ_PORT")); port != "" { + args = append(args, "--port", port) + } + + cmd := exec.CommandContext(ctx, "go", args...) + cmd.Dir = "." + cmd.Env = os.Environ() + + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("e2e smoke failed: %v\noutput:\n%s", err, string(out)) + } +} + +func envBool(key string) bool { + v := strings.ToLower(strings.TrimSpace(os.Getenv(key))) + return v == "1" || v == "true" || v == "yes" || v == "on" +} diff --git a/cmds/block.go b/cmds/block.go new file mode 100644 index 0000000..1bf93b6 --- /dev/null +++ b/cmds/block.go @@ -0,0 +1,519 @@ +package cmds + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "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{ + blockCurrentCmd(), + blockSelectedCmd(), + blockClearSelectedCmd(), + blockNewUUIDCmd(), + blockGetCmd(), + blockPrevSiblingCmd(), + blockNextSiblingCmd(), + blockInsertCmd(), + blockInsertBatchCmd(), + blockUpdateCmd(), + blockDeleteSafeCmd(), + blockRemoveCmd(), + blockMoveCmd(), + blockPrependCmd(), + blockAppendCmd(), + blockPropertyCmd(), + blockCollapseCmd(), + }, + } +} + +func blockDeleteSafeCmd() *redant.Command { + var ( + dryRun bool + confirm bool + ) + + return &redant.Command{ + Use: "delete-safe ", + Short: "Safely delete a block (dry-run + confirm)", + Args: redant.ArgSet{ + {Name: "uuid", Required: true, Value: redant.StringOf(new(string)), Description: "Block UUID"}, + }, + Options: redant.OptionSet{ + {Flag: "dry-run", Description: "Preview impact without deleting", Value: redant.BoolOf(&dryRun)}, + {Flag: "confirm", Description: "Required for dangerous delete in most policies", Value: redant.BoolOf(&confirm)}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*llmEnvelope, error) { + start := time.Now() + uuid := strings.TrimSpace(inv.Args[0]) + if uuid == "" { + return envelopeFailure(start, fmt.Errorf("uuid is required"), "BAD_REQUEST", "请提供块 UUID"), nil + } + + client := NewClient() + block, err := client.GetBlock(ctx, uuid, true) + if err != nil { + return envelopeFailure(start, err, "", "请先确认块可读取", withCapabilityUsed("logseq.Editor.getBlock")), nil + } + if block == nil { + return envelopeFailure(start, fmt.Errorf("block '%s' not found", uuid), "RESOURCE_NOT_FOUND", "请确认 UUID 正确"), nil + } + + subtreeCount := countBlockSubtree(block) + preview := truncate(block.Content, 240) + + if err := ensureWriteAllowed("block.delete-safe", dryRun, confirm, true); err != nil { + return envelopeFailure(start, err, "SAFETY_BLOCKED", "可先使用 --dry-run 或显式 --confirm"), nil + } + + if dryRun { + payload := map[string]any{ + "action": "dry-run", + "uuid": uuid, + "subtree_count": subtreeCount, + "content_preview": preview, + "write_mode": currentLLMWriteMode(), + "message": "delete preview only, nothing deleted", + } + return envelopeSuccess(start, payload, withCapabilityUsed("logseq.Editor.getBlock"), withHints("dry-run 模式未执行删除")), nil + } + + if err := client.RemoveBlock(ctx, uuid); err != nil { + return envelopeFailure(start, err, "UPSTREAM_ERROR", "删除失败,请检查块状态后重试", withCapabilityUsed("logseq.Editor.removeBlock")), nil + } + + payload := map[string]any{ + "action": "deleted", + "uuid": uuid, + "subtree_count": subtreeCount, + "content_preview": preview, + "write_mode": currentLLMWriteMode(), + "message": "block deleted", + } + return envelopeSuccess(start, payload, withCapabilityUsed("logseq.Editor.getBlock", "logseq.Editor.removeBlock")), nil + }), + } +} + +func countBlockSubtree(b *logseq.Block) int { + if b == nil { + return 0 + } + count := 1 + for i := range b.Children { + child := b.Children[i] + count += countBlockSubtree(&child) + } + return count +} + +func blockSelectedCmd() *redant.Command { + return &redant.Command{ + Use: "selected", + Short: "Get currently selected blocks", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) ([]logseq.Block, error) { + client := NewClient() + return client.GetSelectedBlocks(ctx) + }), + } +} + +func blockClearSelectedCmd() *redant.Command { + return &redant.Command{ + Use: "clear-selected", + Short: "Clear currently selected blocks", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { + client := NewClient() + if err := client.ClearSelectedBlocks(ctx); err != nil { + return StatusResult{}, err + } + return StatusResult{OK: true, Message: "selection cleared"}, nil + }), + } +} + +func blockNewUUIDCmd() *redant.Command { + return &redant.Command{ + Use: "new-uuid", + Short: "Create a new block UUID", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (map[string]string, error) { + client := NewClient() + uuid, err := client.NewBlockUUID(ctx) + if err != nil { + return nil, err + } + return map[string]string{"uuid": uuid}, nil + }), + } +} + +func blockCurrentCmd() *redant.Command { + return &redant.Command{ + Use: "current", + Short: "Get currently focused block", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*logseq.Block, error) { + client := NewClient() + block, err := client.GetCurrentBlock(ctx) + if err != nil { + return nil, err + } + if block == nil { + return nil, fmt.Errorf("no current block") + } + return block, nil + }), + } +} + +func blockGetCmd() *redant.Command { + var includeChildren bool + 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", + Shorthand: "c", + Description: "Include child blocks", + Value: redant.BoolOf(&includeChildren), + }, + }, + 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 nil, err + } + if block == nil { + return nil, fmt.Errorf("block '%s' not found", inv.Args[0]) + } + return block, nil + }), + } +} + +func blockPrevSiblingCmd() *redant.Command { + return &redant.Command{ + Use: "prev-sibling ", + Short: "Get previous sibling 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) (*logseq.Block, error) { + client := NewClient() + block, err := client.GetPreviousSiblingBlock(ctx, inv.Args[0]) + if err != nil { + return nil, err + } + if block == nil { + return nil, fmt.Errorf("previous sibling not found for block '%s'", inv.Args[0]) + } + return block, nil + }), + } +} + +func blockNextSiblingCmd() *redant.Command { + return &redant.Command{ + Use: "next-sibling ", + Short: "Get next sibling 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) (*logseq.Block, error) { + client := NewClient() + block, err := client.GetNextSiblingBlock(ctx, inv.Args[0]) + if err != nil { + return nil, err + } + if block == nil { + return nil, fmt.Errorf("next sibling not found for block '%s'", inv.Args[0]) + } + return block, nil + }), + } +} + +func blockInsertCmd() *redant.Command { + var sibling bool + return &redant.Command{ + Use: "insert ", + 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 (use '-' for stdin)"}, + }, + Options: redant.OptionSet{ + { + Flag: "sibling", + Shorthand: "s", + Description: "Insert as sibling (default: child)", + Value: redant.BoolOf(&sibling), + }, + }, + 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], content, &logseq.InsertBlockOptions{ + Sibling: sibling, + }) + }), + } +} + +func blockInsertBatchCmd() *redant.Command { + var sibling bool + return &redant.Command{ + Use: "insert-batch ", + Short: "Insert multiple blocks (JSON array, use '-' to read from stdin)", + Args: redant.ArgSet{ + {Name: "target-uuid", Required: true, Value: redant.StringOf(new(string)), Description: "Target block UUID"}, + {Name: "blocks-json", Required: true, Value: redant.StringOf(new(string)), Description: "JSON array of batch blocks (use '-' for stdin)"}, + }, + Options: redant.OptionSet{ + { + Flag: "sibling", + Shorthand: "s", + Description: "Insert as sibling (default: child)", + Value: redant.BoolOf(&sibling), + }, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { + raw, err := readContent(inv, inv.Args[1]) + if err != nil { + return StatusResult{}, err + } + + var blocks []logseq.BatchBlock + if err := json.Unmarshal([]byte(raw), &blocks); err != nil { + return StatusResult{}, fmt.Errorf("invalid blocks json, expected []BatchBlock: %w", err) + } + if len(blocks) == 0 { + return StatusResult{}, fmt.Errorf("blocks array cannot be empty") + } + + client := NewClient() + if err := client.InsertBatchBlock(ctx, inv.Args[0], blocks, sibling); err != nil { + return StatusResult{}, err + } + + return StatusResult{OK: true, Message: fmt.Sprintf("inserted %d blocks", len(blocks))}, nil + }), + } +} + +func blockUpdateCmd() *redant.Command { + return &redant.Command{ + Use: "update ", + 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 (use '-' for stdin)"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (any, error) { + content, err := readContent(inv, inv.Args[1]) + if err != nil { + return nil, err + } + client := NewClient() + if err := client.UpdateBlock(ctx, inv.Args[0], content); err != nil { + return nil, err + } + // Fetch updated block for consistent response + block, err := client.GetBlock(ctx, inv.Args[0], false) + if err == nil && block != nil { + return block, nil + } + return StatusResult{OK: true, Message: "updated block: " + inv.Args[0]}, nil + }), + } +} + +func blockRemoveCmd() *redant.Command { + return &redant.Command{ + Use: "remove ", + Short: "Remove 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) (StatusResult, error) { + client := NewClient() + if err := client.RemoveBlock(ctx, inv.Args[0]); err != nil { + return StatusResult{}, err + } + return StatusResult{OK: true, Message: "removed block: " + inv.Args[0]}, nil + }), + } +} + +func blockMoveCmd() *redant.Command { + var before bool + return &redant.Command{ + Use: "move ", + Short: "Move a block", + 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"}, + }, + 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], opts); err != nil { + return StatusResult{}, err + } + return StatusResult{OK: true, Message: "moved block: " + inv.Args[0] + " -> " + inv.Args[1]}, nil + }), + } +} + +func blockPrependCmd() *redant.Command { + return &redant.Command{ + Use: "prepend ", + 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 (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], content) + }), + } +} + +func blockAppendCmd() *redant.Command { + return &redant.Command{ + Use: "append ", + 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 (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() + 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/capabilities.go b/cmds/capabilities.go new file mode 100644 index 0000000..a8dadbc --- /dev/null +++ b/cmds/capabilities.go @@ -0,0 +1,305 @@ +package cmds + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/pubgo/logseq-cli/pkg/logseq" + "github.com/pubgo/redant" +) + +type capabilityProbe struct { + Supported bool `json:"supported"` + Reason string `json:"reason,omitempty"` +} + +type writePolicyProbe struct { + DefaultMode string `json:"default_mode"` + DangerousRequiresConfirm bool `json:"dangerous_requires_confirm"` + MaxResults int `json:"max_results"` +} + +type capabilitiesData struct { + API struct { + DatascriptQuery capabilityProbe `json:"datascript_query"` + DSLQuery capabilityProbe `json:"dsl_query"` + Search capabilityProbe `json:"search"` + TagsList capabilityProbe `json:"tags_list"` + StateStore capabilityProbe `json:"state_store"` + AppInfo capabilityProbe `json:"app_info"` + TagSearch capabilityProbe `json:"tag_search"` + PropertyList capabilityProbe `json:"property_list"` + PropertyGet capabilityProbe `json:"property_get"` + } `json:"api"` + Graph struct { + DBGraph capabilityProbe `json:"db_graph"` + TagsFieldAvailable capabilityProbe `json:"tags_field_available"` + RefsFieldAvailable capabilityProbe `json:"refs_field_available"` + CurrentGraphReachable capabilityProbe `json:"current_graph_reachable"` + } `json:"graph"` + WritePolicy writePolicyProbe `json:"write_policy"` + Hints []string `json:"hints,omitempty"` +} + +type capabilitiesResult struct { + GeneratedAt string `json:"generated_at"` + ConnectionInfo ClientConnectionInfo `json:"connection_info"` + Data capabilitiesData `json:"data"` +} + +func CapabilitiesCmd() *redant.Command { + return &redant.Command{ + Use: "capabilities", + Short: "LLM capability probing", + Children: []*redant.Command{ + { + Use: "get", + Short: "Probe runtime capabilities for LLM/MCP orchestration", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*llmEnvelope, error) { + start := time.Now() + res := probeCapabilities(ctx) + return envelopeSuccess( + start, + res, + withCapabilityUsed( + "logseq.DB.datascriptQuery", + "logseq.DB.q", + "logseq.App.search", + "logseq.Editor.getAllPages", + "logseq.App.getCurrentGraph", + ), + withHints(res.Data.Hints...), + ), nil + }), + }, + }, + } +} + +func probeCapabilities(ctx context.Context) *capabilitiesResult { + out := &capabilitiesResult{ + GeneratedAt: time.Now().Format(time.RFC3339), + ConnectionInfo: CurrentClientConnectionInfo(), + } + + client := NewClient() + + // API: Datascript query + if _, err := datalogCount(ctx, client, "[:find (count ?p) :where [?p :block/name ?name]]"); err != nil { + out.Data.API.DatascriptQuery = capabilityProbe{Supported: false, Reason: shortErr(err)} + } else { + out.Data.API.DatascriptQuery = capabilityProbe{Supported: true} + } + + // API: DSL query (native) + _, dslErr := client.DSLQuery(ctx, "{:query [:find ?name :where [?p :block/name ?name]]}") + if dslErr != nil { + out.Data.API.DSLQuery = capabilityProbe{Supported: false, Reason: shortErr(dslErr)} + } else { + out.Data.API.DSLQuery = capabilityProbe{Supported: true} + } + + // API: Search + if _, err := client.Search(ctx, "logseq"); err != nil { + out.Data.API.Search = capabilityProbe{Supported: false, Reason: shortErr(err)} + } else { + out.Data.API.Search = capabilityProbe{Supported: true} + } + + // API: Tags list + if _, err := client.GetAllTags(ctx); err != nil { + out.Data.API.TagsList = capabilityProbe{Supported: false, Reason: shortErr(err)} + } else { + out.Data.API.TagsList = capabilityProbe{Supported: true} + } + + // API: State store (probe raw method availability to avoid decode-shape false negatives) + if _, err := client.CallAPI(ctx, "logseq.App.getStateFromStore", "ui/theme"); err != nil { + out.Data.API.StateStore = capabilityProbe{Supported: false, Reason: shortErr(err)} + } else { + out.Data.API.StateStore = capabilityProbe{Supported: true} + } + + // API: app info (probe raw method, not GetInfo fallback) + out.Data.API.AppInfo = probeMethodAvailability(ctx, client, "logseq.App.getInfo") + + // API: optional capabilities frequently affected by graph mode/version + out.Data.API.TagSearch = probeMethodAvailability(ctx, client, "logseq.Editor.getTagsByName", "golang") + out.Data.API.PropertyList = probeMethodAvailability(ctx, client, "logseq.Editor.getAllProperties") + out.Data.API.PropertyGet = probeMethodAvailability(ctx, client, "logseq.Editor.getProperty", "public") + + // Graph: basic connectivity + if _, err := client.GetCurrentGraph(ctx); err != nil { + out.Data.Graph.CurrentGraphReachable = capabilityProbe{Supported: false, Reason: shortErr(err)} + } else { + out.Data.Graph.CurrentGraphReachable = capabilityProbe{Supported: true} + } + + // Graph: DB graph mode + if ok, err := client.CheckCurrentIsDBGraph(ctx); err != nil { + out.Data.Graph.DBGraph = capabilityProbe{Supported: false, Reason: shortErr(err)} + } else { + if ok { + out.Data.Graph.DBGraph = capabilityProbe{Supported: true} + } else { + out.Data.Graph.DBGraph = capabilityProbe{Supported: false, Reason: "current graph is not DB graph"} + } + } + + // Graph: tags field availability + if c, err := datalogCount(ctx, client, "[:find (count ?b) :where [?b :block/tags ?t]]"); err != nil { + out.Data.Graph.TagsFieldAvailable = capabilityProbe{Supported: false, Reason: shortErr(err)} + } else if c > 0 { + out.Data.Graph.TagsFieldAvailable = capabilityProbe{Supported: true} + } else { + out.Data.Graph.TagsFieldAvailable = capabilityProbe{Supported: false, Reason: "no rows from :block/tags"} + } + + // Graph: refs field availability + if c, err := datalogCount(ctx, client, "[:find (count ?b) :where [?b :block/refs ?r]]"); err != nil { + out.Data.Graph.RefsFieldAvailable = capabilityProbe{Supported: false, Reason: shortErr(err)} + } else if c > 0 { + out.Data.Graph.RefsFieldAvailable = capabilityProbe{Supported: true} + } else { + out.Data.Graph.RefsFieldAvailable = capabilityProbe{Supported: false, Reason: "no rows from :block/refs"} + } + + out.Data.WritePolicy = probeWritePolicy() + out.Data.Hints = buildCapabilityHints(out) + return out +} + +func probeWritePolicy() writePolicyProbe { + mode := strings.ToLower(strings.TrimSpace(os.Getenv("LOGSEQ_LLM_WRITE_MODE"))) + switch mode { + case "", "read-only", "confirm", "direct": + if mode == "" { + mode = "read-only" + } + default: + mode = "read-only" + } + + requireConfirm := envBoolOrDefault("LOGSEQ_LLM_REQUIRE_CONFIRM_FOR_DELETE", true) + maxResults := envIntOrDefault("LOGSEQ_LLM_MAX_RESULTS", 200) + if maxResults <= 0 { + maxResults = 200 + } + + return writePolicyProbe{ + DefaultMode: mode, + DangerousRequiresConfirm: requireConfirm, + MaxResults: maxResults, + } +} + +func buildCapabilityHints(out *capabilitiesResult) []string { + hints := make([]string, 0, 4) + if !out.ConnectionInfo.TokenConfigured { + hints = append(hints, "LOGSEQ_API_TOKEN 未配置,部分能力会失败") + } + if !out.Data.API.DSLQuery.Supported { + hints = append(hints, "DSL 不可用时建议回退到 Datalog 或 DSL-wrapper 提取 :query") + } + if !out.Data.Graph.TagsFieldAvailable.Supported && out.Data.Graph.RefsFieldAvailable.Supported { + hints = append(hints, "当前图谱 :block/tags 为空,建议基于 :block/refs 处理标签相关查询") + } + if !out.Data.API.Search.Supported { + hints = append(hints, "search API 不可用时可用 datalog + 分页策略兜底") + } + return hints +} + +func datalogCount(ctx context.Context, client *logseq.Client, query string) (int, error) { + raw, err := client.DatascriptQuery(ctx, query) + if err != nil { + return 0, err + } + + var rows [][]any + if err := json.Unmarshal(raw, &rows); err != nil { + return 0, fmt.Errorf("parse datalog rows: %w", err) + } + if len(rows) == 0 || len(rows[0]) == 0 { + return 0, nil + } + + return anyToInt(rows[0][0]) +} + +func anyToInt(v any) (int, error) { + switch x := v.(type) { + case float64: + return int(x), nil + case int: + return x, nil + case int64: + return int(x), nil + case json.Number: + i64, err := x.Int64() + if err != nil { + return 0, err + } + return int(i64), nil + default: + return 0, fmt.Errorf("unsupported number type: %T", v) + } +} + +func shortErr(err error) string { + if err == nil { + return "" + } + msg := strings.TrimSpace(err.Error()) + if len(msg) > 180 { + return msg[:180] + "..." + } + return msg +} + +func probeMethodAvailability(ctx context.Context, client *logseq.Client, method string, args ...any) capabilityProbe { + _, err := client.CallAPI(ctx, method, args...) + if err == nil { + return capabilityProbe{Supported: true} + } + + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "methodnotexist") || strings.Contains(msg, "doesn't support name") { + return capabilityProbe{Supported: false, Reason: shortErr(err)} + } + + // Non-MethodNotExist usually means the method exists but current args/context are not ideal. + return capabilityProbe{Supported: true, Reason: shortErr(err)} +} + +func envBoolOrDefault(key string, def bool) bool { + raw := strings.TrimSpace(strings.ToLower(os.Getenv(key))) + if raw == "" { + return def + } + switch raw { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return def + } +} + +func envIntOrDefault(key string, def int) int { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" { + return def + } + v, err := strconv.Atoi(raw) + if err != nil { + return def + } + return v +} diff --git a/cmds/capabilities_test.go b/cmds/capabilities_test.go new file mode 100644 index 0000000..d360347 --- /dev/null +++ b/cmds/capabilities_test.go @@ -0,0 +1,129 @@ +package cmds + +import ( + "encoding/json" + "testing" +) + +func TestAnyToInt(t *testing.T) { + tests := []struct { + name string + input any + want int + wantErr bool + }{ + {"float64", float64(42), 42, false}, + {"int", int(7), 7, false}, + {"int64", int64(100), 100, false}, + {"json.Number", json.Number("55"), 55, false}, + {"string", "hello", 0, true}, + {"nil", nil, 0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := anyToInt(tt.input) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("anyToInt(%v)=%d, want %d", tt.input, got, tt.want) + } + }) + } +} + +func TestShortErr(t *testing.T) { + if got := shortErr(nil); got != "" { + t.Errorf("shortErr(nil)=%q, want empty", got) + } + + // Short message stays as-is + short := "some error" + if got := shortErr(errFromString(short)); got != short { + t.Errorf("shortErr=%q, want %q", got, short) + } + + // Long message gets truncated + long := make([]byte, 300) + for i := range long { + long[i] = 'a' + } + got := shortErr(errFromString(string(long))) + if len(got) > 184 { // 180 + "..." + t.Errorf("shortErr len=%d, expected <= 184", len(got)) + } +} + +func TestEnvBoolOrDefault(t *testing.T) { + key := "TEST_ENVBOOL_DF34K" + + tests := []struct { + env string + def bool + want bool + }{ + {"", true, true}, + {"", false, false}, + {"true", false, true}, + {"1", false, true}, + {"yes", false, true}, + {"on", false, true}, + {"false", true, false}, + {"0", true, false}, + {"no", true, false}, + {"off", true, false}, + {"garbage", true, true}, + {"garbage", false, false}, + } + + for _, tt := range tests { + t.Run("env="+tt.env, func(t *testing.T) { + t.Setenv(key, tt.env) + got := envBoolOrDefault(key, tt.def) + if got != tt.want { + t.Errorf("envBoolOrDefault(%q, %v)=%v, want %v", tt.env, tt.def, got, tt.want) + } + }) + } +} + +func TestEnvIntOrDefault(t *testing.T) { + key := "TEST_ENVINT_XK92P" + + tests := []struct { + env string + def int + want int + }{ + {"", 42, 42}, + {"100", 42, 100}, + {"0", 42, 0}, + {"-5", 42, -5}, + {"abc", 42, 42}, + } + + for _, tt := range tests { + t.Run("env="+tt.env, func(t *testing.T) { + t.Setenv(key, tt.env) + got := envIntOrDefault(key, tt.def) + if got != tt.want { + t.Errorf("envIntOrDefault(%q, %d)=%d, want %d", tt.env, tt.def, got, tt.want) + } + }) + } +} + +type simpleErr string + +func (e simpleErr) Error() string { return string(e) } + +func errFromString(s string) error { + return simpleErr(s) +} diff --git a/cmds/cmds.go b/cmds/cmds.go new file mode 100644 index 0000000..6743611 --- /dev/null +++ b/cmds/cmds.go @@ -0,0 +1,111 @@ +package cmds + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/pubgo/logseq-cli/pkg/logseq" + "github.com/pubgo/redant" +) + +var ( + Token string + Host string + Port string +) + +type StatusResult struct { + OK bool `json:"ok"` + 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 { + resolved := resolveClient() + return logseq.NewClient( + logseq.WithBaseURL(resolved.info.BaseURL), + 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 new file mode 100644 index 0000000..97df659 --- /dev/null +++ b/cmds/graph.go @@ -0,0 +1,188 @@ +package cmds + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/pubgo/logseq-cli/pkg/logseq" + "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", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*logseq.GraphInfo, error) { + client := NewClient() + 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) + }), + }, + { + Use: "app-info", + Short: "Get app info", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (map[string]any, error) { + client := NewClient() + return client.GetInfo(ctx) + }), + }, + { + Use: "user-info", + Short: "Get app user info", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (map[string]any, error) { + client := NewClient() + return client.GetUserInfo(ctx) + }), + }, + { + Use: "db-graph", + Short: "Check if current graph is DB graph", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (map[string]bool, error) { + client := NewClient() + ok, err := client.CheckCurrentIsDBGraph(ctx) + if err != nil { + return nil, err + } + return map[string]bool{"dbGraph": ok}, nil + }), + }, + { + Use: "graph-config", + Short: "Get current graph configs", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (any, error) { + client := NewClient() + return client.GetCurrentGraphConfigs(ctx) + }), + }, + { + Use: "favorites", + Short: "Get current graph favorites", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) ([]any, error) { + client := NewClient() + return client.GetCurrentGraphFavorites(ctx) + }), + }, + { + Use: "recent", + Short: "Get current graph recent items", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) ([]any, error) { + client := NewClient() + return client.GetCurrentGraphRecent(ctx) + }), + }, + { + Use: "templates", + Short: "Get current graph templates", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (map[string]any, error) { + client := NewClient() + return client.GetCurrentGraphTemplates(ctx) + }), + }, + { + Use: "state ", + Short: "Get app state value from store by key", + Args: redant.ArgSet{ + {Name: "key", Required: true, Value: redant.StringOf(new(string)), Description: "State store key"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (any, error) { + client := NewClient() + return client.GetStateFromStore(ctx, inv.Args[0]) + }), + }, + { + Use: "state-set ", + Short: "Set app state value in store by key", + Args: redant.ArgSet{ + {Name: "key", Required: true, Value: redant.StringOf(new(string)), Description: "State store key"}, + {Name: "value", Required: true, Value: redant.StringOf(new(string)), Description: "State value (JSON literal or plain string)"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { + client := NewClient() + + var value any = inv.Args[1] + var parsed any + if err := json.Unmarshal([]byte(inv.Args[1]), &parsed); err == nil { + value = parsed + } + + if err := client.SetStateFromStore(ctx, inv.Args[0], value); err != nil { + return StatusResult{}, fmt.Errorf("set state failed: %w", err) + } + + return StatusResult{OK: true, Message: "state updated"}, nil + }), + }, + }, + } +} + +func QueryCmd() *redant.Command { + return &redant.Command{ + Use: "query", + Short: "Query operations", + Children: []*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 { + client := NewClient() + result, err := client.DatascriptQuery(ctx, inv.Args[0]) + if err != nil { + return err + } + 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 { + client := NewClient() + result, err := client.DSLQuery(ctx, inv.Args[0]) + if err != nil { + return err + } + buf := json.RawMessage(result) + enc := json.NewEncoder(inv.Stdout) + enc.SetIndent("", " ") + return enc.Encode(buf) + }, + }, + }, + } +} + +func SearchCmd() *redant.Command { + return &redant.Command{ + Use: "search ", + Short: "Full-text search", + 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/llm_envelope.go b/cmds/llm_envelope.go new file mode 100644 index 0000000..1aadee1 --- /dev/null +++ b/cmds/llm_envelope.go @@ -0,0 +1,147 @@ +package cmds + +import ( + "fmt" + "strings" + "sync/atomic" + "time" +) + +type llmEnvelopeError struct { + Code string `json:"code"` + Message string `json:"message"` + Retryable bool `json:"retryable,omitempty"` + Suggestion string `json:"suggestion,omitempty"` +} + +type llmEnvelopeMeta struct { + RequestID string `json:"request_id"` + DurationMs int64 `json:"duration_ms"` + CapabilityUsed []string `json:"capability_used,omitempty"` + FallbackUsed []string `json:"fallback_used,omitempty"` + NextCursor string `json:"next_cursor,omitempty"` + HasMore bool `json:"has_more,omitempty"` +} + +type llmEnvelope struct { + OK bool `json:"ok"` + Data any `json:"data,omitempty"` + Error *llmEnvelopeError `json:"error,omitempty"` + Meta llmEnvelopeMeta `json:"meta"` + Hints []string `json:"hints,omitempty"` +} + +type llmEnvelopeOption func(*llmEnvelope) + +var envelopeCounter uint64 + +func newLLMRequestID() string { + n := atomic.AddUint64(&envelopeCounter, 1) + return fmt.Sprintf("req-%d-%d", time.Now().UnixNano(), n) +} + +func withCapabilityUsed(v ...string) llmEnvelopeOption { + return func(e *llmEnvelope) { + e.Meta.CapabilityUsed = append(e.Meta.CapabilityUsed, v...) + } +} + +func withFallbackUsed(v ...string) llmEnvelopeOption { + return func(e *llmEnvelope) { + e.Meta.FallbackUsed = append(e.Meta.FallbackUsed, v...) + } +} + +func withHints(v ...string) llmEnvelopeOption { + return func(e *llmEnvelope) { + e.Hints = append(e.Hints, v...) + } +} + +func withPageMeta(nextCursor string, hasMore bool) llmEnvelopeOption { + return func(e *llmEnvelope) { + e.Meta.NextCursor = strings.TrimSpace(nextCursor) + e.Meta.HasMore = hasMore + } +} + +func envelopeSuccess(start time.Time, data any, opts ...llmEnvelopeOption) *llmEnvelope { + env := &llmEnvelope{ + OK: true, + Data: data, + Meta: llmEnvelopeMeta{ + RequestID: newLLMRequestID(), + DurationMs: time.Since(start).Milliseconds(), + }, + } + for _, opt := range opts { + if opt != nil { + opt(env) + } + } + return env +} + +func envelopeFailure(start time.Time, err error, code string, suggestion string, opts ...llmEnvelopeOption) *llmEnvelope { + if strings.TrimSpace(code) == "" { + code = classifyEnvelopeErrorCode(err) + } + + env := &llmEnvelope{ + OK: false, + Error: &llmEnvelopeError{ + Code: code, + Message: strings.TrimSpace(errString(err)), + Retryable: isRetryableCode(code), + Suggestion: strings.TrimSpace(suggestion), + }, + Meta: llmEnvelopeMeta{ + RequestID: newLLMRequestID(), + DurationMs: time.Since(start).Milliseconds(), + }, + } + + for _, opt := range opts { + if opt != nil { + opt(env) + } + } + return env +} + +func errString(err error) string { + if err == nil { + return "unknown error" + } + return err.Error() +} + +func classifyEnvelopeErrorCode(err error) string { + if err == nil { + return "UPSTREAM_ERROR" + } + msg := strings.ToLower(strings.TrimSpace(err.Error())) + switch { + case strings.Contains(msg, "safety_blocked"): + return "SAFETY_BLOCKED" + case strings.Contains(msg, "required"), strings.Contains(msg, "invalid"), strings.Contains(msg, "missing"): + return "BAD_REQUEST" + case strings.Contains(msg, "not found"): + return "RESOURCE_NOT_FOUND" + case strings.Contains(msg, "methodnotexist"), strings.Contains(msg, "doesn't support"): + return "CAPABILITY_UNAVAILABLE" + case strings.Contains(msg, "timeout"): + return "TIMEOUT" + default: + return "UPSTREAM_ERROR" + } +} + +func isRetryableCode(code string) bool { + switch strings.ToUpper(strings.TrimSpace(code)) { + case "TIMEOUT", "UPSTREAM_ERROR": + return true + default: + return false + } +} diff --git a/cmds/llm_envelope_test.go b/cmds/llm_envelope_test.go new file mode 100644 index 0000000..6ae5471 --- /dev/null +++ b/cmds/llm_envelope_test.go @@ -0,0 +1,147 @@ +package cmds + +import ( + "errors" + "testing" + "time" +) + +func TestClassifyEnvelopeErrorCode(t *testing.T) { + tests := []struct { + err error + want string + }{ + {nil, "UPSTREAM_ERROR"}, + {errors.New("SAFETY_BLOCKED: delete blocked"), "SAFETY_BLOCKED"}, + {errors.New("query is required"), "BAD_REQUEST"}, + {errors.New("invalid limit"), "BAD_REQUEST"}, + {errors.New("missing field"), "BAD_REQUEST"}, + {errors.New("page not found"), "RESOURCE_NOT_FOUND"}, + {errors.New("methodnotexist"), "CAPABILITY_UNAVAILABLE"}, + {errors.New("API doesn't support this"), "CAPABILITY_UNAVAILABLE"}, + {errors.New("context deadline exceeded: timeout"), "TIMEOUT"}, + {errors.New("some random error"), "UPSTREAM_ERROR"}, + } + + for _, tt := range tests { + name := "nil" + if tt.err != nil { + name = tt.err.Error() + } + t.Run(name, func(t *testing.T) { + got := classifyEnvelopeErrorCode(tt.err) + if got != tt.want { + t.Errorf("classifyEnvelopeErrorCode(%v)=%q, want %q", tt.err, got, tt.want) + } + }) + } +} + +func TestIsRetryableCode(t *testing.T) { + tests := []struct { + code string + want bool + }{ + {"TIMEOUT", true}, + {"UPSTREAM_ERROR", true}, + {"timeout", true}, + {" TIMEOUT ", true}, + {"BAD_REQUEST", false}, + {"SAFETY_BLOCKED", false}, + {"RESOURCE_NOT_FOUND", false}, + {"CAPABILITY_UNAVAILABLE", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.code, func(t *testing.T) { + got := isRetryableCode(tt.code) + if got != tt.want { + t.Errorf("isRetryableCode(%q)=%v, want %v", tt.code, got, tt.want) + } + }) + } +} + +func TestErrString(t *testing.T) { + if got := errString(nil); got != "unknown error" { + t.Errorf("errString(nil)=%q, want %q", got, "unknown error") + } + if got := errString(errors.New("some error")); got != "some error" { + t.Errorf("errString(err)=%q, want %q", got, "some error") + } +} + +func TestEnvelopeSuccess(t *testing.T) { + start := time.Now() + env := envelopeSuccess(start, "data", withCapabilityUsed("cap1"), withHints("hint1")) + if !env.OK { + t.Error("expected OK=true") + } + if env.Data != "data" { + t.Errorf("Data=%v, want %q", env.Data, "data") + } + if env.Error != nil { + t.Error("expected Error=nil") + } + if len(env.Meta.CapabilityUsed) != 1 || env.Meta.CapabilityUsed[0] != "cap1" { + t.Errorf("CapabilityUsed=%v", env.Meta.CapabilityUsed) + } + if len(env.Hints) != 1 || env.Hints[0] != "hint1" { + t.Errorf("Hints=%v", env.Hints) + } + if env.Meta.RequestID == "" { + t.Error("expected non-empty RequestID") + } +} + +func TestEnvelopeFailure(t *testing.T) { + start := time.Now() + env := envelopeFailure(start, errors.New("timeout"), "", "retry later") + if env.OK { + t.Error("expected OK=false") + } + if env.Error == nil { + t.Fatal("expected Error != nil") + } + if env.Error.Code != "TIMEOUT" { + t.Errorf("Error.Code=%q, want %q", env.Error.Code, "TIMEOUT") + } + if env.Error.Message != "timeout" { + t.Errorf("Error.Message=%q, want %q", env.Error.Message, "timeout") + } + if !env.Error.Retryable { + t.Error("expected Retryable=true for TIMEOUT") + } + if env.Error.Suggestion != "retry later" { + t.Errorf("Suggestion=%q", env.Error.Suggestion) + } + + // With explicit code + env2 := envelopeFailure(start, errors.New("bad"), "BAD_REQUEST", "fix it") + if env2.Error.Code != "BAD_REQUEST" { + t.Errorf("expected explicit code BAD_REQUEST, got %q", env2.Error.Code) + } + if env2.Error.Retryable { + t.Error("BAD_REQUEST should not be retryable") + } +} + +func TestWithPageMeta(t *testing.T) { + start := time.Now() + env := envelopeSuccess(start, nil, withPageMeta("42", true)) + if env.Meta.NextCursor != "42" { + t.Errorf("NextCursor=%q, want %q", env.Meta.NextCursor, "42") + } + if !env.Meta.HasMore { + t.Error("expected HasMore=true") + } +} + +func TestWithFallbackUsed(t *testing.T) { + start := time.Now() + env := envelopeSuccess(start, nil, withFallbackUsed("fb1", "fb2")) + if len(env.Meta.FallbackUsed) != 2 { + t.Errorf("FallbackUsed=%v", env.Meta.FallbackUsed) + } +} diff --git a/cmds/llm_safety.go b/cmds/llm_safety.go new file mode 100644 index 0000000..277fb24 --- /dev/null +++ b/cmds/llm_safety.go @@ -0,0 +1,94 @@ +package cmds + +import ( + "fmt" + "os" + "strings" + "sync" + "time" +) + +const ( + llmWriteModeReadOnly = "read-only" + llmWriteModeConfirm = "confirm" + llmWriteModeDirect = "direct" +) + +type appendSafeRecord struct { + BlockUUID string `json:"block_uuid"` + Page string `json:"page"` + ContentPreview string `json:"content_preview"` + CreatedAt time.Time `json:"created_at"` +} + +var ( + appendSafeStoreMu sync.Mutex + appendSafeStore = map[string]appendSafeRecord{} +) + +func currentLLMWriteMode() string { + mode := strings.ToLower(strings.TrimSpace(os.Getenv("LOGSEQ_LLM_WRITE_MODE"))) + switch mode { + case llmWriteModeReadOnly, llmWriteModeConfirm, llmWriteModeDirect: + return mode + default: + return llmWriteModeReadOnly + } +} + +func llmDeleteRequiresConfirm() bool { + raw := strings.ToLower(strings.TrimSpace(os.Getenv("LOGSEQ_LLM_REQUIRE_CONFIRM_FOR_DELETE"))) + if raw == "" { + return true + } + switch raw { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return true + } +} + +func ensureWriteAllowed(action string, dryRun bool, confirm bool, dangerous bool) error { + if dryRun { + return nil + } + + mode := currentLLMWriteMode() + if mode == llmWriteModeReadOnly { + return fmt.Errorf("SAFETY_BLOCKED: %s blocked by LOGSEQ_LLM_WRITE_MODE=read-only", action) + } + + if mode == llmWriteModeConfirm && !confirm { + return fmt.Errorf("SAFETY_BLOCKED: %s requires --confirm in LOGSEQ_LLM_WRITE_MODE=confirm", action) + } + + if dangerous && llmDeleteRequiresConfirm() && !confirm { + return fmt.Errorf("SAFETY_BLOCKED: %s requires --confirm (LOGSEQ_LLM_REQUIRE_CONFIRM_FOR_DELETE=true)", action) + } + + return nil +} + +func getAppendSafeRecord(key string) (appendSafeRecord, bool) { + k := strings.TrimSpace(key) + if k == "" { + return appendSafeRecord{}, false + } + appendSafeStoreMu.Lock() + defer appendSafeStoreMu.Unlock() + v, ok := appendSafeStore[k] + return v, ok +} + +func setAppendSafeRecord(key string, rec appendSafeRecord) { + k := strings.TrimSpace(key) + if k == "" { + return + } + appendSafeStoreMu.Lock() + defer appendSafeStoreMu.Unlock() + appendSafeStore[k] = rec +} diff --git a/cmds/llm_safety_test.go b/cmds/llm_safety_test.go new file mode 100644 index 0000000..2d88089 --- /dev/null +++ b/cmds/llm_safety_test.go @@ -0,0 +1,209 @@ +package cmds + +import ( + "testing" +) + +func TestCurrentLLMWriteMode(t *testing.T) { + tests := []struct { + env string + want string + }{ + {"", llmWriteModeReadOnly}, + {"read-only", llmWriteModeReadOnly}, + {"confirm", llmWriteModeConfirm}, + {"direct", llmWriteModeDirect}, + {"CONFIRM", llmWriteModeConfirm}, + {"Direct", llmWriteModeDirect}, + {" read-only ", llmWriteModeReadOnly}, + {"garbage", llmWriteModeReadOnly}, + } + + for _, tt := range tests { + t.Run("env="+tt.env, func(t *testing.T) { + t.Setenv("LOGSEQ_LLM_WRITE_MODE", tt.env) + got := currentLLMWriteMode() + if got != tt.want { + t.Errorf("currentLLMWriteMode()=%q, want %q", got, tt.want) + } + }) + } +} + +func TestLLMDeleteRequiresConfirm(t *testing.T) { + tests := []struct { + env string + want bool + }{ + {"", true}, + {"1", true}, + {"true", true}, + {"yes", true}, + {"on", true}, + {"0", false}, + {"false", false}, + {"no", false}, + {"off", false}, + {"garbage", true}, + } + + for _, tt := range tests { + t.Run("env="+tt.env, func(t *testing.T) { + t.Setenv("LOGSEQ_LLM_REQUIRE_CONFIRM_FOR_DELETE", tt.env) + got := llmDeleteRequiresConfirm() + if got != tt.want { + t.Errorf("llmDeleteRequiresConfirm()=%v, want %v", got, tt.want) + } + }) + } +} + +func TestEnsureWriteAllowed(t *testing.T) { + tests := []struct { + name string + mode string + deleteEnv string + action string + dryRun bool + confirm bool + dangerous bool + wantErr bool + errSubstr string + }{ + { + name: "dryRun always passes", + mode: "read-only", + action: "delete", + dryRun: true, + wantErr: false, + }, + { + name: "read-only blocks writes", + mode: "read-only", + action: "update", + wantErr: true, + errSubstr: "SAFETY_BLOCKED", + }, + { + name: "confirm mode without confirm flag", + mode: "confirm", + action: "update", + confirm: false, + wantErr: true, + errSubstr: "requires --confirm", + }, + { + name: "confirm mode with confirm flag", + mode: "confirm", + action: "update", + confirm: true, + wantErr: false, + }, + { + name: "direct mode allows writes", + mode: "direct", + action: "update", + wantErr: false, + }, + { + name: "dangerous action requires confirm even in direct", + mode: "direct", + deleteEnv: "true", + action: "delete", + dangerous: true, + confirm: false, + wantErr: true, + errSubstr: "SAFETY_BLOCKED", + }, + { + name: "dangerous action with confirm passes", + mode: "direct", + deleteEnv: "true", + action: "delete", + dangerous: true, + confirm: true, + wantErr: false, + }, + { + name: "dangerous action with delete confirm disabled", + mode: "direct", + deleteEnv: "false", + action: "delete", + dangerous: true, + confirm: false, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("LOGSEQ_LLM_WRITE_MODE", tt.mode) + if tt.deleteEnv != "" { + t.Setenv("LOGSEQ_LLM_REQUIRE_CONFIRM_FOR_DELETE", tt.deleteEnv) + } + err := ensureWriteAllowed(tt.action, tt.dryRun, tt.confirm, tt.dangerous) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if tt.errSubstr != "" && !contains(err.Error(), tt.errSubstr) { + t.Errorf("error %q should contain %q", err.Error(), tt.errSubstr) + } + } else { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + } + }) + } +} + +func TestAppendSafeStore(t *testing.T) { + // Clean state + appendSafeStoreMu.Lock() + appendSafeStore = map[string]appendSafeRecord{} + appendSafeStoreMu.Unlock() + + // Empty key returns not found + _, ok := getAppendSafeRecord("") + if ok { + t.Error("empty key should return false") + } + + // Set and get + setAppendSafeRecord("page1", appendSafeRecord{BlockUUID: "uuid-1", Page: "page1"}) + rec, ok := getAppendSafeRecord("page1") + if !ok { + t.Fatal("expected to find record") + } + if rec.BlockUUID != "uuid-1" { + t.Errorf("BlockUUID=%q, want %q", rec.BlockUUID, "uuid-1") + } + + // Empty key set is a no-op + setAppendSafeRecord("", appendSafeRecord{BlockUUID: "nope"}) + _, ok = getAppendSafeRecord("") + if ok { + t.Error("empty key should still return false after set") + } + + // Overwrite + setAppendSafeRecord("page1", appendSafeRecord{BlockUUID: "uuid-2", Page: "page1"}) + rec, _ = getAppendSafeRecord("page1") + if rec.BlockUUID != "uuid-2" { + t.Errorf("BlockUUID=%q, want %q after overwrite", rec.BlockUUID, "uuid-2") + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexSubstring(s, sub) >= 0) +} + +func indexSubstring(s, sub string) int { + for i := 0; i <= len(s)-len(sub); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/cmds/page.go b/cmds/page.go new file mode 100644 index 0000000..154bc02 --- /dev/null +++ b/cmds/page.go @@ -0,0 +1,655 @@ +package cmds + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "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(), + pageCurrentCmd(), + pageCurrentTreeCmd(), + pageGetCmd(), + pageGetContextCmd(), + pageCreateCmd(), + pageAppendSafeCmd(), + pageJournalCmd(), + pageDeleteCmd(), + pageRenameCmd(), + pageRefsCmd(), + pageNamespaceCmd(), + pagePropertiesCmd(), + }, + } +} + +type pageContextBlock struct { + UUID string `json:"uuid,omitempty"` + Content string `json:"content,omitempty"` + Marker string `json:"marker,omitempty"` + Priority string `json:"priority,omitempty"` + Level int `json:"level,omitempty"` + Properties map[string]any `json:"properties,omitempty"` + Children []pageContextBlock `json:"children,omitempty"` +} + +type pageContextLimits struct { + MaxBlocks int `json:"max_blocks"` + MaxDepth int `json:"max_depth"` + IncludeProperties bool `json:"include_properties"` +} + +type pageContextStats struct { + ReturnedBlocks int `json:"returned_blocks"` + ClippedByDepth int `json:"clipped_by_depth,omitempty"` + ClippedByLimit int `json:"clipped_by_limit,omitempty"` +} + +type pageContextResult struct { + Page *logseq.Page `json:"page"` + Properties map[string]any `json:"properties,omitempty"` + OutlineBlocks []pageContextBlock `json:"outline_blocks"` + Limits pageContextLimits `json:"limits"` + Stats pageContextStats `json:"stats"` +} + +type pageContextBuilder struct { + maxBlocks int + maxDepth int + seen int + clippedByDepth int + clippedByLimit int +} + +func pageGetContextCmd() *redant.Command { + maxBlocksRaw := "200" + maxDepthRaw := "6" + includePropertiesRaw := "true" + + return &redant.Command{ + Use: "get-context ", + Short: "Get LLM-friendly page context (bounded outline)", + Args: redant.ArgSet{ + {Name: "name", Required: true, Value: redant.StringOf(new(string)), Description: "Page name"}, + }, + Options: redant.OptionSet{ + {Flag: "max-blocks", Description: "Maximum number of returned blocks", Default: "200", Value: redant.StringOf(&maxBlocksRaw)}, + {Flag: "max-depth", Description: "Maximum outline depth", Default: "6", Value: redant.StringOf(&maxDepthRaw)}, + {Flag: "include-properties", Description: "Include page properties in response", Default: "true", Value: redant.StringOf(&includePropertiesRaw)}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*llmEnvelope, error) { + start := time.Now() + maxBlocks := 200 + maxDepth := 6 + includeProperties := true + + name := strings.TrimSpace(inv.Args[0]) + if name == "" { + return envelopeFailure(start, fmt.Errorf("page name is required"), "BAD_REQUEST", "请提供页面名"), nil + } + + if strings.TrimSpace(maxBlocksRaw) != "" { + v, convErr := strconv.Atoi(strings.TrimSpace(maxBlocksRaw)) + if convErr != nil { + return envelopeFailure(start, fmt.Errorf("invalid max-blocks: %w", convErr), "BAD_REQUEST", "max-blocks 需要是数字"), nil + } + maxBlocks = v + } + + if strings.TrimSpace(maxDepthRaw) != "" { + v, convErr := strconv.Atoi(strings.TrimSpace(maxDepthRaw)) + if convErr != nil { + return envelopeFailure(start, fmt.Errorf("invalid max-depth: %w", convErr), "BAD_REQUEST", "max-depth 需要是数字"), nil + } + maxDepth = v + } + + if strings.TrimSpace(includePropertiesRaw) != "" { + v, convErr := strconv.ParseBool(strings.TrimSpace(includePropertiesRaw)) + if convErr != nil { + return envelopeFailure(start, fmt.Errorf("invalid include-properties: %w", convErr), "BAD_REQUEST", "include-properties 需要是 true/false"), nil + } + includeProperties = v + } + + policyMaxBlocks := envIntOrDefault("LOGSEQ_LLM_MAX_RESULTS", 200) + if policyMaxBlocks <= 0 { + policyMaxBlocks = 200 + } + + if maxBlocks <= 0 { + maxBlocks = 200 + } + if maxBlocks > policyMaxBlocks { + maxBlocks = policyMaxBlocks + } + + if maxDepth <= 0 { + maxDepth = 6 + } + + client := NewClient() + page, err := client.GetPage(ctx, name) + if err != nil { + return envelopeFailure(start, err, "UPSTREAM_ERROR", "请先确认 API 连接正常", withCapabilityUsed("logseq.Editor.getPage")), nil + } + if page == nil { + return envelopeFailure(start, fmt.Errorf("page '%s' not found", name), "RESOURCE_NOT_FOUND", "请确认页面存在"), nil + } + + blocks, err := client.GetPageBlocksTree(ctx, name) + if err != nil { + return envelopeFailure(start, err, "UPSTREAM_ERROR", "读取页面块树失败", withCapabilityUsed("logseq.Editor.getPageBlocksTree")), nil + } + + var ( + properties map[string]any + fallbackUsed bool + ) + + if includeProperties { + properties, err = client.GetPageProperties(ctx, name) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "methodnotexist") { + properties = page.Properties + fallbackUsed = true + } else { + return envelopeFailure(start, err, "UPSTREAM_ERROR", "读取页面属性失败", withCapabilityUsed("logseq.Editor.getPageProperties")), nil + } + } + } + + builder := &pageContextBuilder{maxBlocks: maxBlocks, maxDepth: maxDepth} + outline := builder.buildBlocks(blocks, 1) + + data := pageContextResult{ + Page: page, + Properties: properties, + OutlineBlocks: outline, + Limits: pageContextLimits{ + MaxBlocks: maxBlocks, + MaxDepth: maxDepth, + IncludeProperties: includeProperties, + }, + Stats: pageContextStats{ + ReturnedBlocks: builder.seen, + ClippedByDepth: builder.clippedByDepth, + ClippedByLimit: builder.clippedByLimit, + }, + } + + hints := make([]string, 0, 3) + if builder.clippedByLimit > 0 { + hints = append(hints, fmt.Sprintf("已按 max-blocks 裁剪 %d 个块", builder.clippedByLimit)) + } + if builder.clippedByDepth > 0 { + hints = append(hints, fmt.Sprintf("已按 max-depth 裁剪 %d 个块", builder.clippedByDepth)) + } + if fallbackUsed { + hints = append(hints, "当前版本不支持 getPageProperties,已回退到 getPage.properties") + } + + opts := []llmEnvelopeOption{ + withCapabilityUsed("logseq.Editor.getPage", "logseq.Editor.getPageBlocksTree"), + withHints(hints...), + } + if includeProperties { + opts = append(opts, withCapabilityUsed("logseq.Editor.getPageProperties")) + } + if fallbackUsed { + opts = append(opts, withFallbackUsed("page.properties.from.getPage")) + } + + return envelopeSuccess(start, data, opts...), nil + }), + } +} + +func (b *pageContextBuilder) buildBlocks(src []logseq.Block, depth int) []pageContextBlock { + out := make([]pageContextBlock, 0, len(src)) + for i := range src { + if b.maxBlocks > 0 && b.seen >= b.maxBlocks { + b.clippedByLimit += countBlockSubtreeValue(src[i]) + continue + } + + if b.maxDepth > 0 && depth > b.maxDepth { + b.clippedByDepth += countBlockSubtreeValue(src[i]) + continue + } + + node := pageContextBlock{ + UUID: strings.TrimSpace(src[i].UUID), + Content: truncate(src[i].Content, 500), + Marker: strings.TrimSpace(src[i].Marker), + Priority: strings.TrimSpace(src[i].Priority), + Level: src[i].Level, + Properties: src[i].Properties, + } + b.seen++ + + if len(src[i].Children) > 0 { + node.Children = b.buildBlocks(src[i].Children, depth+1) + } + + out = append(out, node) + } + + return out +} + +func countBlockSubtreeValue(b logseq.Block) int { + total := 1 + for i := range b.Children { + total += countBlockSubtreeValue(b.Children[i]) + } + return total +} + +func pageAppendSafeCmd() *redant.Command { + var ( + dryRun bool + confirm bool + idempotencyKey string + ) + + return &redant.Command{ + Use: "append-safe ", + Short: "Safely append block to page (supports dry-run / idempotency)", + Args: redant.ArgSet{ + {Name: "name", Required: true, Value: redant.StringOf(new(string)), Description: "Page name"}, + {Name: "content", Required: true, Value: redant.StringOf(new(string)), Description: "Block content (use '-' for stdin)"}, + }, + Options: redant.OptionSet{ + {Flag: "dry-run", Description: "Preview action without writing", Value: redant.BoolOf(&dryRun)}, + {Flag: "confirm", Description: "Required in some write policies", Value: redant.BoolOf(&confirm)}, + {Flag: "idempotency-key", Description: "Idempotency key to avoid duplicate writes", Value: redant.StringOf(&idempotencyKey)}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*llmEnvelope, error) { + start := time.Now() + pageName := strings.TrimSpace(inv.Args[0]) + if pageName == "" { + return envelopeFailure(start, fmt.Errorf("page name is required"), "BAD_REQUEST", "请提供页面名"), nil + } + + content, err := readContent(inv, inv.Args[1]) + if err != nil { + return envelopeFailure(start, err, "BAD_REQUEST", "content 参数错误"), nil + } + content = strings.TrimSpace(content) + if content == "" { + return envelopeFailure(start, fmt.Errorf("content is required"), "BAD_REQUEST", "请提供非空内容"), nil + } + + if err := ensureWriteAllowed("page.append-safe", dryRun, confirm, false); err != nil { + return envelopeFailure(start, err, "SAFETY_BLOCKED", "可先使用 --dry-run 或调整 LOGSEQ_LLM_WRITE_MODE"), nil + } + + client := NewClient() + page, err := client.GetPage(ctx, pageName) + if err != nil { + return envelopeFailure(start, err, "UPSTREAM_ERROR", "请先确认 API 连接正常", withCapabilityUsed("logseq.Editor.getPage")), nil + } + if page == nil { + return envelopeFailure(start, fmt.Errorf("page '%s' not found", pageName), "RESOURCE_NOT_FOUND", "请确认页面存在"), nil + } + + trimmedKey := strings.TrimSpace(idempotencyKey) + if !dryRun && trimmedKey != "" { + if rec, ok := getAppendSafeRecord(trimmedKey); ok { + payload := map[string]any{ + "action": "deduped", + "page": pageName, + "idempotency_key": trimmedKey, + "block_uuid": rec.BlockUUID, + "deduped": true, + "write_mode": currentLLMWriteMode(), + "message": "duplicate idempotency key detected, skipped", + "created_at": rec.CreatedAt.Format(time.RFC3339), + } + return envelopeSuccess(start, payload, withCapabilityUsed("logseq.Editor.getPage"), withHints("幂等键已命中,未重复写入")), nil + } + } + + if dryRun { + payload := map[string]any{ + "action": "dry-run", + "page": pageName, + "idempotency_key": trimmedKey, + "content_preview": truncate(content, 240), + "dry_run": true, + "write_mode": currentLLMWriteMode(), + "message": "append preview only, nothing written", + } + return envelopeSuccess(start, payload, withCapabilityUsed("logseq.Editor.getPage"), withHints("dry-run 模式未执行写入")), nil + } + + block, err := client.AppendBlockInPage(ctx, pageName, content) + if err != nil { + return envelopeFailure(start, err, "UPSTREAM_ERROR", "写入失败,请检查页面权限和 API 状态", withCapabilityUsed("logseq.Editor.appendBlockInPage")), nil + } + + if trimmedKey != "" && block != nil { + setAppendSafeRecord(trimmedKey, appendSafeRecord{ + BlockUUID: strings.TrimSpace(block.UUID), + Page: pageName, + ContentPreview: truncate(content, 240), + CreatedAt: time.Now(), + }) + } + + payload := map[string]any{ + "action": "appended", + "page": pageName, + "idempotency_key": trimmedKey, + "block_uuid": block.UUID, + "deduped": false, + "write_mode": currentLLMWriteMode(), + "message": "block appended", + } + return envelopeSuccess(start, payload, withCapabilityUsed("logseq.Editor.getPage", "logseq.Editor.appendBlockInPage")), nil + }), + } +} + +func pageCurrentCmd() *redant.Command { + return &redant.Command{ + Use: "current", + Short: "Get currently focused page", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*logseq.Page, error) { + client := NewClient() + page, err := client.GetCurrentPage(ctx) + if err != nil { + return nil, err + } + if page == nil { + return nil, fmt.Errorf("no current page") + } + return page, nil + }), + } +} + +func pageCurrentTreeCmd() *redant.Command { + return &redant.Command{ + Use: "current-tree", + Short: "Get block tree of currently focused page", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) ([]logseq.Block, error) { + client := NewClient() + return client.GetCurrentPageBlocksTree(ctx) + }), + } +} + +func pageListCmd() *redant.Command { + return &redant.Command{ + Use: "list", + Short: "List all pages", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) ([]logseq.Page, error) { + client := NewClient() + return client.GetAllPages(ctx) + }), + } +} + +func pageGetCmd() *redant.Command { + var withBlocks bool + 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", + Shorthand: "b", + Description: "Include page block tree", + Value: redant.BoolOf(&withBlocks), + }, + }, + Handler: func(ctx context.Context, inv *redant.Invocation) error { + 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 enc.Encode(blocks) + } + + page, err := client.GetPage(ctx, name) + if err != nil { + return err + } + if page == nil { + return fmt.Errorf("page '%s' not found", name) + } + return enc.Encode(page) + }, + } +} + +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() + 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 + }), + } +} + +func pageJournalCmd() *redant.Command { + return &redant.Command{ + Use: "journal [date]", + Short: "Create journal page for date (default: today, format YYYY-MM-DD)", + Args: redant.ArgSet{ + {Name: "date", Required: false, Value: redant.StringOf(new(string)), Description: "Date string in YYYY-MM-DD"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*logseq.Page, error) { + date := time.Now().Format("2006-01-02") + if len(inv.Args) > 0 && strings.TrimSpace(inv.Args[0]) != "" { + date = inv.Args[0] + } + + client := NewClient() + return client.CreateJournalPage(ctx, date) + }), + } +} + +func pageDeleteCmd() *redant.Command { + return &redant.Command{ + Use: "delete ", + Short: "Delete a page", + 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 StatusResult{}, err + } + return StatusResult{OK: true, Message: "deleted page: " + inv.Args[0]}, nil + }), + } +} + +func pageRenameCmd() *redant.Command { + return &redant.Command{ + Use: "rename ", + Short: "Rename a page", + 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 StatusResult{}, err + } + return StatusResult{OK: true, Message: "renamed: " + inv.Args[0] + " -> " + inv.Args[1]}, nil + }), + } +} + +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"}) + } + + enc := json.NewEncoder(inv.Stdout) + enc.SetIndent("", " ") + + // Prefer official API when available. + props, err := client.GetPageProperties(ctx, name) + if err == nil { + if props == nil { + return enc.Encode(map[string]any{}) + } + return enc.Encode(props) + } + + // Backward compatibility fallback for versions without getPageProperties. + if !strings.Contains(strings.ToLower(err.Error()), "methodnotexist") { + return err + } + + page, getErr := client.GetPage(ctx, name) + if getErr != nil { + return getErr + } + if page == nil || page.Properties == nil { + return enc.Encode(map[string]any{}) + } + return enc.Encode(page.Properties) + }, + } +} diff --git a/cmds/page_context_test.go b/cmds/page_context_test.go new file mode 100644 index 0000000..4eaabf0 --- /dev/null +++ b/cmds/page_context_test.go @@ -0,0 +1,99 @@ +package cmds + +import ( + "testing" + + "github.com/pubgo/logseq-cli/pkg/logseq" +) + +func TestPageContextBuilderRespectsMaxDepth(t *testing.T) { + blocks := []logseq.Block{ + { + UUID: "A", + Children: []logseq.Block{ + { + UUID: "B", + Children: []logseq.Block{ + {UUID: "C"}, + }, + }, + }, + }, + {UUID: "D"}, + } + + builder := &pageContextBuilder{maxBlocks: 20, maxDepth: 1} + out := builder.buildBlocks(blocks, 1) + + if got, want := len(out), 2; got != want { + t.Fatalf("len(out)=%d, want %d", got, want) + } + if got, want := builder.seen, 2; got != want { + t.Fatalf("seen=%d, want %d", got, want) + } + if got, want := builder.clippedByDepth, 2; got != want { + t.Fatalf("clippedByDepth=%d, want %d", got, want) + } + if got := builder.clippedByLimit; got != 0 { + t.Fatalf("clippedByLimit=%d, want 0", got) + } + + if got := len(out[0].Children); got != 0 { + t.Fatalf("root children should be clipped by depth, got %d", got) + } +} + +func TestPageContextBuilderRespectsMaxBlocks(t *testing.T) { + blocks := []logseq.Block{ + { + UUID: "A", + Children: []logseq.Block{ + {UUID: "B"}, + }, + }, + {UUID: "C"}, + } + + builder := &pageContextBuilder{maxBlocks: 2, maxDepth: 10} + out := builder.buildBlocks(blocks, 1) + + if got, want := builder.seen, 2; got != want { + t.Fatalf("seen=%d, want %d", got, want) + } + if got, want := builder.clippedByLimit, 1; got != want { + t.Fatalf("clippedByLimit=%d, want %d", got, want) + } + if got := builder.clippedByDepth; got != 0 { + t.Fatalf("clippedByDepth=%d, want 0", got) + } + + if got, want := len(out), 1; got != want { + t.Fatalf("len(out)=%d, want %d", got, want) + } + if out[0].UUID != "A" { + t.Fatalf("first uuid=%q, want A", out[0].UUID) + } + if got, want := len(out[0].Children), 1; got != want { + t.Fatalf("len(out[0].Children)=%d, want %d", got, want) + } + if out[0].Children[0].UUID != "B" { + t.Fatalf("child uuid=%q, want B", out[0].Children[0].UUID) + } +} + +func TestCountBlockSubtreeValue(t *testing.T) { + root := logseq.Block{ + UUID: "A", + Children: []logseq.Block{ + {UUID: "B"}, + { + UUID: "C", + Children: []logseq.Block{{UUID: "D"}}, + }, + }, + } + + if got, want := countBlockSubtreeValue(root), 4; got != want { + t.Fatalf("countBlockSubtreeValue=%d, want %d", got, want) + } +} diff --git a/cmds/property.go b/cmds/property.go new file mode 100644 index 0000000..4d87c40 --- /dev/null +++ b/cmds/property.go @@ -0,0 +1,72 @@ +package cmds + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/pubgo/redant" +) + +func PropertyCmd() *redant.Command { + return &redant.Command{ + Use: "property", + Short: "Property schema operations", + Children: []*redant.Command{ + { + Use: "list", + Short: "List all properties", + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (any, error) { + client := NewClient() + return client.GetAllProperties(ctx) + }), + }, + { + Use: "get ", + Short: "Get property by key", + Args: redant.ArgSet{ + {Name: "key", Required: true, Value: redant.StringOf(new(string)), Description: "Property key"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (any, error) { + client := NewClient() + return client.GetProperty(ctx, inv.Args[0]) + }), + }, + { + Use: "upsert [schema-json]", + Short: "Create or update property schema", + Long: "schema-json should be a JSON object, e.g. '{\"type\":\"number\",\"cardinality\":\"one\"}'", + Args: redant.ArgSet{ + {Name: "key", Required: true, Value: redant.StringOf(new(string)), Description: "Property key"}, + {Name: "schema-json", Required: false, Value: redant.StringOf(new(string)), Description: "Schema JSON object"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (any, error) { + client := NewClient() + + var schema map[string]any + if len(inv.Args) > 1 && inv.Args[1] != "" { + if err := json.Unmarshal([]byte(inv.Args[1]), &schema); err != nil { + return nil, fmt.Errorf("invalid schema-json: %w", err) + } + } + + return client.UpsertProperty(ctx, inv.Args[0], schema, nil) + }), + }, + { + Use: "remove ", + Short: "Remove property schema", + Args: redant.ArgSet{ + {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.RemoveProperty(ctx, inv.Args[0]); err != nil { + return StatusResult{}, err + } + return StatusResult{OK: true, Message: "property removed"}, nil + }), + }, + }, + } +} diff --git a/cmds/search_notes.go b/cmds/search_notes.go new file mode 100644 index 0000000..a109897 --- /dev/null +++ b/cmds/search_notes.go @@ -0,0 +1,265 @@ +package cmds + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "github.com/pubgo/logseq-cli/pkg/logseq" + "github.com/pubgo/redant" +) + +type searchNotesItem struct { + Type string `json:"type"` + Title string `json:"title,omitempty"` + Snippet string `json:"snippet,omitempty"` + Page string `json:"page,omitempty"` + UUID string `json:"uuid,omitempty"` +} + +type searchNotesResult struct { + Query string `json:"query"` + Tag string `json:"tag,omitempty"` + Limit int `json:"limit"` + Cursor string `json:"cursor,omitempty"` + NextCursor string `json:"next_cursor,omitempty"` + HasMore bool `json:"has_more"` + Total int `json:"total"` + Items []searchNotesItem `json:"items"` +} + +func SearchNotesCmd() *redant.Command { + var ( + tag string + limitRaw string + cursor string + include string + ) + + return &redant.Command{ + Use: "search-notes ", + Short: "LLM-friendly search with pagination/cursor", + Args: redant.ArgSet{ + {Name: "query", Required: true, Value: redant.StringOf(new(string)), Description: "Search query"}, + }, + Options: redant.OptionSet{ + { + Flag: "tag", + Description: "Optional tag filter", + Value: redant.StringOf(&tag), + }, + { + Flag: "limit", + Description: "Page size (1-200, default 20)", + Default: "20", + Value: redant.StringOf(&limitRaw), + }, + { + Flag: "cursor", + Description: "Opaque cursor (current implementation uses offset integer)", + Value: redant.StringOf(&cursor), + }, + { + Flag: "include", + Description: "Comma-separated types: pages,blocks,files (default pages,blocks)", + Default: "pages,blocks", + Value: redant.StringOf(&include), + }, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*llmEnvelope, error) { + start := time.Now() + q := strings.TrimSpace(inv.Args[0]) + if q == "" { + return envelopeFailure(start, fmt.Errorf("query is required"), "BAD_REQUEST", "请提供非空 query"), nil + } + + limit := 20 + if strings.TrimSpace(limitRaw) != "" { + v, convErr := strconv.Atoi(strings.TrimSpace(limitRaw)) + if convErr != nil { + return envelopeFailure(start, fmt.Errorf("invalid limit: %w", convErr), "BAD_REQUEST", "limit 需要是数字"), nil + } + limit = v + } + + if limit <= 0 { + limit = 20 + } + if limit > 200 { + limit = 200 + } + + offset, err := parseCursorOffset(cursor) + if err != nil { + return envelopeFailure(start, fmt.Errorf("invalid cursor: %w", err), "BAD_REQUEST", "cursor 需要是非负整数"), nil + } + + includeSet := parseIncludeSet(include) + if len(includeSet) == 0 { + includeSet = map[string]bool{"pages": true, "blocks": true} + } + + client := NewClient() + res, err := client.Search(ctx, q) + if err != nil { + return envelopeFailure(start, err, "UPSTREAM_ERROR", "可先执行 capabilities get 确认 search 可用性", withCapabilityUsed("logseq.App.search")), nil + } + if res == nil { + res = &logseq.SearchResult{} + } + + items := normalizeSearchItems(res, includeSet) + if strings.TrimSpace(tag) != "" { + items = filterSearchItemsByTag(items, strings.TrimSpace(tag)) + } + + total := len(items) + paged, nextCursor, hasMore := paginateSearchItems(items, offset, limit) + + out := &searchNotesResult{ + Query: q, + Tag: strings.TrimSpace(tag), + Limit: limit, + Cursor: strings.TrimSpace(cursor), + NextCursor: nextCursor, + HasMore: hasMore, + Total: total, + Items: paged, + } + + hints := make([]string, 0, 2) + + if len(res.Files) > 0 && !includeSet["files"] { + hints = append(hints, "files 结果已被 include 过滤") + } + if strings.TrimSpace(tag) != "" { + hints = append(hints, "tag 过滤基于结果文本匹配(page/title/snippet)") + } + + return envelopeSuccess( + start, + out, + withCapabilityUsed("logseq.App.search"), + withPageMeta(nextCursor, hasMore), + withHints(hints...), + ), nil + }), + } +} + +func parseCursorOffset(cursor string) (int, error) { + c := strings.TrimSpace(cursor) + if c == "" { + return 0, nil + } + o, err := strconv.Atoi(c) + if err != nil { + return 0, err + } + if o < 0 { + return 0, fmt.Errorf("offset must be >= 0") + } + return o, nil +} + +func parseIncludeSet(include string) map[string]bool { + set := map[string]bool{} + for _, p := range strings.Split(strings.ToLower(include), ",") { + k := strings.TrimSpace(p) + if k == "pages" || k == "blocks" || k == "files" { + set[k] = true + } + } + return set +} + +func normalizeSearchItems(res *logseq.SearchResult, include map[string]bool) []searchNotesItem { + items := make([]searchNotesItem, 0, len(res.Pages)+len(res.Blocks)+len(res.Files)) + + if include["pages"] { + pages := append([]string(nil), res.Pages...) + sort.Strings(pages) + for _, p := range pages { + t := strings.TrimSpace(p) + if t == "" { + continue + } + items = append(items, searchNotesItem{Type: "page", Title: t}) + } + } + + if include["blocks"] { + for _, b := range res.Blocks { + content := strings.TrimSpace(b.Content) + page := strings.TrimSpace(b.Page) + title := content + if title == "" { + title = "(empty block)" + } + items = append(items, searchNotesItem{ + Type: "block", + Title: truncate(title, 120), + Snippet: truncate(content, 500), + Page: page, + UUID: strings.TrimSpace(b.UUID), + }) + } + } + + if include["files"] { + files := append([]string(nil), res.Files...) + sort.Strings(files) + for _, f := range files { + t := strings.TrimSpace(f) + if t == "" { + continue + } + items = append(items, searchNotesItem{Type: "file", Title: t}) + } + } + + return items +} + +func filterSearchItemsByTag(items []searchNotesItem, tag string) []searchNotesItem { + needle := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(tag, "#"))) + if needle == "" { + return items + } + + out := make([]searchNotesItem, 0, len(items)) + for _, it := range items { + hay := strings.ToLower(it.Title + "\n" + it.Snippet + "\n" + it.Page) + if strings.Contains(hay, "#"+needle) || strings.Contains(hay, "[["+needle+"]]") || strings.Contains(hay, needle) { + out = append(out, it) + } + } + return out +} + +func paginateSearchItems(items []searchNotesItem, offset, limit int) ([]searchNotesItem, string, bool) { + if offset >= len(items) { + return []searchNotesItem{}, "", false + } + end := offset + limit + if end > len(items) { + end = len(items) + } + paged := items[offset:end] + hasMore := end < len(items) + if !hasMore { + return paged, "", false + } + return paged, strconv.Itoa(end), true +} + +func truncate(s string, n int) string { + v := strings.TrimSpace(s) + if n <= 0 || len(v) <= n { + return v + } + return v[:n] + "..." +} diff --git a/cmds/search_notes_test.go b/cmds/search_notes_test.go new file mode 100644 index 0000000..aa61479 --- /dev/null +++ b/cmds/search_notes_test.go @@ -0,0 +1,224 @@ +package cmds + +import ( + "testing" + + "github.com/pubgo/logseq-cli/pkg/logseq" +) + +func TestParseCursorOffset(t *testing.T) { + tests := []struct { + input string + want int + wantErr bool + }{ + {"", 0, false}, + {" ", 0, false}, + {"0", 0, false}, + {"10", 10, false}, + {"200", 200, false}, + {"-1", 0, true}, + {"abc", 0, true}, + } + + for _, tt := range tests { + t.Run("cursor="+tt.input, func(t *testing.T) { + got, err := parseCursorOffset(tt.input) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("parseCursorOffset(%q)=%d, want %d", tt.input, got, tt.want) + } + }) + } +} + +func TestParseIncludeSet(t *testing.T) { + tests := []struct { + input string + want map[string]bool + }{ + {"", map[string]bool{}}, + {"pages", map[string]bool{"pages": true}}, + {"pages,blocks", map[string]bool{"pages": true, "blocks": true}}, + {"PAGES,BLOCKS,FILES", map[string]bool{"pages": true, "blocks": true, "files": true}}, + {" pages , blocks ", map[string]bool{"pages": true, "blocks": true}}, + {"pages,invalid,blocks", map[string]bool{"pages": true, "blocks": true}}, + } + + for _, tt := range tests { + t.Run("include="+tt.input, func(t *testing.T) { + got := parseIncludeSet(tt.input) + if len(got) != len(tt.want) { + t.Errorf("parseIncludeSet(%q)=%v, want %v", tt.input, got, tt.want) + return + } + for k := range tt.want { + if !got[k] { + t.Errorf("missing key %q", k) + } + } + }) + } +} + +func TestPaginateSearchItems(t *testing.T) { + items := make([]searchNotesItem, 10) + for i := range items { + items[i] = searchNotesItem{Type: "page", Title: "p" + string(rune('0'+i))} + } + + // First page + paged, cursor, hasMore := paginateSearchItems(items, 0, 3) + if len(paged) != 3 { + t.Errorf("len=%d, want 3", len(paged)) + } + if cursor != "3" { + t.Errorf("cursor=%q, want %q", cursor, "3") + } + if !hasMore { + t.Error("expected hasMore=true") + } + + // Middle page + paged, cursor, hasMore = paginateSearchItems(items, 3, 3) + if len(paged) != 3 { + t.Errorf("len=%d, want 3", len(paged)) + } + if cursor != "6" { + t.Errorf("cursor=%q, want %q", cursor, "6") + } + if !hasMore { + t.Error("expected hasMore=true") + } + + // Last page (partial) + paged, cursor, hasMore = paginateSearchItems(items, 8, 3) + if len(paged) != 2 { + t.Errorf("len=%d, want 2", len(paged)) + } + if cursor != "" { + t.Errorf("cursor=%q, want empty", cursor) + } + if hasMore { + t.Error("expected hasMore=false") + } + + // Offset beyond items + paged, _, hasMore = paginateSearchItems(items, 100, 3) + if len(paged) != 0 { + t.Errorf("len=%d, want 0", len(paged)) + } + if hasMore { + t.Error("expected hasMore=false") + } + + // Exact fit + paged, cursor, hasMore = paginateSearchItems(items, 0, 10) + if len(paged) != 10 { + t.Errorf("len=%d, want 10", len(paged)) + } + if cursor != "" { + t.Errorf("cursor=%q, want empty", cursor) + } + if hasMore { + t.Error("expected hasMore=false") + } +} + +func TestFilterSearchItemsByTag(t *testing.T) { + items := []searchNotesItem{ + {Type: "page", Title: "My #golang notes"}, + {Type: "block", Title: "Some block", Snippet: "about [[python]]"}, + {Type: "page", Title: "Unrelated page"}, + {Type: "block", Title: "golang tips", Page: "golang"}, + } + + // Filter by "golang" + filtered := filterSearchItemsByTag(items, "golang") + if len(filtered) != 2 { + t.Errorf("len=%d, want 2", len(filtered)) + } + + // Filter with # prefix + filtered = filterSearchItemsByTag(items, "#python") + if len(filtered) != 1 { + t.Errorf("len=%d, want 1", len(filtered)) + } + + // Empty tag returns all + filtered = filterSearchItemsByTag(items, "") + if len(filtered) != len(items) { + t.Errorf("len=%d, want %d", len(filtered), len(items)) + } +} + +func TestNormalizeSearchItems(t *testing.T) { + res := &logseq.SearchResult{ + Pages: []string{"Page A", "Page B"}, + Blocks: []logseq.SearchBlock{{UUID: "u1", Content: "block content", Page: "pg"}}, + Files: []string{"file1.md"}, + } + + // pages+blocks only + items := normalizeSearchItems(res, map[string]bool{"pages": true, "blocks": true}) + pageCount, blockCount, fileCount := 0, 0, 0 + for _, it := range items { + switch it.Type { + case "page": + pageCount++ + case "block": + blockCount++ + case "file": + fileCount++ + } + } + if pageCount != 2 { + t.Errorf("pageCount=%d, want 2", pageCount) + } + if blockCount != 1 { + t.Errorf("blockCount=%d, want 1", blockCount) + } + if fileCount != 0 { + t.Error("files should be excluded") + } + + // include files + items = normalizeSearchItems(res, map[string]bool{"files": true}) + for _, it := range items { + if it.Type != "file" { + t.Errorf("unexpected type %q", it.Type) + } + } + if len(items) != 1 { + t.Errorf("len=%d, want 1", len(items)) + } +} + +func TestTruncate(t *testing.T) { + tests := []struct { + s string + n int + want string + }{ + {"hello", 10, "hello"}, + {"hello world", 5, "hello..."}, + {"", 5, ""}, + {"abc", 0, "abc"}, + {" spaces ", 3, "spa..."}, + } + + for _, tt := range tests { + got := truncate(tt.s, tt.n) + if got != tt.want { + t.Errorf("truncate(%q, %d)=%q, want %q", tt.s, tt.n, got, tt.want) + } + } +} diff --git a/cmds/tag.go b/cmds/tag.go new file mode 100644 index 0000000..77fb408 --- /dev/null +++ b/cmds/tag.go @@ -0,0 +1,228 @@ +package cmds + +import ( + "context" + "fmt" + + "github.com/pubgo/logseq-cli/pkg/logseq" + "github.com/pubgo/redant" +) + +func TagCmd() *redant.Command { + return &redant.Command{ + Use: "tag", + Short: "Tag operations", + Children: []*redant.Command{ + tagListCmd(), + tagGetCmd(), + tagSearchCmd(), + tagCreateCmd(), + tagObjectsCmd(), + tagPropertyCmd(), + tagExtendsCmd(), + tagBlockCmd(), + }, + } +} + +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) + }), + } +} + +func tagGetCmd() *redant.Command { + return &redant.Command{ + Use: "get ", + Short: "Get tag by name or entity id", + Args: redant.ArgSet{ + {Name: "name-or-id", Required: true, Value: redant.StringOf(new(string)), Description: "Tag name or numeric entity id"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*logseq.Page, error) { + client := NewClient() + tag, err := client.GetTag(ctx, inv.Args[0]) + if err != nil { + return nil, err + } + if tag == nil { + return nil, fmt.Errorf("tag not found: %s", inv.Args[0]) + } + return tag, nil + }), + } +} + +func tagSearchCmd() *redant.Command { + return &redant.Command{ + Use: "search ", + Short: "Search tags by name", + Args: redant.ArgSet{ + {Name: "name", Required: true, Value: redant.StringOf(new(string)), Description: "Tag name to search"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) ([]logseq.Page, error) { + client := NewClient() + return client.GetTagsByName(ctx, inv.Args[0]) + }), + } +} + +func tagCreateCmd() *redant.Command { + var uuid string + return &redant.Command{ + Use: "create ", + Short: "Create a tag", + Args: redant.ArgSet{ + {Name: "name", Required: true, Value: redant.StringOf(new(string)), Description: "Tag name"}, + }, + Options: redant.OptionSet{ + { + Flag: "uuid", + Description: "Custom UUID for tag page", + Value: redant.StringOf(&uuid), + }, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (*logseq.Page, error) { + client := NewClient() + if uuid != "" { + return client.CreateTag(ctx, inv.Args[0], uuid) + } + return client.CreateTag(ctx, inv.Args[0]) + }), + } +} + +func tagObjectsCmd() *redant.Command { + return &redant.Command{ + Use: "objects ", + Short: "Get tag object blocks", + Args: redant.ArgSet{ + {Name: "name", Required: true, Value: redant.StringOf(new(string)), Description: "Tag name"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) ([]logseq.Block, error) { + client := NewClient() + return client.GetTagObjects(ctx, inv.Args[0]) + }), + } +} + +func tagPropertyCmd() *redant.Command { + return &redant.Command{ + Use: "property", + Short: "Tag property relation operations", + Children: []*redant.Command{ + { + Use: "add ", + Short: "Add property relation to tag", + Args: redant.ArgSet{ + {Name: "tag-id", Required: true, Value: redant.StringOf(new(string)), Description: "Tag id or name"}, + {Name: "property-id-or-name", Required: true, Value: redant.StringOf(new(string)), Description: "Property id or name"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { + client := NewClient() + if err := client.AddTagProperty(ctx, inv.Args[0], inv.Args[1]); err != nil { + return StatusResult{}, err + } + return StatusResult{OK: true, Message: "tag property relation added"}, nil + }), + }, + { + Use: "remove ", + Short: "Remove property relation from tag", + Args: redant.ArgSet{ + {Name: "tag-id", Required: true, Value: redant.StringOf(new(string)), Description: "Tag id or name"}, + {Name: "property-id-or-name", Required: true, Value: redant.StringOf(new(string)), Description: "Property id or name"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { + client := NewClient() + if err := client.RemoveTagProperty(ctx, inv.Args[0], inv.Args[1]); err != nil { + return StatusResult{}, err + } + return StatusResult{OK: true, Message: "tag property relation removed"}, nil + }), + }, + }, + } +} + +func tagExtendsCmd() *redant.Command { + return &redant.Command{ + Use: "extends", + Short: "Tag extends relation operations", + Children: []*redant.Command{ + { + Use: "add ", + Short: "Add parent tag relation", + Args: redant.ArgSet{ + {Name: "tag-id", Required: true, Value: redant.StringOf(new(string)), Description: "Tag id or name"}, + {Name: "parent-tag-id-or-name", Required: true, Value: redant.StringOf(new(string)), Description: "Parent tag id or name"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { + client := NewClient() + if err := client.AddTagExtends(ctx, inv.Args[0], inv.Args[1]); err != nil { + return StatusResult{}, err + } + return StatusResult{OK: true, Message: "tag extends relation added"}, nil + }), + }, + { + Use: "remove ", + Short: "Remove parent tag relation", + Args: redant.ArgSet{ + {Name: "tag-id", Required: true, Value: redant.StringOf(new(string)), Description: "Tag id or name"}, + {Name: "parent-tag-id-or-name", Required: true, Value: redant.StringOf(new(string)), Description: "Parent tag id or name"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { + client := NewClient() + if err := client.RemoveTagExtends(ctx, inv.Args[0], inv.Args[1]); err != nil { + return StatusResult{}, err + } + return StatusResult{OK: true, Message: "tag extends relation removed"}, nil + }), + }, + }, + } +} + +func tagBlockCmd() *redant.Command { + return &redant.Command{ + Use: "block", + Short: "Block tag relation operations", + Children: []*redant.Command{ + { + Use: "add ", + Short: "Add tag to block", + Args: redant.ArgSet{ + {Name: "block-id", Required: true, Value: redant.StringOf(new(string)), Description: "Block id or uuid"}, + {Name: "tag-id", Required: true, Value: redant.StringOf(new(string)), Description: "Tag id or name"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { + client := NewClient() + if err := client.AddBlockTag(ctx, inv.Args[0], inv.Args[1]); err != nil { + return StatusResult{}, err + } + return StatusResult{OK: true, Message: "block tag relation added"}, nil + }), + }, + { + Use: "remove ", + Short: "Remove tag from block", + Args: redant.ArgSet{ + {Name: "block-id", Required: true, Value: redant.StringOf(new(string)), Description: "Block id or uuid"}, + {Name: "tag-id", Required: true, Value: redant.StringOf(new(string)), Description: "Tag id or name"}, + }, + ResponseHandler: redant.Unary(func(ctx context.Context, inv *redant.Invocation) (StatusResult, error) { + client := NewClient() + if err := client.RemoveBlockTag(ctx, inv.Args[0], inv.Args[1]); err != nil { + return StatusResult{}, err + } + return StatusResult{OK: true, Message: "block tag relation removed"}, nil + }), + }, + }, + } +} diff --git a/cmds/webui.go b/cmds/webui.go new file mode 100644 index 0000000..ef994c3 --- /dev/null +++ b/cmds/webui.go @@ -0,0 +1,41 @@ +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", + Metadata: redant.InfraMetadata, + 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/docs/ANALYSIS.md b/docs/ANALYSIS.md new file mode 100644 index 0000000..77f7956 --- /dev/null +++ b/docs/ANALYSIS.md @@ -0,0 +1,439 @@ +# 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[], {sibling}]` | `null` | 批量插入 Block 树(当前 SDK 暴露 `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]` | `SearchResult` | 全文搜索(⚠️ 部分版本可能不可用) | + +### 2.3 DB 命名空间 (`logseq.DB.*`) + +| 方法 | 参数 | 返回 | 说明 | +| ----------------- | --------------------- | ----------------- | --------------------------------- | +| `datascriptQuery` | `[query, ...inputs?]` | `json.RawMessage` | 执行 Datalog 查询(原始 JSON) | +| `q` | `[dslQuery]` | `json.RawMessage` | 执行 Logseq DSL 查询(原始 JSON) | + +### 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"` + 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 入口(根命令与全局参数) +├── cmd/ +│ └── e2e/ # 独立 E2E 可执行程序 +├── cmds/ # CLI 子命令定义 +│ ├── cmds.go # 共享配置与客户端创建 +│ ├── page.go # page list/get/create/delete/rename/refs/namespace/properties +│ ├── tag.go # tag list +│ ├── block.go # block get/insert/update/remove/move/prepend/append/property/collapse +│ ├── graph.go # graph/query/search 命令 +│ └── webui.go # webui 命令入口 +├── internal/ +│ └── webui/ # WebUI 服务与静态页面 +├── 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 查询) +│ └── tags.go # 标签聚合逻辑(含多路径回退) +└── 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) (json.RawMessage, error) +func (c *Client) GetPagesFromNamespace(ctx context.Context, ns string) ([]Page, 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, 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 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 +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) (*SearchResult, error) +``` + +### 4.5 DB API 封装 + +```go +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) +``` + +--- + +## 5. CLI 命令设计(基于 redant 框架) + +### 5.1 全局 Flags + +| Flag | 环境变量 | 默认值 | 说明 | +| ----------------- | ------------------ | ----------- | -------------------------------------------------------- | +| `-t, --token` | `LOGSEQ_API_TOKEN` | — | API 认证 token(必须) | +| `--host` | `LOGSEQ_HOST` | `127.0.0.1` | Logseq 主机地址 | +| `-p, --port` | `LOGSEQ_PORT` | `12315` | Logseq 端口 | +| `--raw-envelope` | — | `false` | 输出结构化 NDJSON envelope | +| `--list-commands` | — | `false` | 列出全部命令(含子命令) | +| `--list-flags` | — | `false` | 列出全部参数 | +| `--list-format` | — | `text` | `--list-commands` / `--list-flags` 输出格式(text/json) | + +### 5.2 命令树 + +``` +logseq +├── page # 页面管理 +│ ├── list # 列出所有页面 +│ ├── get # 获取页面内容 +│ ├── create # 创建页面(支持 --content / stdin) +│ ├── delete # 删除页面 +│ ├── rename # 重命名页面 +│ ├── refs # 获取页面反向引用 +│ ├── namespace # 命名空间页面查询(支持 --tree) +│ └── properties # 页面属性读写 +├── block # Block 管理 +│ ├── get # 获取 Block +│ ├── insert # 插入 Block +│ ├── update # 更新 Block +│ ├── remove # 删除 Block +│ ├── move # 移动 Block(支持 --before) +│ ├── prepend # 页面头部插入 +│ ├── append # 页面尾部追加 +│ ├── property # Block 属性操作(get/set/remove) +│ └── collapse # Block 折叠/展开(--expand) +├── graph # 图谱信息 +│ ├── info # 当前图谱信息 +│ └── config # 用户配置 +├── query # 查询 +│ ├── datalog # Datascript/Datalog 查询 +│ └── dsl # Logseq DSL 查询 +├── search # 全文搜索 +├── tag # 标签管理 +│ └── list # 列出所有标签 +├── completion # shell 自动补全 +├── doc # 交互式命令文档站 +├── web # 可视化命令执行页面 +├── webui # 简易 Logseq 操作台(页面/块/搜索/查询/过滤/连接诊断) +├── mcp # MCP 集成命令 +│ ├── list # 列出 MCP 工具元数据 +│ └── serve # 启动 MCP 服务 +└── llms-txt # LLM 友好文档导出 +``` + +### 5.3 redant 框架核心用法 + +```go +package main + +import ( + "context" + "fmt" + "os" + + "github.com/pubgo/redant" +) + +func main() { + var token string + var host string + var port string + + root := redant.Command{ + Use: "logseq", + Short: "Logseq CLI - 命令行操作 Logseq 图谱", + Options: redant.OptionSet{ + { + Flag: "token", + Description: "Logseq API token", + Shorthand: "t", + Envs: []string{"LOGSEQ_API_TOKEN"}, + Required: true, + Value: redant.StringOf(&token), + Inherit: true, + }, + { + Flag: "host", + Description: "Logseq host", + Envs: []string{"LOGSEQ_HOST"}, + Default: "127.0.0.1", + Value: redant.StringOf(&host), + Inherit: true, + }, + { + Flag: "port", + Shorthand: "p", + Description: "Logseq port", + Envs: []string{"LOGSEQ_PORT"}, + Default: "12315", + Value: redant.StringOf(&port), + Inherit: true, + }, + }, + Children: []*redant.Command{ + pageCmd(), // page 子命令 + blockCmd(), // block 子命令 + graphCmd(), // graph 子命令 + queryCmd(), // query 子命令 + searchCmd(), // search 命令 + tagCmd(), // tag 子命令 + webuiCmd(), // webui 命令 + 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) + } +} +``` + +--- + +## 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. **页面属性获取**: `logseq.Editor.getPageProperties` 在常见版本中不可用;CLI 当前通过 `getPage` 读取页面对象中的 `properties`,并通过 `setPageProperties` 写入。 + +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. **标签聚合与过滤策略**: 某些图谱中 `:block/tags` 结果偏少,需回退 `:block/refs` 与页面属性聚合;WebUI 的标签过滤与搜索标签过滤已采用多路径匹配。 + +7. **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. 增补与维护中文文档(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..2622ff9 --- /dev/null +++ b/docs/COMMANDS.md @@ -0,0 +1,644 @@ +# logseq-cli 命令参考(中文) + +本文档提供 `logseq` 命令的中文速查与参数说明,内容基于当前代码与命令帮助输出整理。 + +## 根命令 + +- 命令:`logseq` +- 描述:Logseq CLI - command line tool for Logseq + +--- + +## capabilities:LLM 能力探测 + +### `logseq capabilities get` + +探测当前运行时对 LLM/MCP 关键链路的可用性,返回能力矩阵与建议提示。 + +返回内容包含(节选): + +- API:`datascript_query`、`dsl_query`、`search`、`tags_list`、`state_store` +- 图谱:`db_graph`、`:block/tags` 可用性、`:block/refs` 可用性 +- 写策略:`LOGSEQ_LLM_WRITE_MODE`、删除确认策略、最大结果数 + +建议在 LLM 工作流开始时先调用一次此命令,用于选择执行路径与回退策略。 + +### 全局参数 + +| 参数 | 类型 | 默认值 | 环境变量 | 说明 | +| ----------------- | ------------------- | ----------- | ------------------ | ------------------------------------------- | +| `-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 | +| `--raw-envelope` | bool | `false` | - | 输出结构化 NDJSON envelope | +| `--list-commands` | bool | `false` | - | 列出全部命令(含子命令) | +| `--list-flags` | bool | `false` | - | 列出全部参数 | +| `--list-format` | enum(`text`,`json`) | `text` | - | `--list-commands` / `--list-flags` 输出格式 | +| `-h, --help` | bool | `false` | - | 显示帮助 | + +--- + +## page:页面管理 + +### `logseq page list` + +列出所有页面。 + +### `logseq page current` + +获取当前焦点页面。 + +### `logseq page current-tree` + +获取当前焦点页面的块树。 + +### `logseq page get ` + +获取页面信息。 + +参数: + +- `name`(string,必填):页面名称 + +选项: + +- `-b, --blocks`(bool):包含页面块树 + +### `logseq page get-context ` + +获取面向 LLM 的页面上下文视图(带块树裁剪与统计信息)。 + +参数: + +- `name`(string,必填):页面名称 + +选项: + +- `--max-blocks`(int,默认 `200`):最多返回的块数(同时受 `LOGSEQ_LLM_MAX_RESULTS` 上限约束) +- `--max-depth`(int,默认 `6`):块树最大深度 +- `--include-properties`(bool,默认 `true`):是否返回页面属性 + +返回字段(`data` 节选): + +- `page`:页面基础信息 +- `properties`:页面属性(当 `include-properties=true` 时) +- `outline_blocks[]`:裁剪后的块树(`uuid/content/marker/priority/level/properties/children`) +- `limits`:本次生效的裁剪参数 +- `stats`:`returned_blocks / clipped_by_depth / clipped_by_limit` + +### `logseq page create ` + +创建页面。 + +参数: + +- `name`(string,必填):页面名称 + +选项: + +- `-c, --content`(string):初始块内容;传 `-` 时从 stdin 读取 + +### `logseq page append-safe ` + +安全追加块(面向 LLM 的受控写入入口)。 + +参数: + +- `name`(string,必填):页面名称 +- `content`(string,必填):块内容(传 `-` 时从 stdin 读取) + +选项: + +- `--dry-run`(bool):仅预览,不写入 +- `--confirm`(bool):在 `LOGSEQ_LLM_WRITE_MODE=confirm` 时必需 +- `--idempotency-key`(string):幂等键,避免重复写入 + +### `logseq page journal [date]` + +按日期创建 Journal 页面。 + +参数: + +- `date`(string,可选):日期字符串,格式 `YYYY-MM-DD`;不传则使用今天 + +### `logseq page delete ` + +删除页面。 + +参数: + +- `name`(string,必填):页面名称 + +### `logseq page rename ` + +重命名页面。 + +参数: + +- `old-name`(string,必填):旧名称 +- `new-name`(string,必填):新名称 + +### `logseq page refs ` + +获取页面的反向引用(backlinks / linked references)。 + +参数: + +- `name`(string,必填):页面名称 + +### `logseq page namespace ` + +列出某命名空间下的页面。 + +参数: + +- `name`(string,必填):命名空间前缀 + +选项: + +- `--tree`(bool):以树结构返回 + +### `logseq page properties [key=value ...]` + +读取或写入页面属性。 + +- 不传 `key=value`:返回当前页面属性 +- 传入一个或多个 `key=value`:批量写入属性 + +参数: + +- `name`(string,必填):页面名称 + +--- + +## block:块管理 + +### `logseq block get ` + +通过 UUID 获取块。 + +参数: + +- `uuid`(string,必填):块 UUID + +选项: + +- `-c, --children`(bool):包含子块 + +### `logseq block current` + +获取当前焦点块。 + +### `logseq block selected` + +获取当前选中的块列表。 + +### `logseq block clear-selected` + +清空当前选中块。 + +### `logseq block new-uuid` + +生成新的块 UUID(`logseq.Editor.newBlockUUID`)。 + +### `logseq block prev-sibling ` + +获取前一个同级块。 + +参数: + +- `uuid`(string,必填):块 UUID + +### `logseq block next-sibling ` + +获取后一个同级块。 + +参数: + +- `uuid`(string,必填):块 UUID + +### `logseq block insert ` + +向目标块插入新块。 + +参数: + +- `target-uuid`(string,必填):目标块 UUID +- `content`(string,必填):块内容(传 `-` 时从 stdin 读取) + +选项: + +- `-s, --sibling`(bool):作为同级块插入(默认作为子块) + +### `logseq block insert-batch ` + +批量插入块树。 + +参数: + +- `target-uuid`(string,必填):目标块 UUID +- `blocks-json`(string,必填):JSON 数组(传 `-` 时从 stdin 读取) + +选项: + +- `-s, --sibling`(bool):作为同级块插入(默认作为子块) + +示例: + +- `echo '[{"content":"- item1"},{"content":"- item2"}]' | logseq block insert-batch -` + +### `logseq block update ` + +更新块内容。 + +参数: + +- `uuid`(string,必填):块 UUID +- `content`(string,必填):新内容(传 `-` 时从 stdin 读取) + +### `logseq block remove ` + +删除块。 + +参数: + +- `uuid`(string,必填):块 UUID + +### `logseq block delete-safe ` + +安全删除块(先预览影响,再确认执行)。 + +参数: + +- `uuid`(string,必填):块 UUID + +选项: + +- `--dry-run`(bool):仅预览,不删除 +- `--confirm`(bool):危险删除确认(默认策略下必需) + +### `logseq block move ` + +移动块。 + +参数: + +- `src-uuid`(string,必填):源块 UUID +- `target-uuid`(string,必填):目标块 UUID + +选项: + +- `--before`(bool):移动到目标块之前(默认是之后) + +### `logseq block prepend ` + +在页面顶部插入块。 + +参数: + +- `page`(string,必填):页面名称 +- `content`(string,必填):块内容(传 `-` 时从 stdin 读取) + +### `logseq block append ` + +在页面底部追加块。 + +参数: + +- `page`(string,必填):页面名称 +- `content`(string,必填):块内容(传 `-` 时从 stdin 读取) + +### `logseq block property` + +块属性操作分组。 + +- `logseq block property get `:获取块全部属性 +- `logseq block property set `:设置块属性 +- `logseq block property remove `:删除块属性 + +> `set` 的 `value` 会尝试按 JSON 解析;解析成功则按结构化值写入。 + +### `logseq block collapse ` + +折叠/展开块。 + +参数: + +- `uuid`(string,必填):块 UUID + +选项: + +- `-e, --expand`(bool):展开而不是折叠 + +--- + +## graph:图谱信息 + +### `logseq graph info` + +获取当前图谱信息。 + +### `logseq graph config` + +获取用户配置(`logseq.App.getUserConfigs`)。 + +### `logseq graph app-info` + +获取应用信息(`logseq.App.getInfo`)。 + +### `logseq graph user-info` + +获取用户信息(`logseq.App.getUserInfo`)。 + +### `logseq graph db-graph` + +检查当前图谱是否为 DB Graph(`logseq.App.checkCurrentIsDbGraph`)。 + +### `logseq graph graph-config` + +获取当前图谱配置(`logseq.App.getCurrentGraphConfigs`)。 + +### `logseq graph favorites` + +获取当前图谱收藏(`logseq.App.getCurrentGraphFavorites`)。 + +### `logseq graph recent` + +获取当前图谱最近访问项(`logseq.App.getCurrentGraphRecent`)。 + +### `logseq graph templates` + +获取当前图谱模板集合(`logseq.App.getCurrentGraphTemplates`)。 + +### `logseq graph state ` + +获取应用状态存储中的键值(`logseq.App.getStateFromStore`)。 + +参数: + +- `key`(string,必填):状态键名 + +### `logseq graph state-set ` + +设置应用状态存储中的键值(`logseq.App.setStateFromStore`)。 + +参数: + +- `key`(string,必填):状态键名 +- `value`(string,必填):状态值(优先按 JSON 字面量解析,失败则按字符串写入) + +--- + +## query:查询 + +### `logseq query datalog ` + +执行 Datalog 查询。 + +参数: + +- `query`(string,必填):Datalog 查询字符串 + +### `logseq query dsl ` + +执行 Logseq DSL 查询。 + +参数: + +- `query`(string,必填):DSL 查询字符串 + +--- + +## search:全文搜索 + +### `logseq search ` + +参数: + +- `query`(string,必填):搜索关键词 + +--- + +## search-notes:LLM 友好检索(分页) + +### `logseq search-notes ` + +面向 LLM 的结构化检索输出,支持分页与游标。 + +参数: + +- `query`(string,必填):搜索关键词 + +选项: + +- `--tag`(string,可选):标签过滤(文本匹配) +- `--limit`(int,默认 `20`,最大 `200`):分页大小 +- `--cursor`(string,可选):游标(当前实现为 offset 字符串) +- `--include`(string,默认 `pages,blocks`):返回类型,支持 `pages,blocks,files` + +返回字段(节选): + +- `items[]`:统一条目(`type/title/snippet/page/uuid`) +- `next_cursor` / `has_more` +- `total` + +--- + +## tag:标签管理 + +### `logseq tag list` + +列出当前图谱中的全部标签。 + +### `logseq tag get ` + +按标签名或实体 id 获取标签信息。 + +参数: + +- `name-or-id`(string,必填):标签名或数字 id + +### `logseq tag search ` + +按名称搜索标签(`logseq.Editor.getTagsByName`)。 + +参数: + +- `name`(string,必填):标签名关键词 + +### `logseq tag create ` + +创建标签(`logseq.Editor.createTag`)。 + +参数: + +- `name`(string,必填):标签名 + +选项: + +- `--uuid`(string):自定义标签页面 UUID + +### `logseq tag objects ` + +获取标签对象块(`logseq.Editor.getTagObjects`)。 + +参数: + +- `name`(string,必填):标签名 + +### `logseq tag property add ` + +给标签添加属性关系。 + +### `logseq tag property remove ` + +移除标签属性关系。 + +### `logseq tag extends add ` + +给标签添加父标签关系。 + +### `logseq tag extends remove ` + +移除标签父标签关系。 + +### `logseq tag block add ` + +给块添加标签。 + +### `logseq tag block remove ` + +从块移除标签。 + +--- + +## property:属性 schema 管理 + +### `logseq property list` + +列出全部属性实体(`logseq.Editor.getAllProperties`)。 + +### `logseq property get ` + +按 key 获取属性实体(`logseq.Editor.getProperty`)。 + +参数: + +- `key`(string,必填):属性 key + +### `logseq property upsert [schema-json]` + +创建或更新属性 schema(`logseq.Editor.upsertProperty`)。 + +参数: + +- `key`(string,必填):属性 key +- `schema-json`(string,可选):JSON 对象,如 `'{"type":"number","cardinality":"one"}'` + +### `logseq property remove ` + +删除属性 schema(`logseq.Editor.removeProperty`)。 + +参数: + +- `key`(string,必填):属性 key + +--- + +## 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`):启动后自动打开浏览器 + +--- + +## 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)` +- `GET /api/search`:支持 `q`,并可用 `tag` 做附加过滤 + +--- + +## 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 输出或 `--raw-envelope`。 +- 若遇到 API 版本差异导致的方法不可用,先用小范围命令验证(如 `graph info`、`page list`)。 diff --git a/docs/LLM_MCP.md b/docs/LLM_MCP.md new file mode 100644 index 0000000..ea0e3a8 --- /dev/null +++ b/docs/LLM_MCP.md @@ -0,0 +1,130 @@ +# LLM 通过 MCP 操作 Logseq(实战指南) + +本文说明如何把 `logseq-cli` 暴露给支持 MCP 的 LLM 客户端,让模型可直接执行页面/块/查询等操作。 + +如果你的目标是“稳定生产托管给 LLM”,请继续阅读:`docs/LLM_P0_API.md`(能力探测 / 安全写入 / 分页与统一 schema)。 + +## 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 的提示词建议 + +为了降低误操作,建议你在系统提示里明确: + +- 会话启动先执行 `logseq capabilities get`,依据能力矩阵选择执行路径 +- 优先读操作:`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` 代码版本是否与当前仓库兼容 + +## 8. 最小接入验收(建议照抄) + +当你把 MCP 配置接入客户端后,建议用下面 4 步做首轮验收: + +1. 能力探测(只读) + +- `logseq capabilities get` + +2. 检索(分页) + +- `logseq search-notes logseq --limit 5` + +3. 页面上下文(裁剪) + +- `logseq page get-context logseq --max-blocks 20 --max-depth 3 --include-properties false` + +4. 安全写入预演(不落盘) + +- `logseq page append-safe logseq "llm mcp dry-run check" --dry-run` + +如果以上命令都返回统一 envelope(`ok/data/error/meta/hints`),说明“LLM 可稳定接入”的核心链路已打通。 + +## 9. 给 LLM 的系统提示词(可直接粘贴) + +可把下面内容放到 MCP 客户端的系统提示中: + +- 先执行 `logseq capabilities get`,再决定调用路径。 +- 读操作优先:`search-notes`、`page get-context`、`query datalog`。 +- 写操作必须先 `--dry-run`,确认后再执行真实写入。 +- 删除类操作必须显式确认;默认优先 `block delete-safe --dry-run`。 +- 分页读取时优先使用 `next_cursor/has_more`,避免一次请求过大。 +- 若 `dsl_query` 不可用,优先回退到 datalog 或已封装命令。 + +推荐直接使用示例文件: + +- 系统提示词:`docs/examples/llm_system_prompt_logseq_mcp.txt` +- 首轮用户提示模板:`docs/examples/llm_first_turn_template.txt` diff --git a/docs/LLM_P0_API.md b/docs/LLM_P0_API.md new file mode 100644 index 0000000..6229c44 --- /dev/null +++ b/docs/LLM_P0_API.md @@ -0,0 +1,354 @@ +# logseq-cli 面向 LLM 的 P0 API 规范(草案) + +> 目标:把当前“可用”的 CLI/MCP 能力升级为“可稳定托管给 LLM”的接口层。 + +## 1. 设计目标 + +P0 仅覆盖三件事: + +1. **可判定能力边界**:不同 Logseq 版本/图谱差异可被探测与显式返回。 +2. **可控写入风险**:默认只读、写入可预演(dry-run)、危险操作强确认。 +3. **可控上下文体积**:统一分页游标与结果裁剪,避免 LLM 上下文爆炸。 + +--- + +## 2. 统一响应信封(所有工具) + +所有 MCP 工具响应建议统一为: + +```json +{ + "ok": true, + "data": {}, + "error": null, + "meta": { + "request_id": "uuid", + "duration_ms": 12, + "capability_used": ["logseq.DB.datascriptQuery"], + "fallback_used": ["refs_over_tags"], + "next_cursor": "opaque-cursor", + "has_more": false + }, + "hints": [ + "当前图谱 :block/tags 为空,已使用 :block/refs 回退" + ] +} +``` + +错误时: + +- `ok=false` +- `error` 结构化:`code` / `message` / `retryable` / `suggestion` + +建议错误码: + +- `BAD_REQUEST` +- `AUTH_REQUIRED` +- `CAPABILITY_UNAVAILABLE` +- `RESOURCE_NOT_FOUND` +- `CONFLICT` +- `TIMEOUT` +- `UPSTREAM_ERROR` +- `SAFETY_BLOCKED` + +--- + +## 3. P0 工具集合 + +## 3.1 `logseq.capabilities.get` + +### 目的 + +返回当前实例能力矩阵,指导 LLM 选择路径(避免盲试)。 + +### 输入 + +```json +{} +``` + +### 输出(示例) + +```json +{ + "ok": true, + "data": { + "api": { + "search": true, + "dsl_query": false, + "datascript_query": true, + "tag_relation_ops": true + }, + "graph": { + "db_graph": true, + "tags_field_available": false, + "refs_field_available": true + }, + "write_policy": { + "default_mode": "read-only", + "dangerous_requires_confirm": true + } + }, + "meta": { + "request_id": "req-...", + "duration_ms": 9, + "capability_used": [ + "logseq.DB.datascriptQuery", + "logseq.DB.q", + "logseq.App.search" + ] + }, + "hints": [ + "dsl query unsupported in current runtime" + ] +} +``` + +### 实现映射(现有能力) + +- `graph db-graph` +- `query datalog` +- `query dsl` +- `tag list` + +--- + +## 3.2 `logseq.search.notes` + +### 目的 + +统一“全文检索 + 标签过滤 + 分页裁剪”的读取入口。 + +### 输入 + +```json +{ + "query": "golang logseq", + "tag": "project", + "limit": 20, + "cursor": "", + "include": ["pages", "blocks"] +} +``` + +### 输出 + +- `items[]`:统一条目结构(`type`, `title`, `snippet`, `page`, `uuid`) +- `next_cursor` / `has_more` +- 外层统一信封:`ok/data/error/meta/hints` + +### 实现映射 + +- `search` +- `query datalog`(必要时兜底) + +当前已落地的 CLI 入口:`logseq search-notes --limit --cursor --include --tag` + +典型返回(节选): + +```json +{ + "ok": true, + "data": { + "query": "golang", + "limit": 20, + "next_cursor": "20", + "has_more": true, + "total": 83, + "items": [] + }, + "meta": { + "request_id": "req-...", + "duration_ms": 5, + "capability_used": ["logseq.App.search"], + "next_cursor": "20", + "has_more": true + } +} +``` + +--- + +## 3.3 `logseq.page.get_context` + +### 目的 + +返回页面上下文的“LLM可消费视图”(元数据 + 有限块树)。 + +### 输入 + +```json +{ + "page": "Project Alpha", + "max_blocks": 200, + "max_depth": 6, + "include_properties": true +} +``` + +### 输出 + +- `page`:基础信息 +- `properties` +- `outline_blocks[]`:裁剪后的块树(含 `uuid/content/marker/priority/level`) + +### 实现映射 + +- `page get --blocks` +- `page properties` + +当前已落地的 CLI 入口:`logseq page get-context --max-blocks --max-depth --include-properties` + +返回同样使用统一信封,并在 `data.stats` 中提供: + +- `returned_blocks` +- `clipped_by_depth` +- `clipped_by_limit` + +--- + +## 3.4 `logseq.page.append_safe` + +### 目的 + +安全追加内容;支持幂等与 dry-run。 + +### 输入 + +```json +{ + "page": "Project Alpha", + "content": "- [ ] Draft proposal", + "idempotency_key": "sha256:...", + "dry_run": false +} +``` + +### 行为约束 + +- `dry_run=true`:仅返回将执行动作,不写入。 +- `idempotency_key` 相同且近期已执行:返回已存在结果,不重复写。 + +### 实现映射 + +- `block append` +- (建议新增轻量幂等缓存,内存/文件均可) + +当前已落地的 CLI 入口:`logseq page append-safe --dry-run --confirm --idempotency-key` + +返回同样使用统一信封,`data.action` 为: + +- `dry-run` +- `deduped` +- `appended` + +--- + +## 3.5 `logseq.task.upsert` + +### 目的 + +给 LLM 一个稳定“任务写入”语义层,避免直接拼装底层块调用。 + +### 输入 + +```json +{ + "page": "Project Alpha", + "title": "Draft proposal", + "marker": "TODO", + "priority": "A", + "properties": { + "owner": "barry", + "due": "2026-05-12" + }, + "dedupe_by": "title" +} +``` + +### 输出 + +- `action`: `created | updated | noop` +- `block_uuid` + +### 实现映射 + +- `page get --blocks`(查重) +- `block append` 或 `block update` +- `block property set` + +--- + +## 3.6 `logseq.block.delete_safe` + +### 目的 + +对危险删除进行双保险。 + +### 输入 + +```json +{ + "uuid": "...", + "confirm": false, + "dry_run": true +} +``` + +### 行为约束 + +- `dry_run=true`:返回块摘要、子树规模与影响范围。 +- 实际删除要求 `confirm=true`,否则 `SAFETY_BLOCKED`。 + +### 实现映射 + +- `block get --children` +- `block remove` + +当前已落地的 CLI 入口:`logseq block delete-safe --dry-run --confirm` + +返回同样使用统一信封,`data.action` 为: + +- `dry-run` +- `deleted` + +--- + +## 4. 分页与裁剪约定 + +- 默认 `limit=20`,最大 `limit=200`。 +- `cursor` 使用不透明字符串(内部可编码 offset/anchor)。 +- 每个 `snippet` 建议裁剪到 `<= 500` 字符。 +- 查询输出建议优先结构化字段,避免超长原始 JSON。 + +--- + +## 5. 安全策略(P0) + +建议新增运行时策略(可来自环境变量): + +- `LOGSEQ_LLM_WRITE_MODE=read-only|confirm|direct`(默认 `read-only`) +- `LOGSEQ_LLM_REQUIRE_CONFIRM_FOR_DELETE=true`(默认 true) +- `LOGSEQ_LLM_MAX_RESULTS=200` + +策略应出现在 `capabilities.get` 返回中,便于 LLM 决策。 + +--- + +## 6. 与现有命令树映射总览 + +- 读取层:`search` / `page get` / `query datalog` / `tag list` +- 写入层:`block append` / `block update` / `block remove` / `block property set` +- 状态层:`graph state` / `graph state-set` + +即:P0 不要求推翻现有命令,只需要在 MCP 暴露层增加“更稳的工具契约”。 + +--- + +## 7. 最小落地顺序(建议) + +1. 先实现 `capabilities.get`。 +2. 再实现 `search.notes` + `page.get_context`(含分页)。 +3. 最后实现 `append_safe` + `delete_safe`(含 dry-run / confirm)。 + +完成以上三步后,LLM 侧稳定性会有明显提升。 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 diff --git a/docs/examples/llm_first_turn_template.txt b/docs/examples/llm_first_turn_template.txt new file mode 100644 index 0000000..141ecf4 --- /dev/null +++ b/docs/examples/llm_first_turn_template.txt @@ -0,0 +1,21 @@ +你可以把下面这段作为“用户给 LLM 的第一句话”直接使用: + +请先完成 logseq MCP 会话预检,然后给我一个只读摘要: +1) 先执行 `logseq capabilities get`,判断 dsl/search/tags/refs/state 可用性。 +2) 执行 `logseq search-notes "logseq" --limit 5`,给出前 5 条结果摘要。 +3) 执行 `logseq page get-context "logseq" --max-blocks 20 --max-depth 3 --include-properties false`,输出页面结构概览。 +4) 不做任何真实写入;如果需要写入建议,先给 dry-run 命令。 + +输出要求: +- 先用 3~6 条要点总结现状。 +- 若能力受限(如 dsl 不可用),明确写出回退策略。 +- 若发现风险操作,必须标注“需确认后执行”。 + +--- + +可选第二句话(当你确认开始写入时): + +请仅做预演,不落盘: +- `logseq page append-safe "logseq" "llm dry-run note" --dry-run` +- `logseq block delete-safe --dry-run` +并说明每条命令实际会改动什么。 diff --git a/docs/examples/llm_system_prompt_logseq_mcp.txt b/docs/examples/llm_system_prompt_logseq_mcp.txt new file mode 100644 index 0000000..c3d594f --- /dev/null +++ b/docs/examples/llm_system_prompt_logseq_mcp.txt @@ -0,0 +1,14 @@ +你是连接 logseq MCP 的智能助手,请严格遵循: + +1) 会话开始先执行 `logseq capabilities get`。 +2) 检索优先使用 `logseq search-notes`,并根据 `next_cursor/has_more` 做分页。 +3) 页面阅读优先使用 `logseq page get-context`,并根据 `max-blocks/max-depth` 控制体积。 +4) 写操作先 dry-run: + - `logseq page append-safe ... --dry-run` + - `logseq block delete-safe ... --dry-run` +5) 只有用户明确确认后,才执行真实写入/删除。 +6) 当能力受限时按 hints 回退: + - dsl 不可用 -> datalog 或封装命令 + - 属性接口不可用 -> 读取 page.properties 回退 +7) 所有响应按 envelope 解析:`ok/data/error/meta/hints`。 +8) 若 `ok=false`,先解释 `error.code` 与 `suggestion`,再给出下一步可执行动作。 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..45ff5c0 --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module github.com/pubgo/logseq-cli + +go 1.26.1 + +require github.com/pubgo/redant v0.4.0-beta.1 + +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 new file mode 100644 index 0000000..d372653 --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +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= +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/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.4.0-beta.1 h1:BNHT4gsGJYifzvqiN00i5UNmyV0FS5XCnETXo2VL+80= +github.com/pubgo/redant v0.4.0-beta.1/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= +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/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/handler_block.go b/internal/webui/handler_block.go new file mode 100644 index 0000000..ecfa50a --- /dev/null +++ b/internal/webui/handler_block.go @@ -0,0 +1,459 @@ +package webui + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/pubgo/logseq-cli/pkg/logseq" +) + +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 + } + + // Fetch updated block to return consistent response + block, err := s.client.GetBlock(ctx, req.UUID, false) + if err == nil && block != nil { + writeOK(w, block) + 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) handleBlockCurrent(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() + + block, err := s.client.GetCurrentBlock(ctx) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + if block == nil { + writeError(w, http.StatusNotFound, "no current block") + return + } + writeOK(w, block) +} + +func (s *Server) handleBlockSelected(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() + + blocks, err := s.client.GetSelectedBlocks(ctx) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, blocks) +} + +func (s *Server) handleBlockClearSelected(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + if err := s.client.ClearSelectedBlocks(ctx); err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, map[string]any{"message": "selection cleared"}) +} + +func (s *Server) handleBlockNewUUID(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() + + uuid, err := s.client.NewBlockUUID(ctx) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, map[string]any{"uuid": uuid}) +} + +func (s *Server) handleBlockPrevSibling(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 + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + block, err := s.client.GetPreviousSiblingBlock(ctx, uuid) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + if block == nil { + writeError(w, http.StatusNotFound, "previous sibling not found") + return + } + writeOK(w, block) +} + +func (s *Server) handleBlockNextSibling(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 + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + block, err := s.client.GetNextSiblingBlock(ctx, uuid) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + if block == nil { + writeError(w, http.StatusNotFound, "next sibling not found") + return + } + writeOK(w, block) +} + +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) +} + +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) +} + +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) +} + +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}) +} + +type blockPropertyRequest struct { + UUID string `json:"uuid"` + Key string `json:"key"` + Value any `json:"value"` + Action string `json:"action"` +} + +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: + 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}) + } +} + +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}) +} diff --git a/internal/webui/handler_graph.go b/internal/webui/handler_graph.go new file mode 100644 index 0000000..f7029a8 --- /dev/null +++ b/internal/webui/handler_graph.go @@ -0,0 +1,193 @@ +package webui + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" +) + +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) 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) handleGraphAppInfo(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.GetInfo(ctx) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, info) +} + +func (s *Server) handleGraphUserInfo(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.GetUserInfo(ctx) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, info) +} + +func (s *Server) handleGraphCurrentConfig(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.GetCurrentGraphConfigs(ctx) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, config) +} + +func (s *Server) handleGraphFavorites(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() + + items, err := s.client.GetCurrentGraphFavorites(ctx) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, items) +} + +func (s *Server) handleGraphRecent(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() + + items, err := s.client.GetCurrentGraphRecent(ctx) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, items) +} + +func (s *Server) handleGraphTemplates(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() + + items, err := s.client.GetCurrentGraphTemplates(ctx) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, items) +} + +type graphStateSetRequest struct { + Key string `json:"key"` + Value any `json:"value"` +} + +func (s *Server) handleGraphState(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + key := strings.TrimSpace(r.URL.Query().Get("key")) + if key == "" { + writeError(w, http.StatusBadRequest, "missing query: key") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + value, err := s.client.GetStateFromStore(ctx, key) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, value) + case http.MethodPost: + var req graphStateSetRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + req.Key = strings.TrimSpace(req.Key) + if req.Key == "" { + writeError(w, http.StatusBadRequest, "key is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + if err := s.client.SetStateFromStore(ctx, req.Key, req.Value); err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, map[string]any{"message": "state updated", "key": req.Key}) + default: + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} diff --git a/internal/webui/handler_page.go b/internal/webui/handler_page.go new file mode 100644 index 0000000..c8cdc5c --- /dev/null +++ b/internal/webui/handler_page.go @@ -0,0 +1,639 @@ +package webui + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/pubgo/logseq-cli/pkg/logseq" +) + +func (s *Server) handlePages(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + pages, err := s.getAllPages(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + 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) + + taggedPages, hasTagIndex := s.getPageNameSetByTag(r.Context(), tag) + + 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 !matchesTagFilterWithIndex(p, tag, taggedPages, hasTagIndex) { + 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) 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 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 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) + 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"` + Content string `json:"content"` +} + +type createJournalRequest struct { + Date string `json:"date"` +} + +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 + } + + content := strings.TrimSpace(req.Content) + if content != "" { + if _, err := s.client.AppendBlockInPage(ctx, name, content); err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + } + writeOK(w, page) +} + +func (s *Server) handlePageJournal(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req createJournalRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + date := strings.TrimSpace(req.Date) + if date == "" { + date = time.Now().Format("2006-01-02") + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + page, err := s.client.CreateJournalPage(ctx, date) + 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 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}) +} + +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) +} + +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) +} + +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") + } +} diff --git a/internal/webui/handler_property.go b/internal/webui/handler_property.go new file mode 100644 index 0000000..43e998a --- /dev/null +++ b/internal/webui/handler_property.go @@ -0,0 +1,113 @@ +package webui + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" +) + +func (s *Server) handlePropertyList(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() + + items, err := s.client.GetAllProperties(ctx) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, items) +} + +func (s *Server) handlePropertyGet(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + key := strings.TrimSpace(r.URL.Query().Get("key")) + if key == "" { + writeError(w, http.StatusBadRequest, "missing query: key") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + item, err := s.client.GetProperty(ctx, key) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, item) +} + +type propertyUpsertRequest struct { + Key string `json:"key"` + Schema map[string]any `json:"schema"` +} + +func (s *Server) handlePropertyUpsert(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req propertyUpsertRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + req.Key = strings.TrimSpace(req.Key) + if req.Key == "" { + writeError(w, http.StatusBadRequest, "key is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + result, err := s.client.UpsertProperty(ctx, req.Key, req.Schema, nil) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, result) +} + +type propertyRemoveRequest struct { + Key string `json:"key"` +} + +func (s *Server) handlePropertyRemove(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req propertyRemoveRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + req.Key = strings.TrimSpace(req.Key) + if req.Key == "" { + writeError(w, http.StatusBadRequest, "key is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + if err := s.client.RemoveProperty(ctx, req.Key); err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, map[string]any{"message": "property removed", "key": req.Key}) +} diff --git a/internal/webui/handler_query.go b/internal/webui/handler_query.go new file mode 100644 index 0000000..d007687 --- /dev/null +++ b/internal/webui/handler_query.go @@ -0,0 +1,73 @@ +package webui + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" +) + +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 +} diff --git a/internal/webui/handler_search.go b/internal/webui/handler_search.go new file mode 100644 index 0000000..c328b61 --- /dev/null +++ b/internal/webui/handler_search.go @@ -0,0 +1,384 @@ +package webui + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/pubgo/logseq-cli/pkg/logseq" +) + +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")) + tag := strings.TrimSpace(r.URL.Query().Get("tag")) + if q == "" { + writeError(w, http.StatusBadRequest, "missing query: q") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + keywords := splitSearchKeywords(q) + result, err := s.searchByKeywords(ctx, keywords) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + 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) + } + + // Filter out empty block objects from search results + if result != nil { + result.Blocks = filterEmptyBlocks(result.Blocks) + s.resolveSearchBlockPageIDs(ctx, result) + } + + writeOK(w, result) +} + +func (s *Server) searchByKeywords(ctx context.Context, keywords []string) (*logseq.SearchResult, error) { + if len(keywords) == 0 { + return &logseq.SearchResult{}, nil + } + + first, err := s.client.Search(ctx, keywords[0]) + if err != nil { + return nil, err + } + if first == nil { + return &logseq.SearchResult{}, nil + } + + result := cloneSearchResult(first) + for _, kw := range keywords[1:] { + next, err := s.client.Search(ctx, kw) + if err != nil { + return nil, err + } + result = intersectSearchResults(result, next) + if len(result.Blocks) == 0 && len(result.Pages) == 0 && len(result.Files) == 0 { + break + } + } + + return result, nil +} + +func splitSearchKeywords(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + out := make([]string, 0) + var buf strings.Builder + inQuote := false + var quote rune + + flush := func() { + v := strings.TrimSpace(buf.String()) + buf.Reset() + if v != "" { + out = append(out, v) + } + } + + for _, r := range raw { + switch { + case (r == '"' || r == '\'') && !inQuote: + inQuote = true + quote = r + case inQuote && r == quote: + inQuote = false + quote = 0 + case !inQuote && (r == ' ' || r == '\t' || r == '\n'): + flush() + default: + buf.WriteRune(r) + } + } + flush() + + if len(out) == 0 { + return []string{raw} + } + + return dedupeStrings(out) +} + +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, + } + + 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 cloneSearchResult(in *logseq.SearchResult) *logseq.SearchResult { + if in == nil { + return &logseq.SearchResult{} + } + out := &logseq.SearchResult{} + out.Pages = append(out.Pages, in.Pages...) + out.Files = append(out.Files, in.Files...) + out.Blocks = append(out.Blocks, in.Blocks...) + return out +} + +func intersectSearchResults(left, right *logseq.SearchResult) *logseq.SearchResult { + if left == nil || right == nil { + return &logseq.SearchResult{} + } + + pageSet := make(map[string]struct{}, len(right.Pages)) + for _, p := range right.Pages { + k := strings.ToLower(strings.TrimSpace(p)) + if k != "" { + pageSet[k] = struct{}{} + } + } + + fileSet := make(map[string]struct{}, len(right.Files)) + for _, f := range right.Files { + k := strings.ToLower(strings.TrimSpace(f)) + if k != "" { + fileSet[k] = struct{}{} + } + } + + blockSet := make(map[string]struct{}, len(right.Blocks)) + for _, b := range right.Blocks { + k := searchBlockKey(b) + if k != "" { + blockSet[k] = struct{}{} + } + } + + out := &logseq.SearchResult{ + Blocks: make([]logseq.SearchBlock, 0, len(left.Blocks)), + Pages: make([]string, 0, len(left.Pages)), + Files: make([]string, 0, len(left.Files)), + } + + for _, p := range left.Pages { + k := strings.ToLower(strings.TrimSpace(p)) + if _, ok := pageSet[k]; ok { + out.Pages = append(out.Pages, p) + } + } + + for _, f := range left.Files { + k := strings.ToLower(strings.TrimSpace(f)) + if _, ok := fileSet[k]; ok { + out.Files = append(out.Files, f) + } + } + + for _, b := range left.Blocks { + if _, ok := blockSet[searchBlockKey(b)]; ok { + out.Blocks = append(out.Blocks, b) + } + } + + return out +} + +func searchBlockKey(b logseq.SearchBlock) string { + if uuid := strings.ToLower(strings.TrimSpace(b.UUID)); uuid != "" { + return "uuid:" + uuid + } + content := strings.ToLower(strings.TrimSpace(b.Content)) + page := strings.ToLower(strings.TrimSpace(b.Page)) + if content == "" && page == "" { + return "" + } + return "cp:" + page + "|" + content +} + +func filterEmptyBlocks(blocks []logseq.SearchBlock) []logseq.SearchBlock { + result := make([]logseq.SearchBlock, 0, len(blocks)) + for _, b := range blocks { + if b.UUID != "" || b.Content != "" { + result = append(result, b) + } + } + return result +} + +func pageNameInSet(name string, set map[string]struct{}) bool { + k := strings.ToLower(strings.TrimSpace(name)) + if k == "" { + return false + } + _, ok := set[k] + return ok +} + +// resolveSearchBlockPageIDs replaces integer page IDs (like "#12345") with +// actual page names by batch-querying Logseq's Datalog store. +func (s *Server) resolveSearchBlockPageIDs(ctx context.Context, result *logseq.SearchResult) { + if result == nil || len(result.Blocks) == 0 { + return + } + + // Collect unique DB IDs that need resolution + ids := make(map[int64]struct{}) + for _, b := range result.Blocks { + if id, ok := parsePageDBID(b.Page); ok { + ids[id] = struct{}{} + } + } + if len(ids) == 0 { + return + } + + // Build a single Datalog query to resolve all IDs at once: + // [:find ?id ?name :where (or [?id ...]) [?id :block/name ?name]] + var orClauses strings.Builder + for id := range ids { + if orClauses.Len() > 0 { + orClauses.WriteString(" ") + } + fmt.Fprintf(&orClauses, "[(= ?id %d)]", id) + } + query := fmt.Sprintf( + "[:find ?id ?name :where (or %s) [?id :block/name ?name]]", + orClauses.String(), + ) + + raw, err := s.client.DatascriptQuery(ctx, query) + if err != nil { + return // best-effort: keep the #ID if resolution fails + } + + nameMap := parseIDNamePairs(raw) + if len(nameMap) == 0 { + return + } + + // Replace page IDs with resolved names + for i := range result.Blocks { + if id, ok := parsePageDBID(result.Blocks[i].Page); ok { + if name, found := nameMap[id]; found { + result.Blocks[i].Page = name + } + } + } +} + +// parsePageDBID checks if a page string is a DB integer ID like "#12345" and +// returns the numeric value. +func parsePageDBID(page string) (int64, bool) { + if !strings.HasPrefix(page, "#") { + return 0, false + } + id, err := strconv.ParseInt(page[1:], 10, 64) + if err != nil { + return 0, false + } + return id, true +} + +// parseIDNamePairs parses Datalog results of [[id, "name"], ...] into a map. +func parseIDNamePairs(raw []byte) map[int64]string { + if len(raw) == 0 { + return nil + } + var rows [][]any + if err := json.Unmarshal(raw, &rows); err != nil { + return nil + } + m := make(map[int64]string, len(rows)) + for _, row := range rows { + if len(row) < 2 { + continue + } + var id int64 + switch v := row[0].(type) { + case float64: + id = int64(v) + case json.Number: + n, err := v.Int64() + if err != nil { + continue + } + id = n + default: + continue + } + if name, ok := row[1].(string); ok && name != "" { + m[id] = name + } + } + return m +} diff --git a/internal/webui/handler_tag.go b/internal/webui/handler_tag.go new file mode 100644 index 0000000..02d7e4f --- /dev/null +++ b/internal/webui/handler_tag.go @@ -0,0 +1,264 @@ +package webui + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/pubgo/logseq-cli/pkg/logseq" +) + +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) handleTagGet(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + nameOrID := strings.TrimSpace(r.URL.Query().Get("nameOrID")) + if nameOrID == "" { + writeError(w, http.StatusBadRequest, "missing query: nameOrID") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + tag, err := s.client.GetTag(ctx, nameOrID) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + if tag == nil { + writeError(w, http.StatusNotFound, "tag not found") + return + } + writeOK(w, tag) +} + +func (s *Server) handleTagSearch(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() + + tags, err := s.client.GetTagsByName(ctx, name) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, tags) +} + +type createTagRequest struct { + Name string `json:"name"` + UUID string `json:"uuid"` +} + +func (s *Server) handleTagCreate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req createTagRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + req.Name = strings.TrimSpace(req.Name) + req.UUID = strings.TrimSpace(req.UUID) + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + var ( + tag *logseq.Page + err error + ) + if req.UUID != "" { + tag, err = s.client.CreateTag(ctx, req.Name, req.UUID) + } else { + tag, err = s.client.CreateTag(ctx, req.Name) + } + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, tag) +} + +func (s *Server) handleTagObjects(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() + + items, err := s.client.GetTagObjects(ctx, name) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, items) +} + +type tagRelationRequest struct { + Action string `json:"action"` + TagID string `json:"tagID"` + Target string `json:"target"` +} + +func (s *Server) handleTagPropertyRelation(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req tagRelationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + req.Action = strings.ToLower(strings.TrimSpace(req.Action)) + req.TagID = strings.TrimSpace(req.TagID) + req.Target = strings.TrimSpace(req.Target) + if req.TagID == "" || req.Target == "" { + writeError(w, http.StatusBadRequest, "tagID and target are required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + var err error + if req.Action == "remove" { + err = s.client.RemoveTagProperty(ctx, req.TagID, req.Target) + } else { + err = s.client.AddTagProperty(ctx, req.TagID, req.Target) + } + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, map[string]any{"message": "tag property relation updated"}) +} + +func (s *Server) handleTagExtendsRelation(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req tagRelationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + req.Action = strings.ToLower(strings.TrimSpace(req.Action)) + req.TagID = strings.TrimSpace(req.TagID) + req.Target = strings.TrimSpace(req.Target) + if req.TagID == "" || req.Target == "" { + writeError(w, http.StatusBadRequest, "tagID and target are required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + var err error + if req.Action == "remove" { + err = s.client.RemoveTagExtends(ctx, req.TagID, req.Target) + } else { + err = s.client.AddTagExtends(ctx, req.TagID, req.Target) + } + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, map[string]any{"message": "tag extends relation updated"}) +} + +type tagBlockRelationRequest struct { + Action string `json:"action"` + BlockID string `json:"blockID"` + TagID string `json:"tagID"` +} + +func (s *Server) handleTagBlockRelation(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + var req tagBlockRelationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + req.Action = strings.ToLower(strings.TrimSpace(req.Action)) + req.BlockID = strings.TrimSpace(req.BlockID) + req.TagID = strings.TrimSpace(req.TagID) + if req.BlockID == "" || req.TagID == "" { + writeError(w, http.StatusBadRequest, "blockID and tagID are required") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + + var err error + if req.Action == "remove" { + err = s.client.RemoveBlockTag(ctx, req.BlockID, req.TagID) + } else { + err = s.client.AddBlockTag(ctx, req.BlockID, req.TagID) + } + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeOK(w, map[string]any{"message": "block tag relation updated"}) +} + +func (s *Server) getAllTags(ctx context.Context) ([]string, error) { + queryCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + return s.client.GetAllTags(queryCtx) +} diff --git a/internal/webui/server.go b/internal/webui/server.go new file mode 100644 index 0000000..6823bbf --- /dev/null +++ b/internal/webui/server.go @@ -0,0 +1,183 @@ +package webui + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "os/exec" + "runtime" + "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/graph/config", s.handleGraphConfig) + mux.HandleFunc("/api/graph/app-info", s.handleGraphAppInfo) + mux.HandleFunc("/api/graph/user-info", s.handleGraphUserInfo) + mux.HandleFunc("/api/graph/current-config", s.handleGraphCurrentConfig) + mux.HandleFunc("/api/graph/favorites", s.handleGraphFavorites) + mux.HandleFunc("/api/graph/recent", s.handleGraphRecent) + mux.HandleFunc("/api/graph/templates", s.handleGraphTemplates) + mux.HandleFunc("/api/graph/state", s.handleGraphState) + mux.HandleFunc("/api/pages", s.handlePages) + mux.HandleFunc("/api/pages/filter", s.handlePagesFilter) + mux.HandleFunc("/api/tags", s.handleTags) + mux.HandleFunc("/api/tag", s.handleTagGet) + mux.HandleFunc("/api/tag/search", s.handleTagSearch) + mux.HandleFunc("/api/tag/create", s.handleTagCreate) + mux.HandleFunc("/api/tag/objects", s.handleTagObjects) + mux.HandleFunc("/api/tag/property", s.handleTagPropertyRelation) + mux.HandleFunc("/api/tag/extends", s.handleTagExtendsRelation) + mux.HandleFunc("/api/tag/block", s.handleTagBlockRelation) + mux.HandleFunc("/api/property/list", s.handlePropertyList) + mux.HandleFunc("/api/property", s.handlePropertyGet) + mux.HandleFunc("/api/property/upsert", s.handlePropertyUpsert) + mux.HandleFunc("/api/property/remove", s.handlePropertyRemove) + mux.HandleFunc("/api/page", s.handlePage) + mux.HandleFunc("/api/page/journal", s.handlePageJournal) + 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/current", s.handleBlockCurrent) + mux.HandleFunc("/api/block/selected", s.handleBlockSelected) + mux.HandleFunc("/api/block/selected/clear", s.handleBlockClearSelected) + mux.HandleFunc("/api/block/new-uuid", s.handleBlockNewUUID) + mux.HandleFunc("/api/block/prev-sibling", s.handleBlockPrevSibling) + mux.HandleFunc("/api/block/next-sibling", s.handleBlockNextSibling) + 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) + 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 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..7903bb9 --- /dev/null +++ b/internal/webui/static/index.html @@ -0,0 +1,2319 @@ + + + + + + + logseq-cli WebUI + + + + + + + +
+ + +
+ + +
+
+ + + + + +
+
+ + +
+
+
+
+
+
+

logseq-cli WebUI

+

⌘K 搜索 · ⌘1-6 切换标签页 · ⌥←/⌥→ 折叠展开当前节点

+
+
+ +
+
+ + + + + + + + + + +
+
+ + +
+
+ 页面列表 + +
+ +
+ + +
+
+ 高级过滤 +
+ + +
+ + +
+
+
+
+ + +
+
+
+ +
+
+ + +
+ + +
+ 页面操作 +
+
+ + +
+
+ + +
+
+
+ + + + +
+ + +
+ 初始内容(创建页面时附带) + +
+ + +
+ 创建 Journal 页面(可选日期,格式 YYYY-MM-DD) +
+ + +
+
+ + +
+ 重命名 +
+ + +
+
+ + +
+ + +
+ + +
+ 命名空间 +
+ + + +
+
+ + +
+ 设置页面属性(key=value 每行一个) + + +
+
+ + +
+ +
+
+

+

+ +
+
+ + + + + + +
+
+ + + + + +
+ +
+ + + +
+ + +
+ 追加内容到页面 + +
+ + + +
+
+ +
+
+
+ + +
+
+ + +
+ 获取块 +
+ + + +
+ + +
+ + +
+ 块快捷操作 +
+ + + + +
+
+ + + +
+
+ + +
+ 插入块 +
+ + +
+ + +
+
+
+ + +
+ 更新块 +
+ + +
+ + +
+
+
+ + +
+ 移动块 +
+ + +
+ + +
+
+
+ + +
+ 块属性 +
+ +
+ + +
+
+ + + +
+
+
+ + +
+ 折叠/展开块 +
+ + + +
+
+ +
+
+ + +
+
+ 全文搜索 +
+ + + +
+
示例:golang logseq(同时包含) / "outline mode" render(短语 + + 关键词)
+
+ + +
+
+ 搜索结果 + +
+
+ +
+
+
+ + +
+
+ + +
+
+ 标签列表 + +
+ +
+
+
+ +
+
+ + +
+
+
+ + + + + +
+ +
+ +
+ 标签 / 属性增强操作 + +
+
标签创建 / 查询 / 对象
+
+ + + +
+
+ + + +
+
+ +
+
+ +
+
标签关系操作(property / extends)
+
+ + + +
+ + +
+
+
+ +
+
块标签关系(block-tag)
+
+ + + + +
+
+ +
+
属性 schema(property)
+
+ + + +
+
+ + +
+ +
+ + +
+
+
+
+ +
+
+ + +
+
+ Datalog / DSL 查询 + + + +
+
+
最近查询
+ +
+
+ +
+
+ +
+
+
查询示例
+
+ + +
+
+
+ + + + +
+
+
+ +
+ + +
+
+
+ + +
+
+
+ 连接与状态 +
+ + + + +
+ +
+ +
+ Graph 增强能力 +
+ + + + + + + +
+
+
State 读写
+
+ + +
+ + +
+ + +
+
+
+ +
+
+ 操作历史 + +
+
+ + +
+
+
+
+ + +
+
+
+ 输出 + +
+
+ + + + +
+
+ + +

+
+      
+      
+ + +
+
+ + +
+
+ + + + +
+
+ + +
+
+
+ +
+
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+
+ 页面 + +
+ + +
+
+ +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+ +
+ + + + + \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..423d518 --- /dev/null +++ b/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "fmt" + "os" + + "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() { + 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), + Inherit: true, + }, + { + Flag: "host", + Description: "Logseq API host", + Envs: []string{"LOGSEQ_HOST"}, + Default: "127.0.0.1", + Value: redant.StringOf(&cmds.Host), + Inherit: true, + }, + { + Flag: "port", + Shorthand: "p", + Description: "Logseq API port", + Envs: []string{"LOGSEQ_PORT"}, + Default: "12315", + Value: redant.StringOf(&cmds.Port), + Inherit: true, + }, + }, + Children: []*redant.Command{ + cmds.CapabilitiesCmd(), + cmds.PageCmd(), + cmds.BlockCmd(), + cmds.GraphCmd(), + cmds.QueryCmd(), + cmds.SearchCmd(), + cmds.SearchNotesCmd(), + cmds.PropertyCmd(), + cmds.TagCmd(), + cmds.WebUICmd(), + 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) + } +} diff --git a/pkg/logseq/app.go b/pkg/logseq/app.go new file mode 100644 index 0000000..d630215 --- /dev/null +++ b/pkg/logseq/app.go @@ -0,0 +1,100 @@ +package logseq + +import ( + "context" + "encoding/json" + "strings" +) + +// 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")) +} + +// GetInfo returns application information. +// Falls back to GetUserConfigs if the native API is unavailable. +func (c *Client) GetInfo(ctx context.Context) (map[string]any, error) { + result, err := decode[map[string]any](c.CallAPI(ctx, "logseq.App.getInfo")) + if err != nil && strings.Contains(strings.ToLower(err.Error()), "methodnotexist") { + return c.GetUserConfigs(ctx) + } + return result, err +} + +// GetUserInfo returns current user information. +func (c *Client) GetUserInfo(ctx context.Context) (map[string]any, error) { + return decode[map[string]any](c.CallAPI(ctx, "logseq.App.getUserInfo")) +} + +// CheckCurrentIsDBGraph reports whether current graph is DB graph mode. +func (c *Client) CheckCurrentIsDBGraph(ctx context.Context) (bool, error) { + return decode[bool](c.CallAPI(ctx, "logseq.App.checkCurrentIsDbGraph")) +} + +// GetCurrentGraphConfigs returns graph configs by keys (or all if keys empty, depending on Logseq version behavior). +func (c *Client) GetCurrentGraphConfigs(ctx context.Context, keys ...string) (any, error) { + args := make([]any, 0, len(keys)) + for _, k := range keys { + args = append(args, k) + } + return decode[any](c.CallAPI(ctx, "logseq.App.getCurrentGraphConfigs", args...)) +} + +// GetCurrentGraphFavorites returns favorites of current graph. +func (c *Client) GetCurrentGraphFavorites(ctx context.Context) ([]any, error) { + return decode[[]any](c.CallAPI(ctx, "logseq.App.getCurrentGraphFavorites")) +} + +// GetCurrentGraphRecent returns recently visited pages in current graph. +func (c *Client) GetCurrentGraphRecent(ctx context.Context) ([]any, error) { + return decode[[]any](c.CallAPI(ctx, "logseq.App.getCurrentGraphRecent")) +} + +// GetCurrentGraphTemplates returns template map in current graph. +func (c *Client) GetCurrentGraphTemplates(ctx context.Context) (map[string]any, error) { + return decode[map[string]any](c.CallAPI(ctx, "logseq.App.getCurrentGraphTemplates")) +} + +// GetStateFromStore returns value from Logseq app state store by key. +func (c *Client) GetStateFromStore(ctx context.Context, key string) (any, error) { + return decode[any](c.CallAPI(ctx, "logseq.App.getStateFromStore", key)) +} + +// SetStateFromStore sets value in Logseq app state store by key. +func (c *Client) SetStateFromStore(ctx context.Context, key string, value any) error { + _, err := c.CallAPI(ctx, "logseq.App.setStateFromStore", key, value) + return err +} + +// 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..6f91ee9 --- /dev/null +++ b/pkg/logseq/client.go @@ -0,0 +1,169 @@ +package logseq + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "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, friendlyConnectionError(err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return nil, fmt.Errorf("logseq api: authentication failed (HTTP %d) — check your API token", resp.StatusCode) + } + + 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 +} + +// friendlyConnectionError wraps low-level connection errors with user-friendly messages. +func friendlyConnectionError(err error) error { + msg := err.Error() + if strings.Contains(msg, "connection refused") { + return fmt.Errorf("cannot connect to Logseq — is it running with the HTTP API server enabled? (%w)", err) + } + if strings.Contains(msg, "no such host") || strings.Contains(msg, "dial tcp") { + return fmt.Errorf("cannot reach Logseq API server — check the host and port configuration (%w)", err) + } + if strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline exceeded") { + return fmt.Errorf("connection to Logseq timed out — the server may be busy or unreachable (%w)", err) + } + return fmt.Errorf("failed to connect to Logseq: %w", err) +} + +func isMethodNotExistError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "methodnotexist") || strings.Contains(msg, "doesn't support name") +} + +func wrapCapabilityUnavailable(apiMethod string, err error) error { + if !isMethodNotExistError(err) { + return err + } + return fmt.Errorf("logseq api capability unavailable: %s (not supported by current Logseq HTTP API / graph mode): %w", apiMethod, err) +} diff --git a/pkg/logseq/db.go b/pkg/logseq/db.go new file mode 100644 index 0000000..5217236 --- /dev/null +++ b/pkg/logseq/db.go @@ -0,0 +1,66 @@ +package logseq + +import ( + "context" + "encoding/json" + "regexp" + "strconv" + "strings" +) + +var limitRegexp = regexp.MustCompile(`:limit\s+(\d+)`) +var orderByRegexp = regexp.MustCompile(`:order-by\s+\[.*?\]`) + +// DatascriptQuery executes a Datalog query against the Logseq database. +// Note: Datascript does not support :limit or :order-by clauses natively. +// This method strips those clauses before sending to the API and applies +// :limit as client-side truncation on the results. +func (c *Client) DatascriptQuery(ctx context.Context, query string, inputs ...any) (json.RawMessage, error) { + // Extract and strip :limit N (Datascript ignores it, but may error) + clientLimit := 0 + if m := limitRegexp.FindStringSubmatch(query); len(m) == 2 { + clientLimit, _ = strconv.Atoi(m[1]) + } + cleanQuery := limitRegexp.ReplaceAllString(query, "") + cleanQuery = orderByRegexp.ReplaceAllString(cleanQuery, "") + + args := []any{cleanQuery} + args = append(args, inputs...) + raw, err := c.CallAPI(ctx, "logseq.DB.datascriptQuery", args...) + if err != nil { + return nil, err + } + + // Apply client-side limit + if clientLimit > 0 && len(raw) > 0 { + var arr []json.RawMessage + if json.Unmarshal(raw, &arr) == nil && len(arr) > clientLimit { + arr = arr[:clientLimit] + if truncated, err := json.Marshal(arr); err == nil { + return truncated, nil + } + } + } + + return raw, nil +} + +// DSLQuery executes a Logseq DSL query. +func (c *Client) DSLQuery(ctx context.Context, query string) (json.RawMessage, error) { + raw, err := c.CallAPI(ctx, "logseq.DB.q", query) + if err == nil { + return raw, nil + } + + if !strings.Contains(strings.ToLower(err.Error()), "methodnotexist") { + return nil, err + } + + // Compatibility fallback for older/newer Logseq variants. + raw2, err2 := c.CallAPI(ctx, "logseq.DB.dslQuery", query) + if err2 == nil { + return raw2, nil + } + + return nil, err +} diff --git a/pkg/logseq/db_test.go b/pkg/logseq/db_test.go new file mode 100644 index 0000000..53f53e1 --- /dev/null +++ b/pkg/logseq/db_test.go @@ -0,0 +1,79 @@ +package logseq + +import ( + "testing" +) + +func TestLimitRegexp(t *testing.T) { + tests := []struct { + query string + limit int + }{ + {"[:find ?name :where [?p :block/name ?name] :limit 5]", 5}, + {"[:find ?name :where [?p :block/name ?name] :limit 100]", 100}, + {"[:find ?name :where [?p :block/name ?name]]", 0}, + {"[:find ?x :where [?b :block/content ?x] :limit 20]", 20}, + } + for _, tt := range tests { + m := limitRegexp.FindStringSubmatch(tt.query) + got := 0 + if len(m) == 2 { + got = mustAtoi(m[1]) + } + if got != tt.limit { + t.Errorf("query=%q: limit=%d, want %d", tt.query, got, tt.limit) + } + } +} + +func TestLimitRegexp_Stripping(t *testing.T) { + tests := []struct { + input string + want string + }{ + { + "[:find ?name :where [?p :block/name ?name] :limit 5]", + "[:find ?name :where [?p :block/name ?name] ]", + }, + { + "[:find ?name :where [?p :block/name ?name]]", + "[:find ?name :where [?p :block/name ?name]]", + }, + } + for _, tt := range tests { + got := limitRegexp.ReplaceAllString(tt.input, "") + if got != tt.want { + t.Errorf("strip limit: got=%q, want=%q", got, tt.want) + } + } +} + +func TestOrderByRegexp_Stripping(t *testing.T) { + tests := []struct { + input string + want string + }{ + { + "[:find ?x ?u :where [?b :block/content ?x] [?b :block/updated-at ?u] :order-by [(desc ?u)] :limit 5]", + "[:find ?x ?u :where [?b :block/content ?x] [?b :block/updated-at ?u] :limit 5]", + }, + { + "[:find ?name :where [?p :block/name ?name]]", + "[:find ?name :where [?p :block/name ?name]]", + }, + } + for _, tt := range tests { + got := orderByRegexp.ReplaceAllString(tt.input, "") + if got != tt.want { + t.Errorf("strip order-by: got=%q, want=%q", got, tt.want) + } + } +} + +func mustAtoi(s string) int { + n := 0 + for _, c := range s { + n = n*10 + int(c-'0') + } + return n +} diff --git a/pkg/logseq/editor.go b/pkg/logseq/editor.go new file mode 100644 index 0000000..e21cbab --- /dev/null +++ b/pkg/logseq/editor.go @@ -0,0 +1,328 @@ +package logseq + +import ( + "context" + "encoding/json" + "strings" +) + +// === 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 +} + +// CreateJournalPage creates a journal page for a specific date string. +// Falls back to CreatePage with journal option if the native API is unavailable. +func (c *Client) CreateJournalPage(ctx context.Context, date string) (*Page, error) { + raw, err := c.CallAPI(ctx, "logseq.Editor.createJournalPage", date) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "methodnotexist") { + return c.CreatePage(ctx, date, nil, &CreatePageOptions{Journal: true}) + } + 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 +} + +// GetPreviousSiblingBlock returns previous sibling block of given block. +func (c *Client) GetPreviousSiblingBlock(ctx context.Context, uuid string) (*Block, error) { + raw, err := c.CallAPI(ctx, "logseq.Editor.getPreviousSiblingBlock", uuid) + 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 +} + +// GetNextSiblingBlock returns next sibling block of given block. +func (c *Client) GetNextSiblingBlock(ctx context.Context, uuid string) (*Block, error) { + raw, err := c.CallAPI(ctx, "logseq.Editor.getNextSiblingBlock", uuid) + 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 +} + +// 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 +} + +// GetSelectedBlocks returns currently selected blocks. +func (c *Client) GetSelectedBlocks(ctx context.Context) ([]Block, error) { + return decode[[]Block](c.CallAPI(ctx, "logseq.Editor.getSelectedBlocks")) +} + +// ClearSelectedBlocks clears current selected blocks. +func (c *Client) ClearSelectedBlocks(ctx context.Context) error { + _, err := c.CallAPI(ctx, "logseq.Editor.clearSelectedBlocks") + return err +} + +// NewBlockUUID creates a unique UUID string. +func (c *Client) NewBlockUUID(ctx context.Context) (string, error) { + return decode[string](c.CallAPI(ctx, "logseq.Editor.newBlockUUID")) +} + +// GetCurrentPageBlocksTree returns block tree of currently focused page. +func (c *Client) GetCurrentPageBlocksTree(ctx context.Context) ([]Block, error) { + return decode[[]Block](c.CallAPI(ctx, "logseq.Editor.getCurrentPageBlocksTree")) +} + +// GetPageProperties returns properties of a page directly. +func (c *Client) GetPageProperties(ctx context.Context, page string) (map[string]any, error) { + return decode[map[string]any](c.CallAPI(ctx, "logseq.Editor.getPageProperties", page)) +} + +// 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/property.go b/pkg/logseq/property.go new file mode 100644 index 0000000..dd67624 --- /dev/null +++ b/pkg/logseq/property.go @@ -0,0 +1,68 @@ +package logseq + +import ( + "context" + "sort" + "strings" +) + +// GetAllProperties returns all property entities. +// Falls back to extracting property keys from all pages if the native API is unavailable. +func (c *Client) GetAllProperties(ctx context.Context) ([]Page, error) { + result, err := decode[[]Page](c.CallAPI(ctx, "logseq.Editor.getAllProperties")) + if err != nil && strings.Contains(strings.ToLower(err.Error()), "methodnotexist") { + return c.getAllPropertiesFallback(ctx) + } + return result, err +} + +func (c *Client) getAllPropertiesFallback(ctx context.Context) ([]Page, error) { + allPages, err := c.GetAllPages(ctx) + if err != nil { + return nil, err + } + seen := make(map[string]struct{}) + for _, p := range allPages { + for k := range p.Properties { + seen[k] = struct{}{} + } + } + pages := make([]Page, 0, len(seen)) + for k := range seen { + pages = append(pages, Page{Name: k, OriginalName: k}) + } + sort.Slice(pages, func(i, j int) bool { return pages[i].Name < pages[j].Name }) + return pages, nil +} + +// GetProperty returns a property entity by key. +func (c *Client) GetProperty(ctx context.Context, key string) (any, error) { + result, err := decode[any](c.CallAPI(ctx, "logseq.Editor.getProperty", key)) + if err != nil { + return nil, wrapCapabilityUnavailable("logseq.Editor.getProperty", err) + } + return result, nil +} + +// UpsertProperty creates or updates property schema. +// schema and opts are raw JSON-like objects accepted by Logseq. +func (c *Client) UpsertProperty(ctx context.Context, key string, schema map[string]any, opts map[string]any) (any, error) { + args := []any{key} + if schema != nil { + args = append(args, schema) + } + if opts != nil { + args = append(args, opts) + } + result, err := decode[any](c.CallAPI(ctx, "logseq.Editor.upsertProperty", args...)) + if err != nil { + return nil, wrapCapabilityUnavailable("logseq.Editor.upsertProperty", err) + } + return result, nil +} + +// RemoveProperty removes a property schema by key. +func (c *Client) RemoveProperty(ctx context.Context, key string) error { + _, err := c.CallAPI(ctx, "logseq.Editor.removeProperty", key) + return wrapCapabilityUnavailable("logseq.Editor.removeProperty", err) +} diff --git a/pkg/logseq/tags.go b/pkg/logseq/tags.go new file mode 100644 index 0000000..0bd840c --- /dev/null +++ b/pkg/logseq/tags.go @@ -0,0 +1,335 @@ +package logseq + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" +) + +// CreateTag creates a tag page. +func (c *Client) CreateTag(ctx context.Context, tagName string, customUUID ...string) (*Page, error) { + args := []any{tagName} + if len(customUUID) > 0 && strings.TrimSpace(customUUID[0]) != "" { + args = append(args, map[string]any{"uuid": strings.TrimSpace(customUUID[0])}) + } + + raw, err := c.CallAPI(ctx, "logseq.Editor.createTag", 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 +} + +// GetTag gets a tag by name or numeric entity id (as string). +func (c *Client) GetTag(ctx context.Context, nameOrIdent string) (*Page, error) { + ident := strings.TrimSpace(nameOrIdent) + arg := any(ident) + if id, err := strconv.ParseInt(ident, 10, 64); err == nil { + arg = id + } + + raw, err := c.CallAPI(ctx, "logseq.Editor.getTag", arg) + 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 +} + +// GetTagsByName gets tags by fuzzy/equal name in Logseq. +// Falls back to GetAllTags + local filtering if the native API is unavailable. +func (c *Client) GetTagsByName(ctx context.Context, tagName string) ([]Page, error) { + result, err := decode[[]Page](c.CallAPI(ctx, "logseq.Editor.getTagsByName", tagName)) + if err != nil && strings.Contains(strings.ToLower(err.Error()), "methodnotexist") { + return c.getTagsByNameFallback(ctx, tagName) + } + return result, err +} + +func (c *Client) getTagsByNameFallback(ctx context.Context, tagName string) ([]Page, error) { + allTags, err := c.GetAllTags(ctx) + if err != nil { + return nil, err + } + needle := strings.ToLower(tagName) + var pages []Page + for _, t := range allTags { + if strings.Contains(strings.ToLower(t), needle) { + pages = append(pages, Page{Name: t, OriginalName: t}) + } + } + return pages, nil +} + +// GetTagObjects gets tag object blocks for the given tag name. +func (c *Client) GetTagObjects(ctx context.Context, nameOrIdent string) ([]Block, error) { + result, err := decode[[]Block](c.CallAPI(ctx, "logseq.Editor.getTagObjects", nameOrIdent)) + if err != nil { + return nil, wrapCapabilityUnavailable("logseq.Editor.getTagObjects", err) + } + return result, nil +} + +// AddTagProperty adds property relation to a tag. +func (c *Client) AddTagProperty(ctx context.Context, tagID, propertyIDOrName string) error { + _, err := c.CallAPI(ctx, "logseq.Editor.addTagProperty", tagID, propertyIDOrName) + return wrapCapabilityUnavailable("logseq.Editor.addTagProperty", err) +} + +// RemoveTagProperty removes property relation from a tag. +func (c *Client) RemoveTagProperty(ctx context.Context, tagID, propertyIDOrName string) error { + _, err := c.CallAPI(ctx, "logseq.Editor.removeTagProperty", tagID, propertyIDOrName) + return wrapCapabilityUnavailable("logseq.Editor.removeTagProperty", err) +} + +// AddTagExtends adds parent tag relation for a tag. +func (c *Client) AddTagExtends(ctx context.Context, tagID, parentTagIDOrName string) error { + _, err := c.CallAPI(ctx, "logseq.Editor.addTagExtends", tagID, parentTagIDOrName) + return wrapCapabilityUnavailable("logseq.Editor.addTagExtends", err) +} + +// RemoveTagExtends removes parent tag relation for a tag. +func (c *Client) RemoveTagExtends(ctx context.Context, tagID, parentTagIDOrName string) error { + _, err := c.CallAPI(ctx, "logseq.Editor.removeTagExtends", tagID, parentTagIDOrName) + return wrapCapabilityUnavailable("logseq.Editor.removeTagExtends", err) +} + +// AddBlockTag adds a tag to a block. +func (c *Client) AddBlockTag(ctx context.Context, blockID, tagID string) error { + _, err := c.CallAPI(ctx, "logseq.Editor.addBlockTag", blockID, tagID) + return wrapCapabilityUnavailable("logseq.Editor.addBlockTag", err) +} + +// RemoveBlockTag removes a tag from a block. +func (c *Client) RemoveBlockTag(ctx context.Context, blockID, tagID string) error { + _, err := c.CallAPI(ctx, "logseq.Editor.removeBlockTag", blockID, tagID) + return wrapCapabilityUnavailable("logseq.Editor.removeBlockTag", err) +} + +// 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{}{} + } + } + + // 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 != "" { + 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 +} + +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 +} diff --git a/pkg/logseq/types.go b/pkg/logseq/types.go new file mode 100644 index 0000000..dd3dcff --- /dev/null +++ b/pkg/logseq/types.go @@ -0,0 +1,159 @@ +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"` + Journal bool `json:"journal,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. +// Uses custom UnmarshalJSON to handle both standard and namespaced (block/xxx) field names. +type SearchBlock struct { + UUID string `json:"uuid,omitempty"` + Content string `json:"content,omitempty"` + Page string `json:"page,omitempty"` +} + +// UnmarshalJSON handles Logseq search results which may use namespaced keys +// like "block/uuid", "block/content", and have page as int, string, or object. +func (b *SearchBlock) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + tryString := func(keys ...string) string { + for _, k := range keys { + v, ok := raw[k] + if !ok { + continue + } + var s string + if json.Unmarshal(v, &s) == nil { + return s + } + } + return "" + } + + b.UUID = tryString("uuid", "block/uuid") + b.Content = tryString("content", "block/content") + b.Page = tryString("page", "block/page") + + // Page may be an integer ID — convert to string for display + if b.Page == "" { + for _, k := range []string{"page", "block/page"} { + v, ok := raw[k] + if !ok { + continue + } + var id int64 + if json.Unmarshal(v, &id) == nil { + b.Page = "#" + json.Number(v).String() + break + } + // Try object form {id: N, name: "...", ...} + var obj map[string]json.RawMessage + if json.Unmarshal(v, &obj) == nil { + for _, nk := range []string{"name", "original-name", "originalName", "block/name"} { + if nv, ok := obj[nk]; ok { + var s string + if json.Unmarshal(nv, &s) == nil && s != "" { + b.Page = s + break + } + } + } + } + } + } + + return nil +} diff --git a/pkg/logseq/types_test.go b/pkg/logseq/types_test.go new file mode 100644 index 0000000..bbee4e8 --- /dev/null +++ b/pkg/logseq/types_test.go @@ -0,0 +1,154 @@ +package logseq + +import ( + "encoding/json" + "testing" +) + +func TestSearchBlock_UnmarshalJSON_StandardKeys(t *testing.T) { + input := `{"uuid":"abc-123","content":"hello world","page":"my-page"}` + var b SearchBlock + if err := json.Unmarshal([]byte(input), &b); err != nil { + t.Fatal(err) + } + if b.UUID != "abc-123" { + t.Errorf("UUID = %q, want %q", b.UUID, "abc-123") + } + if b.Content != "hello world" { + t.Errorf("Content = %q, want %q", b.Content, "hello world") + } + if b.Page != "my-page" { + t.Errorf("Page = %q, want %q", b.Page, "my-page") + } +} + +func TestSearchBlock_UnmarshalJSON_NamespacedKeys(t *testing.T) { + input := `{"block/uuid":"def-456","block/content":"namespaced content","block/page":"ns-page"}` + var b SearchBlock + if err := json.Unmarshal([]byte(input), &b); err != nil { + t.Fatal(err) + } + if b.UUID != "def-456" { + t.Errorf("UUID = %q, want %q", b.UUID, "def-456") + } + if b.Content != "namespaced content" { + t.Errorf("Content = %q, want %q", b.Content, "namespaced content") + } + if b.Page != "ns-page" { + t.Errorf("Page = %q, want %q", b.Page, "ns-page") + } +} + +func TestSearchBlock_UnmarshalJSON_PageAsInt(t *testing.T) { + input := `{"uuid":"aaa","content":"test","page":56312}` + var b SearchBlock + if err := json.Unmarshal([]byte(input), &b); err != nil { + t.Fatal(err) + } + if b.Page != "#56312" { + t.Errorf("Page = %q, want %q", b.Page, "#56312") + } +} + +func TestSearchBlock_UnmarshalJSON_PageAsObject(t *testing.T) { + input := `{"uuid":"bbb","content":"test","page":{"id":100,"name":"my-page","original-name":"My Page"}}` + var b SearchBlock + if err := json.Unmarshal([]byte(input), &b); err != nil { + t.Fatal(err) + } + if b.Page != "my-page" { + t.Errorf("Page = %q, want %q", b.Page, "my-page") + } +} + +func TestSearchBlock_UnmarshalJSON_PageAsObjectOriginalName(t *testing.T) { + input := `{"uuid":"ccc","content":"test","page":{"id":200,"originalName":"Original Page"}}` + var b SearchBlock + if err := json.Unmarshal([]byte(input), &b); err != nil { + t.Fatal(err) + } + if b.Page != "Original Page" { + t.Errorf("Page = %q, want %q", b.Page, "Original Page") + } +} + +func TestSearchBlock_UnmarshalJSON_EmptyObject(t *testing.T) { + input := `{}` + var b SearchBlock + if err := json.Unmarshal([]byte(input), &b); err != nil { + t.Fatal(err) + } + if b.UUID != "" || b.Content != "" || b.Page != "" { + t.Errorf("expected empty SearchBlock, got %+v", b) + } +} + +func TestSearchBlock_UnmarshalJSON_MixedKeys(t *testing.T) { + // Standard UUID but namespaced content + input := `{"uuid":"mix-789","block/content":"mixed content","page":42}` + var b SearchBlock + if err := json.Unmarshal([]byte(input), &b); err != nil { + t.Fatal(err) + } + if b.UUID != "mix-789" { + t.Errorf("UUID = %q, want %q", b.UUID, "mix-789") + } + if b.Content != "mixed content" { + t.Errorf("Content = %q, want %q", b.Content, "mixed content") + } + if b.Page != "#42" { + t.Errorf("Page = %q, want %q", b.Page, "#42") + } +} + +func TestSearchResult_UnmarshalJSON(t *testing.T) { + input := `{ + "blocks": [ + {"uuid":"a1","content":"block one","page":"p1"}, + {"block/uuid":"a2","block/content":"block two","block/page":123} + ], + "pages": ["page-one","page-two"], + "files": ["file.md"] + }` + var sr SearchResult + if err := json.Unmarshal([]byte(input), &sr); err != nil { + t.Fatal(err) + } + if len(sr.Blocks) != 2 { + t.Fatalf("Blocks len = %d, want 2", len(sr.Blocks)) + } + if sr.Blocks[0].UUID != "a1" { + t.Errorf("Blocks[0].UUID = %q, want %q", sr.Blocks[0].UUID, "a1") + } + if sr.Blocks[1].UUID != "a2" { + t.Errorf("Blocks[1].UUID = %q, want %q", sr.Blocks[1].UUID, "a2") + } + if sr.Blocks[1].Page != "#123" { + t.Errorf("Blocks[1].Page = %q, want %q", sr.Blocks[1].Page, "#123") + } + if len(sr.Pages) != 2 { + t.Errorf("Pages len = %d, want 2", len(sr.Pages)) + } +} + +func TestPageRef_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input string + want int64 + }{ + {"integer", "42", 42}, + {"object", `{"id":99}`, 99}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var r PageRef + if err := json.Unmarshal([]byte(tt.input), &r); err != nil { + t.Fatal(err) + } + if r.ID != tt.want { + t.Errorf("ID = %d, want %d", r.ID, tt.want) + } + }) + } +}