Skill: weiwei2027/siyuan (version 1.0.3)

--- name: "SiYuan Note" description: SiYuan Note (思源笔记) API client - Complete notebook, document and block management homepage: https://github.com/siyuan-note/siyuan api_docs: https://www.siyuan-note.club/apis version: "1.0.3" author: "weiwei" metadata: { "openclaw": { "emoji": "🧠", "requires": { "bins": ["python3"] }, "install": [{ "kind": "none", "label": "No external deps required" }], }, } --- # SiYuan Note (思源笔记) A clean API client for SiYuan Note, providing access to your notes via the local HTTP API. **ClawHub**: https://clawhub.ai/weiwei2027/siyuan **Install**: `clawhub install siyuan` **Version**: 1.0.1 ## Prerequisites - SiYuan running with API enabled (Settings → About → API) - API token from SiYuan settings ## Configuration 1. Get your API token from SiYuan: **Settings → About → API → Copy Token** 2. Create/edit `~/.openclaw/workspace/skills/siyuan/config.yaml`: ```yaml siyuan: base_url: "http://127.0.0.1:6806" # Check SiYuan settings for actual port [REDACTED_SECRET]" # Paste your token here timeout: 30 retry: 3 ``` **Note**: SiYuan may use different ports on restart (default 6806, but could be 34669, etc.). Check the current port in SiYuan settings. ## Python Client API ### Initialize Client ```python from siyuan_client import SiYuanClient # Use config.yaml settings client = SiYuanClient() # Or explicit configuration client = SiYuanClient( base_url="http://127.0.0.1:6806", [REDACTED_SECRET]" ) ``` ### System Operations ```python # Get system version version = client.system_version() print(f"SiYuan v{version}") # Get current time timestamp = client.current_time() ``` ### Notebook Operations ```python # List all notebooks notebooks = client.list_notebooks() for nb in notebooks: print(f"{nb['name']}: {nb['id']}") # Create new notebook new_nb = client.create_notebook("我的新项目") # Open/close notebook (load/unload from memory) client.open_notebook("notebook-id") client.close_notebook("notebook-id") # Rename notebook client.rename_notebook("notebook-id", "新名称") # Get/set notebook configuration conf = client.get_notebook_conf("notebook-id") client.set_notebook_conf("notebook-id", {"dailyNoteSavePath": "/daily"}) # Remove notebook client.remove_notebook("notebook-id") ``` ### Document Operations ```python # Export document as Markdown result = client.export_md_content("doc-id") print(result['hPath']) # Human-readable path print(result['content']) # Markdown content # Create new document new_doc = client.create_doc_with_md( notebook_id="notebook-id", path="folder/document-name", # Supports nested paths markdown="# Title\n\nContent here" ) # Rename document client.rename_doc("notebook-id", "/old-path", "新标题") client.rename_doc_by_id("doc-id", "新标题") # Get document paths hpath = client.get_hpath_by_id("doc-id") # Human-readable path path_info = client.get_path_by_id("doc-id") # Storage path ids = client.get_ids_by_hpath("notebook-id", "/人类可读路径") # Move documents by ID client.move_docs_by_id( doc_ids=["doc-id-1", "doc-id-2"], to_id="target-notebook-or-doc-id" ) # Move documents by path client.move_docs( from_paths=["/path/to/doc1.sy", "/path/to/doc2.sy"], to_notebook="target-notebook-id", to_path="/subfolder" ) # Remove document by notebook and path client.remove_doc("notebook-id", "/path/to/doc.sy") # Remove document by ID client.remove_doc_by_id("doc-id") ``` ### Block Operations ```python # Insert blocks at specific position blocks = client.insert_block( data_type="markdown", data="## New Section\n\nSome content", parent_id="doc-id", # Optional: parent block/document previous_id="block-id", # Optional: insert after this block next_id="block-id" # Optional: insert before this block ) # Prepend to beginning of document blocks = client.prepend_block( data_type="markdown", data="# Title\n", parent_id="doc-id" ) # Append to end of document blocks = client.append_block( data_type="markdown", data="\n---\nFooter here", parent_id="doc-id" ) # Update block content client.update_block( data_type="markdown", data="Updated content", block_id="block-id" ) # Delete block client.delete_block("block-id") # Move block client.move_block( block_id="block-id", previous_id="target-block-id", # Optional: insert after this block parent_id="parent-block-id" # Optional: set parent (at least one required) ) # Fold/unfold (collapse/expand) blocks client.fold_block("block-id") client.unfold_block("block-id") # Transfer block references client.transfer_block_ref( from_id="source-block-id", to_id="target-block-id", ref_ids=["ref-1", "ref-2"] # Optional: specific refs to transfer ) # Get block in Kramdown format kramdown = client.get_block_kramdown("block-id") # Get child blocks children = client.get_child_blocks("container-block-id") for child in children: print(f"{child['type']}: {child['content'][:50]}") ``` ### Block Attributes ```python # Set custom attributes on a block client.set_block_attrs("block-id", { "custom-key": "value", "custom-priority": "high", "custom-status": "done" }) # Get all attributes of a block attrs = client.get_block_attrs("block-id") print(attrs.get("custom-key")) ``` ### Assets ```python # Upload asset files result = client.upload_asset( file_paths=["/path/to/image.png", "/path/to/doc.pdf"], assets_dir_path="/assets/" ) print(result['succMap']) # Successfully uploaded files print(result['errFiles']) # Failed uploads ``` ### SQL Operations ```python # Execute SQL query results = client.query_sql(""" SELECT * FROM blocks WHERE type = 'd' ORDER BY updated DESC LIMIT 10 """) # Flush SQLite transaction to disk client.flush_transaction() ``` ### Templates ```python # Render a template file result = client.render_template( doc_id="doc-id", template_path="/data/templates/daily.md" ) print(result['content']) # Render Sprig template string output = client.render_sprig('/daily note/{{now | date "2006/01"}}/{{now | date "2006-01-02"}}') ``` ### File Operations ```python # Read file content content = client.get_file("/data/20210808180117-6v0mkxr/20200923234011-ieuun1p.sy") # Create directory client.put_file("/data/new-folder", is_dir=True) # Upload file with open("local-file.txt", "rb") as f: client.put_file("/data/new-folder/file.txt", file_content=f.read()) # List directory files = client.read_dir("/data/20210808180117-6v0mkxr") for f in files: print(f"{'[DIR]' if f['isDir'] else '[FILE]'} {f['name']}") # Rename/move file client.rename_file("/data/old-name.sy", "/data/new-name.sy") # Remove file client.remove_file("/data/unwanted-file.sy") ``` ### Export ```python # Export document as Markdown result = client.export_md_content("doc-id") print(result['hPath']) # Human-readable path print(result['content']) # Markdown content # Export multiple files/folders as zip zip_path = client.export_resources( paths=["/conf/appearance/boot", "/conf/appearance/langs"], name="my-export" ) print(f"Exported to: {zip_path}") ``` ### Conversion ```python # Run Pandoc conversion # 1. Put input file client.put_file("/temp/convert/pandoc/mydir/input.epub", file_content=epub_bytes) # 2. Run conversion work_dir = client.pandoc("mydir", ["--to", "markdown_strict", "input.epub", "-o", "output.md"]) # 3. Get output file output = client.get_file("/temp/convert/pandoc/mydir/output.md") ``` ### Notifications ```python # Push message to SiYuan UI msg_id = client.push_msg("Hello from API!") # Push error message err_id = client.push_err_msg("Something went wrong!", timeout=10000) ``` ### Network ```python # Forward HTTP request through SiYuan proxy response = client.forward_proxy( url="https://api.example.com/data", method="GET", headers=[{"Authorization": "Bearer token"}] ) print(response['body']) print(response['status']) ``` ### System ```python # Get boot progress progress = client.boot_progress() print(f"Boot: {progress['progress']}% - {progress['details']}") # Get system version version = client.system_version() print(f"SiYuan v{version}") # Get current time (milliseconds) timestamp = client.current_time() ``` ### SQL Query ```python # Execute SQL query (read-only recommended) results = client.query_sql(""" SELECT * FROM blocks WHERE content LIKE '%关键词%' ORDER BY updated DESC LIMIT 10 """) for block in results: print(f"{block['content'][:100]}...") ``` ## CLI Tools All tools are located in `tools/` directory and depend on `siyuan_client.py`. ### List ```bash # List all notebooks python3 tools/list.py --notebooks # List documents in a notebook python3 tools/list.py --docs "notebook-id" # Output as JSON python3 tools/list.py -n -j ``` ### Read ```bash # Read document content python3 tools/read.py 20240602141622-l7ou7t7 # Save to file python3 tools/read.py 20240602141622-l7ou7t7 -o ~/doc.md # Show document metadata python3 tools/read.py 20240602141622-l7ou7t7 --info ``` ### Search ```bash # Search by keyword python3 tools/search.py "keyword" # Limit results python3 tools/search.py "keyword" -l 50 # Raw SQL query python3 tools/search.py "SELECT * FROM blocks WHERE type='d' LIMIT 10" --sql ``` ### Export ```bash # Export all notebooks python3 tools/export.py -o ~/backup/ # Export specific notebook python3 tools/export.py -n "工作" -o ~/backup/ # Export single document python3 tools/export.py -d 20240602141622-l7ou7t7 -o ~/doc.md ``` ### Create ```bash # Create notebook python3 tools/create.py --notebook "New Project" # Create document python3 tools/create.py --doc notebook-id /readme "# Hello\n\nWorld" # Create with nested path python3 tools/create.py --doc notebook-id /folder/doc "## Title\nContent" ``` ### Delete ```bash # Delete notebook python3 tools/delete.py --notebook notebook-id # Delete document python3 tools/delete.py --doc doc-id # Delete block python3 tools/delete.py --block block-id # Skip confirmation python3 tools/delete.py --doc doc-id --yes ``` ### Move ```bash # Move single document python3 tools/move.py --doc doc-id --to-notebook target-nb-id # Move multiple documents python3 tools/move.py --docs id1 id2 id3 --to-notebook target-nb-id # Move by path python3 tools/move.py --from-paths /doc1.sy /doc2.sy --to-nb target-nb --to-path /folder/ ``` ### Update ```bash # Update a block python3 tools/update.py --block block-id --markdown "New content" # Append to document python3 tools/update.py --append doc-id --markdown "\n\nFooter" # Prepend to document python3 tools/update.py --prepend doc-id --markdown "# Header\n" # Insert block python3 tools/update.py --insert "New paragraph" --parent doc-id ``` ## Safety Features - ✅ All write operations are logged - ✅ Automatic retry with exponential backoff - ✅ Connection health checks - ✅ Comprehensive error handling - ✅ Read-only by default for queries ## Troubleshooting ### Connection Refused 1. Check if SiYuan is running 2. Verify API is enabled in Settings → About → API 3. Check the correct port in config.yaml ### Authentication Failed 1. Get fresh token from SiYuan: Settings → About → API 2. Update config.yaml with new token ### Port Changes SiYuan may use different ports on restart. Check current port: ```bash ss -tlnp | grep SiYuan ``` Then update config.yaml accordingly. ## API Reference - **Local API Documentation**: See [`API.md`](./API.md) in this directory (downloaded from [official repo](https://github.com/siyuan-note/siyuan/blob/master/API.md)) - **Online API Docs**: https://www.siyuan-note.club/apis - **Official Repository**: https://github.com/siyuan-note/siyuan ## Changelog ### v1.0.0 (2026-03-20) - Added complete API client with automatic retry - Added 8 CLI tools for all common operations - Added bilingual documentation (Chinese/English) - Added configuration file support - Production-ready with comprehensive error handling ### v0.5.0 (2026-03-18) - Initial release

