41 Commits

Author SHA1 Message Date
bbc4fd4c32 Fix CI workflow
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 12s
CI / build-frontend (push) Successful in 51s
CI / build-backend (push) Successful in 35s
Publish NPM Package / publish (push) Failing after 21s
Build and Publish Docker Image / build (push) Successful in 10m25s
- Added a step to upload frontend build artifacts after the build process.
- Included a step to download the frontend assets in the backend build job, ensuring the backend has access to the latest frontend build.
2025-12-26 21:29:55 -06:00
06f3e6fa5a Update package version to 1.4.0 and switch Svelte adapter to static
Some checks failed
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 15s
CI / build-backend (push) Failing after 1m7s
CI / build-frontend (push) Successful in 1m14s
- Bumped the package version from 1.3.0 to 1.4.0 in both package.json and package-lock.json.
- Replaced '@sveltejs/adapter-node' with '@sveltejs/adapter-static' for improved deployment options.
- Added new scripts for desktop development and building.
2025-12-26 21:22:09 -06:00
548d5dbc35 Add script to inject service worker version from package.json
- Created a new script that reads the version from package.json and injects it into the service worker file (sw.js).
- This ensures the service worker uses the correct version for cache management and updates.
2025-12-26 21:21:21 -06:00
78c07e1c6b Update service worker to use versioning and improve cache management
- Changed CACHE_NAME to include a version number for better cache control.
- Enhanced cache deletion logic to target specific cache names.
- Added message event listener to allow clients to skip waiting for updates.
- Improved fetch handling to ensure offline support and better response caching.
2025-12-26 21:21:12 -06:00
4afe001117 Add Window interface to app.d.ts for file handling and logging
- Extended the Window interface to include a go property with methods for saving and loading files, as well as logging messages from the frontend.
- This enhancement supports improved interaction with the application’s file management features.
2025-12-26 21:21:01 -06:00
826e7d10d1 Add service worker update notifications and reload functionality
- Implemented logic to check for service worker updates and notify users when a new version is available.
- Added buttons to reload the application or dismiss the update notification.
- Enhanced service worker registration to handle updates and online status checks.
2025-12-26 21:20:52 -06:00
4646423f1d Change Svelte adapter from Node to Static
- Updated the Svelte configuration to use '@sveltejs/adapter-static' instead of '@sveltejs/adapter-node'.
- Configured adapter options for output directories and fallback handling.
2025-12-26 21:20:42 -06:00
a3a8e29a6d Update README 2025-12-26 21:20:26 -06:00
a2411bd176 Refactor CI workflow for frontend and backend builds
- Renamed jobs for clarity: 'check' to 'build-frontend' and 'build' to 'build-backend'.
- Updated action versions for checkout and setup-node.
- Added frontend build step and consolidated backend build process with Go setup.
- Enhanced frontend checks and build scripts for improved CI pipeline.
2025-12-26 21:20:19 -06:00
2465e2e42b Add file import/export functionality for Wails desktop and web browser
- Implemented file saving and loading capabilities for the IdentityGraph component, supporting both Wails desktop and web browser environments.
- Enhanced error handling for JSON parsing and validation of graph data format.
- Added logging for application startup and Wails environment detection.
2025-12-26 21:20:11 -06:00
3d81697538 Add wails.json configuration for Linking Tool
- Created wails.json to define project metadata, asset directory, and frontend build commands.
- Included author information for project attribution.
2025-12-26 21:20:03 -06:00
5896cd7064 Add main application and desktop API server implementation
- Introduced main.go for the server with CORS middleware and static asset handling.
- Added desktop/app.go for local API server with logging and file handling capabilities.
- Implemented desktop/main.go to initialize the application with Wails framework and asset management.
2025-12-26 21:19:57 -06:00
9ffce1e12f Update Makefile 2025-12-26 21:19:44 -06:00
b4c65bf30b Add go.mod and go.sum files to manage dependencies for the linking tool 2025-12-26 21:19:30 -06:00
7d2eb81e0f Update ESLint configuration 2025-12-26 21:19:24 -06:00
837c5c471d Update Dockerfile to streamline multi-stage builds for frontend and Go binary 2025-12-26 21:19:14 -06:00
4b9a972706 Update .dockerignore and .gitignore 2025-12-26 21:18:41 -06:00
9dd65dbff8 Update README
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 16s
CI / check (push) Successful in 21s
CI / build (push) Successful in 34s
Publish NPM Package / publish (push) Successful in 44s
Build and Publish Docker Image / build (push) Successful in 8m18s
2025-12-25 16:09:43 -06:00
5eb10386de Refactor header layout in +page.svelte for improved responsiveness and accessibility, including link updates and style adjustments. 2025-12-25 16:07:11 -06:00
c3b0173da3 Remove unnecessary eslint directive from service worker file to streamline code. 2025-12-25 16:07:11 -06:00
0a4a5f7634 Refactor APP_VERSION definition to support multiple sources, including a global variable and npm package version, enhancing flexibility in version management. 2025-12-25 16:07:11 -06:00
ecc1253937 Update IdentityGraph component with link selection and mobile support. 2025-12-25 16:07:10 -06:00
de392d52ea Add linking tool script to set environment variables and import main application module 2025-12-25 16:07:10 -06:00
204dceeff7 Add workflow for publishing NPM packages, including setup for Node.js, dependency installation, packaging, and publishing to a custom registry. 2025-12-25 16:07:10 -06:00
410448b35d Add GitHub Actions workflow for building and publishing Docker images, including setup for QEMU, Docker Buildx, and metadata extraction. 2025-12-25 16:07:10 -06:00
119177d64c Add app version definition in Vite configuration using environment variable or package version 2025-12-25 16:07:10 -06:00
df9ed9465b Add mobile landscape screen breakpoint to Tailwind configuration for responsive design 2025-12-25 16:07:10 -06:00
c2de35082f Update Svelte configuration to use node adapter instead of auto adapter for improved environment compatibility. 2025-12-25 16:07:10 -06:00
bc63b4a42c Update package version to 1.3.0, rename package to @quad4/linking-tool, and add new fields for main entry, binary, and engines in package.json. Enhance package-lock.json with additional dependencies and metadata. 2025-12-25 16:07:09 -06:00
8d4e8cde81 Update Makefile with Docker support and additional npm commands for packaging and publishing 2025-12-25 16:07:09 -06:00
c9053cb0c6 Update ESLint configuration 2025-12-25 16:07:09 -06:00
8d2d520122 Add npm registry configuration and authentication token to .npmrc for package management 2025-12-25 16:07:09 -06:00
ivan
d6d8e8240f Update README.md
All checks were successful
CI / check (push) Successful in 24s
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 19s
CI / build (push) Successful in 30s
2025-12-25 04:59:48 +00:00
ivan
a16e96355f Upload files to "showcase"
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 16s
CI / check (push) Successful in 20s
CI / build (push) Successful in 35s
2025-12-25 04:58:56 +00:00
ea21931650 Update meta tags in +page.svelte for improved clarity and consistency in descriptions and titles.
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 17s
CI / check (push) Successful in 19s
CI / build (push) Successful in 32s
2025-12-24 22:09:54 -06:00
90de7a4850 Update version to 1.2.1
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 15s
CI / check (push) Successful in 20s
CI / build (push) Successful in 34s
2025-12-24 21:39:52 -06:00
0c9db82791 Update README 2025-12-24 21:38:41 -06:00
fa4ff7444d Fix link editing functionality in IdentityGraph component by introducing manual editing for relationship types, adding validation for shareable URLs, and improving input handling for relationship type selection. 2025-12-24 21:38:30 -06:00
8d82c160d1 Remove PNG export functionality from IdentityGraph component and associated button in the UI.
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 15s
CI / check (push) Successful in 19s
CI / build (push) Successful in 31s
2025-12-24 21:27:36 -06:00
99d94e092d Update version to 1.2.0 in package.json and package-lock.json for release. 2025-12-24 21:27:30 -06:00
003a88dcee Improve data validation in IdentityGraph component by adding checks for decoded data structure and image URL validity. Ensure nodes and links are properly validated before processing.
All checks were successful
OSV-Scanner Scheduled Scan / scan-scheduled (push) Successful in 17s
CI / check (push) Successful in 34s
CI / build (push) Successful in 32s
2025-12-24 21:24:36 -06:00
32 changed files with 1399 additions and 254 deletions

