6 Commits

Author SHA1 Message Date
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
4 changed files with 69 additions and 65 deletions

View File

@@ -1,6 +1,6 @@
# 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.
## Features
@@ -8,9 +8,8 @@ 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

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "quad4-linking-tool",
"version": "1.0.0",
"version": "1.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "quad4-linking-tool",
"version": "1.0.0",
"version": "1.2.1",
"dependencies": {
"autoprefixer": "^10.4.23",
"lucide-svelte": "^0.562.0",

View File

@@ -1,7 +1,7 @@
{
"name": "quad4-linking-tool",
"private": true,
"version": "1.0.0",
"version": "1.2.1",
"type": "module",
"scripts": {
"dev": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") vite dev",

View File

@@ -4,7 +4,6 @@
Plus,
Upload,
Download,
Image as ImageIcon,
Undo2,
Redo2,
Maximize,
@@ -109,8 +108,10 @@
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;
@@ -478,11 +479,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 +534,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!');
@@ -616,55 +629,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 {
@@ -981,6 +945,8 @@
if (link) {
editingLinkId = linkId;
editingLinkLabel = link.label;
editingLinkType = link.type || 'Linked';
editingLinkTypeManuallyEdited = false;
setTimeout(() => linkEditInput?.focus(), 10);
}
}
@@ -988,12 +954,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 +968,7 @@
editingLinkId = null;
editingLinkLabel = '';
editingLinkType = 'Linked';
editingLinkTypeManuallyEdited = false;
editingLinkStrength = 'medium';
saveToLocalStorage();
}
@@ -1008,6 +976,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 +1024,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,
@@ -1242,9 +1235,6 @@
<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} />
</button>
<button class={iconButtonClass} title="Share Link" on:click={shareGraph}>
<Share2 size={16} />
</button>
@@ -2112,7 +2102,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 +2127,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>