Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a5fcf0213 | ||
|
|
3f34a02559 |
No files matched your search
@@ -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/*
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
|
Before Width: | Height: | Size: 101 KiB |
@@ -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
|
||||
|
||||

|
||||
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.
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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:[""],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:[""],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
|
||||
@@ -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>
|
||||
|
||||
<a href="{{ $root }}new" class="btn" id="nav-new-page">New Page</a>
|
||||
|
||||
<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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
<a href="{{ .root }}settings" class="btn" id="nav-settings">Settings</a>
|
||||
|
||||
<a href="{{ .root }}new" class="btn" id="nav-new-page">New Page</a>
|
||||
|
||||
<a href="{{ if .location }}{{ .location }}{{ else }}{{ .root }}{{ end }}" class="btn btn-primary" id="nav-home">Go Back</a>
|
||||
</section>
|
||||
</header>
|
||||
<section class="container">
|
||||
|
||||
@@ -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>
|
||||
|
||||
<a href="{{ $root }}about" class="btn">About</a>
|
||||
<a href="{{ $root }}about" class="btn" id="nav-about">About</a>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
{{ $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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
|
||||
<a href="{{ $root }}settings" class="btn" id="nav-settings">Settings</a>
|
||||
|
||||
<a href="{{ $root }}new" class="btn" id="nav-new-page">New Page</a>
|
||||
|
||||
{{ if not $page.hasBinary }}
|
||||
<a href="{{ $root }}edit/{{ $page.unique }}" class="btn btn-error">Attach Binary</a>
|
||||
|
||||
{{ 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>
|
||||
|
||||
<a href="{{ $root }}api/v1/json/pages/{{ $page.unique }}/export" class="btn" download>Export .rznb</a>
|
||||
|
||||
<button class="btn nb-search-toolbar-btn" type="button" id="open-notebook-search">
|
||||
<i class="icon icon-search"></i>
|
||||
<span>Search</span>
|
||||
</button>
|
||||
|
||||
<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>
|
||||
|
||||
<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 }}
|
||||
|
||||
<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>
|
||||
|
||||
<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">×</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()">×</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Edit Settings
|
||||
|
||||
<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> — 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> — 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>
|
||||
@@ -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>
|
||||
|
||||
<a href="{{ $root }}new" class="btn" id="nav-new-page">New Page</a>
|
||||
|
||||
<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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
};
|
||||
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "&", "&")
|
||||
output = strings.ReplaceAll(output, "<", "<")
|
||||
output = strings.ReplaceAll(output, ">", ">")
|
||||
output = strings.ReplaceAll(output, "\"", """)
|
||||
output = strings.ReplaceAll(output, "'", "'")
|
||||
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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}")
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 ./...
|
||||
@@ -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>
|
||||
@@ -0,0 +1,124 @@
|
||||
# Humane Units [](https://travis-ci.org/dustin/go-humanize) [](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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:], ",")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,9 @@
|
||||
Paul Borman <[email protected]>
|
||||
bmatsuo
|
||||
shawnps
|
||||
theory
|
||||
jboverfelt
|
||||
dsymonds
|
||||
cd1
|
||||
wallclockbuilder
|
||||
dansouza
|
||||
@@ -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.
|
||||
@@ -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
|
||||
[](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
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[:]
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||