View File

@@ -1,14 +1,32 @@
node_modules
# Dependencies
node_modules/
vendor/
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# SvelteKit & Vite
.svelte-kit/
build/
dist/
.output/
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# OS
# Go & Binaries
bin/
linking-tool
tmp/
# Wails Desktop
desktop/frontend_dist/
desktop/build/
wailsjs/
# Git
.git
.gitignore
# IDE & OS
.vscode/
.idea/
.DS_Store
Thumbs.db
@@ -16,8 +34,3 @@ Thumbs.db
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

View File

@@ -7,33 +7,44 @@ on:
workflow_dispatch:
jobs:
check:
build-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Svelte check (fail on warnings)
- name: Frontend checks
run: bash scripts/check.sh
- name: Build frontend
run: bash scripts/build.sh
- name: Upload frontend assets
uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3.2.1
with:
name: frontend-build
path: build/
build:
build-backend:
runs-on: ubuntu-latest
needs: check
needs: build-frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Download frontend assets
uses: actions/download-artifact@9bc31d5ccc31df68ecc42ccf4149144866c47d8a # v3.0.2
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build
run: bash scripts/build.sh
name: frontend-build
path: build/
- name: Setup Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.25.4'
- name: Build backend
run: |
mkdir -p bin
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/linking-tool main.go

View File

@@ -0,0 +1,64 @@
name: Build and Publish Docker Image
on:
workflow_dispatch:
push:
tags:
- 'v*'
env:
REGISTRY: git.quad4.io
IMAGE_NAME: quad4-software/linking-tool
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image_digest: ${{ steps.build.outputs.digest }}
image_tags: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout repository
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392
with:
platforms: amd64,arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
- name: Log in to the Container registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch,prefix=,suffix=,enable={{is_default_branch}}
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=short
- name: Build and push Docker image
id: build
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -0,0 +1,36 @@
name: Publish NPM Package
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci --registry=https://registry.npmjs.org/
- name: Package
run: make package
- name: Configure npm for publishing
uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://git.quad4.io/api/packages/quad4-software/npm/'
- name: Publish
run: npm publish
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

40
.gitignore vendored
View File

@@ -1,23 +1,35 @@
node_modules
# Dependencies
node_modules/
vendor/
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# SvelteKit & Vite
.svelte-kit/
build/
dist/
.output/
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# OS
# Go & Binaries
bin/
linking-tool
tmp/
# Wails Desktop
desktop/frontend_dist/
desktop/build/bin/
desktop/build/
wailsjs/
# IDE & OS
.vscode/
.idea/
.DS_Store
Thumbs.db
*.swp
*.swo
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

3
.npmrc
View File

@@ -1 +1,2 @@
engine-strict=true
@quad4:registry=https://git.quad4.io/api/packages/quad4-software/npm/
//git.quad4.io/api/packages/quad4-software/npm/:_authToken=${NPM_TOKEN}

View File

@@ -1,33 +1,30 @@
FROM cgr.dev/chainguard/node:latest-dev AS builder
# Stage 1: Build the frontend
FROM cgr.dev/chainguard/node:latest-dev AS node-builder
WORKDIR /app
COPY --chown=node:node package.json package-lock.json ./
RUN npm ci
RUN npm install --save-dev @sveltejs/adapter-node@latest
COPY --chown=node:node . .
COPY --chown=node:node svelte.config.docker.js svelte.config.js
RUN npm run build
FROM cgr.dev/chainguard/node:latest AS runtime
# Stage 2: Build the Go binary with embedded assets
FROM cgr.dev/chainguard/go:latest-dev AS go-builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=node-builder /app/build ./build
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o linking-tool main.go
COPY --from=builder --chown=node:node /app/package.json /app/package-lock.json ./
RUN npm install --omit=dev && \
npm cache clean --force
COPY --from=builder --chown=node:node /app/build ./build
COPY --from=builder --chown=node:node /app/package.json ./
EXPOSE 3000
# Stage 3: Minimal runtime image
FROM cgr.dev/chainguard/wolfi-base:latest
WORKDIR /app
COPY --from=go-builder /app/linking-tool .
RUN apk add --no-cache ca-certificates
EXPOSE 8080
ENV PORT=8080
ENV NODE_ENV=production
ENV PORT=3000
ENV HOST=0.0.0.0
CMD ["build/index.js"]
USER 65532
CMD ["./linking-tool"]

41
Dockerfile.build Normal file
View File

