2 Commits
1520 changed files with 6396765 additions and 1414 deletions

No files matched your search

+381 -146
View File
@@ -1,174 +1,409 @@
name: Build and Release
name: Build Artifacts
on:
workflow_dispatch:
inputs:
publish_release:
description: "Publish a GitHub release"
required: false
default: false
type: boolean
release_tag:
description: "Release tag or version label (required if publish_release=true)"
required: false
type: string
release_name:
description: "Release name override"
required: false
type: string
push:
branches: main
tags:
- "v*"
# Automatically cancel any previous workflow on new push.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
permissions:
contents: write
jobs:
build:
# ── Go server binaries (6 platform combos) ──────────────────────────────
build-server:
name: Server ${{ matrix.goos }}-${{ matrix.goarch }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Include amd64 on all platforms.
goos: [windows, freebsd, openbsd, linux, dragonfly, darwin]
goarch: [amd64, 386]
exclude:
# Exclude i386 on darwin and dragonfly
# because they are not supported by go.
- goarch: 386
goos: dragonfly
- goarch: 386
goos: darwin
include:
# BEIGIN MacOS ARM64
- goos: windows
goarch: amd64
- goos: windows
goarch: arm64
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
- goos: darwin
goarch: amd64
- goos: darwin
goarch: arm64
# END MacOS ARM64
# BEGIN Linux ARM 5 6 7
- goos: linux
goarch: arm
goarm: 7
- goos: linux
goarch: arm
goarm: 6
- goos: linux
goarch: arm
goarm: 5
# END Linux ARM 5 6 7
# BEGIN Android ARM 8
- goos: android
goarch: arm64
# END Android ARM 8
# Windows ARM 7
- goos: windows
goarch: arm
goarm: 7
# BEGIN Other architectures
# BEGIN riscv64 & ARM64
- goos: linux
goarch: arm64
- goos: linux
goarch: riscv64
# END riscv64 & ARM64
# BEGIN MIPS
- goos: linux
goarch: mips64
- goos: linux
goarch: mips64le
- goos: linux
goarch: mipsle
- goos: linux
goarch: mips
# END MIPS
# BEGIN PPC
- goos: linux
goarch: ppc64
- goos: linux
goarch: ppc64le
# END PPC
# BEGIN FreeBSD ARM
- goos: freebsd
goarch: arm64
- goos: freebsd
goarch: arm
goarm: 7
# END FreeBSD ARM
# BEGIN S390X
- goos: linux
goarch: s390x
# END S390X
# BEGIN LoongArch64
- goos: linux
goarch: loong64
# END LoongArch64
# END Other architectures
# BEGIN OPENBSD ARM
- goos: openbsd
goarch: arm64
- goos: openbsd
goarch: arm
goarm: 7
# END OPENBSD ARM
fail-fast: false
runs-on: ubuntu-latest
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
CGO_ENABLED: 0
steps:
- name: Checkout codebase
uses: actions/checkout@v4
- name: Show workflow information
id: get_filename
run: |
export _NAME=$(jq ".[\"$GOOS-$GOARCH$GOARM$GOMIPS\"].friendlyName" -r < .github/build/friendly-filenames.json)
echo "GOOS: $GOOS, GOARCH: $GOARCH, GOARM: $GOARM, GOMIPS: $GOMIPS, RELEASE_NAME: $_NAME"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ^1.23
cache: false
- name: Get project dependencies
run: go mod download
- name: Build notebook
run: go build -o rz-notebook-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }} -ldflags "-X main.NBVERSION=$(git rev-list -1 HEAD)"
- name: Build Mips softfloat
if: matrix.goarch == 'mips' || matrix.goarch == 'mipsle'
run: GOMIPS=softfloat go build -o rz-notebook-${{ matrix.goos }}-${{ matrix.goarch }} -ldflags "-X main.NBVERSION=$(git rev-list -1 HEAD)"
- name: Rename Windows
if: matrix.goos == 'windows'
run: mv rz-notebook-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }} rz-notebook-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}.exe
- name: Upload files to Artifacts
uses: actions/upload-artifact@v4
with:
name: rz-notebook-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm }}
path: ./rz-notebook-${{ matrix.goos }}-*
create_release:
name: Create Release
runs-on: ubuntu-latest
needs: [build]
steps:
- uses: actions/checkout@v4
- name: Set variables
id: vars
run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- uses: actions/setup-go@v5
with:
go-version: "1.24.x"
cache: true
- run: go mod download
- name: Build
shell: pwsh
run: |
$out = "rizin-notebook-${{ matrix.goos }}-${{ matrix.goarch }}"
$version = (git rev-parse --short HEAD).Trim()
go build -o $out -ldflags "-X main.NBVERSION=$version"
if ($env:GOOS -eq "windows") {
Rename-Item -Path $out -NewName "$out.exe"
}
- uses: actions/upload-artifact@v4
with:
name: rizin-notebook-${{ matrix.goos }}-${{ matrix.goarch }}
path: rizin-notebook-${{ matrix.goos }}-${{ matrix.goarch }}*
if-no-files-found: error
# ── Rizin core plugin (rizin-notebook-plugin) — Windows ─────────────────
build-rizin-plugin-windows:
name: rizin-notebook-plugin (windows-x64)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Verify plugin sources
shell: pwsh
run: |
if (!(Test-Path "rizin-notebook-plugin\CMakeLists.txt")) {
Write-Host "::error::rizin-notebook-plugin sources are missing from repository checkout. Commit the rizin-notebook-plugin directory."
Get-ChildItem -Path .
exit 1
}
- name: Install vcpkg packages
run: vcpkg install protobuf-c:x64-windows
- name: Download rizin SDK
shell: pwsh
run: |
Invoke-WebRequest -Uri "https://github.com/rizinorg/rizin/releases/download/v0.8.2/rizin-windows-shared64-v0.8.2.zip" `
-OutFile rizin.zip
7z x rizin.zip -orizin-sdk
$cfg = Get-ChildItem -Path rizin-sdk -Recurse -Filter "RizinConfig.cmake" |
Select-Object -First 1
if (!$cfg) {
Write-Host "::error::RizinConfig.cmake not found in rizin SDK"
Get-ChildItem -Path rizin-sdk -Recurse | Select-Object -First 80 |
ForEach-Object { Write-Host $_.FullName }
exit 1
}
$prefix = $cfg.Directory.Parent.Parent.Parent.FullName
Write-Host "Rizin prefix: $prefix"
Add-Content -Path $env:GITHUB_ENV -Value "RIZIN_PREFIX=$prefix"
- name: Configure
shell: pwsh
run: |
cmake -S rizin-notebook-plugin -B rizin-notebook-plugin/build `
"-DRizin_ROOT=$env:RIZIN_PREFIX" `
"-DCMAKE_TOOLCHAIN_FILE=$env:VCPKG_INSTALLATION_ROOT\scripts\buildsystems\vcpkg.cmake" `
-G "Visual Studio 17 2022" -A x64
- name: Build
run: cmake --build rizin-notebook-plugin/build --config Release
- uses: actions/upload-artifact@v4
with:
name: rizin-notebook-plugin-windows-x64
path: rizin-notebook-plugin/build/Release/rz_notebook.dll
if-no-files-found: error
# ── Rizin core plugin (rizin-notebook-plugin) — Linux ───────────────────
build-rizin-plugin-linux:
name: rizin-notebook-plugin (linux-x64)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Verify plugin sources
run: |
test -f rizin-notebook-plugin/CMakeLists.txt || {
echo "::error::rizin-notebook-plugin sources are missing from repository checkout. Commit the rizin-notebook-plugin directory.";
ls -la;
exit 1;
}
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y meson ninja-build pkg-config \
libprotobuf-c-dev libcurl4-openssl-dev
- name: Build and install rizin
run: |
curl -L -o rizin-src.tar.xz \
"https://github.com/rizinorg/rizin/releases/download/v0.8.2/rizin-src-v0.8.2.tar.xz"
mkdir rizin-sdk-src
tar xf rizin-src.tar.xz -C rizin-sdk-src --strip-components=1
cd rizin-sdk-src
meson setup build --prefix=/usr -Ddefault_library=shared
ninja -C build
sudo ninja -C build install
sudo ldconfig
- name: Configure and build
run: |
cmake -S rizin-notebook-plugin -B rizin-notebook-plugin/build -DCMAKE_BUILD_TYPE=Release
cmake --build rizin-notebook-plugin/build
- uses: actions/upload-artifact@v4
with:
name: rizin-notebook-plugin-linux-x64
path: rizin-notebook-plugin/build/librz_notebook.so
if-no-files-found: error
# ── Cutter plugin (cutter-notebook-plugin) — Windows ──────────────────
build-cutter-plugin-windows:
name: cutter-notebook-plugin (windows-x64)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Verify plugin sources
shell: pwsh
run: |
if (!(Test-Path "cutter-notebook-plugin\CMakeLists.txt")) {
Write-Host "::error::cutter-notebook-plugin sources are missing from repository checkout."
Get-ChildItem -Path .
exit 1
}
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install Qt SDK
shell: pwsh
run: |
python -m pip install --upgrade pip
python -m pip install aqtinstall
$qtOut = Join-Path $env:RUNNER_TEMP "Qt"
python -m aqt install-qt windows desktop 6.7.2 win64_msvc2019_64 --outputdir $qtOut
$qtDir = Join-Path $qtOut "6.7.2\msvc2019_64"
if (!(Test-Path $qtDir)) {
Write-Host "::error::Qt SDK install failed: $qtDir not found"
exit 1
}
Add-Content -Path $env:GITHUB_ENV -Value "QT_DIR=$qtDir"
- name: Download Cutter SDK
shell: pwsh
run: |
Invoke-WebRequest -Uri "https://github.com/rizinorg/cutter/releases/download/v2.4.1/Cutter-v2.4.1-Windows-x86_64.zip" `
-OutFile cutter.zip
7z x cutter.zip -oCutter
$cutterRoot = Get-ChildItem -Path Cutter -Directory | Select-Object -First 1
if (!$cutterRoot) {
Write-Host "::error::Failed to extract Cutter SDK"
exit 1
}
$hdr = Join-Path $cutterRoot.FullName "include\cutter\plugins\CutterPlugin.h"
if (!(Test-Path $hdr)) {
Write-Host "::error::Invalid Cutter SDK root: $($cutterRoot.FullName)"
exit 1
}
Add-Content -Path $env:GITHUB_ENV -Value "CUTTER_DIR=$($cutterRoot.FullName)"
- name: Configure
shell: pwsh
run: |
cmake -S cutter-notebook-plugin -B cutter-notebook-plugin/build `
"-DCutter_DIR=$env:CUTTER_DIR" `
"-DCMAKE_PREFIX_PATH=$env:QT_DIR" `
-G "Visual Studio 17 2022" -A x64
- name: Build
run: cmake --build cutter-notebook-plugin/build --config Release
- uses: actions/upload-artifact@v4
with:
name: cutter-notebook-plugin-windows-x64
path: |
cutter-notebook-plugin/build/Release/CutterNotebookPlugin.dll
cutter-notebook-plugin/build/Release/CutterNotebookPlugin.pdb
if-no-files-found: error
# ── Package plugin sources ─────────────────────────────────────────────
package-sources:
name: Package plugin sources
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Verify source directories
run: |
test -d rizin-notebook-plugin || { echo "::error::Missing rizin-notebook-plugin directory"; exit 1; }
test -d cutter-notebook-plugin || { echo "::error::Missing cutter-notebook-plugin directory"; exit 1; }
- name: Create archives
run: |
zip -r rizin-notebook-plugin-source.zip rizin-notebook-plugin proto
zip -r cutter-notebook-plugin-source.zip cutter-notebook-plugin
- uses: actions/upload-artifact@v4
with:
name: plugin-sources
path: |
rizin-notebook-plugin-source.zip
cutter-notebook-plugin-source.zip
if-no-files-found: error
# ── Package checksums manifest ───────────────────────────────────────
package-checksums:
name: Package checksums
runs-on: ubuntu-latest
needs:
- build-server
- build-rizin-plugin-windows
- build-rizin-plugin-linux
- build-cutter-plugin-windows
- package-sources
steps:
- uses: actions/download-artifact@v4
- name: List downloaded files
run: ls -lah rz-notebook-*/*
- name: Create Release
if: startsWith(github.ref, 'refs/tags/') || contains(github.ref, 'test-release')
uses: softprops/action-gh-release@v2
- name: Generate SHA256SUMS
shell: pwsh
run: |
$files = Get-ChildItem -Path . -Recurse -File |
Where-Object { $_.Name -ne 'SHA256SUMS.txt' } |
Sort-Object FullName
$lines = foreach ($file in $files) {
$hash = (Get-FileHash -Path $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
$relative = [System.IO.Path]::GetRelativePath((Get-Location).Path, $file.FullName) -replace '\\', '/'
"$hash $relative"
}
$lines | Set-Content -Path SHA256SUMS.txt -Encoding utf8
- uses: actions/upload-artifact@v4
with:
name: release-checksums
path: SHA256SUMS.txt
if-no-files-found: error
# ── Publish GitHub Release ─────────────────────────────────────────────
release:
name: Publish Release
runs-on: ubuntu-latest
needs:
- build-server
- build-rizin-plugin-windows
- build-rizin-plugin-linux
- build-cutter-plugin-windows
- package-sources
- package-checksums
if: >-
startsWith(github.ref, 'refs/tags/v') ||
(github.event_name == 'workflow_dispatch' && inputs.publish_release)
steps:
- uses: actions/download-artifact@v4
- name: Resolve tag and name
id: meta
shell: pwsh
env:
RAW_RELEASE_TAG: ${{ inputs.release_tag }}
RAW_RELEASE_NAME: ${{ inputs.release_name }}
run: |
$tag = ""
$name = ""
$targetCommitish = ""
if ($env:GITHUB_EVENT_NAME -eq "workflow_dispatch") {
$requestedTag = [string]$env:RAW_RELEASE_TAG
$requestedTag = $requestedTag.Trim()
if ([string]::IsNullOrWhiteSpace($requestedTag)) {
Write-Error "release_tag is required when publish_release=true"
exit 1
}
$name = ([string]$env:RAW_RELEASE_NAME).Trim()
if ([string]::IsNullOrWhiteSpace($name)) {
$name = $requestedTag
}
$normalizedTag = $requestedTag
if (-not $normalizedTag.StartsWith("v")) {
$normalizedTag = "v$normalizedTag"
}
$normalizedTag = [regex]::Replace($normalizedTag.Trim(), "\s+", "-")
$normalizedTag = [regex]::Replace($normalizedTag, "[^0-9A-Za-z._/-]", "-")
$normalizedTag = [regex]::Replace($normalizedTag, "-{2,}", "-")
$normalizedTag = $normalizedTag.Trim('-')
if ([string]::IsNullOrWhiteSpace($normalizedTag) -or $normalizedTag -eq "v") {
Write-Error "release_tag could not be normalized into a valid Git tag"
exit 1
}
$tag = $normalizedTag
$targetCommitish = $env:GITHUB_SHA
}
else {
$tag = $env:GITHUB_REF_NAME
$name = $tag
}
$prerelease = $false
if ($tag -match "(?i)(alpha|beta|rc|preview|pre)" -or $name -match "(?i)(alpha|beta|rc|preview|pre)") {
$prerelease = $true
}
"tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
"name=$name" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
"target_commitish=$targetCommitish" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
"prerelease=$($prerelease.ToString().ToLowerInvariant())" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append
- uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
name: Release ${{ github.ref }}
tag_name: ${{ steps.meta.outputs.tag }}
name: ${{ steps.meta.outputs.name }}
target_commitish: ${{ steps.meta.outputs.target_commitish }}
generate_release_notes: true
draft: true
prerelease: false
files: |
./rz-notebook-*/*
- name: Create Nightly
uses: softprops/action-gh-release@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
name: Nightly (${{ steps.vars.outputs.sha_short }})
draft: false
tag_name: nightly-${{ steps.vars.outputs.sha_short }}
generate_release_notes: true
prerelease: true
prerelease: ${{ steps.meta.outputs.prerelease == 'true' }}
files: |
./rz-notebook-*/*
rizin-notebook-linux-*/*
rizin-notebook-windows-*/*
rizin-notebook-darwin-*/*
rizin-notebook-plugin-*/*
cutter-notebook-plugin-*/*
plugin-sources/*
release-checksums/*
-39
View File
@@ -1,39 +0,0 @@
name: Build Notebook
on:
pull_request:
branches: main
# Automatically cancel any previous workflow on new push.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
jobs:
build:
strategy:
fail-fast: false
runs-on: ubuntu-latest
env:
CGO_ENABLED: 0
steps:
- name: Checkout codebase
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ^1.23
cache: false
- name: Get project dependencies
run: go mod download
- name: Build notebook
run: go build -o rz-notebook -ldflags "-X main.NBVERSION=$(git rev-list -1 HEAD)"
- name: Upload files to Artifacts
uses: actions/upload-artifact@v4
with:
name: rz-notebook
path: ./rz-notebook
+28
View File
@@ -0,0 +1,28 @@
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
env:
CGO_ENABLED: 0
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.24.x"
cache: true
- run: go mod download
- run: go vet ./...
- run: go build -o rizin-notebook -ldflags "-X main.NBVERSION=$(git rev-parse --short HEAD)"
+46 -2
View File
@@ -1,3 +1,47 @@
rz-notebook
rizin-notebook
.test-data
rizin-notebook.exe
rizin-notebook.exe~
build-local/
build-local-*/
*.test
*.out
coverage.out
.test-data
.rizin-notebook/
**/build/
**/build-*/
**/cmake-build-*/
**/CMakeFiles/
**/CMakeCache.txt
**/cmake_install.cmake
**/compile_commands.json
**/Makefile
**/*.sln
**/*.vcxproj
**/*.vcxproj.filters
**/*.vcxproj.user
**/*.dir/
**/*.tlog
*.obj
*.o
*.a
*.lib
*.dll
*.dylib
*.so
*.exp
*.pdb
.env
.env.*
.vscode/
.vs/
cutter-notebook-plugin/build/
cutter-notebook-plugin/cmake-build-*/
cutter-notebook-plugin/.qt/
cutter-notebook-plugin/*.zip
rizin-notebook-plugin/build/
rizin-notebook-plugin/cmake-build-*/
.DS_Store
Thumbs.db
embedded-rizin-check.txt
embedded-rizin-plugins.txt
Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

+141 -10
View File
@@ -1,19 +1,150 @@
# Rizin Notebook
# Rizin Notebook
A notebook to write notes while using `rizin`.
A notebook for binary analysis with rizin. Keep notes, commands, and scripts organized per binary.
If you want to compare it with something similar, you can call it as the rizin equivalent of [jupyter notebook](https://jupyter.org/)
Requires rizin 0.7.0 or later.
## Requirements
## What it does
Requires at least rizin version `0.2.0`
Each notebook **page** is tied to one binary. It stores the binary itself, its SHA-256 hash,
and a list of **cells**:
## Screenshot
- **command cells** - a rizin command and its output
- **script cells** - a JavaScript snippet run on the server against a rizin pipe
- **markdown cells** - freeform notes
![rizin-notebook](https://raw.githubusercontent.com/rizinorg/rizin-notebook/master/.rizin-notebook.png)
Pages are stored as individual `.rznb` files (SQLite databases) in `~/.rizin-notebook/`.
They can be exported and imported as single self-contained files.
## Building
## Components
- **Go server** (`rizin-notebook`) - the main application. Serves a web UI, exposes a
protobuf + JSON REST API, stores everything in per-page `.rznb` SQLite files.
- **rizin-notebook-plugin** - a rizin core plugin (C). Adds `NB` commands to rizin so you
can interact with the notebook from the rizin shell.
- **cutter-notebook-plugin** - a Cutter plugin (C++/Qt6). Adds a Plugins -> Notebook dock
and menu to Cutter.
The plugins are optional. The Go server works standalone with just a browser.
## Compatibility note
The Go server is platform-independent. The plugins depend on the Rizin version they are
built against.
If standalone rizin and Cutter ship different Rizin versions on your machine, build
`rz_notebook` once for each. Both builds can talk to the same server and share the same
`.rznb` data.
Simple rule:
- one server
- one `rz_notebook` build per Rizin version in use
- one Cutter plugin if you want the Cutter dock
## Screenshots
Project screenshots are organized under `docs/images/`.
### Web UI
<figure>
<img src="docs/images/web-homepage.png" alt="Rizin Notebook" width="100%" />
<figcaption><em>Figure 1. Homepage view (with cell & scope based search, supporting multiple modes.)</em></figcaption>
</figure>
<figure>
<img src="docs/images/web-settings.png" alt="Rizin Notebook settings page" width="100%" />
<figcaption><em>Figure 2. Settings page for environment configuration, keyboard shortcuts, and server-side behavior.</em></figcaption>
</figure>
<figure>
<img src="docs/images/web-autocomplete.png" alt="Autocomplete suggestions for Rizin commands in the notebook web UI" width="100%" />
<figcaption><em>Figure 3. Command autocomplete in the web console, helping discover and complete Rizin commands quickly.</em></figcaption>
</figure>
<figure>
<img src="docs/images/web-command-help.png" alt="Command search and help modal in the notebook web UI" width="100%" />
<figcaption><em>Figure 4. Built-in command help and search modal for exploring command descriptions and arguments.</em></figcaption>
</figure>
### Plugin Integration
<figure>
<img src="docs/images/rizin-plugin.png" alt="Rizin core plugin integration for Rizin Notebook" width="100%" />
<figcaption><em>Figure 5. Rizin core plugin integration, enabling notebook interaction directly from the Rizin shell.</em></figcaption>
</figure>
<figure>
<img src="docs/images/cutter-plugin-1.png" alt="Cutter plugin integration screenshot 1" width="100%" />
<figcaption><em>Figure 6. Cutter plugin integration view showing the notebook experience inside the Cutter interface.</em></figcaption>
</figure>
<figure>
<img src="docs/images/cutter-plugin-2.png" alt="Cutter plugin integration screenshot 2" width="100%" />
<figcaption><em>Figure 7. Additional Cutter plugin workflow view demonstrating possible notebook usage(s).</em></figcaption>
</figure>
## Building the server
```bash
go build -ldflags "-X main.NBVERSION=$(git rev-list -1 HEAD)"
```
go build -o rizin-notebook .
```
Embed the version string during release builds:
```bash
go build -ldflags "-X main.NBVERSION=$(git rev-parse --short HEAD)" -o rizin-notebook .
```
## Running
```bash
./rizin-notebook
```
Opens at `http://127.0.0.1:8000/` by default.
Flags:
```
-bind 127.0.0.1:8000 address and port to listen on
-root / URL path prefix
-notebook /path/to/data data directory (default: ~/.rizin-notebook)
-debug-assets /path load templates from disk instead of embedded
-debug enable HTTP request logs
```
Set `RIZIN_PATH` to override the rizin binary path used for pipe execution.
## Building the rizin plugin
See [rizin-notebook-plugin/README.md](rizin-notebook-plugin/README.md).
## Building the Cutter plugin
See [cutter-notebook-plugin/README.md](cutter-notebook-plugin/README.md).
If Cutter ships its own embedded Rizin, the `rz_notebook` plugin loaded by Cutter must be
built against that same bundled Rizin version.
## Data storage
Each page is a `.rznb` file in the data directory. It is a SQLite database containing:
- page metadata (title, original filename, binary storage key, SHA-256 hash)
- cells (type, content, output, timestamps)
- the binary blob itself
- per-page config (key/value)
The server maintains a `catalog.db` index for fast listing across all pages.
On first run the server automatically migrates any pages from an older monolithic
`notebook.db` format into the per-page `.rznb` layout.
## API
All `NB` plugin commands use the protobuf API (`Content-Type: application/x-protobuf`).
The schema is in `proto/notebook.proto`.
A parallel JSON API at `/api/v1/json/...` is used by the Cutter plugin and the browser UI.
+298
View File
@@ -0,0 +1,298 @@
/* autocomplete.js — Rizin Notebook command autocomplete
*
* Provides inline autocomplete for rizin command input fields.
* Fetches command data lazily from the server API and caches it.
*
* API:
* NotebookAutocomplete.attach(inputElement, options)
* options.apiUrl — URL to fetch commands (default: inferred from page root)
* options.maxResults — maximum suggestions shown (default: 12)
* options.minChars — minimum chars before suggesting (default: 2)
*/
(function (global, document) {
"use strict";
/* ── Command cache ──────────────────────────────────── */
var _cache = null;
var _fetching = null;
/**
* Fetch commands from the server API. Returns a sorted array of
* { name: string, cmd: RizinCommand } objects. Cached after first call.
*/
function fetchCommands(apiUrl) {
if (_cache) return Promise.resolve(_cache);
if (_fetching) return _fetching;
_fetching = fetch(apiUrl)
.then(function (r) {
return r.json();
})
.then(function (data) {
var list = [];
for (var key in data) {
if (data.hasOwnProperty(key)) {
list.push({ name: key, cmd: data[key] });
}
}
list.sort(function (a, b) {
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
});
_cache = list;
_fetching = null;
return list;
})
.catch(function () {
_fetching = null;
return [];
});
return _fetching;
}
/* ── Attach autocomplete to an input ────────────────── */
function attach(input, options) {
var opts = options || {};
var apiUrl = opts.apiUrl || (global.NB_ROOT || "/") + "api/v1/commands";
var maxResults = opts.maxResults || 12;
var minChars = opts.minChars || 2;
var commands = null;
var selIdx = -1;
var items = [];
var isOpen = false;
// Wrap input so dropdown can position relatively
var wrap = input.parentNode;
if (!wrap.classList.contains("nb-ac-wrap")) {
var newWrap = document.createElement("div");
newWrap.className = "nb-ac-wrap";
newWrap.style.width = "100%";
input.parentNode.insertBefore(newWrap, input);
newWrap.appendChild(input);
wrap = newWrap;
}
// Create dropdown
var dropdown = document.createElement("div");
dropdown.className = "nb-ac-dropdown";
wrap.appendChild(dropdown);
// Kick off lazy fetch
fetchCommands(apiUrl).then(function (list) {
commands = list;
});
function show(matches) {
items = matches;
selIdx = -1;
if (!matches.length) {
hide();
return;
}
var fragment = document.createDocumentFragment();
for (var i = 0; i < matches.length; i++) {
var m = matches[i];
var summ = m.cmd.summary || m.cmd.description || "";
if (summ.length > 60) summ = summ.substring(0, 57) + "…";
var item = document.createElement("div");
item.className = "nb-ac-item";
item.setAttribute("data-idx", String(i));
var cmd = document.createElement("span");
cmd.className = "nb-ac-cmd";
cmd.textContent = m.name;
item.appendChild(cmd);
var args = document.createElement("span");
args.className = "nb-ac-args";
args.textContent = m.cmd.args_str || "";
item.appendChild(args);
if (summ) {
var summary = document.createElement("span");
summary.className = "nb-ac-summ";
summary.textContent = summ;
item.appendChild(summary);
}
fragment.appendChild(item);
}
dropdown.replaceChildren(fragment);
dropdown.style.display = "block";
isOpen = true;
notifyResize();
}
function hide() {
if (!isOpen) return;
dropdown.style.display = "none";
dropdown.replaceChildren();
items = [];
selIdx = -1;
isOpen = false;
notifyResize();
}
function selectItem(idx) {
if (idx < 0 || idx >= items.length) return;
var val = input.value;
var firstSpace = val.indexOf(" ");
// Replace the first token (command) with selected command
if (firstSpace >= 0) {
input.value = items[idx].name + val.substring(firstSpace);
} else {
input.value = items[idx].name;
}
hide();
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
}
function highlight(idx) {
var ch = dropdown.children;
for (var i = 0; i < ch.length; i++) {
ch[i].classList.toggle("nb-ac-sel", i === idx);
}
selIdx = idx;
if (idx >= 0 && ch[idx]) {
ch[idx].scrollIntoView({ block: "nearest" });
}
}
function filter(text) {
if (!commands || !text) {
hide();
return;
}
// Use only the first token being typed
var first = text.split(/\s+/)[0].toLowerCase();
if (first.length < minChars) {
hide();
return;
}
// If input already exactly matches a command AND there are args after it, don't show
if (text.indexOf(" ") >= 0) {
for (var c = 0; c < commands.length; c++) {
if (commands[c].name === first) {
hide();
return;
}
}
}
// Prefix matches first, then substring matches
var prefix = [],
substr = [];
for (var i = 0; i < commands.length; i++) {
var nm = commands[i].name.toLowerCase();
if (nm.indexOf(first) === 0) {
prefix.push(commands[i]);
} else if (nm.indexOf(first) > 0) {
substr.push(commands[i]);
}
}
var results = prefix.concat(substr);
if (results.length > maxResults) results = results.slice(0, maxResults);
// If only exact match remains, hide autocomplete
if (
results.length === 1 &&
results[0].name === first &&
text.indexOf(" ") < 0
) {
hide();
return;
}
show(results);
}
function notifyResize() {
if (global.parent !== global) {
var h = Math.max(
document.body.scrollHeight,
document.documentElement.scrollHeight,
);
global.parent.postMessage(
{ type: "iframe-resize", height: h + 10 },
"*",
);
}
}
/* ── Events ────────────────────────────────────────── */
input.addEventListener("input", function () {
filter(input.value.trim());
});
input.addEventListener("focus", function () {
var v = input.value.trim();
if (v.length >= minChars) filter(v);
});
input.addEventListener("keydown", function (e) {
if (!isOpen) return;
if (e.key === "ArrowDown") {
e.preventDefault();
var next = selIdx + 1;
if (next >= items.length) next = 0;
highlight(next);
} else if (e.key === "ArrowUp") {
e.preventDefault();
var prev = selIdx - 1;
if (prev < 0) prev = items.length - 1;
highlight(prev);
} else if (e.key === "Tab") {
e.preventDefault();
if (selIdx >= 0) {
selectItem(selIdx);
} else if (items.length > 0) {
selectItem(0);
}
} else if (e.key === "Enter" && !e.ctrlKey) {
if (selIdx >= 0) {
e.preventDefault();
e.stopPropagation();
selectItem(selIdx);
}
// If nothing highlighted, let Enter submit the form normally
} else if (e.key === "Escape") {
e.preventDefault();
hide();
}
});
// Click on item
dropdown.addEventListener("mousedown", function (e) {
e.preventDefault(); // don't steal focus
var target = e.target;
while (target && !target.classList.contains("nb-ac-item")) {
target = target.parentNode;
if (target === dropdown) {
target = null;
break;
}
}
if (target) {
var idx = parseInt(target.getAttribute("data-idx"), 10);
selectItem(idx);
}
});
// Hide on blur (with delay for click handling)
input.addEventListener("blur", function () {
setTimeout(function () {
hide();
}, 200);
});
return { hide: hide, filter: filter };
}
/* ── Export ──────────────────────────────────────────── */
global.NotebookAutocomplete = {
attach: attach,
fetchCommands: fetchCommands,
};
})(window, document);
+838
View File
@@ -0,0 +1,838 @@
(function (global, document) {
"use strict";
var state = {
activeIndex: -1,
controller: null,
mode: "literal",
requestId: 0,
results: [],
timer: null,
};
function cfg() {
return global.NB_NOTEBOOK_SEARCH || {};
}
function panel() {
return document.getElementById("notebook-search");
}
function toolbarButton() {
return document.getElementById("open-notebook-search");
}
function input() {
return document.getElementById("notebook-search-focus");
}
function resultsList() {
return document.getElementById("notebook-search-results");
}
function statusBox() {
return document.getElementById("notebook-search-status");
}
function surfaceSelect() {
return document.getElementById("notebook-search-surface");
}
function cellTypeSelect() {
return document.getElementById("notebook-search-cell-type");
}
function caseCheckbox() {
return document.getElementById("notebook-search-case");
}
function modeButtons() {
return Array.prototype.slice.call(
document.querySelectorAll("#notebook-search-mode-group [data-mode]"),
);
}
function normalizeText(text) {
return String(text || "").replace(/\r\n?/g, "\n");
}
function clampIndex(index) {
if (!state.results.length) return -1;
if (index < 0) return 0;
if (index >= state.results.length) return state.results.length - 1;
return index;
}
function searchDefaults() {
var searchConfig = cfg().config || {};
return {
query: "",
mode: searchConfig.default_mode || "literal",
surface: searchConfig.default_surface || "all",
cellType: searchConfig.default_cell_type || "all",
caseSensitive: false,
};
}
function readStoredState() {
var defaults = searchDefaults();
try {
if (!cfg().storageKey || !global.localStorage) {
return defaults;
}
var raw = global.localStorage.getItem(cfg().storageKey);
if (!raw) {
return defaults;
}
var parsed = JSON.parse(raw);
return {
query: String(parsed.query || defaults.query),
mode: String(parsed.mode || defaults.mode),
surface: String(parsed.surface || defaults.surface),
cellType: String(parsed.cellType || defaults.cellType),
caseSensitive: !!parsed.caseSensitive,
};
} catch (error) {
return defaults;
}
}
function writeStoredState() {
try {
if (!cfg().storageKey || !global.localStorage) {
return;
}
global.localStorage.setItem(
cfg().storageKey,
JSON.stringify({
query: input() ? input().value : "",
mode: state.mode,
surface: surfaceSelect() ? surfaceSelect().value : "all",
cellType: cellTypeSelect() ? cellTypeSelect().value : "all",
caseSensitive: caseCheckbox() ? caseCheckbox().checked : false,
}),
);
} catch (error) {}
}
function setMode(mode) {
state.mode = mode || "literal";
modeButtons().forEach(function (button) {
button.classList.toggle("is-active", button.getAttribute("data-mode") === state.mode);
});
}
function currentLimit() {
var searchConfig = cfg().config || {};
var maxResults = Number(searchConfig.max_results || 0);
if (!maxResults || maxResults < 1) {
return 200;
}
return maxResults;
}
function normalizeRanges(ranges, length) {
if (!ranges || !ranges.length) return [];
return ranges
.map(function (range) {
return {
start: Math.max(0, Math.min(length, Number(range.start) || 0)),
end: Math.max(0, Math.min(length, Number(range.end) || 0)),
};
})
.filter(function (range) {
return range.end > range.start;
})
.sort(function (a, b) {
return a.start - b.start;
});
}
function unwrapMarks(root) {
if (!root) return;
var marks = root.querySelectorAll("mark.nb-search-hit");
for (var i = 0; i < marks.length; i++) {
var mark = marks[i];
var parent = mark.parentNode;
while (mark.firstChild) {
parent.insertBefore(mark.firstChild, mark);
}
parent.removeChild(mark);
}
root.normalize();
}
function codePointToCodeUnitOffset(text, codePointOffset) {
if (codePointOffset <= 0) return 0;
return Array.from(text).slice(0, codePointOffset).join("").length;
}
function childOffset(node) {
var offset = 0;
while (node && node.previousSibling) {
node = node.previousSibling;
offset++;
}
return offset;
}
function buildTextSegments(root, includeBreaks) {
var segments = [];
var codePointIndex = 0;
function walk(node) {
if (!node) return;
if (node.nodeType === Node.TEXT_NODE) {
var length = Array.from(node.data || "").length;
if (!length) return;
segments.push({
type: "text",
node: node,
start: codePointIndex,
end: codePointIndex + length,
});
codePointIndex += length;
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return;
}
if (includeBreaks && node.tagName === "BR") {
segments.push({
type: "break",
parent: node.parentNode,
offset: childOffset(node),
start: codePointIndex,
end: codePointIndex + 1,
});
codePointIndex++;
return;
}
for (var child = node.firstChild; child; child = child.nextSibling) {
walk(child);
}
}
walk(root);
return segments;
}
function boundaryBeforeSegment(segment) {
if (!segment) return null;
if (segment.type === "break") {
return { container: segment.parent, offset: segment.offset };
}
return { container: segment.node, offset: 0 };
}
function boundaryAfterSegment(segment) {
if (!segment) return null;
if (segment.type === "break") {
return { container: segment.parent, offset: segment.offset + 1 };
}
return {
container: segment.node,
offset: codePointToCodeUnitOffset(segment.node.data || "", segment.end - segment.start),
};
}
function locateBoundary(root, segments, absoluteOffset) {
if (!segments.length) {
return { container: root, offset: 0 };
}
for (var i = 0; i < segments.length; i++) {
var segment = segments[i];
if (absoluteOffset < segment.start) {
return boundaryBeforeSegment(segment);
}
if (segment.type === "break") {
if (absoluteOffset === segment.start) {
return boundaryBeforeSegment(segment);
}
if (absoluteOffset === segment.end) {
return boundaryAfterSegment(segment);
}
continue;
}
if (absoluteOffset <= segment.end) {
return {
container: segment.node,
offset: codePointToCodeUnitOffset(segment.node.data || "", absoluteOffset - segment.start),
};
}
}
return boundaryAfterSegment(segments[segments.length - 1]);
}
function wrapCodePointRange(root, start, end, includeBreaks) {
var segments = buildTextSegments(root, includeBreaks);
var startBoundary = locateBoundary(root, segments, start);
var endBoundary = locateBoundary(root, segments, end);
if (!startBoundary || !endBoundary) return null;
var range = document.createRange();
range.setStart(startBoundary.container, startBoundary.offset);
range.setEnd(endBoundary.container, endBoundary.offset);
if (range.collapsed) return null;
var mark = document.createElement("mark");
mark.className = "nb-search-hit";
mark.appendChild(range.extractContents());
range.insertNode(mark);
return mark;
}
function lineBounds(text, lineNumber) {
if (lineNumber <= 0) return null;
var chars = Array.from(normalizeText(text));
var start = 0;
var currentLine = 1;
while (start < chars.length && currentLine < lineNumber) {
if (chars[start] === "\n") {
currentLine++;
}
start++;
}
if (currentLine !== lineNumber) {
return null;
}
var end = start;
while (end < chars.length && chars[end] !== "\n") {
end++;
}
return { start: start, end: end };
}
function highlightInlineRanges(root, fullText, lineNumber, ranges, includeBreaks) {
if (!root) return null;
unwrapMarks(root);
var bounds = lineBounds(fullText, Number(lineNumber || 0));
if (!bounds) return null;
var safeRanges = normalizeRanges(ranges, bounds.end - bounds.start).sort(function (a, b) {
return b.start - a.start;
});
var firstMark = null;
for (var i = 0; i < safeRanges.length; i++) {
var range = safeRanges[i];
var mark = wrapCodePointRange(
root,
bounds.start + range.start,
bounds.start + range.end,
includeBreaks,
);
if (mark) {
firstMark = mark;
}
}
return firstMark;
}
function buildHighlightedTextFragment(text, ranges) {
var fragment = document.createDocumentFragment();
var chars = Array.from(String(text || ""));
var safeRanges = normalizeRanges(ranges, chars.length);
if (!safeRanges.length) {
if (chars.length) {
fragment.appendChild(document.createTextNode(chars.join("")));
}
return fragment;
}
var cursor = 0;
safeRanges.forEach(function (range) {
if (cursor < range.start) {
fragment.appendChild(document.createTextNode(chars.slice(cursor, range.start).join("")));
}
var mark = document.createElement("mark");
mark.className = "nb-search-hit";
mark.textContent = chars.slice(range.start, range.end).join("");
fragment.appendChild(mark);
cursor = range.end;
});
if (cursor < chars.length) {
fragment.appendChild(document.createTextNode(chars.slice(cursor).join("")));
}
return fragment;
}
function resultSurfaceLabel(result) {
if (result.surface === "output") {
return "Output";
}
if (result.cell_type === "markdown") {
return "Markdown";
}
return "Source";
}
function resultCellTypeLabel(result) {
if (result.cell_type === "script") {
return "Script";
}
if (result.cell_type === "markdown") {
return "Markdown";
}
return "Command";
}
function setStatus(text, isError) {
var box = statusBox();
if (!box) return;
box.textContent = text;
box.classList.toggle("is-error", !!isError);
}
function clearResults() {
var list = resultsList();
if (list) {
list.replaceChildren();
}
state.results = [];
state.activeIndex = -1;
}
function showEmptyState(message) {
clearResults();
setStatus(message, false);
}
function updateToolbarState() {
var button = toolbarButton();
if (!button) return;
button.classList.toggle("is-active", isNotebookSearchOpen());
}
function isNotebookSearchOpen() {
var currentPanel = panel();
return !!currentPanel && currentPanel.classList.contains("is-open");
}
function openNotebookSearch() {
if (global.isCommandHelpOpen && global.isCommandHelpOpen()) {
global.closeCmdHelp();
}
var currentPanel = panel();
if (!currentPanel) return;
currentPanel.classList.add("is-open");
currentPanel.setAttribute("aria-hidden", "false");
updateToolbarState();
var searchInput = input();
if (!searchInput) return;
searchInput.focus();
searchInput.select();
if (searchInput.value.trim()) {
queueNotebookSearch(0);
return;
}
showEmptyState("Search commands, scripts, outputs, and markdown in this notebook.");
}
function closeNotebookSearch() {
var currentPanel = panel();
if (!currentPanel) return;
currentPanel.classList.remove("is-open");
currentPanel.setAttribute("aria-hidden", "true");
updateToolbarState();
}
function queueNotebookSearch(delay) {
if (state.timer) {
global.clearTimeout(state.timer);
}
state.timer = global.setTimeout(runNotebookSearch, typeof delay === "number" ? delay : 140);
}
function setActiveResult(index, shouldScroll) {
state.activeIndex = clampIndex(index);
var buttons = document.querySelectorAll(".nb-search-result");
for (var i = 0; i < buttons.length; i++) {
var isActive = i === state.activeIndex;
buttons[i].classList.toggle("is-active", isActive);
buttons[i].setAttribute("aria-selected", isActive ? "true" : "false");
if (isActive && shouldScroll && buttons[i].scrollIntoView) {
buttons[i].scrollIntoView({ block: "nearest" });
}
}
}
function moveActiveResult(delta) {
if (!state.results.length) return;
var nextIndex = state.activeIndex;
if (nextIndex < 0) {
nextIndex = 0;
} else {
nextIndex = (nextIndex + delta + state.results.length) % state.results.length;
}
setActiveResult(nextIndex, true);
}
function runNotebookSearch() {
state.timer = null;
var searchInput = input();
var query = searchInput ? searchInput.value.trim() : "";
if (state.controller) {
state.controller.abort();
state.controller = null;
}
writeStoredState();
if (!query) {
showEmptyState("Search commands, scripts, outputs, and markdown in this notebook.");
return;
}
var requestId = ++state.requestId;
var params = new URLSearchParams();
params.set("query", query);
params.set("mode", state.mode);
params.set("surface", surfaceSelect() ? surfaceSelect().value : "all");
params.set("cell_type", cellTypeSelect() ? cellTypeSelect().value : "all");
params.set("limit", String(currentLimit()));
if (caseCheckbox() && caseCheckbox().checked) {
params.set("case_sensitive", "1");
}
setStatus("Searching this notebook...", false);
var controller = null;
if (global.AbortController) {
controller = new global.AbortController();
state.controller = controller;
}
global
.fetch(cfg().searchEndpoint + "?" + params.toString(), {
headers: { Accept: "application/json" },
signal: controller ? controller.signal : undefined,
})
.then(function (response) {
return response.json().then(function (body) {
return { ok: response.ok, body: body };
});
})
.then(function (payload) {
if (state.controller === controller) {
state.controller = null;
}
if (requestId !== state.requestId) return;
if (!payload.ok) {
clearResults();
setStatus(payload.body.error || "Search failed.", true);
return;
}
renderNotebookSearchResults(payload.body);
})
.catch(function (error) {
if (state.controller === controller) {
state.controller = null;
}
if (error && error.name === "AbortError") {
return;
}
if (requestId !== state.requestId) return;
clearResults();
setStatus(error && error.message ? error.message : "Search failed.", true);
});
}
function renderNotebookSearchResults(response) {
var list = resultsList();
if (!list) return;
list.replaceChildren();
state.results = response.results || [];
state.activeIndex = state.results.length ? 0 : -1;
if (!state.results.length) {
setStatus("No matches found in this notebook.", false);
return;
}
setStatus(
response.truncated
? "Showing " + state.results.length + " of " + response.total + " matches."
: response.total + " matches found.",
false,
);
var fragment = document.createDocumentFragment();
state.results.forEach(function (result, index) {
var item = document.createElement("li");
item.className = "nb-search-result-item";
var button = document.createElement("button");
button.type = "button";
button.className = "nb-search-result" + (index === 0 ? " is-active" : "");
button.setAttribute("aria-selected", index === 0 ? "true" : "false");
button.addEventListener("click", function () {
jumpToNotebookSearchResult(index);
});
button.addEventListener("mouseenter", function () {
setActiveResult(index, false);
});
var meta = document.createElement("div");
meta.className = "nb-search-result-meta";
appendMetaBadge(meta, resultSurfaceLabel(result), "nb-search-badge" + (result.surface === "output" ? " is-output" : ""));
appendMetaBadge(meta, resultCellTypeLabel(result), "nb-search-badge");
if (response.mode === "fuzzy") {
appendMetaBadge(meta, "score " + String(result.score || 0), "nb-search-badge is-fuzzy");
}
appendMetaText(meta, "cell " + String(Number(result.cell_position || 0) + 1));
appendMetaText(meta, "line " + String(result.line_number || 0));
appendMetaText(meta, "cols " + String(result.start_column || 0) + "-" + String(result.end_column || 0));
button.appendChild(meta);
var line = document.createElement("div");
line.className = "nb-search-result-line";
line.appendChild(buildHighlightedTextFragment(result.line_text, result.ranges));
button.appendChild(line);
item.appendChild(button);
fragment.appendChild(item);
});
list.appendChild(fragment);
}
function appendMetaBadge(parent, text, className) {
var badge = document.createElement("span");
badge.className = className;
badge.textContent = text;
parent.appendChild(badge);
}
function appendMetaText(parent, text) {
var span = document.createElement("span");
span.textContent = text;
parent.appendChild(span);
}
function clearNotebookSearchHighlights() {
Array.prototype.slice
.call(document.querySelectorAll(".cell-group.nb-search-active-cell"))
.forEach(function (cell) {
cell.classList.remove("nb-search-active-cell");
});
Array.prototype.slice
.call(document.querySelectorAll(".nb-searchable-code"))
.forEach(function (code) {
unwrapMarks(code);
});
Array.prototype.slice
.call(document.querySelectorAll('iframe[id^="output-"], iframe[id^="markdown-"]'))
.forEach(function (iframe) {
try {
iframe.contentWindow.postMessage({ type: "notebook-search-clear" }, "*");
} catch (error) {}
});
}
function highlightSearchableCodeBlock(code, lineNumber, ranges) {
if (!code) return;
var target = highlightInlineRanges(
code,
normalizeText(code.textContent || ""),
lineNumber,
ranges || [],
false,
);
if (target && target.scrollIntoView) {
target.scrollIntoView({ block: "center", behavior: "smooth" });
}
}
function scrollToCell(cellId) {
var group = document.getElementById("cell-" + cellId);
if (!group) return null;
group.classList.add("nb-search-active-cell");
if (group.scrollIntoView) {
group.scrollIntoView({ block: "center", behavior: "smooth" });
}
return group;
}
function serializeRanges(ranges) {
return (ranges || [])
.map(function (range) {
return range.start + ":" + range.end;
})
.join(",");
}
function openMarkdownSearchResult(result) {
var iframe = document.getElementById("markdown-" + result.cell_id);
if (!iframe) return;
if (iframe.src && iframe.src.indexOf("/markdown/edit/") >= 0) {
try {
iframe.contentWindow.postMessage(
{
type: "notebook-search-highlight-editor",
lineNumber: result.line_number,
ranges: result.ranges || [],
},
"*",
);
return;
} catch (error) {}
}
var url = new URL(cfg().markdownEditBase + result.cell_id, global.location.href);
url.searchParams.set("search_line", String(result.line_number));
url.searchParams.set("search_ranges", serializeRanges(result.ranges));
url.searchParams.set("search_request", String(Date.now()));
iframe.src = url.toString();
}
function highlightOutputResult(result) {
var iframe = document.getElementById("output-" + result.cell_id);
if (!iframe) return;
var message = {
type: "notebook-search-highlight",
lineNumber: result.line_number,
ranges: result.ranges || [],
};
try {
iframe.contentWindow.postMessage(message, "*");
global.setTimeout(function () {
try {
iframe.contentWindow.postMessage(message, "*");
} catch (error) {}
}, 90);
global.setTimeout(function () {
try {
iframe.contentWindow.postMessage(message, "*");
} catch (error) {}
}, 240);
} catch (error) {}
}
function jumpToNotebookSearchResult(index) {
var result = state.results[index];
if (!result) return;
setActiveResult(index, true);
clearNotebookSearchHighlights();
scrollToCell(result.cell_id);
if (result.cell_type === "markdown") {
openMarkdownSearchResult(result);
return;
}
if (result.surface === "output") {
highlightOutputResult(result);
return;
}
highlightSearchableCodeBlock(
document.getElementById("content-" + result.cell_id),
result.line_number,
result.ranges || [],
);
}
function applyInitialState() {
var stored = readStoredState();
var searchInput = input();
if (searchInput) {
searchInput.value = stored.query;
}
if (surfaceSelect()) {
surfaceSelect().value = stored.surface;
}
if (cellTypeSelect()) {
cellTypeSelect().value = stored.cellType;
}
if (caseCheckbox()) {
caseCheckbox().checked = !!stored.caseSensitive;
}
setMode(stored.mode);
}
function bindNotebookSearchUI() {
var searchInput = input();
var searchCloseButton = document.getElementById("close-notebook-search");
applyInitialState();
updateToolbarState();
if (toolbarButton()) {
toolbarButton().addEventListener("click", function () {
if (isNotebookSearchOpen()) {
closeNotebookSearch();
} else {
openNotebookSearch();
}
});
}
if (searchCloseButton) {
searchCloseButton.addEventListener("click", function () {
closeNotebookSearch();
});
}
modeButtons().forEach(function (button) {
button.addEventListener("click", function () {
setMode(button.getAttribute("data-mode"));
writeStoredState();
queueNotebookSearch(0);
});
});
if (searchInput) {
searchInput.addEventListener("input", function () {
writeStoredState();
queueNotebookSearch();
});
searchInput.addEventListener("keydown", function (event) {
if (event.key === "ArrowDown") {
event.preventDefault();
moveActiveResult(1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
moveActiveResult(-1);
return;
}
if (event.key === "Enter") {
event.preventDefault();
if (state.activeIndex >= 0) {
jumpToNotebookSearchResult(state.activeIndex);
} else {
runNotebookSearch();
}
return;
}
if (event.key === "Escape") {
event.preventDefault();
closeNotebookSearch();
}
});
}
[surfaceSelect(), cellTypeSelect(), caseCheckbox()].forEach(function (control) {
if (!control) return;
control.addEventListener("change", function () {
writeStoredState();
queueNotebookSearch(0);
});
});
}
global.openNotebookSearch = openNotebookSearch;
global.closeNotebookSearch = closeNotebookSearch;
global.isNotebookSearchOpen = isNotebookSearchOpen;
global.jumpToNotebookSearchResult = jumpToNotebookSearchResult;
document.addEventListener("DOMContentLoaded", bindNotebookSearchUI);
})(window, document);
+829
View File
@@ -0,0 +1,829 @@
(function (global, document) {
"use strict";
var isMac = /Mac|iPhone|iPad|iPod/.test(global.navigator.platform);
var config = global.NOTEBOOK_KB || {};
var defaults = {
about: "alt+a",
new_page: "ctrl+n",
settings: "alt+s",
toggle_pipe: "alt+o",
new_command: "alt+c",
new_markdown: "alt+m",
new_script: "alt+j",
search_notebook: "mod+shift+f",
save: "mod+s",
cancel: "escape",
execute: "ctrl+enter",
edit_markdown: "alt+e",
};
function normalizeKey(key) {
if (!key) return "";
var normalized = String(key).toLowerCase();
switch (normalized) {
case " ":
case "spacebar":
return "space";
case "esc":
return "escape";
case "arrowup":
return "up";
case "arrowdown":
return "down";
case "arrowleft":
return "left";
case "arrowright":
return "right";
default:
return normalized;
}
}
function expandToken(token) {
token = normalizeKey(token);
if (token === "mod") {
return isMac ? "meta" : "ctrl";
}
return token;
}
function parseCombo(combo) {
var parts = String(combo || "")
.split("+")
.map(function (part) {
return expandToken(part.trim());
})
.filter(Boolean);
return {
ctrl: parts.indexOf("ctrl") >= 0,
meta: parts.indexOf("meta") >= 0,
alt: parts.indexOf("alt") >= 0,
shift: parts.indexOf("shift") >= 0,
key:
parts.filter(function (part) {
return ["ctrl", "meta", "alt", "shift"].indexOf(part) < 0;
})[0] || "",
};
}
function matches(event, combo) {
var spec = parseCombo(combo);
if (!spec.key) return false;
if (!!event.ctrlKey !== spec.ctrl) return false;
if (!!event.metaKey !== spec.meta) return false;
if (!!event.altKey !== spec.alt) return false;
if (!!event.shiftKey !== spec.shift) return false;
return normalizeKey(event.key) === spec.key;
}
function isEditableTarget(target) {
if (!target || target === document.body) return false;
if (target.isContentEditable) return true;
var tag = (target.tagName || "").toLowerCase();
return tag === "input" || tag === "textarea" || tag === "select";
}
function formatCombo(combo) {
if (!combo) return "";
var tokens = String(combo)
.split("+")
.map(function (token) {
token = token.trim().toLowerCase();
if (token === "mod") return isMac ? "⌘" : "Ctrl";
if (token === "ctrl") return isMac ? "⌃" : "Ctrl";
if (token === "meta") return isMac ? "⌘" : "Meta";
if (token === "alt") return isMac ? "⌥" : "Alt";
if (token === "shift") return "⇧";
if (token === "escape") return "Esc";
if (token === "enter") return "↩";
if (token === "space") return "Space";
return token.length === 1
? token.toUpperCase()
: token.charAt(0).toUpperCase() + token.slice(1);
});
return tokens.join(isMac ? "" : "+");
}
function combo(action) {
return config[action] || defaults[action] || "";
}
function bind(bindings) {
document.addEventListener(
"keydown",
function (event) {
if (!bindings || !bindings.length) return;
for (var i = 0; i < bindings.length; i++) {
var binding = bindings[i];
if (!binding) continue;
var comboString = "";
if (binding.action) {
comboString = combo(binding.action);
} else if (binding.combo) {
comboString = String(binding.combo);
}
if (!comboString) continue;
if (!matches(event, comboString)) continue;
if (isEditableTarget(event.target) && !binding.allowInInputs) continue;
if (binding.when && !binding.when(event)) continue;
event.preventDefault();
event.stopPropagation();
binding.handler(event);
return;
}
},
true,
);
}
function annotate(element, actionOrCombo) {
if (!element) return;
var comboString = defaults[actionOrCombo] ? combo(actionOrCombo) : actionOrCombo;
if (!comboString) return;
var display = formatCombo(comboString);
var text = (element.textContent || "").replace(/\s+/g, " ").trim();
var tooltip = text ? text + " (" + display + ")" : display;
element.setAttribute("data-tooltip", tooltip);
element.setAttribute("aria-keyshortcuts", display);
if (element.classList) {
element.classList.add("tooltip");
element.classList.add("tooltip-bottom");
}
}
function record(callback) {
function handler(event) {
event.preventDefault();
event.stopPropagation();
var parts = [];
if (event.ctrlKey) parts.push("ctrl");
if (event.altKey) parts.push("alt");
if (event.shiftKey) parts.push("shift");
if (event.metaKey) parts.push("meta");
var key = normalizeKey(event.key);
if (["control", "alt", "shift", "meta"].indexOf(key) >= 0) return;
parts.push(key);
document.removeEventListener("keydown", handler, true);
if (callback) callback(parts.join("+"));
}
document.addEventListener("keydown", handler, true);
}
var ALIGN_VALUES = {
center: true,
justify: true,
left: true,
right: true,
};
var CODE_CLASS_RE =
/^(?:lang|language)-[A-Za-z0-9_-]+(?:\s+(?:lang|language)-[A-Za-z0-9_-]+)*$/;
var HTML_ENTITY_MAP = {
amp: "&",
apos: "'",
gt: ">",
lt: "<",
nbsp: "\u00a0",
quot: '"',
};
function emptyFragment() {
return document.createDocumentFragment();
}
function normalizeUrlCandidate(url) {
return String(url || "").replace(/[\u0000-\u001F\u007F-\u009F\s]+/g, "");
}
function sanitizeUrl(url, options) {
var value = String(url || "").trim();
if (!value) {
return "";
}
if (value.charAt(0) === "#") {
return value;
}
var normalized = normalizeUrlCandidate(value).toLowerCase();
if (
normalized.indexOf("javascript:") === 0 ||
normalized.indexOf("vbscript:") === 0
) {
return "";
}
if (normalized.indexOf("data:") === 0) {
if (
options &&
options.allowDataImage &&
/^data:image\/(?:bmp|gif|jpe?g|png|webp);/i.test(value)
) {
return value;
}
return "";
}
if (value.indexOf("//") === 0) {
return value;
}
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(normalized)) {
var scheme = normalized.split(":", 1)[0];
if (
scheme === "ftp" ||
scheme === "http" ||
scheme === "https" ||
scheme === "mailto"
) {
return value;
}
return "";
}
return value;
}
function applyTextAlign(target, value) {
var normalized = String(value || "").trim().toLowerCase();
if (ALIGN_VALUES[normalized]) {
target.style.textAlign = normalized;
}
}
function copyTextAlign(source, target) {
applyTextAlign(target, source.getAttribute("align"));
var style = source.getAttribute("style");
if (!style) {
return;
}
style.split(";").forEach(function (declaration) {
var parts = declaration.split(":");
if (parts.length < 2) {
return;
}
if (parts[0].trim().toLowerCase() === "text-align") {
applyTextAlign(target, parts.slice(1).join(":"));
}
});
}
function appendNode(target, node) {
if (target && node) {
target.appendChild(node);
}
}
function decodeEntities(text) {
return String(text || "").replace(
/&(?:#(\d+)|#x([0-9a-fA-F]+)|([A-Za-z][A-Za-z0-9]+));/g,
function (match, dec, hex, named) {
var value;
if (dec) {
value = parseInt(dec, 10);
} else if (hex) {
value = parseInt(hex, 16);
} else if (named) {
if (!Object.prototype.hasOwnProperty.call(HTML_ENTITY_MAP, named)) {
return match;
}
return HTML_ENTITY_MAP[named];
}
if (!isFinite(value) || value < 0 || value > 1114111) {
return match;
}
try {
return String.fromCodePoint(value);
} catch (error) {
return match;
}
},
);
}
function appendDecodedText(target, text) {
if (text) {
appendNode(target, document.createTextNode(decodeEntities(text)));
}
}
function appendLiteralText(target, text) {
if (text) {
appendNode(target, document.createTextNode(String(text)));
}
}
function createMarkdownOptions(renderer) {
var options = {
breaks: true,
gfm: true,
pedantic: false,
smartypants: false,
tables: true,
};
var config =
renderer &&
renderer.options &&
renderer.options.renderingConfig;
if (config && config.singleLineBreaks === false) {
options.breaks = false;
}
return options;
}
function getMarkedEngine(renderer) {
if (
renderer &&
renderer.constructor &&
renderer.constructor.marked
) {
return renderer.constructor.marked;
}
if (global.SimpleMDE && global.SimpleMDE.marked) {
return global.SimpleMDE.marked;
}
return null;
}
function getMarkdownTokens(markdownText, renderer) {
var marked = getMarkedEngine(renderer);
var options = createMarkdownOptions(renderer);
var lexer = marked && (marked.lexer || (marked.Lexer && marked.Lexer.lex));
if (typeof lexer !== "function") {
return null;
}
var tokens = lexer(String(markdownText || ""), options);
if (tokens) {
tokens.marked = marked;
tokens.options = options;
}
return tokens;
}
function getInlineRules(marked, options) {
var inlineLexer = marked && marked.InlineLexer;
if (!inlineLexer || !inlineLexer.rules) {
return null;
}
if (options.gfm) {
return options.breaks ? inlineLexer.rules.breaks : inlineLexer.rules.gfm;
}
if (options.pedantic) {
return inlineLexer.rules.pedantic;
}
return inlineLexer.rules.normal;
}
function createInlineContext(links, options, marked) {
return {
inLink: false,
links: links || {},
marked: marked,
options: options || {},
rules: getInlineRules(marked, options || {}),
};
}
function cloneInlineContext(context, inLink) {
return {
inLink: !!inLink,
links: context.links,
marked: context.marked,
options: context.options,
rules: context.rules,
};
}
function createSafeLink(href, title, content) {
var safeHref = sanitizeUrl(href);
if (!safeHref) {
return content;
}
var link = document.createElement("a");
link.setAttribute("href", safeHref);
if (title) {
link.setAttribute("title", title);
}
appendNode(link, content);
return link;
}
function createSafeImage(src, title, altText) {
var safeSrc = sanitizeUrl(src, { allowDataImage: true });
if (!safeSrc) {
return document.createTextNode(altText || "");
}
var image = document.createElement("img");
image.setAttribute("src", safeSrc);
if (altText) {
image.setAttribute("alt", altText);
}
if (title) {
image.setAttribute("title", title);
}
return image;
}
function createInlineElement(tagName, content) {
var element = document.createElement(tagName);
appendNode(element, content);
return element;
}
function createBlockElement(tagName, content) {
var element = document.createElement(tagName);
appendNode(element, content);
return element;
}
function createLiteralBlock(text) {
var value = String(text || "");
if (value.indexOf("\n") >= 0) {
var pre = document.createElement("pre");
pre.appendChild(document.createTextNode(value));
return pre;
}
return createBlockElement("p", document.createTextNode(value));
}
function inlineContextForTokens(tokens) {
return createInlineContext(
(tokens && tokens.links) || {},
(tokens && tokens.options) || {},
tokens && tokens.marked,
);
}
function collectTextTokens(firstToken, tokens, state) {
var text = String((firstToken && firstToken.text) || "");
while (
state.index < tokens.length &&
tokens[state.index] &&
tokens[state.index].type === "text"
) {
text += "\n" + String(tokens[state.index].text || "");
state.index += 1;
}
return text;
}
function renderInline(text, context) {
var source = String(text || "");
var rules = context && context.rules;
var fragment = emptyFragment();
if (!rules) {
appendDecodedText(fragment, source);
return fragment;
}
while (source) {
var match;
var label;
var definition;
if ((match = rules.escape.exec(source))) {
source = source.substring(match[0].length);
appendDecodedText(fragment, match[1]);
continue;
}
if ((match = rules.autolink.exec(source))) {
source = source.substring(match[0].length);
if (match[2] === "@") {
label =
match[1].charAt(6) === ":"
? match[1].substring(7)
: match[1];
appendNode(
fragment,
createSafeLink(
"mailto:" + label,
null,
document.createTextNode(label),
),
);
} else {
label = match[1];
appendNode(
fragment,
createSafeLink(label, null, document.createTextNode(label)),
);
}
continue;
}
if (!context.inLink && (match = rules.url.exec(source))) {
source = source.substring(match[0].length);
label = match[0];
appendNode(
fragment,
createSafeLink(label, null, document.createTextNode(label)),
);
continue;
}
if ((match = rules.tag.exec(source))) {
source = source.substring(match[0].length);
appendLiteralText(fragment, match[0]);
continue;
}
if ((match = rules.link.exec(source))) {
source = source.substring(match[0].length);
if (match[0].charAt(0) === "!") {
appendNode(
fragment,
createSafeImage(match[2], match[3], decodeEntities(match[1] || "")),
);
} else {
appendNode(
fragment,
createSafeLink(
match[2],
match[3],
renderInline(match[1], cloneInlineContext(context, true)),
),
);
}
continue;
}
match = rules.reflink.exec(source) || rules.nolink.exec(source);
if (match) {
source = source.substring(match[0].length);
label = (match[2] || match[1] || "").replace(/\s+/g, " ");
definition = context.links[String(label).toLowerCase()];
if (!definition || !definition.href) {
appendLiteralText(fragment, match[0].charAt(0));
source = match[0].substring(1) + source;
continue;
}
if (match[0].charAt(0) === "!") {
appendNode(
fragment,
createSafeImage(
definition.href,
definition.title,
decodeEntities(match[1] || ""),
),
);
} else {
appendNode(
fragment,
createSafeLink(
definition.href,
definition.title,
renderInline(match[1], cloneInlineContext(context, true)),
),
);
}
continue;
}
if ((match = rules.strong.exec(source))) {
source = source.substring(match[0].length);
appendNode(
fragment,
createInlineElement(
"strong",
renderInline(
match[2] || match[1],
cloneInlineContext(context, context.inLink),
),
),
);
continue;
}
if ((match = rules.em.exec(source))) {
source = source.substring(match[0].length);
appendNode(
fragment,
createInlineElement(
"em",
renderInline(
match[2] || match[1],
cloneInlineContext(context, context.inLink),
),
),
);
continue;
}
if ((match = rules.code.exec(source))) {
source = source.substring(match[0].length);
var inlineCode = document.createElement("code");
inlineCode.textContent = match[2] || "";
appendNode(fragment, inlineCode);
continue;
}
if ((match = rules.br.exec(source))) {
source = source.substring(match[0].length);
appendNode(fragment, document.createElement("br"));
continue;
}
if ((match = rules.del.exec(source))) {
source = source.substring(match[0].length);
appendNode(
fragment,
createInlineElement(
"del",
renderInline(
match[1],
cloneInlineContext(context, context.inLink),
),
),
);
continue;
}
if ((match = rules.text.exec(source))) {
source = source.substring(match[0].length);
appendDecodedText(fragment, match[0]);
continue;
}
appendLiteralText(fragment, source.charAt(0));
source = source.substring(1);
}
return fragment;
}
function renderTextParagraph(text, tokens) {
return createBlockElement("p", renderInline(text, inlineContextForTokens(tokens)));
}
function renderHeading(token, tokens) {
var depth = parseInt(token.depth, 10);
if (isNaN(depth) || depth < 1 || depth > 6) {
depth = 1;
}
return createBlockElement(
"h" + String(depth),
renderInline(token.text, inlineContextForTokens(tokens)),
);
}
function renderCodeBlock(token) {
var pre = document.createElement("pre");
var code = document.createElement("code");
if (token.lang) {
var className = "lang-" + String(token.lang).trim();
if (CODE_CLASS_RE.test(className)) {
code.className = className;
}
}
code.textContent = token.text || "";
pre.appendChild(code);
return pre;
}
function renderTable(token, tokens) {
var table = document.createElement("table");
var thead = document.createElement("thead");
var tbody = document.createElement("tbody");
var row = document.createElement("tr");
var inlineContext = inlineContextForTokens(tokens);
var i;
var j;
for (i = 0; i < token.header.length; i++) {
var th = document.createElement("th");
appendNode(th, renderInline(token.header[i], cloneInlineContext(inlineContext, false)));
applyTextAlign(th, token.align && token.align[i]);
row.appendChild(th);
}
thead.appendChild(row);
table.appendChild(thead);
for (i = 0; i < token.cells.length; i++) {
row = document.createElement("tr");
for (j = 0; j < token.cells[i].length; j++) {
var td = document.createElement("td");
appendNode(td, renderInline(token.cells[i][j], cloneInlineContext(inlineContext, false)));
applyTextAlign(td, token.align && token.align[j]);
row.appendChild(td);
}
tbody.appendChild(row);
}
table.appendChild(tbody);
return table;
}
function renderListItem(loose, tokens, state) {
var item = document.createElement("li");
while (state.index < tokens.length) {
var token = tokens[state.index++];
if (!token || token.type === "list_item_end") {
break;
}
if (token.type === "space") {
continue;
}
if (!loose && token.type === "text") {
appendNode(
item,
renderInline(
collectTextTokens(token, tokens, state),
inlineContextForTokens(tokens),
),
);
continue;
}
appendNode(item, renderBlockToken(token, tokens, state));
}
return item;
}
function renderList(token, tokens, state) {
var list = document.createElement(token.ordered ? "ol" : "ul");
while (state.index < tokens.length) {
var itemToken = tokens[state.index++];
if (!itemToken) {
break;
}
if (itemToken.type === "list_end") {
break;
}
if (
itemToken.type !== "list_item_start" &&
itemToken.type !== "loose_item_start"
) {
continue;
}
list.appendChild(
renderListItem(itemToken.type === "loose_item_start", tokens, state),
);
}
return list;
}
function renderBlockToken(token, tokens, state) {
switch (token.type) {
case "space":
return emptyFragment();
case "hr":
return document.createElement("hr");
case "heading":
return renderHeading(token, tokens);
case "code":
return renderCodeBlock(token);
case "table":
return renderTable(token, tokens);
case "blockquote_start":
return createBlockElement(
"blockquote",
renderBlocks(tokens, state, "blockquote_end"),
);
case "list_start":
return renderList(token, tokens, state);
case "html":
return createLiteralBlock(token.text);
case "paragraph":
return renderTextParagraph(token.text, tokens);
case "text":
return renderTextParagraph(collectTextTokens(token, tokens, state), tokens);
default:
return createLiteralBlock(token.text || "");
}
}
function renderBlocks(tokens, state, stopType) {
var fragment = emptyFragment();
while (state.index < tokens.length) {
var token = tokens[state.index++];
if (!token) {
break;
}
if (stopType && token.type === stopType) {
break;
}
appendNode(fragment, renderBlockToken(token, tokens, state));
}
return fragment;
}
function buildMarkdownFragment(markdownText, renderer) {
var tokens = getMarkdownTokens(markdownText, renderer);
if (!tokens) {
var fallback = document.createElement("pre");
fallback.textContent = String(markdownText || "");
var fragment = emptyFragment();
fragment.appendChild(fallback);
return fragment;
}
return renderBlocks(tokens, { index: 0 });
}
global.NotebookSafeMarkdown = {
buildFragment: function (markdownText, renderer) {
return buildMarkdownFragment(markdownText, renderer);
},
renderInto: function (container, markdownText, renderer) {
if (!container) {
return;
}
container.replaceChildren(buildMarkdownFragment(markdownText, renderer));
},
};
global.NotebookShortcuts = {
bind: bind,
annotate: annotate,
combo: combo,
formatCombo: formatCombo,
record: record,
isEditableTarget: isEditableTarget,
defaults: defaults,
};
})(window, document);
+2 -2
View File
@@ -11,5 +11,5 @@ if(!/^s(hift)$/i.test(l))throw new Error("Unrecognized modifier name: "+l);r=!0}
},receivedFocus:function(){this.slowPoll()},slowPoll:function(){var e=this;e.pollingFast||e.polling.set(this.cm.options.pollInterval,function(){e.poll(),e.cm.state.focused&&e.slowPoll()})},fastPoll:function(){function e(){var r=n.poll();r||t?(n.pollingFast=!1,n.slowPoll()):(t=!0,n.polling.set(60,e))}var t=!1,n=this;n.pollingFast=!0,n.polling.set(20,e)},poll:function(){var e=this.cm,t=this.textarea,n=this.prevInput;if(this.contextMenuPending||!e.state.focused||nl(t)&&!n&&!this.composing||e.isReadOnly()||e.options.disableInput||e.state.keySeq)return!1;var r=t.value;if(r==n&&!e.somethingSelected())return!1;if(xo&&bo>=9&&this.hasSelection===r||Eo&&/[\uf700-\uf7ff]/.test(r))return e.display.input.reset(),!1;if(e.doc.sel==e.display.selForContextMenu){var i=r.charCodeAt(0);if(8203!=i||n||(n="​"),8666==i)return this.reset(),this.cm.execCommand("undo")}for(var o=0,a=Math.min(n.length,r.length);a>o&&n.charCodeAt(o)==r.charCodeAt(o);)++o;var l=this;return At(e,function(){Z(e,r.slice(o),n.length-o,null,l.composing?"*compose":null),r.length>1e3||r.indexOf("\n")>-1?t.value=l.prevInput="":l.prevInput=r,l.composing&&(l.composing.range.clear(),l.composing.range=e.markText(l.composing.start,e.getCursor("to"),{className:"CodeMirror-composing"}))}),!0},ensurePolled:function(){this.pollingFast&&this.poll()&&(this.pollingFast=!1)},onKeyPress:function(){xo&&bo>=9&&(this.hasSelection=null),this.fastPoll()},onContextMenu:function(e){function t(){if(null!=a.selectionStart){var e=i.somethingSelected(),t="​"+(e?a.value:"");a.value="⇚",a.value=t,r.prevInput=e?"":"​",a.selectionStart=1,a.selectionEnd=t.length,o.selForContextMenu=i.doc.sel}}function n(){if(r.contextMenuPending=!1,r.wrapper.style.cssText=f,a.style.cssText=u,xo&&9>bo&&o.scrollbars.setScrollTop(o.scroller.scrollTop=s),null!=a.selectionStart){(!xo||xo&&9>bo)&&t();var e=0,n=function(){o.selForContextMenu==i.doc.sel&&0==a.selectionStart&&a.selectionEnd>0&&"​"==r.prevInput?Et(i,ua.selectAll)(i):e++<10?o.detectingSelectAll=setTimeout(n,500):o.input.reset()};o.detectingSelectAll=setTimeout(n,200)}}var r=this,i=r.cm,o=i.display,a=r.textarea,l=Yt(i,e),s=o.scroller.scrollTop;if(l&&!Co){var c=i.options.resetSelectionOnContextMenu;c&&-1==i.doc.sel.contains(l)&&Et(i,Te)(i.doc,de(l),Wa);var u=a.style.cssText,f=r.wrapper.style.cssText;r.wrapper.style.cssText="position: absolute";var h=r.wrapper.getBoundingClientRect();if(a.style.cssText="position: absolute; width: 30px; height: 30px; top: "+(e.clientY-h.top-5)+"px; left: "+(e.clientX-h.left-5)+"px; z-index: 1000; background: "+(xo?"rgba(255, 255, 255, .05)":"transparent")+"; outline: none; border-width: 0; outline: none; overflow: hidden; opacity: .05; filter: alpha(opacity=5);",wo)var d=window.scrollY;if(o.input.focus(),wo&&window.scrollTo(null,d),o.input.reset(),i.somethingSelected()||(a.value=r.prevInput=" "),r.contextMenuPending=!0,o.selForContextMenu=i.doc.sel,clearTimeout(o.detectingSelectAll),xo&&bo>=9&&t(),Do){Aa(e);var p=function(){Ia(window,"mouseup",p),setTimeout(n,20)};Ea(window,"mouseup",p)}else setTimeout(n,50)}},readOnlyChanged:function(e){e||this.reset()},setUneditable:Di,needsContentAttribute:!1},ne.prototype),ie.prototype=Wi({init:function(e){function t(e){if(!Ti(r,e)){if(r.somethingSelected())Fo={lineWise:!1,text:r.getSelections()},"cut"==e.type&&r.replaceSelection("",null,"cut");else{if(!r.options.lineWiseCopyCut)return;var t=ee(r);Fo={lineWise:!0,text:t.text},"cut"==e.type&&r.operation(function(){r.setSelections(t.ranges,0,Wa),r.replaceSelection("",null,"cut")})}if(e.clipboardData&&!No)e.preventDefault(),e.clipboardData.clearData(),e.clipboardData.setData("text/plain",Fo.text.join("\n"));else{var n=re(),i=n.firstChild;r.display.lineSpace.insertBefore(n,r.display.lineSpace.firstChild),i.value=Fo.text.join("\n");var o=document.activeElement;Ua(i),setTimeout(function(){r.display.lineSpace.removeChild(n),o.focus()},50)}}}var n=this,r=n.cm,i=n.div=e.lineDiv;te(i),Ea(i,"paste",function(e){Ti(r,e)||J(e,r)}),Ea(i,"compositionstart",function(e){var t=e.data;if(n.composing={sel:r.doc.sel,data:t,startData:t},t){var i=r.doc.sel.primary(),o=r.getLine(i.head.line),a=o.indexOf(t,Math.max(0,i.head.ch-t.length));a>-1&&a<=i.head.ch&&(n.composing.sel=de(Bo(i.head.line,a),Bo(i.head.line,a+t.length)))}}),Ea(i,"compositionupdate",function(e){n.composing.data=e.data}),Ea(i,"compositionend",function(e){var t=n.composing;t&&(e.data==t.startData||/\u200b/.test(e.data)||(t.data=e.data),setTimeout(function(){t.handled||n.applyComposition(t),n.composing==t&&(n.composing=null)},50))}),Ea(i,"touchstart",function(){n.forceCompositionEnd()}),Ea(i,"input",function(){n.composing||!r.isReadOnly()&&n.pollContent()||At(n.cm,function(){Dt(r)})}),Ea(i,"copy",t),Ea(i,"cut",t)},prepareSelection:function(){var e=De(this.cm,!1);return e.focus=this.cm.state.focused,e},showSelection:function(e,t){e&&this.cm.display.view.length&&((e.focus||t)&&this.showPrimarySelection(),this.showMultipleSelections(e))},showPrimarySelection:function(){var e=window.getSLine truncated
if(a)return a}}},da=e.isModifierKey=function(e){var t="string"==typeof e?e:ol[e.keyCode];return"Ctrl"==t||"Alt"==t||"Shift"==t||"Mod"==t},pa=e.keyName=function(e,t){if(Co&&34==e.keyCode&&e["char"])return!1;var n=ol[e.keyCode],r=n;return null==r||e.altGraphKey?!1:(e.altKey&&"Alt"!=n&&(r="Alt-"+r),(Ro?e.metaKey:e.ctrlKey)&&"Ctrl"!=n&&(r="Ctrl-"+r),(Ro?e.ctrlKey:e.metaKey)&&"Cmd"!=n&&(r="Cmd-"+r),!t&&e.shiftKey&&"Shift"!=n&&(r="Shift-"+r),r)};e.fromTextArea=function(t,n){function r(){t.value=c.getValue()}if(n=n?Wi(n):{},n.value=t.value,!n.tabindex&&t.tabIndex&&(n.tabindex=t.tabIndex),!n.placeholder&&t.placeholder&&(n.placeholder=t.placeholder),null==n.autofocus){var i=Gi();n.autofocus=i==t||null!=t.getAttribute("autofocus")&&i==document.body}if(t.form&&(Ea(t.form,"submit",r),!n.leaveSubmitMethodAlone)){var o=t.form,a=o.submit;try{var l=o.submit=function(){r(),o.submit=a,o.submit(),o.submit=l}}catch(s){}}n.finishInit=function(e){e.save=r,e.getTextArea=function(){return t},e.toTextArea=function(){e.toTextArea=isNaN,r(),t.parentNode.removeChild(e.getWrapperElement()),t.style.display="",t.form&&(Ia(t.form,"submit",r),"function"==typeof t.form.submit&&(t.form.submit=a))}},t.style.display="none";var c=e(function(e){t.parentNode.insertBefore(e,t.nextSibling)},n);return c};var ma=e.StringStream=function(e,t){this.pos=this.start=0,this.string=e,this.tabSize=t||8,this.lastColumnPos=this.lastColumnValue=0,this.lineStart=0};ma.prototype={eol:function(){return this.pos>=this.string.length},sol:function(){return this.pos==this.lineStart},peek:function(){return this.string.charAt(this.pos)||void 0},next:function(){return this.pos<this.string.length?this.string.charAt(this.pos++):void 0},eat:function(e){var t=this.string.charAt(this.pos);if("string"==typeof e)var n=t==e;else var n=t&&(e.test?e.test(t):e(t));return n?(++this.pos,t):void 0},eatWhile:function(e){for(var t=this.pos;this.eat(e););return this.pos>t},eatSpace:function(){for(var e=this.pos;/[\s\u00a0]/.test(this.string.charAt(this.pos));)++this.pos;return this.pos>e},skipToEnd:function(){this.pos=this.string.length},skipTo:function(e){var t=this.string.indexOf(e,this.pos);return t>-1?(this.pos=t,!0):void 0},backUp:function(e){this.pos-=e},column:function(){return this.lastColumnPos<this.start&&(this.lastColumnValue=Fa(this.string,this.start,this.tabSize,this.lastColumnPos,this.lastColumnValue),this.lastColumnPos=this.start),this.lastColumnValue-(this.lineStart?Fa(this.string,this.lineStart,this.tabSize):0)},indentation:function(){return Fa(this.string,null,this.tabSize)-(this.lineStart?Fa(this.string,this.lineStart,this.tabSize):0)},match:function(e,t,n){if("string"!=typeof e){var r=this.string.slice(this.pos).match(e);return r&&r.index>0?null:(r&&t!==!1&&(this.pos+=r[0].length),r)}var i=function(e){return n?e.toLowerCase():e},o=this.string.substr(this.pos,e.length);return i(o)==i(e)?(t!==!1&&(this.pos+=e.length),!0):void 0},current:function(){return this.string.slice(this.start,this.pos)},hideFirstChars:function(e,t){this.lineStart+=e;try{return t()}finally{this.lineStart-=e}}};var ga=0,va=e.TextMarker=function(e,t){this.lines=[],this.type=t,this.doc=e,this.id=++ga};Ai(va),va.prototype.clear=function(){if(!this.explicitlyCleared){var e=this.doc.cm,t=e&&!e.curOp;if(t&&bt(e),Ni(this,"clear")){var n=this.find();n&&Ci(this,"clear",n.from,n.to)}for(var r=null,i=null,o=0;o<this.lines.length;++o){var a=this.lines[o],l=er(a.markedSpans,this);e&&!this.collapsed?Ht(e,ti(a),"text"):e&&(null!=l.to&&(i=ti(a)),null!=l.from&&(r=ti(a))),a.markedSpans=tr(a.markedSpans,l),null==l.from&&this.collapsed&&!kr(this.doc,a)&&e&&ei(a,yt(e.display))}if(e&&this.collapsed&&!e.options.lineWrapping)for(var o=0;o<this.lines.length;++o){var s=yr(this.lines[o]),c=f(s);c>e.display.maxLineLength&&(e.display.maxLine=s,e.display.maxLineLength=c,e.display.maxLineChanged=!0)}null!=r&&e&&this.collapsed&&Dt(e,r,i+1),this.lines.length=0,this.explicitlyCleared=!0,this.atomic&&this.doc.cantEdit&&(this.doc.cantEdit=!1,e&&Ae(e.doc)),e&&Ci(e,"markerCleared",e,this),t&&kt(e),this.parent&&this.parent.clear()}},va.prototype.find=function(e,t){null==e&&"bookmark"==this.type&&(e=1);for(var n,r,i=0;i<this.lines.length;++i){var o=this.lines[i],a=er(o.markedSpans,this);if(null!=a.from&&(n=Bo(t?o:ti(o),a.from),-1==e))return n;if(null!=a.to&&(r=Bo(t?o:ti(o),a.to),1==e))return r}return n&&{from:n,to:r}},va.prototype.changed=function(){var e=this.find(-1,!0),t=this,n=this.doc.cm;e&&n&&At(n,function(){var r=e.line,i=ti(e.line),o=Qe(n,i);if(o&&(ot(o),n.curOp.selectionChanged=n.curOp.forceUpdate=!0),n.curOp.updateMaxLine=!0,!kr(t.doc,r)&&null!=t.height){var a=t.height;t.height=null;var l=Lr(t)-a;l&&ei(r,r.height+l)}})},va.prototype.attachLine=function(e){if(!this.lines.length&&this.doc.cm){var t=this.doc.cm.curOp;t.maybeHiddenMarkers&&-1!=Pi(t.maybeHiddenMarkers,this)||(t.maybeUnhiddenMarkers||(t.maybeUnhiddenMarkers=[])).push(this)}this.lines.push(e)},va.prototype.detachLine=function(e){if(this.lines.splice(Pi(this.lines,e),1Line truncated
h(r);var l=t.sol(),s=t.next();if(r.linkTitle){r.linkTitle=!1;var u=s;"("===s&&(u=")"),u=(u+"").replace(/([.?*+^$[\]\\(){}|-])/g,"\\$1");var f="^\\s*(?:[^"+u+"\\\\]+|\\\\\\\\|\\\\.)"+u;if(t.match(new RegExp(f),!0))return S.linkHref}if("`"===s){var d=r.formatting;n.highlightFormatting&&(r.formatting="code"),t.eatWhile("`");var p=t.current().length;if(0==r.code)return r.code=p,h(r);if(p==r.code){var v=h(r);return r.code=0,v}return r.formatting=d,h(r)}if(r.code)return h(r);if("\\"===s&&(t.next(),n.highlightFormatting)){var y=h(r),x=S.formatting+"-escape";return y?y+" "+x:x}if("!"===s&&t.match(/\[[^\]]*\] ?(?:\(|\[)/,!1))return t.match(/\[[^\]]*\]/),r.inline=r.f=g,S.image;if("["===s&&t.match(/[^\]]*\](\(.*\)| ?\[.*?\])/,!1))return r.linkText=!0,n.highlightFormatting&&(r.formatting="link"),h(r);if("]"===s&&r.linkText&&t.match(/\(.*?\)| ?\[.*?\]/,!1)){n.highlightFormatting&&(r.formatting="link");var y=h(r);return r.linkText=!1,r.inline=r.f=g,y}if("<"===s&&t.match(/^(https?|ftps?):\/\/(?:[^\\>]|\\.)+>/,!1)){r.f=r.inline=m,n.highlightFormatting&&(r.formatting="link");var y=h(r);return y?y+=" ":y="",y+S.linkInline}if("<"===s&&t.match(/^[^> \\]+@(?:[^\\>]|\\.)+>/,!1)){r.f=r.inline=m,n.highlightFormatting&&(r.formatting="link");var y=h(r);return y?y+=" ":y="",y+S.linkEmail}if("<"===s&&t.match(/^(!--|\w)/,!1)){var b=t.string.indexOf(">",t.pos);if(-1!=b){var k=t.string.substring(t.start,b);/markdown\s*=\s*('|"){0,1}1('|"){0,1}/.test(k)&&(r.md_inside=!0)}return t.backUp(1),r.htmlState=e.startState(w),o(t,r,c)}if("<"===s&&t.match(/^\/\w*?>/))return r.md_inside=!1,"tag";var C=!1;if(!n.underscoresBreakWords&&"_"===s&&"_"!==t.peek()&&t.match(/(\w)/,!1)){var L=t.pos-2;if(L>=0){var T=t.string.charAt(L);"_"!==T&&T.match(/(\w)/,!1)&&(C=!0)}}if("*"===s||"_"===s&&!C)if(l&&" "===t.peek());else{if(r.strong===s&&t.eat(s)){n.highlightFormatting&&(r.formatting="strong");var v=h(r);return r.strong=!1,v}if(!r.strong&&t.eat(s))return r.strong=s,n.highlightFormatting&&(r.formatting="strong"),h(r);if(r.em===s){n.highlightFormatting&&(r.formatting="em");var v=h(r);return r.em=!1,v}if(!r.em)return r.em=s,n.highlightFormatting&&(r.formatting="em"),h(r)}else if(" "===s&&(t.eat("*")||t.eat("_"))){if(" "===t.peek())return h(r);t.backUp(1)}if(n.strikethrough)if("~"===s&&t.eatWhile(s)){if(r.strikethrough){n.highlightFormatting&&(r.formatting="strikethrough");var v=h(r);return r.strikethrough=!1,v}if(t.match(/^[^\s]/,!1))return r.strikethrough=!0,n.highlightFormatting&&(r.formatting="strikethrough"),h(r)}else if(" "===s&&t.match(/^~~/,!0)){if(" "===t.peek())return h(r);t.backUp(2)}return" "===s&&(t.match(/ +$/,!1)?r.trailingSpace++:r.trailingSpace&&(r.trailingSpaceNewLine=!0)),h(r)}function m(e,t){var r=e.next();if(">"===r){t.f=t.inline=p,n.highlightFormatting&&(t.formatting="link");var i=h(t);return i?i+=" ":i="",i+S.linkInline}return e.match(/^[^>]+/,!0),S.linkInline}function g(e,t){if(e.eatSpace())return null;var r=e.next();return"("===r||"["===r?(t.f=t.inline=v("("===r?")":"]",0),n.highlightFormatting&&(t.formatting="link-string"),t.linkHref=!0,h(t)):"error"}function v(e){return function(t,r){var i=t.next();if(i===e){r.f=r.inline=p,n.highlightFormatting&&(r.formatting="link-string");var o=h(r);return r.linkHref=!1,o}return t.match(P[e]),r.linkHref=!0,h(r)}}function y(e,t){return e.match(/^([^\]\\]|\\.)*\]:/,!1)?(t.f=x,e.next(),n.highlightFormatting&&(t.formatting="link"),t.linkText=!0,h(t)):i(e,t,p)}function x(e,t){if(e.match(/^\]:/,!0)){t.f=t.inline=b,n.highlightFormatting&&(t.formatting="link");var r=h(t);return t.linkText=!1,r}return e.match(/^([^\]\\]|\\.)+/,!0),S.linkText}function b(e,t){return e.eatSpace()?null:(e.match(/^[^\s]+/,!0),void 0===e.peek()?t.linkTitle=!0:e.match(/^(?:\s+(?:"(?:[^"\\]|\\\\|\\.)+"|'(?:[^'\\]|\\\\|\\.)+'|\((?:[^)\\]|\\\\|\\.)+\)))?/,!0),t.f=t.inline=p,S.linkHref+" url")}var w=e.getMode(t,"text/html"),k="null"==w.name;void 0===n.highlightFormatting&&(n.highlightFormatting=!1),void 0===n.maxBlockquoteDepth&&(n.maxBlockquoteDepth=0),void 0===n.underscoresBreakWords&&(n.underscoresBreakWords=!0),void 0===n.taskLists&&(n.taskLists=!1),void 0===n.strikethrough&&(n.strikethrough=!1),void 0===n.tokenTypeOverrides&&(n.tokenTypeOverrides={});var S={header:"header",code:"comment",quote:"quote",list1:"variable-2",list2:"variable-3",list3:"keyword",hr:"hr",image:"tag",formatting:"formatting",linkInline:"link",linkEmail:"link",linkText:"link",linkHref:"string",em:"em",strong:"strong",strikethrough:"strikethrough"};for(var C in S)S.hasOwnProperty(C)&&n.tokenTypeOverrides[C]&&(S[C]=n.tokenTypeOverrides[C]);var L=/^([*\-_])(?:\s*\1){2,}\s*$/,T=/^[*\-+]\s+/,M=/^[0-9]+([.)])\s+/,N=/^\[(x| )\](?=\s)/,A=n.allowAtxHeaderWithoutSpace?/^(#+)/:/^(#+)(?: |$)/,E=/^ *(?:\={1,}|-{1,})\s*$/,O=/^[^#!\[\]*_\\<>` "'(~]+/,I=new RegExp("^("+(n.fencedCodeBlocks===!0?"~~~+|```+":n.fencedCodeBlocks)+")[ \\t]*([\\w+#-]*)"),P={")":/^(?:[^\\\(\)]|\\.|\((?:[^\\\(\)]|\\.)*\))*?(?=\))/,"]":/^(?:[^\\\[\]]|\\.|\[(?:[^\\\[\\]]|\\.)*\])*?(?=\])/},R={startState:Line truncated
header:o[1].replace(/^ *| *\| *$/g,"").split(/ *\| */),align:o[2].replace(/^ *|\| *$/g,"").split(/ *\| */),cells:o[3].replace(/(?: *\| *)?\n$/,"").split("\n")},u=0;u<s.align.length;u++)/^ *-+: *$/.test(s.align[u])?s.align[u]="right":/^ *:-+: *$/.test(s.align[u])?s.align[u]="center":/^ *:-+ *$/.test(s.align[u])?s.align[u]="left":s.align[u]=null;for(u=0;u<s.cells.length;u++)s.cells[u]=s.cells[u].replace(/^ *\| *| *\| *$/g,"").split(/ *\| */);this.tokens.push(s)}else if(t&&(o=this.rules.paragraph.exec(e)))e=e.substring(o[0].length),this.tokens.push({type:"paragraph",text:"\n"===o[1].charAt(o[1].length-1)?o[1].slice(0,-1):o[1]});else if(o=this.rules.text.exec(e))e=e.substring(o[0].length),this.tokens.push({type:"text",text:o[0]});else if(e)throw new Error("Infinite loop on byte: "+e.charCodeAt(0));return this.tokens};var p={escape:/^\\([\\`*{}\[\]()#+\-.!_>])/,autolink:/^<([^ >]+(@|:\/)[^ >]+)>/,url:u,tag:/^<!--[\s\S]*?-->|^<\/?\w+(?:"[^"]*"|'[^']*'|[^'">])*?>/,link:/^!?\[(inside)\]\(href\)/,reflink:/^!?\[(inside)\]\s*\[([^\]]*)\]/,nolink:/^!?\[((?:\[[^\]]*\]|[^\[\]])*)\]/,strong:/^__([\s\S]+?)__(?!_)|^\*\*([\s\S]+?)\*\*(?!\*)/,em:/^\b_((?:[^_]|__)+?)_\b|^\*((?:\*\*|[\s\S])+?)\*(?!\*)/,code:/^(`+)\s*([\s\S]*?[^`])\s*\1(?!`)/,br:/^ {2,}\n(?!\s*$)/,del:u,text:/^[\s\S]+?(?=[\\<!\[_*`]| {2,}\n|$)/};p._inside=/(?:\[[^\]]*\]|[^\[\]]|\](?=[^\[]*\]))*/,p._href=/\s*<?([\s\S]*?)>?(?:\s+['"]([\s\S]*?)['"])?\s*/,p.link=c(p.link)("inside",p._inside)("href",p._href)(),p.reflink=c(p.reflink)("inside",p._inside)(),p.normal=f({},p),p.pedantic=f({},p.normal,{strong:/^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/,em:/^_(?=\S)([\s\S]*?\S)_(?!_)|^\*(?=\S)([\s\S]*?\S)\*(?!\*)/}),p.gfm=f({},p.normal,{escape:c(p.escape)("])","~|])")(),url:/^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/,del:/^~~(?=\S)([\s\S]*?\S)~~/,text:c(p.text)("]|","~]|")("|","|https?://|")()}),p.breaks=f({},p.gfm,{br:c(p.br)("{2,}","*")(),text:c(p.gfm.text)("{2,}","*")()}),i.rules=p,i.output=function(e,t,n){var r=new i(t,n);return r.output(e)},i.prototype.output=function(e){for(var t,n,r,i,o="";e;)if(i=this.rules.escape.exec(e))e=e.substring(i[0].length),o+=i[1];else if(i=this.rules.autolink.exec(e))e=e.substring(i[0].length),"@"===i[2]?(n=":"===i[1].charAt(6)?this.mangle(i[1].substring(7)):this.mangle(i[1]),r=this.mangle("mailto:")+n):(n=l(i[1]),r=n),o+=this.renderer.link(r,null,n);else if(this.inLink||!(i=this.rules.url.exec(e))){if(i=this.rules.tag.exec(e))!this.inLink&&/^<a /i.test(i[0])?this.inLink=!0:this.inLink&&/^<\/a>/i.test(i[0])&&(this.inLink=!1),e=e.substring(i[0].length),o+=this.options.sanitize?this.options.sanitizer?this.options.sanitizer(i[0]):l(i[0]):i[0];else if(i=this.rules.link.exec(e))e=e.substring(i[0].length),this.inLink=!0,o+=this.outputLink(i,{href:i[2],title:i[3]}),this.inLink=!1;else if((i=this.rules.reflink.exec(e))||(i=this.rules.nolink.exec(e))){if(e=e.substring(i[0].length),t=(i[2]||i[1]).replace(/\s+/g," "),t=this.links[t.toLowerCase()],!t||!t.href){o+=i[0].charAt(0),e=i[0].substring(1)+e;continue}this.inLink=!0,o+=this.outputLink(i,t),this.inLink=!1}else if(i=this.rules.strong.exec(e))e=e.substring(i[0].length),o+=this.renderer.strong(this.output(i[2]||i[1]));else if(i=this.rules.em.exec(e))e=e.substring(i[0].length),o+=this.renderer.em(this.output(i[2]||i[1]));else if(i=this.rules.code.exec(e))e=e.substring(i[0].length),o+=this.renderer.codespan(l(i[2],!0));else if(i=this.rules.br.exec(e))e=e.substring(i[0].length),o+=this.renderer.br();else if(i=this.rules.del.exec(e))e=e.substring(i[0].length),o+=this.renderer.del(this.output(i[1]));else if(i=this.rules.text.exec(e))e=e.substring(i[0].length),o+=this.renderer.text(l(this.smartypants(i[0])));else if(e)throw new Error("Infinite loop on byte: "+e.charCodeAt(0))}else e=e.substring(i[0].length),n=l(i[1]),r=n,o+=this.renderer.link(r,null,n);return o},i.prototype.outputLink=function(e,t){var n=l(t.href),r=t.title?l(t.title):null;return"!"!==e[0].charAt(0)?this.renderer.link(n,r,this.output(e[1])):this.renderer.image(n,r,l(e[1]))},i.prototype.smartypants=function(e){return this.options.smartypants?e.replace(/---/g,"—").replace(/--/g,"–").replace(/(^|[-\u2014\/(\[{"\s])'/g,"$1‘").replace(/'/g,"’").replace(/(^|[-\u2014\/(\[{\u2018\s])"/g,"$1“").replace(/"/g,"”").replace(/\.{3}/g,"…"):e},i.prototype.mangle=function(e){if(!this.options.mangle)return e;for(var t,n="",r=e.length,i=0;r>i;i++)t=e.charCodeAt(i),Math.random()>.5&&(t="x"+t.toString(16)),n+="&#"+t+";";return n},o.prototype.code=function(e,t,n){if(this.options.highlight){var r=this.options.highlight(e,t);null!=r&&r!==e&&(n=!0,e=r)}return t?'<pre><code class="'+this.options.langPrefix+l(t,!0)+'">'+(n?e:l(e,!0))+"\n</code></pre>\n":"<pre><code>"+(n?e:l(e,!0))+"\n</code></pre>"},o.prototype.blockquote=function(e){return"<blockquote>\n"+e+"</blockquote>\n"},o.prototype.html=function(e){return e},o.prototype.heading=function(e,t,n){return"<h"+t+' id="'+this.options.headerPrefix+n.toLowerCase().replace(/[^\w]+/g,"-"Line truncated
}(navigator.userAgent||navigator.vendor||window.opera),e},V="",K={bold:{name:"bold",action:c,className:"fa fa-bold",title:"Bold","default":!0},italic:{name:"italic",action:u,className:"fa fa-italic",title:"Italic","default":!0},strikethrough:{name:"strikethrough",action:f,className:"fa fa-strikethrough",title:"Strikethrough"},heading:{name:"heading",action:p,className:"fa fa-header",title:"Heading","default":!0},"heading-smaller":{name:"heading-smaller",action:p,className:"fa fa-header fa-header-x fa-header-smaller",title:"Smaller Heading"},"heading-bigger":{name:"heading-bigger",action:m,className:"fa fa-header fa-header-x fa-header-bigger",title:"Bigger Heading"},"heading-1":{name:"heading-1",action:g,className:"fa fa-header fa-header-x fa-header-1",title:"Big Heading"},"heading-2":{name:"heading-2",action:v,className:"fa fa-header fa-header-x fa-header-2",title:"Medium Heading"},"heading-3":{name:"heading-3",action:y,className:"fa fa-header fa-header-x fa-header-3",title:"Small Heading"},"separator-1":{name:"separator-1"},code:{name:"code",action:h,className:"fa fa-code",title:"Code"},quote:{name:"quote",action:d,className:"fa fa-quote-left",title:"Quote","default":!0},"unordered-list":{name:"unordered-list",action:x,className:"fa fa-list-ul",title:"Generic List","default":!0},"ordered-list":{name:"ordered-list",action:b,className:"fa fa-list-ol",title:"Numbered List","default":!0},"clean-block":{name:"clean-block",action:w,className:"fa fa-eraser fa-clean-block",title:"Clean block"},"separator-2":{name:"separator-2"},link:{name:"link",action:k,className:"fa fa-link",title:"Create Link","default":!0},image:{name:"image",action:S,className:"fa fa-picture-o",title:"Insert Image","default":!0},table:{name:"table",action:C,className:"fa fa-table",title:"Insert Table"},"horizontal-rule":{name:"horizontal-rule",action:L,className:"fa fa-minus",title:"Insert Horizontal Line"},"separator-3":{name:"separator-3"},preview:{name:"preview",action:A,className:"fa fa-eye no-disable",title:"Toggle Preview","default":!0},"side-by-side":{name:"side-by-side",action:N,className:"fa fa-columns no-disable no-mobile",title:"Toggle Side by Side","default":!0},fullscreen:{name:"fullscreen",action:s,className:"fa fa-arrows-alt no-disable no-mobile",title:"Toggle Fullscreen","default":!0},"separator-4":{name:"separator-4"},guide:{name:"guide",action:"https://simplemde.com/markdown-guide",className:"fa fa-question-circle",title:"Markdown Guide","default":!0},"separator-5":{name:"separator-5"},undo:{name:"undo",action:T,className:"fa fa-undo no-disable",title:"Undo"},redo:{name:"redo",action:M,className:"fa fa-repeat no-disable",title:"Redo"}},X={link:["[","](#url#)"],image:["![](","#url#)"],table:["","\n\n| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Text | Text | Text |\n\n"],horizontalRule:["","\n\n-----\n\n"]},Z={link:"URL for the link:",image:"URL of the image:"},J={bold:"**",code:"```",italic:"*"};B.prototype.markdown=function(e){if(j){var t={};return this.options&&this.options.renderingConfig&&this.options.renderingConfig.singleLineBreaks===!1?t.breaks=!1:t.breaks=!0,this.options&&this.options.renderingConfig&&this.options.renderingConfig.codeSyntaxHighlighting===!0&&window.hljs&&(t.highlight=function(e){return window.hljs.highlightAuto(e).value}),j.setOptions(t),j(e)}},B.prototype.render=function(e){if(e||(e=this.element||document.getElementsByTagName("textarea")[0]),!this._rendered||this._rendered!==e){this.element=e;var t=this.options,n=this,i={};for(var o in t.shortcuts)null!==t.shortcuts[o]&&null!==q[o]&&!function(e){i[r(t.shortcuts[e])]=function(){q[e](n)}}(o);i.Enter="newlineAndIndentContinueMarkdownList",i.Tab="tabAndIndentMarkdownList",i["Shift-Tab"]="shiftTabAndUnindentMarkdownList",i.Esc=function(e){e.getOption("fullScreen")&&s(n)},document.addEventListener("keydown",function(e){e=e||window.event,27==e.keyCode&&n.codemirror.getOption("fullScreen")&&s(n)},!1);var a,l;if(t.spellChecker!==!1?(a="spell-checker",l=t.parsingConfig,l.name="gfm",l.gitHubSpice=!1,z({codeMirrorInstance:F})):(a=t.parsingConfig,a.name="gfm",a.gitHubSpice=!1),this.codemirror=F.fromTextArea(e,{mode:a,backdrop:l,theme:"paper",tabSize:void 0!=t.tabSize?t.tabSize:2,indentUnit:void 0!=t.tabSize?t.tabSize:2,indentWithTabs:t.indentWithTabs!==!1,lineNumbers:!1,autofocus:t.autofocus===!0,extraKeys:i,lineWrapping:t.lineWrapping!==!1,allowDropFileTypes:["text/plain"],placeholder:t.placeholder||e.getAttribute("placeholder")||"",styleSelectedText:void 0!=t.styleSelectedText?t.styleSelectedText:!0}),t.forceSync===!0){var c=this.codemirror;c.on("change",function(){c.save()})}this.gui={},t.toolbar!==!1&&(this.gui.toolbar=this.createToolbar()),t.status!==!1&&(this.gui.statusbar=this.createStatusbar()),void 0!=t.autosave&&t.autosave.enabled===!0&&this.autosave(),this.gui.sideBySide=this.createSideBySide(),this._rendered=this.element;var u=this.codemirror;setTimeout(function(){u.refresh()}.bind(u),0)}},B.prototype.autosaveLine truncated
header:o[1].replace(/^ *| *\| *$/g,"").split(/ *\| */),align:o[2].replace(/^ *|\| *$/g,"").split(/ *\| */),cells:o[3].replace(/(?: *\| *)?\n$/,"").split("\n")},u=0;u<s.align.length;u++)/^ *-+: *$/.test(s.align[u])?s.align[u]="right":/^ *:-+: *$/.test(s.align[u])?s.align[u]="center":/^ *:-+ *$/.test(s.align[u])?s.align[u]="left":s.align[u]=null;for(u=0;u<s.cells.length;u++)s.cells[u]=s.cells[u].replace(/^ *\| *| *\| *$/g,"").split(/ *\| */);this.tokens.push(s)}else if(t&&(o=this.rules.paragraph.exec(e)))e=e.substring(o[0].length),this.tokens.push({type:"paragraph",text:"\n"===o[1].charAt(o[1].length-1)?o[1].slice(0,-1):o[1]});else if(o=this.rules.text.exec(e))e=e.substring(o[0].length),this.tokens.push({type:"text",text:o[0]});else if(e)throw new Error("Infinite loop on byte: "+e.charCodeAt(0));return this.tokens};var p={escape:/^\\([\\`*{}\[\]()#+\-.!_>])/,autolink:/^<([^ >]+(@|:\/)[^ >]+)>/,url:u,tag:/^<!--[\s\S]*?-->|^<\/?\w+(?:"[^"]*"|'[^']*'|[^'">])*?>/,link:/^!?\[(inside)\]\(href\)/,reflink:/^!?\[(inside)\]\s*\[([^\]]*)\]/,nolink:/^!?\[((?:\[[^\]]*\]|[^\[\]])*)\]/,strong:/^__([\s\S]+?)__(?!_)|^\*\*([\s\S]+?)\*\*(?!\*)/,em:/^\b_((?:[^_]|__)+?)_\b|^\*((?:\*\*|[\s\S])+?)\*(?!\*)/,code:/^(`+)\s*([\s\S]*?[^`])\s*\1(?!`)/,br:/^ {2,}\n(?!\s*$)/,del:u,text:/^[\s\S]+?(?=[\\<!\[_*`]| {2,}\n|$)/};p._inside=/(?:\[[^\]]*\]|[^\[\]]|\](?=[^\[]*\]))*/,p._href=/\s*<?([\s\S]*?)>?(?:\s+['"]([\s\S]*?)['"])?\s*/,p.link=c(p.link)("inside",p._inside)("href",p._href)(),p.reflink=c(p.reflink)("inside",p._inside)(),p.normal=f({},p),p.pedantic=f({},p.normal,{strong:/^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/,em:/^_(?=\S)([\s\S]*?\S)_(?!_)|^\*(?=\S)([\s\S]*?\S)\*(?!\*)/}),p.gfm=f({},p.normal,{escape:c(p.escape)("])","~|])")(),url:/^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/,del:/^~~(?=\S)([\s\S]*?\S)~~/,text:c(p.text)("]|","~]|")("|","|https?://|")()}),p.breaks=f({},p.gfm,{br:c(p.br)("{2,}","*")(),text:c(p.gfm.text)("{2,}","*")()}),i.rules=p,i.output=function(e,t,n){var r=new i(t,n);return r.output(e)},i.prototype.output=function(e){for(var t,n,r,i,o="";e;)if(i=this.rules.escape.exec(e))e=e.substring(i[0].length),o+=i[1];else if(i=this.rules.autolink.exec(e))e=e.substring(i[0].length),"@"===i[2]?(n=":"===i[1].charAt(6)?this.mangle(i[1].substring(7)):this.mangle(i[1]),r=this.mangle("mailto:")+n):(n=l(i[1]),r=n),o+=this.renderer.link(r,null,n);else if(this.inLink||!(i=this.rules.url.exec(e))){if(i=this.rules.tag.exec(e))!this.inLink&&/^<a /i.test(i[0])?this.inLink=!0:this.inLink&&/^<\/a>/i.test(i[0])&&(this.inLink=!1),e=e.substring(i[0].length),o+=this.options.sanitize?this.options.sanitizer?this.options.sanitizer(i[0]):l(i[0]):i[0];else if(i=this.rules.link.exec(e))e=e.substring(i[0].length),this.inLink=!0,o+=this.outputLink(i,{href:i[2],title:i[3]}),this.inLink=!1;else if((i=this.rules.reflink.exec(e))||(i=this.rules.nolink.exec(e))){if(e=e.substring(i[0].length),t=(i[2]||i[1]).replace(/\s+/g," "),t=this.links[t.toLowerCase()],!t||!t.href){o+=i[0].charAt(0),e=i[0].substring(1)+e;continue}this.inLink=!0,o+=this.outputLink(i,t),this.inLink=!1}else if(i=this.rules.strong.exec(e))e=e.substring(i[0].length),o+=this.renderer.strong(this.output(i[2]||i[1]));else if(i=this.rules.em.exec(e))e=e.substring(i[0].length),o+=this.renderer.em(this.output(i[2]||i[1]));else if(i=this.rules.code.exec(e))e=e.substring(i[0].length),o+=this.renderer.codespan(l(i[2],!0));else if(i=this.rules.br.exec(e))e=e.substring(i[0].length),o+=this.renderer.br();else if(i=this.rules.del.exec(e))e=e.substring(i[0].length),o+=this.renderer.del(this.output(i[1]));else if(i=this.rules.text.exec(e))e=e.substring(i[0].length),o+=this.renderer.text(l(this.smartypants(i[0])));else if(e)throw new Error("Infinite loop on byte: "+e.charCodeAt(0))}else e=e.substring(i[0].length),n=l(i[1]),r=n,o+=this.renderer.link(r,null,n);return o},i.prototype.outputLink=function(e,t){var n=l(t.href),r=t.title?l(t.title):null;return"!"!==e[0].charAt(0)?this.renderer.link(n,r,this.output(e[1])):this.renderer.image(n,r,l(e[1]))},i.prototype.smartypants=function(e){return this.options.smartypants?e.replace(/---/g,"—").replace(/--/g,"–").replace(/(^|[-\u2014\/(\[{"\s])'/g,"$1‘").replace(/'/g,"’").replace(/(^|[-\u2014\/(\[{\u2018\s])"/g,"$1“").replace(/"/g,"”").replace(/\.{3}/g,"…"):e},i.prototype.mangle=function(e){if(!this.options.mangle)return e;for(var t,n="",r=e.length,i=0;r>i;i++)t=e.charCodeAt(i),Math.random()>.5&&(t="x"+t.toString(16)),n+="&#"+t+";";return n},o.prototype.code=function(e,t,n){if(this.options.highlight){var r=this.options.highlight(e,t);null!=r&&r!==e&&(n=!0,e=r)}return t?'<pre><code class="'+this.options.langPrefix+l(t,!0)+'">'+(n?e:l(e,!0))+"\n</code></pre>\n":"<pre><code>"+(n?e:l(e,!0))+"\n</code></pre>"},o.prototype.blockquote=function(e){return"<blockquote>\n"+e+"</blockquote>\n"},o.prototype.html=function(e){return e},o.prototype.heading=function(e,t,n){return"<h"+t+' id="'+this.options.headerPrefix+n.toLowerCase().replace(/[^\w]+/g,"-"Line truncated
}(navigator.userAgent||navigator.vendor||window.opera),e},V="",K={bold:{name:"bold",action:c,className:"fa fa-bold",title:"Bold","default":!0},italic:{name:"italic",action:u,className:"fa fa-italic",title:"Italic","default":!0},strikethrough:{name:"strikethrough",action:f,className:"fa fa-strikethrough",title:"Strikethrough"},heading:{name:"heading",action:p,className:"fa fa-header",title:"Heading","default":!0},"heading-smaller":{name:"heading-smaller",action:p,className:"fa fa-header fa-header-x fa-header-smaller",title:"Smaller Heading"},"heading-bigger":{name:"heading-bigger",action:m,className:"fa fa-header fa-header-x fa-header-bigger",title:"Bigger Heading"},"heading-1":{name:"heading-1",action:g,className:"fa fa-header fa-header-x fa-header-1",title:"Big Heading"},"heading-2":{name:"heading-2",action:v,className:"fa fa-header fa-header-x fa-header-2",title:"Medium Heading"},"heading-3":{name:"heading-3",action:y,className:"fa fa-header fa-header-x fa-header-3",title:"Small Heading"},"separator-1":{name:"separator-1"},code:{name:"code",action:h,className:"fa fa-code",title:"Code"},quote:{name:"quote",action:d,className:"fa fa-quote-left",title:"Quote","default":!0},"unordered-list":{name:"unordered-list",action:x,className:"fa fa-list-ul",title:"Generic List","default":!0},"ordered-list":{name:"ordered-list",action:b,className:"fa fa-list-ol",title:"Numbered List","default":!0},"clean-block":{name:"clean-block",action:w,className:"fa fa-eraser fa-clean-block",title:"Clean block"},"separator-2":{name:"separator-2"},link:{name:"link",action:k,className:"fa fa-link",title:"Create Link","default":!0},image:{name:"image",action:S,className:"fa fa-picture-o",title:"Insert Image","default":!0},table:{name:"table",action:C,className:"fa fa-table",title:"Insert Table"},"horizontal-rule":{name:"horizontal-rule",action:L,className:"fa fa-minus",title:"Insert Horizontal Line"},"separator-3":{name:"separator-3"},preview:{name:"preview",action:A,className:"fa fa-eye no-disable",title:"Toggle Preview","default":!0},"side-by-side":{name:"side-by-side",action:N,className:"fa fa-columns no-disable no-mobile",title:"Toggle Side by Side","default":!0},fullscreen:{name:"fullscreen",action:s,className:"fa fa-arrows-alt no-disable no-mobile",title:"Toggle Fullscreen","default":!0},"separator-4":{name:"separator-4"},guide:{name:"guide",action:"https://simplemde.com/markdown-guide",className:"fa fa-question-circle",title:"Markdown Guide","default":!0},"separator-5":{name:"separator-5"},undo:{name:"undo",action:T,className:"fa fa-undo no-disable",title:"Undo"},redo:{name:"redo",action:M,className:"fa fa-repeat no-disable",title:"Redo"}},X={link:["[","](#url#)"],image:["![](","#url#)"],table:["","\n\n| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Text | Text | Text |\n\n"],horizontalRule:["","\n\n-----\n\n"]},Z={link:"URL for the link:",image:"URL of the image:"},J={bold:"**",code:"```",italic:"*"};B.marked=j,B.prototype.markdown=function(e){if(j){var t={};return this.options&&this.options.renderingConfig&&this.options.renderingConfig.singleLineBreaks===!1?t.breaks=!1:t.breaks=!0,this.options&&this.options.renderingConfig&&this.options.renderingConfig.codeSyntaxHighlighting===!0&&window.hljs&&(t.highlight=function(e){return window.hljs.highlightAuto(e).value}),j.setOptions(t),j(e)}},B.prototype.render=function(e){if(e||(e=this.element||document.getElementsByTagName("textarea")[0]),!this._rendered||this._rendered!==e){this.element=e;var t=this.options,n=this,i={};for(var o in t.shortcuts)null!==t.shortcuts[o]&&null!==q[o]&&!function(e){i[r(t.shortcuts[e])]=function(){q[e](n)}}(o);i.Enter="newlineAndIndentContinueMarkdownList",i.Tab="tabAndIndentMarkdownList",i["Shift-Tab"]="shiftTabAndUnindentMarkdownList",i.Esc=function(e){e.getOption("fullScreen")&&s(n)},document.addEventListener("keydown",function(e){e=e||window.event,27==e.keyCode&&n.codemirror.getOption("fullScreen")&&s(n)},!1);var a,l;if(t.spellChecker!==!1?(a="spell-checker",l=t.parsingConfig,l.name="gfm",l.gitHubSpice=!1,z({codeMirrorInstance:F})):(a=t.parsingConfig,a.name="gfm",a.gitHubSpice=!1),this.codemirror=F.fromTextArea(e,{mode:a,backdrop:l,theme:"paper",tabSize:void 0!=t.tabSize?t.tabSize:2,indentUnit:void 0!=t.tabSize?t.tabSize:2,indentWithTabs:t.indentWithTabs!==!1,lineNumbers:!1,autofocus:t.autofocus===!0,extraKeys:i,lineWrapping:t.lineWrapping!==!1,allowDropFileTypes:["text/plain"],placeholder:t.placeholder||e.getAttribute("placeholder")||"",styleSelectedText:void 0!=t.styleSelectedText?t.styleSelectedText:!0}),t.forceSync===!0){var c=this.codemirror;c.on("change",function(){c.save()})}this.gui={},t.toolbar!==!1&&(this.gui.toolbar=this.createToolbar()),t.status!==!1&&(this.gui.statusbar=this.createStatusbar()),void 0!=t.autosave&&t.autosave.enabled===!0&&this.autosave(),this.gui.sideBySide=this.createSideBySide(),this._rendered=this.element;var u=this.codemirror;setTimeout(function(){u.refresh()}.bind(u),0)}},B.prototyLine truncated
+20 -2
View File
@@ -16,6 +16,20 @@
.background-white { background: white; }
.spacer-12px { width: 12px; display: inline-block; }
</style>
<script>window.NOTEBOOK_KB={{ keybindings }};</script>
<script src="{{ $root }}static/shortcuts.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
NotebookShortcuts.annotate(document.getElementById('nav-home'), 'cancel');
NotebookShortcuts.annotate(document.getElementById('nav-settings'), 'settings');
NotebookShortcuts.annotate(document.getElementById('nav-new-page'), 'new_page');
NotebookShortcuts.bind([
{ action: 'new_page', handler: function() { document.getElementById('nav-new-page').click(); } },
{ action: 'settings', handler: function() { document.getElementById('nav-settings').click(); } },
{ action: 'cancel', handler: function() { document.getElementById('nav-home').click(); } }
]);
});
</script>
</head>
<body>
<section class="container grid-960 mt-10">
@@ -25,7 +39,11 @@
<a href="{{ $root }}" class="navbar-brand mr-10">Rizin Notebook</a>
</section>
<section class="navbar-section">
<a href="{{ $root }}" class="btn btn-primary">Go Back</a>
<a href="{{ $root }}settings" class="btn" id="nav-settings">Settings</a>
&nbsp;
<a href="{{ $root }}new" class="btn" id="nav-new-page">New Page</a>
&nbsp;
<a href="{{ $root }}" class="btn btn-primary" id="nav-home">Go Back</a>
</section>
</header>
<section class="container">
@@ -71,7 +89,7 @@
<span class="input-group-addon detail-size">Build</span>
</div>
<div class="input-group spacing-top">
<span class="input-group-addon detail-size">Location (RIZIN_PATH)</span>
<span class="input-group-addon detail-size">Resolved Binary</span>
</div>
</div>
<div class="column">
+7 -1
View File
@@ -17,8 +17,14 @@
<input type="text" disabled="" class="form-input" value="Error: {{ .error }}"/>
</div>
<div class="form-group">
<a class="btn" href="{{ .root }}output/deleted">Delete</a>
<button class="btn" type="button" onclick="window.parent.postMessage({type:'iframe-remove'},'*')">Delete</button>
</div>
</div>
<script>
(function(){
var h = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
if (window.parent !== window) window.parent.postMessage({type:'iframe-resize',height:h},'*');
})();
</script>
</body>
</html>
+69 -3
View File
@@ -10,17 +10,83 @@
<style type="text/css">
html, body { width: 100%; height: 100%; margin: 0 }
.button-edit { width: 75px ! important; margin-top: 9px; margin-right: 9px; }
.nb-ac-wrap { position: relative; }
.nb-ac-dropdown {
position: absolute; top: 100%; left: 0; right: 0; z-index: 99999;
background: white; border: 1px solid transparent; border-top: none;
border-radius: 0 0 4px 4px; max-height: 260px; overflow-y: auto;
display: none;
}
.nb-ac-item {
padding: 5px 10px; cursor: pointer;
font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.nb-ac-item:hover, .nb-ac-item.nb-ac-sel { font-weight: bold; }
.nb-ac-cmd { color: #1447e6; font-weight: bold; font-family: Inconsolata, monospace; margin-right: 4px; }
.nb-ac-args { color: #178236; font-style: italic; font-family: Inconsolata, monospace; margin-right: 6px; }
.nb-ac-summ { font-size: 12px; font-style: normal; opacity: .7; }
</style>
<script>window.NOTEBOOK_KB={{ keybindings }};</script>
<script>window.NB_ROOT="{{ .root }}";</script>
<script>window.NB_AC_CONFIG={{ autocomplete_config }};</script>
<script src="{{ .root }}static/shortcuts.js"></script>
<script src="{{ .root }}static/autocomplete.js"></script>
</head>
<body>
<form style="margin-top: 15px; height: 100%;" action="{{ .root }}output/exec/{{ .unique }}" method="POST">
{{ if .message }}
<div class="toast toast-success" style="margin: 12px 12px 0 12px;">{{ .message }}</div>
{{ end }}
{{ if .error }}
<div class="toast toast-error" style="margin: 12px 12px 0 12px;">{{ .error }}</div>
{{ end }}
<form id="cmdform" style="margin-top: 15px; height: 100%;" action="{{ .root }}output/exec/{{ .unique }}" method="POST">
<div class="form-group">
<input type="text" class="form-input" name="command" value="" placeholder="Rizin command for example: pdf"/>
<input type="text" class="form-input command-input" id="cmdinput" name="command" value="{{ .command }}" placeholder="Rizin command (e.g. pdf, aaa, ii)" autofocus/>
</div>
<div class="form-group">
<button class="btn btn-primary" type="submit">Exec</button>
<a class="btn" href="{{ .root }}output/deleted">Cancel</a>
<button class="btn" type="button" id="cancel-console" onclick="window.parent.postMessage({type:'iframe-remove'},'*')">Cancel</button>
<span style="font-size:12px;margin-left:8px;opacity:.7;">Ctrl+Enter to execute</span>
</div>
</form>
<script>
var createdCellUnique = {{ if .cellUnique }}"{{ .cellUnique }}"{{ else }}""{{ end }};
var createdCellLineCount = {{ if .lineCount }}{{ .lineCount }}{{ else }}0{{ end }};
var createdCellWordCount = {{ if .wordCount }}{{ .wordCount }}{{ else }}0{{ end }};
var createdCellActionTime = {{ if .actionTime }}"{{ .actionTime }}"{{ else }}""{{ end }};
var input = document.getElementById('cmdinput');
NotebookShortcuts.annotate(document.getElementById('cancel-console'), 'cancel');
NotebookShortcuts.bind([
{ action: 'cancel', allowInInputs: true, handler: function() { document.getElementById('cancel-console').click(); } },
{ action: 'execute', allowInInputs: true, handler: function() { document.getElementById('cmdform').submit(); } }
]);
input.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); document.getElementById('cmdform').submit(); }
});
document.getElementById('cmdform').addEventListener('submit', function(e) {
if (!input.value.trim()) { e.preventDefault(); window.parent.postMessage({type:'iframe-remove'},'*'); }
});
(function(){
if (createdCellUnique && window.parent !== window) {
window.parent.postMessage({
type:'iframe-add-cell',
cellType:'command',
cellUnique:createdCellUnique,
content:input.value,
lineCount:createdCellLineCount,
wordCount:createdCellWordCount,
actionTime:createdCellActionTime,
outputUrl:'{{ .root }}output/view/{{ .unique }}/' + createdCellUnique,
deleteUrl:'{{ .root }}output/delete/{{ .unique }}/' + createdCellUnique
}, '*');
}
var h = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
if (window.parent !== window) window.parent.postMessage({type:'iframe-resize',height:h},'*');
input.focus();
input.select();
})();
var acCfg = window.NB_AC_CONFIG || {};
NotebookAutocomplete.attach(input, { apiUrl: '{{ .root }}api/v1/json/commands', maxResults: acCfg.max_results, minChars: acCfg.min_chars });
</script>
</body>
</html>
+24 -2
View File
@@ -1,4 +1,4 @@
{{ $error := .error }}{{ $llen := 0 }}{{ if .location }}{{ $llen := len .location }}{{ end }}<!DOCTYPE html>
{{ $error := .error }}<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
@@ -11,6 +11,22 @@
.spacing-left { margin: 0px 3px 0px 0px !important; }
.align-center { text-align: center; }
</style>
<script>window.NOTEBOOK_KB={{ keybindings }};</script>
<script src="{{ .root }}static/shortcuts.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
NotebookShortcuts.annotate(document.getElementById('nav-home'), 'cancel');
NotebookShortcuts.annotate(document.getElementById('nav-about'), 'about');
NotebookShortcuts.annotate(document.getElementById('nav-new-page'), 'new_page');
NotebookShortcuts.annotate(document.getElementById('nav-settings'), 'settings');
NotebookShortcuts.bind([
{ action: 'about', handler: function() { document.getElementById('nav-about').click(); } },
{ action: 'new_page', handler: function() { document.getElementById('nav-new-page').click(); } },
{ action: 'settings', handler: function() { document.getElementById('nav-settings').click(); } },
{ action: 'cancel', handler: function() { document.getElementById('nav-home').click(); } }
]);
});
</script>
</head>
<body>
<section class="container grid-960 mt-10">
@@ -20,7 +36,13 @@
<a href="{{ .root }}" class="navbar-brand mr-10">Rizin Notebook</a>
</section>
<section class="navbar-section">
<a href="{{ if lt $llen 1 }}{{ .root }}{{ else }}{{ .location }}{{ end }}" class="btn btn-primary">Go Back</a>
<a href="{{ .root }}about" class="btn" id="nav-about">About</a>
&nbsp;
<a href="{{ .root }}settings" class="btn" id="nav-settings">Settings</a>
&nbsp;
<a href="{{ .root }}new" class="btn" id="nav-new-page">New Page</a>
&nbsp;
<a href="{{ if .location }}{{ .location }}{{ else }}{{ .root }}{{ end }}" class="btn btn-primary" id="nav-home">Go Back</a>
</section>
</header>
<section class="container">
+74 -8
View File
@@ -13,7 +13,66 @@
.pipe { color: #e85600; border-color: #e85600; }
.pipe:hover { background: #e85600; border-color: #e85600; }
.footer { position: absolute; bottom: 0px; padding: 5px; }
.page-row { width: 100%; }
.page-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1 1 auto;
}
.page-action {
flex: 0 0 auto;
}
.container, .columns, .column { overflow-x: hidden; }
</style>
<script>window.NOTEBOOK_KB={{ keybindings }};</script>
<script src="{{ $root }}static/shortcuts.js"></script>
<script>
function confirmDelete(e, title) {
e.preventDefault();
if (!confirm('Delete "' + title + '" and all its data?')) return;
window.location.href = e.target.closest('a').href;
}
function importRznb(input) {
if (!input.files || !input.files[0]) return;
var formData = new FormData();
formData.append('file', input.files[0]);
fetch('{{ $root }}api/v1/json/pages/import', {method: 'POST', body: formData})
.then(function(r) {
if (!r.ok) {
return r.text().then(function(t) {
try { var j = JSON.parse(t); throw new Error(j.error || 'Server error ' + r.status); }
catch(e) { if (e.message) throw e; throw new Error('Server error ' + r.status); }
});
}
return r.json();
})
.then(function(data) {
if (data && data.page && data.page.id) {
window.location.href = '{{ $root }}view/' + data.page.id;
} else if (data && data.error) {
alert(data.error);
location.reload();
} else {
alert('Import failed: unexpected response');
location.reload();
}
})
.catch(function(err) { alert('Import error: ' + (err.message || err)); });
input.value = '';
}
document.addEventListener('DOMContentLoaded', function() {
NotebookShortcuts.annotate(document.getElementById('nav-settings'), 'settings');
NotebookShortcuts.annotate(document.getElementById('nav-about'), 'about');
NotebookShortcuts.annotate(document.getElementById('nav-new-page'), 'new_page');
NotebookShortcuts.bind([
{ action: 'about', handler: function() { document.getElementById('nav-about').click(); } },
{ action: 'new_page', handler: function() { document.getElementById('nav-new-page').click(); } },
{ action: 'settings', handler: function() { document.getElementById('nav-settings').click(); } }
]);
});
</script>
</head>
<body>
<section class="container grid-960 mt-10">
@@ -23,11 +82,15 @@
<a href="{{ $root }}" class="navbar-brand mr-10">Rizin Notebook</a>
</section>
<section class="navbar-section">
<a href="{{ $root }}settings" class="btn">Settings</a>
<a href="{{ $root }}settings" class="btn" id="nav-settings">Settings</a>
&nbsp;
<a href="{{ $root }}about" class="btn">About</a>
<a href="{{ $root }}about" class="btn" id="nav-about">About</a>
&nbsp;
<a href="{{ $root }}new" class="btn btn-primary">New Page</a>
<a href="{{ $root }}new" class="btn btn-primary" id="nav-new-page">New Page</a>
&nbsp;
<label class="btn" style="cursor: pointer;">Import .rznb
<input type="file" accept=".rznb" style="display:none" onchange="importRznb(this)">
</label>
</section>
</header>
<section class="container">
@@ -38,18 +101,21 @@
<span class="input-group-addon align-center">To start, create a new page.</span>
</div>
{{ else }}{{ range $i, $page := .list }}
<div class="input-group spacing-top">
<span class="input-group-addon spacing-left">{{ $page.title }}</span>
<div class="input-group spacing-top page-row">
<span class="input-group-addon spacing-left page-title" title="{{ $page.title }}">{{ $page.title }}</span>
{{ if $page.pipe }}
<button class="btn tooltip tooltip-bottom pipe spacing-left" data-tooltip="The pipe is still open."><i class="icon icon-more-horiz"></i></button>
<button class="btn tooltip tooltip-bottom pipe spacing-left page-action" data-tooltip="The pipe is still open."><i class="icon icon-more-horiz"></i></button>
{{ else }}
<a class="btn spacing-left" href="{{ $root }}edit/{{ $page.unique }}">
<a class="btn spacing-left page-action" href="{{ $root }}edit/{{ $page.unique }}">
<i class="icon icon-edit">Edit</i>
</a>
{{ end }}
<a class="btn spacing-left" href="{{ $root }}view/{{ $page.unique }}">
<a class="btn spacing-left page-action" href="{{ $root }}view/{{ $page.unique }}">
<i class="icon icon-forward">View</i>
</a>
<a class="btn spacing-left page-action" href="{{ $root }}delete/{{ $page.unique }}" onclick="confirmDelete(event, {{ stringify $page.title }})">
<i class="icon icon-delete">Delete</i>
</a>
</div>
{{ end }}{{ end }}
</div>
+69 -4
View File
@@ -11,15 +11,18 @@
html, body { width: 100%; height: 100%; margin: 0 }
.position-right { position: absolute; right: 0px; z-index: 99999; }
.button-edit { width: 75px ! important; margin-top: 9px; margin-right: 9px; }
.CodeMirror .nb-search-hit { background: #ffe08a; color: #111111; border-radius: 2px; }
</style>
<script>window.NOTEBOOK_KB={{ keybindings }};</script>
<script src="{{ .root }}static/shortcuts.js"></script>
</head>
<body onload="editor()">
<div style="margin-top: 15px; height: 100%;">
<form action="{{ .root }}markdown/save{{ .path }}" method="POST">
<div class="position-right">
<button class="btn btn-action button-edit" type="submit"><i class="icon icon-share">Code</i> Save</button>
<a class="btn btn-action button-edit" href="{{ .root }}markdown/delete{{ .path }}"><i class="icon icon-cross">Code</i> Delete</a>
<a class="btn btn-action button-edit" href="{{ .root }}markdown/view{{ .path }}"><i class="icon icon-edit">Code</i> Cancel</a>
<button class="btn btn-action button-edit" type="submit" id="save-markdown"><i class="icon icon-share">Code</i> Save</button>
<button class="btn btn-action button-edit" type="button" onclick="if(confirm('Delete this cell?')){fetch('{{ .root }}markdown/delete{{ .path }}',{redirect:'follow'}).then(function(){window.parent.postMessage({type:'iframe-remove'},'*')})}"><i class="icon icon-cross">Code</i> Delete</button>
<a class="btn btn-action button-edit" href="{{ .root }}markdown/view{{ .path }}" id="cancel-markdown"><i class="icon icon-edit">Code</i> Cancel</a>
</div>
<div class="form-group">
<textarea class="form-input" id="input-body" name="markdown" placeholder="Content" cols="80" rows="24">{{printf "%s" .raw}}</textarea>
@@ -28,8 +31,42 @@
</div>
<script src="{{ .root }}static/simplemde.min.js"></script>
<script>
var markdownSearchMarks = [];
function clearMarkdownSearchMarks() {
while (markdownSearchMarks.length > 0) {
markdownSearchMarks.pop().clear();
}
}
function parseMarkdownSearchRanges(raw) {
if (!raw) return [];
return raw.split(',').map(function(part) {
var pieces = part.split(':');
return {
start: Number(pieces[0] || 0),
end: Number(pieces[1] || 0)
};
}).filter(function(range) {
return range.end > range.start;
});
}
function applyMarkdownSearchHighlight(editor, lineNumber, ranges) {
clearMarkdownSearchMarks();
if (!editor || lineNumber <= 0 || !ranges || !ranges.length) return;
var targetLine = lineNumber - 1;
for (var i = 0; i < ranges.length; i++) {
markdownSearchMarks.push(editor.markText(
{ line: targetLine, ch: ranges[i].start },
{ line: targetLine, ch: ranges[i].end },
{ className: 'nb-search-hit' }
));
}
editor.setCursor({ line: targetLine, ch: ranges[0].start });
editor.scrollIntoView({ line: targetLine, ch: ranges[0].start }, 140);
editor.focus();
}
function editor() {
var simplemde = new SimpleMDE({
var simplemde;
simplemde = new SimpleMDE({
element: document.getElementById("input-body"),
autofocus: true,
hideIcons: ["side-by-side", "fullscreen", "guide"],
@@ -37,9 +74,37 @@
spellChecker: false,
forceSync: true,
indentWithTabs: false,
previewRender: function(plainText) {
return NotebookSafeMarkdown.buildFragment(plainText, simplemde);
},
promptURLs: true,
tabSize: 4,
});
var cm = simplemde.codemirror;
var params = new URLSearchParams(window.location.search);
applyMarkdownSearchHighlight(
cm,
Number(params.get('search_line') || 0),
parseMarkdownSearchRanges(params.get('search_ranges') || '')
);
window.addEventListener('message', function(event) {
if (!event.data || !event.data.type) return;
if (event.data.type === 'notebook-search-clear') {
clearMarkdownSearchMarks();
} else if (event.data.type === 'notebook-search-highlight-editor') {
applyMarkdownSearchHighlight(cm, Number(event.data.lineNumber || 0), event.data.ranges || []);
}
});
setTimeout(function() {
var h = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
if (window.parent !== window) window.parent.postMessage({type:'iframe-resize',height:h},'*');
}, 100);
NotebookShortcuts.annotate(document.getElementById('save-markdown'), 'save');
NotebookShortcuts.annotate(document.getElementById('cancel-markdown'), 'cancel');
NotebookShortcuts.bind([
{ action: 'save', allowInInputs: true, handler: function() { document.getElementById('save-markdown').click(); } },
{ action: 'cancel', allowInInputs: true, handler: function() { document.getElementById('cancel-markdown').click(); } }
]);
};
</script>
</body>
+18 -3
View File
@@ -11,13 +11,15 @@
.button-edit { float: right; width: 60px ! important; margin-top: 5px; }
.markdown-container { margin-top: 9px; }
</style>
<script>window.NOTEBOOK_KB={{ keybindings }};</script>
<script src="{{ .root }}static/shortcuts.js"></script>
</head>
<body onload="view()">
<a class="btn btn-action spacing-left button-edit" href="{{ .root }}markdown/edit{{ .path }}">
<a class="btn btn-action spacing-left button-edit" href="{{ .root }}markdown/edit{{ .path }}" id="edit-markdown">
<i class="icon icon-edit">Code</i> Edit
</a>
<div class="markdown-container" id="markdown-container" >
<textarea style="display: none;" name="md-container" id="md-container" cols="30" rows="10">{{ .html }}</textarea>
<textarea style="display: none;" name="md-container" id="md-container" cols="30" rows="10">{{ .raw }}</textarea>
</div>
<script src="{{ .root }}static/simplemde.min.js"></script>
<script>
@@ -26,13 +28,26 @@
element: document.getElementById('md-container'),
autoDownloadFontAwesome: false,
spellChecker: false,
toolbar: false,
status: false,
indentWithTabs: false,
promptURLs: true,
tabSize: 4,
});
var md = smde.value();
document.getElementById('markdown-container').innerHTML = smde.markdown(md);
NotebookSafeMarkdown.renderInto(
document.getElementById('markdown-container'),
md,
smde
);
setTimeout(function() {
var h = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
if (window.parent !== window) window.parent.postMessage({type:'iframe-resize',height:h},'*');
}, 50);
NotebookShortcuts.annotate(document.getElementById('edit-markdown'), 'edit_markdown');
NotebookShortcuts.bind([
{ action: 'edit_markdown', handler: function() { document.getElementById('edit-markdown').click(); } }
]);
};
</script>
</body>
+215 -2
View File
@@ -37,6 +37,12 @@
td, th {
padding: 3px 10px;
}
.nb-search-hit {
background: #ffe08a;
color: #111111;
border-radius: 2px;
padding: 0 1px;
}
tbody tr {
border-bottom: 1px solid #dddddd;
}
@@ -50,6 +56,213 @@
</style>
</head>
<body>
{{ .output | raw }}
<div id="output-root">{{ .output }}</div>
<script>
(function(){
var rawOutput = {{ .raw_output | stringify }};
function normalizeText(text) {
return String(text || '').replace(/\r\n?/g, '\n');
}
function notifySize(){
var h = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
if (window.parent !== window) window.parent.postMessage({type:'iframe-resize',height:h},'*');
}
function unwrapMarks(root) {
var marks = root.querySelectorAll('mark.nb-search-hit');
for (var i = 0; i < marks.length; i++) {
var mark = marks[i];
var parent = mark.parentNode;
while (mark.firstChild) {
parent.insertBefore(mark.firstChild, mark);
}
parent.removeChild(mark);
}
root.normalize();
}
function codePointToCodeUnitOffset(text, codePointOffset) {
if (codePointOffset <= 0) return 0;
return Array.from(text).slice(0, codePointOffset).join('').length;
}
function childOffset(node) {
var offset = 0;
while (node && node.previousSibling) {
node = node.previousSibling;
offset++;
}
return offset;
}
function buildTextSegments(root) {
var segments = [];
var codePointIndex = 0;
function walk(node) {
if (!node) return;
if (node.nodeType === Node.TEXT_NODE) {
var length = Array.from(node.data || '').length;
if (!length) return;
segments.push({ type: 'text', node: node, start: codePointIndex, end: codePointIndex + length });
codePointIndex += length;
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.tagName === 'BR') {
segments.push({
type: 'break',
parent: node.parentNode,
offset: childOffset(node),
start: codePointIndex,
end: codePointIndex + 1
});
codePointIndex++;
return;
}
for (var child = node.firstChild; child; child = child.nextSibling) {
walk(child);
}
}
walk(root);
return segments;
}
function boundaryBeforeSegment(segment) {
if (!segment) return null;
if (segment.type === 'break') {
return { container: segment.parent, offset: segment.offset };
}
return { container: segment.node, offset: 0 };
}
function boundaryAfterSegment(segment) {
if (!segment) return null;
if (segment.type === 'break') {
return { container: segment.parent, offset: segment.offset + 1 };
}
return {
container: segment.node,
offset: codePointToCodeUnitOffset(segment.node.data || '', segment.end - segment.start)
};
}
function locateBoundary(root, segments, absoluteOffset) {
if (!segments.length) {
return { container: root, offset: 0 };
}
for (var i = 0; i < segments.length; i++) {
var segment = segments[i];
if (absoluteOffset < segment.start) {
return boundaryBeforeSegment(segment);
}
if (segment.type === 'break') {
if (absoluteOffset === segment.start) {
return boundaryBeforeSegment(segment);
}
if (absoluteOffset === segment.end) {
return boundaryAfterSegment(segment);
}
continue;
}
if (absoluteOffset <= segment.end) {
return {
container: segment.node,
offset: codePointToCodeUnitOffset(segment.node.data || '', Math.max(0, absoluteOffset - segment.start))
};
}
}
return boundaryAfterSegment(segments[segments.length - 1]);
}
function wrapCodePointRange(root, start, end) {
var segments = buildTextSegments(root);
var startBoundary = locateBoundary(root, segments, start);
var endBoundary = locateBoundary(root, segments, end);
if (!startBoundary || !endBoundary) return null;
var range = document.createRange();
range.setStart(startBoundary.container, startBoundary.offset);
range.setEnd(endBoundary.container, endBoundary.offset);
if (range.collapsed) return null;
var mark = document.createElement('mark');
mark.className = 'nb-search-hit';
mark.appendChild(range.extractContents());
range.insertNode(mark);
return mark;
}
function normalizeRanges(ranges, length) {
if (!ranges || !ranges.length) return [];
return ranges.map(function(range) {
return {
start: Math.max(0, Math.min(length, Number(range.start) || 0)),
end: Math.max(0, Math.min(length, Number(range.end) || 0))
};
}).filter(function(range) {
return range.end > range.start;
});
}
function lineBounds(text, lineNumber) {
if (lineNumber <= 0) return null;
var chars = Array.from(normalizeText(text));
var start = 0;
var currentLine = 1;
while (start < chars.length && currentLine < lineNumber) {
if (chars[start] === '\n') {
currentLine++;
}
start++;
}
if (currentLine !== lineNumber) return null;
var end = start;
while (end < chars.length && chars[end] !== '\n') {
end++;
}
return { start: start, end: end };
}
function highlightSearchResult(message) {
var root = document.getElementById('output-root');
if (!root) return;
unwrapMarks(root);
var bounds = lineBounds(rawOutput, Number(message.lineNumber || 0));
if (!bounds) return;
var ranges = normalizeRanges(message.ranges || [], bounds.end - bounds.start).sort(function(a, b) {
return (b.start || 0) - (a.start || 0);
});
var firstMark = null;
for (var i = 0; i < ranges.length; i++) {
var range = ranges[i];
var start = bounds.start + Number(range.start || 0);
var end = bounds.start + Number(range.end || 0);
if (end <= start) continue;
var mark = wrapCodePointRange(root, start, end);
if (mark) {
firstMark = mark;
}
}
if (firstMark && firstMark.scrollIntoView) {
firstMark.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
notifySize();
}
window.addEventListener('message', function(event) {
if (!event.data || !event.data.type) return;
if (event.data.type === 'notebook-search-clear') {
var root = document.getElementById('output-root');
if (root) {
unwrapMarks(root);
notifySize();
}
} else if (event.data.type === 'notebook-search-highlight') {
highlightSearchResult(event.data);
}
});
rawOutput = normalizeText(rawOutput);
if (document.readyState === 'complete') notifySize();
else window.addEventListener('load', notifySize);
setTimeout(notifySize, 100);
setTimeout(notifySize, 500);
})();
</script>
</body>
</html>
</html>
+21 -8
View File
@@ -13,6 +13,8 @@
.icon-height { height: 20px; }
iframe { border: 0px; width: 100%; height: 100% }
</style>
<script>window.NOTEBOOK_KB={{ keybindings }};</script>
<script src="{{ $root }}static/shortcuts.js"></script>
<script type="text/javascript">
function confirm_delete(event) {
if (!window.confirm("Are you sure you want to delete it?")) {
@@ -21,15 +23,21 @@
}
function verify(event) {
var title = document.getElementById('title').value.trim().length;
var files = document.getElementById('binary').files.length;
if (title < 1) {
alert("Title field is empty. Please add one.");
event.preventDefault();
} else if (files < 1) {
alert("You forgot to select a file to analyze. Please select one.");
event.preventDefault();
}
}
document.addEventListener('DOMContentLoaded', function() {
NotebookShortcuts.annotate(document.getElementById('nav-about'), 'about');
NotebookShortcuts.annotate(document.getElementById('save-page'), 'save');
NotebookShortcuts.bind([
{ action: 'about', handler: function() { document.getElementById('nav-about').click(); } },
{ action: 'new_page', handler: function() { window.location.href = '{{ $root }}new'; } },
{ action: 'save', allowInInputs: true, handler: function() { document.getElementById('save-page').click(); } },
{ action: 'cancel', handler: function() { document.getElementById('cancel-page').click(); } }
]);
});
</script>
</head>
<body>
@@ -40,6 +48,8 @@
<a href="{{ $root }}" class="navbar-brand mr-10">Rizin Notebook</a>
</section>
<section class="navbar-section">
<a href="{{ $root }}about" class="btn" id="nav-about">About</a>
&nbsp;
{{ $page.title }}
</section>
</header>
@@ -50,15 +60,18 @@
<div class="form-group">
<input type="hidden" name="unique" value="{{ .unique }}" />
<input type="text" class="form-input" name="title" id="title" value="{{ .title }}" placeholder="Notebook page title"/>
{{ if lt $nlen 1 }}
<br>
<label for="binary">Choose a binary to open: </label>
<label for="binary">{{ if lt $nlen 1 }}Optional binary to open{{ else }}Attach or replace binary{{ end }}: </label>
<input type="file" id="binary" name="binary">
{{ if gt $nlen 0 }}
<p class="form-input-hint">Current binary: {{ if $page.filename }}{{ $page.filename }}{{ else }}none attached{{ end }}</p>
{{ else }}
<p class="form-input-hint">You can create notes without a binary now and attach one later from the edit page.</p>
{{ end }}
</div>
<div class="form-group">
<button class="btn btn-primary" type="submit">{{ if lt $nlen 1 }}Create{{ else }}Save{{ end }}</button>
<a class="btn" href="{{ $root }}">Cancel</a>
<button class="btn btn-primary" type="submit" id="save-page">{{ if lt $nlen 1 }}Create{{ else }}Save{{ end }}</button>
<a class="btn" href="{{ $root }}" id="cancel-page">Cancel</a>
{{ if gt $nlen 0 }}
<a class="btn float-right" href="{{ $root }}delete/{{ .unique }}" onclick="confirm_delete(event)">Delete</a>
{{ end }}
+652 -48
View File
@@ -7,46 +7,425 @@
<link rel="stylesheet" href="{{ $root }}static/spectre.min.css">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
@font-face { font-family: 'Inconsolata'; src: URL('{{ .root }}static/inconsolata.ttf') format('truetype'); }
.spacing-top { margin: 3px 0px 0px 0px !important; }
.spacing-left { margin: 0px 3px 0px 0px !important; }
.align-center { text-align: center; }
.navbar-fixed-height { height: 32px; }
.icon-height { height: 20px; }
.title-margin { margin-top: auto; margin-bottom: auto; margin-right: 10px; }
.md-heigh { height: 64px; }
.pipe { color: #e85600; border-color: #e85600; }
.pipe:hover { background: #e85600; border-color: #e85600; }
iframe { border: 0px; width: 100%; height: 100% }
.resizer { display: flex; margin: 0; padding: 0; resize: vertical; overflow: hidden }
.resizer > .resized { flex-grow: 1; margin: 0; padding: 0; border: 0 }
.syntax-menu { right: 35px; }
.align-right { text-align: right; }
textarea {
margin: 5px;
font-family: Inconsolata;
background-color: inherit;
color: inherit;
line-height: 8px;
resize: none;
@font-face { font-family: 'Inconsolata'; src: URL('{{ .root }}static/inconsolata.ttf') format('truetype'); }
.spacing-top { margin: 3px 0px 0px 0px !important; }
.spacing-left { margin: 0px 3px 0px 0px !important; }
.align-center { text-align: center; }
.navbar-fixed-height { height: 32px; }
.icon-height { height: 20px; }
.title-margin { margin-top: auto; margin-bottom: auto; margin-right: 10px; }
.md-heigh { height: 64px; }
.cell-group { position: relative; margin-bottom: 2px; }
.cell-footer {
font-size: 11px;
text-align: right;
padding: 3px 8px 0 8px;
font-family: Inconsolata, monospace;
opacity: .7;
}
.cell-footer span { margin-left: 12px; }
.pipe { color: #e85600; border-color: #e85600; }
.pipe:hover { background: #e85600; border-color: #e85600; }
iframe { border: 0px; width: 100%; height: 100% }
.resizer { display: flex; margin: 0; padding: 0; resize: vertical; overflow: hidden }
.resizer > .resized { flex-grow: 1; margin: 0; padding: 0; border: 0 }
.syntax-menu { right: 35px; }
.align-right { text-align: right; }
textarea {
margin: 5px;
font-family: Inconsolata;
background-color: inherit;
color: inherit;
line-height: 8px;
resize: none;
}
.button-help { width: 32px; float: left; }
.command-input { margin: 0px 0px 0px 36px; display: flex; width: calc(100% - 36px); min-width: 0; }
.modal-box { width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.8); }
.modal-search { width: 80%; height: 80%; margin: 10px; }
.modal-results { width: 100%; max-height: 500px; position: initial; overflow-y: scroll; overflow-x: hidden; }
.modal-results li { -webkit-user-select: text !important; -ms-user-select: text !important; user-select: text !important; }
.modal-name-big { font-size: 20px; }
.modal-name { color: #1447e6; font-weight: bold; font-family: Inconsolata; }
.modal-args { color: #178236; font-style: italic; font-family: Inconsolata; }
.modal-detail { width: 100%; margin: 10px 0px 0px 0px; }
.modal-entry { width: 100%; margin: 0px 0px 0px 20px; list-style: none; }
.modal-entry-text { min-width: 200px; display: inline-block; }
.modal-searchbar { width: calc(100% - 36px); }
.modal-close { float: right; width: 32px; }
.shortcut-hint { opacity: .7; font-size: 11px; margin-left: 6px; }
.nb-search-hit {
background: #ffe08a;
color: #111111;
border-radius: 2px;
padding: 0 1px;
}
@keyframes nb-search-pulse {
0% { box-shadow: 0 0 0 0 rgba(20, 71, 230, 0.18); }
100% { box-shadow: 0 0 0 8px rgba(20, 71, 230, 0); }
}
.nb-search-active-cell {
box-shadow: 0 0 0 2px rgba(20, 71, 230, 0.24);
border-radius: 8px;
animation: nb-search-pulse 0.55s ease-out 1;
}
.nb-search-toolbar-btn {
display: inline-flex;
align-items: center;
gap: 8px;
}
.nb-search-toolbar-btn.is-active {
color: #1447e6;
border-color: rgba(20, 71, 230, 0.35);
background: rgba(20, 71, 230, 0.08);
}
.nb-search-panel {
position: fixed;
top: 72px;
right: 24px;
width: min(460px, calc(100vw - 24px));
max-height: calc(100vh - 96px);
display: none;
flex-direction: column;
background: linear-gradient(180deg, #fffdf7 0%, #ffffff 62%);
border: 1px solid rgba(20, 71, 230, 0.16);
border-radius: 14px;
box-shadow: 0 22px 50px rgba(17, 24, 39, 0.18);
overflow: hidden;
z-index: 1000;
}
.nb-search-panel.is-open { display: flex; }
.nb-search-head {
padding: 14px 14px 10px 14px;
background: linear-gradient(180deg, rgba(20, 71, 230, 0.08) 0%, rgba(255, 224, 138, 0.14) 100%);
border-bottom: 1px solid rgba(20, 71, 230, 0.1);
}
.nb-search-head-top {
display: flex;
align-items: center;
gap: 10px;
}
.nb-search-title {
margin: 0;
font-size: 14px;
letter-spacing: 0.02em;
}
.nb-search-close {
margin-left: auto;
border: 0;
background: rgba(17, 24, 39, 0.06);
color: #111827;
border-radius: 999px;
width: 30px;
height: 30px;
cursor: pointer;
}
.nb-search-input-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
margin-top: 12px;
}
.nb-search-query {
font-family: Inconsolata, monospace;
}
.nb-search-select {
font-size: 12px;
}
.nb-search-mode-row,
.nb-search-filter-row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
margin-top: 10px;
}
.nb-search-mode-label,
.nb-search-filter-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: rgba(17, 24, 39, 0.66);
}
.nb-search-toggle-group {
display: inline-flex;
gap: 6px;
flex-wrap: wrap;
}
.nb-search-toggle {
border: 1px solid rgba(20, 71, 230, 0.16);
background: rgba(255, 255, 255, 0.86);
color: #334155;
border-radius: 999px;
padding: 5px 11px;
font-size: 12px;
cursor: pointer;
}
.nb-search-toggle.is-active {
background: #1447e6;
border-color: #1447e6;
color: #ffffff;
}
.nb-search-toggle.is-case {
display: inline-flex;
align-items: center;
gap: 6px;
}
.nb-search-toggle input {
margin: 0;
}
.nb-search-status {
padding: 10px 14px;
font-size: 12px;
color: rgba(17, 24, 39, 0.72);
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
background: rgba(255, 255, 255, 0.72);
}
.nb-search-status.is-error {
color: #b42318;
background: rgba(254, 242, 242, 0.92);
}
.nb-search-results {
list-style: none;
margin: 0;
padding: 8px;
overflow-y: auto;
min-height: 140px;
}
.nb-search-result-item {
margin: 0;
padding: 0;
}
.nb-search-result {
width: 100%;
border: 1px solid transparent;
background: transparent;
text-align: left;
padding: 10px 12px;
cursor: pointer;
border-radius: 10px;
}
.nb-search-result:hover,
.nb-search-result.is-active {
background: rgba(20, 71, 230, 0.08);
border-color: rgba(20, 71, 230, 0.16);
}
.nb-search-result-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-bottom: 6px;
font-size: 11px;
font-family: Inconsolata, monospace;
}
.nb-search-badge {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
background: rgba(20, 71, 230, 0.08);
color: #1447e6;
}
.nb-search-badge.is-output {
background: rgba(232, 86, 0, 0.12);
color: #b54708;
}
.nb-search-badge.is-fuzzy {
background: rgba(23, 130, 54, 0.12);
color: #178236;
}
.nb-search-result-line {
font-family: Inconsolata, monospace;
white-space: pre-wrap;
word-break: break-word;
color: #0f172a;
}
@media (max-width: 720px) {
.nb-search-panel {
top: 12px;
right: 12px;
left: 12px;
width: auto;
max-height: calc(100vh - 24px);
}
}
</style>
<script>window.NOTEBOOK_KB={{ keybindings }};</script>
<script src="{{ $root }}static/shortcuts.js"></script>
<script>
window.NB_NOTEBOOK_SEARCH = {
searchEndpoint: "{{ $root }}api/v1/json/pages/{{ $page.unique }}/search",
markdownEditBase: "{{ $root }}markdown/edit/{{ $page.unique }}/",
pageId: "{{ $page.unique }}",
storageKey: "nb:notebook-search:{{ $page.unique }}",
config: {{ search_config }}
};
</script>
<script src="{{ $root }}static/notebook-search.js"></script>
<script>
function resizeIframe(obj, padding) {
try {
var h = obj.contentWindow.document.documentElement.scrollHeight;
if (h > 20) { obj.style.height = (h + padding) + 'px'; }
} catch(e) {}
}
function handle(obj) {
obj.style.height = (obj.contentWindow.document.documentElement.scrollHeight + 20) + 'px';
if (obj.contentWindow.location.href.indexOf("/deleted") > 0) {
obj.parentNode.parentNode.removeChild(obj.parentNode);
}
window.scrollTo(0, document.body.scrollHeight);
resizeIframe(obj, 20);
try {
if (obj.contentWindow.location.href.indexOf("/deleted") > 0) {
var group = obj.closest('.cell-group');
if (group) { group.remove(); } else { obj.parentNode.remove(); }
return;
}
} catch(e) {}
setTimeout(function() { resizeIframe(obj, 20); }, 150);
setTimeout(function() { resizeIframe(obj, 20); }, 500);
}
function output(obj) {
obj.style.height = obj.contentWindow.document.documentElement.scrollHeight + 'px';
if (obj.contentWindow.location.href.indexOf("/deleted") > 0) {
obj.parentNode.parentNode.removeChild(obj.parentNode);
} else if (obj.contentWindow.location.href.indexOf("/loaded") > 0) {
location.reload()
resizeIframe(obj, 10);
try {
var loc = obj.contentWindow.location.href;
if (loc.indexOf("/deleted") > 0) {
var group = obj.closest('.cell-group');
if (group) { group.remove(); } else { obj.parentNode.remove(); }
return;
}
if (loc.indexOf("/loaded") > 0) {
location.reload();
return;
}
} catch(e) {}
setTimeout(function() { resizeIframe(obj, 10); }, 150);
setTimeout(function() { resizeIframe(obj, 10); }, 500);
}
window.addEventListener('message', function(e) {
if (!e.data || !e.data.type) return;
if (e.data.type === 'iframe-resize') {
var iframes = document.querySelectorAll('iframe');
for (var k = 0; k < iframes.length; k++) {
try {
if (iframes[k].contentWindow === e.source) {
iframes[k].style.height = Math.max(e.data.height, 50) + 'px';
break;
}
} catch(err) {}
}
} else if (e.data.type === 'iframe-remove') {
var iframes = document.querySelectorAll('iframe');
for (var k = 0; k < iframes.length; k++) {
try {
if (iframes[k].contentWindow === e.source) {
var group = iframes[k].closest('.cell-group');
if (group) { group.remove(); } else { iframes[k].parentNode.remove(); }
break;
}
} catch(err) {}
}
} else if (e.data.type === 'iframe-reload') {
location.reload();
} else if (e.data.type === 'iframe-add-cell') {
var iframes = document.querySelectorAll('iframe');
for (var k = 0; k < iframes.length; k++) {
try {
if (iframes[k].contentWindow === e.source) {
appendOutputCell(iframes[k], e.data);
break;
}
} catch(err) {}
}
}
window.scrollTo(0, document.body.scrollHeight);
});
function appendOutputCell(sourceIframe, data) {
if (!data || !data.cellUnique || !data.outputUrl) return;
if (document.getElementById('cell-' + data.cellUnique)) return;
var body = document.getElementById('page-body');
if (!body) return;
var group = document.createElement('div');
group.className = 'cell-group';
group.id = 'cell-' + data.cellUnique;
var inputGroup = document.createElement('div');
inputGroup.className = 'input-group spacing-top';
var code = document.createElement('code');
code.className = 'input-group-addon spacing-right spacing-left nb-searchable-code';
code.id = 'content-' + data.cellUnique;
code.setAttribute('data-cell-id', data.cellUnique);
code.setAttribute('data-cell-type', data.cellType || '');
if (data.cellType === 'script') {
code.style.display = 'block';
code.style.whiteSpace = 'pre-wrap';
}
code.textContent = data.content || '';
inputGroup.appendChild(code);
var button = document.createElement('button');
button.className = 'btn float-right';
button.type = 'button';
var icon = document.createElement('i');
icon.className = 'icon icon-delete icon-height';
icon.textContent = 'Delete';
button.appendChild(icon);
button.onclick = function() { deleteCell(data.deleteUrl, data.cellUnique); };
inputGroup.appendChild(button);
var outputGroup = document.createElement('div');
outputGroup.className = 'input-group spacing-top';
var iframe = document.createElement('iframe');
iframe.id = 'output-' + data.cellUnique;
iframe.setAttribute('data-cell-id', data.cellUnique);
iframe.setAttribute('frameborder', '0');
iframe.setAttribute('marginheight', '0');
iframe.setAttribute('marginwidth', '0');
iframe.setAttribute('width', '100%');
iframe.setAttribute('scrolling', 'no');
iframe.setAttribute('onload', 'handle(this)');
iframe.className = 'input-group-addon spacing-left md-heigh';
iframe.src = data.outputUrl;
outputGroup.appendChild(iframe);
var footer = document.createElement('div');
footer.className = 'cell-footer';
var footerCount = 0;
function appendFooterMetric(label, value) {
if (!value) return;
var span = document.createElement('span');
span.textContent = label + ': ' + value;
footer.appendChild(span);
footerCount++;
}
appendFooterMetric('lines', data.lineCount);
appendFooterMetric('words', data.wordCount);
appendFooterMetric('time', data.actionTime);
group.appendChild(inputGroup);
group.appendChild(outputGroup);
if (footerCount > 0) {
group.appendChild(footer);
}
var anchor = sourceIframe.closest('.panel');
if (anchor && anchor.parentNode === body) {
body.insertBefore(group, anchor);
} else {
body.insertBefore(group, body.childNodes[body.childNodes.length - 2]);
}
}
function deleteCell(url, cellId) {
if (!window.confirm("Are you sure you want to delete this cell?")) return;
fetch(url, {redirect: 'follow'}).then(function() {
var el = document.getElementById('cell-' + cellId);
if (el) el.remove();
});
}
function isCommandHelpOpen() {
var modal = document.getElementById('command-help');
return modal && modal.className.indexOf(' active') >= 0;
}
function newmd() {
var body = document.getElementById("page-body");
@@ -58,8 +437,8 @@
iframe.setAttribute("frameborder", "0");
iframe.setAttribute("marginheight", "0");
iframe.setAttribute("marginwidth", "0");
iframe.style.height = "300px";
iframe.setAttribute("width", "100%");
iframe.setAttribute("height", "100%");
iframe.setAttribute("scrolling", "no");
iframe.setAttribute("onload", "handle(this)");
iframe.src = "{{ $root }}markdown/new/{{ $page.unique }}";
@@ -78,7 +457,6 @@
iframe.setAttribute("marginheight", "0");
iframe.setAttribute("marginwidth", "0");
iframe.setAttribute("width", "100%");
iframe.setAttribute("height", "110%");
iframe.setAttribute("scrolling", "no");
iframe.setAttribute("onload", "output(this)");
iframe.src = "{{ $root }}output/input/script/{{ $page.unique }}";
@@ -92,11 +470,11 @@
div.className = "panel spacing-top";
iframe.className = "panel-body no-borders";
iframe.style.height = "120px";
iframe.setAttribute("frameborder", "0");
iframe.setAttribute("marginheight", "0");
iframe.setAttribute("marginwidth", "0");
iframe.setAttribute("width", "100%");
iframe.setAttribute("height", "100%");
iframe.setAttribute("scrolling", "no");
iframe.setAttribute("onload", "output(this)");
iframe.src = "{{ $root }}output/input/console/{{ $page.unique }}";
@@ -108,6 +486,136 @@
event.preventDefault();
}
}
{{ if $pipe }}
var rizinCmds = JSON.parse({{ .cmds | stringify }});
var rizinCmdsList = Object.keys(rizinCmds);
function openCmdHelp() {
var modal = document.getElementById("command-help");
modal.className += " active";
var search = document.getElementById("search-focus");
search.focus();
}
function closeCmdHelp() {
var modal = document.getElementById("command-help");
modal.className = modal.className.replace(" active", "");
}
function cmdAsText(parent, command) {
var details = (command.details || []);
var element = document.createElement('span');
element.className = "modal-name modal-name-big";
element.textContent = command.cmd;
parent.appendChild(element);
element = document.createElement('span');
element.className = "modal-args modal-name-big";
element.textContent = " " + command.args_str;
parent.appendChild(element);
if ((command.summary || "") != "") {
element = document.createElement('div');
element.textContent = command.summary.replace(".", ".\n");
parent.appendChild(element);
}
if ((command.description || "") != "") {
element = document.createElement('div');
element.textContent = command.description.replace(".", ".\n");
parent.appendChild(element);
}
for (var i = 0; i < details.length; i++) {
var entries = (details[i].entries || []);
var detail = document.createElement('ul');
detail.className = "modal-detail";
detail.textContent = details[i].name;
parent.appendChild(detail);
for (var j = 0; j < entries.length; j++) {
var entry = document.createElement('li');
entry.className = "modal-entry";
detail.appendChild(entry);
var text = document.createElement('span');
text.className = "modal-entry-text";
entry.appendChild(text);
if ((entries[j].text || "").length > 0) {
element = document.createElement('span');
element.className = "modal-name";
element.textContent = entries[j].text;
text.appendChild(element);
}
if ((entries[j].arg_str || "").length > 0) {
element = document.createElement('span');
element.className = "modal-args";
element.textContent = entries[j].arg_str;
text.appendChild(element);
}
if ((entries[j].comment || "").length > 0) {
element = document.createElement('span');
element.style.display = "inline-block";
element.textContent = " # " + entries[j].comment;
entry.appendChild(element);
}
}
}
}
function findCmd(element) {
var dropdown = document.getElementById("dropdown-results");
var results = document.getElementById("search-results");
results.replaceChildren();
var tokens = element.value.split(/\s+/);
for (var i = 0, count = 0; i < rizinCmdsList.length; i++) {
var key = rizinCmdsList[i];
var descr = rizinCmds[key].description || "";
var summr = rizinCmds[key].summary || "";
var found = true;
for (var j = 0; j < tokens.length; j++) {
if (key.indexOf(tokens[j]) < 0 && descr.indexOf(tokens[j]) < 0 && summr.indexOf(tokens[j]) < 0) {
found = false;
break;
}
}
if (!found) {
continue;
}
var li = document.createElement('li');
if (count > 0) {
li.className = "divider";
results.appendChild(li);
li = document.createElement('li');
}
li.className = "menu-item";
cmdAsText(li, rizinCmds[key]);
results.appendChild(li);
count++;
}
if (dropdown.className.indexOf(" active") < 0) {
dropdown.className += " active";
}
}
{{ end }}
document.addEventListener('DOMContentLoaded', function() {
NotebookShortcuts.annotate(document.getElementById('nav-about'), 'about');
NotebookShortcuts.annotate(document.getElementById('nav-settings'), 'settings');
NotebookShortcuts.annotate(document.getElementById('nav-new-page'), 'new_page');
NotebookShortcuts.annotate(document.getElementById('toggle-pipe'), 'toggle_pipe');
NotebookShortcuts.annotate(document.getElementById('open-notebook-search'), 'search_notebook');
{{ if $pipe }}NotebookShortcuts.annotate(document.getElementById('open-command-line'), 'new_command');{{ end }}
NotebookShortcuts.annotate(document.getElementById('open-markdown'), 'new_markdown');
NotebookShortcuts.annotate(document.getElementById('open-script'), 'new_script');
NotebookShortcuts.bind([
{ action: 'about', handler: function() { document.getElementById('nav-about').click(); } },
{ action: 'new_page', handler: function() { document.getElementById('nav-new-page').click(); } },
{ action: 'settings', handler: function() { document.getElementById('nav-settings').click(); } },
{ action: 'toggle_pipe', handler: function() { document.getElementById('toggle-pipe').click(); } },
{ action: 'search_notebook', when: function() { return !isCommandHelpOpen(); }, handler: function() { openNotebookSearch(); } },
{ action: 'new_markdown', when: function() { return !isCommandHelpOpen(); }, handler: function() { newmd(); } },
{ action: 'new_script', when: function() { return !isCommandHelpOpen(); }, handler: function() { newsc(); } },
{ action: 'new_command', when: function() { return !isCommandHelpOpen() && {{ if $pipe }}true{{ else }}false{{ end }}; }, handler: function() { newcm(); } },
{ action: 'cancel', when: function() { return isNotebookSearchOpen(); }, handler: function() { closeNotebookSearch(); } },
{ action: 'cancel', when: function() { return isCommandHelpOpen(); }, handler: function() { closeCmdHelp(); } }
]);
});
</script>
</head>
<body>
@@ -120,10 +628,27 @@
<section class="navbar-section">
<h4 class="title-margin">{{ $page.title }}</h4>
&nbsp;
<a href="{{ $root }}pipe/{{ if $pipe }}close{{ else }}open{{ end }}/{{ $page.unique }}" class="btn{{ if $pipe }} pipe{{ end }}">
<a href="{{ $root }}about" class="btn" id="nav-about">About</a>
&nbsp;
<a href="{{ $root }}settings" class="btn" id="nav-settings">Settings</a>
&nbsp;
<a href="{{ $root }}new" class="btn" id="nav-new-page">New Page</a>
&nbsp;
{{ if not $page.hasBinary }}
<a href="{{ $root }}edit/{{ $page.unique }}" class="btn btn-error">Attach Binary</a>
&nbsp;
{{ end }}
<a href="{{ $root }}pipe/{{ if $pipe }}close{{ else }}open{{ end }}/{{ $page.unique }}" class="btn{{ if $pipe }} pipe{{ end }}" id="toggle-pipe">
{{ if $pipe }}Close Pipe{{ else }}Open Pipe{{ end }}
</a>
&nbsp;
<a href="{{ $root }}api/v1/json/pages/{{ $page.unique }}/export" class="btn" download>Export .rznb</a>
&nbsp;
<button class="btn nb-search-toolbar-btn" type="button" id="open-notebook-search">
<i class="icon icon-search"></i>
<span>Search</span>
</button>
&nbsp;
<a href="{{ $root }}" class="btn btn-primary">Go Back</a>
</section>
</header>
@@ -131,8 +656,13 @@
<div class="columns">
<div class="column">
<div class="input-group spacing-top">
<code class="input-group-addon spacing-right">File: {{ $page.filename }}</code>
<code class="input-group-addon spacing-right">File: {{ if $page.filename }}{{ $page.filename }}{{ else }}none attached{{ end }}</code>
</div>
{{ if $page.binaryHash }}
<div class="input-group spacing-top">
<code class="input-group-addon spacing-right" style="font-size: 11px;">SHA-256: {{ $page.binaryHash }}</code>
</div>
{{ end }}
</div>
</div>
</section>
@@ -143,37 +673,111 @@
<div class="column" style="padding-top: 0px" id="page-body">
{{ range $i, $line := $page.lines }}
{{ if eq $line.type "markdown" }}
<div class="cell-group" id="cell-{{ $line.unique }}">
<div class="panel spacing-top resizer">
<iframe frameborder="0" marginheight="0" marginwidth="0" width="100%" height="100%" scrolling="no" onload="handle(this)" onunload="handle(this)" class="panel-body no-borders md-heigh" src="{{ $root }}markdown/view/{{ $page.unique }}/{{ $line.unique }}"></iframe>
<iframe id="markdown-{{ $line.unique }}" data-cell-id="{{ $line.unique }}" frameborder="0" marginheight="0" marginwidth="0" width="100%" scrolling="no" onload="handle(this)" class="panel-body no-borders md-heigh" src="{{ $root }}markdown/view/{{ $page.unique }}/{{ $line.unique }}"></iframe>
</div>
{{ if or $line.lineCount $line.wordCount $line.actionTime }}
<div class="cell-footer">
{{ if $line.lineCount }}<span>lines: {{ $line.lineCount }}</span>{{ end }}
{{ if $line.wordCount }}<span>words: {{ $line.wordCount }}</span>{{ end }}
{{ if $line.actionTime }}<span>time: {{ $line.actionTime }}</span>{{ end }}
</div>
{{ end }}
</div>
{{ else }}
<div class="cell-group" id="cell-{{ $line.unique }}">
<div class="input-group spacing-top">
{{ if eq $line.type "script" }}
<code style="display: block; white-space: pre-wrap;" class="input-group-addon spacing-right spacing-left">{{ $line.script }}</code>
<code id="content-{{ $line.unique }}" data-cell-id="{{ $line.unique }}" data-cell-type="{{ $line.type }}" style="display: block; white-space: pre-wrap;" class="input-group-addon spacing-right spacing-left nb-searchable-code">{{ $line.script }}</code>
{{ else }}
<code class="input-group-addon spacing-right spacing-left">{{ $line.command }}</code>
<code id="content-{{ $line.unique }}" data-cell-id="{{ $line.unique }}" data-cell-type="{{ $line.type }}" class="input-group-addon spacing-right spacing-left nb-searchable-code">{{ $line.command }}</code>
{{ end }}
<a class="btn float-right" href="{{ $root }}output/delete/{{ $page.unique }}/{{ $line.unique }}" onclick="confirm_delete(event)">
<button class="btn float-right" type="button" onclick="deleteCell('{{ $root }}output/delete/{{ $page.unique }}/{{ $line.unique }}', '{{ $line.unique }}')">
<i class="icon icon-delete icon-height">Delete</i>
</a>
</button>
</div>
<div class="input-group spacing-top">
<iframe id="output-{{ $i }}" frameborder="0" marginheight="0" marginwidth="0" width="100%" height="100%" scrolling="no" onload="handle(this)" class="input-group-addon spacing-left md-heigh" src="{{ $root }}output/view/{{ $page.unique }}/{{ $line.unique }}"></iframe>
<iframe id="output-{{ $line.unique }}" data-cell-id="{{ $line.unique }}" frameborder="0" marginheight="0" marginwidth="0" width="100%" scrolling="no" onload="handle(this)" class="input-group-addon spacing-left md-heigh" src="{{ $root }}output/view/{{ $page.unique }}/{{ $line.unique }}"></iframe>
</div>
{{ if or $line.lineCount $line.wordCount $line.actionTime }}
<div class="cell-footer">
{{ if $line.lineCount }}<span>lines: {{ $line.lineCount }}</span>{{ end }}
{{ if $line.wordCount }}<span>words: {{ $line.wordCount }}</span>{{ end }}
{{ if $line.actionTime }}<span>time: {{ $line.actionTime }}</span>{{ end }}
</div>
{{ end }}
</div>
{{ end }}
{{ end }}
<div class="input-group spacing-top" style="margin-top: 10px;">
<a class="btn" href="#" onclick="newmd()"><i class="icon icon-message">Markdown</i> Markdown</a>
<a class="btn" href="#" onclick="newmd()" id="open-markdown"><i class="icon icon-message">Markdown</i> Markdown</a>
&nbsp;
<a class="btn" href="#" onclick="newsc()"><i class="icon icon-share">Script</i> Script</a>
<a class="btn" href="#" onclick="newsc()" id="open-script"><i class="icon icon-share">Script</i> Script</a>
{{ if $pipe }}
&nbsp;
<a class="btn" href="#" onclick="newcm()"><i class="icon icon-resize-horiz">Command Line</i> Command Line</a>
<a class="btn" href="#" onclick="newcm()" id="open-command-line"><i class="icon icon-resize-horiz">Command Line</i> Command Line</a>
&nbsp;
<a class="btn" href="#" onclick="openCmdHelp()"><i class="icon icon-search">Help</i> Help</a>
{{ end }}
</div>
</div>
</div>
<aside id="notebook-search" class="nb-search-panel" aria-hidden="true">
<div class="nb-search-head">
<div class="nb-search-head-top">
<h3 class="nb-search-title">Notebook Search</h3>
<button class="nb-search-close" type="button" id="close-notebook-search" aria-label="Close notebook search">&times;</button>
</div>
<div class="nb-search-input-row">
<input class="form-input nb-search-query" id="notebook-search-focus" value="" placeholder="Search commands, scripts, outputs, and markdown"/>
<select class="form-select nb-search-select" id="notebook-search-surface" aria-label="Search surface">
<option value="all">All surfaces</option>
<option value="content">Input / source</option>
<option value="output">Outputs</option>
</select>
</div>
<div class="nb-search-mode-row">
<span class="nb-search-mode-label">Mode</span>
<div class="nb-search-toggle-group" id="notebook-search-mode-group">
<button class="nb-search-toggle" type="button" data-mode="literal">Literal</button>
<button class="nb-search-toggle" type="button" data-mode="regex">Regex</button>
<button class="nb-search-toggle" type="button" data-mode="fuzzy">Fuzzy</button>
</div>
</div>
<div class="nb-search-filter-row">
<span class="nb-search-filter-label">Filters</span>
<select class="form-select nb-search-select" id="notebook-search-cell-type" aria-label="Cell type filter">
<option value="all">All cell types</option>
<option value="command">Command cells</option>
<option value="script">Script cells</option>
<option value="markdown">Markdown cells</option>
</select>
<label class="nb-search-toggle is-case" for="notebook-search-case">
<input type="checkbox" id="notebook-search-case" />
<span>Case sensitive</span>
</label>
</div>
</div>
<div class="nb-search-status" id="notebook-search-status">Search commands, scripts, outputs, and markdown in this notebook.</div>
<ul class="nb-search-results" id="notebook-search-results"></ul>
</aside>
{{ if $pipe }}
<div id="command-help" class="modal modal-box">
<div class="modal-content modal-search" onclick="" style="z-index: 999999;">
<div class="modal-body">
<div class="content">
<div class="dropdown" id="dropdown-results" style="width: 100%; height: 100%; max-height: 500px;">
<button class="btn btn-primary modal-close" onclick="closeCmdHelp()">&times;</button>
<input class="form-input modal-searchbar" id="search-focus" onchange="findCmd(this);" onkeypress="findCmd(this);" onpaste="findCmd(this);" oninput="findCmd(this);" value="" placeholder="Search for a command or keyword in a description"/>
<ul class="modal-results menu active" id="search-results"></ul>
</div>
</div>
</div>
</div>
</div>
{{ end }}
</section>
</section>
</body>
</html>
</html>
+2 -2
View File
@@ -21,11 +21,11 @@
border-top-color:transparent;
}
</style>
<meta http-equiv="refresh" content="2"/>
<meta http-equiv="refresh" content="1"/>
</head>
<body>
<div class="center">
<div class="loading-xl loading"></div> Waiting for rizin output. Please wait.
<div class="loading-xl loading"></div> Reloading page...
</div>
</body>
</html>
+42 -4
View File
@@ -13,20 +13,38 @@
@font-face { font-family: 'Inconsolata'; src: URL('{{ .root }}static/inconsolata.ttf') format('truetype'); }
</style>
<link rel="stylesheet" href="{{ .root }}static/codelines.css">
<script>window.NOTEBOOK_KB={{ keybindings }};</script>
<script src="{{ .root }}static/shortcuts.js"></script>
</head>
<body>
{{ if .message }}
<div class="toast toast-success" style="margin: 12px 12px 0 12px;">{{ .message }}</div>
{{ end }}
{{ if .error }}
<div class="toast toast-error" style="margin: 12px 12px 0 12px;">{{ .error }}</div>
{{ end }}
<form style="margin-top: 15px; height: 100%;" action="{{ .root }}output/script/{{ .unique }}" method="POST">
<div class="form-group">
<p><textarea id="codelines" class="code-lines" wrap="off" readonly>1 </textarea><textarea id="codeeditor" class="code-editor" wrap="off" name="script">console.log(rizin.cmd("?e hello world").trim(), "from the script");</textarea></p>
<p><textarea id="codelines" class="code-lines" wrap="off" readonly>1 </textarea><textarea id="codeeditor" class="code-editor" wrap="off" name="script">{{ if .script }}{{ .script }}{{ else }}console.log(rizin.cmd("?e hello world").trim(), "from the script");{{ end }}</textarea></p>
</div>
<div class="form-group">
<button class="btn btn-primary" type="submit">Run</button>
<a class="btn" href="{{ .root }}output/deleted">Cancel</a>
<button class="btn" type="button" id="cancel-script" onclick="window.parent.postMessage({type:'iframe-remove'},'*')">Cancel</button>
<span style="font-size:12px;margin-left:8px;opacity:.7;">Ctrl+Enter to run</span>
</div>
</form>
<script type="text/javascript">
var createdCellUnique = {{ if .cellUnique }}"{{ .cellUnique }}"{{ else }}""{{ end }};
var createdCellLineCount = {{ if .lineCount }}{{ .lineCount }}{{ else }}0{{ end }};
var createdCellWordCount = {{ if .wordCount }}{{ .wordCount }}{{ else }}0{{ end }};
var createdCellActionTime = {{ if .actionTime }}"{{ .actionTime }}"{{ else }}""{{ end }};
var editor = document.getElementById('codeeditor');
var lines = document.getElementById('codelines');
NotebookShortcuts.annotate(document.getElementById('cancel-script'), 'cancel');
NotebookShortcuts.bind([
{ action: 'cancel', allowInInputs: true, handler: function() { document.getElementById('cancel-script').click(); } },
{ action: 'execute', allowInInputs: true, handler: function() { editor.form.submit(); } }
]);
editor.addEventListener('scroll', function() {
lines.scrollTop = editor.scrollTop;
lines.scrollLeft = editor.scrollLeft;
@@ -35,7 +53,7 @@
if (e.keyCode === 9) {
// TAB = 9
e.preventDefault();
editor.value = editor.value.slice(0, editor.selectionStart) + '\t' + value.slice(editor.selectionEnd);
editor.value = editor.value.slice(0, editor.selectionStart) + '\t' + editor.value.slice(editor.selectionEnd);
editor.setSelectionRange(editor.selectionStart + 2, editor.selectionStart + 2)
}
});
@@ -47,11 +65,31 @@
return (n + 1).toString() + ' ';
}).join('\n');
}
lineCountCache = lineCount;
lineCountCache = editorLines.length;
}
editor.addEventListener('input', function() {
updateLines();
});
editor.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); editor.form.submit(); }
});
(function() {
updateLines();
if (createdCellUnique && window.parent !== window) {
window.parent.postMessage({
type:'iframe-add-cell',
cellType:'script',
cellUnique:createdCellUnique,
content:editor.value,
lineCount:createdCellLineCount,
wordCount:createdCellWordCount,
actionTime:createdCellActionTime,
outputUrl:'{{ .root }}output/view/{{ .unique }}/' + createdCellUnique,
deleteUrl:'{{ .root }}output/delete/{{ .unique }}/' + createdCellUnique
}, '*');
}
editor.focus();
})();
</script>
</body>
</html>
+132 -2
View File
@@ -14,11 +14,14 @@
.icon-height { height: 20px; }
iframe { border: 0px; width: 100%; height: 100% }
</style>
<script>window.NOTEBOOK_KB={{ keybindings }};</script>
<script src="{{ $root }}static/shortcuts.js"></script>
<script type="text/javascript">
{{ if eq .action "environment" }}
function confirm_delete(event) {
if (!window.confirm("Are you sure you want to delete it?")) {
event.preventDefault();
return;
}
document.getElementById('subaction').value = "delete";
}
@@ -29,7 +32,89 @@
event.preventDefault();
}
}
{{ else if eq .action "keybinding" }}
function verify(event) {
var value = document.getElementById('value').value.trim();
if (value.length < 1) {
alert("Shortcut combo cannot be empty.");
event.preventDefault();
}
}
function startRecording() {
var btn = document.getElementById('record-btn');
var input = document.getElementById('value');
btn.textContent = 'Press a key combo...';
btn.classList.add('btn-error');
input.value = '';
input.placeholder = 'Press keys now...';
NotebookShortcuts.record(function(combo) {
input.value = combo;
btn.textContent = 'Record';
btn.classList.remove('btn-error');
input.placeholder = 'e.g. alt+n, ctrl+s, ctrl+enter';
});
}
{{ else if eq .action "autocomplete" }}
function verify(event) {
var raw = document.getElementById('value').value.trim();
var val = parseInt(raw, 10);
if (isNaN(val) || raw.length < 1) {
alert("Please enter a valid number.");
event.preventDefault();
return;
}
if (val < 1) {
alert("Value must be at least 1.");
event.preventDefault();
return;
}
if (val > 100) {
alert("Value must be at most 100.");
event.preventDefault();
return;
}
}
{{ else if eq .action "search" }}
function verify(event) {
if ("{{ .input_type }}" !== "number") {
return;
}
var raw = document.getElementById('value').value.trim();
var val = parseInt(raw, 10);
if (isNaN(val) || raw.length < 1) {
alert("Please enter a valid number.");
event.preventDefault();
return;
}
if (val < {{ .min_value }}) {
alert("Value must be at least {{ .min_value }}.");
event.preventDefault();
return;
}
if (val > {{ .max_value }}) {
alert("Value must be at most {{ .max_value }}.");
event.preventDefault();
return;
}
}
{{ else }}
function verify(event) {}
{{ end }}
document.addEventListener('DOMContentLoaded', function() {
NotebookShortcuts.annotate(document.getElementById('nav-home'), 'cancel');
NotebookShortcuts.annotate(document.getElementById('nav-about'), 'about');
NotebookShortcuts.annotate(document.getElementById('save-settings'), 'save');
NotebookShortcuts.bind([
{ action: 'about', handler: function() { document.getElementById('nav-about').click(); } },
{ action: 'new_page', handler: function() { window.location.href = '{{ $root }}new'; } },
{ action: 'save', allowInInputs: true, handler: function() { document.getElementById('save-settings').click(); } },
{ action: 'cancel', handler: function() { document.getElementById('nav-home').click(); } }
]);
var labels = document.querySelectorAll('[data-shortcut-label]');
for (var i = 0; i < labels.length; i++) {
labels[i].textContent = NotebookShortcuts.formatCombo(labels[i].getAttribute('data-shortcut-label'));
}
});
</script>
</head>
<body>
@@ -40,7 +125,11 @@
<a href="{{ $root }}" class="navbar-brand mr-10">Rizin Notebook</a>
</section>
<section class="navbar-section">
<a href="{{ $root }}about" class="btn" id="nav-about">About</a>
&nbsp;
Edit Settings
&nbsp;
<a href="{{ $root }}settings" class="btn btn-primary" id="nav-home">Go Back</a>
</section>
</header>
<section class="container">
@@ -56,10 +145,42 @@
<input type="text" class="form-input" name="key" id="key" value="{{ .editkey }}" placeholder="Environment Variable Name"/>
<label class="form-label" for="value">Value</label>
<input type="text" class="form-input" name="value" id="value" value="{{ if gt $eklen 0 }}{{ index .data .editkey }}{{ end }}" placeholder="Insert a variable value (or leave it empty)"/>
{{ else if eq .action "keybinding" }}
<input type="hidden" name="editkey" value="{{ .editkey }}" />
<input type="hidden" name="subaction" id="subaction" value="save" />
<label class="form-label">Action: <strong>{{ .label }}</strong></label>
<label class="form-label" for="value">Keyboard Shortcut</label>
<div class="input-group">
<input type="text" class="form-input" name="value" id="value" value="{{ .combo }}" placeholder="e.g. alt+n, ctrl+s, ctrl+enter" />
<button class="btn input-group-btn" type="button" id="record-btn" onclick="startRecording()">Record</button>
</div>
<p class="form-input-hint">Default: <code data-shortcut-label="{{ .default_combo }}"></code> &mdash; Type a combo like <code>alt+n</code> or click Record and press keys.</p>
{{ else if eq .action "autocomplete" }}
<input type="hidden" name="editkey" value="{{ .editkey }}" />
<input type="hidden" name="subaction" id="subaction" value="save" />
<label class="form-label">Setting: <strong>{{ .label }}</strong></label>
<label class="form-label" for="value">Value</label>
<input type="number" class="form-input" name="value" id="value" value="{{ .current_value }}" min="1" max="100" placeholder="Enter a number (1-100)" />
<p class="form-input-hint">Default: <code>{{ .default_value }}</code> &mdash; Enter a number between 1 and 100.</p>
{{ else if eq .action "search" }}
<input type="hidden" name="editkey" value="{{ .editkey }}" />
<input type="hidden" name="subaction" id="subaction" value="save" />
<label class="form-label">Setting: <strong>{{ .label }}</strong></label>
<label class="form-label" for="value">Value</label>
{{ if eq .input_type "number" }}
<input type="number" class="form-input" name="value" id="value" value="{{ .current_value }}" min="{{ .min_value }}" max="{{ .max_value }}" placeholder="Enter a number ({{ .min_value }}-{{ .max_value }})" />
{{ else }}
<select class="form-select" name="value" id="value">
{{ range $i, $option := .options }}
<option value="{{ index $option "value" }}"{{ if eq $.current_value (index $option "value") }} selected{{ end }}>{{ index $option "label" }}</option>
{{ end }}
</select>
{{ end }}
<p class="form-input-hint">Default: <code>{{ .default_display }}</code></p>
{{ end }}
</div>
<div class="form-group">
<button class="btn btn-primary" type="submit">{{ if lt $eklen 1 }}Create{{ else }}Save{{ end }}</button>
<button class="btn btn-primary" type="submit" id="save-settings">{{ if eq .action "keybinding" }}Save{{ else if lt $eklen 1 }}Create{{ else }}Save{{ end }}</button>
{{ if eq .action "environment" }}
{{ if gt $eklen 0 }}
<a class="btn" href="{{ $root }}settings">Cancel</a>
@@ -67,6 +188,15 @@
{{ else }}
<a class="btn" href="{{ $root }}settings">Cancel</a>
{{ end }}
{{ else if eq .action "keybinding" }}
<a class="btn" href="{{ $root }}settings">Cancel</a>
<button class="btn float-right" type="button" onclick="document.getElementById('subaction').value='reset';this.form.submit()">Reset to Default</button>
{{ else if eq .action "autocomplete" }}
<a class="btn" href="{{ $root }}settings">Cancel</a>
<button class="btn float-right" type="button" onclick="document.getElementById('subaction').value='reset';this.form.submit()">Reset to Default</button>
{{ else if eq .action "search" }}
<a class="btn" href="{{ $root }}settings">Cancel</a>
<button class="btn float-right" type="button" onclick="document.getElementById('subaction').value='reset';this.form.submit()">Reset to Default</button>
{{ else }}
<button class="btn float-right" type="submit">Cancel</button>
{{ end }}
@@ -77,4 +207,4 @@
</section>
</section>
</body>
</html>
</html>
+113 -11
View File
@@ -18,15 +18,26 @@
.spacer-12px { width: 12px; display: inline-block; }
.col-btn { width: 3.333%; }
</style>
<script type="text/javascript">
function keycombo(elem, keys) {
keys = keys.split(',');
if (keys.length === 1 && keys[0].length < 1) {
keys = [];
}
elem.textContent = keys.length > 0 ? keys.join(' + ') : "Not Assigned";
}
</script>
<script>window.NOTEBOOK_KB={{ keybindings }};</script>
<script src="{{ $root }}static/shortcuts.js"></script>
<script type="text/javascript">
document.addEventListener('DOMContentLoaded', function() {
NotebookShortcuts.annotate(document.getElementById('nav-home'), 'cancel');
NotebookShortcuts.annotate(document.getElementById('nav-about'), 'about');
NotebookShortcuts.annotate(document.getElementById('nav-new-page'), 'new_page');
NotebookShortcuts.bind([
{ action: 'about', handler: function() { document.getElementById('nav-about').click(); } },
{ action: 'new_page', handler: function() { document.getElementById('nav-new-page').click(); } },
{ action: 'cancel', handler: function() { document.getElementById('nav-home').click(); } }
]);
var labels = document.querySelectorAll('[data-shortcut-label]');
for (var index = 0; index < labels.length; index++) {
var element = labels[index];
element.textContent = NotebookShortcuts.formatCombo(element.getAttribute('data-shortcut-label'));
}
});
</script>
</head>
<body>
<section class="container grid-960 mt-10">
@@ -36,7 +47,11 @@
<a href="{{ $root }}" class="navbar-brand mr-10">Rizin Notebook</a>
</section>
<section class="navbar-section">
<a href="{{ $root }}" class="btn btn-primary">Go Back</a>
<a href="{{ $root }}about" class="btn" id="nav-about">About</a>
&nbsp;
<a href="{{ $root }}new" class="btn" id="nav-new-page">New Page</a>
&nbsp;
<a href="{{ $root }}" class="btn btn-primary" id="nav-home">Go Back</a>
</section>
</header>
<section class="container">
@@ -70,7 +85,94 @@
New Environment Variable
</a>
</p>
<p>
<h3 class="s-title">Keyboard Shortcuts</h3>
<div class="columns col-gapless">
<div class="column col-1 col-btn spacing-left">
{{ range $i, $kb := .kb_actions }}
<a class="btn spacing-top" href="{{ $root }}settings/keybinding/edit/{{ index $kb "name" }}">
<i class="icon icon-edit">Edit</i>
</a>
{{ end }}
</div>
<div class="column col-3 spacing-left">
{{ range $i, $kb := .kb_actions }}
<div class="input-group">
<span class="input-group-addon detail-size spacing-top height-32px">{{ index $kb "label" }}</span>
</div>
{{ end }}
</div>
<div class="column col-8">
{{ range $i, $kb := .kb_actions }}
<div class="input-group spacing-left">
<span class="input-group-addon height-32px spacing-top" data-shortcut-label="{{ index $kb "combo" }}"></span>
</div>
{{ end }}
</div>
</div>
<a class="btn" href="{{ $root }}settings/keybinding/reset">
Reset All to Defaults
</a>
</p>
<p>
<h3 class="s-title">Autocomplete Settings</h3>
<div class="columns col-gapless">
<div class="column col-1 col-btn spacing-left">
{{ range $i, $ac := .ac_actions }}
<a class="btn spacing-top" href="{{ $root }}settings/autocomplete/edit/{{ index $ac "name" }}">
<i class="icon icon-edit">Edit</i>
</a>
{{ end }}
</div>
<div class="column col-3 spacing-left">
{{ range $i, $ac := .ac_actions }}
<div class="input-group">
<span class="input-group-addon detail-size spacing-top height-32px">{{ index $ac "label" }}</span>
</div>
{{ end }}
</div>
<div class="column col-8">
{{ range $i, $ac := .ac_actions }}
<div class="input-group spacing-left">
<span class="input-group-addon height-32px spacing-top">{{ index $ac "value" }}</span>
</div>
{{ end }}
</div>
</div>
<a class="btn" href="{{ $root }}settings/autocomplete/reset">
Reset to Defaults
</a>
</p>
<p>
<h3 class="s-title">Notebook Search Settings</h3>
<div class="columns col-gapless">
<div class="column col-1 col-btn spacing-left">
{{ range $i, $ns := .ns_actions }}
<a class="btn spacing-top" href="{{ $root }}settings/search/edit/{{ index $ns "name" }}">
<i class="icon icon-edit">Edit</i>
</a>
{{ end }}
</div>
<div class="column col-3 spacing-left">
{{ range $i, $ns := .ns_actions }}
<div class="input-group">
<span class="input-group-addon detail-size spacing-top height-32px">{{ index $ns "label" }}</span>
</div>
{{ end }}
</div>
<div class="column col-8">
{{ range $i, $ns := .ns_actions }}
<div class="input-group spacing-left">
<span class="input-group-addon height-32px spacing-top">{{ index $ns "value_display" }}</span>
</div>
{{ end }}
</div>
</div>
<a class="btn" href="{{ $root }}settings/search/reset">
Reset to Defaults
</a>
</p>
</section>
</section>
</body>
</html>
</html>
+109
View File
@@ -0,0 +1,109 @@
package main
import (
"encoding/json"
"html/template"
"strconv"
"strings"
)
// Autocomplete setting defaults and bounds.
const (
autocompleteSettingsPrefix = "ac:"
defaultACMaxResults = 12
defaultACMinChars = 2
minACMaxResults = 1
maxACMaxResults = 100
minACMinChars = 1
maxACMinChars = 10
)
// AutocompleteSettingKeys lists the configurable autocomplete settings.
var AutocompleteSettingKeys = []string{"max_results", "min_chars"}
// AutocompleteLabels maps setting keys to human-readable labels.
var AutocompleteLabels = map[string]string{
"max_results": "Max Results",
"min_chars": "Minimum Characters",
}
// AutocompleteDefaults maps setting keys to their default string values.
var AutocompleteDefaults = map[string]string{
"max_results": strconv.Itoa(defaultACMaxResults),
"min_chars": strconv.Itoa(defaultACMinChars),
}
// GetAutocompleteConfig returns the current autocomplete configuration,
// merging database overrides with defaults and clamping values to safe bounds.
func GetAutocompleteConfig() map[string]int {
result := map[string]int{
"max_results": defaultACMaxResults,
"min_chars": defaultACMinChars,
}
if catalog == nil {
return result
}
settings, _ := catalog.GetAllSettings()
if settings == nil {
return result
}
for k, v := range settings {
if !strings.HasPrefix(k, autocompleteSettingsPrefix) {
continue
}
key := strings.TrimPrefix(k, autocompleteSettingsPrefix)
val, err := strconv.Atoi(v)
if err != nil {
continue
}
switch key {
case "max_results":
result["max_results"] = clampInt(val, minACMaxResults, maxACMaxResults)
case "min_chars":
result["min_chars"] = clampInt(val, minACMinChars, maxACMinChars)
}
}
return result
}
// getAutocompleteConfigJSON returns the autocomplete config as a JSON
// object suitable for embedding in a <script> tag.
func getAutocompleteConfigJSON() template.JS {
cfg := GetAutocompleteConfig()
bytes, err := json.Marshal(cfg)
if err != nil {
return template.JS("{}")
}
return template.JS(bytes)
}
// autocompleteActions returns ordered setting info for the settings page.
func autocompleteActions() []map[string]string {
cfg := GetAutocompleteConfig()
actions := make([]map[string]string, 0, len(AutocompleteSettingKeys))
for _, key := range AutocompleteSettingKeys {
actions = append(actions, map[string]string{
"name": key,
"label": AutocompleteLabels[key],
"value": strconv.Itoa(cfg[key]),
"default_value": AutocompleteDefaults[key],
})
}
return actions
}
func clampInt(val, lo, hi int) int {
if val < lo {
return lo
}
if val > hi {
return hi
}
return val
}
+18 -53
View File
@@ -3,18 +3,13 @@ package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path"
"strings"
"sync"
)
const (
CONFIG_FILE = "config.json"
KB_INDEX = "Index"
KB_PAGE = "Page"
)
// ConfigFile is the filename for the notebook configuration JSON file.
const ConfigFile = "config.json"
type NotebookConfig struct {
Environment map[string]string `json:"environment"`
@@ -22,31 +17,27 @@ type NotebookConfig struct {
mutex sync.Mutex
}
func getValue(kmap map[string]string, action, defkey string) string {
if kmap == nil {
return defkey
}
if value, ok := kmap[action]; ok && len(value) > 0 {
return value
}
return defkey
}
func NewNotebookConfig(folder string) *NotebookConfig {
var config = &NotebookConfig{}
config.filename = path.Join(folder, CONFIG_FILE)
bytes, err := ioutil.ReadFile(config.filename)
nc := &NotebookConfig{}
nc.filename = path.Join(folder, ConfigFile)
bytes, err := os.ReadFile(nc.filename)
if err == nil {
json.Unmarshal(bytes, config)
}
if config.Environment == nil {
config.Environment = map[string]string{}
if jsonErr := json.Unmarshal(bytes, nc); jsonErr != nil {
fmt.Printf("warning: failed to parse config file: %v\n", jsonErr)
}
}
if value, ok := config.Environment["RIZIN_PATH"]; !ok || len(value) < 1 {
config.Environment["RIZIN_PATH"] = os.Getenv("RIZIN_PATH")
if nc.Environment == nil {
nc.Environment = map[string]string{}
}
return config
// Auto-populate RIZIN_PATH from the OS environment if not configured.
if value, ok := nc.Environment["RIZIN_PATH"]; !ok || len(value) < 1 {
nc.Environment["RIZIN_PATH"] = os.Getenv("RIZIN_PATH")
}
return nc
}
func (nc *NotebookConfig) UpdateEnvironment() {
@@ -56,29 +47,3 @@ func (nc *NotebookConfig) UpdateEnvironment() {
os.Setenv(key, value)
}
}
func (nc *NotebookConfig) DelEnvironment(key string) {
nc.mutex.Lock()
defer nc.mutex.Unlock()
key = strings.TrimSpace(key)
delete(nc.Environment, key)
os.Unsetenv(key)
}
func (nc *NotebookConfig) SetEnvironment(key, value string) {
nc.mutex.Lock()
defer nc.mutex.Unlock()
value = strings.TrimSpace(value)
key = strings.TrimSpace(key)
os.Setenv(key, value)
nc.Environment[key] = value
}
func (nc *NotebookConfig) Save() {
nc.mutex.Lock()
defer nc.mutex.Unlock()
bytes, _ := json.MarshalIndent(nc, "", "\t")
if err := ioutil.WriteFile(nc.filename, bytes, 0644); err != nil {
fmt.Println(err)
}
}
+111
View File
@@ -0,0 +1,111 @@
cmake_minimum_required(VERSION 3.16)
project(CutterNotebookPlugin LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
# ── Locate the Cutter installation ──────────────────────────────────────────
# Usage:
# cmake -S . -B build -DCutter_DIR="C:/path/to/Cutter-v2.4.1-Windows-x86_64"
# Also pass -DCMAKE_PREFIX_PATH="C:/Qt/6.7.2/msvc2019_64" for Qt6 SDK.
if(NOT Cutter_DIR AND NOT DEFINED ENV{Cutter_DIR})
message(FATAL_ERROR
"Set Cutter_DIR to your Cutter installation root, e.g.:\n"
" cmake -S . -B build "
"-DCutter_DIR=\"C:/path/to/Cutter-v2.4.1-Windows-x86_64\" "
"-DCMAKE_PREFIX_PATH=\"C:/Qt/6.7.2/msvc2019_64\"\n"
)
endif()
if(NOT Cutter_DIR)
set(Cutter_DIR "$ENV{Cutter_DIR}")
endif()
file(TO_CMAKE_PATH "${Cutter_DIR}" CUTTER_ROOT)
# Normalise: if the user passed the cmake package dir, walk up.
if(EXISTS "${CUTTER_ROOT}/CutterTargets.cmake")
get_filename_component(CUTTER_ROOT "${CUTTER_ROOT}/../../.." ABSOLUTE)
endif()
# Validate.
if(NOT EXISTS "${CUTTER_ROOT}/include/cutter/plugins/CutterPlugin.h")
message(FATAL_ERROR
"Cannot find CutterPlugin.h in '${CUTTER_ROOT}/include/cutter/plugins'.\n"
"Set Cutter_DIR to the root of an extracted Cutter binary release.\n"
)
endif()
message(STATUS "Cutter SDK root: ${CUTTER_ROOT}")
set(CUTTER_LIB_DIR "${CUTTER_ROOT}/lib")
set(CUTTER_INCLUDE_DIR "${CUTTER_ROOT}/include")
# ── Find Qt6 from the system / CMAKE_PREFIX_PATH ───────────────────────────
# We need Qt6 headers, MOC, and import libraries from a proper SDK install.
# The Cutter binary release only ships runtime DLLs, not development files.
find_package(Qt6 6.6 REQUIRED COMPONENTS Core Widgets Gui Network)
# ── Manually create Cutter::Cutter imported target ──────────────────────────
# We bypass CutterConfig.cmake because it calls find_dependency(Qt6 …) for
# components (Svg, Core5Compat, etc.) that we don't need, causing failures.
add_library(Cutter::Cutter SHARED IMPORTED)
if(WIN32)
set_target_properties(Cutter::Cutter PROPERTIES
IMPORTED_IMPLIB "${CUTTER_LIB_DIR}/cutter.lib"
)
elseif(APPLE)
find_library(_cutter_lib NAMES cutter PATHS "${CUTTER_LIB_DIR}" NO_DEFAULT_PATH)
set_target_properties(Cutter::Cutter PROPERTIES
IMPORTED_LOCATION "${_cutter_lib}"
)
else()
find_library(_cutter_lib NAMES cutter PATHS "${CUTTER_LIB_DIR}" NO_DEFAULT_PATH)
set_target_properties(Cutter::Cutter PROPERTIES
IMPORTED_LOCATION "${_cutter_lib}"
)
endif()
set_target_properties(Cutter::Cutter PROPERTIES
INTERFACE_INCLUDE_DIRECTORIES
"${CUTTER_INCLUDE_DIR}/cutter;${CUTTER_INCLUDE_DIR}/cutter/core;${CUTTER_INCLUDE_DIR}/cutter/widgets;${CUTTER_INCLUDE_DIR}/cutter/common;${CUTTER_INCLUDE_DIR}/cutter/plugins;${CUTTER_INCLUDE_DIR}/cutter/menus;${CUTTER_INCLUDE_DIR}/cutter/dialogs;${CUTTER_INCLUDE_DIR}/cutter/tools"
INTERFACE_LINK_LIBRARIES
"Qt6::Core;Qt6::Widgets;Qt6::Gui;Qt6::Network"
)
# ── Rizin (include paths for rz_core.h etc.) ───────────────────────────────
add_library(Cutter::Rizin INTERFACE IMPORTED)
set_target_properties(Cutter::Rizin PROPERTIES
INTERFACE_INCLUDE_DIRECTORIES "${CUTTER_INCLUDE_DIR}/librz;${CUTTER_INCLUDE_DIR}/librz/sdb"
)
# ── Plugin install directory ────────────────────────────────────────────────
if(WIN32)
file(TO_CMAKE_PATH "$ENV{APPDATA}" _appdata)
set(CUTTER_INSTALL_PLUGDIR "${_appdata}/rizin/cutter/plugins/native"
CACHE STRING "Cutter native plugin install directory")
elseif(APPLE)
set(CUTTER_INSTALL_PLUGDIR "$ENV{HOME}/Library/Application Support/rizin/cutter/plugins/native"
CACHE STRING "Cutter native plugin install directory")
else()
set(CUTTER_INSTALL_PLUGDIR "$ENV{HOME}/.local/share/rizin/cutter/plugins/native"
CACHE STRING "Cutter native plugin install directory")
endif()
# ── Plugin shared library ──────────────────────────────────────────────────
add_library(CutterNotebookPlugin MODULE
src/CutterNotebookPlugin.cpp
src/CutterNotebookPlugin.h
)
target_link_libraries(CutterNotebookPlugin PRIVATE
Cutter::Cutter
Cutter::Rizin
Qt6::Core
Qt6::Network
Qt6::Widgets
)
install(TARGETS CutterNotebookPlugin DESTINATION "${CUTTER_INSTALL_PLUGDIR}")
+115
View File
@@ -0,0 +1,115 @@
# cutter-notebook-plugin
Adds a Plugins -> Notebook dock and menu to Cutter for interacting with the
[rizin-notebook](../README.md) server.
This plugin talks to the notebook server directly over HTTP using Qt's network stack.
It does not depend on the `NB` commands from the rizin core plugin for its UI functionality,
but the core plugin must be installed for any `NB` command functionality in Cutter's
rizin shell.
## What you get
A dock panel with two tabs:
**Pages tab:**
- List of all notebook pages
- Per-page cell viewer showing cell type, content, and output
- Buttons: Refresh Pages, New Page, Delete Page, Attach Binary, Open Pipe, Close Pipe,
Reload Page, Export, Import
**Compose tab:**
- Active page and pipe status
- Editor for writing and submitting cells:
- Markdown note
- Command cell (recorded without execution)
- Script cell (recorded without execution)
- Run command now (executes via server pipe, records result)
- Run script now (executes JavaScript on server, records result)
**Menu entries** (Plugins -> Notebook):
- Connect... - connect to a running server
- Spawn Server - auto-start `rizin-notebook.exe`
- Server Status - show server version, rizin path, storage, page count
- List Pages - refresh the pages tab
- New Page... - create a page (auto-attaches the currently open binary)
- Delete Page - delete the selected page
- Attach Binary... - attach a binary file to the selected page
- Export Page... - save the selected page as a `.rznb` file
- Import Page... - import a `.rznb` file as a new page
- (separator)
- Open in Browser - open the server web UI in your browser
- Set Server URL... - change the server address
**Status bar** (bottom of dock): shows connection state, page count, open pipe count,
and server details on hover.
## Requirements
- Cutter 2.4.1 (or compatible)
- `rz_notebook` core plugin installed and loaded in Cutter (provides `NB` commands for
the rizin shell; the Cutter plugin itself uses the HTTP JSON API directly)
- `rizin-notebook` Go server running (the Cutter plugin can auto-start it)
- Qt 6.7.x SDK with MSVC 2019 64-bit (Windows) or the matching platform toolkit
- CMake 3.16+ and a C++20 compiler
## Build (Windows)
Install Qt 6.7.2 if you don't have it:
```powershell
pip install aqtinstall
python -m aqt install-qt windows desktop 6.7.2 win64_msvc2019_64 --outputdir C:\Qt
```
Configure and build:
```powershell
cmake -S cutter-notebook-plugin -B cutter-notebook-plugin/build ^
-DCutter_DIR="C:\path\to\Cutter-v2.4.1-Windows-x86_64" ^
-DQt6_DIR="C:\Qt\6.7.2\msvc2019_64\lib\cmake\Qt6"
cmake --build cutter-notebook-plugin/build --config Release
```
## Build (Linux / macOS)
```bash
cmake -S cutter-notebook-plugin -B cutter-notebook-plugin/build \
-DCutter_DIR=/opt/Cutter \
-DCMAKE_PREFIX_PATH=/path/to/Qt/6.x/gcc_64
cmake --build cutter-notebook-plugin/build --config Release
```
## Install
Copy `CutterNotebookPlugin.dll` (Windows) or `CutterNotebookPlugin.so` (Linux) to:
- Windows: `%APPDATA%\rizin\cutter\plugins\native`
- Linux: `~/.local/share/rizin/cutter/plugins/native`
- macOS: `~/Library/Application Support/rizin/cutter/plugins/native`
The `rz_notebook` core plugin (`rz_notebook.dll` / `.so`) goes into the Rizin plugin
directory that Cutter uses. For portable Cutter builds, this is often inside the Cutter
installation directory, not just the user plugin directory.
Run `rizin -H RZ_USER_PLUGINS` to find the user path. For portable Cutter, also check
`<cutter-root>/lib/rizin/plugins/`.
## Troubleshooting
**Dock does not appear** - make sure `CutterNotebookPlugin.dll` is in the native plugins
directory and was built against the same Cutter and Qt version you are running.
**Server not reachable** - start `rizin-notebook` first, or use Spawn Server from the menu.
Use Set Server URL if the server runs on a non-default address.
**NB commands not available in Cutter's rizin shell** - `rz_notebook.dll` (the core plugin)
must be built against the same Rizin that Cutter bundles and placed in the Rizin plugin
path that Cutter loads from. The Cutter plugin itself does not require this for its dock UI,
but you need it to use `NB` commands in Cutter's command panel.
**Binary hash mismatch warning** - when you open a page whose attached binary differs from
the file currently open in Cutter, the plugin shows a warning. You can re-attach the
correct binary via Attach Binary.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,107 @@
#pragma once
#include <QObject>
#include <CutterPlugin.h>
class QMenu;
class QDockWidget;
class QComboBox;
class QLabel;
class QListWidget;
class QPlainTextEdit;
class QTabWidget;
class MainWindow;
class CutterNotebookPlugin final : public QObject, public CutterPlugin {
Q_OBJECT
Q_PLUGIN_METADATA(IID "re.rizin.cutter.plugins.CutterPlugin")
Q_INTERFACES(CutterPlugin)
public:
void setupPlugin() override;
void setupInterface(MainWindow *main) override;
void terminate() override;
QString getName() const override { return "Notebook"; }
QString getAuthor() const override { return "rizin-notebook"; }
QString getDescription() const override {
return "Provides a native Cutter client for the shared rizin-notebook server.";
}
QString getVersion() const override { return "2.0.0"; }
private slots:
void onConnect();
void onSpawn();
void onServerStatus();
void onListPages();
void onNewPage();
void onDeletePage();
void onAttachBinary();
void onOpenPipe();
void onClosePipe();
void onSubmitEditor();
void onReloadSelectedPage();
void onOpenBrowser();
void onSetUrl();
void onExportPage();
void onImportPage();
private:
QString runCmd(const QString &cmd, bool *ok = nullptr);
QString notebookBaseUrl(bool *fromNBPlugin = nullptr);
void setNotebookBaseUrl(const QString &url);
bool ensureNotebookReady(bool showDialog = true);
QByteArray sendJsonRequest(const QString &method,
const QString &path,
const QByteArray &body = QByteArray(),
int *statusCode = nullptr,
QString *errorOut = nullptr);
QByteArray sendRawGetRequest(const QString &path,
int *statusCode = nullptr,
QString *contentDisposition = nullptr,
QString *errorOut = nullptr);
QByteArray sendMultipartPost(const QString &path,
const QString &fieldName,
const QString &fileName,
const QByteArray &fileData,
int *statusCode = nullptr,
QString *errorOut = nullptr);
bool populatePages(bool keepSelection = true);
bool loadPage(const QString &pageId, bool focusView = false);
bool attachBinaryToPage(const QString &pageId, const QString &filePath = QString());
void renderPage(const QVariantMap &page);
QString selectedPageId() const;
void setActivePage(const QString &pageId);
void updateComposeLabels(const QVariantMap &page = QVariantMap());
void updateEditorPlaceholder();
void refreshDockStatus();
void ensureDockVisible();
void updateConnectionUI();
QString computeFileHash(const QString &filePath);
void verifyBinaryHash(const QString &serverHash);
QString currentBinaryPath();
void fetchCommandsForCompleter();
void fetchAutocompleteSettings();
void updateAutocomplete();
void acceptAutocomplete();
bool eventFilter(QObject *obj, QEvent *event) override;
MainWindow *mainWindow = nullptr;
QDockWidget *dockWidget = nullptr;
QLabel *statusLabel = nullptr;
QLabel *currentPageLabel = nullptr;
QLabel *pipeLabel = nullptr;
QListWidget *pageListWidget = nullptr;
QPlainTextEdit *pageView = nullptr;
QPlainTextEdit *editor = nullptr;
QComboBox *editorMode = nullptr;
QTabWidget *dockTabs = nullptr;
QListWidget *acPopup = nullptr;
QStringList cmdCommandList;
int maxCompleterResults = 12;
int minCompleterChars = 2;
QString currentServerUrl;
QString activePageId;
bool connected = false;
bool m_populatingPages = false;
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+10 -1
View File
@@ -8,6 +8,8 @@ require (
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6
github.com/gin-gonic/gin v1.11.0
golang.org/x/sync v0.17.0
google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.46.1
)
require (
@@ -16,6 +18,7 @@ require (
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
@@ -25,24 +28,30 @@ require (
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.55.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.uber.org/mock v0.6.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+42 -2
View File
@@ -15,6 +15,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 h1:6dE1TmjqkY6tehR4A67gDNhvDtuZ54ocu7ab4K9o540=
github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@@ -40,6 +42,10 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0=
github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
@@ -53,6 +59,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -61,6 +69,8 @@ github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -82,6 +92,8 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
@@ -95,11 +107,39 @@ golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+91
View File
@@ -0,0 +1,91 @@
package main
import (
"encoding/json"
"html/template"
"strings"
)
var DefaultKeybindings = map[string]string{
"about": "alt+a",
"new_page": "ctrl+n",
"settings": "alt+s",
"toggle_pipe": "alt+o",
"new_command": "alt+c",
"new_markdown": "alt+m",
"new_script": "alt+j",
"search_notebook": "mod+shift+f",
"save": "mod+s",
"cancel": "escape",
"execute": "ctrl+enter",
"edit_markdown": "alt+e",
}
var KeybindingOrder = []string{
"about", "new_page", "settings", "toggle_pipe",
"new_command", "new_markdown", "new_script", "search_notebook",
"save", "execute", "cancel", "edit_markdown",
}
var KeybindingLabels = map[string]string{
"about": "About",
"new_page": "New Page",
"settings": "Settings",
"toggle_pipe": "Open / Close Pipe",
"new_command": "Command Line",
"new_markdown": "New Markdown",
"new_script": "New Script",
"search_notebook": "Notebook Search",
"save": "Save",
"cancel": "Cancel / Go Back",
"execute": "Execute / Run",
"edit_markdown": "Edit Markdown",
}
const keybindingSettingsPrefix = "kb:"
func GetAllKeybindings() map[string]string {
result := make(map[string]string, len(DefaultKeybindings))
for k, v := range DefaultKeybindings {
result[k] = v
}
if catalog == nil {
return result
}
settings, _ := catalog.GetAllSettings()
if settings == nil {
return result
}
for k, v := range settings {
if strings.HasPrefix(k, keybindingSettingsPrefix) {
action := strings.TrimPrefix(k, keybindingSettingsPrefix)
if _, ok := DefaultKeybindings[action]; ok && len(v) > 0 {
result[action] = v
}
}
}
return result
}
func getKeybindingsJSON() template.JS {
kb := GetAllKeybindings()
bytes, err := json.Marshal(kb)
if err != nil {
return template.JS("{}")
}
return template.JS(bytes)
}
func keybindingActions() []map[string]string {
kb := GetAllKeybindings()
actions := make([]map[string]string, 0, len(KeybindingOrder))
for _, name := range KeybindingOrder {
actions = append(actions, map[string]string{
"name": name,
"label": KeybindingLabels[name],
"combo": kb[name],
"default_combo": DefaultKeybindings[name],
})
}
return actions
}
+149 -18
View File
@@ -1,61 +1,192 @@
package main
import (
"context"
"flag"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"path"
"runtime/debug"
"syscall"
"time"
)
var (
NBVERSION string // Notebook version string
NBVERSION string
webroot string
notebook *Notebook // Notebook object
config *NotebookConfig // NotebookConfig object
notebook *Notebook
config *NotebookConfig
catalog *Catalog
)
func usage() {
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "Environment vars:\n RIZIN_PATH\n overrides where rizin executable is installed\n")
fmt.Fprintf(os.Stderr, "Environment vars:\n RIZIN_PATH\n \toverrides where rizin executable is installed\n")
}
func main() {
var debug bool
var assets string
var bind string
var rizinbin = "rizin"
var dataDir string = ".rizin-notebook"
var dataDir = ".rizin-notebook"
if len(NBVERSION) < 1 {
NBVERSION = "unknown"
if len(NBVERSION) < 1 || NBVERSION == "unknown" {
NBVERSION = resolveNotebookVersion()
}
if homedir, err := os.UserHomeDir(); err == nil {
dataDir = path.Join(homedir, ".rizin-notebook")
}
config = NewNotebookConfig(dataDir)
config.UpdateEnvironment()
if loc := os.Getenv("RIZIN_PATH"); len(loc) > 1 {
rizinbin = loc
}
// Parse flags BEFORE using dataDir to create config or notebook,
// so that the -notebook flag is respected on first load.
flag.StringVar(&bind, "bind", "127.0.0.1:8000", "[address]:[port] address to bind to.")
flag.StringVar(&webroot, "root", "/", "defines where the web root of the application is.")
flag.StringVar(&dataDir, "notebook", ""+dataDir, "defines where the notebook folder is located.")
flag.StringVar(&dataDir, "notebook", dataDir, "defines where the notebook folder is located.")
flag.StringVar(&assets, "debug-assets", "", "allows you to debug the assets (-debug-assets /path/to/assets).")
flag.BoolVar(&debug, "debug", false, "enable http debug logs.")
flag.Usage = usage
flag.Parse()
// Ensure the data directory exists.
if err := os.MkdirAll(dataDir, os.ModePerm); err != nil && !os.IsExist(err) {
panic(err)
fmt.Fprintf(os.Stderr, "fatal: cannot create data directory: %v\n", err)
os.Exit(1)
}
// Load config AFTER flags are parsed so -notebook is respected.
config = NewNotebookConfig(dataDir)
config.UpdateEnvironment()
// Initialize catalog (per-page .rznb storage).
var err error
catalog, err = NewCatalog(dataDir)
if err != nil {
fmt.Fprintf(os.Stderr, "fatal: cannot open catalog: %v\n", err)
os.Exit(1)
}
defer catalog.Close()
// Migrate legacy monolithic notebook.db into per-page .rznb files.
if migrated, merr := catalog.MigrateFromLegacyDB(dataDir); merr != nil {
fmt.Printf("warning: legacy DB migration error: %v\n", merr)
} else if migrated > 0 {
fmt.Printf("Migrated %d pages from legacy notebook.db to .rznb files.\n", migrated)
}
// Import JSON page data into .rznb files if present.
if migrated, merr := catalog.MigrateFromJSON(dataDir); merr != nil {
fmt.Printf("warning: JSON migration error: %v\n", merr)
} else if migrated > 0 {
fmt.Printf("Migrated %d pages from JSON to .rznb files.\n", migrated)
}
if merr := catalog.MigrateSettings(dataDir); merr != nil {
fmt.Printf("warning: settings migration error: %v\n", merr)
}
// Allow RIZIN_PATH env var to override the default rizin binary location.
if loc := os.Getenv("RIZIN_PATH"); len(loc) > 1 {
rizinbin = loc
}
// Prevent the rz_notebook plugin from auto-starting another server
// instance when we spawn rizin sub-processes (to load commands, etc.).
os.Setenv("RIZIN_NOTEBOOK_NO_AUTOSTART", "1")
fmt.Printf("Server data dir '%s'\n", dataDir)
// Create notebook WITHOUT loading commands yet; the HTTP server
// must be listening BEFORE we spawn any rizin sub-process, so that
// the rz_notebook plugin inside those sub-processes sees us as alive
// and does not try to start yet another server instance.
notebook = NewNotebook(dataDir, rizinbin)
runServer(assets, bind, debug)
// Start the HTTP server FIRST (synchronous listen, async serve).
srv := startServer(assets, bind, debug)
// NOW load rizin commands; any rizin sub-process will find us alive.
notebook.LoadCommands()
// Block until SIGINT or SIGTERM is received.
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
fmt.Println("\nShutting down server...")
// Close all open Rizin pipes to prevent orphan processes.
notebook.closeAllPipes()
// Gracefully shut down the HTTP server with a 5-second timeout.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
fmt.Fprintf(os.Stderr, "server shutdown error: %v\n", err)
}
fmt.Println("Server stopped.")
}
func resolveNotebookVersion() string {
bi, ok := debug.ReadBuildInfo()
if !ok {
return "dev"
}
if bi.Main.Version != "" && bi.Main.Version != "(devel)" {
return bi.Main.Version
}
var rev string
var dirty bool
for _, s := range bi.Settings {
switch s.Key {
case "vcs.revision":
rev = s.Value
case "vcs.modified":
dirty = s.Value == "true"
}
}
if rev == "" {
return "dev"
}
if len(rev) > 12 {
rev = rev[:12]
}
if dirty {
return "dev-" + rev + "-dirty"
}
return "dev-" + rev
}
func startServer(assets, bind string, debug bool) *http.Server {
router := setupRouter(assets, bind, debug)
srv := &http.Server{
Handler: router,
}
// Bind the socket synchronously so the server is reachable
// before we return. This guarantees that any rizin sub-process
// spawned later will find the health-check endpoint alive.
ln, err := net.Listen("tcp", bind)
if err != nil {
fmt.Fprintf(os.Stderr, "fatal: listen error: %v\n", err)
os.Exit(1)
}
fmt.Printf("Server listening at http://%s\n", bind)
go func() {
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "serve error: %v\n", err)
os.Exit(1)
}
}()
return srv
}
+21 -8
View File
@@ -2,25 +2,38 @@ package main
import (
"crypto/rand"
"strings"
)
const ELEMENT_NONCE_SIZE = 8
const PAGE_NONCE_SIZE = 32
// ElementNonceSize is the length of nonce strings used for page elements.
const ElementNonceSize = 8
// PageNonceSize is the length of nonce strings used for notebook pages.
const PageNonceSize = 32
// nonceAlphabet is the character set used for generating nonce strings.
const nonceAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
func Nonce(n int) string {
const encoding = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const modulo = byte(len(encoding))
const modulo = byte(len(nonceAlphabet))
bytes := make([]byte, n)
_, err := rand.Read(bytes)
if err != nil {
if _, err := rand.Read(bytes); err != nil {
panic(err)
}
for i, b := range bytes {
bytes[i] = encoding[b%modulo]
bytes[i] = nonceAlphabet[b%modulo]
}
return string(bytes)
}
func IsValidNonce(unique string) bool {
func IsValidNonce(nonce string, expectedLen int) bool {
if len(nonce) != expectedLen {
return false
}
for _, c := range nonce {
if !strings.ContainsRune(nonceAlphabet, c) {
return false
}
}
return true
}
+81 -321
View File
@@ -1,384 +1,144 @@
package main
import (
"encoding/json"
"github.com/gin-gonic/gin"
"io/ioutil"
"os"
"path"
"fmt"
"path/filepath"
"sort"
"strings"
"sync"
)
const PAGE_FILE = "page.json"
func readJson(filepath string) gin.H {
var data = gin.H{}
bytes, err := ioutil.ReadFile(filepath)
if err != nil {
panic(err)
}
json.Unmarshal(bytes, &data)
return data
}
func sanitizeLines(lines []interface{}) []interface{} {
for i, v := range lines {
n := gin.H{}
p := v.(map[string]interface{})
for k, _ := range p {
n[k] = p[k]
}
lines[i] = n
}
return lines
}
func findLineByKey(lines []interface{}, key, eunique string) int {
for i, n := range lines {
p := n.(gin.H)
if v, ok := p[key]; ok && eunique == v {
return i
}
}
return -1
}
func findNonce(a []string, b string) int {
for i, n := range a {
if n == b {
return i
}
}
return -1
}
type Notebook struct {
mutex sync.Mutex
pages gin.H
uniques []string
storage string
pipes map[string]*Rizin
jsvm *JavaScript
rizin string
cmds map[string]RizinCommand
rizinInfoCache []string
}
func NewNotebook(storage, rizinbin string) *Notebook {
pages := gin.H{}
uniques := []string{}
prefix := path.Join(storage) + string(os.PathSeparator)
suffix := string(os.PathSeparator) + PAGE_FILE
files, err := filepath.Glob(path.Join(storage, "*", PAGE_FILE))
if err != nil {
panic(err)
}
for _, file := range files {
page := readJson(file)
page["lines"] = sanitizeLines(page["lines"].([]interface{}))
unique := strings.TrimSuffix(strings.TrimPrefix(file, prefix), suffix)
page["unique"] = unique
pages[unique] = page
uniques = append(uniques, unique)
}
jsvm := NewJavaScript()
if jsvm == nil {
panic("failed to create scripting engine")
}
sort.Strings(uniques)
return &Notebook{
pages: pages,
uniques: uniques,
storage: storage,
pipes: map[string]*Rizin{},
jsvm: jsvm,
rizin: rizinbin,
cmds: map[string]RizinCommand{},
rizinInfoCache: nil,
}
}
func (n *Notebook) list() []gin.H {
n.mutex.Lock()
defer n.mutex.Unlock()
pages := make([]gin.H, len(n.uniques))
for i, unique := range n.uniques {
page := n.pages[unique].(gin.H)
pipe := n.pipes[unique]
pages[i] = gin.H{
"title": page["title"],
"unique": unique,
"pipe": pipe != nil,
}
// LoadCommands fetches the list of rizin commands by spawning rizin.
// This MUST be called after the HTTP server is listening, because the
// spawned rizin process loads rz_notebook which health-checks us.
func (n *Notebook) LoadCommands() {
cmds, err := RizinCommands(n.rizin)
if err != nil {
fmt.Printf("warning: failed to load rizin commands: %v\n", err)
}
return pages
info, infoErr := RizinInfo(n.rizin)
if infoErr != nil {
fmt.Printf("warning: failed to load rizin info: %v\n", infoErr)
}
n.mutex.Lock()
if cmds != nil {
n.cmds = cmds
}
if len(info) > 0 {
n.rizinInfoCache = append([]string(nil), info...)
}
n.mutex.Unlock()
}
func (n *Notebook) info() ([]string, error) {
return RizinInfo(n.rizin)
}
func (n *Notebook) get(unique string) gin.H {
if len(unique) != PAGE_NONCE_SIZE {
return nil
}
n.mutex.Lock()
defer n.mutex.Unlock()
if page, ok := n.pages[unique]; ok {
return page.(gin.H)
if len(n.rizinInfoCache) > 0 {
info := append([]string(nil), n.rizinInfoCache...)
n.mutex.Unlock()
return info, nil
}
return nil
}
n.mutex.Unlock()
func (n *Notebook) newmd(unique string) string {
if len(unique) != PAGE_NONCE_SIZE {
return ""
}
n.mutex.Lock()
defer n.mutex.Unlock()
if data, ok := n.pages[unique]; ok {
page := data.(gin.H)
var eunique = Nonce(ELEMENT_NONCE_SIZE)
for {
if _, err := n.file(unique, eunique+".md"); err != nil {
break
}
eunique = Nonce(ELEMENT_NONCE_SIZE)
}
if !n.save([]byte{}, unique, eunique+".md") {
return ""
}
page["lines"] = append(page["lines"].([]interface{}), gin.H{
"type": "markdown",
"unique": eunique,
})
filepath := path.Join(n.storage, unique, PAGE_FILE)
bytes, _ := json.MarshalIndent(page, "", "\t")
if ioutil.WriteFile(filepath, bytes, 0644) == nil {
n.pages[unique] = page
return eunique
}
}
return ""
}
func (n *Notebook) newscript(unique, script string) string {
if len(unique) != PAGE_NONCE_SIZE {
return ""
}
n.mutex.Lock()
defer n.mutex.Unlock()
if data, ok := n.pages[unique]; ok {
page := data.(gin.H)
var eunique = Nonce(ELEMENT_NONCE_SIZE)
for {
if _, err := n.file(unique, eunique+".out"); err != nil {
break
}
eunique = Nonce(ELEMENT_NONCE_SIZE)
}
page["lines"] = append(page["lines"].([]interface{}), gin.H{
"type": "script",
"unique": eunique,
"script": script,
})
filepath := path.Join(n.storage, unique, PAGE_FILE)
bytes, _ := json.MarshalIndent(page, "", "\t")
if ioutil.WriteFile(filepath, bytes, 0644) == nil {
n.pages[unique] = page
return eunique
}
}
return ""
}
func (n *Notebook) newcmd(unique, command string) string {
if len(unique) != PAGE_NONCE_SIZE {
return ""
}
n.mutex.Lock()
defer n.mutex.Unlock()
if data, ok := n.pages[unique]; ok {
page := data.(gin.H)
var eunique = Nonce(ELEMENT_NONCE_SIZE)
for {
if _, err := n.file(unique, eunique+".out"); err != nil {
break
}
eunique = Nonce(ELEMENT_NONCE_SIZE)
}
page["lines"] = append(page["lines"].([]interface{}), gin.H{
"type": "command",
"unique": eunique,
"command": command,
})
filepath := path.Join(n.storage, unique, PAGE_FILE)
bytes, _ := json.MarshalIndent(page, "", "\t")
if ioutil.WriteFile(filepath, bytes, 0644) == nil {
n.pages[unique] = page
return eunique
}
}
return ""
}
func (n *Notebook) deleteElem(unique, eunique string, markdown bool) bool {
if len(unique) != PAGE_NONCE_SIZE || len(eunique) != ELEMENT_NONCE_SIZE {
return false
}
n.mutex.Lock()
defer n.mutex.Unlock()
if data, ok := n.pages[unique]; ok {
suffix := ".md"
if !markdown {
suffix = ".out"
}
page := data.(gin.H)
os.Remove(path.Join(n.storage, unique, eunique+suffix))
lines := page["lines"].([]interface{})
if idx := findLineByKey(lines, "unique", eunique); idx > -1 {
page["lines"] = append(lines[:idx], lines[idx+1:]...)
}
filepath := path.Join(n.storage, unique, PAGE_FILE)
bytes, _ := json.MarshalIndent(page, "", "\t")
if ioutil.WriteFile(filepath, bytes, 0644) == nil {
n.pages[unique] = page
return true
}
}
return false
}
func (n *Notebook) new(title, filename, binary string) string {
if len(title) < 1 {
return ""
info, err := RizinInfo(n.rizin)
if err != nil {
return nil, err
}
n.mutex.Lock()
defer n.mutex.Unlock()
var unique = Nonce(PAGE_NONCE_SIZE)
for {
if _, ok := n.pages[unique]; !ok {
break
}
unique = Nonce(PAGE_NONCE_SIZE)
}
data := gin.H{
"title": title,
"unique": unique,
"filename": filename,
"binary": binary,
"lines": []interface{}{},
}
if err := os.MkdirAll(path.Join(n.storage, unique), os.ModePerm); err != nil {
return ""
}
filepath := path.Join(n.storage, unique, PAGE_FILE)
bytes, _ := json.MarshalIndent(data, "", "\t")
if err := ioutil.WriteFile(filepath, bytes, 0644); err != nil {
return ""
}
n.pages[unique] = data
n.uniques = append(n.uniques, unique)
sort.Strings(n.uniques)
return unique
}
func (n *Notebook) rename(unique, title string) bool {
if len(title) < 1 || len(unique) != PAGE_NONCE_SIZE {
return false
}
n.mutex.Lock()
defer n.mutex.Unlock()
if data, ok := n.pages[unique]; ok {
page := data.(gin.H)
page["title"] = title
filepath := path.Join(n.storage, unique, PAGE_FILE)
bytes, _ := json.MarshalIndent(page, "", "\t")
if ioutil.WriteFile(filepath, bytes, 0644) == nil {
n.pages[unique] = page
return true
}
}
return false
}
func (n *Notebook) delete(unique string) bool {
if len(unique) != PAGE_NONCE_SIZE {
return false
}
if _, ok := n.pages[unique]; !ok {
return false
}
filepath := path.Join(n.storage, unique)
if os.RemoveAll(filepath) == nil {
delete(n.pages, unique)
idx := findNonce(n.uniques, unique)
n.uniques = append(n.uniques[:idx], n.uniques[idx+1:]...)
return true
}
return false
n.rizinInfoCache = append([]string(nil), info...)
n.mutex.Unlock()
return info, nil
}
// Returns existing pipe or opens a new one, extracting the binary to a temp file.
func (n *Notebook) open(unique string, open bool) *Rizin {
if len(unique) != PAGE_NONCE_SIZE {
if !IsValidNonce(unique, PageNonceSize) {
return nil
}
n.mutex.Lock()
defer n.mutex.Unlock()
if p, ok := n.pipes[unique]; ok {
return p
}
if p, ok := n.pages[unique]; ok && open {
page := p.(gin.H)
filepath := path.Join(n.storage, unique, page["binary"].(string))
prjpath := path.Join(n.storage, unique, "project.rzdb")
// This should be async.
rizin := NewRizin(n.rizin, filepath, prjpath)
if rizin == nil {
return nil
}
n.pipes[unique] = rizin
return rizin
if !open {
return nil
}
return nil
page, err := catalog.GetPage(unique)
if err != nil || page == nil {
return nil
}
binaryPath, err := catalog.GetBinaryForPipe(unique, n.storage)
if err != nil {
fmt.Printf("warning: failed to extract binary for pipe: %v\n", err)
return nil
}
prjPath := filepath.Join(n.storage, unique, "project.rzdb")
rizin := NewRizin(n.rizin, binaryPath, prjPath)
if rizin == nil {
return nil
}
n.pipes[unique] = rizin
return rizin
}
func (n *Notebook) close(unique string) {
if len(unique) != PAGE_NONCE_SIZE {
func (n *Notebook) closePipe(unique string) {
if !IsValidNonce(unique, PageNonceSize) {
return
}
n.mutex.Lock()
defer n.mutex.Unlock()
if p, ok := n.pipes[unique]; ok {
p.close()
p, ok := n.pipes[unique]
if ok {
delete(n.pipes, unique)
}
}
n.mutex.Unlock()
func (n *Notebook) exists(paths ...string) bool {
args := append([]string{n.storage}, paths...)
_, err := os.Stat(path.Join(args...))
if os.IsNotExist(err) {
return false
if ok && p != nil {
p.close()
}
return true
}
func (n *Notebook) file(paths ...string) ([]byte, error) {
args := append([]string{n.storage}, paths...)
filepath := path.Join(args...)
return ioutil.ReadFile(filepath)
}
func (n *Notebook) closeAllPipes() {
n.mutex.Lock()
pipesToClose := make(map[string]*Rizin)
for k, v := range n.pipes {
pipesToClose[k] = v
}
n.pipes = map[string]*Rizin{}
n.mutex.Unlock()
func (n *Notebook) save(bytes []byte, paths ...string) bool {
args := append([]string{n.storage}, paths...)
filepath := path.Join(args...)
return ioutil.WriteFile(filepath, bytes, 0644) == nil
for unique, p := range pipesToClose {
fmt.Printf("Closing pipe for page %s...\n", unique)
p.close()
}
}
+730
View File
@@ -0,0 +1,730 @@
package main
import (
"fmt"
"regexp"
"sort"
"strings"
"unicode"
"unicode/utf8"
)
type NotebookSearchMode string
const (
NotebookSearchModeLiteral NotebookSearchMode = "literal"
NotebookSearchModeRegex NotebookSearchMode = "regex"
NotebookSearchModeFuzzy NotebookSearchMode = "fuzzy"
)
type NotebookSearchSurface string
const (
NotebookSearchSurfaceContent NotebookSearchSurface = "content"
NotebookSearchSurfaceOutput NotebookSearchSurface = "output"
)
type NotebookSearchSurfaceFilter string
const (
NotebookSearchSurfaceFilterAll NotebookSearchSurfaceFilter = "all"
NotebookSearchSurfaceFilterContent NotebookSearchSurfaceFilter = "content"
NotebookSearchSurfaceFilterOutput NotebookSearchSurfaceFilter = "output"
)
type NotebookSearchCellTypeFilter string
const (
NotebookSearchCellTypeFilterAll NotebookSearchCellTypeFilter = "all"
NotebookSearchCellTypeFilterCommand NotebookSearchCellTypeFilter = "command"
NotebookSearchCellTypeFilterScript NotebookSearchCellTypeFilter = "script"
NotebookSearchCellTypeFilterMarkdown NotebookSearchCellTypeFilter = "markdown"
)
const (
defaultNotebookSearchLimit = 200
maxNotebookSearchLimit = 500
fuzzyNoScore = -1 << 30
fuzzyGapPenalty = 8
)
type NotebookSearchOptions struct {
Query string
Mode NotebookSearchMode
CaseSensitive bool
Limit int
SurfaceFilter NotebookSearchSurfaceFilter
CellTypeFilter NotebookSearchCellTypeFilter
}
type NotebookSearchRange struct {
Start int `json:"start"`
End int `json:"end"`
}
type NotebookSearchResult struct {
CellID string `json:"cell_id"`
CellType string `json:"cell_type"`
Surface NotebookSearchSurface `json:"surface"`
CellPosition int `json:"cell_position"`
LineNumber int `json:"line_number"`
StartColumn int `json:"start_column"`
EndColumn int `json:"end_column"`
LineText string `json:"line_text"`
Ranges []NotebookSearchRange `json:"ranges"`
Score int `json:"score,omitempty"`
}
type NotebookSearchResponse struct {
Query string `json:"query"`
Mode NotebookSearchMode `json:"mode"`
CaseSensitive bool `json:"case_sensitive"`
SurfaceFilter NotebookSearchSurfaceFilter `json:"surface_filter"`
CellTypeFilter NotebookSearchCellTypeFilter `json:"cell_type_filter"`
Total int `json:"total"`
Truncated bool `json:"truncated"`
Results []NotebookSearchResult `json:"results"`
}
type notebookSearchDocument struct {
CellID string
CellType string
Surface NotebookSearchSurface
CellPosition int
Text string
}
type notebookSearchLineMatch struct {
Ranges []NotebookSearchRange
Score int
}
func SearchNotebook(cells []CellRow, options NotebookSearchOptions) (NotebookSearchResponse, error) {
mode, err := normalizeNotebookSearchMode(options.Mode)
if err != nil {
return NotebookSearchResponse{}, err
}
surfaceFilter, err := normalizeNotebookSearchSurfaceFilter(options.SurfaceFilter)
if err != nil {
return NotebookSearchResponse{}, err
}
cellTypeFilter, err := normalizeNotebookSearchCellTypeFilter(options.CellTypeFilter)
if err != nil {
return NotebookSearchResponse{}, err
}
query := strings.TrimSpace(options.Query)
limit := normalizeNotebookSearchLimit(options.Limit)
response := NotebookSearchResponse{
Query: query,
Mode: mode,
CaseSensitive: options.CaseSensitive,
SurfaceFilter: surfaceFilter,
CellTypeFilter: cellTypeFilter,
Results: []NotebookSearchResult{},
}
if query == "" {
return response, nil
}
docs := buildNotebookSearchDocuments(cells, surfaceFilter, cellTypeFilter)
results := make([]NotebookSearchResult, 0, 32)
switch mode {
case NotebookSearchModeLiteral:
results = searchNotebookLiteral(docs, query, options.CaseSensitive)
case NotebookSearchModeRegex:
results, err = searchNotebookRegex(docs, query, options.CaseSensitive)
if err != nil {
return response, err
}
case NotebookSearchModeFuzzy:
results = searchNotebookFuzzy(docs, query, options.CaseSensitive)
default:
return response, fmt.Errorf("unsupported search mode: %s", mode)
}
response.Total = len(results)
if len(results) > limit {
response.Truncated = true
results = results[:limit]
}
response.Results = results
return response, nil
}
func normalizeNotebookSearchMode(mode NotebookSearchMode) (NotebookSearchMode, error) {
if mode == "" {
return NotebookSearchModeLiteral, nil
}
switch mode {
case NotebookSearchModeLiteral, NotebookSearchModeRegex, NotebookSearchModeFuzzy:
return mode, nil
default:
return "", fmt.Errorf("invalid search mode: %s", mode)
}
}
func normalizeNotebookSearchSurfaceFilter(filter NotebookSearchSurfaceFilter) (NotebookSearchSurfaceFilter, error) {
if filter == "" {
return NotebookSearchSurfaceFilterAll, nil
}
switch filter {
case NotebookSearchSurfaceFilterAll, NotebookSearchSurfaceFilterContent, NotebookSearchSurfaceFilterOutput:
return filter, nil
default:
return "", fmt.Errorf("invalid surface filter: %s", filter)
}
}
func normalizeNotebookSearchCellTypeFilter(filter NotebookSearchCellTypeFilter) (NotebookSearchCellTypeFilter, error) {
if filter == "" {
return NotebookSearchCellTypeFilterAll, nil
}
switch filter {
case NotebookSearchCellTypeFilterAll, NotebookSearchCellTypeFilterCommand, NotebookSearchCellTypeFilterScript, NotebookSearchCellTypeFilterMarkdown:
return filter, nil
default:
return "", fmt.Errorf("invalid cell type filter: %s", filter)
}
}
func normalizeNotebookSearchLimit(limit int) int {
if limit <= 0 {
return defaultNotebookSearchLimit
}
if limit > maxNotebookSearchLimit {
return maxNotebookSearchLimit
}
return limit
}
func buildNotebookSearchDocuments(cells []CellRow, surfaceFilter NotebookSearchSurfaceFilter, cellTypeFilter NotebookSearchCellTypeFilter) []notebookSearchDocument {
docs := make([]notebookSearchDocument, 0, len(cells)*2)
for _, cell := range cells {
if !notebookSearchMatchesCellType(cell.Type, cellTypeFilter) {
continue
}
switch cell.Type {
case "markdown":
if cell.Content != "" && notebookSearchMatchesSurface(NotebookSearchSurfaceContent, surfaceFilter) {
docs = append(docs, notebookSearchDocument{
CellID: cell.ID,
CellType: cell.Type,
Surface: NotebookSearchSurfaceContent,
CellPosition: cell.Position,
Text: normalizeNotebookSearchText(cell.Content),
})
}
case "command", "script":
if cell.Content != "" && notebookSearchMatchesSurface(NotebookSearchSurfaceContent, surfaceFilter) {
docs = append(docs, notebookSearchDocument{
CellID: cell.ID,
CellType: cell.Type,
Surface: NotebookSearchSurfaceContent,
CellPosition: cell.Position,
Text: normalizeNotebookSearchText(cell.Content),
})
}
if len(cell.Output) > 0 && notebookSearchMatchesSurface(NotebookSearchSurfaceOutput, surfaceFilter) {
docs = append(docs, notebookSearchDocument{
CellID: cell.ID,
CellType: cell.Type,
Surface: NotebookSearchSurfaceOutput,
CellPosition: cell.Position,
Text: normalizeNotebookSearchText(outputVisibleText(string(cell.Output))),
})
}
}
}
return docs
}
func notebookSearchMatchesSurface(surface NotebookSearchSurface, filter NotebookSearchSurfaceFilter) bool {
return filter == NotebookSearchSurfaceFilterAll || NotebookSearchSurfaceFilter(surface) == filter
}
func notebookSearchMatchesCellType(cellType string, filter NotebookSearchCellTypeFilter) bool {
return filter == NotebookSearchCellTypeFilterAll || NotebookSearchCellTypeFilter(cellType) == filter
}
func normalizeNotebookSearchText(text string) string {
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
return text
}
func searchNotebookLiteral(docs []notebookSearchDocument, query string, caseSensitive bool) []NotebookSearchResult {
queryRunes := []rune(query)
results := make([]NotebookSearchResult, 0, 32)
for _, doc := range docs {
lines := strings.Split(doc.Text, "\n")
for lineIdx, line := range lines {
matches := findLiteralLineMatches([]rune(line), queryRunes, caseSensitive)
for _, match := range matches {
results = append(results, notebookSearchResultFromLineMatch(doc, lineIdx+1, line, match))
}
}
}
return results
}
func searchNotebookRegex(docs []notebookSearchDocument, query string, caseSensitive bool) ([]NotebookSearchResult, error) {
pattern := query
if !caseSensitive {
pattern = "(?i)" + query
}
re, err := regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("invalid regex: %w", err)
}
if re.MatchString("") {
return nil, fmt.Errorf("regex must not match empty strings")
}
results := make([]NotebookSearchResult, 0, 32)
for _, doc := range docs {
lines := strings.Split(doc.Text, "\n")
for lineIdx, line := range lines {
matches := findRegexLineMatches(line, re)
for _, match := range matches {
results = append(results, notebookSearchResultFromLineMatch(doc, lineIdx+1, line, match))
}
}
}
return results, nil
}
func searchNotebookFuzzy(docs []notebookSearchDocument, query string, caseSensitive bool) []NotebookSearchResult {
results := make([]NotebookSearchResult, 0, 32)
for _, doc := range docs {
lines := strings.Split(doc.Text, "\n")
for lineIdx, line := range lines {
match, ok := findFuzzyLineMatch(line, query, caseSensitive)
if !ok {
continue
}
results = append(results, notebookSearchResultFromLineMatch(doc, lineIdx+1, line, match))
}
}
sort.SliceStable(results, func(i, j int) bool {
if results[i].Score != results[j].Score {
return results[i].Score > results[j].Score
}
spanI := results[i].EndColumn - results[i].StartColumn
spanJ := results[j].EndColumn - results[j].StartColumn
if spanI != spanJ {
return spanI < spanJ
}
if results[i].CellPosition != results[j].CellPosition {
return results[i].CellPosition < results[j].CellPosition
}
if results[i].LineNumber != results[j].LineNumber {
return results[i].LineNumber < results[j].LineNumber
}
return results[i].StartColumn < results[j].StartColumn
})
return results
}
func notebookSearchResultFromLineMatch(doc notebookSearchDocument, lineNumber int, line string, match notebookSearchLineMatch) NotebookSearchResult {
startCol, endCol := notebookSearchRangeColumns(match.Ranges)
return NotebookSearchResult{
CellID: doc.CellID,
CellType: doc.CellType,
Surface: doc.Surface,
CellPosition: doc.CellPosition,
LineNumber: lineNumber,
StartColumn: startCol,
EndColumn: endCol,
LineText: line,
Ranges: match.Ranges,
Score: match.Score,
}
}
func notebookSearchRangeColumns(ranges []NotebookSearchRange) (int, int) {
if len(ranges) == 0 {
return 0, 0
}
start := ranges[0].Start + 1
end := ranges[0].End
for _, r := range ranges[1:] {
if r.Start+1 < start {
start = r.Start + 1
}
if r.End > end {
end = r.End
}
}
return start, end
}
func findLiteralLineMatches(lineRunes, queryRunes []rune, caseSensitive bool) []notebookSearchLineMatch {
if len(lineRunes) == 0 || len(queryRunes) == 0 || len(queryRunes) > len(lineRunes) {
return nil
}
matches := make([]notebookSearchLineMatch, 0, 2)
for idx := 0; idx <= len(lineRunes)-len(queryRunes); idx++ {
if literalRunesEqual(lineRunes[idx:idx+len(queryRunes)], queryRunes, caseSensitive) {
matches = append(matches, notebookSearchLineMatch{
Ranges: []NotebookSearchRange{{Start: idx, End: idx + len(queryRunes)}},
Score: len(queryRunes) * 100,
})
}
}
return matches
}
func literalRunesEqual(line, query []rune, caseSensitive bool) bool {
if len(line) != len(query) {
return false
}
for i := range query {
if !searchRunesEqual(line[i], query[i], caseSensitive) {
return false
}
}
return true
}
func searchRunesEqual(a, b rune, caseSensitive bool) bool {
if caseSensitive {
return a == b
}
return unicode.ToLower(a) == unicode.ToLower(b)
}
func findRegexLineMatches(line string, re *regexp.Regexp) []notebookSearchLineMatch {
byteMatches := re.FindAllStringIndex(line, -1)
if len(byteMatches) == 0 {
return nil
}
results := make([]notebookSearchLineMatch, 0, len(byteMatches))
for _, byteMatch := range byteMatches {
start := byteOffsetToRuneIndex(line, byteMatch[0])
end := byteOffsetToRuneIndex(line, byteMatch[1])
if end <= start {
continue
}
results = append(results, notebookSearchLineMatch{
Ranges: []NotebookSearchRange{{Start: start, End: end}},
Score: (end - start) * 100,
})
}
return results
}
func byteOffsetToRuneIndex(text string, byteOffset int) int {
if byteOffset <= 0 {
return 0
}
if byteOffset >= len(text) {
return utf8.RuneCountInString(text)
}
return utf8.RuneCountInString(text[:byteOffset])
}
func findFuzzyLineMatch(line, query string, caseSensitive bool) (notebookSearchLineMatch, bool) {
lineRunes := []rune(line)
if len(lineRunes) == 0 {
return notebookSearchLineMatch{}, false
}
tokens := fuzzyQueryTokens(query)
if len(tokens) == 0 {
return notebookSearchLineMatch{}, false
}
allPositions := make([]int, 0, len([]rune(query)))
totalScore := 0
searchStart := 0
for _, token := range tokens {
tokenRunes := []rune(token)
match, ok := bestFuzzyTokenMatch(lineRunes, tokenRunes, searchStart, caseSensitive)
if !ok {
return notebookSearchLineMatch{}, false
}
allPositions = append(allPositions, match.positions...)
totalScore += match.score
searchStart = match.positions[len(match.positions)-1] + 1
}
if len(allPositions) == 0 {
return notebookSearchLineMatch{}, false
}
sort.Ints(allPositions)
ranges := mergeFuzzyPositions(allPositions)
if len(ranges) == 0 {
return notebookSearchLineMatch{}, false
}
return notebookSearchLineMatch{
Ranges: ranges,
Score: totalScore,
}, true
}
type fuzzyTokenMatch struct {
positions []int
score int
}
func fuzzyQueryTokens(query string) []string {
return strings.Fields(strings.TrimSpace(query))
}
func bestFuzzyTokenMatch(lineRunes, tokenRunes []rune, searchStart int, caseSensitive bool) (fuzzyTokenMatch, bool) {
if len(tokenRunes) == 0 || searchStart >= len(lineRunes) {
return fuzzyTokenMatch{}, false
}
lineLen := len(lineRunes)
tokenLen := len(tokenRunes)
parents := make([][]int, tokenLen)
for idx := range parents {
parents[idx] = make([]int, lineLen)
for col := range parents[idx] {
parents[idx][col] = -1
}
}
prevScores := make([]int, lineLen)
prevStarts := make([]int, lineLen)
currScores := make([]int, lineLen)
currStarts := make([]int, lineLen)
fillFuzzyState(prevScores, prevStarts)
fillFuzzyState(currScores, currStarts)
for col := searchStart; col < lineLen; col++ {
if !searchRunesEqual(lineRunes[col], tokenRunes[0], caseSensitive) {
continue
}
prevScores[col] = fuzzyBaseScore(lineRunes, tokenRunes[0], col, caseSensitive)
prevStarts[col] = col
}
for row := 1; row < tokenLen; row++ {
fillFuzzyState(currScores, currStarts)
bestGapScore := fuzzyNoScore
bestGapStart := -1
bestGapIndex := -1
for col := searchStart; col < lineLen; col++ {
gapCandidate := col - 2
if gapCandidate >= searchStart && prevScores[gapCandidate] != fuzzyNoScore {
score := prevScores[gapCandidate] + fuzzyGapPenalty*(gapCandidate+1)
if fuzzyStateBetter(score, prevStarts[gapCandidate], gapCandidate, bestGapScore, bestGapStart, bestGapIndex) {
bestGapScore = score
bestGapStart = prevStarts[gapCandidate]
bestGapIndex = gapCandidate
}
}
if !searchRunesEqual(lineRunes[col], tokenRunes[row], caseSensitive) {
continue
}
bestScore := fuzzyNoScore
bestStart := -1
bestPrev := -1
if col-1 >= searchStart && prevScores[col-1] != fuzzyNoScore {
score := prevScores[col-1] + fuzzyCharScore(lineRunes, tokenRunes[row], col, true, caseSensitive)
if fuzzyStateBetter(score, prevStarts[col-1], col-1, bestScore, bestStart, bestPrev) {
bestScore = score
bestStart = prevStarts[col-1]
bestPrev = col - 1
}
}
if bestGapScore != fuzzyNoScore {
score := bestGapScore - fuzzyGapPenalty*col + fuzzyCharScore(lineRunes, tokenRunes[row], col, false, caseSensitive)
if fuzzyStateBetter(score, bestGapStart, bestGapIndex, bestScore, bestStart, bestPrev) {
bestScore = score
bestStart = bestGapStart
bestPrev = bestGapIndex
}
}
if bestScore == fuzzyNoScore {
continue
}
currScores[col] = bestScore
currStarts[col] = bestStart
parents[row][col] = bestPrev
}
prevScores, currScores = currScores, prevScores
prevStarts, currStarts = currStarts, prevStarts
}
bestEnd := -1
bestScore := fuzzyNoScore
bestStart := -1
for col := searchStart; col < lineLen; col++ {
if prevScores[col] == fuzzyNoScore {
continue
}
score := finalizeFuzzyScore(prevScores[col], prevStarts[col], col, tokenLen)
if fuzzyFinalStateBetter(score, prevStarts[col], col, bestScore, bestStart, bestEnd) {
bestScore = score
bestStart = prevStarts[col]
bestEnd = col
}
}
if bestEnd < 0 {
return fuzzyTokenMatch{}, false
}
positions := make([]int, tokenLen)
col := bestEnd
for row := tokenLen - 1; row >= 0; row-- {
positions[row] = col
if row > 0 {
col = parents[row][col]
if col < 0 {
return fuzzyTokenMatch{}, false
}
}
}
return fuzzyTokenMatch{positions: positions, score: bestScore}, true
}
func fillFuzzyState(scores, starts []int) {
for idx := range scores {
scores[idx] = fuzzyNoScore
starts[idx] = -1
}
}
func fuzzyBaseScore(lineRunes []rune, tokenRune rune, pos int, caseSensitive bool) int {
score := 60 - pos*2
score += fuzzyPositionBonus(lineRunes, pos)
if !caseSensitive && lineRunes[pos] == tokenRune {
score += 8
}
return score
}
func fuzzyCharScore(lineRunes []rune, tokenRune rune, pos int, adjacent bool, caseSensitive bool) int {
score := 44
if adjacent {
score += 56
}
score += fuzzyPositionBonus(lineRunes, pos)
if !caseSensitive && lineRunes[pos] == tokenRune {
score += 8
}
return score
}
func finalizeFuzzyScore(score, start, end, tokenLen int) int {
span := end - start + 1
score -= span * 2
if span == tokenLen {
score += 60
}
if span <= tokenLen+2 {
score += 18
}
if start == 0 {
score += 10
}
return score
}
func fuzzyPositionBonus(lineRunes []rune, pos int) int {
score := 0
if pos == 0 {
score += 20
}
if fuzzyWordBoundary(lineRunes, pos) {
score += 18
}
if fuzzyCamelBoundary(lineRunes, pos) {
score += 12
}
return score
}
func fuzzyWordBoundary(lineRunes []rune, pos int) bool {
if pos <= 0 {
return true
}
return !fuzzyIsWordRune(lineRunes[pos-1])
}
func fuzzyCamelBoundary(lineRunes []rune, pos int) bool {
if pos <= 0 {
return false
}
prev := lineRunes[pos-1]
curr := lineRunes[pos]
if unicode.IsLower(prev) && unicode.IsUpper(curr) {
return true
}
return unicode.IsDigit(prev) != unicode.IsDigit(curr) && fuzzyIsWordRune(prev) && fuzzyIsWordRune(curr)
}
func fuzzyIsWordRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r)
}
func fuzzyStateBetter(score, start, end, bestScore, bestStart, bestEnd int) bool {
if score != bestScore {
return score > bestScore
}
span := end - start
bestSpan := bestEnd - bestStart
if span != bestSpan {
return span < bestSpan
}
if start != bestStart {
return start < bestStart
}
return end < bestEnd
}
func fuzzyFinalStateBetter(score, start, end, bestScore, bestStart, bestEnd int) bool {
if score != bestScore {
return score > bestScore
}
span := end - start
bestSpan := bestEnd - bestStart
if span != bestSpan {
return span < bestSpan
}
if start != bestStart {
return start < bestStart
}
return end < bestEnd
}
func mergeFuzzyPositions(positions []int) []NotebookSearchRange {
if len(positions) == 0 {
return nil
}
ranges := make([]NotebookSearchRange, 0, len(positions))
current := NotebookSearchRange{Start: positions[0], End: positions[0] + 1}
for _, pos := range positions[1:] {
if pos <= current.End {
if pos+1 > current.End {
current.End = pos + 1
}
continue
}
ranges = append(ranges, current)
current = NotebookSearchRange{Start: pos, End: pos + 1}
}
ranges = append(ranges, current)
return ranges
}
+307 -141
View File
@@ -5,11 +5,13 @@ import (
"fmt"
"html"
"io"
"math"
"regexp"
"strconv"
"strings"
)
// tagMap maps ANSI SGR attribute codes to their HTML tag equivalents.
var tagMap = map[string]string{
"1": "b",
"2": "b",
@@ -19,6 +21,7 @@ var tagMap = map[string]string{
"9": "strike",
}
// colMap maps ANSI 8-color codes to CSS color names.
var colMap = map[string]string{
"0": "white",
"1": "red",
@@ -31,7 +34,24 @@ var colMap = map[string]string{
"9": "white",
}
var color265 = map[string]string{
// foregroundColorMap maps ANSI 8-color codes to contrast-safe colors for text
// rendered on the notebook output background.
var foregroundColorMap = map[string]string{
"0": "#d0d7de",
"1": "#ff9e64",
"2": "#98c379",
"3": "#e5c07b",
"4": "#61afef",
"5": "#c678dd",
"6": "#56b6c2",
"7": "#f0f0f0",
"9": "#ffffff",
}
const outputBackgroundColor = "#4b4b4b"
// color256 maps ANSI 256-color palette indices to hex color codes.
var color256 = map[string]string{
"0": "#000000",
"1": "#800000",
"2": "#008000",
@@ -290,67 +310,170 @@ var color265 = map[string]string{
"255": "#eeeeee",
}
var col256 = regexp.MustCompile(`^\[([34]8);5;(\d+)m`)
var colrgb = regexp.MustCompile(`^\[([34]8|1);2;(\d+);(\d+);(\d+)m`)
var escape = regexp.MustCompile(`^\[(;?\d+)+([A-Za-z])`)
// Precompiled regular expressions for ANSI escape sequence parsing.
var (
// col256Re matches 256-color ANSI escape sequences: ESC[38;5;Nm or ESC[48;5;Nm.
col256Re = regexp.MustCompile(`^\[([34]8);5;(\d+)m`)
func fromRawToString(raw []byte) string {
var output = strings.TrimSuffix(string(raw), "\x00")
output = strings.ReplaceAll(output, "\r", "")
return strings.Trim(output, "\n")
// colRGBRe matches RGB true color ANSI escape sequences: ESC[38;2;R;G;Bm.
colRGBRe = regexp.MustCompile(`^\[([34]8|1);2;(\d+);(\d+);(\d+)m`)
// escapeRe matches generic ANSI escape sequences with numeric parameters.
escapeRe = regexp.MustCompile(`^\[(;?\d+)+([A-Za-z])`)
outputHTMLTagRe = regexp.MustCompile(`(?s)<[^>]+>`)
)
func parseHexColor(hexColor string) (int, int, int, bool) {
if len(hexColor) != 7 || hexColor[0] != '#' {
return 0, 0, 0, false
}
r, err := strconv.ParseInt(hexColor[1:3], 16, 0)
if err != nil {
return 0, 0, 0, false
}
g, err := strconv.ParseInt(hexColor[3:5], 16, 0)
if err != nil {
return 0, 0, 0, false
}
b, err := strconv.ParseInt(hexColor[5:7], 16, 0)
if err != nil {
return 0, 0, 0, false
}
return int(r), int(g), int(b), true
}
func toCsv(output string) ([]byte, bool) {
func formatHexColor(red, green, blue int) string {
if red < 0 {
red = 0
} else if red > 255 {
red = 255
}
if green < 0 {
green = 0
} else if green > 255 {
green = 255
}
if blue < 0 {
blue = 0
} else if blue > 255 {
blue = 255
}
return fmt.Sprintf("#%02x%02x%02x", red, green, blue)
}
func channelLuminance(value int) float64 {
v := float64(value) / 255.0
if v <= 0.03928 {
return v / 12.92
}
return math.Pow((v+0.055)/1.055, 2.4)
}
func relativeLuminance(red, green, blue int) float64 {
return 0.2126*channelLuminance(red) + 0.7152*channelLuminance(green) + 0.0722*channelLuminance(blue)
}
func contrastRatio(colorA, colorB string) float64 {
r1, g1, b1, ok := parseHexColor(colorA)
if !ok {
return 21.0
}
r2, g2, b2, ok := parseHexColor(colorB)
if !ok {
return 21.0
}
l1 := relativeLuminance(r1, g1, b1)
l2 := relativeLuminance(r2, g2, b2)
if l1 < l2 {
l1, l2 = l2, l1
}
return (l1 + 0.05) / (l2 + 0.05)
}
func ensureReadableForeground(hexColor string) string {
red, green, blue, ok := parseHexColor(hexColor)
if !ok {
return hexColor
}
adjusted := hexColor
for i := 0; i < 12 && contrastRatio(adjusted, outputBackgroundColor) < 4.5; i++ {
red += (255 - red) * 35 / 100
green += (255 - green) * 35 / 100
blue += (255 - blue) * 35 / 100
adjusted = formatHexColor(red, green, blue)
}
return adjusted
}
func toCsv(output string) ([]byte, error) {
reader := csv.NewReader(strings.NewReader(output))
reader.Comma = ','
var htmlStr = "<table>\n"
var sb strings.Builder
sb.WriteString("<table>\n")
for i := 0; ; i++ {
record, err := reader.Read()
if err == io.EOF {
break
} else if err != nil || (i == 0 && len(record) < 2) {
return nil, false
}
if err != nil {
return nil, err
}
if i == 0 && len(record) < 2 {
return nil, fmt.Errorf("CSV requires at least 2 columns, got %d", len(record))
}
htmlStr += "<tr>"
sb.WriteString("<tr>")
for _, elem := range record {
htmlStr += "\n<td>"
htmlStr += html.EscapeString(elem)
htmlStr += "</td>"
sb.WriteString("\n<td>")
sb.WriteString(html.EscapeString(elem))
sb.WriteString("</td>")
}
htmlStr += "\n</tr>\n"
sb.WriteString("\n</tr>\n")
}
htmlStr += "</table>"
return []byte(htmlStr), true
sb.WriteString("</table>")
return []byte(sb.String()), nil
}
func toHtml(output string) []byte {
if output == "" {
return []byte{}
}
// HTML-escape the raw text first.
output = strings.ReplaceAll(output, "&", "&amp;")
output = strings.ReplaceAll(output, "<", "&lt;")
output = strings.ReplaceAll(output, ">", "&gt;")
output = strings.ReplaceAll(output, "\"", "&quot;")
output = strings.ReplaceAll(output, "'", "&apos;")
var htmlStr = ""
var sb strings.Builder
tokens := strings.Split(output, "\x1b")
for _, token := range tokens {
if token == "" {
continue
}
// Handle cursor control and screen-clearing codes (strip them).
if strings.HasPrefix(token, "[H") ||
strings.HasPrefix(token, "[s") ||
strings.HasPrefix(token, "[u") ||
strings.HasPrefix(token, "[J") ||
strings.HasPrefix(token, "[K") {
htmlStr += token[2:]
} else if strings.HasPrefix(token, "[#;#R") {
htmlStr += token[5:]
} else if strings.HasPrefix(token, "[#") ||
sb.WriteString(token[2:])
continue
}
if strings.HasPrefix(token, "[#;#R") {
sb.WriteString(token[5:])
continue
}
if strings.HasPrefix(token, "[#") ||
strings.HasPrefix(token, "[0m") ||
strings.HasPrefix(token, "[0J") ||
strings.HasPrefix(token, "[1J") ||
@@ -359,128 +482,171 @@ func toHtml(output string) []byte {
strings.HasPrefix(token, "[1K") ||
strings.HasPrefix(token, "[2K") ||
strings.HasPrefix(token, "[6n") {
htmlStr += token[3:]
} else if strings.HasPrefix(token, "[1m") ||
strings.HasPrefix(token, "[2m") {
htmlStr += "<b>"
htmlStr += token[3:]
htmlStr += "</b>"
} else if strings.HasPrefix(token, "[3m") {
htmlStr += "<i>"
htmlStr += token[3:]
htmlStr += "</i>"
} else if strings.HasPrefix(token, "[4m") {
htmlStr += "<ins>"
htmlStr += token[3:]
htmlStr += "</ins>"
} else if strings.HasPrefix(token, "[5m") {
htmlStr += "<blink>"
htmlStr += token[3:]
htmlStr += "</blink>"
} else if strings.HasPrefix(token, "[7m") ||
strings.HasPrefix(token, "[8m") {
htmlStr += token[3:]
} else if strings.HasPrefix(token, "[9m") {
htmlStr += "<strike>"
htmlStr += token[3:]
htmlStr += "</strike>"
} else if strings.HasPrefix(token, "[22m") ||
sb.WriteString(token[3:])
continue
}
// Handle individual SGR attribute codes.
if strings.HasPrefix(token, "[1m") || strings.HasPrefix(token, "[2m") {
sb.WriteString("<b>")
sb.WriteString(token[3:])
sb.WriteString("</b>")
continue
}
if strings.HasPrefix(token, "[3m") {
sb.WriteString("<i>")
sb.WriteString(token[3:])
sb.WriteString("</i>")
continue
}
if strings.HasPrefix(token, "[4m") {
sb.WriteString("<ins>")
sb.WriteString(token[3:])
sb.WriteString("</ins>")
continue
}
if strings.HasPrefix(token, "[5m") {
sb.WriteString("<blink>")
sb.WriteString(token[3:])
sb.WriteString("</blink>")
continue
}
if strings.HasPrefix(token, "[7m") || strings.HasPrefix(token, "[8m") {
sb.WriteString(token[3:])
continue
}
if strings.HasPrefix(token, "[9m") {
sb.WriteString("<strike>")
sb.WriteString(token[3:])
sb.WriteString("</strike>")
continue
}
// Handle SGR reset codes (22m-29m).
if strings.HasPrefix(token, "[22m") ||
strings.HasPrefix(token, "[23m") ||
strings.HasPrefix(token, "[24m") ||
strings.HasPrefix(token, "[25m") ||
strings.HasPrefix(token, "[28m") ||
strings.HasPrefix(token, "[27m") ||
strings.HasPrefix(token, "[28m") ||
strings.HasPrefix(token, "[29m") {
htmlStr += token[4:]
} else if token == "7" || token == "8" {
htmlStr += token[1:]
} else {
var btok = []byte(token)
var found = col256.FindSubmatch(btok)
if len(found) == 3 {
if len(token) == len(found[0]) {
continue
}
color, ok := color265[string(found[2])]
if ok {
if string(found[1]) == "48" {
htmlStr += fmt.Sprintf("<span style=\"background-color: %s\">", color)
} else {
htmlStr += fmt.Sprintf("<span style=\"color: %s\">", color)
}
}
htmlStr += token[len(found[0]):]
if ok {
htmlStr += "</span>"
}
continue
}
found = colrgb.FindSubmatch(btok)
if len(found) == 5 {
if len(token) == len(found[0]) {
continue
}
r, _ := strconv.Atoi(string(found[2]))
g, _ := strconv.Atoi(string(found[3]))
b, _ := strconv.Atoi(string(found[4]))
r &= 0xFF
g &= 0xFF
b &= 0xFF
if string(found[1]) == "48" {
htmlStr += fmt.Sprintf("<span style=\"background-color: #%02x%02x%02x\">", r, g, b)
} else {
htmlStr += fmt.Sprintf("<span style=\"color: #%02x%02x%02x\">", r, g, b)
}
htmlStr += token[len(found[0]):]
htmlStr += "</span>"
continue
}
found = escape.FindSubmatch(btok)
if len(found) < 1 {
htmlStr += token
continue
} else if string(found[len(found)-1]) != "m" {
htmlStr += token[len(found[0]):]
continue
} else if len(token) == len(found[0]) {
continue
}
var tags = ""
for _, e := range found {
e := string(e)
if len(e) < 1 {
continue
}
if e[len(e)-1] == ';' {
e = e[:len(e)-1]
}
if e == "0" {
htmlStr += tags
tags = ""
continue
} else if tag, ok := tagMap[e]; ok {
htmlStr += fmt.Sprintf("<%s>", tag)
tags += fmt.Sprintf("</%s>", tag)
} else if len(e) == 2 && (e[0] == '3' || e[0] == '4' || e[0] == '9') && e[1] != '8' {
color, _ := colMap[string(e[1])]
if e[0] == '4' {
htmlStr += fmt.Sprintf("<span style=\"background-color: %s\">", color)
} else {
htmlStr += fmt.Sprintf("<span style=\"color: %s\">", color)
}
tags += "</span>"
} else if len(e) == 3 && e[0] == '1' && e[1] == '0' && e[2] != '8' {
color, _ := colMap[string(e[1])]
htmlStr += fmt.Sprintf("<span style=\"background-color: %s\">", color)
tags += "</span>"
}
}
htmlStr += token[len(found[0]):]
htmlStr += tags
sb.WriteString(token[4:])
continue
}
if token == "7" || token == "8" {
sb.WriteString(token[1:])
continue
}
// Try 256-color match.
btok := []byte(token)
found := col256Re.FindSubmatch(btok)
if len(found) == 3 {
if len(token) == len(found[0]) {
continue
}
hexColor, ok := color256[string(found[2])]
if ok {
if string(found[1]) == "48" {
sb.WriteString(fmt.Sprintf("<span style=\"background-color: %s\">", hexColor))
} else {
sb.WriteString(fmt.Sprintf("<span style=\"color: %s\">", ensureReadableForeground(hexColor)))
}
}
sb.WriteString(token[len(found[0]):])
if ok {
sb.WriteString("</span>")
}
continue
}
// Try RGB true color match.
found = colRGBRe.FindSubmatch(btok)
if len(found) == 5 {
if len(token) == len(found[0]) {
continue
}
r, _ := strconv.Atoi(string(found[2]))
g, _ := strconv.Atoi(string(found[3]))
b, _ := strconv.Atoi(string(found[4]))
r &= 0xFF
g &= 0xFF
b &= 0xFF
if string(found[1]) == "48" {
sb.WriteString(fmt.Sprintf("<span style=\"background-color: #%02x%02x%02x\">", r, g, b))
} else {
sb.WriteString(fmt.Sprintf("<span style=\"color: %s\">", ensureReadableForeground(formatHexColor(r, g, b))))
}
sb.WriteString(token[len(found[0]):])
sb.WriteString("</span>")
continue
}
// Try generic escape sequence match.
found = escapeRe.FindSubmatch(btok)
if len(found) < 1 {
sb.WriteString(token)
continue
}
if string(found[len(found)-1]) != "m" {
sb.WriteString(token[len(found[0]):])
continue
}
if len(token) == len(found[0]) {
continue
}
var tags strings.Builder
for _, e := range found {
es := string(e)
if len(es) < 1 {
continue
}
if es[len(es)-1] == ';' {
es = es[:len(es)-1]
}
if es == "0" {
sb.WriteString(tags.String())
tags.Reset()
continue
}
if tag, ok := tagMap[es]; ok {
sb.WriteString(fmt.Sprintf("<%s>", tag))
tags.WriteString(fmt.Sprintf("</%s>", tag))
} else if len(es) == 2 && (es[0] == '3' || es[0] == '4' || es[0] == '9') && es[1] != '8' {
clr, _ := colMap[string(es[1])]
if es[0] == '4' {
sb.WriteString(fmt.Sprintf("<span style=\"background-color: %s\">", clr))
} else {
fgColor, ok := foregroundColorMap[string(es[1])]
if !ok {
fgColor = clr
}
sb.WriteString(fmt.Sprintf("<span style=\"color: %s\">", fgColor))
}
tags.WriteString("</span>")
} else if len(es) == 3 && es[0] == '1' && es[1] == '0' && es[2] != '8' {
clr, _ := colMap[string(es[1])]
sb.WriteString(fmt.Sprintf("<span style=\"background-color: %s\">", clr))
tags.WriteString("</span>")
}
}
sb.WriteString(token[len(found[0]):])
sb.WriteString(tags.String())
}
htmlStr = strings.ReplaceAll(htmlStr, "\n", "<br>\n")
return []byte("<pre>" + htmlStr + "</pre>")
result := strings.ReplaceAll(sb.String(), "\n", "<br>\n")
return []byte("<pre>" + result + "</pre>")
}
func outputVisibleText(output string) string {
if output == "" {
return ""
}
text := string(toHtml(output))
text = strings.ReplaceAll(text, "<br>\n", "\n")
text = strings.ReplaceAll(text, "<br>", "\n")
text = outputHTMLTagRe.ReplaceAllString(text, "")
return html.UnescapeString(text)
}
+2063
View File
File diff suppressed because it is too large. Load diff
+187 -19
View File
@@ -3,40 +3,144 @@ package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"time"
)
// pipeReadTimeout is the maximum time to wait for a response from Rizin.
const pipeReadTimeout = 5 * time.Minute
type RizinCommandArg struct {
Type string `json:"type"`
Name string `json:"name"`
DefaultArg string `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
IsOption bool `json:"is_option,omitempty"`
IsArray bool `json:"is_array,omitempty"`
Choices []string `json:"choices,omitempty"`
}
type RizinCommandDetailEntry struct {
Text string `json:"text,omitempty"`
Comment string `json:"comment,omitempty"`
Arg string `json:"arg_str,omitempty"`
}
type RizinCommandDetail struct {
Name string `json:"name"`
Entries []RizinCommandDetailEntry `json:"entries,omitempty"`
}
type RizinCommand struct {
Command string `json:"cmd"`
ArgsStr string `json:"args_str"`
Args []RizinCommandArg `json:"args,omitempty"`
Description string `json:"description,omitempty"`
Summary string `json:"summary,omitempty"`
Details []RizinCommandDetail `json:"details,omitempty"`
}
type Rizin struct {
pipe *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
reader *bufio.Reader
mutex sync.Mutex
project string
closed bool
}
func RizinInfo(rizinbin string) ([]string, error) {
out, err := exec.Command(rizinbin, "-version").Output()
flags := [][]string{{"--version"}, {"-v"}}
var lastErr error
for _, args := range flags {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
cmd := exec.CommandContext(ctx, rizinbin, args...)
cmd.Env = rizinEnv()
configureSubprocess(cmd)
out, err := cmd.CombinedOutput()
cancel()
if err != nil {
lastErr = err
continue
}
text := strings.TrimSpace(string(out))
if text == "" {
continue
}
lines := []string{}
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line != "" {
lines = append(lines, line)
}
}
if len(lines) > 0 {
return lines, nil
}
}
if lastErr == nil {
lastErr = errors.New("no version output")
}
return nil, lastErr
}
// rizinEnv returns the current environment with RIZIN_NOTEBOOK_NO_AUTOSTART=1
// so the rz_notebook plugin in sub-processes won't try to auto-start a server.
func rizinEnv() []string {
env := os.Environ()
// Ensure the variable is present (even if main.go already set it).
found := false
for i, e := range env {
if strings.HasPrefix(e, "RIZIN_NOTEBOOK_NO_AUTOSTART=") {
env[i] = "RIZIN_NOTEBOOK_NO_AUTOSTART=1"
found = true
break
}
}
if !found {
env = append(env, "RIZIN_NOTEBOOK_NO_AUTOSTART=1")
}
return env
}
func RizinCommands(rizinbin string) (map[string]RizinCommand, error) {
cmd := exec.Command(rizinbin, "-qc", "?*j")
cmd.Env = rizinEnv()
configureSubprocess(cmd)
out, err := cmd.Output()
if err != nil {
return nil, err
}
return strings.Split(string(out), "\n"), nil
var commands map[string]RizinCommand
err = json.Unmarshal(out, &commands)
return commands, err
}
// Launches Rizin with -2 -0 -e scr.color=3 -p <project> and waits for initial null-byte.
func NewRizin(rizinbin, file, project string) *Rizin {
args := []string{
"-2",
"-0",
"-e",
"scr.color=3",
"-p",
project,
"-e", "scr.color=3",
"-p", project,
file,
}
pipe := exec.Command(rizinbin, args...)
pipe.Env = rizinEnv()
configureSubprocess(pipe)
stdin, err := pipe.StdinPipe()
if err != nil {
@@ -55,35 +159,99 @@ func NewRizin(rizinbin, file, project string) *Rizin {
return nil
}
rizin := &Rizin{pipe: pipe, stdin: stdin, stdout: stdout, project: project}
rizin := &Rizin{
pipe: pipe,
stdin: stdin,
reader: bufio.NewReader(stdout),
project: project,
closed: false,
}
// Wait for the initial null-byte prompt indicating Rizin is ready.
// This is done in a goroutine with the mutex held to prevent commands
// from being sent before Rizin has finished initialization.
go func(r *Rizin) {
r.mutex.Lock()
if _, err := bufio.NewReader(r.stdout).ReadString('\x00'); err != nil {
fmt.Println("pipe error:", err)
defer r.mutex.Unlock()
if _, err := r.reader.ReadString('\x00'); err != nil {
fmt.Println("pipe error: initial prompt:", err)
}
r.mutex.Unlock()
}(rizin)
return rizin
}
func (r *Rizin) exec(cmd string) (string, error) {
r.mutex.Lock()
defer r.mutex.Unlock()
if r.closed {
return "", fmt.Errorf("pipe is closed")
}
if _, err := fmt.Fprintln(r.stdin, cmd); err != nil {
fmt.Println("pipe error:", err)
return "", err
}
buf, err := bufio.NewReader(r.stdout).ReadString('\x00')
if err != nil && err != io.EOF {
fmt.Println("pipe error:", err)
return "", err
// Use a context with timeout to prevent indefinite blocking.
ctx, cancel := context.WithTimeout(context.Background(), pipeReadTimeout)
defer cancel()
type readResult struct {
data string
err error
}
ch := make(chan readResult, 1)
go func() {
buf, err := r.reader.ReadString('\x00')
if err != nil && err != io.EOF {
ch <- readResult{"", err}
return
}
buf = string(bytes.Trim([]byte(buf), "\x00"))
ch <- readResult{buf, nil}
}()
select {
case result := <-ch:
if result.err != nil {
fmt.Println("pipe error:", result.err)
}
return result.data, result.err
case <-ctx.Done():
return "", fmt.Errorf("pipe read timed out after %v", pipeReadTimeout)
}
buf = string(bytes.Trim([]byte(buf), "\x00"))
return buf, nil
}
func (r *Rizin) close() {
r.exec("Ps " + r.project)
r.exec("q!")
if r.closed {
return
}
// Save the project before quitting. Errors are logged but not fatal.
if _, err := r.exec("Ps " + r.project); err != nil {
fmt.Println("pipe warning: failed to save project:", err)
}
// Send quit command. Use direct stdin write since exec() checks closed flag.
r.mutex.Lock()
r.closed = true
fmt.Fprintln(r.stdin, "q!")
r.mutex.Unlock()
// Wait for the process to exit (with a timeout).
done := make(chan error, 1)
go func() {
done <- r.pipe.Wait()
}()
select {
case <-done:
// Process exited cleanly.
case <-time.After(10 * time.Second):
// Force kill if it doesn't exit in time.
fmt.Println("pipe warning: force killing unresponsive rizin process")
r.pipe.Process.Kill()
}
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !windows
package main
import "os/exec"
// configureSubprocess is a no-op on non-Windows platforms.
func configureSubprocess(cmd *exec.Cmd) {}
+17
View File
@@ -0,0 +1,17 @@
//go:build windows
package main
import (
"os/exec"
"syscall"
)
// configureSubprocess hides the console window for spawned rizin sub-processes
// so they don't flash a CMD window on the user's desktop.
func configureSubprocess(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000, // CREATE_NO_WINDOW
}
}
+237
View File
@@ -0,0 +1,237 @@
// Protocol Buffers schema for rizin-notebook API.
// All API endpoints accept and return protobuf-encoded messages.
// Content-Type: application/x-protobuf
syntax = "proto3";
package notebook;
option go_package = "github.com/rizinorg/rizin-notebook/pb";
// ─── Core Data Model ────────────────────────────────────────
// CellType enumerates the kinds of notebook cells.
enum CellType {
CELL_TYPE_UNSPECIFIED = 0;
CELL_TYPE_COMMAND = 1;
CELL_TYPE_SCRIPT = 2;
CELL_TYPE_MARKDOWN = 3;
}
// Cell represents a single notebook cell (command, script, or markdown).
message Cell {
string id = 1; // Unique cell identifier (8-char nonce).
CellType type = 2; // Cell type.
string content = 3; // Command string, script source, or markdown text.
bytes output = 4; // Raw execution output (ANSI bytes for cmd/script, rendered HTML for markdown).
int64 created = 5; // Unix timestamp of creation.
int64 executed = 6; // Unix timestamp of last execution (0 if never).
}
// Page represents a notebook page containing cells and associated binary.
message Page {
string id = 1; // Unique page identifier (32-char nonce).
string title = 2; // Human-readable page title.
string filename = 3; // Original filename of the uploaded binary.
string binary = 4; // Storage key for the binary file (internal use).
bool pipe = 5; // Whether a Rizin pipe is currently open.
int64 created = 6; // Unix timestamp of creation.
int64 modified = 7; // Unix timestamp of last modification.
repeated Cell cells = 8; // Ordered list of cells.
string binary_hash = 9; // SHA-256 hex digest of the bundled binary.
}
// ─── Request / Response Messages ────────────────────────────
// StatusResponse is returned by the health/status endpoint.
message StatusResponse {
string version = 1; // Notebook server version string.
string rizin_version = 2; // Rizin version string.
string rizin_path = 3; // Resolved path to rizin binary.
string storage = 4; // Notebook data directory path.
int32 pages = 5; // Total number of pages.
int32 open_pipes = 6; // Number of currently open Rizin pipes.
}
// ListPagesResponse contains a summary of all pages.
message ListPagesResponse {
repeated Page pages = 1;
}
// GetPageResponse returns a single page with all cells.
message GetPageResponse {
Page page = 1;
}
// CreatePageRequest creates a new notebook page.
message CreatePageRequest {
string title = 1; // Required: page title.
string filename = 2; // Required: original binary filename.
bytes binary = 3; // Required: binary file content.
}
// CreatePageResponse returns the created page.
message CreatePageResponse {
Page page = 1;
}
// UpdatePageRequest modifies page metadata.
message UpdatePageRequest {
string id = 1; // Required: page ID.
string title = 2; // New title (if non-empty).
}
// DeletePageRequest removes a page and all its cells.
message DeletePageRequest {
string id = 1; // Required: page ID.
}
// ─── Cell Operations ────────────────────────────────────────
// AddCellRequest adds a new cell to a page.
message AddCellRequest {
string page_id = 1; // Required: parent page ID.
CellType type = 2; // Required: cell type.
string content = 3; // Command, script source, or initial markdown.
}
// AddCellResponse returns the created cell.
message AddCellResponse {
Cell cell = 1;
}
// UpdateCellRequest modifies a cell's content.
message UpdateCellRequest {
string page_id = 1; // Required: parent page ID.
string cell_id = 2; // Required: cell ID.
string content = 3; // New content (command, script, or markdown).
}
// DeleteCellRequest removes a cell from a page.
message DeleteCellRequest {
string page_id = 1; // Required: parent page ID.
string cell_id = 2; // Required: cell ID.
}
// GetCellOutputRequest retrieves the output of a cell.
message GetCellOutputRequest {
string page_id = 1;
string cell_id = 2;
}
// GetCellOutputResponse returns raw output bytes.
message GetCellOutputResponse {
bytes output = 1; // Raw ANSI output bytes.
string html = 2; // Pre-rendered HTML (ANSI-to-HTML converted).
int64 executed_at = 3; // Unix timestamp of execution.
}
// ─── Execution ──────────────────────────────────────────────
// ExecCommandRequest executes a rizin command and stores result as a cell.
message ExecCommandRequest {
string page_id = 1; // Required: page ID (must have open pipe).
string command = 2; // Required: rizin command string.
}
// ExecCommandResponse returns execution result.
message ExecCommandResponse {
Cell cell = 1; // The created command cell with output.
bool success = 2; // Whether execution succeeded.
string error = 3; // Error message if failed.
}
// ExecScriptRequest executes a JavaScript script.
message ExecScriptRequest {
string page_id = 1; // Required: page ID.
string script = 2; // Required: JavaScript source code.
}
// ExecScriptResponse returns script execution result.
message ExecScriptResponse {
Cell cell = 1; // The created script cell with output.
bool success = 2;
string error = 3;
}
// ─── Record (client-side execution) ─────────────────────────
// RecordCommandRequest records a pre-executed command+output as a cell.
// Used by clients that execute commands locally (rizin plugin, Cutter).
message RecordCommandRequest {
string page_id = 1; // Required: page ID.
string command = 2; // Required: the command that was executed.
bytes output = 3; // Required: raw execution output.
}
// RecordCommandResponse returns the recorded cell.
message RecordCommandResponse {
Cell cell = 1; // The created command cell.
bool success = 2;
string error = 3;
}
// ─── Pipe Management ────────────────────────────────────────
// PipeRequest opens or closes a Rizin pipe for a page.
message PipeRequest {
string page_id = 1; // Required: page ID.
}
// PipeResponse reports pipe status.
message PipeResponse {
bool open = 1; // Whether the pipe is now open.
string error = 2; // Error message if operation failed.
}
// ─── Import / Export ────────────────────────────────────────
// ExportPageResponse returns the raw .rznb database file for a page.
message ExportPageResponse {
bytes data = 1; // The complete .rznb SQLite database file.
string filename = 2; // Suggested filename (e.g. "page-title.rznb").
}
// ImportPageResponse returns the imported page.
message ImportPageResponse {
Page page = 1; // The imported page.
}
// DownloadBinaryResponse returns the raw binary associated with a page.
message DownloadBinaryResponse {
bytes data = 1; // The raw binary file content.
string filename = 2; // Original filename (e.g. "crackme.exe").
string hash = 3; // SHA-256 hex digest of the binary.
}
// ─── Settings ───────────────────────────────────────────────
// SettingsResponse returns all environment variable settings.
message SettingsResponse {
map<string, string> environment = 1;
}
// SetSettingRequest sets an environment variable.
message SetSettingRequest {
string key = 1;
string value = 2;
}
// DeleteSettingRequest removes an environment variable.
message DeleteSettingRequest {
string key = 1;
}
// ─── Generic ────────────────────────────────────────────────
// ErrorResponse is returned on any API error.
message ErrorResponse {
int32 code = 1; // HTTP status code.
string message = 2; // Human-readable error description.
}
// SuccessResponse is a generic acknowledgement.
message SuccessResponse {
bool ok = 1;
string message = 2;
}
+190
View File
@@ -0,0 +1,190 @@
cmake_minimum_required(VERSION 3.16)
project(rizin-notebook-plugin C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
# ── Find Rizin ────────────────────────────────────────────────────────────
# Option A: Cutter SDK (provides Rizin libraries + headers).
# cmake -S . -B build -DCUTTER_SDK_DIR="C:/path/to/Cutter-v2.4.1-Windows-x86_64"
#
# Option B: Build-tree mode (in-tree meson build).
# cmake -S . -B build
# -DRIZIN_SOURCE_DIR="C:/path/to/rizin"
# -DRIZIN_BUILD_DIR="C:/path/to/rizin/build"
#
# Option C: Installed rizin — find_package(Rizin) just works.
set(CUTTER_SDK_DIR "" CACHE PATH "Path to Cutter SDK (contains lib/cmake/Rizin)")
set(RIZIN_SOURCE_DIR "" CACHE PATH "Path to rizin source tree")
set(RIZIN_BUILD_DIR "" CACHE PATH "Path to rizin build directory")
if(CUTTER_SDK_DIR)
# ── Cutter SDK mode ──
message(STATUS "Using Cutter SDK: ${CUTTER_SDK_DIR}")
set(CMAKE_PREFIX_PATH "${CUTTER_SDK_DIR}/lib/cmake/Rizin" ${CMAKE_PREFIX_PATH})
find_package(Rizin COMPONENTS Core REQUIRED)
set(RZ_LIBRARIES Rizin::Core)
get_target_property(RZ_INCLUDE_DIRS Rizin::Core INTERFACE_INCLUDE_DIRECTORIES)
elseif(RIZIN_SOURCE_DIR AND RIZIN_BUILD_DIR)
# ── Build-tree mode: use source/build directories directly ──
message(STATUS "Using rizin build tree:")
message(STATUS " Source: ${RIZIN_SOURCE_DIR}")
message(STATUS " Build: ${RIZIN_BUILD_DIR}")
set(RZ_INCLUDE_DIRS
"${RIZIN_SOURCE_DIR}/librz/include"
"${RIZIN_SOURCE_DIR}/librz/util/sdb/src"
"${RIZIN_SOURCE_DIR}/subprojects/rzheap"
"${RIZIN_BUILD_DIR}"
"${RIZIN_BUILD_DIR}/librz"
)
set(RZ_LIBRARY_DIRS
"${RIZIN_BUILD_DIR}/librz/core"
"${RIZIN_BUILD_DIR}/librz/util"
"${RIZIN_BUILD_DIR}/librz/config"
"${RIZIN_BUILD_DIR}/librz/cons"
"${RIZIN_BUILD_DIR}/librz/io"
"${RIZIN_BUILD_DIR}/librz/cmd"
)
find_library(RZ_CORE_LIB NAMES rz_core HINTS "${RIZIN_BUILD_DIR}/librz/core" REQUIRED)
find_library(RZ_UTIL_LIB NAMES rz_util HINTS "${RIZIN_BUILD_DIR}/librz/util" REQUIRED)
find_library(RZ_CONS_LIB NAMES rz_cons HINTS "${RIZIN_BUILD_DIR}/librz/cons" REQUIRED)
find_library(RZ_CONFIG_LIB NAMES rz_config HINTS "${RIZIN_BUILD_DIR}/librz/config" REQUIRED)
find_library(RZ_IO_LIB NAMES rz_io HINTS "${RIZIN_BUILD_DIR}/librz/io" REQUIRED)
set(RZ_LIBRARIES ${RZ_CORE_LIB} ${RZ_UTIL_LIB} ${RZ_CONS_LIB} ${RZ_CONFIG_LIB} ${RZ_IO_LIB})
message(STATUS " Libraries: ${RZ_LIBRARIES}")
else()
# ── Installed mode ──
find_package(Rizin COMPONENTS Core REQUIRED)
get_target_property(RZ_INCLUDE_DIRS Rizin::Core INTERFACE_INCLUDE_DIRECTORIES)
set(RZ_LIBRARIES Rizin::Core)
endif()
# ── Find protobuf-c ──────────────────────────────────────────────────────
# Either via CMake config (vcpkg), pkg-config, or manual path.
find_package(protobuf-c CONFIG QUIET)
if(protobuf-c_FOUND)
message(STATUS "Found protobuf-c via CMake config")
set(PROTOBUF_C_LIBS protobuf-c::protobuf-c)
get_target_property(PROTOBUF_C_INCLUDE_DIRS protobuf-c::protobuf-c INTERFACE_INCLUDE_DIRECTORIES)
else()
# Fallback: manual search.
find_library(PROTOBUF_C_LIB NAMES protobuf-c REQUIRED)
find_path(PROTOBUF_C_INC NAMES protobuf-c/protobuf-c.h REQUIRED)
set(PROTOBUF_C_LIBS ${PROTOBUF_C_LIB})
set(PROTOBUF_C_INCLUDE_DIRS ${PROTOBUF_C_INC})
message(STATUS "Found protobuf-c: ${PROTOBUF_C_LIB}")
endif()
# Resolve protobuf-c runtime DLL path for Windows packaging/install.
set(PROTOBUF_C_RUNTIME_DLL "")
if(WIN32)
if(TARGET protobuf-c::protobuf-c)
get_target_property(_pb_dll_rel protobuf-c::protobuf-c IMPORTED_LOCATION_RELEASE)
get_target_property(_pb_dll_dbg protobuf-c::protobuf-c IMPORTED_LOCATION_DEBUG)
get_target_property(_pb_dll_any protobuf-c::protobuf-c IMPORTED_LOCATION)
if(_pb_dll_rel)
set(PROTOBUF_C_RUNTIME_DLL "${_pb_dll_rel}")
elseif(_pb_dll_any)
set(PROTOBUF_C_RUNTIME_DLL "${_pb_dll_any}")
elseif(_pb_dll_dbg)
set(PROTOBUF_C_RUNTIME_DLL "${_pb_dll_dbg}")
endif()
endif()
# vcpkg fallback for common setup.
if(NOT PROTOBUF_C_RUNTIME_DLL AND EXISTS "C:/vcpkg/installed/x64-windows/bin/protobuf-c.dll")
set(PROTOBUF_C_RUNTIME_DLL "C:/vcpkg/installed/x64-windows/bin/protobuf-c.dll")
endif()
if(PROTOBUF_C_RUNTIME_DLL)
message(STATUS "protobuf-c runtime DLL: ${PROTOBUF_C_RUNTIME_DLL}")
else()
message(WARNING "protobuf-c runtime DLL path not found; rz_notebook.dll may fail to load unless protobuf-c.dll is in PATH")
endif()
endif()
# ── WinHTTP (Windows) or libcurl (Unix) ──────────────────────────────────
if(WIN32)
set(HTTP_LIBS winhttp)
else()
find_package(CURL REQUIRED)
set(HTTP_LIBS CURL::libcurl)
endif()
# ── Plugin shared library ─────────────────────────────────────────────────
add_library(rz_notebook MODULE
src/rz_notebook.c
src/nb_http.c
src/nb_http.h
src/nb_server.c
src/nb_server.h
src/notebook.pb-c.c
src/notebook.pb-c.h
)
target_link_libraries(rz_notebook PRIVATE
${RZ_LIBRARIES}
${PROTOBUF_C_LIBS}
${HTTP_LIBS}
)
target_include_directories(rz_notebook PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${PROTOBUF_C_INCLUDE_DIRS}
${RZ_INCLUDE_DIRS}
)
# On Windows with shared protobuf-c (DLL), PROTOBUF_C_USE_SHARED_LIB must be
# defined so the header provides a local static copy of protobuf_c_empty_string
# instead of referencing an unresolved extern symbol.
if(WIN32 AND NOT protobuf-c_FOUND)
# Manual find — assume shared
target_compile_definitions(rz_notebook PRIVATE PROTOBUF_C_USE_SHARED_LIB)
elseif(WIN32)
# vcpkg shared libs: check if DLL exists
if(EXISTS "${_VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/bin/protobuf-c.dll"
OR EXISTS "C:/vcpkg/installed/x64-windows/bin/protobuf-c.dll")
target_compile_definitions(rz_notebook PRIVATE PROTOBUF_C_USE_SHARED_LIB)
endif()
endif()
# Suppress warnings in generated protobuf-c code.
if(MSVC)
set_source_files_properties(src/notebook.pb-c.c PROPERTIES
COMPILE_FLAGS "/wd4146 /wd4267 /wd4244")
endif()
# ── Plugin install directory ─────────────────────────────────────────────
if(WIN32)
file(TO_CMAKE_PATH "$ENV{APPDATA}" _appdata)
set(RZ_INSTALL_PLUGDIR "${_appdata}/rizin/plugins"
CACHE STRING "Rizin plugin install directory")
else()
set(RZ_INSTALL_PLUGDIR "$ENV{HOME}/.local/lib/rizin/plugins"
CACHE STRING "Rizin plugin install directory")
endif()
install(TARGETS rz_notebook
LIBRARY DESTINATION "${RZ_INSTALL_PLUGDIR}"
RUNTIME DESTINATION "${RZ_INSTALL_PLUGDIR}"
)
if(WIN32 AND PROTOBUF_C_RUNTIME_DLL)
# Keep plugin self-contained at install destination.
install(FILES "${PROTOBUF_C_RUNTIME_DLL}" DESTINATION "${RZ_INSTALL_PLUGDIR}")
# Also place runtime next to build output DLL for manual copy workflows and CI artifacts.
add_custom_command(TARGET rz_notebook POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${PROTOBUF_C_RUNTIME_DLL}"
"$<TARGET_FILE_DIR:rz_notebook>/protobuf-c.dll"
VERBATIM
)
endif()
message(STATUS "Rizin plugin install dir: ${RZ_INSTALL_PLUGDIR}")
+133
View File
@@ -0,0 +1,133 @@
# rizin-notebook-plugin
Rizin core plugin that adds `NB` commands for interacting with the
[rizin-notebook](../README.md) server.
This plugin must be built against the same Rizin version as the host that loads it.
That means standalone rizin and Cutter may need different `rz_notebook` builds if they
ship different Rizin versions. Both can still use the same notebook server and data.
## Commands
```
NB list pages (default action)
NBc <url> connect to a running notebook server
NBs spawn (auto-start) the notebook server
NBi show server status and session info
NBl list all pages
NBn [title] create a new page; auto-attaches the currently open binary
NBo <page-id> open/select a page as the active page;
if no binary is loaded, downloads the page binary from
the server and opens it in rizin automatically
NBq close/detach the active page
NBp [page-id] show page details (active page if no id given)
NBd <page-id> delete a page
NBx <command> execute a command locally and record it to the active page
NBxs <script> execute a JavaScript script on the server for the active page
NBe [path] export the active page as a .rznb file
NBm <path> import a .rznb file as a new page
NBu [url] show or set the server URL
```
### Typical workflow
```
[rizin /path/to/binary]
NBs -- start the server if not running
NBn "My Analysis" -- create a page; the binary is attached automatically
NBx aaa -- run 'aaa', output is recorded as a command cell
NBx pdf @ main -- same
NBe mywork.rznb -- export for sharing or backup
```
### Binary auto-download
When you run `NBo <page-id>` and no binary is loaded in rizin, the plugin checks whether
the page has an attached binary. If it does, it downloads the binary from the server, saves
it to a temporary file, and opens it in rizin automatically.
This removes the need to locate and open the binary file manually before working on a page.
## Dependencies
- Rizin headers (from a Cutter SDK, rizin source tree, or installed rizin)
- protobuf-c (for serializing API messages)
- WinHTTP (Windows, built-in) or libcurl (Linux/macOS)
- OpenSSL (Linux/macOS, for SHA-256; Windows uses WinCrypt)
## Build
### 1. Generate protobuf-c files
Run from the repo root. Requires `protoc` and `protoc-gen-c` on your PATH.
```bash
protoc --plugin=protoc-gen-c=/path/to/protoc-gen-c \
--c_out=rizin-notebook-plugin/src \
proto/notebook.proto
```
This generates `notebook.pb-c.c` and `notebook.pb-c.h` in
`rizin-notebook-plugin/src/proto/`. Copy them up one directory to
`rizin-notebook-plugin/src/` before building.
### 2. Configure and build
**Windows (Cutter SDK + vcpkg):**
```powershell
cmake -S rizin-notebook-plugin -B rizin-notebook-plugin/build ^
-DCUTTER_SDK_DIR="C:\path\to\Cutter-v2.4.1-Windows-x86_64" ^
-DCMAKE_TOOLCHAIN_FILE="C:\vcpkg\scripts\buildsystems\vcpkg.cmake"
cmake --build rizin-notebook-plugin/build --config Release
```
**Windows (rizin source tree + vcpkg):**
```powershell
cmake -S rizin-notebook-plugin -B rizin-notebook-plugin/build ^
-DRIZIN_SOURCE_DIR="C:\path\to\rizin" ^
-DRIZIN_BUILD_DIR="C:\path\to\rizin\build" ^
-DCMAKE_TOOLCHAIN_FILE="C:\vcpkg\scripts\buildsystems\vcpkg.cmake"
cmake --build rizin-notebook-plugin/build --config Release
```
**Linux:**
```bash
sudo apt install protobuf-c-compiler libprotobuf-c-dev librizin-dev \
libcurl4-openssl-dev libssl-dev
cmake -S rizin-notebook-plugin -B rizin-notebook-plugin/build
cmake --build rizin-notebook-plugin/build --config Release
```
### 3. Install
```bash
cmake --install rizin-notebook-plugin/build --config Release
```
Or copy `rz_notebook.dll` / `rz_notebook.so` manually to the rizin plugin directory:
- Windows: `%APPDATA%\rizin\plugins`
- Linux: `~/.local/lib/rizin/plugins`
Run `rizin -H RZ_USER_PLUGINS` to confirm the correct path.
If you use Cutter with a bundled Rizin, also copy the Cutter-compatible build into the
Rizin plugin directory inside the Cutter installation.
## Notes
- The plugin does **not** auto-connect on load. You must run `NBc` or `NBs` first.
- `NBx` runs the command in the current rizin session and records both command and output.
It requires a binary to be open. If you used `NBo` and the binary was auto-downloaded,
it is already open and `NBx` works immediately.
- `NBxs` runs JavaScript on the server side via a rizin pipe. The pipe must be open
(opened automatically by the server when you open a page with a binary).
- `NBm` auto-selects the imported page as the active page.
- `NBe` uses the page title as the filename if no path is given.
+365
View File
@@ -0,0 +1,365 @@
#include "nb_http.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
// ── Configuration ───────────────────────────────────────────────────────
static char g_base_url[512] = "http://127.0.0.1:8000";
void nb_http_set_base_url(const char *base_url) {
if (!base_url) return;
snprintf(g_base_url, sizeof(g_base_url), "%s", base_url);
// Strip trailing slash.
size_t len = strlen(g_base_url);
while (len > 0 && g_base_url[len - 1] == '/') {
g_base_url[--len] = '\0';
}
}
const char *nb_http_get_base_url(void) {
return g_base_url;
}
void nb_http_response_free(NbHttpResponse *resp) {
if (!resp) return;
free(resp->body);
free(resp->error);
resp->body = NULL;
resp->error = NULL;
}
// ═══════════════════════════════════════════════════════════════════════
// Windows implementation using WinHTTP
// ═══════════════════════════════════════════════════════════════════════
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <winhttp.h>
#pragma comment(lib, "winhttp.lib")
static HINTERNET g_session = NULL;
bool nb_http_init(void) {
if (g_session) return true;
g_session = WinHttpOpen(L"rizin-notebook/1.0",
WINHTTP_ACCESS_TYPE_NO_PROXY,
WINHTTP_NO_PROXY_NAME,
WINHTTP_NO_PROXY_BYPASS, 0);
if (!g_session) return false;
// Set timeouts: 5s connect, 10s send/recv.
WinHttpSetTimeouts(g_session, 5000, 5000, 10000, 10000);
return true;
}
void nb_http_fini(void) {
if (g_session) {
WinHttpCloseHandle(g_session);
g_session = NULL;
}
}
static wchar_t *build_url_wide(const char *path) {
char full[1024];
snprintf(full, sizeof(full), "%s%s", g_base_url, path);
int wlen = MultiByteToWideChar(CP_UTF8, 0, full, -1, NULL, 0);
if (wlen <= 0) return NULL;
wchar_t *wurl = malloc(wlen * sizeof(wchar_t));
if (!wurl) return NULL;
MultiByteToWideChar(CP_UTF8, 0, full, -1, wurl, wlen);
return wurl;
}
static bool winhttp_request(const char *method, const char *path,
const uint8_t *body, size_t body_len,
NbHttpResponse *resp) {
memset(resp, 0, sizeof(*resp));
if (!g_session) {
resp->error = _strdup("HTTP subsystem not initialized");
return false;
}
wchar_t *url_w = build_url_wide(path);
if (!url_w) {
resp->error = _strdup("Failed to build URL");
return false;
}
// Crack URL.
URL_COMPONENTS uc;
memset(&uc, 0, sizeof(uc));
uc.dwStructSize = sizeof(uc);
uc.dwSchemeLength = (DWORD)-1;
uc.dwHostNameLength = (DWORD)-1;
uc.dwUrlPathLength = (DWORD)-1;
uc.dwExtraInfoLength = (DWORD)-1;
if (!WinHttpCrackUrl(url_w, 0, 0, &uc)) {
resp->error = _strdup("Failed to parse URL");
free(url_w);
return false;
}
// Extract host.
wchar_t host[256] = {0};
if (uc.dwHostNameLength > 0 && uc.dwHostNameLength < 255) {
wcsncpy(host, uc.lpszHostName, uc.dwHostNameLength);
host[uc.dwHostNameLength] = L'\0';
}
// Extract path (includes query string).
wchar_t req_path[512] = {0};
if (uc.dwUrlPathLength > 0) {
size_t plen = uc.dwUrlPathLength + uc.dwExtraInfoLength;
if (plen < 511) {
wcsncpy(req_path, uc.lpszUrlPath, plen);
req_path[plen] = L'\0';
}
}
// Convert method to wide.
wchar_t method_w[16] = {0};
MultiByteToWideChar(CP_UTF8, 0, method, -1, method_w, 16);
HINTERNET conn = WinHttpConnect(g_session, host, uc.nPort, 0);
if (!conn) {
resp->error = _strdup("WinHttpConnect failed");
free(url_w);
return false;
}
HINTERNET req = WinHttpOpenRequest(conn, method_w, req_path,
NULL, WINHTTP_NO_REFERER,
WINHTTP_DEFAULT_ACCEPT_TYPES, 0);
if (!req) {
resp->error = _strdup("WinHttpOpenRequest failed");
WinHttpCloseHandle(conn);
free(url_w);
return false;
}
// Set content type header for protobuf.
if (body && body_len > 0) {
WinHttpAddRequestHeaders(req,
L"Content-Type: application/x-protobuf\r\n",
(DWORD)-1, WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE);
}
WinHttpAddRequestHeaders(req,
L"Accept: application/x-protobuf\r\n",
(DWORD)-1, WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE);
// Send.
BOOL ok = WinHttpSendRequest(req,
WINHTTP_NO_ADDITIONAL_HEADERS, 0,
(LPVOID)body, (DWORD)body_len,
(DWORD)body_len, 0);
if (!ok) {
DWORD err = GetLastError();
char msg[128];
snprintf(msg, sizeof(msg), "WinHttpSendRequest failed: %lu", err);
resp->error = _strdup(msg);
WinHttpCloseHandle(req);
WinHttpCloseHandle(conn);
free(url_w);
return false;
}
ok = WinHttpReceiveResponse(req, NULL);
if (!ok) {
DWORD err = GetLastError();
char msg[128];
snprintf(msg, sizeof(msg), "WinHttpReceiveResponse failed: %lu", err);
resp->error = _strdup(msg);
WinHttpCloseHandle(req);
WinHttpCloseHandle(conn);
free(url_w);
return false;
}
// Get status code.
DWORD status_code = 0;
DWORD sz = sizeof(status_code);
WinHttpQueryHeaders(req, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
WINHTTP_HEADER_NAME_BY_INDEX, &status_code, &sz,
WINHTTP_NO_HEADER_INDEX);
resp->status = (int)status_code;
// Read body.
size_t total = 0;
size_t cap = 4096;
uint8_t *buf = malloc(cap);
if (!buf) {
resp->error = _strdup("Out of memory");
WinHttpCloseHandle(req);
WinHttpCloseHandle(conn);
free(url_w);
return false;
}
for (;;) {
DWORD available = 0;
if (!WinHttpQueryDataAvailable(req, &available) || available == 0) break;
if (total + available > cap) {
while (cap < total + available) cap *= 2;
uint8_t *nb = realloc(buf, cap);
if (!nb) break;
buf = nb;
}
DWORD read = 0;
if (!WinHttpReadData(req, buf + total, available, &read)) break;
total += read;
}
resp->body = buf;
resp->body_len = total;
resp->ok = true;
WinHttpCloseHandle(req);
WinHttpCloseHandle(conn);
free(url_w);
return true;
}
bool nb_http_get(const char *path, NbHttpResponse *resp) {
return winhttp_request("GET", path, NULL, 0, resp);
}
bool nb_http_post(const char *path, const uint8_t *body, size_t body_len,
NbHttpResponse *resp) {
return winhttp_request("POST", path, body, body_len, resp);
}
bool nb_http_put(const char *path, const uint8_t *body, size_t body_len,
NbHttpResponse *resp) {
return winhttp_request("PUT", path, body, body_len, resp);
}
bool nb_http_delete(const char *path, NbHttpResponse *resp) {
return winhttp_request("DELETE", path, NULL, 0, resp);
}
#else
// ═══════════════════════════════════════════════════════════════════════
// Non-Windows implementation using libcurl
// ═══════════════════════════════════════════════════════════════════════
#include <curl/curl.h>
static CURL *g_curl = NULL;
bool nb_http_init(void) {
curl_global_init(CURL_GLOBAL_DEFAULT);
g_curl = curl_easy_init();
return g_curl != NULL;
}
void nb_http_fini(void) {
if (g_curl) {
curl_easy_cleanup(g_curl);
g_curl = NULL;
}
curl_global_cleanup();
}
struct curl_buf {
uint8_t *data;
size_t len;
size_t cap;
};
static size_t curl_write_cb(void *ptr, size_t size, size_t nmemb, void *userdata) {
struct curl_buf *cb = (struct curl_buf *)userdata;
size_t total = size * nmemb;
if (cb->len + total > cb->cap) {
while (cb->cap < cb->len + total) cb->cap *= 2;
uint8_t *nb = realloc(cb->data, cb->cap);
if (!nb) return 0;
cb->data = nb;
}
memcpy(cb->data + cb->len, ptr, total);
cb->len += total;
return total;
}
static bool curl_request(const char *method, const char *path,
const uint8_t *body, size_t body_len,
NbHttpResponse *resp) {
memset(resp, 0, sizeof(*resp));
if (!g_curl) {
resp->error = strdup("HTTP subsystem not initialized");
return false;
}
char url[1024];
snprintf(url, sizeof(url), "%s%s", g_base_url, path);
CURL *c = g_curl;
curl_easy_reset(c);
struct curl_buf cb = { .data = malloc(4096), .len = 0, .cap = 4096 };
if (!cb.data) {
resp->error = strdup("Out of memory");
return false;
}
curl_easy_setopt(c, CURLOPT_URL, url);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, curl_write_cb);
curl_easy_setopt(c, CURLOPT_WRITEDATA, &cb);
curl_easy_setopt(c, CURLOPT_CONNECTTIMEOUT, 5L);
curl_easy_setopt(c, CURLOPT_TIMEOUT, 10L);
curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, method);
struct curl_slist *headers = NULL;
headers = curl_slist_append(headers, "Accept: application/x-protobuf");
if (body && body_len > 0) {
headers = curl_slist_append(headers, "Content-Type: application/x-protobuf");
curl_easy_setopt(c, CURLOPT_POSTFIELDS, body);
curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)body_len);
}
curl_easy_setopt(c, CURLOPT_HTTPHEADER, headers);
CURLcode res = curl_easy_perform(c);
curl_slist_free_all(headers);
if (res != CURLE_OK) {
resp->error = strdup(curl_easy_strerror(res));
free(cb.data);
return false;
}
long code = 0;
curl_easy_getinfo(c, CURLINFO_RESPONSE_CODE, &code);
resp->status = (int)code;
resp->body = cb.data;
resp->body_len = cb.len;
resp->ok = true;
return true;
}
bool nb_http_get(const char *path, NbHttpResponse *resp) {
return curl_request("GET", path, NULL, 0, resp);
}
bool nb_http_post(const char *path, const uint8_t *body, size_t body_len,
NbHttpResponse *resp) {
return curl_request("POST", path, body, body_len, resp);
}
bool nb_http_put(const char *path, const uint8_t *body, size_t body_len,
NbHttpResponse *resp) {
return curl_request("PUT", path, body, body_len, resp);
}
bool nb_http_delete(const char *path, NbHttpResponse *resp) {
return curl_request("DELETE", path, NULL, 0, resp);
}
#endif
+47
View File
@@ -0,0 +1,47 @@
// HTTP client for communicating with the notebook server.
// Uses WinHTTP on Windows, libcurl on other platforms.
#ifndef NB_HTTP_H
#define NB_HTTP_H
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
int status;
uint8_t *body;
size_t body_len;
bool ok;
char *error;
} NbHttpResponse;
void nb_http_response_free(NbHttpResponse *resp);
bool nb_http_init(void);
void nb_http_fini(void);
void nb_http_set_base_url(const char *base_url);
const char *nb_http_get_base_url(void);
bool nb_http_get(const char *path, NbHttpResponse *resp);
bool nb_http_post(const char *path, const uint8_t *body, size_t body_len,
NbHttpResponse *resp);
bool nb_http_put(const char *path, const uint8_t *body, size_t body_len,
NbHttpResponse *resp);
bool nb_http_delete(const char *path, NbHttpResponse *resp);
#ifdef __cplusplus
}
#endif
#endif // NB_HTTP_H
+264
View File
@@ -0,0 +1,264 @@
#include "nb_server.h"
#include "nb_http.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#ifdef _WIN32
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <shlobj.h>
#else
#include <unistd.h>
#include <limits.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <errno.h>
#endif
#ifndef _WIN32
#ifndef PATH_MAX
#define PATH_MAX 4096
#endif
#endif
// ── Health Check ────────────────────────────────────────────────────────
bool nb_server_is_alive(void) {
NbHttpResponse resp;
bool ok = nb_http_get("/api/v1/ping", &resp);
bool alive = ok && resp.ok && (resp.status == 200 || resp.status == 204);
nb_http_response_free(&resp);
return alive;
}
// ── Executable Discovery ────────────────────────────────────────────────
#ifdef _WIN32
static const char *NB_EXE_NAME = "rizin-notebook.exe";
#else
static const char *NB_EXE_NAME = "rizin-notebook";
#endif
static bool file_exists(const char *path) {
#ifdef _WIN32
DWORD attr = GetFileAttributesA(path);
return attr != INVALID_FILE_ATTRIBUTES && !(attr & FILE_ATTRIBUTE_DIRECTORY);
#else
struct stat st;
return stat(path, &st) == 0 && S_ISREG(st.st_mode);
#endif
}
char *nb_server_find_executable(void) {
#ifdef _WIN32
char buf[MAX_PATH + 1];
#else
char buf[PATH_MAX + 1];
#endif
#ifdef _WIN32
// 1. Next to the plugin DLL (same directory as rz_notebook.dll).
{
HMODULE hMod = NULL;
// Try to get our own module handle.
if (GetModuleHandleExA(
GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
(LPCSTR)nb_server_find_executable, &hMod)) {
if (GetModuleFileNameA(hMod, buf, MAX_PATH)) {
char *sep = strrchr(buf, '\\');
if (sep) {
sep[1] = '\0';
strncat(buf, NB_EXE_NAME, MAX_PATH - strlen(buf));
if (file_exists(buf)) return _strdup(buf);
}
}
}
}
// 2. In PATH.
{
DWORD r = SearchPathA(NULL, NB_EXE_NAME, NULL, MAX_PATH, buf, NULL);
if (r > 0 && r < MAX_PATH && file_exists(buf)) return _strdup(buf);
}
// 3. Common locations.
{
const char *appdata = getenv("LOCALAPPDATA");
if (appdata) {
snprintf(buf, sizeof(buf), "%s\\rizin-notebook\\%s", appdata, NB_EXE_NAME);
if (file_exists(buf)) return _strdup(buf);
}
}
{
const char *progfiles = getenv("PROGRAMFILES");
if (progfiles) {
snprintf(buf, sizeof(buf), "%s\\rizin-notebook\\%s", progfiles, NB_EXE_NAME);
if (file_exists(buf)) return _strdup(buf);
}
}
#else // POSIX
// 1. In PATH (use which-like logic).
{
const char *path_env = getenv("PATH");
if (path_env) {
char *path_copy = strdup(path_env);
char *saveptr = NULL;
char *dir = strtok_r(path_copy, ":", &saveptr);
while (dir) {
snprintf(buf, sizeof(buf), "%s/%s", dir, NB_EXE_NAME);
if (file_exists(buf)) {
char *result = strdup(buf);
free(path_copy);
return result;
}
dir = strtok_r(NULL, ":", &saveptr);
}
free(path_copy);
}
}
// 2. Common locations.
{
const char *home = getenv("HOME");
if (home) {
snprintf(buf, sizeof(buf), "%s/.local/bin/%s", home, NB_EXE_NAME);
if (file_exists(buf)) return strdup(buf);
snprintf(buf, sizeof(buf), "%s/go/bin/%s", home, NB_EXE_NAME);
if (file_exists(buf)) return strdup(buf);
}
}
{
snprintf(buf, sizeof(buf), "/usr/local/bin/%s", NB_EXE_NAME);
if (file_exists(buf)) return strdup(buf);
}
#endif
return NULL;
}
// ── Server Start ────────────────────────────────────────────────────────
#ifdef _WIN32
static HANDLE g_server_process = NULL;
static bool start_server_process(const char *exe_path) {
STARTUPINFOA si;
PROCESS_INFORMATION pi;
memset(&si, 0, sizeof(si));
si.cb = sizeof(si);
si.dwFlags = STARTF_USESHOWWINDOW;
si.wShowWindow = SW_HIDE;
memset(&pi, 0, sizeof(pi));
char cmdline[MAX_PATH + 32];
snprintf(cmdline, sizeof(cmdline), "\"%s\"", exe_path);
if (!CreateProcessA(NULL, cmdline, NULL, NULL, FALSE,
CREATE_NO_WINDOW | DETACHED_PROCESS,
NULL, NULL, &si, &pi)) {
return false;
}
g_server_process = pi.hProcess;
CloseHandle(pi.hThread);
return true;
}
#else
static pid_t g_server_pid = 0;
static bool start_server_process(const char *exe_path) {
pid_t pid = fork();
if (pid < 0) return false;
if (pid == 0) {
// Child process: become daemon.
setsid();
// Redirect stdout/stderr to /dev/null.
FILE *out = freopen("/dev/null", "w", stdout);
FILE *err = freopen("/dev/null", "w", stderr);
if (!out || !err) {
_exit(127);
}
execl(exe_path, exe_path, (char *)NULL);
_exit(127);
}
g_server_pid = pid;
return true;
}
#endif
bool nb_server_wait_alive(int timeout_ms) {
int elapsed = 0;
int interval = 200; // ms
while (elapsed < timeout_ms) {
if (nb_server_is_alive()) return true;
#ifdef _WIN32
Sleep(interval);
#else
usleep(interval * 1000);
#endif
elapsed += interval;
}
return false;
}
bool nb_server_ensure(const char *exe_path) {
// Already running?
if (nb_server_is_alive()) return true;
// Find executable.
char *found = NULL;
if (exe_path && file_exists(exe_path)) {
found = NULL; // Use the provided path directly.
} else {
found = nb_server_find_executable();
if (!found) {
fprintf(stderr, "[notebook] cannot find rizin-notebook executable "
"(searched next to plugin DLL, PATH, common locations)\n");
return false;
}
exe_path = found;
}
fprintf(stderr, "[notebook] starting server: %s\n", exe_path);
// Start it.
bool ok = start_server_process(exe_path);
if (!ok) {
fprintf(stderr, "[notebook] CreateProcess/fork failed for: %s\n", exe_path);
free(found);
return false;
}
free(found);
// Wait up to 10 seconds for it to become reachable.
if (!nb_server_wait_alive(10000)) {
fprintf(stderr, "[notebook] server process started but did not become "
"reachable within 10 seconds (port 8000 conflict?)\n");
return false;
}
return true;
}
void nb_server_stop(void) {
// The notebook server runs independently; we don't kill it.
// It can be stopped by the user or will exit on its own.
#ifdef _WIN32
if (g_server_process) {
CloseHandle(g_server_process);
g_server_process = NULL;
}
#else
g_server_pid = 0;
#endif
}
+26
View File
@@ -0,0 +1,26 @@
// Server auto-start and lifecycle management.
#ifndef NB_SERVER_H
#define NB_SERVER_H
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
bool nb_server_is_alive(void);
bool nb_server_ensure(const char *exe_path);
char *nb_server_find_executable(void);
bool nb_server_wait_alive(int timeout_ms);
void nb_server_stop(void);
#ifdef __cplusplus
}
#endif
#endif // NB_SERVER_H
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+112 -56
View File
@@ -4,22 +4,30 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/dop251/goja"
"golang.org/x/sync/semaphore"
"time"
)
// scriptTimeout is the maximum execution time for a single script.
const scriptTimeout = 5 * time.Minute
type JavaScript struct {
semaphore *semaphore.Weighted
runtime *goja.Runtime
rizin *Rizin
output string
output strings.Builder
}
func rizinCmd(command string, rizin *Rizin) (string, error) {
rizin.exec("scr.color=0")
result, err := rizin.exec(command)
rizin.exec("scr.color=3")
func rizinCmd(command string, rz *Rizin) (string, error) {
if _, err := rz.exec("e scr.color=0"); err != nil {
return "", err
}
result, err := rz.exec(command)
// Restore color regardless of command success.
rz.exec("e scr.color=3")
return result, err
}
@@ -39,83 +47,131 @@ func convertValue(ivalue interface{}) string {
func NewJavaScript() *JavaScript {
runtime := goja.New()
if runtime == nil {
fmt.Println("cannot create a JavaScript runtime.")
fmt.Println("error: cannot create JavaScript runtime")
return nil
}
sem := semaphore.NewWeighted(1)
js := &JavaScript{semaphore: sem, runtime: runtime, rizin: nil}
rizin := map[string]interface{}{}
rizin["cmd"] = func(args ...interface{}) goja.Value {
// Register the rizin API object.
rizinAPI := map[string]interface{}{}
rizinAPI["cmd"] = func(args ...interface{}) goja.Value {
if js.rizin == nil {
panic(js.runtime.ToValue("Rizin pipe is closed."))
} else if len(args) < 1 {
}
if len(args) < 1 {
panic(js.runtime.ToValue("No string was passed."))
}
switch value := args[0].(type) {
case string:
result, err := rizinCmd(value, js.rizin)
if err != nil {
panic(js.runtime.ToValue(err))
}
return js.runtime.ToValue(result)
default:
cmdStr, ok := args[0].(string)
if !ok {
panic(js.runtime.ToValue("input is not a string."))
}
}
rizin["cmdj"] = func(args ...interface{}) goja.Value {
if js.rizin == nil {
panic(js.runtime.ToValue("Rizin pipe is closed."))
} else if len(args) < 1 {
panic(js.runtime.ToValue("No string was passed."))
}
switch value := args[0].(type) {
case string:
result, err := rizinCmd(value, js.rizin)
if err != nil {
panic(js.runtime.ToValue(err))
}
var data interface{}
err = json.Unmarshal([]byte(result), &data)
if err != nil {
panic(js.runtime.ToValue(err))
}
return js.runtime.ToValue(data)
default:
panic(js.runtime.ToValue("input is not a string."))
result, err := rizinCmd(cmdStr, js.rizin)
if err != nil {
panic(js.runtime.ToValue(err.Error()))
}
return js.runtime.ToValue(result)
}
console := map[string]interface{}{}
console["log"] = func(args ...interface{}) {
n_args := len(args)
rizinAPI["cmdj"] = func(args ...interface{}) goja.Value {
if js.rizin == nil {
panic(js.runtime.ToValue("Rizin pipe is closed."))
}
if len(args) < 1 {
panic(js.runtime.ToValue("No string was passed."))
}
cmdStr, ok := args[0].(string)
if !ok {
panic(js.runtime.ToValue("input is not a string."))
}
result, err := rizinCmd(cmdStr, js.rizin)
if err != nil {
panic(js.runtime.ToValue(err.Error()))
}
var data interface{}
if jsonErr := json.Unmarshal([]byte(result), &data); jsonErr != nil {
panic(js.runtime.ToValue(jsonErr.Error()))
}
return js.runtime.ToValue(data)
}
// Register the console API object.
consoleAPI := map[string]interface{}{}
// writeArgs writes all arguments to js.output separated by spaces.
writeArgs := func(prefix string, args []interface{}) {
if len(prefix) > 0 {
js.output.WriteString(prefix)
js.output.WriteString(" ")
}
for i, value := range args {
js.output += convertValue(value)
if i+1 < n_args {
js.output += " "
js.output.WriteString(convertValue(value))
if i+1 < len(args) {
js.output.WriteString(" ")
}
}
js.output += "\n"
js.output.WriteString("\n")
}
runtime.Set("rizin", rizin)
runtime.Set("console", console)
consoleAPI["log"] = func(args ...interface{}) {
writeArgs("", args)
}
consoleAPI["warn"] = func(args ...interface{}) {
writeArgs("[WARN]", args)
}
consoleAPI["error"] = func(args ...interface{}) {
writeArgs("[ERROR]", args)
}
consoleAPI["info"] = func(args ...interface{}) {
writeArgs("[INFO]", args)
}
consoleAPI["clear"] = func() {
js.output.Reset()
}
consoleAPI["table"] = func(args ...interface{}) {
if len(args) < 1 {
return
}
bytes, err := json.MarshalIndent(args[0], "", " ")
if err != nil {
js.output.WriteString(fmt.Sprintf("[TABLE] %v\n", args[0]))
return
}
js.output.WriteString(string(bytes))
js.output.WriteString("\n")
}
runtime.Set("rizin", rizinAPI)
runtime.Set("console", consoleAPI)
return js
}
func (js *JavaScript) exec(script string, rizin *Rizin) (string, error) {
func (js *JavaScript) exec(script string, rz *Rizin) (string, error) {
if !js.semaphore.TryAcquire(1) {
return "", errors.New("A script is already running.")
return "", errors.New("a script is already running")
}
defer js.semaphore.Release(1)
js.rizin = rizin
js.output = ""
timer := time.AfterFunc(5*time.Minute, func() {
js.rizin = rz
js.output.Reset()
timer := time.AfterFunc(scriptTimeout, func() {
js.runtime.Interrupt("The script execution has timed out.")
})
defer timer.Stop()
_, err := js.runtime.RunScript("script.js", script)
result := js.output
result := js.output.String()
// Clean up state for next execution.
js.rizin = nil
js.output = ""
timer.Stop()
js.output.Reset()
return result, err
}
+167
View File
@@ -0,0 +1,167 @@
package main
import (
"encoding/json"
"html/template"
"strconv"
"strings"
)
const (
notebookSearchSettingsPrefix = "ns:"
defaultSearchMaxResults = 200
minSearchMaxResults = 10
maxSearchMaxResults = 500
)
type notebookSearchConfig struct {
MaxResults int `json:"max_results"`
DefaultMode NotebookSearchMode `json:"default_mode"`
Surface NotebookSearchSurfaceFilter `json:"default_surface"`
CellType NotebookSearchCellTypeFilter `json:"default_cell_type"`
}
var notebookSearchSettingOrder = []string{
"max_results",
"default_mode",
"default_surface",
"default_cell_type",
}
var notebookSearchSettingLabels = map[string]string{
"max_results": "Max Results",
"default_mode": "Default Mode",
"default_surface": "Default Surface",
"default_cell_type": "Default Cell Type",
}
var notebookSearchSettingDefaults = map[string]string{
"max_results": strconv.Itoa(defaultSearchMaxResults),
"default_mode": string(NotebookSearchModeLiteral),
"default_surface": string(NotebookSearchSurfaceFilterAll),
"default_cell_type": string(NotebookSearchCellTypeFilterAll),
}
var notebookSearchSettingKinds = map[string]string{
"max_results": "number",
"default_mode": "select",
"default_surface": "select",
"default_cell_type": "select",
}
var notebookSearchSettingChoices = map[string][]map[string]string{
"default_mode": {
{"value": string(NotebookSearchModeLiteral), "label": "Literal"},
{"value": string(NotebookSearchModeRegex), "label": "Regex"},
{"value": string(NotebookSearchModeFuzzy), "label": "Fuzzy"},
},
"default_surface": {
{"value": string(NotebookSearchSurfaceFilterAll), "label": "All Surfaces"},
{"value": string(NotebookSearchSurfaceFilterContent), "label": "Input / Source"},
{"value": string(NotebookSearchSurfaceFilterOutput), "label": "Outputs"},
},
"default_cell_type": {
{"value": string(NotebookSearchCellTypeFilterAll), "label": "All Cell Types"},
{"value": string(NotebookSearchCellTypeFilterCommand), "label": "Command Cells"},
{"value": string(NotebookSearchCellTypeFilterScript), "label": "Script Cells"},
{"value": string(NotebookSearchCellTypeFilterMarkdown), "label": "Markdown Cells"},
},
}
func getNotebookSearchConfig() notebookSearchConfig {
cfg := notebookSearchConfig{
MaxResults: defaultSearchMaxResults,
DefaultMode: NotebookSearchModeLiteral,
Surface: NotebookSearchSurfaceFilterAll,
CellType: NotebookSearchCellTypeFilterAll,
}
if catalog == nil {
return cfg
}
settings, _ := catalog.GetAllSettings()
if settings == nil {
return cfg
}
for key, value := range settings {
if !strings.HasPrefix(key, notebookSearchSettingsPrefix) {
continue
}
settingKey := strings.TrimPrefix(key, notebookSearchSettingsPrefix)
switch settingKey {
case "max_results":
val, err := strconv.Atoi(value)
if err == nil {
cfg.MaxResults = clampInt(val, minSearchMaxResults, maxSearchMaxResults)
}
case "default_mode":
mode, err := normalizeNotebookSearchMode(NotebookSearchMode(value))
if err == nil {
cfg.DefaultMode = mode
}
case "default_surface":
surface, err := normalizeNotebookSearchSurfaceFilter(NotebookSearchSurfaceFilter(value))
if err == nil {
cfg.Surface = surface
}
case "default_cell_type":
cellType, err := normalizeNotebookSearchCellTypeFilter(NotebookSearchCellTypeFilter(value))
if err == nil {
cfg.CellType = cellType
}
}
}
return cfg
}
func getNotebookSearchConfigJSON() template.JS {
cfg := getNotebookSearchConfig()
bytes, err := json.Marshal(cfg)
if err != nil {
return template.JS("{}")
}
return template.JS(bytes)
}
func notebookSearchActions() []map[string]string {
cfg := getNotebookSearchConfig()
actions := make([]map[string]string, 0, len(notebookSearchSettingOrder))
for _, key := range notebookSearchSettingOrder {
value := notebookSearchSettingDefaults[key]
switch key {
case "max_results":
value = strconv.Itoa(cfg.MaxResults)
case "default_mode":
value = string(cfg.DefaultMode)
case "default_surface":
value = string(cfg.Surface)
case "default_cell_type":
value = string(cfg.CellType)
}
actions = append(actions, map[string]string{
"name": key,
"label": notebookSearchSettingLabels[key],
"value": value,
"value_display": notebookSearchSettingValueLabel(key, value),
"default_value": notebookSearchSettingDefaults[key],
"default_display": notebookSearchSettingValueLabel(key, notebookSearchSettingDefaults[key]),
})
}
return actions
}
func notebookSearchSettingValueLabel(key, value string) string {
choices, ok := notebookSearchSettingChoices[key]
if !ok {
return value
}
for _, choice := range choices {
if choice["value"] == value {
return choice["label"]
}
}
return value
}
+28 -11
View File
@@ -1,22 +1,20 @@
package main
import (
"fmt"
"github.com/gin-gonic/gin"
)
func sanitizeWebRoot(path string) string {
if len(path) < 2 {
return "/"
} else if len(path) > 1 && path[len(path)-1] != '/' {
}
if path[len(path)-1] != '/' {
return path + "/"
}
return path
}
func runServer(assets, bind string, debug bool) {
var root *gin.RouterGroup
func setupRouter(assets, bind string, debug bool) *gin.Engine {
gin.DisableConsoleColor()
if debug {
gin.SetMode(gin.DebugMode)
@@ -28,21 +26,38 @@ func runServer(assets, bind string, debug bool) {
static, templates := setupTemplate(assets, router)
root = router.Group(sanitizeWebRoot(webroot))
root := router.Group(sanitizeWebRoot(webroot))
serverAddAssets(root, assets, static, templates)
// Index page: list all notebook pages
root.GET("/", func(c *gin.Context) {
pages, err := catalog.ListPages()
if err != nil {
c.HTML(500, "error.tmpl", gin.H{"root": webroot, "error": err.Error()})
return
}
list := make([]gin.H, len(pages))
for i, p := range pages {
notebook.mutex.Lock()
pipeOpen := notebook.pipes[p.ID] != nil
notebook.mutex.Unlock()
list[i] = gin.H{
"title": p.Title,
"unique": p.ID,
"pipe": pipeOpen,
}
}
c.HTML(200, "index.tmpl", gin.H{
"root": webroot,
"list": notebook.list(),
"list": list,
})
})
serverAddAbout(root)
serverAddSettings(root)
serverAddPage(root)
pipe := root.Group("/pipe")
@@ -54,6 +69,8 @@ func runServer(assets, bind string, debug bool) {
output := root.Group("/output")
serverAddOutput(output)
fmt.Printf("Server listening at http://%s\n", bind)
router.Run(bind)
// Protobuf REST API.
serverAddAPI(root)
return router
}
+15 -16
View File
@@ -1,37 +1,36 @@
package main
import (
"os"
"os/exec"
"github.com/gin-gonic/gin"
)
func serverAddAbout(root *gin.RouterGroup) {
root.GET("/about", func(c *gin.Context) {
var rzversion, rzbuild string
info, err := notebook.info()
rzpath := notebook.rizin
if err != nil {
rzversion = err.Error()
rzbuild = rzversion
if rzpath == "rizin" {
rzpath = "Not found in $PATH"
} else {
rzpath = "Not found in " + rzpath
}
} else {
if info, err := notebook.info(); err == nil && len(info) > 0 {
rzversion = info[0]
rzbuild = info[1]
if rzpath == "rizin" {
rzpath = "Available from $PATH"
if len(info) > 1 {
rzbuild = info[1]
}
}
rzpath := os.Getenv("RIZIN_PATH")
if len(rzpath) < 1 {
rzpath = notebook.rizin
}
if resolved, err := exec.LookPath(rzpath); err == nil {
rzpath = resolved
}
c.HTML(200, "about.tmpl", gin.H{
"root": webroot,
"nbversion": NBVERSION,
"rzversion": rzversion,
"rzbuild": rzbuild,
"rzpath": rzpath,
"nbversion": NBVERSION,
"storage": notebook.storage,
})
})
+1294
View File
File diff suppressed because it is too large. Load diff
+149 -88
View File
@@ -3,113 +3,174 @@ package main
import (
"embed"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"html/template"
"io"
"io/fs"
"net/http"
"path"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/render"
)
//go:embed assets/*
var Assets embed.FS
//go:embed assets/templates/*
var embedTemplates embed.FS
//go:embed assets/static/*
var embedStatic embed.FS
// Template helper functions: stringify, keybindings.
var functionMap = template.FuncMap{
"raw": func(b []byte) template.HTML {
return template.HTML(b)
},
"stringify": func(input interface{}) string {
buffer, err := json.Marshal(input)
"stringify": func(v interface{}) string {
bytes, err := json.Marshal(v)
if err != nil {
return `""`
return "{}"
}
return string(buffer)
return string(bytes)
},
"keycombo": func(input string) string {
if len(input) > 0 {
return strings.Replace(input, ",", " + ", -1)
}
return "Not Assigned"
"keybindings": func() template.JS {
return getKeybindingsJSON()
},
"autocomplete_config": func() template.JS {
return getAutocompleteConfigJSON()
},
"search_config": func() template.JS {
return getNotebookSearchConfigJSON()
},
}
func loadEmbedded() (*template.Template, error) {
t := template.New("")
fs.WalkDir(Assets, ".", func(filePath string, file fs.DirEntry, err error) error {
if file.IsDir() || !strings.HasSuffix(file.Name(), ".tmpl") {
/* ─── Custom template render ─────────────────────────────── */
type customHTMLRender struct {
Template *template.Template
}
func (r *customHTMLRender) Instance(name string, data interface{}) render.Render {
return &customHTMLRenderInstance{
Template: r.Template,
Name: name,
Data: data,
}
}
type customHTMLRenderInstance struct {
Template *template.Template
Name string
Data interface{}
}
func (r *customHTMLRenderInstance) Render(w http.ResponseWriter) error {
r.WriteContentType(w)
return r.Template.ExecuteTemplate(w, r.Name, r.Data)
}
func (r *customHTMLRenderInstance) WriteContentType(w http.ResponseWriter) {
header := w.Header()
if val := header["Content-Type"]; len(val) == 0 {
header["Content-Type"] = []string{"text/html; charset=utf-8"}
}
}
/* ─── Template loading ───────────────────────────────────── */
func setupTemplate(assets string, router *gin.Engine) (http.FileSystem, *template.Template) {
if len(assets) > 0 {
return setupTemplateDebug(assets, router)
}
return setupTemplateEmbed(router)
}
func setupTemplateEmbed(router *gin.Engine) (http.FileSystem, *template.Template) {
tmpl := template.New("").Funcs(functionMap)
err := fs.WalkDir(embedTemplates, "assets/templates", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".tmpl") {
return nil
}
h, _ := Assets.ReadFile(filePath)
t, err = t.New(file.Name()).Funcs(functionMap).Parse(string(h))
return err
})
return t, nil
}
func loadAsset(file string) ([]byte, error) {
file = path.Base(file)
return Assets.ReadFile("assets/static/" + file)
}
func setupTemplate(assets string, router *gin.Engine) (string, string) {
var static, templates string
if len(assets) > 0 {
fmt.Println("debugging assets.")
static = path.Join(assets, "static")
templates = path.Join(assets, "templates", "*")
router.SetFuncMap(functionMap)
router.LoadHTMLGlob(templates)
} else {
templates, err := loadEmbedded()
if err != nil {
panic(err)
data, readErr := embedTemplates.ReadFile(path)
if readErr != nil {
return readErr
}
router.SetHTMLTemplate(templates)
name := filepath.Base(path)
_, parseErr := tmpl.New(name).Parse(string(data))
return parseErr
})
if err != nil {
panic("failed to load embedded templates: " + err.Error())
}
return static, templates
router.HTMLRender = &customHTMLRender{Template: tmpl}
staticFS, fsErr := fs.Sub(embedStatic, "assets/static")
if fsErr != nil {
panic("failed to load embedded static assets: " + fsErr.Error())
}
return http.FS(staticFS), tmpl
}
func serverAddAssets(root *gin.RouterGroup, assets, static, templates string) {
if len(assets) > 0 {
root.GET("/favicon.ico", func(c *gin.Context) {
c.Redirect(302, "/static/favicon.ico")
})
root.Static("/static", static)
} else {
root.GET("/favicon.ico", func(c *gin.Context) {
content, err := loadAsset("favicon.ico")
if content == nil && err == nil {
c.Status(404)
return
} else if err != nil {
c.Status(500)
fmt.Println("[Assets]", err)
return
}
c.Data(200, "image/x-icon", content)
})
root.GET("/static/:file", func(c *gin.Context) {
file := c.Param("file")
content, err := loadAsset(file)
if content == nil && err == nil {
c.Status(404)
return
} else if err != nil {
c.Status(500)
fmt.Println("[Assets]", err)
return
}
var contentType = "text/plain"
if strings.HasSuffix(file, ".css") {
contentType = "text/css"
} else if strings.HasSuffix(file, ".js") {
contentType = "text/javascript"
} else {
contentType = http.DetectContentType(content)
}
c.Data(200, contentType, content)
})
func setupTemplateDebug(assets string, router *gin.Engine) (http.FileSystem, *template.Template) {
tmpl := template.New("").Funcs(functionMap)
templateDir := filepath.Join(assets, "templates")
err := filepath.Walk(templateDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() || !strings.HasSuffix(path, ".tmpl") {
return nil
}
data, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
name := filepath.Base(path)
_, parseErr := tmpl.New(name).Parse(string(data))
return parseErr
})
if err != nil {
panic("failed to load debug templates: " + err.Error())
}
router.HTMLRender = &customHTMLRender{Template: tmpl}
staticDir := filepath.Join(assets, "static")
return http.Dir(staticDir), tmpl
}
/* ─── Static file routes ─────────────────────────────────── */
func serverAddAssets(root *gin.RouterGroup, assets string, static http.FileSystem, templates *template.Template) {
root.StaticFS("/static", static)
root.GET("/favicon.ico", func(c *gin.Context) {
c.Writer.WriteHeader(http.StatusNoContent)
_, _ = io.WriteString(c.Writer, "")
})
root.GET("/reload", func(c *gin.Context) {
if len(assets) > 0 {
templateDir := filepath.Join(assets, "templates")
newTmpl := template.New("").Funcs(functionMap)
_ = filepath.Walk(templateDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".tmpl") {
return err
}
data, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
name := filepath.Base(path)
_, parseErr := newTmpl.New(name).Parse(string(data))
return parseErr
})
templates = newTmpl
}
c.HTML(200, "reload.tmpl", gin.H{
"root": webroot,
})
})
}
+133 -80
View File
@@ -1,94 +1,147 @@
package main
import (
"github.com/gin-gonic/gin"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
func serverAddMarkdown(markdown *gin.RouterGroup) {
markdown.GET("/deleted", func(c *gin.Context) {
c.String(200, "deleted.")
})
markdown.GET("/view/*path", func(c *gin.Context) {
path := c.Param("path")
tokens := strings.Split(path[1:], "/")
if len(tokens) != 2 || !IsValidNonce(tokens[0]) || !IsValidNonce(tokens[1]) {
c.String(400, "invalid request")
} else {
bytes, err := notebook.file(tokens[0], tokens[1]+".md")
if err != nil {
c.String(404, "file not found")
} else {
c.HTML(200, "markdown-view.tmpl", gin.H{
"root": webroot,
"html": string(bytes),
"path": "/" + tokens[0] + "/" + tokens[1],
})
}
}
})
markdown.GET("/edit/*path", func(c *gin.Context) {
path := c.Param("path")
tokens := strings.Split(path[1:], "/")
if len(tokens) != 2 || !IsValidNonce(tokens[0]) || !IsValidNonce(tokens[1]) {
c.String(400, "invalid request")
} else {
bytes, err := notebook.file(tokens[0], tokens[1]+".md")
if err != nil {
c.String(404, "file not found")
} else {
c.HTML(200, "markdown-edit.tmpl", gin.H{
"root": webroot,
"raw": bytes,
"path": "/" + tokens[0] + "/" + tokens[1],
})
}
}
})
markdown.GET("/delete/*path", func(c *gin.Context) {
path := c.Param("path")
tokens := strings.Split(path[1:], "/")
if len(tokens) != 2 || !IsValidNonce(tokens[0]) || !IsValidNonce(tokens[1]) {
c.String(400, "invalid request")
} else {
if !notebook.deleteElem(tokens[0], tokens[1], true) {
c.String(404, "file not found")
} else {
c.Redirect(302, webroot+"markdown/deleted")
}
}
})
markdown.POST("/save/*path", func(c *gin.Context) {
path := c.Param("path")
tokens := strings.Split(path[1:], "/")
if len(tokens) != 2 || !IsValidNonce(tokens[0]) || !IsValidNonce(tokens[1]) {
c.String(400, "invalid request")
} else {
markdown := c.DefaultPostForm("markdown", "")
if !notebook.save([]byte(markdown), tokens[0], tokens[1]+".md") {
c.String(500, "can't save markdown.")
} else {
c.Redirect(302, webroot+"markdown/view/"+tokens[0]+"/"+tokens[1])
}
}
})
// Create a new markdown cell.
markdown.GET("/new/:unique", func(c *gin.Context) {
unique := c.Param("unique")
page := notebook.get(unique)
if page == nil {
c.HTML(404, "error.tmpl", gin.H{
if !IsValidNonce(unique, PageNonceSize) {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "cannot find a notebook page",
"error": "invalid page identifier",
})
} else {
if eunique := notebook.newmd(unique); len(eunique) > 0 {
c.Redirect(302, webroot+"markdown/edit/"+unique+"/"+eunique)
} else {
c.HTML(404, "error.tmpl", gin.H{
"root": webroot,
"error": "cannot add element to the page",
})
}
return
}
eunique := Nonce(ElementNonceSize)
if err := catalog.AddCell(unique, eunique, "markdown", ""); err != nil {
c.HTML(http.StatusNotFound, "error.tmpl", gin.H{
"root": webroot,
"error": "failed to create markdown cell",
})
return
}
c.Redirect(http.StatusFound, webroot+"markdown/edit/"+unique+"/"+eunique)
})
// View a rendered markdown cell.
markdown.GET("/view/*path", func(c *gin.Context) {
unique, eunique, ok := parseElementPath(c.Param("path"))
if !ok {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "invalid path",
})
return
}
cell, _ := catalog.GetCell(unique, eunique)
var content string
if cell != nil {
content = cell.Content
}
c.HTML(200, "markdown-view.tmpl", gin.H{
"root": webroot,
"path": "/" + unique + "/" + eunique,
"raw": content,
})
})
// Edit a markdown cell.
markdown.GET("/edit/*path", func(c *gin.Context) {
unique, eunique, ok := parseElementPath(c.Param("path"))
if !ok {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "invalid path",
})
return
}
cell, _ := catalog.GetCell(unique, eunique)
var content []byte
if cell != nil {
content = []byte(cell.Content)
}
c.HTML(200, "markdown-edit.tmpl", gin.H{
"root": webroot,
"path": "/" + unique + "/" + eunique,
"raw": content,
})
})
// Save markdown cell content.
markdown.POST("/save/*path", func(c *gin.Context) {
unique, eunique, ok := parseElementPath(c.Param("path"))
if !ok {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "invalid path",
})
return
}
content := c.PostForm("markdown")
catalog.UpdateCellContent(unique, eunique, content)
c.Redirect(http.StatusFound, webroot+"markdown/view/"+unique+"/"+eunique)
})
// Delete a markdown cell.
markdown.GET("/delete/*path", func(c *gin.Context) {
unique, eunique, ok := parseElementPath(c.Param("path"))
if !ok {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "invalid path",
})
return
}
catalog.DeleteCell(unique, eunique)
// Redirect to "deleted" pseudo-page so iframe can detect removal.
c.Redirect(http.StatusFound, webroot+"markdown/deleted")
})
// Pseudo-page for iframe deletion detection.
markdown.GET("/deleted", func(c *gin.Context) {
c.String(200, "deleted")
})
}
/* ─── Helpers ─────────────────────────────────────────────── */
func parseElementPath(path string) (string, string, bool) {
parts := splitPath(path)
if len(parts) != 2 {
return "", "", false
}
unique := parts[0]
eunique := parts[1]
if !IsValidNonce(unique, PageNonceSize) || !IsValidNonce(eunique, ElementNonceSize) {
return "", "", false
}
return unique, eunique, true
}
func splitPath(path string) []string {
raw := strings.Split(path, "/")
var parts []string
for _, p := range raw {
if p != "" {
parts = append(parts, p)
}
}
return parts
}
+228 -128
View File
@@ -2,185 +2,285 @@ package main
import (
"fmt"
"github.com/gin-gonic/gin"
"html/template"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
func formatExecutionDuration(duration time.Duration) string {
if duration < time.Millisecond {
return duration.Round(time.Microsecond).String()
}
if duration < time.Second {
return duration.Round(time.Millisecond).String()
}
return duration.Round(time.Millisecond).String()
}
func formatActionTime(unixTime int64) string {
if unixTime <= 0 {
return ""
}
return time.Unix(unixTime, 0).Local().Format("15:04")
}
func serverAddOutput(output *gin.RouterGroup) {
output.GET("/deleted", func(c *gin.Context) {
c.String(200, "deleted.")
})
output.GET("/loaded", func(c *gin.Context) {
c.String(200, "found.")
})
output.POST("/script/:unique", func(c *gin.Context) {
unique := c.Param("unique")
script := c.DefaultPostForm("script", "")
if !IsValidNonce(unique) || len(script) < 1 {
c.HTML(400, "console-error.tmpl", gin.H{
"error": "invalid request",
// View a rendered output cell.
output.GET("/view/*path", func(c *gin.Context) {
unique, eunique, ok := parseElementPath(c.Param("path"))
if !ok {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "invalid path",
})
return
}
section := notebook.newscript(unique, script)
if len(section) < 1 {
c.HTML(400, "console-error.tmpl", gin.H{
"error": "cannot create section for output.",
"root": webroot,
})
return
}
rizin := notebook.open(unique, false)
jsvm := notebook.jsvm
go func(unique, name, script string, jsvm *JavaScript, rizin *Rizin) {
output, err := jsvm.exec(script, rizin)
if err != nil {
errstr := strings.Replace(err.Error(), " at main.NewJavaScript.func1 (native)", "", 1)
output += "\nException: " + errstr
cell, _ := catalog.GetCell(unique, eunique)
var data []byte
var lines, words int
var actionTime string
var visibleOutput string
if cell != nil {
data = cell.Output
visibleOutput = outputVisibleText(string(data))
if len(visibleOutput) > 0 {
lines = strings.Count(visibleOutput, "\n") + 1
words = len(strings.Fields(visibleOutput))
}
notebook.save([]byte(output), unique, name+".out")
}(unique, section, script, jsvm, rizin)
c.Redirect(302, webroot+"output/check/"+unique+"/"+section)
})
output.POST("/exec/:unique", func(c *gin.Context) {
unique := c.Param("unique")
command := c.DefaultPostForm("command", "")
if !IsValidNonce(unique) || len(command) < 1 {
c.HTML(400, "console-error.tmpl", gin.H{
"error": "invalid request",
"root": webroot,
})
return
}
rizin := notebook.open(unique, false)
if rizin == nil {
c.HTML(400, "console-error.tmpl", gin.H{
"error": "pipe is closed.",
"root": webroot,
})
return
}
section := notebook.newcmd(unique, command)
if len(section) < 1 {
c.HTML(400, "console-error.tmpl", gin.H{
"error": "cannot create section for output.",
"root": webroot,
})
return
}
go func(unique, name, command string, rizin *Rizin) {
output, err := rizin.exec(command)
if err != nil {
output = fmt.Sprintf("pipe error: %v", err)
if cell.Executed > 0 {
actionTime = formatActionTime(cell.Executed)
} else {
actionTime = formatActionTime(cell.Created)
}
notebook.save([]byte(output), unique, name+".out")
}(unique, section, command, rizin)
}
c.Redirect(302, webroot+"output/check/"+unique+"/"+section)
htmlOutput := toHtml(string(data))
c.HTML(200, "output.tmpl", gin.H{
"root": webroot,
"output": template.HTML(string(htmlOutput)),
"raw_output": visibleOutput,
"lines": lines,
"words": words,
"actionTime": actionTime,
})
})
output.GET("/check/*path", func(c *gin.Context) {
tokens := strings.Split(c.Param("path")[1:], "/")
if len(tokens) != 2 || !IsValidNonce(tokens[0]) || !IsValidNonce(tokens[1]) {
c.HTML(400, "error.tmpl", gin.H{
// Delete an output cell.
output.GET("/delete/*path", func(c *gin.Context) {
unique, eunique, ok := parseElementPath(c.Param("path"))
if !ok {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "invalid request",
"error": "invalid path",
})
return
}
_, err := notebook.file(tokens[0], tokens[1]+".out")
if err != nil {
c.HTML(200, "reload.tmpl", gin.H{
"root": webroot,
})
return
}
catalog.DeleteCell(unique, eunique)
c.Redirect(302, webroot+"output/loaded")
c.Redirect(http.StatusFound, webroot+"output/deleted")
})
// Command line input form (rendered in an iframe).
output.GET("/input/console/:unique", func(c *gin.Context) {
unique := c.Param("unique")
if !IsValidNonce(unique, PageNonceSize) {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "invalid page identifier",
})
return
}
c.HTML(200, "console.tmpl", gin.H{
"root": webroot,
"unique": unique,
})
})
// Script editor input form (rendered in an iframe).
output.GET("/input/script/:unique", func(c *gin.Context) {
unique := c.Param("unique")
if !IsValidNonce(unique) {
c.HTML(400, "error.tmpl", gin.H{
if !IsValidNonce(unique, PageNonceSize) {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "invalid request",
"error": "invalid page identifier",
})
return
}
c.HTML(200, "script.tmpl", gin.H{
"unique": unique,
"root": webroot,
"unique": unique,
})
})
output.GET("/input/console/:unique", func(c *gin.Context) {
// Execute a Rizin command.
output.POST("/exec/:unique", func(c *gin.Context) {
unique := c.Param("unique")
if !IsValidNonce(unique) {
c.HTML(400, "error.tmpl", gin.H{
if !IsValidNonce(unique, PageNonceSize) {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "invalid request",
})
return
}
c.HTML(200, "console.tmpl", gin.H{
"unique": unique,
"root": webroot,
})
})
output.GET("/view/*path", func(c *gin.Context) {
tokens := strings.Split(c.Param("path")[1:], "/")
if len(tokens) != 2 || !IsValidNonce(tokens[0]) || !IsValidNonce(tokens[1]) {
c.HTML(400, "output.tmpl", gin.H{
"root": webroot,
"output": []byte("invalid request"),
"error": "invalid page identifier",
})
return
}
bytes, err := notebook.file(tokens[0], tokens[1]+".out")
command := c.PostForm("command")
if command == "" {
c.Redirect(http.StatusFound, webroot+"output/deleted")
return
}
// Get or open the pipe.
rz := notebook.open(unique, true)
if rz == nil {
c.HTML(http.StatusInternalServerError, "console.tmpl", gin.H{
"root": webroot,
"unique": unique,
"command": command,
"error": "Failed to open Rizin pipe",
})
return
}
// Execute the command.
started := time.Now()
result, err := rz.exec(command)
if err != nil {
c.HTML(404, "output.tmpl", gin.H{
"root": webroot,
"output": []byte("missing output file"),
c.HTML(200, "console.tmpl", gin.H{
"root": webroot,
"unique": unique,
"command": command,
"error": err.Error(),
})
return
}
outputStr := fromRawToString(bytes)
output, ok := toCsv(outputStr)
if !ok {
output = toHtml(outputStr)
// Create a command cell and save the output in SQLite.
eunique := Nonce(ElementNonceSize)
if storeErr := catalog.AddCell(unique, eunique, "command", command); storeErr != nil {
c.HTML(http.StatusInternalServerError, "console.tmpl", gin.H{
"root": webroot,
"unique": unique,
"command": command,
"error": "Failed to create command element",
})
return
}
c.HTML(200, "output.tmpl", gin.H{
"root": webroot,
"output": output,
catalog.UpdateCellOutput(unique, eunique, []byte(result))
durationText := formatExecutionDuration(time.Since(started))
lineCount := 0
wordCount := 0
if result != "" {
lineCount = strings.Count(result, "\n") + 1
wordCount = len(strings.Fields(result))
}
actionTime := time.Now().Local().Format("15:04")
c.HTML(http.StatusOK, "console.tmpl", gin.H{
"root": webroot,
"unique": unique,
"command": command,
"message": fmt.Sprintf("Command executed in %s", durationText),
"lineCount": lineCount,
"wordCount": wordCount,
"actionTime": actionTime,
"cellUnique": eunique,
})
})
output.GET("/delete/*path", func(c *gin.Context) {
path := c.Param("path")
tokens := strings.Split(path[1:], "/")
if len(tokens) != 2 || !IsValidNonce(tokens[0]) || !IsValidNonce(tokens[1]) {
c.HTML(400, "error.tmpl", gin.H{
// Execute a JavaScript script.
output.POST("/script/:unique", func(c *gin.Context) {
unique := c.Param("unique")
if !IsValidNonce(unique, PageNonceSize) {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "invalid request",
"error": "invalid page identifier",
})
return
} else if notebook.deleteElem(tokens[0], tokens[1], false) {
c.Redirect(302, webroot+"view/"+tokens[0])
return
}
c.HTML(400, "error.tmpl", gin.H{
"root": webroot,
"error": "invalid request",
script := c.PostForm("script")
if script == "" {
c.Redirect(http.StatusFound, webroot+"output/deleted")
return
}
// Get or open the pipe.
rz := notebook.open(unique, true)
// Create a script cell in SQLite.
eunique := Nonce(ElementNonceSize)
if storeErr := catalog.AddCell(unique, eunique, "script", script); storeErr != nil {
c.HTML(http.StatusInternalServerError, "script.tmpl", gin.H{
"root": webroot,
"unique": unique,
"script": script,
"error": "Failed to create script element",
})
return
}
// Execute the script. rz may be nil if no pipe is open,
// which is handled by the JavaScript engine's rizin.cmd error.
started := time.Now()
result, err := notebook.jsvm.exec(script, rz)
if err != nil {
catalog.UpdateCellOutput(unique, eunique, []byte(err.Error()))
c.HTML(200, "script.tmpl", gin.H{
"root": webroot,
"unique": unique,
"script": script,
"error": err.Error(),
"cellUnique": eunique,
})
return
}
catalog.UpdateCellOutput(unique, eunique, []byte(result))
durationText := formatExecutionDuration(time.Since(started))
lineCount := 0
wordCount := 0
if result != "" {
lineCount = strings.Count(result, "\n") + 1
wordCount = len(strings.Fields(result))
}
actionTime := time.Now().Local().Format("15:04")
c.HTML(http.StatusOK, "script.tmpl", gin.H{
"root": webroot,
"unique": unique,
"script": script,
"message": fmt.Sprintf("Script executed in %s", durationText),
"lineCount": lineCount,
"wordCount": wordCount,
"actionTime": actionTime,
"cellUnique": eunique,
})
})
// Pseudo-page for iframe deletion detection.
// When the iframe navigates here, the parent page's output() handler
// removes the iframe's container div from the DOM.
output.GET("/deleted", func(c *gin.Context) {
c.String(200, "deleted")
})
// Pseudo-page for parent page reload trigger.
// When the iframe navigates here, the parent page's output() handler
// calls location.reload() to refresh the entire page.
output.GET("/loaded", func(c *gin.Context) {
c.HTML(200, "reload.tmpl", gin.H{
"root": webroot,
})
})
}
+188 -89
View File
@@ -1,116 +1,215 @@
package main
import (
"fmt"
"errors"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"path"
"path/filepath"
)
func serverAddPage(root *gin.RouterGroup) {
// Show new page form (empty unique).
root.GET("/new", func(c *gin.Context) {
c.HTML(200, "page-new.tmpl", gin.H{
"root": webroot,
"title": "",
"page": gin.H{},
"unique": "",
"title": "",
})
})
root.POST("/edit", func(c *gin.Context) {
unique := c.DefaultPostForm("unique", "")
title := c.DefaultPostForm("title", "")
if len(unique) > 0 {
valid := IsValidNonce(unique)
if valid && notebook.rename(unique, title) {
c.Redirect(302, webroot+"view/"+unique)
} else {
c.HTML(500, "error.tmpl", gin.H{
"root": webroot,
"error": "cannot rename page",
})
}
} else {
file, err := c.FormFile("binary")
if err != nil {
fmt.Println(err)
c.HTML(500, "error.tmpl", gin.H{
"root": webroot,
"error": "invalid or missing binary",
})
return
}
filename := filepath.Base(file.Filename)
if len(filename) < 1 {
c.HTML(500, "error.tmpl", gin.H{
"root": webroot,
"error": "invalid binary name",
})
return
}
ext := filepath.Ext(filename)
binary := Nonce(ELEMENT_NONCE_SIZE) + ext
unique := notebook.new(title, filename, binary)
if len(unique) < 1 {
c.HTML(500, "error.tmpl", gin.H{
"root": webroot,
"error": "cannot create page",
})
return
}
if c.SaveUploadedFile(file, path.Join(notebook.storage, unique, binary)) != nil {
notebook.delete(unique)
c.HTML(500, "error.tmpl", gin.H{
"root": webroot,
"error": "cannot save binary",
})
} else {
c.Redirect(302, webroot+"view/"+unique)
}
}
})
root.GET("/delete/:unique", func(c *gin.Context) {
unique := c.Param("unique")
if !IsValidNonce(unique) || !notebook.delete(unique) {
c.HTML(404, "error.tmpl", gin.H{
"root": webroot,
"error": "cannot find page",
})
} else {
c.Redirect(302, webroot)
}
})
// Show edit page form (existing page).
root.GET("/edit/:unique", func(c *gin.Context) {
unique := c.Param("unique")
page := notebook.get(unique)
if page == nil {
c.HTML(404, "error.tmpl", gin.H{
if !IsValidNonce(unique, PageNonceSize) {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "cannot find page",
})
} else {
c.HTML(200, "page-new.tmpl", gin.H{
"root": webroot,
"title": page["title"],
"unique": unique,
"error": "invalid page identifier",
})
return
}
page, err := catalog.GetPage(unique)
if err != nil || page == nil {
c.HTML(http.StatusNotFound, "error.tmpl", gin.H{
"root": webroot,
"error": "page not found",
})
return
}
c.HTML(200, "page-new.tmpl", gin.H{
"root": webroot,
"page": gin.H{"title": page.Title, "filename": page.Filename},
"unique": unique,
"title": page.Title,
})
})
// Create or update a page (handles file upload for new pages).
root.POST("/edit", func(c *gin.Context) {
unique := c.PostForm("unique")
title := c.PostForm("title")
if len(title) < 1 {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "Title is required",
})
return
}
var uploadName string
var uploadData []byte
file, header, err := c.Request.FormFile("binary")
if err == nil {
defer file.Close()
data, readErr := io.ReadAll(file)
if readErr != nil {
c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{
"root": webroot,
"error": "Failed to read uploaded file",
})
return
}
uploadName = header.Filename
uploadData = data
} else if !errors.Is(err, http.ErrMissingFile) {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "Failed to read uploaded file",
})
return
}
if len(unique) > 0 {
// Editing an existing page: rename and optionally replace the binary.
if !IsValidNonce(unique, PageNonceSize) {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "invalid page identifier",
})
return
}
if err := catalog.RenamePage(unique, title); err != nil {
c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{
"root": webroot,
"error": "Failed to update page",
})
return
}
if len(uploadData) > 0 {
if err := catalog.AttachBinary(unique, uploadName, uploadData); err != nil {
c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{
"root": webroot,
"error": "Failed to save binary file",
})
return
}
}
c.Redirect(http.StatusFound, webroot+"view/"+unique)
return
}
// Creating a new page: uploaded binary is optional.
pageID, createErr := catalog.CreatePage(title, uploadName, uploadData)
if createErr != nil {
c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{
"root": webroot,
"error": "Failed to create page",
})
return
}
c.Redirect(http.StatusFound, webroot+"view/"+pageID)
})
// View a page with all its cells.
root.GET("/view/:unique", func(c *gin.Context) {
unique := c.Param("unique")
page := notebook.get(unique)
if page == nil {
c.HTML(404, "error.tmpl", gin.H{
if !IsValidNonce(unique, PageNonceSize) {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "cannot find a new page",
})
} else {
c.HTML(200, "page-view.tmpl", gin.H{
"root": webroot,
"page": page,
"pipe": notebook.open(unique, false) != nil,
"error": "invalid page identifier",
})
return
}
page, err := catalog.GetPage(unique)
if err != nil || page == nil {
c.HTML(http.StatusNotFound, "error.tmpl", gin.H{
"root": webroot,
"error": "page not found",
})
return
}
// Check if a Rizin pipe is open for this page.
pipe := notebook.open(unique, false) != nil
// Load cells from SQLite.
cells, _ := catalog.ListCells(unique)
lines := make([]interface{}, len(cells))
for i, cell := range cells {
statsSource := cell.Content
if cell.Type == "command" || cell.Type == "script" {
statsSource = string(cell.Output)
}
lineCount := 0
wordCount := 0
if statsSource != "" {
lineCount = strings.Count(statsSource, "\n") + 1
wordCount = len(strings.Fields(statsSource))
}
actionTime := formatActionTime(cell.Created)
if cell.Executed > 0 {
actionTime = formatActionTime(cell.Executed)
}
lines[i] = gin.H{
"type": cell.Type,
"unique": cell.ID,
"command": cell.Content,
"script": cell.Content,
"lineCount": lineCount,
"wordCount": wordCount,
"actionTime": actionTime,
}
}
pageData := gin.H{
"title": page.Title,
"unique": page.ID,
"filename": page.Filename,
"binary": page.Binary,
"binaryHash": page.BinaryHash,
"hasBinary": page.Binary != "",
"lines": lines,
}
c.HTML(200, "page-view.tmpl", gin.H{
"root": webroot,
"page": pageData,
"pipe": pipe,
"cmds": notebook.cmds,
})
})
// Delete a page.
root.GET("/delete/:unique", func(c *gin.Context) {
unique := c.Param("unique")
if !IsValidNonce(unique, PageNonceSize) {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "invalid page identifier",
})
return
}
notebook.closePipe(unique)
catalog.DeletePage(unique)
c.Redirect(http.StatusFound, webroot)
})
}
+40 -15
View File
@@ -1,40 +1,65 @@
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func serverAddPipe(pipe *gin.RouterGroup) {
// Open a Rizin pipe for a page.
pipe.GET("/open/:unique", func(c *gin.Context) {
unique := c.Param("unique")
if !IsValidNonce(unique) {
c.HTML(404, "error.tmpl", gin.H{
if !IsValidNonce(unique, PageNonceSize) {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "unknown page.",
"error": "invalid page identifier",
})
return
}
rizin := notebook.open(unique, true)
if rizin == nil {
c.HTML(404, "error.tmpl", gin.H{
page, err := catalog.GetPage(unique)
if err != nil || page == nil {
c.HTML(http.StatusNotFound, "error.tmpl", gin.H{
"root": webroot,
"error": "failed to open the rizin pipe.",
"error": "page not found",
})
} else {
c.Redirect(302, webroot+"view/"+unique)
return
}
if page.Binary == "" {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "This page has no binary attached yet. Attach one from the edit page before opening a Rizin pipe.",
"location": webroot + "edit/" + unique,
})
return
}
if notebook.open(unique, true) == nil {
c.HTML(http.StatusInternalServerError, "error.tmpl", gin.H{
"root": webroot,
"error": "Failed to open Rizin pipe. Check that the binary file exists and rizin is installed.",
"location": webroot + "view/" + unique,
})
return
}
c.Redirect(http.StatusFound, webroot+"view/"+unique)
})
// Close a Rizin pipe for a page.
pipe.GET("/close/:unique", func(c *gin.Context) {
unique := c.Param("unique")
if !IsValidNonce(unique) {
c.HTML(404, "error.tmpl", gin.H{
if !IsValidNonce(unique, PageNonceSize) {
c.HTML(http.StatusBadRequest, "error.tmpl", gin.H{
"root": webroot,
"error": "unknown page.",
"error": "invalid page identifier",
})
return
} else {
notebook.close(unique)
c.Redirect(302, webroot+"view/"+unique)
}
notebook.closePipe(unique)
c.Redirect(http.StatusFound, webroot+"view/"+unique)
})
}
+83
View File
@@ -0,0 +1,83 @@
package main
import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
func apiJSONSearchPage(c *gin.Context) {
pageID := c.Param("id")
if !IsValidNonce(pageID, PageNonceSize) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid page identifier"})
return
}
page, err := catalog.GetPage(pageID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if page == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "page not found"})
return
}
query := strings.TrimSpace(c.Query("query"))
if query == "" {
query = strings.TrimSpace(c.Query("q"))
}
limit, err := parseNotebookSearchLimit(c.Query("limit"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cells, err := catalog.ListCells(pageID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
response, err := SearchNotebook(cells, NotebookSearchOptions{
Query: query,
Mode: NotebookSearchMode(strings.TrimSpace(c.DefaultQuery("mode", string(NotebookSearchModeLiteral)))),
CaseSensitive: parseNotebookSearchBool(c.Query("case_sensitive")),
Limit: limit,
SurfaceFilter: NotebookSearchSurfaceFilter(strings.TrimSpace(c.DefaultQuery("surface", string(NotebookSearchSurfaceFilterAll)))),
CellTypeFilter: NotebookSearchCellTypeFilter(strings.TrimSpace(c.DefaultQuery("cell_type", string(NotebookSearchCellTypeFilterAll)))),
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
}
func parseNotebookSearchLimit(raw string) (int, error) {
if strings.TrimSpace(raw) == "" {
return getNotebookSearchConfig().MaxResults, nil
}
limit, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("invalid limit: %w", err)
}
if limit <= 0 {
return getNotebookSearchConfig().MaxResults, nil
}
return limit, nil
}
func parseNotebookSearchBool(raw string) bool {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
+282 -65
View File
@@ -1,77 +1,294 @@
package main
import (
"github.com/gin-gonic/gin"
"net/http"
"os"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
func serverAddSettings(root *gin.RouterGroup) {
root.GET("/settings", func(c *gin.Context) {
c.HTML(200, "settings.tmpl", gin.H{
"root": webroot,
"environment": config.Environment,
})
})
root.GET("/settings/:action/:section/:editkey", func(c *gin.Context) {
action := c.Param("action")
section := c.Param("section")
editkey := c.Param("editkey")
if action != "environment" {
c.HTML(404, "error.tmpl", gin.H{
"root": webroot,
"error": "cannot find page",
})
return
}
if editkey == "new" {
editkey = ""
}
var data map[string]string = nil
if action == "environment" {
data = config.Environment
}
c.HTML(200, "settings-edit.tmpl", gin.H{
"root": webroot,
"action": action,
"section": section,
"editkey": editkey,
"data": data,
})
})
root.POST("/settings", func(c *gin.Context) {
key := c.DefaultPostForm("key", "")
value := c.DefaultPostForm("value", "")
action := c.DefaultPostForm("action", "")
if action != "environment" {
c.HTML(404, "error.tmpl", gin.H{
"root": webroot,
"location": webroot + "settings",
"error": "invalid settings (Action)",
})
return
} else if action == "environment" {
editkey := c.DefaultPostForm("editkey", "")
subaction := c.DefaultPostForm("subaction", "")
editkey = strings.TrimSpace(editkey)
if editkey == "" && subaction != "new" {
c.HTML(404, "error.tmpl", gin.H{
"root": webroot,
"location": webroot + "settings",
"error": "invalid settings (Environment Variable)",
})
} else if subaction == "new" {
config.SetEnvironment(key, value)
} else if subaction == "delete" {
config.DelEnvironment(editkey)
} else {
if editkey != key {
config.DelEnvironment(editkey)
}
config.SetEnvironment(key, value)
// View current settings.
root.GET("/settings", func(c *gin.Context) {
settings, _ := catalog.GetAllSettings()
if settings == nil {
settings = map[string]string{}
}
// Filter out structured settings from the environment display.
env := map[string]string{}
for k, v := range settings {
if !strings.HasPrefix(k, keybindingSettingsPrefix) &&
!strings.HasPrefix(k, autocompleteSettingsPrefix) &&
!strings.HasPrefix(k, notebookSearchSettingsPrefix) {
env[k] = v
}
}
config.Save()
c.Redirect(302, webroot+"settings")
c.HTML(200, "settings.tmpl", gin.H{
"root": webroot,
"environment": env,
"kb_actions": keybindingActions(),
"ac_actions": autocompleteActions(),
"ns_actions": notebookSearchActions(),
})
})
// Edit or create an environment variable form.
root.GET("/settings/environment/edit/:key", func(c *gin.Context) {
key := c.Param("key")
// "new" is a special sentinel value meaning create a new variable.
editkey := ""
if key != "new" {
editkey = key
}
settings, _ := catalog.GetAllSettings()
if settings == nil {
settings = map[string]string{}
}
c.HTML(200, "settings-edit.tmpl", gin.H{
"root": webroot,
"action": "environment",
"editkey": editkey,
"data": settings,
})
})
// Edit a keybinding.
root.GET("/settings/keybinding/edit/:action", func(c *gin.Context) {
action := c.Param("action")
if _, ok := DefaultKeybindings[action]; !ok {
c.Redirect(http.StatusFound, webroot+"settings")
return
}
kb := GetAllKeybindings()
c.HTML(200, "settings-edit.tmpl", gin.H{
"root": webroot,
"action": "keybinding",
"editkey": action,
"label": KeybindingLabels[action],
"combo": kb[action],
"default_combo": DefaultKeybindings[action],
})
})
// Reset all keybindings to defaults.
root.GET("/settings/keybinding/reset", func(c *gin.Context) {
for action := range DefaultKeybindings {
catalog.DeleteSetting(keybindingSettingsPrefix + action)
}
c.Redirect(http.StatusFound, webroot+"settings")
})
// Edit an autocomplete setting.
root.GET("/settings/autocomplete/edit/:key", func(c *gin.Context) {
key := c.Param("key")
if _, ok := AutocompleteLabels[key]; !ok {
c.Redirect(http.StatusFound, webroot+"settings")
return
}
cfg := GetAutocompleteConfig()
c.HTML(200, "settings-edit.tmpl", gin.H{
"root": webroot,
"action": "autocomplete",
"editkey": key,
"label": AutocompleteLabels[key],
"current_value": strconv.Itoa(cfg[key]),
"default_value": AutocompleteDefaults[key],
})
})
// Reset all autocomplete settings to defaults.
root.GET("/settings/autocomplete/reset", func(c *gin.Context) {
for _, key := range AutocompleteSettingKeys {
catalog.DeleteSetting(autocompleteSettingsPrefix + key)
}
c.Redirect(http.StatusFound, webroot+"settings")
})
root.GET("/settings/search/edit/:key", func(c *gin.Context) {
key := c.Param("key")
if _, ok := notebookSearchSettingLabels[key]; !ok {
c.Redirect(http.StatusFound, webroot+"settings")
return
}
cfg := getNotebookSearchConfig()
currentValue := notebookSearchSettingDefaults[key]
switch key {
case "max_results":
currentValue = strconv.Itoa(cfg.MaxResults)
case "default_mode":
currentValue = string(cfg.DefaultMode)
case "default_surface":
currentValue = string(cfg.Surface)
case "default_cell_type":
currentValue = string(cfg.CellType)
}
c.HTML(200, "settings-edit.tmpl", gin.H{
"root": webroot,
"action": "search",
"editkey": key,
"label": notebookSearchSettingLabels[key],
"current_value": currentValue,
"default_value": notebookSearchSettingDefaults[key],
"default_display": notebookSearchSettingValueLabel(key, notebookSearchSettingDefaults[key]),
"input_type": notebookSearchSettingKinds[key],
"options": notebookSearchSettingChoices[key],
"min_value": strconv.Itoa(minSearchMaxResults),
"max_value": strconv.Itoa(maxSearchMaxResults),
})
})
root.GET("/settings/search/reset", func(c *gin.Context) {
for _, key := range notebookSearchSettingOrder {
catalog.DeleteSetting(notebookSearchSettingsPrefix + key)
}
c.Redirect(http.StatusFound, webroot+"settings")
})
// Handle settings form submissions.
root.POST("/settings", func(c *gin.Context) {
action := strings.TrimSpace(c.PostForm("action"))
if action == "environment" {
handleEnvironmentSettings(c)
return
}
if action == "keybinding" {
handleKeybindingSettings(c)
return
}
if action == "autocomplete" {
handleAutocompleteSettings(c)
return
}
if action == "search" {
handleNotebookSearchSettings(c)
return
}
c.Redirect(http.StatusFound, webroot+"settings")
})
}
func handleEnvironmentSettings(c *gin.Context) {
subaction := strings.TrimSpace(c.PostForm("subaction"))
editkey := strings.TrimSpace(c.PostForm("editkey"))
key := strings.TrimSpace(c.PostForm("key"))
value := strings.TrimSpace(c.PostForm("value"))
switch subaction {
case "new":
if len(key) > 0 {
catalog.SetSetting(key, value)
os.Setenv(key, value)
}
case "edit":
if len(editkey) > 0 && len(key) > 0 {
if editkey != key {
catalog.DeleteSetting(editkey)
os.Unsetenv(editkey)
}
catalog.SetSetting(key, value)
os.Setenv(key, value)
}
case "delete":
if len(editkey) > 0 {
catalog.DeleteSetting(editkey)
os.Unsetenv(editkey)
}
}
c.Redirect(http.StatusFound, webroot+"settings")
}
func handleKeybindingSettings(c *gin.Context) {
subaction := strings.TrimSpace(c.PostForm("subaction"))
editkey := strings.TrimSpace(c.PostForm("editkey"))
value := strings.TrimSpace(c.PostForm("value"))
switch subaction {
case "save":
if len(editkey) > 0 && len(value) > 0 {
catalog.SetSetting(keybindingSettingsPrefix+editkey, value)
}
case "reset":
if len(editkey) > 0 {
catalog.DeleteSetting(keybindingSettingsPrefix + editkey)
}
}
c.Redirect(http.StatusFound, webroot+"settings")
}
func handleAutocompleteSettings(c *gin.Context) {
subaction := strings.TrimSpace(c.PostForm("subaction"))
editkey := strings.TrimSpace(c.PostForm("editkey"))
value := strings.TrimSpace(c.PostForm("value"))
switch subaction {
case "save":
if len(editkey) > 0 && len(value) > 0 {
// Validate and clamp the numeric value.
val, err := strconv.Atoi(value)
if err == nil {
val = clampInt(val, minACMaxResults, maxACMaxResults)
catalog.SetSetting(autocompleteSettingsPrefix+editkey, strconv.Itoa(val))
}
}
case "reset":
if len(editkey) > 0 {
catalog.DeleteSetting(autocompleteSettingsPrefix + editkey)
}
}
c.Redirect(http.StatusFound, webroot+"settings")
}
func handleNotebookSearchSettings(c *gin.Context) {
subaction := strings.TrimSpace(c.PostForm("subaction"))
editkey := strings.TrimSpace(c.PostForm("editkey"))
value := strings.TrimSpace(c.PostForm("value"))
switch subaction {
case "save":
switch editkey {
case "max_results":
val, err := strconv.Atoi(value)
if err == nil {
val = clampInt(val, minSearchMaxResults, maxSearchMaxResults)
catalog.SetSetting(notebookSearchSettingsPrefix+editkey, strconv.Itoa(val))
}
case "default_mode":
mode, err := normalizeNotebookSearchMode(NotebookSearchMode(value))
if err == nil {
catalog.SetSetting(notebookSearchSettingsPrefix+editkey, string(mode))
}
case "default_surface":
surface, err := normalizeNotebookSearchSurfaceFilter(NotebookSearchSurfaceFilter(value))
if err == nil {
catalog.SetSetting(notebookSearchSettingsPrefix+editkey, string(surface))
}
case "default_cell_type":
cellType, err := normalizeNotebookSearchCellTypeFilter(NotebookSearchCellTypeFilter(value))
if err == nil {
catalog.SetSetting(notebookSearchSettingsPrefix+editkey, string(cellType))
}
}
case "reset":
if len(editkey) > 0 {
catalog.DeleteSetting(notebookSearchSettingsPrefix + editkey)
}
}
c.Redirect(http.StatusFound, webroot+"settings")
}
+1278
View File
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
sudo: false
language: go
go_import_path: github.com/dustin/go-humanize
go:
- 1.13.x
- 1.14.x
- 1.15.x
- 1.16.x
- stable
- master
matrix:
allow_failures:
- go: master
fast_finish: true
install:
- # Do nothing. This is needed to prevent default install action "go get -t -v ./..." from happening here (we want it to happen inside script step).
script:
- diff -u <(echo -n) <(gofmt -d -s .)
- go vet .
- go install -v -race ./...
- go test -v -race ./...
+21
View File
@@ -0,0 +1,21 @@
Copyright (c) 2005-2008 Dustin Sallings <[email protected]>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
<http://www.opensource.org/licenses/mit-license.php>
+124
View File
@@ -0,0 +1,124 @@
# Humane Units [![Build Status](https://travis-ci.org/dustin/go-humanize.svg?branch=master)](https://travis-ci.org/dustin/go-humanize) [![GoDoc](https://godoc.org/github.com/dustin/go-humanize?status.svg)](https://godoc.org/github.com/dustin/go-humanize)
Just a few functions for helping humanize times and sizes.
`go get` it as `github.com/dustin/go-humanize`, import it as
`"github.com/dustin/go-humanize"`, use it as `humanize`.
See [godoc](https://pkg.go.dev/github.com/dustin/go-humanize) for
complete documentation.
## Sizes
This lets you take numbers like `82854982` and convert them to useful
strings like, `83 MB` or `79 MiB` (whichever you prefer).
Example:
```go
fmt.Printf("That file is %s.", humanize.Bytes(82854982)) // That file is 83 MB.
```
## Times
This lets you take a `time.Time` and spit it out in relative terms.
For example, `12 seconds ago` or `3 days from now`.
Example:
```go
fmt.Printf("This was touched %s.", humanize.Time(someTimeInstance)) // This was touched 7 hours ago.
```
Thanks to Kyle Lemons for the time implementation from an IRC
conversation one day. It's pretty neat.
## Ordinals
From a [mailing list discussion][odisc] where a user wanted to be able
to label ordinals.
0 -> 0th
1 -> 1st
2 -> 2nd
3 -> 3rd
4 -> 4th
[...]
Example:
```go
fmt.Printf("You're my %s best friend.", humanize.Ordinal(193)) // You are my 193rd best friend.
```
## Commas
Want to shove commas into numbers? Be my guest.
0 -> 0
100 -> 100
1000 -> 1,000
1000000000 -> 1,000,000,000
-100000 -> -100,000
Example:
```go
fmt.Printf("You owe $%s.\n", humanize.Comma(6582491)) // You owe $6,582,491.
```
## Ftoa
Nicer float64 formatter that removes trailing zeros.
```go
fmt.Printf("%f", 2.24) // 2.240000
fmt.Printf("%s", humanize.Ftoa(2.24)) // 2.24
fmt.Printf("%f", 2.0) // 2.000000
fmt.Printf("%s", humanize.Ftoa(2.0)) // 2
```
## SI notation
Format numbers with [SI notation][sinotation].
Example:
```go
humanize.SI(0.00000000223, "M") // 2.23 nM
```
## English-specific functions
The following functions are in the `humanize/english` subpackage.
### Plurals
Simple English pluralization
```go
english.PluralWord(1, "object", "") // object
english.PluralWord(42, "object", "") // objects
english.PluralWord(2, "bus", "") // buses
english.PluralWord(99, "locus", "loci") // loci
english.Plural(1, "object", "") // 1 object
english.Plural(42, "object", "") // 42 objects
english.Plural(2, "bus", "") // 2 buses
english.Plural(99, "locus", "loci") // 99 loci
```
### Word series
Format comma-separated words lists with conjuctions:
```go
english.WordSeries([]string{"foo"}, "and") // foo
english.WordSeries([]string{"foo", "bar"}, "and") // foo and bar
english.WordSeries([]string{"foo", "bar", "baz"}, "and") // foo, bar and baz
english.OxfordWordSeries([]string{"foo", "bar", "baz"}, "and") // foo, bar, and baz
```
[odisc]: https://groups.google.com/d/topic/golang-nuts/l8NhI74jl-4/discussion
[sinotation]: http://en.wikipedia.org/wiki/Metric_prefix
+31
View File
@@ -0,0 +1,31 @@
package humanize
import (
"math/big"
)
// order of magnitude (to a max order)
func oomm(n, b *big.Int, maxmag int) (float64, int) {
mag := 0
m := &big.Int{}
for n.Cmp(b) >= 0 {
n.DivMod(n, b, m)
mag++
if mag == maxmag && maxmag >= 0 {
break
}
}
return float64(n.Int64()) + (float64(m.Int64()) / float64(b.Int64())), mag
}
// total order of magnitude
// (same as above, but with no upper limit)
func oom(n, b *big.Int) (float64, int) {
mag := 0
m := &big.Int{}
for n.Cmp(b) >= 0 {
n.DivMod(n, b, m)
mag++
}
return float64(n.Int64()) + (float64(m.Int64()) / float64(b.Int64())), mag
}
+189
View File
@@ -0,0 +1,189 @@
package humanize
import (
"fmt"
"math/big"
"strings"
"unicode"
)
var (
bigIECExp = big.NewInt(1024)
// BigByte is one byte in bit.Ints
BigByte = big.NewInt(1)
// BigKiByte is 1,024 bytes in bit.Ints
BigKiByte = (&big.Int{}).Mul(BigByte, bigIECExp)
// BigMiByte is 1,024 k bytes in bit.Ints
BigMiByte = (&big.Int{}).Mul(BigKiByte, bigIECExp)
// BigGiByte is 1,024 m bytes in bit.Ints
BigGiByte = (&big.Int{}).Mul(BigMiByte, bigIECExp)
// BigTiByte is 1,024 g bytes in bit.Ints
BigTiByte = (&big.Int{}).Mul(BigGiByte, bigIECExp)
// BigPiByte is 1,024 t bytes in bit.Ints
BigPiByte = (&big.Int{}).Mul(BigTiByte, bigIECExp)
// BigEiByte is 1,024 p bytes in bit.Ints
BigEiByte = (&big.Int{}).Mul(BigPiByte, bigIECExp)
// BigZiByte is 1,024 e bytes in bit.Ints
BigZiByte = (&big.Int{}).Mul(BigEiByte, bigIECExp)
// BigYiByte is 1,024 z bytes in bit.Ints
BigYiByte = (&big.Int{}).Mul(BigZiByte, bigIECExp)
// BigRiByte is 1,024 y bytes in bit.Ints
BigRiByte = (&big.Int{}).Mul(BigYiByte, bigIECExp)
// BigQiByte is 1,024 r bytes in bit.Ints
BigQiByte = (&big.Int{}).Mul(BigRiByte, bigIECExp)
)
var (
bigSIExp = big.NewInt(1000)
// BigSIByte is one SI byte in big.Ints
BigSIByte = big.NewInt(1)
// BigKByte is 1,000 SI bytes in big.Ints
BigKByte = (&big.Int{}).Mul(BigSIByte, bigSIExp)
// BigMByte is 1,000 SI k bytes in big.Ints
BigMByte = (&big.Int{}).Mul(BigKByte, bigSIExp)
// BigGByte is 1,000 SI m bytes in big.Ints
BigGByte = (&big.Int{}).Mul(BigMByte, bigSIExp)
// BigTByte is 1,000 SI g bytes in big.Ints
BigTByte = (&big.Int{}).Mul(BigGByte, bigSIExp)
// BigPByte is 1,000 SI t bytes in big.Ints
BigPByte = (&big.Int{}).Mul(BigTByte, bigSIExp)
// BigEByte is 1,000 SI p bytes in big.Ints
BigEByte = (&big.Int{}).Mul(BigPByte, bigSIExp)
// BigZByte is 1,000 SI e bytes in big.Ints
BigZByte = (&big.Int{}).Mul(BigEByte, bigSIExp)
// BigYByte is 1,000 SI z bytes in big.Ints
BigYByte = (&big.Int{}).Mul(BigZByte, bigSIExp)
// BigRByte is 1,000 SI y bytes in big.Ints
BigRByte = (&big.Int{}).Mul(BigYByte, bigSIExp)
// BigQByte is 1,000 SI r bytes in big.Ints
BigQByte = (&big.Int{}).Mul(BigRByte, bigSIExp)
)
var bigBytesSizeTable = map[string]*big.Int{
"b": BigByte,
"kib": BigKiByte,
"kb": BigKByte,
"mib": BigMiByte,
"mb": BigMByte,
"gib": BigGiByte,
"gb": BigGByte,
"tib": BigTiByte,
"tb": BigTByte,
"pib": BigPiByte,
"pb": BigPByte,
"eib": BigEiByte,
"eb": BigEByte,
"zib": BigZiByte,
"zb": BigZByte,
"yib": BigYiByte,
"yb": BigYByte,
"rib": BigRiByte,
"rb": BigRByte,
"qib": BigQiByte,
"qb": BigQByte,
// Without suffix
"": BigByte,
"ki": BigKiByte,
"k": BigKByte,
"mi": BigMiByte,
"m": BigMByte,
"gi": BigGiByte,
"g": BigGByte,
"ti": BigTiByte,
"t": BigTByte,
"pi": BigPiByte,
"p": BigPByte,
"ei": BigEiByte,
"e": BigEByte,
"z": BigZByte,
"zi": BigZiByte,
"y": BigYByte,
"yi": BigYiByte,
"r": BigRByte,
"ri": BigRiByte,
"q": BigQByte,
"qi": BigQiByte,
}
var ten = big.NewInt(10)
func humanateBigBytes(s, base *big.Int, sizes []string) string {
if s.Cmp(ten) < 0 {
return fmt.Sprintf("%d B", s)
}
c := (&big.Int{}).Set(s)
val, mag := oomm(c, base, len(sizes)-1)
suffix := sizes[mag]
f := "%.0f %s"
if val < 10 {
f = "%.1f %s"
}
return fmt.Sprintf(f, val, suffix)
}
// BigBytes produces a human readable representation of an SI size.
//
// See also: ParseBigBytes.
//
// BigBytes(82854982) -> 83 MB
func BigBytes(s *big.Int) string {
sizes := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB", "RB", "QB"}
return humanateBigBytes(s, bigSIExp, sizes)
}
// BigIBytes produces a human readable representation of an IEC size.
//
// See also: ParseBigBytes.
//
// BigIBytes(82854982) -> 79 MiB
func BigIBytes(s *big.Int) string {
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB", "RiB", "QiB"}
return humanateBigBytes(s, bigIECExp, sizes)
}
// ParseBigBytes parses a string representation of bytes into the number
// of bytes it represents.
//
// See also: BigBytes, BigIBytes.
//
// ParseBigBytes("42 MB") -> 42000000, nil
// ParseBigBytes("42 mib") -> 44040192, nil
func ParseBigBytes(s string) (*big.Int, error) {
lastDigit := 0
hasComma := false
for _, r := range s {
if !(unicode.IsDigit(r) || r == '.' || r == ',') {
break
}
if r == ',' {
hasComma = true
}
lastDigit++
}
num := s[:lastDigit]
if hasComma {
num = strings.Replace(num, ",", "", -1)
}
val := &big.Rat{}
_, err := fmt.Sscanf(num, "%f", val)
if err != nil {
return nil, err
}
extra := strings.ToLower(strings.TrimSpace(s[lastDigit:]))
if m, ok := bigBytesSizeTable[extra]; ok {
mv := (&big.Rat{}).SetInt(m)
val.Mul(val, mv)
rv := &big.Int{}
rv.Div(val.Num(), val.Denom())
return rv, nil
}
return nil, fmt.Errorf("unhandled size name: %v", extra)
}
+143
View File
@@ -0,0 +1,143 @@
package humanize
import (
"fmt"
"math"
"strconv"
"strings"
"unicode"
)
// IEC Sizes.
// kibis of bits
const (
Byte = 1 << (iota * 10)
KiByte
MiByte
GiByte
TiByte
PiByte
EiByte
)
// SI Sizes.
const (
IByte = 1
KByte = IByte * 1000
MByte = KByte * 1000
GByte = MByte * 1000
TByte = GByte * 1000
PByte = TByte * 1000
EByte = PByte * 1000
)
var bytesSizeTable = map[string]uint64{
"b": Byte,
"kib": KiByte,
"kb": KByte,
"mib": MiByte,
"mb": MByte,
"gib": GiByte,
"gb": GByte,
"tib": TiByte,
"tb": TByte,
"pib": PiByte,
"pb": PByte,
"eib": EiByte,
"eb": EByte,
// Without suffix
"": Byte,
"ki": KiByte,
"k": KByte,
"mi": MiByte,
"m": MByte,
"gi": GiByte,
"g": GByte,
"ti": TiByte,
"t": TByte,
"pi": PiByte,
"p": PByte,
"ei": EiByte,
"e": EByte,
}
func logn(n, b float64) float64 {
return math.Log(n) / math.Log(b)
}
func humanateBytes(s uint64, base float64, sizes []string) string {
if s < 10 {
return fmt.Sprintf("%d B", s)
}
e := math.Floor(logn(float64(s), base))
suffix := sizes[int(e)]
val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10
f := "%.0f %s"
if val < 10 {
f = "%.1f %s"
}
return fmt.Sprintf(f, val, suffix)
}
// Bytes produces a human readable representation of an SI size.
//
// See also: ParseBytes.
//
// Bytes(82854982) -> 83 MB
func Bytes(s uint64) string {
sizes := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB"}
return humanateBytes(s, 1000, sizes)
}
// IBytes produces a human readable representation of an IEC size.
//
// See also: ParseBytes.
//
// IBytes(82854982) -> 79 MiB
func IBytes(s uint64) string {
sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
return humanateBytes(s, 1024, sizes)
}
// ParseBytes parses a string representation of bytes into the number
// of bytes it represents.
//
// See Also: Bytes, IBytes.
//
// ParseBytes("42 MB") -> 42000000, nil
// ParseBytes("42 mib") -> 44040192, nil
func ParseBytes(s string) (uint64, error) {
lastDigit := 0
hasComma := false
for _, r := range s {
if !(unicode.IsDigit(r) || r == '.' || r == ',') {
break
}
if r == ',' {
hasComma = true
}
lastDigit++
}
num := s[:lastDigit]
if hasComma {
num = strings.Replace(num, ",", "", -1)
}
f, err := strconv.ParseFloat(num, 64)
if err != nil {
return 0, err
}
extra := strings.ToLower(strings.TrimSpace(s[lastDigit:]))
if m, ok := bytesSizeTable[extra]; ok {
f *= float64(m)
if f >= math.MaxUint64 {
return 0, fmt.Errorf("too large: %v", s)
}
return uint64(f), nil
}
return 0, fmt.Errorf("unhandled size name: %v", extra)
}
+116
View File
@@ -0,0 +1,116 @@
package humanize
import (
"bytes"
"math"
"math/big"
"strconv"
"strings"
)
// Comma produces a string form of the given number in base 10 with
// commas after every three orders of magnitude.
//
// e.g. Comma(834142) -> 834,142
func Comma(v int64) string {
sign := ""
// Min int64 can't be negated to a usable value, so it has to be special cased.
if v == math.MinInt64 {
return "-9,223,372,036,854,775,808"
}
if v < 0 {
sign = "-"
v = 0 - v
}
parts := []string{"", "", "", "", "", "", ""}
j := len(parts) - 1
for v > 999 {
parts[j] = strconv.FormatInt(v%1000, 10)
switch len(parts[j]) {
case 2:
parts[j] = "0" + parts[j]
case 1:
parts[j] = "00" + parts[j]
}
v = v / 1000
j--
}
parts[j] = strconv.Itoa(int(v))
return sign + strings.Join(parts[j:], ",")
}
// Commaf produces a string form of the given number in base 10 with
// commas after every three orders of magnitude.
//
// e.g. Commaf(834142.32) -> 834,142.32
func Commaf(v float64) string {
buf := &bytes.Buffer{}
if v < 0 {
buf.Write([]byte{'-'})
v = 0 - v
}
comma := []byte{','}
parts := strings.Split(strconv.FormatFloat(v, 'f', -1, 64), ".")
pos := 0
if len(parts[0])%3 != 0 {
pos += len(parts[0]) % 3
buf.WriteString(parts[0][:pos])
buf.Write(comma)
}
for ; pos < len(parts[0]); pos += 3 {
buf.WriteString(parts[0][pos : pos+3])
buf.Write(comma)
}
buf.Truncate(buf.Len() - 1)
if len(parts) > 1 {
buf.Write([]byte{'.'})
buf.WriteString(parts[1])
}
return buf.String()
}
// CommafWithDigits works like the Commaf but limits the resulting
// string to the given number of decimal places.
//
// e.g. CommafWithDigits(834142.32, 1) -> 834,142.3
func CommafWithDigits(f float64, decimals int) string {
return stripTrailingDigits(Commaf(f), decimals)
}
// BigComma produces a string form of the given big.Int in base 10
// with commas after every three orders of magnitude.
func BigComma(b *big.Int) string {
sign := ""
if b.Sign() < 0 {
sign = "-"
b.Abs(b)
}
athousand := big.NewInt(1000)
c := (&big.Int{}).Set(b)
_, m := oom(c, athousand)
parts := make([]string, m+1)
j := len(parts) - 1
mod := &big.Int{}
for b.Cmp(athousand) >= 0 {
b.DivMod(b, athousand, mod)
parts[j] = strconv.FormatInt(mod.Int64(), 10)
switch len(parts[j]) {
case 2:
parts[j] = "0" + parts[j]
case 1:
parts[j] = "00" + parts[j]
}
j--
}
parts[j] = strconv.Itoa(int(b.Int64()))
return sign + strings.Join(parts[j:], ",")
}
+41
View File
@@ -0,0 +1,41 @@
//go:build go1.6
// +build go1.6
package humanize
import (
"bytes"
"math/big"
"strings"
)
// BigCommaf produces a string form of the given big.Float in base 10
// with commas after every three orders of magnitude.
func BigCommaf(v *big.Float) string {
buf := &bytes.Buffer{}
if v.Sign() < 0 {
buf.Write([]byte{'-'})
v.Abs(v)
}
comma := []byte{','}
parts := strings.Split(v.Text('f', -1), ".")
pos := 0
if len(parts[0])%3 != 0 {
pos += len(parts[0]) % 3
buf.WriteString(parts[0][:pos])
buf.Write(comma)
}
for ; pos < len(parts[0]); pos += 3 {
buf.WriteString(parts[0][pos : pos+3])
buf.Write(comma)
}
buf.Truncate(buf.Len() - 1)
if len(parts) > 1 {
buf.Write([]byte{'.'})
buf.WriteString(parts[1])
}
return buf.String()
}
+49
View File
@@ -0,0 +1,49 @@
package humanize
import (
"strconv"
"strings"
)
func stripTrailingZeros(s string) string {
if !strings.ContainsRune(s, '.') {
return s
}
offset := len(s) - 1
for offset > 0 {
if s[offset] == '.' {
offset--
break
}
if s[offset] != '0' {
break
}
offset--
}
return s[:offset+1]
}
func stripTrailingDigits(s string, digits int) string {
if i := strings.Index(s, "."); i >= 0 {
if digits <= 0 {
return s[:i]
}
i++
if i+digits >= len(s) {
return s
}
return s[:i+digits]
}
return s
}
// Ftoa converts a float to a string with no trailing zeros.
func Ftoa(num float64) string {
return stripTrailingZeros(strconv.FormatFloat(num, 'f', 6, 64))
}
// FtoaWithDigits converts a float to a string but limits the resulting string
// to the given number of decimal places, and no trailing zeros.
func FtoaWithDigits(num float64, digits int) string {
return stripTrailingZeros(stripTrailingDigits(strconv.FormatFloat(num, 'f', 6, 64), digits))
}
+8
View File
@@ -0,0 +1,8 @@
/*
Package humanize converts boring ugly numbers to human-friendly strings and back.
Durations can be turned into strings such as "3 days ago", numbers
representing sizes like 82854982 into useful strings like, "83 MB" or
"79 MiB" (whichever you prefer).
*/
package humanize
+192
View File
@@ -0,0 +1,192 @@
package humanize
/*
Slightly adapted from the source to fit go-humanize.
Author: https://github.com/gorhill
Source: https://gist.github.com/gorhill/5285193
*/
import (
"math"
"strconv"
)
var (
renderFloatPrecisionMultipliers = [...]float64{
1,
10,
100,
1000,
10000,
100000,
1000000,
10000000,
100000000,
1000000000,
}
renderFloatPrecisionRounders = [...]float64{
0.5,
0.05,
0.005,
0.0005,
0.00005,
0.000005,
0.0000005,
0.00000005,
0.000000005,
0.0000000005,
}
)
// FormatFloat produces a formatted number as string based on the following user-specified criteria:
// * thousands separator
// * decimal separator
// * decimal precision
//
// Usage: s := RenderFloat(format, n)
// The format parameter tells how to render the number n.
//
// See examples: http://play.golang.org/p/LXc1Ddm1lJ
//
// Examples of format strings, given n = 12345.6789:
// "#,###.##" => "12,345.67"
// "#,###." => "12,345"
// "#,###" => "12345,678"
// "#\u202F###,##" => "12 345,68"
// "#.###,###### => 12.345,678900
// "" (aka default format) => 12,345.67
//
// The highest precision allowed is 9 digits after the decimal symbol.
// There is also a version for integer number, FormatInteger(),
// which is convenient for calls within template.
func FormatFloat(format string, n float64) string {
// Special cases:
// NaN = "NaN"
// +Inf = "+Infinity"
// -Inf = "-Infinity"
if math.IsNaN(n) {
return "NaN"
}
if n > math.MaxFloat64 {
return "Infinity"
}
if n < (0.0 - math.MaxFloat64) {
return "-Infinity"
}
// default format
precision := 2
decimalStr := "."
thousandStr := ","
positiveStr := ""
negativeStr := "-"
if len(format) > 0 {
format := []rune(format)
// If there is an explicit format directive,
// then default values are these:
precision = 9
thousandStr = ""
// collect indices of meaningful formatting directives
formatIndx := []int{}
for i, char := range format {
if char != '#' && char != '0' {
formatIndx = append(formatIndx, i)
}
}
if len(formatIndx) > 0 {
// Directive at index 0:
// Must be a '+'
// Raise an error if not the case
// index: 0123456789
// +0.000,000
// +000,000.0
// +0000.00
// +0000
if formatIndx[0] == 0 {
if format[formatIndx[0]] != '+' {
panic("RenderFloat(): invalid positive sign directive")
}
positiveStr = "+"
formatIndx = formatIndx[1:]
}
// Two directives:
// First is thousands separator
// Raise an error if not followed by 3-digit
// 0123456789
// 0.000,000
// 000,000.00
if len(formatIndx) == 2 {
if (formatIndx[1] - formatIndx[0]) != 4 {
panic("RenderFloat(): thousands separator directive must be followed by 3 digit-specifiers")
}
thousandStr = string(format[formatIndx[0]])
formatIndx = formatIndx[1:]
}
// One directive:
// Directive is decimal separator
// The number of digit-specifier following the separator indicates wanted precision
// 0123456789
// 0.00
// 000,0000
if len(formatIndx) == 1 {
decimalStr = string(format[formatIndx[0]])
precision = len(format) - formatIndx[0] - 1
}
}
}
// generate sign part
var signStr string
if n >= 0.000000001 {
signStr = positiveStr
} else if n <= -0.000000001 {
signStr = negativeStr
n = -n
} else {
signStr = ""
n = 0.0
}
// split number into integer and fractional parts
intf, fracf := math.Modf(n + renderFloatPrecisionRounders[precision])
// generate integer part string
intStr := strconv.FormatInt(int64(intf), 10)
// add thousand separator if required
if len(thousandStr) > 0 {
for i := len(intStr); i > 3; {
i -= 3
intStr = intStr[:i] + thousandStr + intStr[i:]
}
}
// no fractional part, we can leave now
if precision == 0 {
return signStr + intStr
}
// generate fractional part
fracStr := strconv.Itoa(int(fracf * renderFloatPrecisionMultipliers[precision]))
// may need padding
if len(fracStr) < precision {
fracStr = "000000000000000"[:precision-len(fracStr)] + fracStr
}
return signStr + intStr + decimalStr + fracStr
}
// FormatInteger produces a formatted number as string.
// See FormatFloat.
func FormatInteger(format string, n int) string {
return FormatFloat(format, float64(n))
}
+25
View File
@@ -0,0 +1,25 @@
package humanize
import "strconv"
// Ordinal gives you the input number in a rank/ordinal format.
//
// Ordinal(3) -> 3rd
func Ordinal(x int) string {
suffix := "th"
switch x % 10 {
case 1:
if x%100 != 11 {
suffix = "st"
}
case 2:
if x%100 != 12 {
suffix = "nd"
}
case 3:
if x%100 != 13 {
suffix = "rd"
}
}
return strconv.Itoa(x) + suffix
}
+127
View File
@@ -0,0 +1,127 @@
package humanize
import (
"errors"
"math"
"regexp"
"strconv"
)
var siPrefixTable = map[float64]string{
-30: "q", // quecto
-27: "r", // ronto
-24: "y", // yocto
-21: "z", // zepto
-18: "a", // atto
-15: "f", // femto
-12: "p", // pico
-9: "n", // nano
-6: "µ", // micro
-3: "m", // milli
0: "",
3: "k", // kilo
6: "M", // mega
9: "G", // giga
12: "T", // tera
15: "P", // peta
18: "E", // exa
21: "Z", // zetta
24: "Y", // yotta
27: "R", // ronna
30: "Q", // quetta
}
var revSIPrefixTable = revfmap(siPrefixTable)
// revfmap reverses the map and precomputes the power multiplier
func revfmap(in map[float64]string) map[string]float64 {
rv := map[string]float64{}
for k, v := range in {
rv[v] = math.Pow(10, k)
}
return rv
}
var riParseRegex *regexp.Regexp
func init() {
ri := `^([\-0-9.]+)\s?([`
for _, v := range siPrefixTable {
ri += v
}
ri += `]?)(.*)`
riParseRegex = regexp.MustCompile(ri)
}
// ComputeSI finds the most appropriate SI prefix for the given number
// and returns the prefix along with the value adjusted to be within
// that prefix.
//
// See also: SI, ParseSI.
//
// e.g. ComputeSI(2.2345e-12) -> (2.2345, "p")
func ComputeSI(input float64) (float64, string) {
if input == 0 {
return 0, ""
}
mag := math.Abs(input)
exponent := math.Floor(logn(mag, 10))
exponent = math.Floor(exponent/3) * 3
value := mag / math.Pow(10, exponent)
// Handle special case where value is exactly 1000.0
// Should return 1 M instead of 1000 k
if value == 1000.0 {
exponent += 3
value = mag / math.Pow(10, exponent)
}
value = math.Copysign(value, input)
prefix := siPrefixTable[exponent]
return value, prefix
}
// SI returns a string with default formatting.
//
// SI uses Ftoa to format float value, removing trailing zeros.
//
// See also: ComputeSI, ParseSI.
//
// e.g. SI(1000000, "B") -> 1 MB
// e.g. SI(2.2345e-12, "F") -> 2.2345 pF
func SI(input float64, unit string) string {
value, prefix := ComputeSI(input)
return Ftoa(value) + " " + prefix + unit
}
// SIWithDigits works like SI but limits the resulting string to the
// given number of decimal places.
//
// e.g. SIWithDigits(1000000, 0, "B") -> 1 MB
// e.g. SIWithDigits(2.2345e-12, 2, "F") -> 2.23 pF
func SIWithDigits(input float64, decimals int, unit string) string {
value, prefix := ComputeSI(input)
return FtoaWithDigits(value, decimals) + " " + prefix + unit
}
var errInvalid = errors.New("invalid input")
// ParseSI parses an SI string back into the number and unit.
//
// See also: SI, ComputeSI.
//
// e.g. ParseSI("2.2345 pF") -> (2.2345e-12, "F", nil)
func ParseSI(input string) (float64, string, error) {
found := riParseRegex.FindStringSubmatch(input)
if len(found) != 4 {
return 0, "", errInvalid
}
mag := revSIPrefixTable[found[2]]
unit := found[3]
base, err := strconv.ParseFloat(found[1], 64)
return base * mag, unit, err
}
+117
View File
@@ -0,0 +1,117 @@
package humanize
import (
"fmt"
"math"
"sort"
"time"
)
// Seconds-based time units
const (
Day = 24 * time.Hour
Week = 7 * Day
Month = 30 * Day
Year = 12 * Month
LongTime = 37 * Year
)
// Time formats a time into a relative string.
//
// Time(someT) -> "3 weeks ago"
func Time(then time.Time) string {
return RelTime(then, time.Now(), "ago", "from now")
}
// A RelTimeMagnitude struct contains a relative time point at which
// the relative format of time will switch to a new format string. A
// slice of these in ascending order by their "D" field is passed to
// CustomRelTime to format durations.
//
// The Format field is a string that may contain a "%s" which will be
// replaced with the appropriate signed label (e.g. "ago" or "from
// now") and a "%d" that will be replaced by the quantity.
//
// The DivBy field is the amount of time the time difference must be
// divided by in order to display correctly.
//
// e.g. if D is 2*time.Minute and you want to display "%d minutes %s"
// DivBy should be time.Minute so whatever the duration is will be
// expressed in minutes.
type RelTimeMagnitude struct {
D time.Duration
Format string
DivBy time.Duration
}
var defaultMagnitudes = []RelTimeMagnitude{
{time.Second, "now", time.Second},
{2 * time.Second, "1 second %s", 1},
{time.Minute, "%d seconds %s", time.Second},
{2 * time.Minute, "1 minute %s", 1},
{time.Hour, "%d minutes %s", time.Minute},
{2 * time.Hour, "1 hour %s", 1},
{Day, "%d hours %s", time.Hour},
{2 * Day, "1 day %s", 1},
{Week, "%d days %s", Day},
{2 * Week, "1 week %s", 1},
{Month, "%d weeks %s", Week},
{2 * Month, "1 month %s", 1},
{Year, "%d months %s", Month},
{18 * Month, "1 year %s", 1},
{2 * Year, "2 years %s", 1},
{LongTime, "%d years %s", Year},
{math.MaxInt64, "a long while %s", 1},
}
// RelTime formats a time into a relative string.
//
// It takes two times and two labels. In addition to the generic time
// delta string (e.g. 5 minutes), the labels are used applied so that
// the label corresponding to the smaller time is applied.
//
// RelTime(timeInPast, timeInFuture, "earlier", "later") -> "3 weeks earlier"
func RelTime(a, b time.Time, albl, blbl string) string {
return CustomRelTime(a, b, albl, blbl, defaultMagnitudes)
}
// CustomRelTime formats a time into a relative string.
//
// It takes two times two labels and a table of relative time formats.
// In addition to the generic time delta string (e.g. 5 minutes), the
// labels are used applied so that the label corresponding to the
// smaller time is applied.
func CustomRelTime(a, b time.Time, albl, blbl string, magnitudes []RelTimeMagnitude) string {
lbl := albl
diff := b.Sub(a)
if a.After(b) {
lbl = blbl
diff = a.Sub(b)
}
n := sort.Search(len(magnitudes), func(i int) bool {
return magnitudes[i].D > diff
})
if n >= len(magnitudes) {
n = len(magnitudes) - 1
}
mag := magnitudes[n]
args := []interface{}{}
escaped := false
for _, ch := range mag.Format {
if escaped {
switch ch {
case 's':
args = append(args, lbl)
case 'd':
args = append(args, diff/mag.DivBy)
}
escaped = false
} else {
escaped = ch == '%'
}
}
return fmt.Sprintf(mag.Format, args...)
}
+41
View File
@@ -0,0 +1,41 @@
# Changelog
## [1.6.0](https://github.com/google/uuid/compare/v1.5.0...v1.6.0) (2024-01-16)
### Features
* add Max UUID constant ([#149](https://github.com/google/uuid/issues/149)) ([c58770e](https://github.com/google/uuid/commit/c58770eb495f55fe2ced6284f93c5158a62e53e3))
### Bug Fixes
* fix typo in version 7 uuid documentation ([#153](https://github.com/google/uuid/issues/153)) ([016b199](https://github.com/google/uuid/commit/016b199544692f745ffc8867b914129ecb47ef06))
* Monotonicity in UUIDv7 ([#150](https://github.com/google/uuid/issues/150)) ([a2b2b32](https://github.com/google/uuid/commit/a2b2b32373ff0b1a312b7fdf6d38a977099698a6))
## [1.5.0](https://github.com/google/uuid/compare/v1.4.0...v1.5.0) (2023-12-12)
### Features
* Validate UUID without creating new UUID ([#141](https://github.com/google/uuid/issues/141)) ([9ee7366](https://github.com/google/uuid/commit/9ee7366e66c9ad96bab89139418a713dc584ae29))
## [1.4.0](https://github.com/google/uuid/compare/v1.3.1...v1.4.0) (2023-10-26)
### Features
* UUIDs slice type with Strings() convenience method ([#133](https://github.com/google/uuid/issues/133)) ([cd5fbbd](https://github.com/google/uuid/commit/cd5fbbdd02f3e3467ac18940e07e062be1f864b4))
### Fixes
* Clarify that Parse's job is to parse but not necessarily validate strings. (Documents current behavior)
## [1.3.1](https://github.com/google/uuid/compare/v1.3.0...v1.3.1) (2023-08-18)
### Bug Fixes
* Use .EqualFold() to parse urn prefixed UUIDs ([#118](https://github.com/google/uuid/issues/118)) ([574e687](https://github.com/google/uuid/commit/574e6874943741fb99d41764c705173ada5293f0))
## Changelog
+26
View File
@@ -0,0 +1,26 @@
# How to contribute
We definitely welcome patches and contribution to this project!
### Tips
Commits must be formatted according to the [Conventional Commits Specification](https://www.conventionalcommits.org).
Always try to include a test case! If it is not possible or not necessary,
please explain why in the pull request description.
### Releasing
Commits that would precipitate a SemVer change, as described in the Conventional
Commits Specification, will trigger [`release-please`](https://github.com/google-github-actions/release-please-action)
to create a release candidate pull request. Once submitted, `release-please`
will create a release.
For tips on how to work with `release-please`, see its documentation.
### Legal requirements
In order to protect both you and ourselves, you will need to sign the
[Contributor License Agreement](https://cla.developers.google.com/clas).
You may have already signed it for other Google projects.
+9
View File
@@ -0,0 +1,9 @@
Paul Borman <[email protected]>
bmatsuo
shawnps
theory
jboverfelt
dsymonds
cd1
wallclockbuilder
dansouza
+27
View File
@@ -0,0 +1,27 @@
Copyright (c) 2009,2014 Google Inc. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
* Neither the name of Google Inc. nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+21
View File
@@ -0,0 +1,21 @@
# uuid
The uuid package generates and inspects UUIDs based on
[RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122)
and DCE 1.1: Authentication and Security Services.
This package is based on the github.com/pborman/uuid package (previously named
code.google.com/p/go-uuid). It differs from these earlier packages in that
a UUID is a 16 byte array rather than a byte slice. One loss due to this
change is the ability to represent an invalid UUID (vs a NIL UUID).
###### Install
```sh
go get github.com/google/uuid
```
###### Documentation
[![Go Reference](https://pkg.go.dev/badge/github.com/google/uuid.svg)](https://pkg.go.dev/github.com/google/uuid)
Full `go doc` style documentation for the package can be viewed online without
installing this package by using the GoDoc site here:
http://pkg.go.dev/github.com/google/uuid
+80
View File
@@ -0,0 +1,80 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"encoding/binary"
"fmt"
"os"
)
// A Domain represents a Version 2 domain
type Domain byte
// Domain constants for DCE Security (Version 2) UUIDs.
const (
Person = Domain(0)
Group = Domain(1)
Org = Domain(2)
)
// NewDCESecurity returns a DCE Security (Version 2) UUID.
//
// The domain should be one of Person, Group or Org.
// On a POSIX system the id should be the users UID for the Person
// domain and the users GID for the Group. The meaning of id for
// the domain Org or on non-POSIX systems is site defined.
//
// For a given domain/id pair the same token may be returned for up to
// 7 minutes and 10 seconds.
func NewDCESecurity(domain Domain, id uint32) (UUID, error) {
uuid, err := NewUUID()
if err == nil {
uuid[6] = (uuid[6] & 0x0f) | 0x20 // Version 2
uuid[9] = byte(domain)
binary.BigEndian.PutUint32(uuid[0:], id)
}
return uuid, err
}
// NewDCEPerson returns a DCE Security (Version 2) UUID in the person
// domain with the id returned by os.Getuid.
//
// NewDCESecurity(Person, uint32(os.Getuid()))
func NewDCEPerson() (UUID, error) {
return NewDCESecurity(Person, uint32(os.Getuid()))
}
// NewDCEGroup returns a DCE Security (Version 2) UUID in the group
// domain with the id returned by os.Getgid.
//
// NewDCESecurity(Group, uint32(os.Getgid()))
func NewDCEGroup() (UUID, error) {
return NewDCESecurity(Group, uint32(os.Getgid()))
}
// Domain returns the domain for a Version 2 UUID. Domains are only defined
// for Version 2 UUIDs.
func (uuid UUID) Domain() Domain {
return Domain(uuid[9])
}
// ID returns the id for a Version 2 UUID. IDs are only defined for Version 2
// UUIDs.
func (uuid UUID) ID() uint32 {
return binary.BigEndian.Uint32(uuid[0:4])
}
func (d Domain) String() string {
switch d {
case Person:
return "Person"
case Group:
return "Group"
case Org:
return "Org"
}
return fmt.Sprintf("Domain%d", int(d))
}
+12
View File
@@ -0,0 +1,12 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package uuid generates and inspects UUIDs.
//
// UUIDs are based on RFC 4122 and DCE 1.1: Authentication and Security
// Services.
//
// A UUID is a 16 byte (128 bit) array. UUIDs may be used as keys to
// maps or compared directly.
package uuid
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"crypto/md5"
"crypto/sha1"
"hash"
)
// Well known namespace IDs and UUIDs
var (
NameSpaceDNS = Must(Parse("6ba7b810-9dad-11d1-80b4-00c04fd430c8"))
NameSpaceURL = Must(Parse("6ba7b811-9dad-11d1-80b4-00c04fd430c8"))
NameSpaceOID = Must(Parse("6ba7b812-9dad-11d1-80b4-00c04fd430c8"))
NameSpaceX500 = Must(Parse("6ba7b814-9dad-11d1-80b4-00c04fd430c8"))
Nil UUID // empty UUID, all zeros
// The Max UUID is special form of UUID that is specified to have all 128 bits set to 1.
Max = UUID{
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
}
)
// NewHash returns a new UUID derived from the hash of space concatenated with
// data generated by h. The hash should be at least 16 byte in length. The
// first 16 bytes of the hash are used to form the UUID. The version of the
// UUID will be the lower 4 bits of version. NewHash is used to implement
// NewMD5 and NewSHA1.
func NewHash(h hash.Hash, space UUID, data []byte, version int) UUID {
h.Reset()
h.Write(space[:]) //nolint:errcheck
h.Write(data) //nolint:errcheck
s := h.Sum(nil)
var uuid UUID
copy(uuid[:], s)
uuid[6] = (uuid[6] & 0x0f) | uint8((version&0xf)<<4)
uuid[8] = (uuid[8] & 0x3f) | 0x80 // RFC 4122 variant
return uuid
}
// NewMD5 returns a new MD5 (Version 3) UUID based on the
// supplied name space and data. It is the same as calling:
//
// NewHash(md5.New(), space, data, 3)
func NewMD5(space UUID, data []byte) UUID {
return NewHash(md5.New(), space, data, 3)
}
// NewSHA1 returns a new SHA1 (Version 5) UUID based on the
// supplied name space and data. It is the same as calling:
//
// NewHash(sha1.New(), space, data, 5)
func NewSHA1(space UUID, data []byte) UUID {
return NewHash(sha1.New(), space, data, 5)
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import "fmt"
// MarshalText implements encoding.TextMarshaler.
func (uuid UUID) MarshalText() ([]byte, error) {
var js [36]byte
encodeHex(js[:], uuid)
return js[:], nil
}
// UnmarshalText implements encoding.TextUnmarshaler.
func (uuid *UUID) UnmarshalText(data []byte) error {
id, err := ParseBytes(data)
if err != nil {
return err
}
*uuid = id
return nil
}
// MarshalBinary implements encoding.BinaryMarshaler.
func (uuid UUID) MarshalBinary() ([]byte, error) {
return uuid[:], nil
}
// UnmarshalBinary implements encoding.BinaryUnmarshaler.
func (uuid *UUID) UnmarshalBinary(data []byte) error {
if len(data) != 16 {
return fmt.Errorf("invalid UUID (got %d bytes)", len(data))
}
copy(uuid[:], data)
return nil
}
+90
View File
@@ -0,0 +1,90 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"sync"
)
var (
nodeMu sync.Mutex
ifname string // name of interface being used
nodeID [6]byte // hardware for version 1 UUIDs
zeroID [6]byte // nodeID with only 0's
)
// NodeInterface returns the name of the interface from which the NodeID was
// derived. The interface "user" is returned if the NodeID was set by
// SetNodeID.
func NodeInterface() string {
defer nodeMu.Unlock()
nodeMu.Lock()
return ifname
}
// SetNodeInterface selects the hardware address to be used for Version 1 UUIDs.
// If name is "" then the first usable interface found will be used or a random
// Node ID will be generated. If a named interface cannot be found then false
// is returned.
//
// SetNodeInterface never fails when name is "".
func SetNodeInterface(name string) bool {
defer nodeMu.Unlock()
nodeMu.Lock()
return setNodeInterface(name)
}
func setNodeInterface(name string) bool {
iname, addr := getHardwareInterface(name) // null implementation for js
if iname != "" && addr != nil {
ifname = iname
copy(nodeID[:], addr)
return true
}
// We found no interfaces with a valid hardware address. If name
// does not specify a specific interface generate a random Node ID
// (section 4.1.6)
if name == "" {
ifname = "random"
randomBits(nodeID[:])
return true
}
return false
}
// NodeID returns a slice of a copy of the current Node ID, setting the Node ID
// if not already set.
func NodeID() []byte {
defer nodeMu.Unlock()
nodeMu.Lock()
if nodeID == zeroID {
setNodeInterface("")
}
nid := nodeID
return nid[:]
}
// SetNodeID sets the Node ID to be used for Version 1 UUIDs. The first 6 bytes
// of id are used. If id is less than 6 bytes then false is returned and the
// Node ID is not set.
func SetNodeID(id []byte) bool {
if len(id) < 6 {
return false
}
defer nodeMu.Unlock()
nodeMu.Lock()
copy(nodeID[:], id)
ifname = "user"
return true
}
// NodeID returns the 6 byte node id encoded in uuid. It returns nil if uuid is
// not valid. The NodeID is only well defined for version 1 and 2 UUIDs.
func (uuid UUID) NodeID() []byte {
var node [6]byte
copy(node[:], uuid[10:])
return node[:]
}
+12
View File
@@ -0,0 +1,12 @@
// Copyright 2017 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build js
package uuid
// getHardwareInterface returns nil values for the JS version of the code.
// This removes the "net" dependency, because it is not used in the browser.
// Using the "net" library inflates the size of the transpiled JS code by 673k bytes.
func getHardwareInterface(name string) (string, []byte) { return "", nil }
+33
View File
@@ -0,0 +1,33 @@
// Copyright 2017 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// +build !js
package uuid
import "net"
var interfaces []net.Interface // cached list of interfaces
// getHardwareInterface returns the name and hardware address of interface name.
// If name is "" then the name and hardware address of one of the system's
// interfaces is returned. If no interfaces are found (name does not exist or
// there are no interfaces) then "", nil is returned.
//
// Only addresses of at least 6 bytes are returned.
func getHardwareInterface(name string) (string, []byte) {
if interfaces == nil {
var err error
interfaces, err = net.Interfaces()
if err != nil {
return "", nil
}
}
for _, ifs := range interfaces {
if len(ifs.HardwareAddr) >= 6 && (name == "" || name == ifs.Name) {
return ifs.Name, ifs.HardwareAddr
}
}
return "", nil
}
+118
View File
@@ -0,0 +1,118 @@
// Copyright 2021 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"bytes"
"database/sql/driver"
"encoding/json"
"fmt"
)
var jsonNull = []byte("null")
// NullUUID represents a UUID that may be null.
// NullUUID implements the SQL driver.Scanner interface so
// it can be used as a scan destination:
//
// var u uuid.NullUUID
// err := db.QueryRow("SELECT name FROM foo WHERE id=?", id).Scan(&u)
// ...
// if u.Valid {
// // use u.UUID
// } else {
// // NULL value
// }
//
type NullUUID struct {
UUID UUID
Valid bool // Valid is true if UUID is not NULL
}
// Scan implements the SQL driver.Scanner interface.
func (nu *NullUUID) Scan(value interface{}) error {
if value == nil {
nu.UUID, nu.Valid = Nil, false
return nil
}
err := nu.UUID.Scan(value)
if err != nil {
nu.Valid = false
return err
}
nu.Valid = true
return nil
}
// Value implements the driver Valuer interface.
func (nu NullUUID) Value() (driver.Value, error) {
if !nu.Valid {
return nil, nil
}
// Delegate to UUID Value function
return nu.UUID.Value()
}
// MarshalBinary implements encoding.BinaryMarshaler.
func (nu NullUUID) MarshalBinary() ([]byte, error) {
if nu.Valid {
return nu.UUID[:], nil
}
return []byte(nil), nil
}
// UnmarshalBinary implements encoding.BinaryUnmarshaler.
func (nu *NullUUID) UnmarshalBinary(data []byte) error {
if len(data) != 16 {
return fmt.Errorf("invalid UUID (got %d bytes)", len(data))
}
copy(nu.UUID[:], data)
nu.Valid = true
return nil
}
// MarshalText implements encoding.TextMarshaler.
func (nu NullUUID) MarshalText() ([]byte, error) {
if nu.Valid {
return nu.UUID.MarshalText()
}
return jsonNull, nil
}
// UnmarshalText implements encoding.TextUnmarshaler.
func (nu *NullUUID) UnmarshalText(data []byte) error {
id, err := ParseBytes(data)
if err != nil {
nu.Valid = false
return err
}
nu.UUID = id
nu.Valid = true
return nil
}
// MarshalJSON implements json.Marshaler.
func (nu NullUUID) MarshalJSON() ([]byte, error) {
if nu.Valid {
return json.Marshal(nu.UUID)
}
return jsonNull, nil
}
// UnmarshalJSON implements json.Unmarshaler.
func (nu *NullUUID) UnmarshalJSON(data []byte) error {
if bytes.Equal(data, jsonNull) {
*nu = NullUUID{}
return nil // valid null UUID
}
err := json.Unmarshal(data, &nu.UUID)
nu.Valid = err == nil
return err
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2016 Google Inc. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package uuid
import (
"database/sql/driver"
"fmt"
)
// Scan implements sql.Scanner so UUIDs can be read from databases transparently.
// Currently, database types that map to string and []byte are supported. Please
// consult database-specific driver documentation for matching types.
func (uuid *UUID) Scan(src interface{}) error {
switch src := src.(type) {
case nil:
return nil
case string:
// if an empty UUID comes from a table, we return a null UUID
if src == "" {
return nil
}
// see Parse for required string format
u, err := Parse(src)
if err != nil {
return fmt.Errorf("Scan: %v", err)
}
*uuid = u
case []byte:
// if an empty UUID comes from a table, we return a null UUID
if len(src) == 0 {
return nil
}
// assumes a simple slice of bytes if 16 bytes
// otherwise attempts to parse
if len(src) != 16 {
return uuid.Scan(string(src))
}
copy((*uuid)[:], src)
default:
return fmt.Errorf("Scan: unable to scan type %T into UUID", src)
}
return nil
}
// Value implements sql.Valuer so that UUIDs can be written to databases
// transparently. Currently, UUIDs map to strings. Please consult
// database-specific driver documentation for matching types.
func (uuid UUID) Value() (driver.Value, error) {
return uuid.String(), nil
}
Loaded 100 of 1520 files, more files were not shown because too many files have changed in this diff. Show more