path: API.md
content: [中文](https://github.com/siyuan-note/siyuan/blob/master/API_zh_CN.md)| [日本語](https://github.com/siyuan-note/siyuan/blob/master/API_ja_JP.md)

* [Specification](#Specification)
    * [Parameters and return values](#Parameters-and-return-values)
    * [Authentication](#Authentication)
* [Notebooks](#Notebooks)
    * [List notebooks](#List-notebooks)
    * [Open a notebook](#Open-a-notebook)
    * [Close a notebook](#Close-a-notebook)
    * [Rename a notebook](#Rename-a-notebook)
    * [Create a notebook](#Create-a-notebook)
    * [Remove a notebook](#Remove-a-notebook)
    * [Get notebook configuration](#Get-notebook-configuration)
    * [Save notebook configuration](#Save-notebook-configuration)
* [Documents](#Documents)
    * [Create a document with Markdown](#Create-a-document-with-Markdown)
    * [Rename a document](#Rename-a-document)
    * [Remove a document](#Remove-a-document)
    * [Move documents](#Move-documents)
    * [Get human-readable path based on path](#Get-human-readable-path-based-on-path)
    * [Get human-readable path based on ID](#Get-human-readable-path-based-on-ID)
    * [Get storage path based on ID](#Get-storage-path-based-on-ID)
    * [Get IDs based on human-readable path](#Get-IDs-based-on-human-readable-path)
* [Assets](#Assets)
    * [Upload assets](#Upload-assets)
* [Blocks](#Blocks)
    * [Insert blocks](#Insert-blocks)
    * [Prepend blocks](#Prepend-blocks)
    * [Append blocks](#Append-blocks)
    * [Update a block](#Update-a-block)
    * [Delete a block](#Delete-a-block)
    * [Move a block](#Move-a-block)
    * [Fold a block](#Fold-a-block)
    * [Unfold a block](#Unfold-a-block)
    * [Get a block kramdown](#Get-a-block-kramdown)
    * [Get child blocks](#get-child-blocks)
    * [Transfer block ref](#transfer-block-ref)
* [Attributes](#Attributes)
    * [Set block attributes](#Set-block-attributes)
    * [Get block attributes](#Get-block-attributes)
* [SQL](#SQL)
    * [Execute SQL query](#Execute-SQL-query)
    * [Flush transaction](#Flush-transaction)
* [Templates](#Templates)
    * [Render a template](#Render-a-template)
    * [Render Sprig](#Render-Sprig)
* [File](#File)
    * [Get file](#Get-file)
    * [Put file](#Put-file)
    * [Remove file](#Remove-file)
    * [Rename file](#Rename-file)
    * [List files](#List-files)
* [Export](#Export)
    * [Export Markdown](#Export-Markdown)
    * [Export Files and Folders](#Export-files-and-folders)
* [Conversion](#Conversion)
    * [Pandoc](#Pandoc)
* [Notification](#Notification)
    * [Push message](#Push-message)
    * [Push error message](#Push-error-message)
* [Network](#Network)
    * [Forward proxy](#Forward-proxy)
* [System](#System)
    * [Get boot progress](#Get-boot-progress)
    * [Get system version](#Get-system-version)
    * [Get the current time of the system](#Get-the-current-time-of-the-system)

---

## Specification

### Parameters and return values

* Endpoint: `http://127.0.0.1:6806`
* Both are POST methods
* An interface with parameters is required, the parameter is a JSON string, placed in the body, and the header
  Content-Type is `application/json`
* Return value

   ````json
   {
     "code": 0,
     "msg": "",
     "data": {}
   }
   ````

    * `code`: non-zero for exceptions
    * `msg`: an empty string under normal circumstances, an error text will be returned under abnormal conditions
    * `data`: may be `{}`, `[]` or `NULL`, depending on the interface

### Authentication

View API token in <kbd>Settings - About</kbd>, request header: `Authorization: Token xxx`

## Notebooks

### List notebooks

* `/api/notebook/lsNotebooks`
* No parameters
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": {
      "notebooks": [
        {
          "id": "20210817205410-2kvfpfn", 
          "name": "Test Notebook",
          "icon": "1f41b",
          "sort": 0,
          "closed": false
        },
        {
          "id": "20210808180117-czj9bvb",
          "name": "SiYuan User Guide",
          "icon": "1f4d4",
          "sort": 1,
          "closed": false
        }
      ]
    }
  }
  ```

### Open a notebook

* `/api/notebook/openNotebook`
* Parameters

  ```json
  {
    "notebook": "20210831090520-7dvbdv0"
  }
  ```

    * `notebook`: Notebook ID
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": null
  }
  ```

### Close a notebook

* `/api/notebook/closeNotebook`
* Parameters

  ```json
  {
    "notebook": "20210831090520-7dvbdv0"
  }
  ```

    * `notebook`: Notebook ID
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": null
  }
  ```

### Rename a notebook

* `/api/notebook/renameNotebook`
* Parameters

  ```json
  {
    "notebook": "20210831090520-7dvbdv0",
    "name": "New name for notebook"
  }
  ```

    * `notebook`: Notebook ID
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": null
  }
  ```

### Create a notebook

* `/api/notebook/createNotebook`
* Parameters

  ```json
  {
    "name": "Notebook name"
  }
  ```
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": {
      "notebook": {
        "id": "20220126215949-r1wvoch",
        "name": "Notebook name",
        "icon": "",
        "sort": 0,
        "closed": false
      }
    }
  }
  ```

### Remove a notebook

* `/api/notebook/removeNotebook`
* Parameters

  ```json
  {
    "notebook": "20210831090520-7dvbdv0"
  }
  ```

    * `notebook`: Notebook ID
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": null
  }
  ```

### Get notebook configuration

* `/api/notebook/getNotebookConf`
* Parameters

  ```json
  {
    "notebook": "20210817205410-2kvfpfn"
  }
  ```

    * `notebook`: Notebook ID
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": {
      "box": "20210817205410-2kvfpfn",
      "conf": {
        "name": "Test Notebook",
        "closed": false,
        "refCreateSavePath": "",
        "createDocNameTemplate": "",
        "dailyNoteSavePath": "/daily note/{{now | date \"2006/01\"}}/{{now | date \"2006-01-02\"}}",
        "dailyNoteTemplatePath": ""
      },
      "name": "Test Notebook"
    }
  }
  ```

### Save notebook configuration

* `/api/notebook/setNotebookConf`
* Parameters

  ```json
  {
    "notebook": "20210817205410-2kvfpfn",
    "conf": {
        "name": "Test Notebook",
        "closed": false,
        "refCreateSavePath": "",
        "createDocNameTemplate": "",
        "dailyNoteSavePath": "/daily note/{{now | date \"2006/01\"}}/{{now | date \"2006-01-02\"}}",
        "dailyNoteTemplatePath": ""
      }
  }
  ```

    * `notebook`: Notebook ID
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": {
      "name": "Test Notebook",
      "closed": false,
      "refCreateSavePath": "",
      "createDocNameTemplate": "",
      "dailyNoteSavePath": "/daily note/{{now | date \"2006/01\"}}/{{now | date \"2006-01-02\"}}",
      "dailyNoteTemplatePath": ""
    }
  }
  ```

## Documents

### Create a document with Markdown

* `/api/filetree/createDocWithMd`
* Parameters

  ```json
  {
    "notebook": "20210817205410-2kvfpfn",
    "path": "/foo/bar",
    "markdown": ""
  }
  ```

    * `notebook`: Notebook ID
    * `path`: Document path, which needs to start with / and separate levels with / (path here corresponds to the
      database hpath field)
    * `markdown`: GFM Markdown content
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": "20210914223645-oj2vnx2"
  }
  ```

    * `data`: Created document ID
    * If you use the same `path` to call this interface repeatedly, the existing document will not be overwritten

### Rename a document

* `/api/filetree/renameDoc`
* Parameters

  ```json
  {
    "notebook": "20210831090520-7dvbdv0",
    "path": "/20210902210113-0avi12f.sy",
    "title": "New document title"
  }
  ```

    * `notebook`: Notebook ID
    * `path`: Document path
    * `title`: New document title
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": null
  }
  ```

Rename a document by `id`:

* `/api/filetree/renameDocByID`
* Parameters

  ```json
  {
    "id": "20210902210113-0avi12f",
    "title": "New document title"
  }
  ```

    * `id`: Document ID
    * `title`: New document title
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": null
  }
  ```

### Remove a document

* `/api/filetree/removeDoc`
* Parameters

  ```json
  {
    "notebook": "20210831090520-7dvbdv0",
    "path": "/20210902210113-0avi12f.sy"
  }
  ```

    * `notebook`: Notebook ID
    * `path`: Document path
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": null
  }
  ```

Remove a document by `id`:

* `/api/filetree/removeDocByID`
* Parameters

  ```json
  {
    "id": "20210902210113-0avi12f"
  }
  ```

    * `id`: Document ID
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": null
  }
  ```

### Move documents

* `/api/filetree/moveDocs`
* Parameters

  ```json
  {
    "fromPaths": ["/20210917220056-yxtyl7i.sy"],
    "toNotebook": "20210817205410-2kvfpfn",
    "toPath": "/"
  }
  ```

    * `fromPaths`: Source paths
    * `toNotebook`: Target notebook ID
    * `toPath`: Target path
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": null
  }
  ```

Move documents by `id`:

* `/api/filetree/moveDocsByID`
* Parameters

  ```json
  {
    "fromIDs": ["20210917220056-yxtyl7i"],
    "toID": "20210817205410-2kvfpfn"
  }
  ```

    * `fromIDs`: Source docs' IDs
    * `toID`: Target parent doc's ID or notebook ID
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": null
  }
  ```

### Get human-readable path based on path

* `/api/filetree/getHPathByPath`
* Parameters

  ```json
  {
    "notebook": "20210831090520-7dvbdv0",
    "path": "/20210917220500-sz588nq/20210917220056-yxtyl7i.sy"
  }
  ```

    * `notebook`: Notebook ID
    * `path`: Document path
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": "/foo/bar"
  }
  ```

### Get human-readable path based on ID

* `/api/filetree/getHPathByID`
* Parameters

  ```json
  {
    "id": "20210917220056-yxtyl7i"
  }
  ```

    * `id`: Block ID
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": "/foo/bar"
  }
  ```

### Get storage path based on ID

* `/api/filetree/getPathByID`
* Parameters

  ```json
  {
    "id": "20210808180320-fqgskfj"
  }
  ```

    * `id`: Block ID
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": {
    "notebook": "20210808180117-czj9bvb",
    "path": "/20200812220555-lj3enxa/20210808180320-fqgskfj.sy"
    }
  }
  ```

### Get IDs based on human-readable path

* `/api/filetree/getIDsByHPath`
* Parameters

  ```json
  {
    "path": "/foo/bar",
    "notebook": "20210808180117-czj9bvb"
  }
  ```

    * `path`: Human-readable path
    * `notebook`: Notebook ID
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": [
        "20200813004931-q4cu8na"
    ]
  }
  ```

## Assets

### Upload assets

* `/api/asset/upload`
* The parameter is an HTTP Multipart form

    * `assetsDirPath`: The folder path where assets are stored, with the data folder as the root path, for example:
        * `"/assets/"`: workspace/data/assets/ folder
        * `"/assets/sub/"`: workspace/data/assets/sub/ folder

      Under normal circumstances, it is recommended to use the first method, which is stored in the assets folder of the
      workspace, putting in a subdirectory has some side effects, please refer to the assets chapter of the user guide.
    * `file[]`: Uploaded file list
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": {
      "errFiles": [""],
      "succMap": {
        "foo.png": "assets/foo-20210719092549-9j5y79r.png"
      }
    }
  }
  ```

    * `errFiles`: List of filenames with errors in upload processing
    * `succMap`: For successfully processed files, the key is the file name when uploading, and the value is
      assets/foo-id.png, which is used to replace the asset link address in the existing Markdown content with the
      uploaded address

## Blocks

### Insert blocks

* `/api/block/insertBlock`
* Parameters

  ```json
  {
    "dataType": "markdown",
    "data": "foo**bar**{: style=\"color: var(--b3-font-color8);\"}baz",
    "nextID": "",
    "previousID": "20211229114650-vrek5x6",
    "parentID": ""
  }
  ```

    * `dataType`: The data type to be inserted, the value can be `markdown` or `dom`
    * `data`: Data to be inserted
    * `nextID`: The ID of the next block, used to anchor the insertion position
    * `previousID`: The ID of the previous block, used to anchor the insertion position
    * `parentID`: The ID of the parent block, used to anchor the insertion position

  `nextID`, `previousID`, and `parentID` must have at least one value, using priority: `nextID` > `previousID` >
  `parentID`
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": [
      {
        "doOperations": [
          {
            "action": "insert",
            "data": "<div data-node-id=\"20211230115020-g02dfx0\" data-node-index=\"1\" data-type=\"NodeParagraph\" class=\"p\"><div contenteditable=\"true\" spellcheck=\"false\">foo<strong style=\"color: var(--b3-font-color8);\">bar</strong>baz</div><div class=\"protyle-attr\" contenteditable=\"false\"></div></div>",
            "id": "20211230115020-g02dfx0",
            "parentID": "",
            "previousID": "20211229114650-vrek5x6",
            "retData": null
          }
        ],
        "undoOperations": null
      }
    ]
  }
  ```

    * `action.data`: DOM generated by the newly inserted block
    * `action.id`: ID of the newly inserted block

### Prepend blocks

* `/api/block/prependBlock`
* Parameters

  ```json
  {
    "data": "foo**bar**{: style=\"color: var(--b3-font-color8);\"}baz",
    "dataType": "markdown",
    "parentID": "20220107173950-7f9m1nb"
  }
  ```

    * `dataType`: The data type to be inserted, the value can be `markdown` or `dom`
    * `data`: Data to be inserted
    * `parentID`: The ID of the parent block, used to anchor the insertion position
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": [
      {
        "doOperations": [
          {
            "action": "insert",
            "data": "<div data-node-id=\"20220108003710-hm0x9sc\" data-node-index=\"1\" data-type=\"NodeParagraph\" class=\"p\"><div contenteditable=\"true\" spellcheck=\"false\">foo<strong style=\"color: var(--b3-font-color8);\">bar</strong>baz</div><div class=\"protyle-attr\" contenteditable=\"false\"></div></div>",
            "id": "20220108003710-hm0x9sc",
            "parentID": "20220107173950-7f9m1nb",
            "previousID": "",
            "retData": null
          }
        ],
        "undoOperations": null
      }
    ]
  }
  ```

    * `action.data`: DOM generated by the newly inserted block
    * `action.id`: ID of the newly inserted block

### Append blocks

* `/api/block/appendBlock`
* Parameters

  ```json
  {
    "data": "foo**bar**{: style=\"color: var(--b3-font-color8);\"}baz",
    "dataType": "markdown",
    "parentID": "20220107173950-7f9m1nb"
  }
  ```

    * `dataType`: The data type to be inserted, the value can be `markdown` or `dom`
    * `data`: Data to be inserted
    * `parentID`: The ID of the parent block, used to anchor the insertion position
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": [
      {
        "doOperations": [
          {
            "action": "insert",
            "data": "<div data-node-id=\"20220108003642-y2wmpcv\" data-node-index=\"1\" data-type=\"NodeParagraph\" class=\"p\"><div contenteditable=\"true\" spellcheck=\"false\">foo<strong style=\"color: var(--b3-font-color8);\">bar</strong>baz</div><div class=\"protyle-attr\" contenteditable=\"false\"></div></div>",
            "id": "20220108003642-y2wmpcv",
            "parentID": "20220107173950-7f9m1nb",
            "previousID": "20220108003615-7rk41t1",
            "retData": null
          }
        ],
        "undoOperations": null
      }
    ]
  }
  ```

    * `action.data`: DOM generated by the newly inserted block
    * `action.id`: ID of the newly inserted block

### Update a block

* `/api/block/updateBlock`
* Parameters

  ```json
  {
    "dataType": "markdown",
    "data": "foobarbaz",
    "id": "20211230161520-querkps"
  }
  ```

    * `dataType`: The data type to be updated, the value can be `markdown` or `dom`
    * `data`: Data to be updated
    * `id`: ID of the block to be updated
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": [
      {
        "doOperations": [
          {
            "action": "update",
            "data": "<div data-node-id=\"20211230161520-querkps\" data-node-index=\"1\" data-type=\"NodeParagraph\" class=\"p\"><div contenteditable=\"true\" spellcheck=\"false\">foo<strong>bar</strong>baz</div><div class=\"protyle-attr\" contenteditable=\"false\"></div></div>",
            "id": "20211230161520-querkps",
            "parentID": "",
            "previousID": "",
            "retData": null
            }
          ],
        "undoOperations": null
      }
    ]
  }
  ```

    * `action.data`: DOM generated by the updated block

### Delete a block

* `/api/block/deleteBlock`
* Parameters

  ```json
  {
    "id": "20211230161520-querkps"
  }
  ```

    * `id`: ID of the block to be deleted
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": [
      {
        "doOperations": [
          {
            "action": "delete",
            "data": null,
            "id": "20211230162439-vtm09qo",
            "parentID": "",
            "previousID": "",
            "retData": null
          }
        ],
       "undoOperations": null
      }
    ]
  }
  ```

### Move a block

* `/api/block/moveBlock`
* Parameters

  ```json
  {
    "id": "20230406180530-3o1rqkc",
    "previousID": "20230406152734-if5kyx6",
    "parentID": "20230404183855-woe52ko"
  }
  ```

    * `id`: Block ID to move
    * `previousID`: The ID of the previous block, used to anchor the insertion position
    * `parentID`: The ID of the parent block, used to anchor the insertion position, `previousID` and `parentID` cannot
      be empty at the same time, if they exist at the same time, `previousID` will be used first
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": [
        {
            "doOperations": [
                {
                    "action": "move",
                    "data": null,
                    "id": "20230406180530-3o1rqkc",
                    "parentID": "20230404183855-woe52ko",
                    "previousID": "20230406152734-if5kyx6",
                    "nextID": "",
                    "retData": null,
                    "srcIDs": null,
                    "name": "",
                    "type": ""
                }
            ],
            "undoOperations": null
        }
    ]
  }
  ```

### Fold a block

* `/api/block/foldBlock`
* Parameters

  ```json
  {
    "id": "20231224160424-2f5680o"
  }
  ```

    * `id`: Block ID to fold
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": null
  }
  ```

### Unfold a block

* `/api/block/unfoldBlock`
* Parameters

  ```json
  {
    "id": "20231224160424-2f5680o"
  }
  ```

    * `id`: Block ID to unfold
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": null
  }
  ```

### Get a block kramdown

* `/api/block/getBlockKramdown`
* Parameters

  ```json
  {
    "id": "20201225220954-dlgzk1o"
  }
  ```

    * `id`: ID of the block to be got
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": {
      "id": "20201225220954-dlgzk1o",
      "kramdown": "* {: id=\"20201225220954-e913snx\"}Create a new notebook, create a new document under the notebook\n  {: id=\"20210131161940-kfs31q6\"}\n* {: id=\"20201225220954-ygz217h\"}Enter <kbd>/</kbd> in the editor to trigger the function menu\n  {: id=\"20210131161940-eo0riwq\"}\n* {: id=\"20201225220954-875yybt\"}((20200924101200-gss5vee \"Navigate in the content block\")) and ((20200924100906-0u4zfq3 \"Window and tab\"))\n  {: id=\"20210131161940-b5uow2h\"}"
    }
  }
  ```

### Get child blocks

* `/api/block/getChildBlocks`
* Parameters

  ```json
  {
    "id": "20230506212712-vt9ajwj"
  }
  ```

    * `id`: Parent block ID
    * The blocks below a heading are also counted as child blocks
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": [
      {
        "id": "20230512083858-mjdwkbn",
        "type": "h",
        "subType": "h1"
      },
      {
        "id": "20230513213727-thswvfd",
        "type": "s"
      },
      {
        "id": "20230513213633-9lsj4ew",
        "type": "l",
        "subType": "u"
      }
    ]
  }
  ```

### Transfer block ref

* `/api/block/transferBlockRef`
* Parameters

  ```json
  {
    "fromID": "20230612160235-mv6rrh1",
    "toID": "20230613093045-uwcomng",
    "refIDs": ["20230613092230-cpyimmd"]
  }
  ```

    * `fromID`: Def block ID
    * `toID`: Target block ID
    * `refIDs`: Ref block IDs point to def block ID, optional, if not specified, all ref block IDs will be transferred
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": null
  }
  ```

## Attributes

### Set block attributes

* `/api/attr/setBlockAttrs`
* Parameters

  ```json
  {
    "id": "20210912214605-uhi5gco",
    "attrs": {
      "custom-attr1": "line1\nline2"
    }
  }
  ```

    * `id`: Block ID
    * `attrs`: Block attributes, custom attributes must be prefixed with `custom-`
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": null
  }
  ```

### Get block attributes

* `/api/attr/getBlockAttrs`
* Parameters

  ```json
  {
    "id": "20210912214605-uhi5gco"
  }
  ```

    * `id`: Block ID
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": {
      "custom-attr1": "line1\nline2",
      "id": "20210912214605-uhi5gco",
      "title": "PDF Annotation Demo",
      "type": "doc",
      "updated": "20210916120715"
    }
  }
  ```

## SQL

### Execute SQL query

* `/api/query/sql`
* Parameters

  ```json
  {
    "stmt": "SELECT * FROM blocks WHERE content LIKE'%content%' LIMIT 7"
  }
  ```

    * `stmt`: SQL statement
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": [
      { "col": "val" }
    ]
  }
  ```

Note: In publish mode, access to this interface will be prohibited unless all document read and write permissions are made public. Please refer to the [discussion](https://github.com/siyuan-note/siyuan/pull/16041#issuecomment-3912139575).

### Flush transaction

* `/api/sqlite/flushTransaction`
* No parameters
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": null
  }
  ```

## Templates

### Render a template

* `/api/template/render`
* Parameters

  ```json
  {
    "id": "20220724223548-j6g0o87",
    "path": "F:\\SiYuan\\data\\templates\\foo.md"
  }
  ```

    * `id`: The ID of the document where the rendering is called
    * `path`: Template file absolute path
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": {
      "content": "<div data-node-id=\"20220729234848-dlgsah7\" data-node-index=\"1\" data-type=\"NodeParagraph\" class=\"p\" updated=\"20220729234840\"><div contenteditable=\"true\" spellcheck=\"false\">foo</div><div class=\"protyle-attr\" contenteditable=\"false\">​</div></div>",
      "path": "F:\\SiYuan\\data\\templates\\foo.md"
    }
  }
  ```

### Render Sprig

* `/api/template/renderSprig`
* Parameters

  ```json
  {
    "template": "/daily note/{{now | date \"2006/01\"}}/{{now | date \"2006-01-02\"}}"
  }
  ```
    * `template`: template content
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": "/daily note/2023/03/2023-03-24"
  }
  ```

## File

### Get file

* `/api/file/getFile`
* Parameters

  ``json {
  "path": "/data/20210808180117-6v0mkxr/20200923234011-ieuun1p.sy"
  }
  ``
    * `path`: the file path under the workspace path
* Return value

    * Response status code `200`: File content
    * Response status code `202`: Exception information

      ```json
      {
        "code": 404,
        "msg": "",
        "data": null
      }
      ```

        * `code`: non-zero for exceptions

            * `-1`: Parameter parsing error
            * `403`: Permission denied (file is not in the workspace)
            * `404`: Not Found (file doesn't exist)
            * `405`: Method Not Allowed (it's a directory)
            * `500`: Server Error (stat file failed / read file failed)
        * `msg`: a piece of text describing the error

### Put file

* `/api/file/putFile`
* The parameter is an HTTP Multipart form

    * `path`: the file path under the workspace path
    * `isDir`: whether to create a folder, when `true` only create a folder, ignore `file`
    * `modTime`: last access and modification time, Unix time
    * `file`: the uploaded file
* Return value

   ```json
   {
     "code": 0,
     "msg": "",
     "data": null
   }
   ```

### Remove file

* `/api/file/removeFile`
* Parameters

  ```json
  {
    "path": "/data/20210808180117-6v0mkxr/20200923234011-ieuun1p.sy"
  }
  ```
    * `path`: the file path under the workspace path
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": null
  }
  ```

### Rename file

* `/api/file/renameFile`
* Parameters

  ```json
  {
    "path": "/data/assets/image-20230523085812-k3o9t32.png",
    "newPath": "/data/assets/test-20230523085812-k3o9t32.png"
  }
  ```
    * `path`: the file path under the workspace path
    * `newPath`: the new file path under the workspace path
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": null
  }
  ```

### List files

* `/api/file/readDir`
* Parameters

  ```json
  {
    "path": "/data/20210808180117-6v0mkxr/20200923234011-ieuun1p"
  }
  ```
    * `path`: the dir path under the workspace path
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": [
      {
        "isDir": true,
        "isSymlink": false,
        "name": "20210808180303-6yi0dv5",
        "updated": 1691467624
      },
      {
        "isDir": false,
        "isSymlink": false,
        "name": "20210808180303-6yi0dv5.sy",
        "updated": 1663298365
      }
    ]
  }
  ```

## Export

### Export Markdown

* `/api/export/exportMdContent`
* Parameters

  ```json
  {
    "id": ""
  }
  ```

    * `id`: ID of the doc block to export
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": {
      "hPath": "/Please Start Here",
      "content": "## 🍫 Content Block\n\nIn SiYuan, the only important core concept is..."
    }
  }
  ```

    * `hPath`: human-readable path
    * `content`: Markdown content

### Export files and folders

* `/api/export/exportResources`
* Parameters

  ```json
  {
    "paths": [
      "/conf/appearance/boot",
      "/conf/appearance/langs",
      "/conf/appearance/emojis/conf.json",
      "/conf/appearance/icons/index.html"
    ],
    "name": "zip-file-name"
  }
  ```

    * `paths`: A list of file or folder paths to be exported, the same filename/folder name will be overwritten
    * `name`: (Optional) The exported file name, which defaults to `export-YYYY-MM-DD_hh-mm-ss.zip` when not set
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": {
      "path": "temp/export/zip-file-name.zip"
    }
  }
  ```

    * `path`: The path of `*.zip` file created
        * The directory structure in `zip-file-name.zip` is as follows:
            * `zip-file-name`
                * `boot`
                * `langs`
                * `conf.json`
                * `index.html`

## Conversion

### Pandoc

* `/api/convert/pandoc`
* Working directory
    * Executing the pandoc command will set the working directory to `workspace/temp/convert/pandoc/${dir}`
    * API [`Put file`](#put-file) can be used to write the file to be converted to this directory first
    * Then call the API for conversion, and the converted file will also be written to this directory
    * Finally, call the API [`Get file`](#get-file) to get the converted file
        * Or call the API [Create a document with Markdown](#Create-a-document-with-Markdown)
        * Or call the internal API `importStdMd` to import the converted folder directly
* Parameters

  ```json
  {
    "dir": "test",
    "args": [
      "--to", "markdown_strict-raw_html",
      "foo.epub",
      "-o", "foo.md"
   ]
  }
  ```

    * `args`: Pandoc command line parameters
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": {
       "path": "/temp/convert/pandoc/test"
    }
  }
  ```
    * `path`: the path under the workspace

## Notification

### Push message

* `/api/notification/pushMsg`
* Parameters

  ```json
  {
    "msg": "test",
    "timeout": 7000
  }
  ```
    * `timeout`: The duration of the message display in milliseconds. This field can be omitted, the default is 7000
      milliseconds
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": {
        "id": "62jtmqi"
    }
  }
  ```
    * `id`: Message ID

### Push error message

* `/api/notification/pushErrMsg`
* Parameters

  ```json
  {
    "msg": "test",
    "timeout": 7000
  }
  ```
    * `timeout`: The duration of the message display in milliseconds. This field can be omitted, the default is 7000
      milliseconds
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": {
        "id": "qc9znut"
    }
  }
  ```
    * `id`: Message ID

## Network

### Forward proxy

* `/api/network/forwardProxy`
* Parameters

  ```json
  {
    "url": "https://b3log.org/siyuan/",
    "method": "GET",
    "timeout": 7000,
    "contentType": "text/html",
    "headers": [
        {
            "Cookie": ""
        }
    ],
    "payload": {},
    "payloadEncoding": "text",
    "responseEncoding": "text"
  }
  ```

    * `url`: URL to forward
    * `method`: HTTP method, default is `POST`
    * `timeout`: timeout in milliseconds, default is `7000`
    * `contentType`: Content-Type, default is `application/json`
    * `headers`: HTTP headers
    * `payload`: HTTP payload, object or string
    * `payloadEncoding`: The encoding scheme used by `pyaload`, default is `text`, optional values are as follows

        * `text`
        * `base64` | `base64-std`
        * `base64-url`
        * `base32` | `base32-std`
        * `base32-hex`
        * `hex`
    * `responseEncoding`: The encoding scheme used by `body` in response data, default is `text`, optional values are as
      follows

        * `text`
        * `base64` | `base64-std`
        * `base64-url`
        * `base32` | `base32-std`
        * `base32-hex`
        * `hex`
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": {
      "body": "",
      "bodyEncoding": "text",
      "contentType": "text/html",
      "elapsed": 1976,
      "headers": {
      },
      "status": 200,
      "url": "https://b3log.org/siyuan"
    }
  }
  ```

    * `bodyEncoding`: The encoding scheme used by `body`, is consistent with field `responseEncoding` in request,
      default is `text`, optional values are as follows

        * `text`
        * `base64` | `base64-std`
        * `base64-url`
        * `base32` | `base32-std`
        * `base32-hex`
        * `hex`

## System

### Get boot progress

* `/api/system/bootProgress`
* No parameters
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": {
      "details": "Finishing boot...",
      "progress": 100
    }
  }
  ```

### Get system version

* `/api/system/version`
* No parameters
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": "1.3.5"
  }
  ```

### Get the current time of the system

* `/api/system/currentTime`
* No parameters
* Return value

  ```json
  {
    "code": 0,
    "msg": "",
    "data": 1631850968131
  }
  ```

    * `data`: Precision in milliseconds


path: CHANGELOG.md
content: # Changelog

All notable changes to this project will be documented in this file.

## [1.0.3] - 2026-03-20

### Changed
- Updated ClawHub display name to "SiYuan Note" (install name remains "siyuan")

## [1.0.2] - 2026-03-20

### Fixed
- Display name formatting on ClawHub

## [1.0.1] - 2026-03-20

### Fixed
- Removed unnecessary `curl` from required binaries (uses urllib)
- Fixed config path mismatch: now correctly looks for `skills/siyuan/config.yaml`
- Verified no dangerous unicode control characters (UTF-8 Chinese characters are normal)

## [1.0.0] - 2026-03-20

### Added
- Complete API client with automatic retry and error handling
- 8 CLI tools for common operations (list, read, search, create, delete, update, move, export)
- Full notebook management (create, open, close, rename, remove)
- Document operations (create, read, update, move, export as Markdown)
- Block-level editing (insert, update, delete, move)
- SQL query support for advanced search
- Asset upload (images, attachments)
- Configuration file support (config.yaml)
- Comprehensive documentation (SKILL.md, README.md)
- Bilingual documentation (Chinese/English)

### Security
- API token stored in config.yaml (not included in repository)
- Config template provided (config.example.yaml)

## [0.5.0] - 2026-03-18

### Added
- Initial release
- Basic API client
- Core CLI tools


path: README.md
content: # SiYuan Note API Client

[![Version](https://img.shields.io/badge/version-1.0.0-blue)](https://clawhub.ai)
[![Python](https://img.shields.io/badge/python-3.8%2B-green)](https://python.org)
[![License](https://img.shields.io/badge/license-MIT-yellow)](LICENSE)

> 🧠 思源笔记 (SiYuan Note) 的完整 API 客户端，支持笔记本管理、文档操作、块编辑等全部功能。

[English](#english) | [中文](#中文)

---

## 中文

### ✨ 功能特性

- **📚 笔记本管理** - 创建、打开、关闭、重命名、删除笔记本
- **📝 文档操作** - 创建、读取、更新、移动、导出文档
- **🧩 块级编辑** - 插入、更新、删除、移动内容块
- **🔍 全文搜索** - 基于 SQL 的灵活查询
- **📎 资源管理** - 上传图片、附件等资产文件
- **📤 导入导出** - Markdown、PDF、ZIP 格式支持
- **🔒 安全可靠** - 自动重试、错误处理、连接健康检查

### 🚀 快速开始

#### 1. 安装

```bash
clawhub install siyuan
```

#### 2. 配置

复制配置模板并填入你的 API Token：

```bash
cd ~/.openclaw/workspace/skills/siyuan
cp config.example.yaml config.yaml
```

编辑 `config.yaml`：

```yaml
siyuan:
  base_url: "http://127.0.0.1:6806"
  [REDACTED_SECRET]"  # 从思源笔记设置中获取
  timeout: 30
  retry: 3
```

**获取 Token**：思源笔记 → 设置 → 关于 → API → 复制 Token

#### 3. 使用

**列出所有笔记本：**
```bash
python3 tools/list.py --notebooks
```

**搜索笔记：**
```bash
python3 tools/search.py "关键词"
```

**读取文档：**
```bash
python3 tools/read.py 20240602141622-l7ou7t7
```

**创建文档：**
```bash
python3 tools/create.py --doc notebook-id /readme "# 标题\n\n内容"
```

### 📖 完整文档

查看 [SKILL.md](./SKILL.md) 获取完整的 API 参考和示例。

### 🛠️ Python API

```python
from siyuan_client import SiYuanClient

# 初始化客户端
client = SiYuanClient()

# 列出笔记本
notebooks = client.list_notebooks()

# 读取文档
result = client.export_md_content("doc-id")
print(result['content'])

# 创建文档
client.create_doc_with_md(
    notebook_id="notebook-id",
    path="folder/document",
    markdown="# 新文档\n\n内容"
)
```

---

## English

### ✨ Features

- **📚 Notebook Management** - Create, open, close, rename, remove notebooks
- **📝 Document Operations** - Create, read, update, move, export documents
- **🧩 Block-level Editing** - Insert, update, delete, move content blocks
- **🔍 Full-text Search** - Flexible SQL-based queries
- **📎 Asset Management** - Upload images, attachments
- **📤 Import/Export** - Markdown, PDF, ZIP support
- **🔒 Reliable** - Auto-retry, error handling, health checks

### 🚀 Quick Start

#### 1. Install

```bash
clawhub install siyuan
```

#### 2. Configure

Copy the config template and add your API token:

```bash
cd ~/.openclaw/workspace/skills/siyuan
cp config.example.yaml config.yaml
```

Edit `config.yaml`:

```yaml
siyuan:
  base_url: "http://127.0.0.1:6806"
  [REDACTED_SECRET]"  # Get from SiYuan settings
  timeout: 30
  retry: 3
```

**Get Token**: SiYuan → Settings → About → API → Copy Token

#### 3. Usage

**List all notebooks:**
```bash
python3 tools/list.py --notebooks
```

**Search notes:**
```bash
python3 tools/search.py "keyword"
```

**Read document:**
```bash
python3 tools/read.py 20240602141622-l7ou7t7
```

**Create document:**
```bash
python3 tools/create.py --doc notebook-id /readme "# Title\n\nContent"
```

### 📖 Full Documentation

See [SKILL.md](./SKILL.md) for complete API reference and examples.

### 🛠️ Python API

```python
from siyuan_client import SiYuanClient

# Initialize client
client = SiYuanClient()

# List notebooks
notebooks = client.list_notebooks()

# Read document
result = client.export_md_content("doc-id")
print(result['content'])

# Create document
client.create_doc_with_md(
    notebook_id="notebook-id",
    path="folder/document",
    markdown="# New Document\n\nContent"
)
```

---

## 🔧 CLI Tools

| 工具 | 功能 |
|------|------|
| `list.py` | 列出笔记本和文档 |
| `read.py` | 读取文档内容 |
| `search.py` | 搜索笔记 |
| `create.py` | 创建笔记本/文档 |
| `delete.py` | 删除笔记本/文档/块 |
| `update.py` | 更新块内容 |
| `move.py` | 移动文档 |
| `export.py` | 导出笔记 |

---

## 📋 Requirements

- Python 3.8+
- SiYuan Note running with API enabled
- API Token from SiYuan settings

---

## 📄 License

MIT License - see LICENSE file for details.

---

## 🔗 Links

- **ClawHub**: https://clawhub.ai/weiwei2027/siyuan
- **SiYuan Official**: https://github.com/siyuan-note/siyuan
- **API Docs**: https://www.siyuan-note.club/apis

---

## 🙏 Credits

Created for [OpenClaw](https://openclaw.ai) - AI-powered personal assistant platform.


path: config.example.yaml
content: # SiYuan Note API Configuration
# 思源笔记 API 配置
#
# 1. 从思源笔记获取 API Token:
#    设置 → 关于 → API → 复制 Token
#
# 2. 保存此文件为 config.yaml 并填入你的 Token

siyuan:
  base_url: "http://127.0.0.1:6806"  # API 地址，端口可能在重启后变化
  [REDACTED_SECRET]"       # 填入你的 API Token
  timeout: 30                        # 请求超时时间（秒）
  retry: 3                           # 重试次数


path: siyuan_client.py
content: #!/usr/bin/env python3
"""
SiYuan API Client - Production Grade
思源笔记 API 客户端 - 生产级
"""

import json
import logging
import time
import yaml
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
from urllib.parse import urljoin

# Setup logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger('siyuan')


class SiYuanError(Exception):
    """Base exception for SiYuan API errors"""
    pass


class SiYuanAPIError(SiYuanError):
    """API returned error response"""
    def __init__(self, code: int, msg: str):
        self.code = code
        self.msg = msg
        super().__init__(f"API Error {code}: {msg}")


class SiYuanConnectionError(SiYuanError):
    """Failed to connect to SiYuan"""
    pass


class SiYuanClient:
    """
    Production-grade client for SiYuan Note API.
    
    Features:
    - Automatic retry with exponential backoff
    - Connection pooling (via keep-alive)
    - Comprehensive error handling
    - Request/response logging
    - Config file support
    """
    
    DEFAULT_CONFIG_PATHS = [
        Path.home() / ".openclaw" / "workspace" / "skills" / "siyuan" / "config.yaml",
        Path(__file__).parent / "config.yaml",
    ]
    
    def __init__(
        self,
        base_url: str = None,
        token: str = None,
        timeout: int = 30,
        retry: int = 3,
        verify_ssl: bool = True,
        config_path: Path = None
    ):
        """
        Initialize SiYuan client.
        
        Priority: explicit args > config file > defaults
        
        Args:
            base_url: SiYuan API base URL (e.g., "http://127.0.0.1:6806")
            token: API authorization token
            timeout: Request timeout in seconds
            retry: Number of retry attempts
            verify_ssl: Whether to verify SSL certificates
            config_path: Path to config YAML file
        """
        # Load config from file if exists
        config = self._load_config(config_path)
        
        # Set attributes with priority: explicit > config > default
        self.base_url = (base_url or config.get('base_url', "http://127.0.0.1:6806")).rstrip('/')
        self.token = token or config.get('token')
        self.timeout = timeout or config.get('timeout', 30)
        self.retry = retry or config.get('retry', 3)
        self.verify_ssl = verify_ssl if verify_ssl is not None else config.get('verify_ssl', True)
        
        if not self.token:
            raise SiYuanError("API token is required. Provide it explicitly or in config.yaml")
        
        logger.info(f"Initialized SiYuan client: {self.base_url}")
    
    def _load_config(self, config_path: Path = None) -> Dict[str, Any]:
        """Load configuration from YAML file"""
        paths = [config_path] if config_path else self.DEFAULT_CONFIG_PATHS
        
        for path in paths:
            if path and path.exists():
                try:
                    with open(path, 'r', encoding='utf-8') as f:
                        config = yaml.safe_load(f)
                        if config and 'siyuan' in config:
                            logger.debug(f"Loaded config from {path}")
                            return config['siyuan']
                except Exception as e:
                    logger.warning(f"Failed to load config from {path}: {e}")
        
        return {}
    
    def _make_request(
        self,
        endpoint: str,
        data: Dict[str, Any] = None,
        method: str = "POST"
    ) -> Dict[str, Any]:
        """
        Make HTTP request with retry logic.
        
        Args:
            endpoint: API endpoint (without base URL)
            data: JSON payload
            method: HTTP method
            
        Returns:
            Parsed JSON response
            
        Raises:
            SiYuanAPIError: API returned error
            SiYuanConnectionError: Connection failed after retries
        """
        url = urljoin(self.base_url + "/", endpoint)
        headers = {
            "Authorization": f"Token {self.token}",
            "Content-Type": "application/json",
            "Accept": "application/json"
        }
        
        payload = json.dumps(data or {}).encode('utf-8')
        last_error = None
        
        for attempt in range(self.retry):
            try:
                req = Request(
                    url,
                    data=payload,
                    headers=headers,
                    method=method
                )
                
                with urlopen(req, timeout=self.timeout) as response:
                    result = json.loads(response.read().decode('utf-8'))
                    
                    # Check for API-level errors
                    if result.get('code') != 0:
                        msg = result.get('msg', 'Unknown error')
                        logger.warning(f"API error: {msg}")
                        raise SiYuanAPIError(result['code'], msg)
                    
                    return result
                    
            except HTTPError as e:
                last_error = f"HTTP {e.code}: {e.reason}"
                logger.warning(f"Request failed (attempt {attempt + 1}/{self.retry}): {last_error}")
                
                if e.code == 401:
                    raise SiYuanError("Authentication failed. Check your API token.")
                elif e.code == 404:
                    raise SiYuanError(f"Endpoint not found: {endpoint}")
                    
            except URLError as e:
                last_error = f"Connection error: {e.reason}"
                logger.warning(f"Request failed (attempt {attempt + 1}/{self.retry}): {last_error}")
                
            except json.JSONDecodeError as e:
                last_error = f"Invalid JSON response: {e}"
                logger.warning(f"Request failed (attempt {attempt + 1}/{self.retry}): {last_error}")
                
            except Exception as e:
                last_error = str(e)
                logger.warning(f"Request failed (attempt {attempt + 1}/{self.retry}): {last_error}")
            
            # Exponential backoff before retry
            if attempt < self.retry - 1:
                sleep_time = 2 ** attempt
                logger.info(f"Retrying in {sleep_time}s...")
                time.sleep(sleep_time)
        
        # All retries exhausted
        raise SiYuanConnectionError(
            f"Failed after {self.retry} attempts. Last error: {last_error}"
        )
    
    # ==================== System Operations ====================
    
    def boot_progress(self) -> Dict[str, Any]:
        """
        Get SiYuan boot progress.
        
        Returns:
            Dict with 'details' and 'progress' (0-100)
        """
        result = self._make_request("api/system/bootProgress")
        return result.get('data', {})
    
    def system_version(self) -> str:
        """
        Get SiYuan system version.
        
        Returns:
            Version string (e.g., "1.3.5")
        """
        result = self._make_request("api/system/version")
        return result.get('data', '')
    
    def current_time(self) -> int:
        """
        Get current system time.
        
        Returns:
            Unix timestamp in milliseconds
        """
        result = self._make_request("api/system/currentTime")
        return result.get('data', 0)
    
    # ==================== Notebook Operations ====================
    
    def list_notebooks(self) -> List[Dict[str, Any]]:
        """Get all notebooks"""
        result = self._make_request("api/notebook/lsNotebooks")
        return result.get('data', {}).get('notebooks', [])
    
    def create_notebook(self, name: str) -> Dict[str, Any]:
        """
        Create a new notebook.
        
        Args:
            name: Notebook name
            
        Returns:
            Created notebook info
        """
        result = self._make_request(
            "api/notebook/createNotebook",
            {"name": name}
        )
        logger.info(f"Created notebook: {name}")
        return result.get('data', {})
    
    def remove_notebook(self, notebook_id: str) -> bool:
        """
        Remove a notebook.
        
        Args:
            notebook_id: Notebook ID to remove
            
        Returns:
            True if successful
        """
        result = self._make_request(
            "api/notebook/removeNotebook",
            {"notebook": notebook_id}
        )
        logger.info(f"Removed notebook: {notebook_id}")
        return result.get('code') == 0
    
    def open_notebook(self, notebook_id: str) -> bool:
        """
        Open a notebook (load it into memory).
        
        Args:
            notebook_id: Notebook ID to open
            
        Returns:
            True if successful
        """
        result = self._make_request(
            "api/notebook/openNotebook",
            {"notebook": notebook_id}
        )
        logger.info(f"Opened notebook: {notebook_id}")
        return result.get('code') == 0
    
    def close_notebook(self, notebook_id: str) -> bool:
        """
        Close a notebook (unload from memory).
        
        Args:
            notebook_id: Notebook ID to close
            
        Returns:
            True if successful
        """
        result = self._make_request(
            "api/notebook/closeNotebook",
            {"notebook": notebook_id}
        )
        logger.info(f"Closed notebook: {notebook_id}")
        return result.get('code') == 0
    
    def rename_notebook(self, notebook_id: str, new_name: str) -> bool:
        """
        Rename a notebook.
        
        Args:
            notebook_id: Notebook ID
            new_name: New name for the notebook
            
        Returns:
            True if successful
        """
        result = self._make_request(
            "api/notebook/renameNotebook",
            {"notebook": notebook_id, "name": new_name}
        )
        logger.info(f"Renamed notebook {notebook_id} to: {new_name}")
        return result.get('code') == 0
    
    def get_notebook_conf(self, notebook_id: str) -> Dict[str, Any]:
        """
        Get notebook configuration.
        
        Args:
            notebook_id: Notebook ID
            
        Returns:
            Notebook configuration dict
        """
        result = self._make_request(
            "api/notebook/getNotebookConf",
            {"notebook": notebook_id}
        )
        return result.get('data', {})
    
    def set_notebook_conf(self, notebook_id: str, conf: Dict[str, Any]) -> bool:
        """
        Set notebook configuration.
        
        Args:
            notebook_id: Notebook ID
            conf: Configuration dict
            
        Returns:
            True if successful
        """
        result = self._make_request(
            "api/notebook/setNotebookConf",
            {"notebook": notebook_id, "conf": conf}
        )
        logger.info(f"Updated notebook config: {notebook_id}")
        return result.get('code') == 0
    
    # ==================== Document Operations ====================
    
    def export_md_content(self, doc_id: str) -> Dict[str, Any]:
        """
        Export document as Markdown content.
        
        Args:
            doc_id: Document ID
            
        Returns:
            Dict with 'hPath' (human-readable path) and 'content' (Markdown)
        """
        result = self._make_request(
            "api/export/exportMdContent",
            {"id": doc_id}
        )
        return result.get('data', {})
    
    def create_doc_with_md(self, notebook_id: str, path: str, markdown: str = "") -> str:
        """
        Create a new document with Markdown content.
        
        Args:
            notebook_id: Target notebook ID
            path: Document path (e.g., "folder/docname" or just "docname")
            markdown: Initial Markdown content
            
        Returns:
            Created document ID
        """
        result = self._make_request(
            "api/filetree/createDocWithMd",
            {
                "notebook": notebook_id,
                "path": path,
                "markdown": markdown
            }
        )
        logger.info(f"Created document: {path} in {notebook_id}")
        return result.get('data', '')
    
    def rename_doc(self, notebook_id: str, path: str, new_name: str) -> bool:
        """
        Rename a document by path.
        
        Args:
            notebook_id: Notebook ID
            path: Current document path
            new_name: New name for the document
            
        Returns:
            True if successful
        """
        result = self._make_request(
            "api/filetree/renameDoc",
            {"notebook": notebook_id, "path": path, "title": new_name}
        )
        logger.info(f"Renamed document {path} to: {new_name}")
        return result.get('code') == 0
    
    def rename_doc_by_id(self, doc_id: str, new_name: str) -> bool:
        """
        Rename a document by ID.
        
        Args:
            doc_id: Document ID
            new_name: New name for the document
            
        Returns:
            True if successful
        """
        result = self._make_request(
            "api/filetree/renameDocByID",
            {"id": doc_id, "title": new_name}
        )
        logger.info(f"Renamed document {doc_id} to: {new_name}")
        return result.get('code') == 0
    
    def move_docs_by_id(self, doc_ids: List[str], to_id: str) -> bool:
        """
        Move documents to another notebook/document by ID.
        
        Args:
            doc_ids: List of document IDs to move
            to_id: Target parent document ID or notebook ID
            
        Returns:
            True if successful
        """
        result = self._make_request(
            "api/filetree/moveDocsByID",
            {
                "fromIDs": doc_ids,
                "toID": to_id
            }
        )
        logger.info(f"Moved {len(doc_ids)} documents to {to_id}")
        return result.get('code') == 0
    
    def move_docs(self, from_paths: List[str], to_notebook: str, to_path: str = "/") -> bool:
        """
        Move documents by path.
        
        Args:
            from_paths: List of source document paths
            to_notebook: Target notebook ID
            to_path: Target path (default: root)
            
        Returns:
            True if successful
        """
        result = self._make_request(
            "api/filetree/moveDocs",
            {
                "fromPaths": from_paths,
                "toNotebook": to_notebook,
                "toPath": to_path
            }
        )
        logger.info(f"Moved {len(from_paths)} documents to {to_notebook}{to_path}")
        return result.get('code') == 0
    
    def get_hpath_by_id(self, doc_id: str) -> str:
        """
        Get human-readable path based on document ID.
        
        Args:
            doc_id: Document ID
            
        Returns:
            Human-readable path string
        """
        result = self._make_request(
            "api/filetree/getHPathByID",
            {"id": doc_id}
        )
        return result.get('data', '')
    
    def get_path_by_id(self, doc_id: str) -> Dict[str, Any]:
        """
        Get storage path based on document ID.
        
        Args:
            doc_id: Document ID
            
        Returns:
            Dict with path information
        """
        result = self._make_request(
            "api/filetree/getPathByID",
            {"id": doc_id}
        )
        return result.get('data', {})
    
    def get_ids_by_hpath(self, notebook_id: str, hpath: str) -> List[str]:
        """
        Get document IDs based on human-readable path.
        
        Args:
            notebook_id: Notebook ID
            hpath: Human-readable path
            
        Returns:
            List of document IDs
        """
        result = self._make_request(
            "api/filetree/getIDsByHPath",
            {"notebook": notebook_id, "path": hpath}
        )
        return result.get('data', [])
    
    def remove_doc(self, notebook_id: str, path: str) -> bool:
        """
        Remove a document by notebook and path.
        
        Args:
            notebook_id: Notebook ID
            path: Document path
            
        Returns:
            True if successful
        """
        result = self._make_request(
            "api/filetree/removeDoc",
            {"notebook": notebook_id, "path": path}
        )
        logger.info(f"Removed document: {path} from {notebook_id}")
        return result.get('code') == 0
    
    def remove_doc_by_id(self, doc_id: str) -> bool:
        """
        Remove a document by ID.
        
        Args:
            doc_id: Document ID to remove
            
        Returns:
            True if successful
        """
        result = self._make_request(
            "api/filetree/removeDocByID",
            {"id": doc_id}
        )
        logger.info(f"Removed document by ID: {doc_id}")
        return result.get('code') == 0
    
    def get_hpath_by_path(self, notebook_id: str, path: str) -> str:
        """
        Get human-readable path based on document path.
        
        Args:
            notebook_id: Notebook ID
            path: Document path
            
        Returns:
            Human-readable path string
        """
        result = self._make_request(
            "api/filetree/getHPathByPath",
            {"notebook": notebook_id, "path": path}
        )
        return result.get('data', '')
    
    # ==================== Asset Operations ====================
    
    def upload_asset(self, file_paths: List[str], assets_dir_path: str = "/assets/") -> Dict[str, Any]:
        """
        Upload asset files to SiYuan.
        
        Args:
            file_paths: List of local file paths to upload
            assets_dir_path: Target directory in workspace (default: "/assets/")
            
        Returns:
            Dict with 'succMap' (successful uploads) and 'errFiles' (failed uploads)
        """
        import mimetypes
        from urllib.request import Request as URLRequest
        
        url = urljoin(self.base_url + "/", "api/asset/upload")
        boundary = [REDACTED_SECRET]
        
        body = []
        
        # Add assetsDirPath field
        body.append(f'--{boundary}'.encode())
        body.append(b'Content-Disposition: form-data; name="assetsDirPath"')
        body.append(b'')
        body.append(assets_dir_path.encode())
        
        # Add files
        for file_path in file_paths:
            file_name = Path(file_path).name
            mime_type, _ = mimetypes.guess_type(file_path)
            mime_type = mime_type or 'application/octet-stream'
            
            body.append(f'--{boundary}'.encode())
            body.append(f'Content-Disposition: form-data; name="file[]"; filename="{file_name}"'.encode())
            body.append(f'Content-Type: {mime_type}'.encode())
            body.append(b'')
            
            with open(file_path, 'rb') as f:
                body.append(f.read())
        
        body.append(f'--{boundary}--'.encode())
        body.append(b'')
        
        payload = b'\r\n'.join(body)
        
        headers = {
            "Authorization": f"Token {self.token}",
            "Content-Type": f"multipart/form-data; boundary={boundary}"
        }
        
        last_error = None
        for attempt in range(self.retry):
            try:
                req = URLRequest(url, data=payload, headers=headers, method="POST")
                with urlopen(req, timeout=self.timeout) as response:
                    result = json.loads(response.read().decode('utf-8'))
                    if result.get('code') != 0:
                        raise SiYuanAPIError(result['code'], result.get('msg', 'Unknown error'))
                    logger.info(f"Uploaded {len(file_paths)} assets")
                    return result.get('data', {})
            except Exception as e:
                last_error = str(e)
                if attempt < self.retry - 1:
                    time.sleep(2 ** attempt)
        
        raise SiYuanConnectionError(f"Upload failed after {self.retry} attempts: {last_error}")
    
    # ==================== Block Operations ====================
    
    def insert_block(self, data_type: str, data: str, parent_id: str = None, 
                     previous_id: str = None, next_id: str = None) -> List[Dict[str, Any]]:
        """
        Insert blocks at specified position.
        
        Args:
            data_type: Data type (e.g., "markdown", "dom")
            data: Content to insert
            parent_id: Parent block ID (optional)
            previous_id: Insert after this block ID (optional)
            next_id: Insert before this block ID (optional)
            
        Returns:
            List of created block IDs
        """
        payload = {"dataType": data_type, "data": data}
        if parent_id:
            payload["parentID"] = parent_id
        if previous_id:
            payload["previousID"] = previous_id
        if next_id:
            payload["nextID"] = next_id
            
        result = self._make_request("api/block/insertBlock", payload)
        return result.get('data', [])
    
    def prepend_block(self, data_type: str, data: str, parent_id: str) -> List[Dict[str, Any]]:
        """
        Prepend blocks to the beginning of a document/block.
        
        Args:
            data_type: Data type (e.g., "markdown", "dom")
            data: Content to prepend
            parent_id: Parent block/document ID
            
        Returns:
            List of created block IDs
        """
        result = self._make_request(
            "api/block/prependBlock",
            {"dataType": data_type, "data": data, "parentID": parent_id}
        )
        return result.get('data', [])
    
    def append_block(self, data_type: str, data: str, parent_id: str) -> List[Dict[str, Any]]:
        """
        Append blocks to the end of a document/block.
        
        Args:
            data_type: Data type (e.g., "markdown", "dom")
            data: Content to append
            parent_id: Parent block/document ID
            
        Returns:
            List of created block IDs
        """
        result = self._make_request(
            "api/block/appendBlock",
            {"dataType": data_type, "data": data, "parentID": parent_id}
        )
        return result.get('data', [])
    
    def update_block(self, data_type: str, data: str, block_id: str) -> bool:
        """
        Update a block's content.
        
        Args:
            data_type: Data type (e.g., "markdown", "dom")
            data: New content
            block_id: Block ID to update
            
        Returns:
            True if successful
        """
        result = self._make_request(
            "api/block/updateBlock",
            {"dataType": data_type, "data": data, "id": block_id}
        )
        return result.get('code') == 0
    
    def delete_block(self, block_id: str) -> bool:
        """
        Delete a block.
        
        Args:
            block_id: Block ID to delete
            
        Returns:
            True if successful
        """
        result = self._make_request(
            "api/block/deleteBlock",
            {"id": block_id}
        )
        logger.info(f"Deleted block: {block_id}")
        return result.get('code') == 0
    
    def move_block(self, block_id: str, parent_id: str = None,
                   previous_id: str = None) -> bool:
        """
        Move a block to a new position.

        Args:
            block_id: Block ID to move
            parent_id: Parent block ID (optional)
            previous_id: Previous block ID for positioning (optional)

        Note:
            previous_id and parent_id cannot both be empty.
            If both provided, previous_id takes priority.

        Returns:
            True if successful
        """
        payload = {"id": block_id}
        if previous_id:
            payload["previousID"] = previous_id
        if parent_id:
            payload["parentID"] = parent_id

        result = self._make_request("api/block/moveBlock", payload)
        logger.info(f"Moved block {block_id}")
        return result.get('code') == 0
    
    def fold_block(self, block_id: str) -> bool:
        """
        Fold (collapse) a block.
        
        Args:
            block_id: Block ID to fold
            
        Returns:
            True if successful
        """
        result = self._make_request(
            "api/block/foldBlock",
            {"id": block_id}
        )
        return result.get('code') == 0
    
    def unfold_block(self, block_id: str) -> bool:
        """
        Unfold (expand) a block.
        
        Args:
            block_id: Block ID to unfold
            
        Returns:
            True if successful
        """
        result = self._make_request(
            "api/block/unfoldBlock",
            {"id": block_id}
        )
        return result.get('code') == 0
    
    def get_block_kramdown(self, block_id: str) -> str:
        """
        Get block content in Kramdown format.
        
        Args:
            block_id: Block ID
            
        Returns:
            Kramdown formatted content
        """
        result = self._make_request(
            "api/block/getBlockKramdown",
            {"id": block_id}
        )
        return result.get('data', {}).get('kramdown', '')
    
    def get_child_blocks(self, block_id: str) -> List[Dict[str, Any]]:
        """
        Get child blocks of a container block.
        
        Args:
            block_id: Parent block ID
            
        Returns:
            List of child block info
        """
        result = self._make_request(
            "api/block/getChildBlocks",
            {"id": block_id}
        )
        return result.get('data', [])
    
    def transfer_block_ref(self, from_id: str, to_id: str, ref_ids: List[str] = None) -> bool:
        """
        Transfer block references from one block to another.
        
        Args:
            from_id: Source block ID (def block)
            to_id: Target block ID
            ref_ids: Specific reference block IDs to transfer (optional, transfers all if not specified)
            
        Returns:
            True if successful
        """
        payload = {"fromID": from_id, "toID": to_id}
        if ref_ids:
            payload["refIDs"] = ref_ids
        
        result = self._make_request("api/block/transferBlockRef", payload)
        logger.info(f"Transferred refs from {from_id} to {to_id}")
        return result.get('code') == 0
    
    # ==================== Attribute Operations ====================
    
    def set_block_attrs(self, block_id: str, attrs: Dict[str, str]) -> bool:
        """
        Set attributes for a block.
        
        Args:
            block_id: Block ID
            attrs: Dictionary of attribute key-value pairs
            
        Returns:
            True if successful
        """
        result = self._make_request(
            "api/attr/setBlockAttrs",
            {"id": block_id, "attrs": attrs}
        )
        logger.info(f"Set attributes for block: {block_id}")
        return result.get('code') == 0
    
    def get_block_attrs(self, block_id: str) -> Dict[str, str]:
        """
        Get all attributes of a block.
        
        Args:
            block_id: Block ID
            
        Returns:
            Dictionary of attribute key-value pairs
        """
        result = self._make_request(
            "api/attr/getBlockAttrs",
            {"id": block_id}
        )
        return result.get('data', {})
    
    # ==================== SQL Operations ====================
    
    def query_sql(self, stmt: str) -> List[Dict[str, Any]]:
        """
        Execute SQL query against SiYuan database.
        
        WARNING: Read-only queries recommended. Use with caution.
        
        Args:
            stmt: SQL statement
            
        Returns:
            Query results as list of dictionaries
        """
        result = self._make_request("api/query/sql", {"stmt": stmt})
        return result.get('data', [])
    
    def flush_transaction(self) -> bool:
        """
        Flush SQLite transaction to disk.
        
        Returns:
            True if successful
        """
        result = self._make_request("api/sqlite/flushTransaction")
        logger.info("Flushed SQLite transaction")
        return result.get('code') == 0
    
    # ==================== Template Operations ====================
    
    def render_template(self, doc_id: str, template_path: str) -> Dict[str, Any]:
        """
        Render a template file.
        
        Args:
            doc_id: Document ID where rendering is called
            template_path: Absolute path to template file
            
        Returns:
            Dict with 'content' (rendered HTML) and 'path'
        """
        result = self._make_request(
            "api/template/render",
            {"id": doc_id, "path": template_path}
        )
        return result.get('data', {})
    
    def render_sprig(self, template: str) -> str:
        """
        Render a Sprig template string.
        
        Args:
            template: Template content (e.g., "/daily note/{{now | date \"2006/01\"}}")
            
        Returns:
            Rendered string
        """
        result = self._make_request(
            "api/template/renderSprig",
            {"template": template}
        )
        return result.get('data', '')
    
    # ==================== File Operations ====================
    
    def get_file(self, path: str) -> Union[bytes, Dict[str, Any]]:
        """
        Get file content from workspace.
        
        Args:
            path: File path under workspace (e.g., "/data/xxx/xxx.sy")
            
        Returns:
            File bytes on success, or error dict on failure
        """
        url = urljoin(self.base_url + "/", "api/file/getFile")
        headers = {
            "Authorization": f"Token {self.token}"
        }
        
        import urllib.request
        req = urllib.request.Request(url, data=json.dumps({"path": path}).encode(), 
                                     headers=headers, method="POST")
        
        try:
            with urllib.request.urlopen(req, timeout=self.timeout) as response:
                if response.status == 200:
                    return response.read()
                else:
                    return json.loads(response.read().decode())
        except HTTPError as e:
            if e.code == 202:
                return json.loads(e.read().decode())
            raise
    
    def put_file(self, path: str, file_content: bytes = None, is_dir: bool = False, 
                 mod_time: int = None) -> bool:
        """
        Upload/create a file or directory in workspace.
        
        Args:
            path: Target path under workspace
            file_content: File content bytes (ignored if is_dir=True)
            is_dir: Whether to create a directory
            mod_time: Unix timestamp for modification time
            
        Returns:
            True if successful
        """
        import mimetypes
        from urllib.request import Request as URLRequest
        
        url = urljoin(self.base_url + "/", "api/file/putFile")
        boundary = [REDACTED_SECRET]
        
        body = []
        
        # Add path field
        body.append(f'--{boundary}'.encode())
        body.append(b'Content-Disposition: form-data; name="path"')
        body.append(b'')
        body.append(path.encode())
        
        # Add isDir field
        body.append(f'--{boundary}'.encode())
        body.append(b'Content-Disposition: form-data; name="isDir"')
        body.append(b'')
        body.append(b'true' if is_dir else b'false')
        
        # Add modTime field if provided
        if mod_time:
            body.append(f'--{boundary}'.encode())
            body.append(b'Content-Disposition: form-data; name="modTime"')
            body.append(b'')
            body.append(str(mod_time).encode())
        
        # Add file content if not directory
        if not is_dir and file_content:
            file_name = Path(path).name
            body.append(f'--{boundary}'.encode())
            body.append(f'Content-Disposition: form-data; name="file"; filename="{file_name}"'.encode())
            body.append(b'Content-Type: application/octet-stream')
            body.append(b'')
            body.append(file_content)
        
        body.append(f'--{boundary}--'.encode())
        body.append(b'')
        
        payload = b'\r\n'.join(body)
        
        headers = {
            "Authorization": f"Token {self.token}",
            "Content-Type": f"multipart/form-data; boundary={boundary}"
        }
        
        req = URLRequest(url, data=payload, headers=headers, method="POST")
        with urlopen(req, timeout=self.timeout) as response:
            result = json.loads(response.read().decode('utf-8'))
            return result.get('code') == 0
    
    def remove_file(self, path: str) -> bool:
        """
        Remove a file from workspace.
        
        Args:
            path: File path under workspace
            
        Returns:
            True if successful
        """
        result = self._make_request(
            "api/file/removeFile",
            {"path": path}
        )
        logger.info(f"Removed file: {path}")
        return result.get('code') == 0
    
    def rename_file(self, path: str, new_path: str) -> bool:
        """
        Rename/move a file in workspace.
        
        Args:
            path: Current file path
            new_path: New file path
            
        Returns:
            True if successful
        """
        result = self._make_request(
            "api/file/renameFile",
            {"path": path, "newPath": new_path}
        )
        logger.info(f"Renamed file {path} to {new_path}")
        return result.get('code') == 0
    
    def read_dir(self, path: str) -> List[Dict[str, Any]]:
        """
        List files in a directory.
        
        Args:
            path: Directory path under workspace
            
        Returns:
            List of file info dicts with 'name', 'isDir', 'isSymlink', 'updated'
        """
        result = self._make_request(
            "api/file/readDir",
            {"path": path}
        )
        return result.get('data', [])
    
    # ==================== Export Operations ====================
    
    def export_resources(self, paths: List[str], name: str = None) -> str:
        """
        Export files/folders as a zip archive.
        
        Args:
            paths: List of paths to export
            name: Optional zip filename (default: export-YYYY-MM-DD_hh-mm-ss.zip)
            
        Returns:
            Path to created zip file (relative to workspace)
        """
        payload = {"paths": paths}
        if name:
            payload["name"] = name
        
        result = self._make_request("api/export/exportResources", payload)
        return result.get('data', {}).get('path', '')
    
    # ==================== Conversion Operations ====================
    
    def pandoc(self, dir_name: str, args: List[str]) -> str:
        """
        Run Pandoc conversion.
        
        Working directory: workspace/temp/convert/pandoc/{dir_name}
        Use put_file() to write input files first, then call this, then get_file() to retrieve output.
        
        Args:
            dir_name: Working directory name
            args: Pandoc command line arguments (e.g., ["--to", "markdown", "input.md", "-o", "output.md"])
            
        Returns:
            Path to working directory
        """
        result = self._make_request(
            "api/convert/pandoc",
            {"dir": dir_name, "args": args}
        )
        return result.get('data', {}).get('path', '')
    
    # ==================== Notification Operations ====================
    
    def push_msg(self, msg: str, timeout: int = 7000) -> str:
        """
        Push a notification message to SiYuan UI.
        
        Args:
            msg: Message text
            timeout: Display duration in milliseconds (default: 7000)
            
        Returns:
            Message ID
        """
        result = self._make_request(
            "api/notification/pushMsg",
            {"msg": msg, "timeout": timeout}
        )
        return result.get('data', {}).get('id', '')
    
    def push_err_msg(self, msg: str, timeout: int = 7000) -> str:
        """
        Push an error message to SiYuan UI.
        
        Args:
            msg: Error message text
            timeout: Display duration in milliseconds (default: 7000)
            
        Returns:
            Message ID
        """
        result = self._make_request(
            "api/notification/pushErrMsg",
            {"msg": msg, "timeout": timeout}
        )
        return result.get('data', {}).get('id', '')
    
    # ==================== Network Operations ====================
    
    def forward_proxy(self, url: str, method: str = "POST", timeout: int = 7000,
                      content_type: str = "application/json", headers: List[Dict[str, str]] = None,
                      payload: Union[str, Dict] = None, payload_encoding: str = "text",
                      response_encoding: str = "text") -> Dict[str, Any]:
        """
        Forward HTTP request through SiYuan proxy.
        
        Args:
            url: Target URL
            method: HTTP method (default: POST)
            timeout: Timeout in milliseconds (default: 7000)
            content_type: Content-Type header (default: application/json)
            headers: Additional HTTP headers
            payload: Request body (string or dict)
            payload_encoding: Payload encoding (text, base64, base32, hex)
            response_encoding: Expected response encoding
            
        Returns:
            Response dict with 'body', 'status', 'headers', etc.
        """
        data = {
            "url": url,
            "method": method,
            "timeout": timeout,
            "contentType": content_type,
            "payloadEncoding": payload_encoding,
            "responseEncoding": response_encoding
        }
        if headers:
            data["headers"] = headers
        if payload:
            data["payload"] = payload
        
        result = self._make_request("api/network/forwardProxy", data)
        return result.get('data', {})
    
    # ==================== System Operations ====================
    
    def boot_progress(self) -> Dict[str, Any]:
        """
        Get SiYuan boot progress.
        
        Returns:
            Dict with 'details' and 'progress' (0-100)
        """
        result = self._make_request("api/system/bootProgress")
        return result.get('data', {})
    
    def system_version(self) -> str:
        """
        Get SiYuan system version.
        
        Returns:
            Version string (e.g., "1.3.5")
        """
        result = self._make_request("api/system/version")
        return result.get('data', '')
    
    def current_time(self) -> int:
        """
        Get current system time.
        
        Returns:
            Unix timestamp in milliseconds
        """
        result = self._make_request("api/system/currentTime")
        return result.get('data', 0)


# ==================== Helper Functions ====================

def get_client() -> SiYuanClient:
    """Get a configured SiYuan client instance"""
    return SiYuanClient()


if __name__ == "__main__":
    # Test the client
    client = SiYuanClient()
    print("System version:", client.system_version())
    print("\nNotebooks:", len(client.list_notebooks()))


path: tools/create.py
content: #!/usr/bin/env python3
"""
Create SiYuan notebooks and documents
创建思源笔记的笔记本和文档

Usage:
    python3 create.py --notebook "New Notebook"          # 创建笔记本
    python3 create.py --doc 20210817205410 /path "内容"  # 在笔记本中创建文档
    python3 create.py --doc 20210817205410 /folder/doc "# Title\n\nContent"
"""

import argparse
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from siyuan_client import SiYuanClient, SiYuanError


def create_notebook(client, name):
    """Create a new notebook"""
    try:
        result = client.create_notebook(name)
        notebook_id = result.get('notebook', {}).get('id')
        print(f"✅ Created notebook: {name}")
        print(f"   ID: {notebook_id}")
        return notebook_id
    except SiYuanError as e:
        print(f"❌ Error: {e}", file=sys.stderr)
        return None


def create_document(client, notebook_id, path, markdown=""):
    """Create a new document"""
    try:
        doc_id = client.create_doc_with_md(notebook_id, path, markdown)
        print(f"✅ Created document: {path}")
        print(f"   ID: {doc_id}")
        return doc_id
    except SiYuanError as e:
        print(f"❌ Error: {e}", file=sys.stderr)
        return None


def main():
    parser = argparse.ArgumentParser(
        description='Create SiYuan notebooks and documents',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python3 create.py --notebook "My Project"
  python3 create.py --doc 20210817205410 /readme "# Hello\n\nWorld"
  python3 create.py --doc 20210817205410 /notes/ideas "## Ideas\n\n- First idea"
        """
    )
    parser.add_argument('--notebook', '-n', metavar='NAME', help='Create a new notebook')
    parser.add_argument('--doc', '-d', nargs=3, metavar=('NOTEBOOK_ID', 'PATH', 'MARKDOWN'),
                        help='Create a document (path should start with /)')
    
    args = parser.parse_args()
    
    if not args.notebook and not args.doc:
        parser.print_help()
        sys.exit(1)
    
    try:
        client = SiYuanClient()
    except SiYuanError as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)
    
    if args.notebook:
        create_notebook(client, args.notebook)
    
    if args.doc:
        notebook_id, path, markdown = args.doc
        create_document(client, notebook_id, path, markdown)


if __name__ == "__main__":
    main()


path: tools/delete.py
content: #!/usr/bin/env python3
"""
Delete SiYuan notebooks and documents
删除思源笔记的笔记本和文档

Usage:
    python3 delete.py --notebook <notebook_id>          # 删除笔记本
    python3 delete.py --doc <doc_id>                   # 删除文档
    python3 delete.py --block <block_id>               # 删除块
"""

import argparse
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from siyuan_client import SiYuanClient, SiYuanError


def delete_notebook(client, notebook_id, confirm=True):
    """Delete a notebook"""
    try:
        # 获取笔记本名称
        notebooks = client.list_notebooks()
        notebook = next((nb for nb in notebooks if nb['id'] == notebook_id), None)
        
        if not notebook:
            print(f"❌ Notebook not found: {notebook_id}", file=sys.stderr)
            return False
        
        name = notebook['name']
        
        if confirm:
            response = input(f"⚠️  Delete notebook '{name}' ({notebook_id})? [y/N] ")
            if response.lower() != 'y':
                print("Cancelled")
                return False
        
        if client.remove_notebook(notebook_id):
            print(f"✅ Deleted notebook: {name}")
            return True
        else:
            print(f"❌ Failed to delete notebook: {name}", file=sys.stderr)
            return False
    except SiYuanError as e:
        print(f"❌ Error: {e}", file=sys.stderr)
        return False


def delete_document(client, doc_id, confirm=True):
    """Delete a document"""
    try:
        # 获取文档标题
        try:
            attrs = client.get_block_attrs(doc_id)
            title = attrs.get('title', doc_id)
        except:
            title = doc_id
        
        if confirm:
            response = input(f"⚠️  Delete document '{title}' ({doc_id})? [y/N] ")
            if response.lower() != 'y':
                print("Cancelled")
                return False
        
        if client.remove_doc_by_id(doc_id):
            print(f"✅ Deleted document: {title}")
            return True
        else:
            print(f"❌ Failed to delete document: {title}", file=sys.stderr)
            return False
    except SiYuanError as e:
        print(f"❌ Error: {e}", file=sys.stderr)
        return False


def delete_block(client, block_id, confirm=True):
    """Delete a block"""
    try:
        if confirm:
            response = input(f"⚠️  Delete block {block_id}? [y/N] ")
            if response.lower() != 'y':
                print("Cancelled")
                return False
        
        if client.delete_block(block_id):
            print(f"✅ Deleted block: {block_id}")
            return True
        else:
            print(f"❌ Failed to delete block: {block_id}", file=sys.stderr)
            return False
    except SiYuanError as e:
        print(f"❌ Error: {e}", file=sys.stderr)
        return False


def main():
    parser = argparse.ArgumentParser(
        description='Delete SiYuan notebooks and documents',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python3 delete.py --notebook 20210817205410-2kvfpfn
  python3 delete.py --doc 20240602141622-l7ou7t7
  python3 delete.py --block 20240602141622-abcdef1
        """
    )
    parser.add_argument('--notebook', '-n', metavar='ID', help='Delete notebook by ID')
    parser.add_argument('--doc', '-d', metavar='ID', help='Delete document by ID')
    parser.add_argument('--block', '-b', metavar='ID', help='Delete block by ID')
    parser.add_argument('--yes', '-y', action='store_true', help='Skip confirmation')
    
    args = parser.parse_args()
    
    if not args.notebook and not args.doc and not args.block:
        parser.print_help()
        sys.exit(1)
    
    try:
        client = SiYuanClient()
    except SiYuanError as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)
    
    success = True
    
    if args.notebook:
        success = delete_notebook(client, args.notebook, confirm=not args.yes) and success
    
    if args.doc:
        success = delete_document(client, args.doc, confirm=not args.yes) and success
    
    if args.block:
        success = delete_block(client, args.block, confirm=not args.yes) and success
    
    sys.exit(0 if success else 1)


if __name__ == "__main__":
    main()


path: tools/export.py
content: #!/usr/bin/env python3
"""
Export SiYuan notes to Markdown files
导出思源笔记为 Markdown 文件

Usage:
    python3 export.py -o ~/backup/              # 导出所有笔记本
    python3 export.py -n "工作" -o ~/backup/    # 导出指定笔记本
    python3 export.py -d 20240602141622 -o ~/   # 导出单个文档
"""

import argparse
import json
import os
import re
import sys
from datetime import datetime
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from siyuan_client import SiYuanClient, SiYuanError


def sanitize_filename(name: str) -> str:
    """Make filename safe for filesystem"""
    # 移除或替换不安全的字符
    unsafe = '<>:"/\\|?*\n\r\t'
    for char in unsafe:
        name = name.replace(char, '_')
    # 移除开头的点和空格
    name = name.strip('. ')
    # 限制长度
    if len(name) > 100:
        name = name[:100]
    return name or 'untitled'


def export_document(client, doc_id, output_dir, notebook_name=""):
    """Export a single document"""
    try:
        # 获取文档内容
        result = client.export_md_content(doc_id)
        content = result.get('content', '')
        hpath = result.get('hPath', '')
        
        # 确定文件名
        if hpath:
            # 使用 hpath 的最后一部分作为文件名
            title = hpath.split('/')[-1] if '/' in hpath else hpath
        else:
            # 从内容中提取标题
            lines = content.split('\n')
            title = lines[0].lstrip('#').strip() if lines else 'untitled'
        
        # 清理文件名
        safe_title = sanitize_filename(title)
        filename = f"{safe_title}.md"
        
        # 处理重复文件名
        filepath = output_dir / filename
        counter = 1
        while filepath.exists():
            filename = f"{safe_title}_{counter}.md"
            filepath = output_dir / filename
            counter += 1
        
        # 写入文件
        with open(filepath, 'w', encoding='utf-8') as f:
            f.write(content)
        
        return str(filepath)
    except SiYuanError as e:
        print(f"  ❌ Failed to export {doc_id}: {e}", file=sys.stderr)
        return None


def export_notebook(client, notebook_id, output_dir):
    """Export all documents from a notebook"""
    try:
        # 获取笔记本信息
        notebooks = client.list_notebooks()
        notebook_name = next((nb['name'] for nb in notebooks if nb['id'] == notebook_id), "unknown")
        
        # 创建笔记本导出目录
        safe_name = sanitize_filename(notebook_name)
        nb_dir = output_dir / safe_name
        nb_dir.mkdir(parents=True, exist_ok=True)
        
        print(f"📖 Exporting: {notebook_name}")
        
        # 获取所有文档
        docs = client.query_sql(f"""
            SELECT id, content FROM blocks 
            WHERE box = '{notebook_id}' AND type = 'd'
        """)
        
        exported = 0
        failed = 0
        
        for doc in docs:
            doc_id = doc.get('id')
            if not doc_id:
                continue
            
            result = export_document(client, doc_id, nb_dir, notebook_name)
            if result:
                print(f"  ✓ {Path(result).name}")
                exported += 1
            else:
                failed += 1
        
        return exported, failed
    except SiYuanError as e:
        print(f"❌ Error exporting notebook: {e}", file=sys.stderr)
        return 0, 0


def main():
    parser = argparse.ArgumentParser(
        description='Export SiYuan notes to Markdown',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python3 export.py -o ~/Documents/SiYuan-Backups
  python3 export.py -n "工作" -o ~/backup/
  python3 export.py -d 20240602141622-l7ou7t7 -o ~/doc.md
        """
    )
    parser.add_argument('--output', '-o', default='~/Documents/SiYuan-Export',
                        help='Output directory (default: ~/Documents/SiYuan-Export)')
    parser.add_argument('--notebook', '-n', help='Export specific notebook by name')
    parser.add_argument('--doc', '-d', help='Export single document by ID')
    
    args = parser.parse_args()
    
    # 设置输出路径
    output_dir = Path(args.output).expanduser()
    
    # 如果导出单个文档且输出路径以 .md 结尾，作为文件处理
    if args.doc and args.output.endswith('.md'):
        output_file = output_dir
        output_dir = output_file.parent
    else:
        output_file = None
        # 添加时间戳子目录
        timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
        output_dir = output_dir / f"export_{timestamp}"
    
    output_dir.mkdir(parents=True, exist_ok=True)
    
    try:
        client = SiYuanClient()
    except SiYuanError as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)
    
    # 执行导出
    if args.doc:
        # 导出单个文档
        if output_file:
            result = export_document(client, args.doc, output_file.parent)
            if result:
                # 重命名到指定文件名
                Path(result).rename(output_file)
                print(f"✅ Exported to: {output_file}")
        else:
            result = export_document(client, args.doc, output_dir)
            if result:
                print(f"✅ Exported to: {result}")
    else:
        # 导出笔记本
        total_exported = 0
        total_failed = 0
        
        notebooks = client.list_notebooks()
        
        if args.notebook:
            # 导出指定笔记本
            notebook = next((nb for nb in notebooks if nb['name'] == args.notebook), None)
            if not notebook:
                print(f"❌ Notebook not found: {args.notebook}", file=sys.stderr)
                sys.exit(1)
            
            if notebook.get('closed'):
                print(f"⚠️  Notebook '{args.notebook}' is closed, opening...")
                client.open_notebook(notebook['id'])
            
            exported, failed = export_notebook(client, notebook['id'], output_dir)
            total_exported += exported
            total_failed += failed
        else:
            # 导出所有打开的笔记本
            for notebook in notebooks:
                if notebook.get('closed'):
                    print(f"⏭️  Skipping closed notebook: {notebook['name']}")
                    continue
                
                exported, failed = export_notebook(client, notebook['id'], output_dir)
                total_exported += exported
                total_failed += failed
                print()
        
        # 保存清单
        manifest = {
            "exported_at": datetime.now().isoformat(),
            "siyuan_version": client.system_version(),
            "total_exported": total_exported,
            "total_failed": total_failed,
        }
        
        with open(output_dir / "manifest.json", 'w', encoding='utf-8') as f:
            json.dump(manifest, f, ensure_ascii=False, indent=2)
        
        print("=" * 50)
        print(f"✅ Export complete!")
        print(f"   Total: {total_exported} documents")
        print(f"   Failed: {total_failed}")
        print(f"   Location: {output_dir}")


if __name__ == "__main__":
    main()


path: tools/list.py
content: #!/usr/bin/env python3
"""
List SiYuan notebooks and documents
列出思源笔记的笔记本和文档

Usage:
    python3 list.py --notebooks              # 列出所有笔记本
    python3 list.py --docs <notebook_id>     # 列出指定笔记本中的文档
    python3 list.py -n -j                    # JSON 格式输出笔记本列表
"""

import argparse
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from siyuan_client import SiYuanClient, SiYuanError


def list_notebooks(client, output_json=False):
    """List all notebooks"""
    try:
        notebooks = client.list_notebooks()
        
        if output_json:
            print(json.dumps(notebooks, ensure_ascii=False, indent=2))
        else:
            print(f"Found {len(notebooks)} notebook(s):\n")
            for nb in notebooks:
                status = "📕" if nb.get('closed') else "📖"
                print(f"{status} {nb['name']}")
                print(f"   ID: {nb['id']}")
                print(f"   Sort: {nb.get('sort', 'N/A')}")
                print()
        
        return notebooks
    except SiYuanError as e:
        print(f"Error: {e}", file=sys.stderr)
        return []


def list_docs(client, notebook_id, output_json=False):
    """List documents in a notebook using SQL query"""
    try:
        # 使用 SQL 查询获取文档列表
        docs = client.query_sql(f"""
            SELECT id, content, created, updated, path 
            FROM blocks 
            WHERE box = '{notebook_id}' AND type = 'd' 
            ORDER BY updated DESC
        """)
        
        if output_json:
            print(json.dumps(docs, ensure_ascii=False, indent=2))
        else:
            # 获取笔记本名称
            notebooks = client.list_notebooks()
            notebook_name = next((nb['name'] for nb in notebooks if nb['id'] == notebook_id), "Unknown")
            
            print(f"Documents in '{notebook_name}' ({len(docs)} total):\n")
            for doc in docs:
                doc_id = doc.get('id', 'N/A')
                title = doc.get('content', '(无标题)').lstrip('#').strip() if doc.get('content') else '(无标题)'
                updated = doc.get('updated', 'N/A')
                if len(updated) >= 14:
                    updated = f"{updated[:4]}-{updated[4:6]}-{updated[6:8]} {updated[8:10]}:{updated[10:12]}"
                
                print(f"📝 {title}")
                print(f"   ID: {doc_id}")
                print(f"   Updated: {updated}")
                print()
        
        return docs
    except SiYuanError as e:
        print(f"Error: {e}", file=sys.stderr)
        return []


def main():
    parser = argparse.ArgumentParser(
        description='List SiYuan notebooks and documents',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python3 list.py --notebooks              # List all notebooks
  python3 list.py --docs 20210817205410    # List docs in notebook
  python3 list.py -n -j                    # Output notebooks as JSON
        """
    )
    parser.add_argument('--notebooks', '-n', action='store_true', help='List all notebooks')
    parser.add_argument('--docs', '-d', metavar='NOTEBOOK_ID', help='List documents in notebook')
    parser.add_argument('--json', '-j', action='store_true', help='Output as JSON')
    
    args = parser.parse_args()
    
    # Initialize client
    try:
        client = SiYuanClient()
    except SiYuanError as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)
    
    # Execute command
    if args.docs:
        list_docs(client, args.docs, args.json)
    else:
        # Default to listing notebooks
        list_notebooks(client, args.json)


if __name__ == "__main__":
    main()


path: tools/move.py
content: #!/usr/bin/env python3
"""
Move SiYuan documents
移动思源笔记文档

Usage:
    python3 move.py --doc <doc_id> --to-notebook <nb_id>          # 移动文档到另一个笔记本
    python3 move.py --docs id1 id2 --to-notebook <nb_id>            # 批量移动文档
    python3 move.py --from-paths /path1 /path2 --to-nb <nb_id> --to-path /
"""

import argparse
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from siyuan_client import SiYuanClient, SiYuanError


def move_doc_by_id(client, doc_id, to_notebook_id, confirm=True):
    """Move a single document by ID"""
    try:
        # 获取文档信息
        try:
            hpath = client.get_hpath_by_id(doc_id)
            title = hpath.split('/')[-1] if '/' in hpath else hpath
        except:
            title = doc_id
        
        if confirm:
            response = input(f"⚠️  Move document '{title}' ({doc_id}) to notebook {to_notebook_id}? [y/N] ")
            if response.lower() != 'y':
                print("Cancelled")
                return False
        
        if client.move_docs_by_id([doc_id], to_notebook_id):
            print(f"✅ Moved document: {title}")
            return True
        else:
            print(f"❌ Failed to move document: {title}", file=sys.stderr)
            return False
    except SiYuanError as e:
        print(f"❌ Error: {e}", file=sys.stderr)
        return False


def move_docs_by_id(client, doc_ids, to_notebook_id, confirm=True):
    """Move multiple documents by ID"""
    try:
        if confirm:
            response = input(f"⚠️  Move {len(doc_ids)} documents to notebook {to_notebook_id}? [y/N] ")
            if response.lower() != 'y':
                print("Cancelled")
                return False
        
        if client.move_docs_by_id(doc_ids, to_notebook_id):
            print(f"✅ Moved {len(doc_ids)} documents")
            return True
        else:
            print(f"❌ Failed to move documents", file=sys.stderr)
            return False
    except SiYuanError as e:
        print(f"❌ Error: {e}", file=sys.stderr)
        return False


def move_docs_by_path(client, from_paths, to_notebook_id, to_path="/"):
    """Move documents by path"""
    try:
        if client.move_docs(from_paths, to_notebook_id, to_path):
            print(f"✅ Moved {len(from_paths)} document(s) to {to_path}")
            return True
        else:
            print(f"❌ Failed to move documents", file=sys.stderr)
            return False
    except SiYuanError as e:
        print(f"❌ Error: {e}", file=sys.stderr)
        return False


def main():
    parser = argparse.ArgumentParser(
        description='Move SiYuan documents',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python3 move.py --doc 20240602141622-l7ou7t7 --to-notebook 20210817205410-2kvfpfn
  python3 move.py --docs id1 id2 id3 --to-notebook 20210817205410-2kvfpfn
  python3 move.py --from-paths /doc1.sy /doc2.sy --to-nb 20210817205410 --to-path /folder/
        """
    )
    parser.add_argument('--doc', '-d', metavar='ID', help='Document ID to move')
    parser.add_argument('--docs', nargs='+', metavar='ID', help='Multiple document IDs to move')
    parser.add_argument('--from-paths', nargs='+', metavar='PATH', help='Source document paths')
    parser.add_argument('--to-notebook', '-t', metavar='ID', required=True, help='Target notebook ID')
    parser.add_argument('--to-path', '-p', default='/', help='Target path (default: /)')
    parser.add_argument('--yes', '-y', action='store_true', help='Skip confirmation')
    
    args = parser.parse_args()
    
    try:
        client = SiYuanClient()
    except SiYuanError as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)
    
    success = True
    
    if args.doc:
        success = move_doc_by_id(client, args.doc, args.to_notebook, confirm=not args.yes) and success
    elif args.docs:
        success = move_docs_by_id(client, args.docs, args.to_notebook, confirm=not args.yes) and success
    elif args.from_paths:
        success = move_docs_by_path(client, args.from_paths, args.to_notebook, args.to_path) and success
    else:
        parser.print_help()
        sys.exit(1)
    
    sys.exit(0 if success else 1)


if __name__ == "__main__":
    main()


path: tools/read.py
content: #!/usr/bin/env python3
"""
Read SiYuan document content
读取思源笔记文档内容

Usage:
    python3 read.py <doc_id>                # 读取文档 Markdown 内容
    python3 read.py <doc_id> -o output.md   # 保存到文件
    python3 read.py <doc_id> -i             # 显示文档信息
"""

import argparse
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from siyuan_client import SiYuanClient, SiYuanError


def get_doc_info(client, doc_id):
    """Get document metadata"""
    try:
        # 获取文档路径信息
        path_info = client.get_path_by_id(doc_id)
        hpath = client.get_hpath_by_id(doc_id)
        
        # 获取文档属性
        attrs = client.get_block_attrs(doc_id)
        
        info = {
            "id": doc_id,
            "title": attrs.get('title', '(无标题)'),
            "notebook": path_info.get('notebook', 'N/A'),
            "storage_path": path_info.get('path', 'N/A'),
            "human_path": hpath,
            "type": attrs.get('type', 'N/A'),
            "updated": attrs.get('updated', 'N/A'),
            "created": attrs.get('created', 'N/A'),
        }
        
        return info
    except SiYuanError as e:
        print(f"Error getting document info: {e}", file=sys.stderr)
        return None


def read_doc(client, doc_id, output_file=None):
    """Read document markdown content"""
    try:
        result = client.export_md_content(doc_id)
        
        content = result.get('content', '')
        hpath = result.get('hPath', '')
        
        if output_file:
            with open(output_file, 'w', encoding='utf-8') as f:
                f.write(content)
            print(f"✅ Saved to: {output_file}")
        else:
            if hpath:
                print(f"# Document: {hpath}\n")
            print(content)
        
        return content
    except SiYuanError as e:
        print(f"Error reading document: {e}", file=sys.stderr)
        return None


def main():
    parser = argparse.ArgumentParser(
        description='Read SiYuan document content',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python3 read.py 20240602141622-l7ou7t7
  python3 read.py 20240602141622-l7ou7t7 -o ~/doc.md
  python3 read.py 20240602141622-l7ou7t7 -i
        """
    )
    parser.add_argument('doc_id', help='Document ID (e.g., 20240602141622-l7ou7t7)')
    parser.add_argument('--info', '-i', action='store_true', help='Show document metadata only')
    parser.add_argument('--output', '-o', metavar='FILE', help='Save to file instead of stdout')
    
    args = parser.parse_args()
    
    try:
        client = SiYuanClient()
    except SiYuanError as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)
    
    if args.info:
        info = get_doc_info(client, args.doc_id)
        if info:
            print(json.dumps(info, ensure_ascii=False, indent=2))
    else:
        read_doc(client, args.doc_id, args.output)


if __name__ == "__main__":
    main()


path: tools/search.py
content: #!/usr/bin/env python3
"""
Search SiYuan notes using SQL
使用 SQL 搜索思源笔记

Usage:
    python3 search.py "keyword"              # 搜索关键词
    python3 search.py "keyword" -l 50        # 限制结果数量
    python3 search.py "SELECT * FROM blocks WHERE content LIKE '%test%'" --sql
"""

import argparse
import json
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from siyuan_client import SiYuanClient, SiYuanError


def search_content(client, keyword, limit=20, output_json=False):
    """Search for content in blocks"""
    try:
        # 转义 SQL 中的特殊字符
        escaped_keyword = keyword.replace("'", "''").replace("%", "\\%")
        
        stmt = f"""
            SELECT * FROM blocks 
            WHERE content LIKE '%{escaped_keyword}%' 
            ORDER BY updated DESC 
            LIMIT {limit}
        """
        
        results = client.query_sql(stmt)
        
        if output_json:
            print(json.dumps(results, ensure_ascii=False, indent=2))
        else:
            if not results:
                print(f"No results found for: '{keyword}'")
                return []
            
            print(f"Found {len(results)} result(s) for '{keyword}':\n")
            
            for i, block in enumerate(results, 1):
                content = block.get('content', '').strip()
                if not content:
                    continue
                
                # 截断长内容
                if len(content) > 150:
                    content = content[:150] + "..."
                
                block_type = block.get('type', 'unknown')
                block_id = block.get('id', 'N/A')
                root_id = block.get('root_id', 'N/A')
                
                type_emoji = {
                    'd': '📄',  # Document
                    'h': '📌',  # Heading
                    'p': '📝',  # Paragraph
                    'c': '💬',  # Code
                    't': '📊',  # Table
                    'l': '📋',  # List
                }.get(block_type, '•')
                
                print(f"{i}. {type_emoji} {content}")
                print(f"   Block: {block_id} | Doc: {root_id}")
                print()
        
        return results
    except SiYuanError as e:
        print(f"Error: {e}", file=sys.stderr)
        return []


def search_by_sql(client, sql_stmt, output_json=False):
    """Execute raw SQL query"""
    try:
        results = client.query_sql(sql_stmt)
        
        if output_json:
            print(json.dumps(results, ensure_ascii=False, indent=2))
        else:
            print(f"Query returned {len(results)} row(s):\n")
            for i, row in enumerate(results[:20], 1):  # 只显示前20行
                print(f"{i}. {json.dumps(row, ensure_ascii=False)[:200]}...")
            if len(results) > 20:
                print(f"\n... and {len(results) - 20} more rows")
        
        return results
    except SiYuanError as e:
        print(f"Error: {e}", file=sys.stderr)
        return []


def main():
    parser = argparse.ArgumentParser(
        description='Search SiYuan notes',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python3 search.py "neural network"
  python3 search.py "keyword" -l 50 -j
  python3 search.py "SELECT * FROM blocks WHERE type='d' LIMIT 10" --sql
        """
    )
    parser.add_argument('query', help='Search keyword or SQL statement')
    parser.add_argument('--limit', '-l', type=int, default=20, help='Max results (default: 20)')
    parser.add_argument('--json', '-j', action='store_true', help='Output as JSON')
    parser.add_argument('--sql', '-s', action='store_true', help='Query is raw SQL')
    
    args = parser.parse_args()
    
    try:
        client = SiYuanClient()
    except SiYuanError as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)
    
    if args.sql:
        search_by_sql(client, args.query, args.json)
    else:
        search_content(client, args.query, args.limit, args.json)


if __name__ == "__main__":
    main()


path: tools/update.py
content: #!/usr/bin/env python3
"""
Update SiYuan document and block content
更新思源笔记文档和块内容

Usage:
    python3 update.py --doc <doc_id> --markdown "# New Title\n\nContent"  # 更新文档
    python3 update.py --block <block_id> --markdown "New content"       # 更新块
    python3 update.py --append <doc_id> --markdown "\n\nAppended text"  # 追加内容
"""

import argparse
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))

from siyuan_client import SiYuanClient, SiYuanError


def update_document(client, doc_id, markdown):
    """Update document content (replaces entire document)"""
    try:
        # 获取文档所在笔记本
        path_info = client.get_path_by_id(doc_id)
        notebook_id = path_info.get('notebook')
        
        if not notebook_id:
            print(f"❌ Cannot determine notebook for document: {doc_id}", file=sys.stderr)
            return False
        
        # 获取路径
        hpath = client.get_hpath_by_id(doc_id)
        path = hpath if hpath else doc_id
        
        # 删除旧文档并创建新文档
        # 注意：由于思源没有直接更新文档内容的API，我们使用删除+重建的方式
        # 但这会改变文档ID，谨慎使用！
        print(f"⚠️  Warning: This will delete and recreate the document (ID will change)", file=sys.stderr)
        response = input(f"Continue? [y/N] ")
        if response.lower() != 'y':
            print("Cancelled")
            return False
        
        # 删除旧文档
        client.remove_doc_by_id(doc_id)
        
        # 创建新文档
        new_doc_id = client.create_doc_with_md(notebook_id, path, markdown)
        
        print(f"✅ Updated document (new ID: {new_doc_id})")
        return True
    except SiYuanError as e:
        print(f"❌ Error: {e}", file=sys.stderr)
        return False


def update_block(client, block_id, markdown):
    """Update a block's content"""
    try:
        if client.update_block("markdown", markdown, block_id):
            print(f"✅ Updated block: {block_id}")
            return True
        else:
            print(f"❌ Failed to update block: {block_id}", file=sys.stderr)
            return False
    except SiYuanError as e:
        print(f"❌ Error: {e}", file=sys.stderr)
        return False


def append_to_document(client, doc_id, markdown):
    """Append content to the end of a document"""
    try:
        result = client.append_block("markdown", markdown, doc_id)
        if result:
            print(f"✅ Appended {len(result)} block(s) to document")
            return True
        else:
            print(f"❌ Failed to append to document", file=sys.stderr)
            return False
    except SiYuanError as e:
        print(f"❌ Error: {e}", file=sys.stderr)
        return False


def prepend_to_document(client, doc_id, markdown):
    """Prepend content to the beginning of a document"""
    try:
        result = client.prepend_block("markdown", markdown, doc_id)
        if result:
            print(f"✅ Prepended {len(result)} block(s) to document")
            return True
        else:
            print(f"❌ Failed to prepend to document", file=sys.stderr)
            return False
    except SiYuanError as e:
        print(f"❌ Error: {e}", file=sys.stderr)
        return False


def insert_block(client, markdown, parent_id=None, previous_id=None, next_id=None):
    """Insert a block at specific position"""
    try:
        result = client.insert_block("markdown", markdown, parent_id, previous_id, next_id)
        if result:
            print(f"✅ Inserted {len(result)} block(s)")
            for block in result:
                for op in block.get('doOperations', []):
                    print(f"   ID: {op.get('id')}")
            return True
        else:
            print(f"❌ Failed to insert block", file=sys.stderr)
            return False
    except SiYuanError as e:
        print(f"❌ Error: {e}", file=sys.stderr)
        return False


def main():
    parser = argparse.ArgumentParser(
        description='Update SiYuan document and block content',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  python3 update.py --block 20240602141622-abcdef1 --markdown "Updated content"
  python3 update.py --append 20240602141622-l7ou7t7 --markdown "\n\nFooter text"
  python3 update.py --prepend 20240602141622-l7ou7t7 --markdown "# New Header\n"
  python3 update.py --insert "New paragraph" --parent 20240602141622-l7ou7t7
        """
    )
    parser.add_argument('--doc', '-d', metavar='ID', help='Document ID to update (caution: changes ID)')
    parser.add_argument('--block', '-b', metavar='ID', help='Block ID to update')
    parser.add_argument('--append', '-a', metavar='ID', help='Document ID to append to')
    parser.add_argument('--prepend', '-p', metavar='ID', help='Document ID to prepend to')
    parser.add_argument('--insert', '-i', metavar='MARKDOWN', help='Markdown content to insert')
    parser.add_argument('--markdown', '-m', required=True, help='Markdown content')
    parser.add_argument('--parent-id', help='Parent block/document ID for insert')
    parser.add_argument('--previous-id', help='Insert after this block ID')
    parser.add_argument('--next-id', help='Insert before this block ID')
    
    args = parser.parse_args()
    
    try:
        client = SiYuanClient()
    except SiYuanError as e:
        print(f"Error: {e}", file=sys.stderr)
        sys.exit(1)
    
    success = False
    
    if args.doc:
        success = update_document(client, args.doc, args.markdown)
    elif args.block:
        success = update_block(client, args.block, args.markdown)
    elif args.append:
        success = append_to_document(client, args.append, args.markdown)
    elif args.prepend:
        success = prepend_to_document(client, args.prepend, args.markdown)
    elif args.insert:
        success = insert_block(client, args.insert, args.parent_id, args.previous_id, args.next_id)
    else:
        parser.print_help()
        sys.exit(1)
    
    sys.exit(0 if success else 1)


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)