@@ -0,0 +1,41 @@
FROM cgr.dev/chainguard/node:latest-dev AS node-builder
WORKDIR /app
COPY --chown=node:node package.json package-lock.json ./
RUN npm ci
COPY --chown=node:node . .
RUN npm run build
FROM golang:alpine AS builder
# Install dependencies for Wails on Alpine
# Added webkit2gtk-4.1-dev which is the modern package name in Alpine
RUN apk add --no-cache \
git \
make \
gcc \
musl-dev \
pkgconfig \
gtk+3.0-dev \
webkit2gtk-4.1-dev \
curl
# Install Wails
RUN go install github.com/wailsapp/wails/v2/cmd/wails@latest
ENV PATH=$PATH:/root/go/bin
WORKDIR /app
COPY . .
COPY --from=node-builder /app/build ./build
# Build the Go server
RUN mkdir -p bin && \
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/linking-tool main.go
# Build desktop apps
RUN mkdir -p desktop/frontend_dist && \
cp -r build/* desktop/frontend_dist/ && \
cd desktop && wails build -s -platform linux/amd64 -o linking-tool-linux
FROM scratch
COPY --from=builder /app/bin /bin
COPY --from=builder /app/desktop/build/bin /desktop-bin

View File

@@ -1,32 +1,92 @@
.PHONY: help install dev build preview check lint format clean
BINARY_NAME=linking-tool
BUILD_DIR=bin
.PHONY: help install dev build preview check lint format clean docker-build docker-run docker-builder release build-linux-amd64 build-linux-arm64 build-linux-armv6 build-linux-armv7 build-windows-amd64 build-darwin-amd64 build-darwin-arm64 build-freebsd-amd64 desktop-build desktop-windows desktop-darwin desktop-dev
help:
@echo 'Usage: make [target]'
@echo ''
@echo 'Available targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
install:
npm install
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
dev:
npm install
npm run dev
build:
npm install
npm run build
mkdir -p $(BUILD_DIR)
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(BUILD_DIR)/$(BINARY_NAME) main.go
preview:
npm run preview
release: build
mkdir -p $(BUILD_DIR)
@$(MAKE) build-linux-amd64
@$(MAKE) build-linux-arm64
@$(MAKE) build-linux-armv6
@$(MAKE) build-linux-armv7
@$(MAKE) build-windows-amd64
@$(MAKE) build-darwin-amd64
@$(MAKE) build-darwin-arm64
@$(MAKE) build-freebsd-amd64
check:
npm run check
build-linux-amd64:
GOOS=linux GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 main.go
lint:
npm run lint
build-linux-arm64:
GOOS=linux GOARCH=arm64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 main.go
format:
npm run format
build-linux-armv6:
GOOS=linux GOARCH=arm GOARM=6 go build -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv6 main.go
build-linux-armv7:
GOOS=linux GOARCH=arm GOARM=7 go build -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 main.go
build-windows-amd64:
GOOS=windows GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe main.go
build-darwin-amd64:
GOOS=darwin GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 main.go
build-darwin-arm64:
GOOS=darwin GOARCH=arm64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 main.go
build-freebsd-amd64:
GOOS=freebsd GOARCH=amd64 go build -o $(BUILD_DIR)/$(BINARY_NAME)-freebsd-amd64 main.go
docker-build:
docker build -t $(BINARY_NAME) .
docker-run:
docker run -p 8080:8080 $(BINARY_NAME)
docker-builder:
docker build -f Dockerfile.build -t $(BINARY_NAME)-build .
docker create --name $(BINARY_NAME)-temp $(BINARY_NAME)-build
mkdir -p $(BUILD_DIR)
docker cp $(BINARY_NAME)-temp:/bin/. $(BUILD_DIR)/
docker cp $(BINARY_NAME)-temp:/desktop-bin/. $(BUILD_DIR)/
docker rm $(BINARY_NAME)-temp
desktop-build: build
rm -rf desktop/frontend_dist/*
cp -r build/* desktop/frontend_dist/
cd desktop && wails build -s
desktop-windows: build
rm -rf desktop/frontend_dist/*
cp -r build/* desktop/frontend_dist/
cd desktop && wails build -s -platform windows/amd64
desktop-darwin: build
rm -rf desktop/frontend_dist/*
cp -r build/* desktop/frontend_dist/
cd desktop && wails build -s -platform darwin/universal
desktop-dev: build
rm -rf desktop/frontend_dist/*
cp -r build/* desktop/frontend_dist/
cd desktop && wails dev
clean:
rm -rf .svelte-kit build node_modules/.vite
rm -rf .svelte-kit build node_modules/.vite dist package linking-tool tmp $(BUILD_DIR)

View File

@@ -1,6 +1,8 @@
# Quad4 Linking Tool
A client-side identity graph visualization tool for mapping relationships between entities.
A client-side web linking tool for mapping relationships between entities.
<img src="showcase/linkingtool.png" alt="showcase image" width="900">
## Features
@@ -8,36 +10,78 @@ A client-side identity graph visualization tool for mapping relationships betwee
- Multiple entity types (person, email, phone, address, domain, org, IP, social)
- Auto-save to localStorage
- Import/Export JSON
- Export PNG snapshots
- Share link via base64 for smaller graphs
- Undo/Redo support
- Pan and zoom controls
- PWA support (installable, offline-capable)
- Self-hostable
- Desktop App support (via Wails)
- Single Binary Web Server (via Go)
- Mobile support
## Development
## Self-Hosting
### Go Binary
The easiest way to self-host is using the single binary:
```sh
./linking-tool --port 8080
```
### NPM
```sh
npm install
npm run dev
npm config set @quad4:registry https://git.quad4.io/api/packages/quad4-software/npm/
npm install -g @quad4/linking-tool
linking-tool
```
### Docker
```sh
docker run -p 8080:8080 git.quad4.io/quad4-software/linking-tool
```
## Desktop Application
You can build the desktop application for your platform using Wails:
```sh
make desktop-build
```
The binary will be located in `bin/`.
## Development
```sh
git clone https://git.quad4.io/quad4-software/linking-tool.git
cd linking-tool
```
### Makefile
```sh
make dev
```
## Docker
Uses Chainguard Images which are rootless and very minimal images.
The project uses a Makefile for all common tasks:
```sh
docker build -t quad4-linking-tool .
docker run -p 3000:3000 quad4-linking-tool
make dev # Run development servers (Go & SvelteKit)
make build # Build the single binary web server
make help # List all available targets
```
### Docker Build & Artifact Extraction
If you don't have the development environment (Go, Node, Wails) installed locally, you can build and extract binaries using Docker:
```sh
make docker-builder
```
This will build the server and desktop application inside a container and copy the resulting binaries to the `bin/` directory on your host machine.
## Contributing
Send us an email at [team@quad4.io](mailto:team@quad4.io) for any issues or feedback.
## LICENSE
[MIT](LICENSE)

7
bin/linking-tool.js Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env node
process.env.HOST = process.env.HOST || '127.0.0.1';
process.env.PORT = process.env.PORT || '3000';
import '../build/index.js';

159
desktop/app.go Normal file
View File

@@ -0,0 +1,159 @@
package main
import (
"context"
"fmt"
"net"
"net/http"
"os"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// App struct
type App struct {
ctx context.Context
port int
debug bool
}
// NewApp creates a new App struct
func NewApp(debug bool) *App {
return &App{
debug: debug,
}
}
func (a *App) logDebug(format string, args ...any) {
if a != nil && a.debug {
fmt.Printf("[debug] "+format+"\n", args...)
}
}
// logHandler wraps HTTP handlers to log requests when debug is enabled.
func (a *App) logHandler(next http.Handler) http.Handler {
if !a.debug {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
fmt.Printf("[debug] http %s %s %dms\n", r.Method, r.URL.Path, time.Since(start).Milliseconds())
})
}
// startup is called when the app starts. The context is saved
// so we can call the runtime methods
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
a.logDebug("startup begin")
// Start local API server on a random port
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Printf("Error starting local server: %v\n", err)
return
}
a.port = listener.Addr().(*net.TCPAddr).Port
a.logDebug("local API listener bound on %s", listener.Addr().String())
mux := http.NewServeMux()
// CORS middleware for local desktop API
cors := func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
}
}
mux.HandleFunc("/api/ping", cors(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"status":"ok"}`)
}))
server := &http.Server{
Addr: listener.Addr().String(),
Handler: a.logHandler(mux),
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
fmt.Printf("Error serving desktop API: %v\n", err)
}
}()
fmt.Printf("Desktop API server started on port %d\n", a.port)
a.logDebug("startup complete")
}
// GetAPIPort returns the port the local server is running on
func (a *App) GetAPIPort() int {
a.logDebug("GetAPIPort -> %d", a.port)
return a.port
}
// LogFrontend allows the frontend to log to the terminal
func (a *App) LogFrontend(message string) {
fmt.Printf("[frontend] %s\n", message)
}
// SaveFile shows a save dialog and writes the content to the selected file
func (a *App) SaveFile(filename string, content string) error {
a.logDebug("SaveFile filename=%s", filename)
filePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
DefaultFilename: filename,
Title: "Save Graph",
Filters: []runtime.FileFilter{
{
DisplayName: "JSON Files (*.json)",
Pattern: "*.json",
},
},
})
if err != nil {
return err
}
if filePath == "" {
return nil // Cancelled
}
return os.WriteFile(filePath, []byte(content), 0644)
}
// LoadFile shows an open dialog and returns the content of the selected file
func (a *App) LoadFile() (string, error) {
a.logDebug("LoadFile")
filePath, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "Open Graph",
Filters: []runtime.FileFilter{
{
DisplayName: "JSON Files (*.json)",
Pattern: "*.json",
},
},
})
if err != nil {
return "", err
}
if filePath == "" {
return "", nil // Cancelled
}
content, err := os.ReadFile(filePath)
if err != nil {
return "", err
}
return string(content), nil
}

53
desktop/main.go Normal file
View File

@@ -0,0 +1,53 @@
package main
import (
"embed"
"os"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
//go:embed all:frontend_dist
var assets embed.FS
func debugEnabled() bool {
for _, arg := range os.Args[1:] {
if arg == "--debug" || arg == "-d" {
return true
}
}
return false
}
func main() {
debug := debugEnabled()
if debug {
println("Debug logging enabled")
}
// Create an instance of the app structure
app := NewApp(debug)
// Create application with options
err := wails.Run(&options.App{
Title: "Linking Tool",
Width: 1280,
Height: 800,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
Bind: []interface{}{
app,
},
EnableDefaultContextMenu: true,
})
if err != nil {
println("Error:", err.Error())
}
}

15
desktop/wails.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "Linking Tool",
"assetdir": "frontend_dist",
"frontend:dir": "..",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:dev:serverUrl": "http://localhost:5173",
"outputfilename": "linking-tool",
"author": {
"name": "Quad4",
"email": "dev@quad4.io"
}
}

View File

@@ -32,12 +32,28 @@ export default [
Blob: 'readonly',
Event: 'readonly',
MouseEvent: 'readonly',
TouchEvent: 'readonly',
Touch: 'readonly',
WheelEvent: 'readonly',
KeyboardEvent: 'readonly',
URLSearchParams: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
localStorage: 'readonly',
sessionStorage: 'readonly',
Response: 'readonly',
Request: 'readonly',
Headers: 'readonly',
FormData: 'readonly',
ServiceWorkerRegistration: 'readonly',
location: 'readonly',
history: 'readonly',
addEventListener: 'readonly',
removeEventListener: 'readonly',
requestAnimationFrame: 'readonly',
queueMicrotask: 'readonly',
atob: 'readonly',
btoa: 'readonly',
alert: 'readonly',
@@ -71,6 +87,28 @@ export default [
},
},
{
ignores: ['node_modules/**', '.svelte-kit/**', 'build/**', 'dist/**', 'archive/**'],
files: ['bin/**/*.js'],
languageOptions: {
globals: {
process: 'readonly',
},
},
},
{
files: ['static/sw.js'],
languageOptions: {
globals: {
self: 'readonly',
caches: 'readonly',
fetch: 'readonly',
URL: 'readonly',
console: 'readonly',
Response: 'readonly',
Request: 'readonly',
},
},
},
{
ignores: ['node_modules/**', '.svelte-kit/**', 'build/**', 'dist/**', 'archive/**', 'desktop/frontend_dist/**', 'wailsjs/**'],
},
];

