Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "graph-lens-lite",
"version": "1.15.1",
"version": "1.15.3",
"main": "src/package/electron_app.js",
"description": "Visualise and explore property graphs in a lightweight desktop app.",
"homepage": "https://github.com/Delta4AI/GraphLensLite",
Expand Down
2 changes: 1 addition & 1 deletion src/config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Defaults for the graph, layouts and UI
*/
const VERSION = "1.15.1";
const VERSION = "1.15.3";

const DEFAULTS = {
NODE: {
Expand Down
9 changes: 4 additions & 5 deletions src/managers/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -236,9 +236,8 @@ class QueryManager {
);

/* 5-3 Categorical Values (Dropdown): "IN [" up to "]" --------- */
asciiStr = asciiStr.replace(/IN\s*\[([^\]]*?)]/g, (_match, list) => {
const encodedCategories = list
.split(",")
asciiStr = asciiStr.replace(/IN\s*\[((?:\\.|[^\]\\])*)]/g, (_match, list) => {
const encodedCategories = StaticUtilities.splitQueryList(list)
.map(cat => `<span class='q-string' data-encoded>${StaticUtilities.escapeHtml(cat)}</span>`)
.join(`<span class='q-comma' data-encoded>,</span>`);

Expand Down Expand Up @@ -408,7 +407,7 @@ class QueryManager {
for (const [propID, fo] of this.cache.data.layouts[this.cache.data.selectedLayout].filters.entries()) {
if (fo.active) {
if (fo.isCategory) {
queryEntries.push(`${propID} IN [${[...fo.categories].map(cat => cat).join(",")}]`);
queryEntries.push(`${propID} IN [${[...fo.categories].map(cat => StaticUtilities.escapeQueryValue(cat)).join(",")}]`);
} else if (fo.isInverted) {
queryEntries.push(`${propID} LOWER THAN ${fo.upperThreshold} OR GREATER THAN ${fo.lowerThreshold}`);
} else {
Expand Down Expand Up @@ -629,7 +628,7 @@ class QueryManager {
'q-in-cat-bracket-open': () => ({type: 'KW', value: 'IN ['}),

// category strings
'q-string': el => ({type: 'STR', value: el.textContent}),
'q-string': el => ({type: 'STR', value: StaticUtilities.unescapeQueryValue(el.textContent)}),

// whole property path ("A::B::C")
'q-property-wrapper': el => {
Expand Down
23 changes: 12 additions & 11 deletions src/managers/ui_components.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,17 +133,18 @@ class DropdownChecklist {
this.itemsList.style.display = "";
this.itemsList.style.visibility = "";

const anchorRect = this.anchor.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const availableHeight = viewportHeight - anchorRect.bottom;
const {left, top, maxHeight} = StaticUtilities.computeDropdownPlacement({
anchorRect: this.anchor.getBoundingClientRect(),
dropdownHeight,
viewportHeight: window.innerHeight,
});

// Set position of the dropdown
this.itemsList.style.top = `${anchorRect.bottom}px`;
this.itemsList.style.left = `${anchorRect.left - 3}px`;
this.itemsList.style.left = `${left}px`;
this.itemsList.style.top = `${top}px`;

// Make the dropdown scrollable if there's not enough space
if (dropdownHeight > availableHeight) {
this.itemsList.style.maxHeight = `${availableHeight}px`;
// Cap height and scroll only when even the chosen side is too small.
if (maxHeight != null) {
this.itemsList.style.maxHeight = `${maxHeight}px`;
this.itemsList.style.overflowY = "auto";
} else {
this.itemsList.style.maxHeight = "";
Expand Down Expand Up @@ -941,9 +942,9 @@ class UIComponentManager {
}
} else if (dropdown) {
if (this.cache.CFG.QUERY_BTN_USE_CURRENT_FILTER) {
queryFragment = `${propID} IN [${[...dropdown.selectedCategories].join(",")}]`
queryFragment = `${propID} IN [${[...dropdown.selectedCategories].map(cat => StaticUtilities.escapeQueryValue(cat)).join(",")}]`
} else {
queryFragment = `${propID} IN [${[...dropdown.categories].join(",")}]`
queryFragment = `${propID} IN [${[...dropdown.categories].map(cat => StaticUtilities.escapeQueryValue(cat)).join(",")}]`
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -3160,6 +3160,10 @@ input:checked + .slider:before {

.dropdown-check-list ul.items {
position: fixed;
/* Include padding+border in the height so the JS max-height cap (which uses
the available space) matches the rendered box and the panel never spills
over the anchor when a huge list opens upward. */
box-sizing: border-box;
padding: 2px;
display: none;
margin: 0 0 0 3px;
Expand Down
53 changes: 53 additions & 0 deletions src/utilities/static.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,59 @@ class StaticUtilities {
return propId.split("::");
}

// The query DSL uses [ ] , ( ) and \ as grammar. Categorical values can
// contain any of these (e.g. free-text "Evidence sample" cells), so they are
// backslash-escaped when written into a query string and unescaped when the
// query is decoded back into category tokens.
static escapeQueryValue(value) {
return String(value).replace(/[\\[\],()]/g, "\\$&");
}

static unescapeQueryValue(value) {
return String(value).replace(/\\(.)/g, "$1");
}

// Split a category list on unescaped commas only, keeping escape sequences
// intact in each token (they are unescaped later, at decode time).
static splitQueryList(listStr) {
const out = [];
let cur = "";
for (let i = 0; i < listStr.length; i++) {
const ch = listStr[i];
if (ch === "\\" && i + 1 < listStr.length) {
cur += ch + listStr[i + 1];
i++;
continue;
}
if (ch === ",") {
out.push(cur);
cur = "";
continue;
}
cur += ch;
}
out.push(cur);
return out;
}

// Decide where a dropdown panel should open relative to its anchor: flip
// upward when there is more room above than below, and cap the height (with
// scroll) to the chosen side so it never spills past the window edge.
// Pure function of measured geometry so the flip logic stays unit-testable.
static computeDropdownPlacement({anchorRect, dropdownHeight, viewportHeight, margin = 4}) {
const spaceBelow = viewportHeight - anchorRect.bottom - margin;
const spaceAbove = anchorRect.top - margin;
const openUp = dropdownHeight > spaceBelow && spaceAbove > spaceBelow;
const available = Math.max(0, openUp ? spaceAbove : spaceBelow);
const height = Math.min(dropdownHeight, available);
return {
openUp,
left: anchorRect.left - 3,
top: openUp ? anchorRect.top - height : anchorRect.bottom,
maxHeight: dropdownHeight > available ? available : null,
};
}

static setsAreEqual(setA, setB) {
if (setA.size !== setB.size) return false;
for (let item of setA) {
Expand Down
62 changes: 62 additions & 0 deletions tests/query-categorical-escaping.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach } from 'vitest'
import { QueryManager } from '../src/managers/query.js'
import { StaticUtilities } from '../src/utilities/static.js'

// ==========================================================================
// Categorical values containing the query-DSL grammar characters
// ( [ ] , ( ) \ ) must survive a full encodeQuery -> decodeQuery round-trip.
// Regression: bracketed "Relations detail" / prose "Evidence sample" values
// broke the auto-generated query and hid every node and edge (0/N shown).
// ==========================================================================

function makeQM() {
for (const id of ['queryUpdateBtn', 'querySelectBtn']) {
const btn = document.createElement('button')
btn.id = id
document.body.appendChild(btn)
}
const cache = {
query: { valid: true, overlay: document.createElement('div') },
uniquePropHierarchy: {
'Edge filters': { relation: new Set(['Relations detail']) },
},
}
return new QueryManager(cache)
}

// Pull every decoded STR (category) token value out of the nested instructions.
function collectCategories(instructions) {
const out = []
const walk = (node) => {
if (Array.isArray(node)) return node.forEach(walk)
if (node && node.type === 'STR') out.push(node.value)
}
walk(instructions)
return out
}

describe('categorical query escaping (encode -> decode round-trip)', () => {
let qm
beforeEach(() => {
document.body.innerHTML = ''
qm = makeQM()
})

it('preserves bracketed, comma, paren and backslash categories exactly', () => {
const cats = [
'protects [B,12]; located_in [B,4]; binds [B,2]',
'Jacob et al. (HES vs saline) [PMID 30654825]',
'weird \\ backslash, and (paren)',
]
// Build the query string exactly as updateQueryTextArea does.
const list = cats.map((c) => StaticUtilities.escapeQueryValue(c)).join(',')
const queryStr = `(Edge filters::relation::Relations detail IN [${list}])`

qm.cache.query.overlay.innerHTML = qm.encodeQuery(queryStr)
const decoded = collectCategories(qm.decodeQuery())

expect(qm.cache.query.valid).toBe(true)
expect(decoded).toEqual(cats)
})
})
84 changes: 84 additions & 0 deletions tests/static-utilities.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,90 @@
import { describe, it, expect } from 'vitest'
import { StaticUtilities } from '../src/utilities/static.js'

// ==========================================================================
// Query DSL category escaping — round-trips values containing the grammar
// characters [ ] , ( ) and \ so categorical filters survive auto-query
// generation (regression: bracketed values hid the entire graph).
// ==========================================================================

describe('StaticUtilities query-value escaping', () => {
const roundTrip = (v) =>
StaticUtilities.unescapeQueryValue(StaticUtilities.escapeQueryValue(v))

it('escapes every DSL grammar character', () => {
expect(StaticUtilities.escapeQueryValue('a[b]c,d(e)f\\g')).toBe(
'a\\[b\\]c\\,d\\(e\\)f\\\\g'
)
})

it('round-trips bracketed/comma/paren values exactly', () => {
for (const v of [
'protects [B,12]; located_in [B,4]; binds [B,2]',
'Jacob et al. (HES vs saline) effect [PMID 30654825]',
'weird \\ backslash, and (paren)',
'plain value',
'',
]) {
expect(roundTrip(v)).toBe(v)
}
})

it('splits a category list only on unescaped commas', () => {
const escaped = ['protects [B,12]', 'located_in [B,3]']
.map((c) => StaticUtilities.escapeQueryValue(c))
.join(',')
const tokens = StaticUtilities.splitQueryList(escaped)
expect(tokens.map((t) => StaticUtilities.unescapeQueryValue(t))).toEqual([
'protects [B,12]',
'located_in [B,3]',
])
})
})

// ==========================================================================
// Dropdown placement — flips a filter dropdown upward when it would be cut
// off at the bottom of the window, scrolling only when even the larger side
// is too small (regression: bottom-of-panel dropdowns were unusable).
// ==========================================================================

describe('StaticUtilities.computeDropdownPlacement', () => {
const rect = (top, bottom, left = 100) => ({ top, bottom, left })

it('opens below the anchor when there is room', () => {
const p = StaticUtilities.computeDropdownPlacement({
anchorRect: rect(100, 120),
dropdownHeight: 200,
viewportHeight: 800,
})
expect(p.openUp).toBe(false)
expect(p.top).toBe(120) // anchor bottom
expect(p.left).toBe(97) // anchor left - 3
expect(p.maxHeight).toBeNull() // fits, no scroll
})

it('flips upward when the anchor sits near the bottom edge', () => {
const p = StaticUtilities.computeDropdownPlacement({
anchorRect: rect(560, 580),
dropdownHeight: 200,
viewportHeight: 600, // only 16px below, 556px above
})
expect(p.openUp).toBe(true)
expect(p.top).toBe(360) // anchorTop(560) - height(200)
expect(p.maxHeight).toBeNull() // fits above without scroll
})

it('caps height and scrolls when even the larger side is too small', () => {
const p = StaticUtilities.computeDropdownPlacement({
anchorRect: rect(300, 320),
dropdownHeight: 400,
viewportHeight: 600, // below: 276, above: 296 -> flip up, still < 400
})
expect(p.openUp).toBe(true)
expect(p.maxHeight).toBe(296) // spaceAbove - margin
expect(p.top).toBe(4) // anchorTop(300) - height(296)
})
})

// ==========================================================================
// StaticUtilities — pure function unit tests
// ==========================================================================
Expand Down
Loading