35 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
32 changed files with 1333 additions and 192 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

@@ -2,6 +2,8 @@
A client-side web linking tool for mapping relationships between entities.
<img src="showcase/linkingtool.png" alt="showcase image" width="900">
## Features
- Interactive graph visualization
@@ -11,32 +13,75 @@ A client-side web linking tool for mapping relationships between entities.
- Share link via base64 for smaller graphs
- Undo/Redo support
- 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.2.1",
"name": "@quad4/linking-tool",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "quad4-linking-tool",
"version": "1.2.1",
"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.2.1",
"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

@@ -102,6 +102,7 @@
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;
@@ -166,6 +167,7 @@
let imageUploadError = '';
let newNodeImageError = '';
let controlsCollapsed = false;
let isMobile = false;
let copiedNodes: Node[] = [];
let searchQuery = '';
let searchInput: HTMLInputElement | null = null;
@@ -177,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';
@@ -574,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);
@@ -593,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';
@@ -676,6 +715,7 @@
isPanning = true;
selectedNodeId = null;
selectedNodeIds.clear();
selectedLinkId = null;
containerElement.style.cursor = 'grabbing';
}
}
@@ -685,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;
@@ -722,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) {
@@ -789,7 +846,7 @@
}
}
function handleMouseUp(_e?: MouseEvent) {
function handleMouseUp() {
if (isDragging && (draggedNodeId || draggedNodeIds.size > 0)) {
saveToLocalStorage();
}
@@ -818,6 +875,7 @@
touchHoldTimeout = null;
}
touchHoldNodeId = null;
isLongPressing = false;
}
function touchToMouseEvent(
@@ -842,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();
}
}
@@ -924,6 +982,10 @@
}
function deleteSelected() {
if (selectedLinkId) {
deleteLink(selectedLinkId);
return;
}
if (selectedNodeIds.size === 0 && !selectedNodeId) return;
pushState();
const idsToDelete =
@@ -939,10 +1001,25 @@
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';
@@ -1145,7 +1222,7 @@
if (
(e.key === 'Delete' || e.key === 'Backspace') &&
(selectedNodeId || selectedNodeIds.size > 0)
(selectedLinkId || selectedNodeId || selectedNodeIds.size > 0)
) {
deleteSelected();
}
@@ -1185,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);
};
});
$: {
@@ -1203,40 +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} />
<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
@@ -1244,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
@@ -1254,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}
@@ -1263,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>
@@ -1427,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 -->
@@ -1445,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') {
@@ -1538,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"

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()],
});