35
go.mod Normal file
View File

@@ -0,0 +1,35 @@
module git.quad4.io/Quad4-Software/linking-tool
go 1.24
require github.com/wailsapp/wails/v2 v2.11.0
require (
github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)

81
go.sum Normal file
View File

@@ -0,0 +1,81 @@
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

141
main.go Normal file
View File

@@ -0,0 +1,141 @@
package main
import (
"embed"
"flag"
"io/fs"
"log"
"net"
"net/http"
"os"
"strings"
"time"
)
//go:embed build/*
var buildAssets embed.FS
func corsMiddleware(allowedOrigins []string) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
next.ServeHTTP(w, r)
return
}
allowed := false
if len(allowedOrigins) == 0 {
allowed = true
} else {
for _, o := range allowedOrigins {
if o == "*" || o == origin {
allowed = true
break
}
}
}
if allowed {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
}
if r.Method == "OPTIONS" {
if allowed {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusForbidden)
}
return
}
if !allowed && len(allowedOrigins) > 0 {
log.Printf("Blocked CORS request from origin: %s", origin)
http.Error(w, "CORS Origin Not Allowed", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
}
}
}
func main() {
frontendPath := flag.String("frontend", "", "Path to custom frontend build directory (overrides embedded assets)")
host := flag.String("host", "0.0.0.0", "Host to bind the server to")
port := flag.String("port", "", "Port to listen on (overrides PORT env var)")
allowedOriginsStr := flag.String("allowed-origins", os.Getenv("ALLOWED_ORIGINS"), "Comma-separated list of allowed CORS origins")
flag.Parse()
var allowedOrigins []string
if *allowedOriginsStr != "" {
origins := strings.Split(*allowedOriginsStr, ",")
for _, o := range origins {
allowedOrigins = append(allowedOrigins, strings.TrimSpace(o))
}
}
if *port == "" {
*port = os.Getenv("PORT")
if *port == "" {
*port = "8080"
}
}
// Middleware chains
cors := corsMiddleware(allowedOrigins)
http.HandleFunc("/api/ping", cors(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
}))
// Static Assets
var staticFS fs.FS
if *frontendPath != "" {
log.Printf("Using custom frontend from: %s\n", *frontendPath)
staticFS = os.DirFS(*frontendPath)
} else {
sub, err := fs.Sub(buildAssets, "build")
if err != nil {
log.Fatal(err)
}
staticFS = sub
}
fileServer := http.FileServer(http.FS(staticFS))
// SPA Handler
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
_, err := staticFS.Open(path)
if err != nil {
// If file doesn't exist, serve index.html for SPA routing
r.URL.Path = "/"
}
fileServer.ServeHTTP(w, r)
})
addr := net.JoinHostPort(*host, *port)
log.Printf("Linking Tool server starting on %s...\n", addr)
server := &http.Server{
Addr: addr,
Handler: nil,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}

24
package-lock.json generated
View File

@@ -1,21 +1,24 @@
{
"name": "quad4-linking-tool",
"version": "1.0.0",
"name": "@quad4/linking-tool",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "quad4-linking-tool",
"version": "1.0.0",
"name": "@quad4/linking-tool",
"version": "1.4.0",
"dependencies": {
"autoprefixer": "^10.4.23",
"lucide-svelte": "^0.562.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19"
},
"bin": {
"linking-tool": "bin/linking-tool.js"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@typescript-eslint/eslint-plugin": "^8.50.1",
@@ -29,6 +32,9 @@
"svelte-eslint-parser": "^1.4.1",
"typescript": "^5.9.3",
"vite": "^7.2.6"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@alloc/quick-lru": {
@@ -1137,10 +1143,10 @@
"acorn": "^8.9.0"
}
},
"node_modules/@sveltejs/adapter-auto": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.0.tgz",
"integrity": "sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw==",
"node_modules/@sveltejs/adapter-static": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
"integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
"dev": true,
"license": "MIT",
"peerDependencies": {

View File

@@ -1,21 +1,39 @@
{
"name": "quad4-linking-tool",
"private": true,
"version": "1.0.0",
"name": "@quad4/linking-tool",
"version": "1.4.0",
"type": "module",
"main": "./build/index.js",
"bin": {
"linking-tool": "./bin/linking-tool.js"
},
"engines": {
"node": ">=18.0.0"
},
"publishConfig": {
"registry": "https://git.quad4.io/api/packages/quad4-software/npm/"
},
"files": [
"build/**/*",
"bin/**/*",
"README.md",
"LICENSE"
],
"scripts": {
"dev": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite dev",
"prebuild": "node scripts/inject-sw-version.js",
"build": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite build",
"preview": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "eslint ."
"lint": "eslint .",
"package": "svelte-kit sync && vite build",
"desktop:dev": "make desktop-dev",
"desktop:build": "make desktop-build"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.49.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@typescript-eslint/eslint-plugin": "^8.50.1",

1
package.json.md5 Executable file
View File

@@ -0,0 +1 @@
6da4cdcafa6966a9d35d5e1ce48583eb

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env node
import { readFileSync, writeFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const rootDir = join(__dirname, '..');
const packageJson = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf-8'));
const version = packageJson.version;
const swPath = join(rootDir, 'static', 'sw.js');
let swContent = readFileSync(swPath, 'utf-8');
swContent = swContent.replace(
/const CACHE_VERSION = ['"](.*?)['"];/,
`const CACHE_VERSION = '${version}';`
);
writeFileSync(swPath, swContent);
console.log(`Injected version ${version} into service worker`);

BIN
showcase/linkingtool.png Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

13
src/app.d.ts vendored
View File

@@ -8,6 +8,19 @@ declare global {
// interface PageState {}
// interface Platform {}
}
interface Window {
go?: {
main: {
App: {
SaveFile(filename: string, content: string): Promise<string>;
LoadFile(): Promise<string>;
LogFrontend(message: string): void;
};
};
};
runtime?: unknown;
}
}
export {};

View File

@@ -4,7 +4,6 @@
Plus,
Upload,
Download,
Image as ImageIcon,
Undo2,
Redo2,
Maximize,
@@ -103,14 +102,17 @@
let selectedNodeId: string | null = null;
let selectedNodeIds: Set<string> = new Set();
let selectedLinkId: string | null = null;
let draggedNodeId: string | null = null;
let draggedNodeIds: Set<string> = new Set();
let linkStartNodeId: string | null = null;
let hoverNodeId: string | null = null;
let editingLinkId: string | null = null;
let editingLinkLabel = '';
let editingLinkType: RelationshipType = 'Linked';
let editingLinkType: string = 'Linked';
let editingLinkTypeManuallyEdited = false;
let editingLinkStrength: RelationshipStrength = 'medium';
let linkTypeEditInput: HTMLInputElement | null = null;
let selectedNode: Node | null = null;
let isSelecting = false;
let selectionBox: { x1: number; y1: number; x2: number; y2: number } | null = null;
@@ -165,6 +167,7 @@
let imageUploadError = '';
let newNodeImageError = '';
let controlsCollapsed = false;
let isMobile = false;
let copiedNodes: Node[] = [];
let searchQuery = '';
let searchInput: HTMLInputElement | null = null;
@@ -176,16 +179,17 @@
let touchHoldTimeout: ReturnType<typeof setTimeout> | null = null;
let touchHoldNodeId: string | null = null;
let touchHoldStart = { x: 0, y: 0 };
let isLongPressing = false;
$: isLight = theme === 'light';
$: panelClass =
(isLight
? 'bg-white/60 border-amber-200 text-neutral-800 shadow-amber-900/10'
: 'bg-neutral-900/50 border-neutral-800 text-neutral-200 shadow-lg') + ' backdrop-blur';
isLight
? 'bg-white/95 border-amber-200 text-neutral-800 shadow-amber-900/10'
: 'bg-neutral-900/95 border-neutral-800 text-neutral-200 shadow-lg';
$: iconButtonClass = isLight
? 'p-1.5 md:p-2 rounded text-neutral-600 hover:text-neutral-900 hover:bg-amber-100'
: 'p-1.5 md:p-2 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white';
? 'p-2 md:p-2 mobile-landscape:p-1 rounded text-neutral-600 hover:text-neutral-900 hover:bg-amber-100'
: 'p-2 md:p-2 mobile-landscape:p-1 hover:bg-neutral-800 rounded text-neutral-400 hover:text-white';
$: dividerClass = isLight
? 'hidden md:block md:h-px bg-neutral-300 md:my-1'
: 'hidden md:block md:h-px bg-neutral-800 md:my-1';
@@ -478,11 +482,15 @@
try {
const decoded = atob(encoded);
const data = JSON.parse(decoded);
if (!data || typeof data !== 'object') return false;
if (!Array.isArray(data.nodes) || !Array.isArray(data.links)) return false;
if (data.nodes && data.links) {
pushState();
nodes = normalizeNodes(data.nodes);
links = data.links;
if (data.transform) {
if (data.transform && typeof data.transform === 'object') {
transform = data.transform;
} else {
centerView();
@@ -529,6 +537,14 @@
const encoded = btoa(jsonData);
const shareUrl = `${window.location.origin}${window.location.pathname}?graph=${encoded}`;
const MAX_URL_LENGTH = 2000;
if (shareUrl.length > MAX_URL_LENGTH) {
alert(
'Graph data is too large to share via URL. Please use the JSON export feature instead.'
);
return;
}
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(shareUrl);
alert('Share link copied to clipboard!');
@@ -561,13 +577,23 @@
links,
};
const jsonData = JSON.stringify(data, null, 2);
const defaultFilename = `quad4-linking-graph-${new Date().toISOString().slice(0, 10)}.json`;
try {
const jsonData = JSON.stringify(data, null, 2);
if (window.go?.main?.App?.SaveFile) {
// Wails desktop export
const err = await window.go.main.App.SaveFile(defaultFilename, jsonData);
if (err) throw new Error(err);
return;
}
// Web browser export
const blob = new Blob([jsonData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `quad4-linking-graph-${new Date().toISOString().slice(0, 10)}.json`;
link.download = defaultFilename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
@@ -580,6 +606,32 @@
async function importGraph() {
try {
if (window.go?.main?.App?.LoadFile) {
// Wails desktop import
const text = await window.go.main.App.LoadFile();
if (!text) return; // Cancelled
let data;
try {
data = JSON.parse(text);
} catch (parseErr) {
alert(`Failed to parse JSON: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`);
return;
}
if (data.nodes && data.links) {
pushState();
nodes = normalizeNodes(data.nodes);
links = data.links;
centerView();
saveToLocalStorage();
} else {
alert('Invalid graph format: file must contain nodes and links arrays');
}
return;
}
// Web browser import
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
@@ -616,55 +668,6 @@
}
}
async function exportPNG() {
if (!svgElement) return;
try {
const serializer = new XMLSerializer();
const svgStr = serializer.serializeToString(svgElement);
const svgBlob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
const bbox = svgElement.getBoundingClientRect();
canvas.width = bbox.width * 2;
canvas.height = bbox.height * 2;
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.scale(2, 2);
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
if (blob) {
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = `quad4-linking-snapshot-${new Date().toISOString().slice(0, 10)}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
URL.revokeObjectURL(url);
}
}, 'image/png');
};
img.onerror = () => {
console.error('Failed to load SVG image');
URL.revokeObjectURL(url);
};
img.src = url;
} catch (err) {
console.error('Failed to export PNG', err);
}
}
function getScreenCoords(e: MouseEvent) {
const rect = containerElement.getBoundingClientRect();
return {
@@ -712,6 +715,7 @@
isPanning = true;
selectedNodeId = null;
selectedNodeIds.clear();
selectedLinkId = null;
containerElement.style.cursor = 'grabbing';
}
}
@@ -721,6 +725,15 @@
e.stopPropagation();
const mouse = getScreenCoords(e);
lastMouse = mouse;
selectedLinkId = null;
if (isMobile && selectedNodeId && selectedNodeId !== nodeId && !isLinking) {
addLink(selectedNodeId, nodeId);
selectedNodeId = nodeId;
selectedNodeIds.clear();
selectedNodeIds.add(nodeId);
return;
}
if (e.shiftKey) {
isLinking = true;
@@ -758,22 +771,30 @@
clearTouchHold();
touchHoldNodeId = nodeId;
touchHoldStart = { x: touch.clientX, y: touch.clientY };
handleNodeMouseDown(
new MouseEvent('mousedown', {
clientX: touch.clientX,
clientY: touch.clientY,
button: 0,
bubbles: true,
}),
nodeId
);
isLongPressing = false;
if (selectedNodeId && selectedNodeId !== nodeId && !isLinking) {
addLink(selectedNodeId, nodeId);
selectedNodeId = nodeId;
selectedNodeIds.clear();
selectedNodeIds.add(nodeId);
return;
}
selectedNodeId = nodeId;
selectedNodeIds.clear();
selectedNodeIds.add(nodeId);
selectedLinkId = null;
touchHoldTimeout = setTimeout(() => {
if (touchHoldNodeId === nodeId) {
selectedNodeId = nodeId;
selectedNodeIds.clear();
selectedNodeIds.add(nodeId);
isLongPressing = true;
isDragging = true;
draggedNodeId = nodeId;
draggedNodeIds.clear();
draggedNodeIds.add(nodeId);
}
}, 450);
}, 300);
}
function handleMouseMove(e: MouseEvent) {
@@ -825,7 +846,7 @@
}
}
function handleMouseUp(_e?: MouseEvent) {
function handleMouseUp() {
if (isDragging && (draggedNodeId || draggedNodeIds.size > 0)) {
saveToLocalStorage();
}
@@ -854,6 +875,7 @@
touchHoldTimeout = null;
}
touchHoldNodeId = null;
isLongPressing = false;
}
function touchToMouseEvent(
@@ -878,22 +900,22 @@
function handleTouchMove(e: TouchEvent) {
if (e.touches.length === 0) return;
const touch = e.touches[0];
if (touchHoldTimeout) {
if (touchHoldTimeout && !isLongPressing) {
const dx = touch.clientX - touchHoldStart.x;
const dy = touch.clientY - touchHoldStart.y;
if (Math.hypot(dx, dy) > 10) {
clearTouchHold();
}
}
handleMouseMove(touchToMouseEvent(touch, 'mousemove'));
if (isLongPressing && touchHoldNodeId) {
handleMouseMove(touchToMouseEvent(touch, 'mousemove'));
}
}
function handleTouchEnd(e: TouchEvent) {
function handleTouchEnd() {
const wasLongPressing = isLongPressing;
clearTouchHold();
const touch = e.changedTouches[0] || e.touches[0];
if (touch) {
handleMouseUp();
} else {
if (wasLongPressing) {
handleMouseUp();
}
}
@@ -960,6 +982,10 @@
}
function deleteSelected() {
if (selectedLinkId) {
deleteLink(selectedLinkId);
return;
}
if (selectedNodeIds.size === 0 && !selectedNodeId) return;
pushState();
const idsToDelete =
@@ -975,12 +1001,29 @@
saveToLocalStorage();
}
function deleteLink(linkId: string) {
pushState();
links = links.filter((l) => l.id !== linkId);
selectedLinkId = null;
saveToLocalStorage();
}
function selectLink(linkId: string, e: MouseEvent) {
e.stopPropagation();
selectedLinkId = linkId;
selectedNodeId = null;
selectedNodeIds.clear();
}
function startEditingLink(linkId: string, e: MouseEvent) {
e.stopPropagation();
const link = links.find((l) => l.id === linkId);
if (link) {
selectedLinkId = linkId;
editingLinkId = linkId;
editingLinkLabel = link.label;
editingLinkType = link.type || 'Linked';
editingLinkTypeManuallyEdited = false;
setTimeout(() => linkEditInput?.focus(), 10);
}
}
@@ -988,12 +1031,13 @@
function saveLinkEdit() {
if (!editingLinkId) return;
pushState();
const linkType = editingLinkType.trim() || 'Linked';
links = links.map((l) =>
l.id === editingLinkId
? {
...l,
label: editingLinkLabel.trim() || editingLinkType,
type: editingLinkType,
label: editingLinkLabel.trim() || linkType,
type: linkType as RelationshipType,
strength: editingLinkStrength,
}
: l
@@ -1001,6 +1045,7 @@
editingLinkId = null;
editingLinkLabel = '';
editingLinkType = 'Linked';
editingLinkTypeManuallyEdited = false;
editingLinkStrength = 'medium';
saveToLocalStorage();
}
@@ -1008,6 +1053,18 @@
function cancelLinkEdit() {
editingLinkId = null;
editingLinkLabel = '';
editingLinkType = 'Linked';
editingLinkTypeManuallyEdited = false;
}
function handleRelationshipTypeButtonClick(relType: RelationshipType) {
if (!editingLinkTypeManuallyEdited) {
editingLinkType = relType;
}
}
function handleRelationshipTypeInput() {
editingLinkTypeManuallyEdited = true;
}
function centerView() {
@@ -1044,9 +1101,22 @@
};
}
function isValidImageUrl(url: string): boolean {
if (!url || typeof url !== 'string') return false;
const trimmed = url.trim();
if (!trimmed) return false;
if (trimmed.startsWith('javascript:')) return false;
if (trimmed.startsWith('data:')) {
return trimmed.startsWith('data:image/');
}
return trimmed.startsWith('http://') || trimmed.startsWith('https://');
}
function normalizeNodes(nodesToNormalize: Node[]): Node[] {
return nodesToNormalize.map((node) => ({
...node,
imageUrl: node.imageUrl && isValidImageUrl(node.imageUrl) ? node.imageUrl : undefined,
showLabel: node.showLabel !== undefined ? node.showLabel : true,
showType: node.showType !== undefined ? node.showType : true,
showNotes: node.showNotes !== undefined ? node.showNotes : true,
@@ -1152,7 +1222,7 @@
if (
(e.key === 'Delete' || e.key === 'Backspace') &&
(selectedNodeId || selectedNodeIds.size > 0)
(selectedLinkId || selectedNodeId || selectedNodeIds.size > 0)
) {
deleteSelected();
}
@@ -1192,10 +1262,37 @@
}
onMount(() => {
const log = (msg: string) => {
console.log(msg);
if (window.go?.main?.App?.LogFrontend) {
window.go.main.App.LogFrontend(msg);
}
};
log('Linking Tool started');
const checkMobile = () => {
isMobile = window.innerWidth < 640;
};
checkMobile();
if (isMobile) {
controlsCollapsed = true;
}
window.addEventListener('resize', checkMobile);
loadTheme();
if (!loadFromUrl()) {
loadFromLocalStorage();
}
// Wails detection
const isWails = window.runtime || window.go;
if (isWails) {
log('Wails environment detected');
}
return () => {
window.removeEventListener('resize', checkMobile);
};
});
$: {
@@ -1210,43 +1307,40 @@
</script>
<div
class={`flex h-full flex-col rounded-xl overflow-hidden relative border transition-colors ${
class={`flex h-full flex-col overflow-hidden relative transition-colors ${
isLight
? 'bg-gradient-to-b from-amber-50 via-white to-amber-100 border-amber-200 shadow-lg'
: 'bg-neutral-900/50 border-neutral-800'
? 'bg-gradient-to-b from-amber-50 via-white to-amber-100 border-amber-200 shadow-lg rounded-none md:rounded-xl border-0 md:border'
: 'bg-neutral-900/50 border-neutral-800 rounded-none md:rounded-xl border-0 md:border'
}`}
bind:this={containerElement}
>
<div
class="absolute z-10 pointer-events-none flex flex-col gap-2 top-2 left-1/2 -translate-x-1/2 md:left-4 md:translate-x-0 md:top-4 max-w-[calc(100vw-0.5rem)] md:max-w-none"
class="absolute z-10 pointer-events-none flex flex-col gap-1 md:gap-2 top-1 md:top-2 left-1/2 -translate-x-1/2 md:left-4 md:translate-x-0 md:top-4 max-w-[calc(100vw-1rem)] md:max-w-none max-h-[calc(100vh-120px)] md:max-h-none mobile-landscape:flex-row mobile-landscape:left-1/2 mobile-landscape:-translate-x-1/2 mobile-landscape:top-auto mobile-landscape:bottom-2 mobile-landscape:max-h-none mobile-landscape:gap-1"
>
<div class={`rounded-lg p-1 md:p-2 pointer-events-auto shadow-lg border ${panelClass}`}>
<div class={`rounded-lg p-1 mobile-landscape:p-1 md:p-2 pointer-events-auto shadow-lg border ${panelClass} max-h-full overflow-y-auto mobile-landscape:max-h-none mobile-landscape:overflow-visible`}>
<div
class="flex flex-row flex-wrap md:flex-col md:flex-nowrap gap-0.5 md:gap-1 justify-center w-full md:w-auto"
class="flex flex-row flex-wrap md:flex-col md:flex-nowrap mobile-landscape:flex-row mobile-landscape:flex-nowrap mobile-landscape:flex-wrap gap-1.5 md:gap-1.5 mobile-landscape:gap-1 justify-center w-full md:w-auto mobile-landscape:w-auto"
>
<button class={iconButtonClass} title="Toggle Theme" on:click={toggleTheme}>
{#if theme === 'dark'}
<Sun size={16} />
<Sun size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
{:else}
<Moon size={16} />
<Moon size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
{/if}
</button>
<div class={dividerClass}></div>
<button class={iconButtonClass} title="Add Node" on:click={() => (showAddModal = true)}>
<Plus size={16} />
<Plus size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<div class={dividerClass}></div>
<button class={iconButtonClass} title="Import Graph" on:click={importGraph}>
<Upload size={16} />
<Upload size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<button class={iconButtonClass} title="Export JSON" on:click={exportGraph}>
<Download size={16} />
</button>
<button class={iconButtonClass} title="Export PNG" on:click={exportPNG}>
<ImageIcon size={16} />
<Download size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<button class={iconButtonClass} title="Share Link" on:click={shareGraph}>
<Share2 size={16} />
<Share2 size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<div class={dividerClass}></div>
<button
@@ -1254,7 +1348,7 @@
title="Keyboard Shortcuts (?)"
on:click={() => (showShortcutsModal = true)}
>
<HelpCircle size={16} />
<HelpCircle size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<div class={dividerClass}></div>
<button
@@ -1264,7 +1358,7 @@
disabled={undoStack.length === 0}
class:opacity-50={undoStack.length === 0}
>
<Undo2 size={16} />
<Undo2 size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<button
class={iconButtonClass}
@@ -1273,14 +1367,14 @@
disabled={redoStack.length === 0}
class:opacity-50={redoStack.length === 0}
>
<Redo2 size={16} />
<Redo2 size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<div class={dividerClass}></div>
<button class={iconButtonClass} title="Fit to Screen" on:click={centerView}>
<Maximize size={16} />
<Maximize size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
<button class={iconButtonClass} title="Clear Graph" on:click={clearGraph}>
<Trash2 size={16} />
<Trash2 size={16} class="md:w-4 md:h-4 mobile-landscape:w-3 mobile-landscape:h-3" />
</button>
</div>
</div>
@@ -1437,15 +1531,27 @@
(target && target.label.toLowerCase().includes(searchQuery.toLowerCase())))}
{#if source && target}
<g class="group">
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<line
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke={isMatch ? '#ef4444' : linkColor}
stroke-width={isMatch ? '3' : strokeWidth}
stroke-opacity={isMatch ? '0.8' : linkStrength === 'weak' ? '0.5' : '1'}
class="transition-colors group-hover:stroke-neutral-300"
stroke={selectedLinkId === link.id ? '#ef4444' : isMatch ? '#ef4444' : linkColor}
stroke-width={selectedLinkId === link.id ? '3' : isMatch ? '3' : strokeWidth}
stroke-opacity={selectedLinkId === link.id ? '1' : isMatch ? '0.8' : linkStrength === 'weak' ? '0.5' : '1'}
class="transition-colors group-hover:stroke-neutral-300 cursor-pointer"
style="pointer-events: stroke;"
role="button"
tabindex="0"
on:click={(e) => selectLink(link.id, e)}
on:keydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectLink(link.id, e as unknown as MouseEvent);
}
}}
/>
{#if !editingLinkId || editingLinkId !== link.id}
<!-- svelte-ignore a11y-no-static-element-interactions -->
@@ -1455,10 +1561,12 @@
width={link.label.length * 6 + 8}
height="16"
rx="4"
fill={isLight ? '#ffffff' : '#171717'}
fill={selectedLinkId === link.id ? (isLight ? '#fef2f2' : '#7f1d1d') : isLight ? '#ffffff' : '#171717'}
stroke="none"
class="cursor-pointer"
role="button"
tabindex="0"
on:click={(e) => selectLink(link.id, e)}
on:dblclick={(e) => startEditingLink(link.id, e)}
on:keydown={(e) => {
if (e.key === 'Enter') {
@@ -1548,6 +1656,17 @@
class="animate-pulse"
/>
{/if}
{#if isMobile && selectedNodeId === node.id && !isLinking}
<circle
r="32"
fill="none"
stroke="#ef4444"
stroke-width="3"
stroke-opacity="0.6"
stroke-dasharray="8 4"
class="animate-pulse"
/>
{/if}
{#if isMatch && searchQuery.trim()}
<circle
r="30"
@@ -2112,7 +2231,21 @@
/>
</div>
<div>
<div class={`block text-xs font-medium mb-1 ${mutedTextClass}`}>Relationship Type</div>
<label for="linkType" class={`block text-xs font-medium mb-1 ${mutedTextClass}`}
>Relationship Type</label
>
<input
id="linkType"
bind:this={linkTypeEditInput}
class={`w-full rounded-lg border px-3 py-2 text-sm focus:border-rose-500 focus:outline-none placeholder-neutral-600 ${inputClass} mb-2`}
placeholder="Relationship type"
bind:value={editingLinkType}
on:input={handleRelationshipTypeInput}
on:keydown={(e) => {
if (e.key === 'Enter') saveLinkEdit();
if (e.key === 'Escape') cancelLinkEdit();
}}
/>
<div class="grid grid-cols-3 gap-2" role="group" aria-label="Relationship Type">
{#each relationshipTypes as relType}
{@const relColor = RELATIONSHIP_COLORS[relType]}
@@ -2123,8 +2256,9 @@
: isLight
? 'border-amber-300 bg-amber-50 text-neutral-700 hover:border-amber-400'
: 'border-neutral-800 bg-neutral-800 text-neutral-400 hover:border-neutral-700')}
on:click={() => (editingLinkType = relType)}
on:click={() => handleRelationshipTypeButtonClick(relType)}
style={editingLinkType === relType ? `border-color: ${relColor}` : ''}
type="button"
>
{relType}
</button>

View File

@@ -1 +1,20 @@
export const APP_VERSION = import.meta.env.VITE_APP_VERSION || 'dev';
declare const __APP_VERSION__: string | undefined;
type ProcessLike = {
env?: Record<string, string | undefined>;
};
const definedVersion =
typeof __APP_VERSION__ !== 'undefined' && __APP_VERSION__ ? __APP_VERSION__ : undefined;
const processEnv =
typeof globalThis === 'object' && globalThis !== null
? ((globalThis as unknown as { process?: ProcessLike }).process?.env ?? undefined)
: undefined;
const envVersion =
typeof processEnv?.npm_package_version === 'string' && processEnv.npm_package_version
? processEnv.npm_package_version
: undefined;
export const APP_VERSION = definedVersion ?? envVersion ?? 'dev';

View File

@@ -2,12 +2,62 @@
import '../app.css';
import { onMount } from 'svelte';
let showUpdateAvailable = false;
let registration: ServiceWorkerRegistration | null = null;
function checkForUpdates() {
if (registration && navigator.onLine) {
registration.update().catch(() => {
});
}
}
function reloadApp() {
if (registration && registration.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}
}
onMount(() => {
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('Service Worker registered:', registration);
.then((reg) => {
registration = reg;
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed') {
if (reg.waiting) {
showUpdateAvailable = true;
} else if (navigator.serviceWorker.controller) {
showUpdateAvailable = true;
}
}
});
}
});
if (reg.waiting) {
showUpdateAvailable = true;
}
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
if (navigator.onLine) {
setInterval(() => {
checkForUpdates();
}, 60000);
}
window.addEventListener('online', () => {
checkForUpdates();
});
})
.catch((error) => {
console.error('Service Worker registration failed:', error);
@@ -16,4 +66,43 @@
});
</script>
{#if showUpdateAvailable}
<div
class="fixed bottom-4 left-1/2 -translate-x-1/2 z-50 bg-neutral-900 border border-neutral-800 rounded-lg shadow-lg p-4 max-w-md mx-4"
>
<div class="flex items-center gap-3">
<div class="flex-1">
<p class="text-sm font-medium text-white">Update Available</p>
<p class="text-xs text-neutral-400 mt-1">A new version is available. Reload to update.</p>
</div>
<button
on:click={reloadApp}
class="px-4 py-2 bg-accent-red text-white rounded-md text-sm font-medium hover:bg-accent-red-dark transition-colors"
>
Reload
</button>
<button
on:click={() => (showUpdateAvailable = false)}
class="text-neutral-400 hover:text-white transition-colors"
aria-label="Dismiss"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
{/if}
<slot />

View File

@@ -8,13 +8,13 @@
<title>Linking Tool - Identity Graph</title>
<meta
name="description"
content="Linking Tool - A client-side identity graph visualization tool for mapping relationships between entities."
content="Linking Tool - A client-side web linking tool for mapping relationships between entities."
/>
<meta property="og:type" content="website" />
<meta property="og:title" content="Linking Tool - Identity Graph" />
<meta property="og:title" content="Linking Tool" />
<meta
property="og:description"
content="A client-side identity graph visualization tool for mapping relationships between entities."
content="A client-side web linking tool for mapping relationships between entities."
/>
<meta property="og:image" content="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
@@ -22,17 +22,22 @@
<div class="flex flex-col h-screen bg-bg-primary text-text-primary">
<header
class="bg-neutral-950 border-b border-neutral-800 px-4 sm:px-6 py-3 flex flex-col sm:flex-row justify-between items-center gap-2 flex-shrink-0 shadow-lg"
class="bg-neutral-950 border-b border-neutral-800 px-2 sm:px-6 py-1.5 sm:py-3 flex flex-col sm:flex-row justify-between items-center gap-1 sm:gap-2 flex-shrink-0 shadow-lg"
>
<h1 class="text-lg sm:text-xl font-semibold text-accent-red-light flex items-center gap-2">
<a
href="https://git.quad4.io/Quad4-Software/Linking-Tool"
target="_blank"
rel="noopener noreferrer"
class="text-sm sm:text-xl font-semibold text-accent-red-light flex items-center gap-1.5 sm:gap-2 hover:text-accent-red-dark transition-colors"
>
<div
class="h-5 w-5 rounded border border-accent-red-light flex items-center justify-center bg-neutral-900"
class="h-4 w-4 sm:h-5 sm:w-5 rounded border border-accent-red-light flex items-center justify-center bg-neutral-900"
>
<LinkIcon size={14} class="text-accent-red-light" />
<LinkIcon size={12} class="sm:w-[14px] sm:h-[14px] text-accent-red-light" />
</div>
Linking Tool
</h1>
<div class="text-text-secondary text-xs sm:text-sm flex items-center gap-2">
</a>
<div class="text-text-secondary text-[10px] sm:text-sm flex items-center gap-1 sm:gap-2">
<span
>Created by <a
href="https://quad4.io"
@@ -51,7 +56,7 @@
>
</div>
</header>
<main class="flex-1 relative overflow-hidden bg-bg-primary p-4">
<main class="flex-1 relative overflow-hidden bg-bg-primary p-0 sm:p-4">
<IdentityGraph />
</main>
</div>

View File

@@ -1,5 +1,5 @@
/* eslint-env serviceworker */
const CACHE_NAME = 'quad4-linking-tool-v1';
const CACHE_VERSION = '1.4.0';
const CACHE_NAME = `quad4-linking-tool-${CACHE_VERSION}`;
const urlsToCache = ['/', '/favicon.svg', '/manifest.json'];
self.addEventListener('install', (event) => {
@@ -18,7 +18,7 @@ self.addEventListener('activate', (event) => {
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
if (cacheName !== CACHE_NAME && cacheName.startsWith('quad4-linking-tool-')) {
return caches.delete(cacheName);
}
})
@@ -28,26 +28,40 @@ self.addEventListener('activate', (event) => {
self.clients.claim();
});
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') {
return;
}
if (!event.request.url.startsWith(self.location.origin)) {
return;
}
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
return response;
}
return fetch(event.request).then((response) => {
if (!response || response.status !== 200 || response.type !== 'basic') {
return fetch(event.request)
.then((response) => {
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
}
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
})
.catch(() => {
return caches.match('/') || new Response('Offline', { status: 503 });
});
return response;
});
})
);
});

View File

@@ -1,18 +1,19 @@
import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter(),
},
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: true
})
}
};
export default config;

View File

@@ -16,6 +16,9 @@ export default {
fontFamily: {
sans: ['Nunito', 'Inter', 'Segoe UI', 'system-ui', '-apple-system', 'sans-serif'],
},
screens: {
'mobile-landscape': { raw: '(max-width: 767px) and (orientation: landscape)' },
},
},
},
plugins: [],

View File

@@ -1,6 +1,16 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import pkg from './package.json' with { type: 'json' };
declare const process: {
env: Record<string, string | undefined>;
};
const appVersion = process.env.VITE_APP_VERSION ?? pkg.version ?? 'dev';
export default defineConfig({
define: {
__APP_VERSION__: JSON.stringify(appVersion),
},
plugins: [sveltekit()],
});