diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 677043bc..bb5868c3 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -42,5 +42,5 @@ jobs:
uses: actions/upload-artifact@v3
if: failure()
with:
- name: cypress-screenshots
+ name: temp_dir
path: temp
\ No newline at end of file
diff --git a/controllers/create-record.js b/controllers/create-record.js
index 61c432be..b2b187d6 100644
--- a/controllers/create-record.js
+++ b/controllers/create-record.js
@@ -66,8 +66,6 @@ function createRecord(
undefined,
undefined,
undefined,
- undefined,
- undefined,
config.opts,
);
if (saveIdOnYmlFrontMatter === false) {
diff --git a/controllers/modelize.js b/controllers/modelize.js
index 7268080f..08bda587 100644
--- a/controllers/modelize.js
+++ b/controllers/modelize.js
@@ -1,9 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import getHistorySavePath from './history.js';
-import Graph from '../core/models/graph.js';
import Cosmoscope from '../core/models/cosmoscope.js';
-import Link from '../core/models/link.js';
import Record from '../core/models/record.js';
import Config from '../core/models/config.js';
import Template from '../core/models/template.js';
@@ -11,6 +9,7 @@ import Report from '../models/report-cli.js';
import { DowloadOnlineCsvFilesError } from '../core/models/errors.js';
import { downloadFile } from '../core/utils/misc.js';
import { tmpdir } from 'node:os';
+import getGraph from '../core/utils/getGraph.js';
async function modelize(options) {
let config = Config.get(Config.configFilePath);
@@ -24,17 +23,10 @@ async function modelize(options) {
})
.filter(({ value }) => value === true);
- const optionsGraph = options
- .filter(({ name }) => Graph.validParams.has(name))
- .map(({ name }) => name);
const optionsTemplate = options
.filter(({ name }) => Template.validParams.has(name))
.map(({ name }) => name);
- if (optionsGraph.includes('sample')) {
- config = new Config(Config.getSampleConfig());
- }
-
const {
select_origin: originType,
files_origin: filesPath,
@@ -77,19 +69,26 @@ async function modelize(options) {
break;
}
- console.log(getModelizeMessage(optionsGraph, optionsTemplate, originType));
+ console.log(getModelizeMessage(optionsTemplate, originType));
let records;
switch (originType) {
- case 'directory':
+ case 'directory': {
const files = Cosmoscope.getFromPathFiles(filesPath, config.opts);
- records = Cosmoscope.getRecordsFromFiles(
- files,
- optionsTemplate.includes('citeproc'),
- config.opts,
- );
+
+ records = Cosmoscope.getRecordsFromFiles(files, config.opts);
+
+ if (
+ optionsTemplate.includes('citeproc') &&
+ config.opts['references_as_nodes'] &&
+ config.canCiteproc()
+ ) {
+ records = records.concat(Cosmoscope.getBibliographicRecords(records, config.opts));
+ }
+
break;
- case 'online':
+ }
+ case 'online': {
const tempDir = tmpdir();
nodesPath = path.join(tempDir, 'cosma-nodes.csv');
linksPath = path.join(tempDir, 'cosma-links.csv');
@@ -101,16 +100,17 @@ async function modelize(options) {
} catch (error) {
throw new DowloadOnlineCsvFilesError(error);
}
- case 'csv':
+ }
+ case 'csv': {
let [formatedRecords, formatedLinks] = await Cosmoscope.getFromPathCsv(nodesPath, linksPath);
- const links = Link.formatedDatasetToLinks(formatedLinks);
- records = Record.formatedDatasetToRecords(formatedRecords, links, config);
+ records = Record.formatedDatasetToRecords(formatedRecords, formatedLinks, config);
break;
+ }
}
- const graph = new Cosmoscope(records, config.opts, []);
+ const graph = getGraph(records, config);
- const { html } = new Template(graph, optionsTemplate);
+ const { html } = new Template(records, graph, optionsTemplate);
fs.writeFile(path.join(exportPath, 'cosmoscope.html'), html, (err) => {
// Cosmoscope file for export folder
@@ -122,7 +122,7 @@ async function modelize(options) {
}
console.log(
['\x1b[34m', 'Cosmoscope generated', '\x1b[0m'].join(''),
- `(${graph.records.length} records)`,
+ `(${records.length} records)`,
);
});
@@ -158,13 +158,12 @@ async function modelize(options) {
}
/**
- * @param {string[]} optionsGraph
* @param {string[]} optionsTemplate
* @param {string} originType
*/
-function getModelizeMessage(optionsGraph, optionsTemplate, originType) {
- const settings = [...optionsGraph, ...optionsTemplate];
+function getModelizeMessage(optionsTemplate, originType) {
+ const settings = optionsTemplate.filter((setting) => setting !== 'publish');
const msgSetting =
settings.length === 0 ? '' : `; settings: \x1b[1m${settings.join(', ')}\x1b[0m`;
diff --git a/core/frontend/filter.js b/core/frontend/filter.js
index 92f150cd..24d931a6 100644
--- a/core/frontend/filter.js
+++ b/core/frontend/filter.js
@@ -5,7 +5,6 @@
*/
import { setNodesDisplaying } from './graph.js';
-import filterPriority from './filterPriority.js';
window.addEventListener('DOMContentLoaded', () => {
/** @type {HTMLFormElement} */
@@ -13,12 +12,15 @@ window.addEventListener('DOMContentLoaded', () => {
/** @type {HTMLInputElement[]} */
const inputs = form.querySelectorAll('input');
+ /** @type {[string, string[]][]} */
+ const types = Object.entries(typeList);
+
/**
* Default state
*/
- for (const [name, { active }] of Object.entries(typeList)) {
- form.querySelector(`[name="${name}"]`).checked = active;
+ for (const [name] of types) {
+ form.querySelector(`[name="${name}"]`).checked = true;
}
changeTypesState();
@@ -30,7 +32,7 @@ window.addEventListener('DOMContentLoaded', () => {
const filtersFromSearch = searchParams.get('filters')?.split('-');
if (filtersFromSearch?.length) {
- for (const [name] of Object.entries(typeList)) {
+ for (const [name] of types) {
form.querySelector(`[name="${name}"]`).checked = filtersFromSearch.includes(name);
}
changeTypesState();
@@ -48,13 +50,13 @@ window.addEventListener('DOMContentLoaded', () => {
const nodeIdsToDisplay = new Set();
- formState = Object.entries(typeList)
+ types
.filter(([name]) => !!formState[name])
- .forEach(([, { nodes }]) => {
+ .forEach(([, nodes]) => {
nodes.forEach((id) => nodeIdsToDisplay.add(id));
});
- setNodesDisplaying(Array.from(nodeIdsToDisplay), filterPriority.filteredByType);
+ setNodesDisplaying(Array.from(nodeIdsToDisplay));
}
let filterNameAltMode;
diff --git a/core/frontend/filterPriority.js b/core/frontend/filterPriority.js
deleted file mode 100644
index 57e1dcb7..00000000
--- a/core/frontend/filterPriority.js
+++ /dev/null
@@ -1,9 +0,0 @@
-const filterPriority = {
- notFiltered: 0,
- filteredByTimeline: 1,
- filteredByFocus: 2,
- filteredByTag: 3,
- filteredByType: 4,
-};
-
-export default filterPriority;
diff --git a/core/frontend/focus.js b/core/frontend/focus.js
index 460d6dc6..ade50276 100644
--- a/core/frontend/focus.js
+++ b/core/frontend/focus.js
@@ -1,11 +1,7 @@
-import GraphEngine from 'graphology';
import { bfsFromNode as neighborsExtend } from 'graphology-traversal/bfs.js';
import hotkeys from 'hotkeys-js';
-import { displayNodesAll, setNodesDisplaying } from './graph.js';
-import filterPriority from './filterPriority.js';
-import { getRecordIdFromHash } from './records.js';
-
-let graph = getGraphEngine();
+import { graph, displayNodesAll, setNodesDisplaying } from './graph.js';
+import { getRecordIdFromHash, displayAllIndex } from './records.js';
window.addEventListener('DOMContentLoaded', () => {
/** @type {HTMLInputElement} */
@@ -49,7 +45,8 @@ window.addEventListener('DOMContentLoaded', () => {
input.classList.remove('active');
input.removeEventListener('input', display);
modeSelect.removeEventListener('change', changeMode);
- displayNodesAll(filterPriority.filteredByFocus);
+ displayNodesAll();
+ displayAllIndex();
}
});
@@ -83,7 +80,7 @@ window.addEventListener('DOMContentLoaded', () => {
{ mode: focusMode },
);
- setNodesDisplaying(neighborsNodeIds, filterPriority.filteredByFocus);
+ setNodesDisplaying(neighborsNodeIds);
}
function changeMode() {
@@ -91,30 +88,3 @@ window.addEventListener('DOMContentLoaded', () => {
display();
}
});
-
-/**
- * @returns {GraphEngine}
- */
-
-function getGraphEngine() {
- const graph = new GraphEngine();
-
- for (const { id, label } of data.nodes) {
- if (graph.hasNode(id)) {
- continue;
- }
-
- graph.addNode(id, {
- label,
- });
- }
- for (const { source, target } of data.links) {
- if (graph.hasEdge(source.id, target.id)) {
- continue;
- }
-
- graph.addEdge(source.id, target.id);
- }
-
- return graph;
-}
diff --git a/core/frontend/graph.js b/core/frontend/graph.js
index 4b4cbcab..d47459fd 100644
--- a/core/frontend/graph.js
+++ b/core/frontend/graph.js
@@ -6,26 +6,28 @@
*/
import * as d3 from 'd3';
+import GraphEngine from 'graphology';
import View from './view.js';
-import { hideFromIndex, displayFromIndex, getRecordIdFromHash } from './records.js';
+import { getRecordIdFromHash } from './records.js';
import { setCounters } from './counter.js';
import hotkeys from 'hotkeys-js';
-import filterPriority from './filterPriority.js';
/** Data serialization
------------------------------------------------------------*/
-const allNodeIds = [];
+const graph = new GraphEngine({ multi: true });
+graph.import(data);
-data.nodes = data.nodes.map((node) => {
- allNodeIds.push(node.id);
- node.hidden = filterPriority.notFiltered;
- node.isolated = false;
- node.highlighted = false;
- return node;
+graph.updateEachNodeAttributes((node, attr) => {
+ return {
+ ...attr,
+ hidden: false,
+ };
});
+const allNodeIds = graph.nodes();
+
/** Box sizing
------------------------------------------------------------*/
@@ -43,7 +45,7 @@ const simulation = d3
.forceSimulation(data.nodes)
.force(
'link',
- d3.forceLink(data.links).id((d) => d.id),
+ d3.forceLink(data.edges).id((d) => d.key),
)
.force('charge', d3.forceManyBody())
.force('center', d3.forceCenter())
@@ -103,22 +105,22 @@ const imageFileValidExtnames = new Set(['jpg', 'jpeg', 'png']);
elts.links = svgSub
.append('g')
.selectAll('line')
- .data(data.links)
+ .data(data.edges)
.enter()
.append('line')
- .attr('stroke', (d) => d.color)
- .attr('title', (d) => d.title)
- .attr('data-link', (d) => d.id)
- .attr('data-source', (d) => d.source.id)
- .attr('data-target', (d) => d.target.id)
+ .attr('stroke', (d) => `var(--l_${d.attributes.type})`)
+ .attr('title', (d) => d.attributes.title)
+ .attr('data-link', (d) => d.key)
+ .attr('data-source', (d) => d.source.key)
+ .attr('data-target', (d) => d.target.key)
.attr('stroke-dasharray', function (d) {
- if (d.shape.stroke === 'dash' || d.shape.stroke === 'dotted') {
- return d.shape.dashInterval;
+ if (d.attributes.shape.stroke === 'dash' || d.attributes.shape.stroke === 'dotted') {
+ return d.attributes.shape.dashInterval;
}
return false;
})
.attr('filter', function (d) {
- if (d.shape.stroke === 'double') {
+ if (d.attributes.shape.stroke === 'double') {
return 'url(#double)';
}
return false;
@@ -137,7 +139,7 @@ elts.nodes = svgSub
.data(data.nodes)
.enter()
.append('g')
- .attr('data-node', (d) => d.id)
+ .attr('data-node', (d) => d.key)
.call(
d3
.drag()
@@ -156,48 +158,26 @@ elts.nodes = svgSub
d.fy = null;
}),
)
- .on('mouseover', (e, nodeMetas) => {
+ .on('mouseover', (e, { key: nodeId }) => {
if (!graphProperties.graph_highlight_on_hover) {
return;
}
- let nodesIdsHovered = [nodeMetas.id];
+ const links = graph.edges(nodeId);
+ const nodes = graph.neighbors(nodeId);
+ nodes.push(nodeId);
- const linksToModif = elts.links.filter(function (link) {
- if (link.source.id === nodeMetas.id || link.target.id === nodeMetas.id) {
- nodesIdsHovered.push(link.source.id, link.target.id);
- return false;
- }
- return true;
- });
-
- const nodesToModif = elts.nodes.filter(function (node) {
- if (nodesIdsHovered.includes(node.id)) {
- return false;
- }
- return true;
- });
-
- const linksHovered = elts.links.filter(function (link) {
- if (link.source.id !== nodeMetas.id && link.target.id !== nodeMetas.id) {
- return false;
- }
- return true;
- });
-
- const nodesHovered = elts.nodes.filter(function (node) {
- if (!nodesIdsHovered.includes(node.id)) {
- return false;
- }
- return true;
- });
+ const linksTransparent = elts.links.filter(({ key }) => !links.includes(key));
+ const nodesTransparent = elts.nodes.filter(({ key }) => !nodes.includes(key));
+ const linksHovered = elts.links.filter(({ key }) => links.includes(key));
+ const nodesHovered = elts.nodes.filter(({ key }) => nodes.includes(key));
nodesHovered.nodes().forEach((elt) => elt.classList.add('highlight'));
linksHovered.nodes().forEach((elt) => elt.classList.add('highlight'));
- nodesToModif.nodes().forEach((elt) => elt.classList.add('translucent'));
- linksToModif.nodes().forEach((elt) => elt.classList.add('translucent'));
+ nodesTransparent.nodes().forEach((elt) => elt.classList.add('translucent'));
+ linksTransparent.nodes().forEach((elt) => elt.classList.add('translucent'));
})
- .on('mouseout', () => {
+ .on('mouseout', (e, { key: nodeId }) => {
if (!graphProperties.graph_highlight_on_hover) {
return;
}
@@ -205,9 +185,9 @@ elts.nodes = svgSub
const selectedNodeId = getRecordIdFromHash();
elts.nodes
- .filter(({ id }) => {
+ .filter(({ key }) => {
if (selectedNodeId) {
- return id !== selectedNodeId;
+ return key !== selectedNodeId;
}
return true;
})
@@ -216,7 +196,7 @@ elts.nodes = svgSub
elts.links
.filter((link) => {
if (selectedNodeId) {
- return link.source.id !== selectedNodeId && link.target.id !== selectedNodeId;
+ return link.source.key !== selectedNodeId && link.target.key !== selectedNodeId;
}
return true;
})
@@ -230,7 +210,7 @@ elts.nodes = svgSub
elts.nodes.each(function (d) {
const node = d3.select(this);
- const link = node.append('a').attr('href', (d) => '#' + d.id);
+ const link = node.append('a').attr('href', (d) => '#' + d.key);
const getFill = (fill) => {
if (imageFileValidExtnames.has(fill.split('.').at(-1))) {
@@ -251,47 +231,54 @@ elts.nodes.each(function (d) {
link
.append('circle')
.attr('class', 'border')
- .attr('r', d.size + 2)
+ .attr('r', d.attributes.size + 2)
.attr('fill', stroke);
/** Foreground: circle contains color or image */
- link.append('circle').attr('r', d.size).attr('fill', fill);
+ link.append('circle').attr('r', d.attributes.size).attr('fill', fill);
};
- if (d.thumbnail) {
- if (d.types.length === 1) {
- const type = graphProperties['record_types'][d.types[0]];
- drawSimpleCircle(type.stroke, `url(#${d.thumbnail})`);
+ if (d.attributes.thumbnail) {
+ if (d.attributes.types.length === 1) {
+ const type = graphProperties['record_types'][d.attributes.types[0]];
+ drawSimpleCircle(type.stroke, `url(#${d.attributes.thumbnail})`);
} else {
- generatePathCoordinatesWithBorder(d.types.length, d.size, strokeWidth).forEach(
- ({ border }, i) => {
- const type = graphProperties['record_types'][d.types[i]];
- /** Background: borders with one color per type */
- link.append('path').attr('d', border).attr('fill', type.stroke).attr('class', 'border');
- },
- );
+ generatePathCoordinatesWithBorder(
+ d.attributes.types.length,
+ d.attributes.size,
+ strokeWidth,
+ ).forEach(({ border }, i) => {
+ const type = graphProperties['record_types'][d.attributes.types[i]];
+ /** Background: borders with one color per type */
+ link.append('path').attr('d', border).attr('fill', type.stroke).attr('class', 'border');
+ });
/** Background: neutral white color */
- link.append('circle').attr('r', d.size).attr('fill', `var(--background-gray)`);
+ link.append('circle').attr('r', d.attributes.size).attr('fill', `var(--background-gray)`);
/** Foreground: circle contains thumbnail */
- link.append('circle').attr('r', d.size).attr('fill', `url(#${d.thumbnail})`);
+ link
+ .append('circle')
+ .attr('r', d.attributes.size)
+ .attr('fill', `url(#${d.attributes.thumbnail})`);
}
return;
}
- if (d.types.length === 1) {
- const type = graphProperties['record_types'][d.types[0]];
+ if (d.attributes.types.length === 1) {
+ const type = graphProperties['record_types'][d.attributes.types[0]];
drawSimpleCircle(type.stroke, getFill(type.fill));
} else {
- generatePathCoordinatesWithBorder(d.types.length, d.size, strokeWidth).forEach(
- ({ segment, border }, i) => {
- const type = graphProperties['record_types'][d.types[i]];
- /** Background: borders with one color per type */
- link.append('path').attr('d', border).attr('fill', type.stroke).attr('class', 'border');
- /** Background: neutral white color */
- link.append('path').attr('d', segment).attr('fill', 'var(--background-gray)');
- /** Foreground: circle fragment per type with color or image */
- link.append('path').attr('d', segment).attr('fill', getFill(type.fill));
- },
- );
+ generatePathCoordinatesWithBorder(
+ d.attributes.types.length,
+ d.attributes.size,
+ strokeWidth,
+ ).forEach(({ segment, border }, i) => {
+ const type = graphProperties['record_types'][d.attributes.types[i]];
+ /** Background: borders with one color per type */
+ link.append('path').attr('d', border).attr('fill', type.stroke).attr('class', 'border');
+ /** Background: neutral white color */
+ link.append('path').attr('d', segment).attr('fill', 'var(--background-gray)');
+ /** Foreground: circle fragment per type with color or image */
+ link.append('path').attr('d', segment).attr('fill', getFill(type.fill));
+ });
}
});
@@ -301,7 +288,7 @@ elts.labels = elts.nodes
.append('text')
.attr('class', 'label')
.each(function (d) {
- const words = d.label.split(' '),
+ const words = d.attributes.label.split(' '),
max = 25,
text = d3.select(this);
let label = '';
@@ -322,7 +309,7 @@ elts.labels = elts.nodes
})
.attr('font-size', graphProperties.graph_text_size)
.attr('x', 0)
- .attr('y', (d) => d.size)
+ .attr('y', (d) => d.attributes.size)
.attr('dominant-baseline', 'middle')
.attr('text-anchor', 'middle');
@@ -378,71 +365,48 @@ function generatePathCoordinatesWithBorder(numSegments, diameter, borderSize) {
/**
* Get nodes and their links
- * @param {array} nodeIds - List of nodes ids
- * @returns {NodeNetwork} - DOM elts : nodes and their links
+ * @param {string} nodeIds - List of nodes ids
*/
-function getNodeNetwork(nodeIds) {
- const diplayedNodes = elts.nodes
- .filter((item) => item.hidden === filterPriority.notFiltered)
- .data()
- .map((item) => item.id);
-
- const nodes = elts.nodes.filter((node) => nodeIds.includes(node.id));
+function getNodeNetwork(nodeId) {
+ const edges = graph.edges(nodeId);
- const links = elts.links.filter(function (link) {
- if (!nodeIds.includes(link.source.id) && !nodeIds.includes(link.target.id)) {
- return false;
- }
- if (!diplayedNodes.includes(link.source.id) || !diplayedNodes.includes(link.target.id)) {
- return false;
- }
-
- return true;
- });
+ const node = elts.nodes.filter(({ key }) => key === nodeId);
+ const links = elts.links.filter(({ key }) => edges.includes(key));
return {
- nodes,
+ node,
links,
};
}
-function setNodesDisplaying(nodeIds, priority) {
- const toHide = [],
- toDisplay = [];
-
- allNodeIds.forEach((id) => {
- if (nodeIds.includes(id)) {
- toDisplay.push(id);
- } else {
- toHide.push(id);
- }
- });
+function setNodesDisplaying(nodeIds) {
+ const toDisplay = nodeIds;
+ const toHide = Array.from(d3.difference(allNodeIds, toDisplay));
- hideNodes(toHide, priority);
- displayNodes(toDisplay, priority);
+ displayNodes(toDisplay);
+ hideNodes(toHide);
}
+graph.on('nodeAttributesUpdated', function ({ key, attributes }) {
+ const { links, node } = getNodeNetwork(key);
+
+ if (attributes.hidden) {
+ node.node().classList.add('hide');
+ links.nodes().forEach((elt) => elt.classList.add('hide'));
+ } else {
+ node.node().classList.remove('hide');
+ links.nodes().forEach((elt) => elt.classList.remove('hide'));
+ }
+});
+
/**
* Hide some nodes & their links, by their id
* @param {array} nodeIds - List of nodes ids
*/
-function hideNodes(nodeIds, priority) {
- hideNodeNetwork(nodeIds);
- hideFromIndex(nodeIds);
-
- if (priority === undefined) {
- throw new Error('Need priority');
- }
-
- elts.nodes.data().map((node) => {
- const { id, hidden } = node;
- if (nodeIds.includes(id) && hidden <= priority) {
- node.hidden = priority;
- }
- return node;
- });
+function hideNodes(nodeIds) {
+ nodeIds.forEach((nodeId) => graph.setNodeAttribute(nodeId, 'hidden', true));
}
/**
@@ -450,53 +414,21 @@ function hideNodes(nodeIds, priority) {
* @param {array} nodeIds - List of nodes ids
*/
-function displayNodes(nodeIds, priority = filterPriority.notFiltered) {
- const nodesToDisplayIds = [];
-
- elts.nodes.data().map((node) => {
- const { id, hidden } = node;
- if (nodeIds.includes(id) && hidden <= priority) {
- nodesToDisplayIds.push(id);
- node.hidden = filterPriority.notFiltered;
- }
- return node;
- });
-
- displayNodeNetwork(nodesToDisplayIds);
- displayFromIndex(nodesToDisplayIds);
+function displayNodes(nodeIds) {
+ nodeIds.forEach((nodeId) => graph.setNodeAttribute(nodeId, 'hidden', false));
}
-function displayNodesAll(priority = filterPriority.notFiltered) {
- displayNodes(allNodeIds, priority);
-}
+function displayNodesAll() {
+ graph.updateEachNodeAttributes((node, attr) => ({
+ ...attr,
+ hidden: false,
+ }));
-function hideNodesAll(priority = filterPriority.notFiltered) {
- hideNodes(allNodeIds, priority);
+ elts.nodes.nodes().forEach((elt) => elt.classList.remove('hide'));
+ elts.links.nodes().forEach((elt) => elt.classList.remove('hide'));
}
-/**
- * Display none nodes and their link
- * @param {string[]|number[]} nodeIds - List of nodes ids
- */
-
-window.hideNodeNetwork = function (nodeIds) {
- const { nodes, links } = getNodeNetwork(nodeIds);
-
- nodes.nodes().forEach((elt) => elt.classList.add('hide'));
- links.nodes().forEach((elt) => elt.classList.add('hide'));
-};
-
-/**
- * Reset display nodes and their link
- * @param {string[]|number[]} nodeIds - List of nodes ids
- */
-
-window.displayNodeNetwork = function (nodeIds) {
- const { nodes, links } = getNodeNetwork(nodeIds);
-
- nodes.nodes().forEach((elt) => elt.classList.remove('hide'));
- links.nodes().forEach((elt) => elt.classList.remove('hide'));
-};
+let highlightedNodes = [];
/**
* Apply highlightColor (from config) to somes nodes and their links
@@ -504,12 +436,15 @@ window.displayNodeNetwork = function (nodeIds) {
*/
function highlightNodes(nodeIds) {
- const { nodes, links } = getNodeNetwork(nodeIds);
-
- nodes.nodes().forEach((elt) => elt.classList.add('highlight'));
- links.nodes().forEach((elt) => elt.classList.add('highlight'));
+ nodeIds
+ .filter((nodeId) => graph.hasNode(nodeId))
+ .forEach((nodeId) => {
+ const { links, node } = getNodeNetwork(nodeId);
+ node.node().classList.add('highlight');
+ links.nodes().forEach((elt) => elt.classList.add('highlight'));
+ });
- View.highlightedNodes = View.highlightedNodes.concat(nodeIds);
+ highlightedNodes = highlightedNodes.concat(nodeIds);
}
/**
@@ -517,16 +452,19 @@ function highlightNodes(nodeIds) {
*/
function unlightNodes() {
- if (View.highlightedNodes.length === 0) {
+ if (highlightedNodes.length === 0) {
return;
}
- const { nodes, links } = getNodeNetwork(View.highlightedNodes);
-
- nodes.nodes().forEach((elt) => elt.classList.remove('highlight'));
- links.nodes().forEach((elt) => elt.classList.remove('highlight'));
+ highlightedNodes
+ .filter((nodeId) => graph.hasNode(nodeId))
+ .forEach((nodeId) => {
+ const { links, node } = getNodeNetwork(nodeId);
+ node.node().classList.remove('highlight');
+ links.nodes().forEach((elt) => elt.classList.remove('highlight'));
+ });
- View.highlightedNodes = [];
+ highlightedNodes = [];
}
/**
@@ -572,7 +510,7 @@ function translate() {
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight;
- const { x, y, zoom } = View.position;
+ const { x, y, zoom } = position;
const screenMin = d3.min([screenHeight, screenWidth]);
@@ -595,20 +533,106 @@ function translate() {
}
}
-const nodes = elts.nodes.data();
-const links = elts.links.data();
+const zoomMax = 10,
+ zoomMin = 1;
+
+let zoomInterval = 0.2;
+
+/**
+ * Sum of nodes size
+ * @type {number}
+ */
+const nodeFactor = graph.reduceNodes((acc, node, { size }) => acc + size, 0);
+
+window.addEventListener('resize', () => {
+ let density = nodeFactor / (window.innerWidth * window.innerHeight);
+ density *= 1000;
+
+ zoomInterval = Math.log2(density);
+});
+
+const position = { x: 0, y: 0, zoom: 1 };
+
+const zoom = d3
+ .zoom()
+ .scaleExtent([zoomMin, zoomMax])
+ .on('zoom', (e) => {
+ const { x, y, k } = e.transform;
+ position.x = x || 0;
+ position.y = y || 0;
+ position.zoom = k || 1;
+ translate();
+ });
+
+svg.call(zoom);
+
+function zoomMore() {
+ zoom.scaleTo(svg, position.zoom + zoomInterval);
+}
+
+function zoomLess() {
+ zoom.scaleTo(svg, position.zoom - zoomInterval);
+}
+
+function zoomReset() {
+ position.zoom = 1;
+ position.x = 0;
+ position.y = 0;
+ svg.call(zoom.transform, d3.zoomIdentity.translate(position.y, position.x).scale(position.zoom));
+ translate();
+}
+
+window.zoomMore = zoomMore;
+window.zoomLess = zoomLess;
+window.zoomReset = zoomReset;
+
+hotkeys('e,alt+r', (e) => {
+ e.preventDefault();
+ zoomReset();
+});
+
+/**
+ * Zoom to a node from its coordinates
+ * @param {string} nodeId
+ */
+
+function zoomToNode(nodeId) {
+ const nodes = elts.nodes.data();
+
+ const node = nodes.find(({ key }) => key === nodeId);
+
+ if (!node) return;
+ const { x, y } = node;
+
+ const meanX = d3.mean(nodes, (d) => d.x);
+ const meanY = d3.mean(nodes, (d) => d.y);
+
+ const zoomScale = position.zoom === 1 ? position.zoom + zoomInterval * 2 : position.zoom;
+
+ svg.call(
+ zoom.transform,
+ d3.zoomIdentity.translate(-(x * zoomScale - meanX), -(y * zoomScale - meanY)).scale(zoomScale),
+ );
+ translate();
+}
+
+hotkeys('c', (e) => {
+ e.preventDefault();
+ const recordId = getRecordIdFromHash();
+
+ zoomToNode(recordId);
+});
export {
svg,
svgSub,
hideNodes,
- hideNodesAll,
displayNodes,
displayNodesAll,
setNodesDisplaying,
highlightNodes,
unlightNodes,
translate,
- nodes,
- links,
+ graph,
+ zoomToNode,
};
diff --git a/core/frontend/index.js b/core/frontend/index.js
index bc8ad9c3..f35fe17e 100644
--- a/core/frontend/index.js
+++ b/core/frontend/index.js
@@ -4,7 +4,6 @@ import './print.css';
import './records.js';
import './search.js';
import './graph.js';
-import './zoom.js';
import './bibliography.js';
import './timeline.js';
import './filter.js';
diff --git a/core/frontend/records.js b/core/frontend/records.js
index f067dace..1bc90765 100644
--- a/core/frontend/records.js
+++ b/core/frontend/records.js
@@ -1,7 +1,5 @@
-import { nodes, highlightNodes, unlightNodes } from './graph.js';
-import { zoomToNode } from './zoom.js';
+import { graph, zoomToNode, highlightNodes, unlightNodes } from './graph.js';
import hotkeys from 'hotkeys-js';
-import filterPriority from './filterPriority.js';
window.addEventListener('DOMContentLoaded', () => {
const recordContainer = document.getElementById('record-container');
@@ -112,67 +110,13 @@ window.addEventListener('DOMContentLoaded', () => {
}
});
-/**
- * Hide items from the index list that correspond to the nodes ids
- * @param {array} nodeIds - List of nodes ids
- */
-
-function hideFromIndex(nodesIds) {
- for (const indexItem of nodesIds) {
- const indexItems = indexContainer.querySelectorAll('[data-index="' + indexItem + '"]');
- indexItems.forEach((elt) => {
- elt.style.display = 'none';
- });
- }
-}
-
-/**
- * Hide all items from the index list
- */
-
-function hideAllFromIndex() {
- indexContainer.querySelectorAll('[data-index]').forEach((elt) => {
- elt.style.display = 'none';
- });
-}
-
-/**
- * Display items from the index list that correspond to the nodes ids
- * @param {array} nodeIds - List of nodes ids
- */
-
-function displayFromIndex(nodesIds) {
- nodesIds = nodesIds.filter(function (nodeId) {
- // hidden nodes can not be displayed
- const nodeIsHidden = nodes.find((i) => i.id === nodeId).hidden;
- if (nodeIsHidden === filterPriority.notFiltered) {
- return true;
- }
- });
-
- for (const indexItem of nodesIds) {
- const indexItems = indexContainer.querySelectorAll('[data-index="' + indexItem + '"]');
- indexItems.forEach((elt) => {
- elt.style.display = null;
- });
- }
-}
-
-/**
- * Display all items from the index list
- */
+graph.on('nodeAttributesUpdated', function ({ key, attributes }) {
+ const elt = indexContainer.querySelector(`[data-index="${key}"]`);
+ elt.style.display = attributes.hidden ? 'none' : null;
+});
-function displayAllFromIndex() {
- const indexItems = indexContainer.querySelectorAll('[data-index]');
- indexItems.forEach((elt) => {
- elt.style.display = null;
- });
+function displayAllIndex() {
+ document.querySelectorAll('[data-index]').forEach((elt) => (elt.style.display = null));
}
-export {
- getRecordIdFromHash,
- hideFromIndex,
- hideAllFromIndex,
- displayFromIndex,
- displayAllFromIndex,
-};
+export { getRecordIdFromHash, displayAllIndex };
diff --git a/core/frontend/search.js b/core/frontend/search.js
index c9c570eb..b2078da5 100644
--- a/core/frontend/search.js
+++ b/core/frontend/search.js
@@ -1,10 +1,10 @@
import Fuse from 'fuse.js';
import hotkeys from 'hotkeys-js';
-import { nodes } from './graph.js';
+import { graph } from './graph';
const fuse = new Fuse([], {
includeScore: false,
- keys: ['label'],
+ keys: ['attributes.label'],
});
window.addEventListener('DOMContentLoaded', () => {
@@ -16,7 +16,17 @@ window.addEventListener('DOMContentLoaded', () => {
const resultContainer = document.getElementById('search-result-list');
input.addEventListener('focus', () => {
- fuse.setCollection(nodes.filter(({ hidden }) => hidden === 0));
+ const visibleNodes = graph.reduceNodes((acc, key, attributes) => {
+ if (attributes.hidden === false) {
+ acc.push({
+ key,
+ attributes,
+ });
+ }
+ return acc;
+ }, []);
+
+ fuse.setCollection(visibleNodes);
input.addEventListener('input', () => {
resultContainer.innerHTML = '';
@@ -31,13 +41,16 @@ window.addEventListener('DOMContentLoaded', () => {
for (let i = 0; i < Math.min(maxResultNb, resultList.length); i++) {
let {
- item: { id, label, types },
+ item: {
+ key,
+ attributes: { label, types },
+ },
} = resultList[i];
const resultElement = document.createElement('li');
resultElement.classList.add('search-result-item');
resultElement.innerHTML = `
-
+
${types.map((type) => `⬤`).join(' ')}
@@ -94,7 +107,7 @@ window.addEventListener('DOMContentLoaded', () => {
break;
case 'Enter':
e.preventDefault();
- window.location.hash = resultList[selectedResult].item.id;
+ window.location.hash = resultList[selectedResult].item.key;
input.blur();
break;
}
diff --git a/core/frontend/tags.js b/core/frontend/tags.js
index aeff8410..2b667413 100644
--- a/core/frontend/tags.js
+++ b/core/frontend/tags.js
@@ -1,5 +1,5 @@
import { displayNodesAll, setNodesDisplaying } from './graph.js';
-import filterPriority from './filterPriority.js';
+import { displayAllIndex } from './records.js';
import hotkeys from 'hotkeys-js';
window.addEventListener('DOMContentLoaded', () => {
@@ -11,6 +11,9 @@ window.addEventListener('DOMContentLoaded', () => {
/** @type {HTMLSelectElement} */
const sortSelect = document.querySelector('.menu-tags .sorting-select');
+ /** @type {[string, string[]][]} */
+ const tags = Object.entries(tagList);
+
const tagsSorting = sorting.tags;
let tagsState;
@@ -66,18 +69,19 @@ window.addEventListener('DOMContentLoaded', () => {
const nodeIdsToDisplay = new Set();
- tagsState = tagList
- .filter(({ name }) => !!formState[name])
- .forEach(({ nodes }) => {
+ tagsState = tags
+ .filter(([name]) => !!formState[name])
+ .forEach(([, nodes]) => {
nodes.forEach((id) => nodeIdsToDisplay.add(id));
});
if (nodeIdsToDisplay.size === 0) {
- displayNodesAll(filterPriority.filteredByTag);
+ displayNodesAll();
+ displayAllIndex();
return;
}
- setNodesDisplaying(Array.from(nodeIdsToDisplay), filterPriority.filteredByTag);
+ setNodesDisplaying(Array.from(nodeIdsToDisplay));
}
hotkeys('alt+r', (e) => {
diff --git a/core/frontend/timeline.js b/core/frontend/timeline.js
index 42ff33eb..1b0db84d 100644
--- a/core/frontend/timeline.js
+++ b/core/frontend/timeline.js
@@ -1,6 +1,5 @@
import { setCounters } from './counter.js';
import { setNodesDisplaying, displayNodesAll } from './graph.js';
-import filterPriority from './filterPriority.js';
const { begin, end } = timeline;
@@ -33,7 +32,7 @@ window.addEventListener('DOMContentLoaded', () => {
window.removeEventListener('resize', setChronosTicks);
range.removeEventListener('input', action);
- displayNodesAll(filterPriority.filteredByTimeline);
+ displayNodesAll();
setCounters();
}
});
@@ -44,7 +43,10 @@ window.addEventListener('DOMContentLoaded', () => {
const toDisplay = [];
- for (let { begin: nodeBegin, end: nodeEnd, id } of data.nodes) {
+ for (let {
+ attributes: { begin: nodeBegin, end: nodeEnd },
+ key,
+ } of data.nodes) {
if (nodeEnd === undefined) {
nodeEnd = end;
}
@@ -53,11 +55,11 @@ window.addEventListener('DOMContentLoaded', () => {
}
if (timestamp >= nodeBegin && timestamp <= nodeEnd) {
- toDisplay.push(id);
+ toDisplay.push(key);
}
}
- setNodesDisplaying(toDisplay, filterPriority.filteredByTimeline);
+ setNodesDisplaying(toDisplay);
setCounters();
}
diff --git a/core/frontend/view.js b/core/frontend/view.js
index 5c435fbc..c1d40ab7 100644
--- a/core/frontend/view.js
+++ b/core/frontend/view.js
@@ -1,11 +1,3 @@
-export default class View {
- static highlightedNodes = [];
-
- static focusMode = false;
-
- static position = { x: 0, y: 0, zoom: 1 };
-}
-
window.addEventListener('DOMContentLoaded', () => {
document.getElementById('view-save').addEventListener('click', () => {
const url = new URL(window.location);
diff --git a/core/frontend/zoom.js b/core/frontend/zoom.js
deleted file mode 100644
index a5ad4655..00000000
--- a/core/frontend/zoom.js
+++ /dev/null
@@ -1,97 +0,0 @@
-import * as d3 from 'd3';
-
-import View from './view.js';
-import { svg, translate, nodes } from './graph.js';
-import hotkeys from 'hotkeys-js';
-import { getRecordIdFromHash } from './records.js';
-
-const zoomMax = 10,
- zoomMin = 1;
-
-let zoomInterval = 0.2;
-
-/**
- * Sum of nodes size
- * @type {number}
- */
-const nodeFactor = nodes.reduce((acc, { size }) => acc + size, 0);
-
-window.addEventListener('resize', () => {
- let density = nodeFactor / (window.innerWidth * window.innerHeight);
- density *= 1000;
-
- zoomInterval = Math.log2(density);
-});
-
-const zoom = d3
- .zoom()
- .scaleExtent([zoomMin, zoomMax])
- .on('zoom', (e) => {
- const { x, y, k } = e.transform;
- View.position.x = x || 0;
- View.position.y = y || 0;
- View.position.zoom = k || 1;
- translate();
- });
-
-svg.call(zoom);
-
-function zoomMore() {
- zoom.scaleTo(svg, View.position.zoom + zoomInterval);
-}
-
-function zoomLess() {
- zoom.scaleTo(svg, View.position.zoom - zoomInterval);
-}
-
-function zoomReset() {
- View.position.zoom = 1;
- View.position.x = 0;
- View.position.y = 0;
- svg.call(
- zoom.transform,
- d3.zoomIdentity.translate(View.position.y, View.position.x).scale(View.position.zoom),
- );
- translate();
-}
-
-hotkeys('e,alt+r', (e) => {
- e.preventDefault();
- zoomReset();
-});
-
-/**
- * Zoom to a node from its coordinates
- * @param {string} nodeId
- */
-
-function zoomToNode(nodeId) {
- const node = nodes.find(({ id }) => id === nodeId);
- if (!node) return;
- const { x, y } = node;
-
- const meanX = d3.mean(nodes, (d) => d.x);
- const meanY = d3.mean(nodes, (d) => d.y);
-
- const zoomScale =
- View.position.zoom === 1 ? View.position.zoom + zoomInterval * 2 : View.position.zoom;
-
- svg.call(
- zoom.transform,
- d3.zoomIdentity.translate(-(x * zoomScale - meanX), -(y * zoomScale - meanY)).scale(zoomScale),
- );
- translate();
-}
-
-hotkeys('c', (e) => {
- e.preventDefault();
- const recordId = getRecordIdFromHash();
-
- zoomToNode(recordId);
-});
-
-window.zoomMore = zoomMore;
-window.zoomLess = zoomLess;
-window.zoomReset = zoomReset;
-
-export { zoomToNode };
diff --git a/core/index.js b/core/index.js
deleted file mode 100644
index 4330b13f..00000000
--- a/core/index.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { cosmocope, cosmocopeTimeline, cosmocopeTitleId, tempDirPath } from './utils/generate.js';
-import { startServer as webpackServer } from './utils/webpack.js';
-
-(async () => {
- try {
- console.log('Download some files...');
- await cosmocope(tempDirPath);
- await cosmocopeTimeline();
- await cosmocopeTitleId();
- console.log('Start devserver...');
- await webpackServer('development');
- } catch (err) {
- console.error(['\x1b[31m', 'Err.', '\x1b[0m'].join(''), err);
- }
-})();
diff --git a/core/models/bibliography.js b/core/models/bibliography.js
index 018a8de9..8cb9f9d8 100644
--- a/core/models/bibliography.js
+++ b/core/models/bibliography.js
@@ -12,13 +12,10 @@ import extractParaphs from '../utils/paraphExtractor';
/**
* @typedef BibliographicRecord
* @type {object}
- * @property {object} quotesExtract
- * @property {Citation[]} quotesExtract.citationItems
- * @property {object} quotesExtract.properties
- * @property {number} quotesExtract.properties.noteIndex
- * @property {string} text Quote string from plain text
- * @property {string[]} contexts Paragraph contains quote
- * @property {Set} ids All quote ids from quotesExtract.citationItems
+ * @property {string} type
+ * @property {string} target
+ * @property {string} text
+ * @property {string[]} contexts
*/
/**
@@ -37,22 +34,19 @@ class Bibliography {
* @returns {BibliographicRecord[]}
*/
- static getBibliographicRecordsFromText(recordContent) {
+ static getBibliographicLinksFromText(recordContent) {
/** @type {BibliographicRecord[]} */
let quotes = [];
extractParaphs(recordContent).forEach((paraph) => {
extractCitations(paraph).forEach((result) => {
- quotes.push({
- quotesExtract: {
- citationItems: result.citations,
- properties: {
- noteIndex: 1,
- },
- },
- text: result.source,
- contexts: [paraph],
- ids: new Set(result.citations.map(({ id }) => id)),
+ result.citations.forEach((citation) => {
+ quotes.push({
+ contexts: [paraph],
+ target: citation.id,
+ text: undefined,
+ type: citation.type || 'undefined',
+ });
});
});
});
@@ -65,25 +59,13 @@ class Bibliography {
* @returns {BibliographicRecord[]}
*/
- static getBibliographicRecordsFromList(quotesId = []) {
+ static getBibliographicLinksFromList(quotesId = []) {
return quotesId.map((quoteId, index) => {
return {
- quotesExtract: {
- citationItems: [
- {
- prefix: '',
- suffix: '',
- id: quoteId,
- label: 'page',
- locator: '',
- 'suppress-author': false,
- },
- ],
- properties: { noteIndex: index + 1 },
- },
- text: '',
contexts: [],
- ids: new Set([quoteId]),
+ target: quoteId,
+ text: undefined,
+ type: 'undefined',
};
});
}
diff --git a/core/models/config.js b/core/models/config.js
index b08c75fe..619d31b0 100644
--- a/core/models/config.js
+++ b/core/models/config.js
@@ -8,7 +8,6 @@ import fs from 'node:fs';
import path from 'node:path';
import envPaths from 'env-paths';
import yml from 'yaml';
-import Link from './link.js';
import lang from './lang.js';
import http from 'node:http';
import { FindUserDataDirError, ReadUserDataDirError } from './errors.js';
@@ -252,6 +251,8 @@ class Config {
return false;
}
+ const validLinkStrokes = new Set(['simple', 'double', 'dotted', 'dash']);
+
for (const key in linkTypes) {
if (!key) {
return false;
@@ -262,7 +263,7 @@ class Config {
typeof linkTypes[key]['color'] !== 'string' ||
linkTypes[key]['stroke'] === undefined ||
typeof linkTypes[key]['stroke'] !== 'string' ||
- Link.validLinkStrokes.has(linkTypes[key]['stroke']) === false
+ validLinkStrokes.has(linkTypes[key]['stroke']) === false
) {
return false;
}
diff --git a/core/models/cosmoscope.js b/core/models/cosmoscope.js
index c1bdd322..4e542d77 100644
--- a/core/models/cosmoscope.js
+++ b/core/models/cosmoscope.js
@@ -8,16 +8,15 @@ import fs from 'node:fs';
import path from 'node:path';
import { glob } from 'glob';
import { parse } from 'csv-parse';
-import Graph from './graph.js';
import Config from './config.js';
-import Node from './node.js';
-import Link from './link.js';
import Record from './record.js';
import Bibliography from './bibliography.js';
import Report from './report.js';
import { read as readYmlFm } from '../utils/yamlfrontmatter.js';
import { ReadCsvFileNodesError, ReadCsvFileLinksError, ReadCsvLinesLinksError } from './errors.js';
import quoteIdsWithContexts from '../utils/quoteIdsWithContexts.js';
+import GraphEngine from 'graphology';
+import { scaleLinear } from 'd3';
/**
* @typedef File
@@ -49,51 +48,7 @@ import quoteIdsWithContexts from '../utils/quoteIdsWithContexts.js';
* @property {number} target.id
*/
-class Cosmoscope extends Graph {
- /**
- * @param {fs.PathLike} pathToFiles
- * @returns {File[]}
- */
-
- static getFromPathFilesAsync(pathToFiles) {
- const files = [];
- return new Promise((resolve, reject) => {
- glob('**/*.md', { cwd: pathToFiles, realpath: true }, (err, filesPath) => {
- if (err) {
- reject(err);
- }
- Promise.all(
- filesPath.map((filePath) => {
- return new Promise((resolveFile, rejectFile) => {
- return fs.readFile(filePath, 'utf-8', (err, fileContain) => {
- if (err) {
- rejectFile(err);
- }
- const { __content: content, ...metas } = ymlFM.loadFront(fileContain);
- /** @type {File} */
- const file = {
- path: filePath,
- name: path.basename(filePath),
- lastEditDate: fs.statSync(filePath).mtime,
- content,
- metas,
- };
- file.metas.type = file.metas.type || 'undefined';
- file.metas.tags = file.metas['tags'] || file.metas['keywords'] || [];
- file.metas.id = file.metas.id;
- file.metas.references = file.metas.references || [];
- files.push(file);
- resolveFile();
- });
- });
- }),
- )
- .then(() => resolve(files))
- .catch((err) => reject);
- });
- });
- }
-
+class Cosmoscope {
/**
* @param {fs.PathLike} pathToFiles
* @returns {File[]}
@@ -231,7 +186,9 @@ class Cosmoscope extends Graph {
});
});
- // const ignoreLinesLinks = [];
+ function getFormatedDataFromCsvLine({ source, target, label, type }) {
+ return { source, target, label, type: type || 'undefined' };
+ }
const linksPromise = new Promise((resolve, reject) => {
fs.readFile(linksFilePath, 'utf-8', (err, data) => {
@@ -250,7 +207,7 @@ class Cosmoscope extends Graph {
while ((line = this.read()) !== null) {
i++;
if (!!line['source'] && !!line['target']) {
- links.push(Link.getFormatedDataFromCsvLine(line));
+ links.push(getFormatedDataFromCsvLine(line));
continue;
}
new Report('ignored_csv_line', '', 'error').aboutIgnoredCsvLine(
@@ -274,130 +231,78 @@ class Cosmoscope extends Graph {
/**
* @param {File[]} files
- * @param {boolean} citeproc
* @param {Config.opts} opts
* @returns {Record[]}
*/
- static getRecordsFromFiles(files, citeproc, opts = {}) {
- const config = new Config(opts);
- /** @type {Bibliography} */
- let bibliography;
-
- /** @type {Link[]} */
- const links = [];
- /** @type {Node[]} */
- const nodes = [];
-
- for (const file of files) {
- const id = file.metas['id'] || file.metas['title'].toLowerCase();
- const { content } = file;
- links.push(...Link.getWikiLinksFromFileContent(id, content));
-
- let { title, types } = file.metas;
- nodes.push(new Node(id, title, types));
- }
-
- /**
- * @typedef ReferenceRecord
- * @type {object}
- * @property {Set} targets
- * @property {Map} contexts
- */
-
- /** @type {Map} */
- let referenceRecords = new Map([]);
-
- if (citeproc && opts['references_as_nodes'] && config.canCiteproc()) {
- const { bib, cslStyle, xmlLocal } = Bibliography.getBibliographicFilesFromConfig(config);
- bibliography = new Bibliography(bib, cslStyle, xmlLocal);
-
- for (const file of files) {
- const quotesWithContexts = [
- ...quoteIdsWithContexts(file.content),
- ...file.metas.references.map((quoteId) => ({
- contexts: [],
- id: quoteId,
- })),
- ];
-
- const fileId = file.metas['id'] || file.metas['title'].toLowerCase();
-
- quotesWithContexts.forEach(({ id, type, contexts }) => {
- if (!bibliography.library[id]) return;
-
- if (referenceRecords.has(id)) {
- const ref = referenceRecords.get(id);
- ref.targets.add(fileId);
- } else {
- referenceRecords.set(id, {
- contexts,
- targets: new Set([fileId]),
- type,
- });
- }
- });
- }
- }
-
- referenceRecords.forEach(({ targets, contexts, type }, key) => {
- nodes.push(
- new Node(key, bibliography.library[key]['title'] || '', [opts['references_type_label']]),
- );
- Array.from(targets).forEach((id) =>
- links.push(
- new Link(
- undefined,
- contexts,
- type || 'undefined',
- undefined,
- undefined,
- undefined,
- id,
- key,
- ),
- ),
- );
- });
-
- const records = files.map((file) => {
+ static getRecordsFromFiles(files, opts = {}) {
+ return files.map((file) => {
let { id, title, types, tags, thumbnail, references, begin, end, ...metas } = file.metas;
id = id || file.metas['title'].toLowerCase();
- const { linksReferences, backlinksReferences } = Link.getReferencesFromLinks(
- id,
- links,
- nodes,
- );
- const bibliographicRecords = [
- ...Bibliography.getBibliographicRecordsFromText(file.content),
- ...Bibliography.getBibliographicRecordsFromList(references),
+ const bibliographicLinks = [
+ ...Bibliography.getBibliographicLinksFromText(file.content),
+ ...Bibliography.getBibliographicLinksFromList(file.metas.references),
];
- return new Record(
+ const record = new Record(
id,
title,
types,
tags,
metas,
file.content,
- linksReferences,
- backlinksReferences,
begin,
end,
- bibliographicRecords,
+ bibliographicLinks,
thumbnail,
opts,
);
+
+ return record;
});
+ }
- referenceRecords.forEach((targets, key) => {
- const { linksReferences, backlinksReferences } = Link.getReferencesFromLinks(
- key,
- links,
- nodes,
- );
+ /**
+ * @param {Record[]} records
+ * @param {Config.opts} opts
+ * @returns {Record[]}
+ */
+
+ static getBibliographicRecords(records, opts = {}) {
+ const config = new Config(opts);
+
+ const { bib, cslStyle, xmlLocal } = Bibliography.getBibliographicFilesFromConfig(config);
+ const bibliography = new Bibliography(bib, cslStyle, xmlLocal);
+
+ let referenceRecords = new Map([]);
+ records.forEach(({ id: recordId, bibliographicLinks }) => {
+ bibliographicLinks.forEach(({ target, contexts }) => {
+ if (!bibliography.library[target]) return;
+
+ if (referenceRecords.has(target)) {
+ const ref = referenceRecords.get(target);
+ ref.targets.add(recordId);
+
+ if (ref.contexts.has(recordId)) {
+ ref.contexts.get(recordId).push(...contexts);
+ } else {
+ ref.contexts.set(recordId, contexts);
+ }
+ } else {
+ referenceRecords.set(target, {
+ contexts: new Map([[recordId, contexts]]),
+ targets: new Set([recordId]),
+ });
+ }
+ });
+ });
+
+ /** @type {Record[]} */
+ const bibliographicRecords = [];
+
+ referenceRecords.forEach((targets, key) => {
bibliography.citeproc.updateItems([key]);
let content = bibliography.citeproc
.makeBibliography()[1]
@@ -405,7 +310,7 @@ class Cosmoscope extends Graph {
const title = bibliography.library[key]['title'] || '';
- records.push(
+ bibliographicRecords.push(
new Record(
key,
title,
@@ -413,8 +318,6 @@ class Cosmoscope extends Graph {
undefined,
undefined,
content,
- linksReferences,
- backlinksReferences,
undefined,
undefined,
undefined,
@@ -424,7 +327,7 @@ class Cosmoscope extends Graph {
);
});
- return records;
+ return bibliographicRecords;
}
/**
@@ -450,9 +353,7 @@ class Cosmoscope extends Graph {
return 0;
}
- constructor(records, opts, params) {
- super(records, opts, params);
- }
+ constructor(records, opts, params) {}
}
export default Cosmoscope;
diff --git a/core/models/graph.js b/core/models/graph.js
deleted file mode 100644
index ae8cd775..00000000
--- a/core/models/graph.js
+++ /dev/null
@@ -1,168 +0,0 @@
-/**
- * @file Graph pattern
- * @author Guillaume Brioudes
- * @copyright GNU GPL 3.0 Cosma's authors
- */
-
-import Config from './config.js';
-import Node from './node.js';
-import Link from './link.js';
-import Record from './record.js';
-import Report from './report.js';
-import { extent } from 'd3';
-
-/**
- * @typedef Timeline
- * @type {object}
- * @property {number | undefined} begin
- * @property {number | undefined} end
- */
-
-/**
- * @typedef Folksonomy
- * @type {object}
- * @property {object} tags
- * @property {object} metas
- */
-
-class Graph {
- static validParams = new Set(['sample', 'fake', 'empty']);
-
- /**
- *
- * @param {Record[]} records
- * @param {Config.opts} opts
- * @param {string[]} params
- * @exemple
- * ```
- * const { files_origin: filesPath } = Config.get();
- * const files = Cosmoscope.getFromPathFiles(filesPath);
- * const records = Cosmoscope.getRecordsFromFiles(files, true, config.opts);
- * const graph = new Cosmocope(records, config.opts, ['sample']);
- * ```
- */
-
- constructor(records = [], opts = {}, params = []) {
- this.params = new Set(params.filter((param) => Graph.validParams.has(param)));
- this.records = records;
-
- this.stats = {
- linksExtent: extent(this.records, (d) => d.links.length),
- backlinksExtent: extent(this.records, (d) => d.backlinks.length),
- };
-
- this.data = {
- nodes: Node.getNodesFromRecords(this.records, this.stats),
- links: Link.getLinksFromRecords(this.records),
- };
- this.config = new Config(opts);
-
- this.reportDuplicatedIds();
- }
-
- /**
- * Check if ids are all unique and report duplicated ones
- */
-
- reportDuplicatedIds() {
- const recordsIdAlreadyAnalysed = new Set();
- let lastRecordTitle;
- for (const { id, title } of this.records) {
- if (recordsIdAlreadyAnalysed.has(id)) {
- new Report(id, title, 'error').aboutDuplicatedIds(id, title, lastRecordTitle);
- }
- recordsIdAlreadyAnalysed.add(id);
- lastRecordTitle = title;
- }
- }
-
- /**
- * Get timestamps begin and end for timeline,
- * based on most recent and oldest node
- * @returns {Timeline}
- */
-
- getTimelineFromRecords() {
- let dates = [];
- for (const { begin, end } of this.records) {
- dates.push(begin, end);
- }
- const [begin, end] = extent(dates);
- return {
- begin,
- end: end,
- };
- }
-
- /**
- * @returns {Map}
- */
-
- getTypesFromRecords() {
- const typesList = new Map();
- for (const { types, id } of this.records) {
- for (const type of types) {
- if (typesList.has(type)) {
- typesList.get(type).add(id);
- } else {
- typesList.set(type, new Set([id]));
- }
- }
- }
- return typesList;
- }
-
- /**
- * @returns {Map}
- */
-
- getTagsFromRecords() {
- const tagsList = new Map();
- for (const { tags, id } of this.records) {
- for (const tag of tags) {
- if (tagsList.has(tag)) {
- tagsList.get(tag).add(id);
- } else {
- tagsList.set(tag, new Set([id]));
- }
- }
- }
- return tagsList;
- }
-
- /**
- * @returns {Map}
- */
-
- getMetasFromRecords() {
- const metasList = new Map();
- for (const { metas } of this.records) {
- for (const [meta, value] of Object.entries(metas)) {
- if (metasList.has(meta)) {
- metasList.get(meta).add(value);
- } else {
- metasList.set(meta, new Set([value]));
- }
- }
- }
- return metasList;
- }
-
- /**
- * Get graph tags and metas as arrays.
- * @returns {string}
- */
-
- getFolksonomyAsObjectOfArrays() {
- let folksonomy = {
- tags: Object.fromEntries(this.getTagsFromRecords()),
- metas: Object.fromEntries(this.getMetasFromRecords()),
- };
- folksonomy = JSON.stringify(folksonomy, (key, value) =>
- value instanceof Set ? Array.from(value) : value,
- );
- return JSON.parse(folksonomy);
- }
-}
-
-export default Graph;
diff --git a/core/models/link.js b/core/models/link.js
deleted file mode 100644
index b8ad06de..00000000
--- a/core/models/link.js
+++ /dev/null
@@ -1,326 +0,0 @@
-/**
- * @file Link (link in graph) pattern
- * @author Guillaume Brioudes
- * @copyright GNU GPL 3.0 Cosma's authors
- */
-
-import Config from './config.js';
-import Report from './report.js';
-
-/**
- * @typedef Shape
- * @type {object}
- * @property {'simple' | 'double' | 'dotted' | 'dash'} stroke Display as a title
- * @property {string | null} dashInterval Display as a body text
- */
-
-/**
- * @typedef LinkNormalized
- * @type {object}
- * @property {string} type
- * @property {object} target
- * @property {number} target.id
- */
-
-/**
- * @typedef FormatedLinkData
- * @type {object}
- * @property {number} source
- * @property {number} target
- * @property {string} label
- * @property {string} type
- */
-
-class Link {
- /**
- * List of valid values for the links stroke
- * Apply to config form
- * @type {Set}
- * @static
- */
-
- static validLinkStrokes = new Set(['simple', 'double', 'dotted', 'dash']);
-
- /** @type {Shape} */
-
- static baseShape = { stroke: 'simple', dashInterval: null };
-
- static regexParagraph = new RegExp(/[^\r\n]+((\r|\n|\r\n)[^\r\n]+)*/, 'g');
-
- /** @exemple `"[[a:20210424214230|link text]]"` */
- static regexWikilink = new RegExp(
- /\[\[((?[^:|\]]+?):)?(?.+?)(\|(?.+?))?\]\]/,
- 'g',
- );
-
- /**
- * @param {File.metas.id} fileId
- * @param {File.content} fileContent
- * @returns {Link[]}
- * @exemple
- * ```
- * getWikiLinksFromFileContent(1, "Lorem [[20210531145255]] ipsum")
- * ```
- */
-
- static getWikiLinksFromFileContent(fileId, fileContent) {
- const links = {};
-
- let match;
- while ((match = Link.regexWikilink.exec(fileContent))) {
- const { type, text } = match.groups;
- const targetId = match.groups.id.toLowerCase();
- links[targetId] = { type, targetId, text, context: new Set() };
- }
-
- let paraphs = fileContent.match(Link.regexParagraph) || [];
-
- for (const paraph of paraphs) {
- let match;
- while ((match = Link.regexWikilink.exec(paraph))) {
- // const { id: targetId } = match.groups;
- const targetId = match.groups.id.toLowerCase();
- links[targetId].context.add(paraph);
- }
- }
-
- return Object.values(links).map(({ type, targetId, text, context }) => {
- return new Link(
- undefined,
- Array.from(context),
- type || 'undefined',
- undefined,
- undefined,
- undefined,
- fileId,
- targetId,
- );
- });
- }
-
- /**
- *
- * @param {Config.opts} configOpts
- * @param {string} linkType
- * @returns {object}
- */
-
- static getLinkStyle(configOpts, linkType) {
- const linkTypeConfig = configOpts.link_types[linkType];
- let stroke, color;
-
- if (linkTypeConfig) {
- stroke = linkTypeConfig.stroke;
- color = linkTypeConfig.color;
- } else {
- stroke = 'simple';
- color = null;
- }
-
- switch (stroke) {
- case 'simple':
- return { shape: { stroke: stroke, dashInterval: null }, color: color };
-
- case 'double':
- return { shape: { stroke: stroke, dashInterval: null }, color: color };
-
- case 'dash':
- return { shape: { stroke: stroke, dashInterval: '4, 5' }, color: color };
-
- case 'dotted':
- return { shape: { stroke: stroke, dashInterval: '1, 3' }, color: color };
- }
-
- // default return
- return { shape: { stroke: 'simple', dashInterval: null }, color: color };
- }
-
- /**
- * @param {string} nodeId
- * @param {Link[]} links
- * @param {Node[]} nodes
- * @returns {Reference[]}
- * @static
- */
-
- static getReferencesFromLinks(nodeId, links, nodes) {
- const linksFromNodeReferences = links
- .filter(({ source }) => source === nodeId)
- .map(({ context, type, source: sourceId, target: targetId }) => {
- const nodeTarget = nodes.find((n) => n.id === targetId);
- const nodeSource = nodes.find((n) => n.id === sourceId);
- if (!nodeTarget || !nodeSource) {
- new Report(nodeSource.id, nodeSource.label, 'error').aboutBrokenLinks(
- nodeSource.label,
- context,
- );
- return undefined;
- }
- const { label: targetLabel, types: targetTypes } = nodeTarget;
- const { label: sourceLabel, types: sourceTypes } = nodeSource;
- return {
- context,
- type,
- source: {
- id: sourceId,
- title: sourceLabel,
- types: sourceTypes,
- },
- target: {
- id: targetId,
- title: targetLabel,
- types: targetTypes,
- },
- };
- })
- .filter((link) => link !== undefined);
- const backlinksToNodeReferences = links
- .filter(({ target }) => target === nodeId)
- .map(({ context, type, source: sourceId, target: targetId }) => {
- const nodeTarget = nodes.find((n) => n.id === targetId);
- const nodeSource = nodes.find((n) => n.id === sourceId);
- if (!nodeTarget || !nodeSource) {
- return undefined;
- }
- const { label: targetLabel, types: targetTypes } = nodeTarget;
- const { label: sourceLabel, types: sourceTypes } = nodeSource;
- return {
- context,
- type,
- source: {
- id: sourceId,
- title: sourceLabel,
- types: sourceTypes,
- },
- target: {
- id: targetId,
- title: targetLabel,
- types: targetTypes,
- },
- };
- })
- .filter((link) => link !== undefined);
- return {
- linksReferences: linksFromNodeReferences,
- backlinksReferences: backlinksToNodeReferences,
- };
- }
-
- /**
- * @param {Record[]} records
- * @returns {Link[]}
- */
-
- static getLinksFromRecords(records) {
- const linksFromRecord = [];
- let id = 0;
- for (const { links, config } of records) {
- const { opts } = config;
- for (const { context, type, source, target } of links) {
- const { shape, color } = Link.getLinkStyle(opts, type);
- linksFromRecord.push(
- new Link(
- id,
- context,
- type,
- shape,
- color,
- opts['graph_highlight_color'],
- source.id,
- target.id,
- ),
- );
- id++;
- }
- }
- return linksFromRecord;
- }
-
- /**
- * Get data from a fromated CSV line
- * @param {object} line
- * @return {FormatedLinkData}
- */
-
- static getFormatedDataFromCsvLine({ source, target, label, type }) {
- return { source, target, label, type: type || 'undefined' };
- }
-
- /**
- * @param {FormatedLinkData[]} data
- * @returns {Link[]}
- */
-
- static formatedDatasetToLinks(data) {
- return data.map(({ label, source, target, type }, i) => {
- if (label) {
- label = [label];
- }
-
- const link = new Link(i, label, type, undefined, undefined, undefined, source, target);
-
- if (link.isValid()) {
- return link;
- }
- return undefined;
- });
- }
-
- /**
- * @param {number} id
- * @param {string[]} context
- * @param {string} type
- * @param {Shape} [shape = Link.baseShape]
- * @param {string} color
- * @param {string} colorHighlight
- * @param {string} source
- * @param {string} target
- */
-
- constructor(
- id,
- context = [],
- type,
- shape = Link.baseShape,
- color,
- colorHighlight,
- source,
- target,
- ) {
- this.id = id;
- this.context = context;
- this.type = type;
- this.shape = shape;
- this.color = color;
- this.colorHighlight = colorHighlight;
- this.source = source;
- this.target = target;
-
- this.report = [];
- }
-
- verif() {
- if (!this.title) {
- this.report.push('Invalid title');
- }
- if (!this.shape || Link.validLinkStrokes.has(this.shape) === false) {
- this.report.push('Invalid shape');
- }
- if (!this.source || isNaN(this.source)) {
- this.report.push('Invalid source');
- }
- if (!this.target || isNaN(this.target)) {
- this.report.push('Invalid target');
- }
- }
-
- /**
- * @returns {boolean}
- */
-
- isValid() {
- return this.report.length === 0;
- }
-}
-
-export default Link;
diff --git a/core/models/node.js b/core/models/node.js
deleted file mode 100644
index dff29223..00000000
--- a/core/models/node.js
+++ /dev/null
@@ -1,174 +0,0 @@
-/**
- * @file Define node pattern
- * @author Guillaume Brioudes
- * @copyright GNU GPL 3.0 Cosma's authors
- */
-
-import Config from './config.js';
-import { scaleLinear } from 'd3';
-
-class Node {
- /**
- * @param {number} linksNb
- * @param {number} backlinksNb
- * @param {[number, number]} linksExtent
- * @param {[number, number]} backlinksExtent
- * @param {number} minRange
- * @param {number} maxRange
- * @returns
- */
-
- static getNodeSizeByLinkRank(
- linksNb,
- backlinksNb,
- linksExtent,
- backlinksExtent,
- minRange,
- maxRange,
- ) {
- const [minLinks, maxLinks] = linksExtent;
- const [minBacklinks, maxBacklinks] = backlinksExtent;
-
- var size = scaleLinear()
- .domain([minLinks + minBacklinks, maxLinks + maxBacklinks])
- .range([minRange, maxRange]);
-
- return size(linksNb + backlinksNb);
- }
-
- /**
- * @param {Config} config
- * @param {string} nodeType
- * @param {string} thumbnail
- * @returns {object}
- */
-
- static getNodeStyle(config, nodeType, thumbnail) {
- if (!config || config instanceof Config === false) {
- throw new Error('Need instance of Config to process');
- }
- const format = config.getFormatOfTypeRecord(nodeType);
- let fill;
- switch (format) {
- case 'image':
- fill = `url(#${config.opts['record_types'][nodeType]['fill']})`;
- break;
- case 'color':
- default:
- fill = config.opts['record_types'][nodeType]['fill'];
- break;
- }
-
- if (thumbnail) {
- fill = `url(#${thumbnail})`;
- }
-
- return {
- fill,
- colorStroke: config.opts['record_types'][nodeType]['stroke'],
- highlight: config.opts['graph_highlight_color'],
- };
- }
-
- /**
- * @param {Record[]} records
- * @param {Graph.stats} graphStats
- * @returns {Node[]}
- */
-
- static getNodesFromRecords(records, { linksExtent, backlinksExtent }) {
- return records.map((record) => {
- const { id, title, types, links, backlinks, begin, end, thumbnail, config } = record;
- // const { fill, colorStroke, highlight } = Node.getNodeStyle(config, types[0], thumbnail);
- const { node_size_method, node_size, node_size_min, node_size_max } = config.opts;
- let size;
- switch (node_size_method) {
- case 'unique':
- size = node_size;
- break;
- case 'degree':
- size = Node.getNodeSizeByLinkRank(
- links.length,
- backlinks.length,
- linksExtent,
- backlinksExtent,
- node_size_min,
- node_size_max,
- );
- break;
- }
- return new Node(
- id,
- title,
- types,
- undefined,
- undefined,
- undefined,
- size,
- 2,
- begin,
- end,
- thumbnail,
- );
- });
- }
-
- /**
- * @param {string} id
- * @param {string} label
- * @param {string} type
- * @param {string} fill Color of the center
- * @param {string} colorStroke Color of the border
- * @param {string} colorHighlight Color on highlight
- * @param {number} size
- * @param {number} strokeWidth
- * @param {array} focus
- * @param {number} begin
- * @param {number} end
- * @param {string} thumbnail
- */
-
- constructor(
- id,
- label,
- types = ['undefined'],
- fill,
- colorStroke,
- highlight,
- size,
- strokeWidth,
- begin,
- end,
- thumbnail,
- ) {
- this.id = id;
- this.label = label;
- this.types = types;
- this.fill = fill;
- this.colorStroke = colorStroke;
- this.highlight = highlight;
- this.size = Number(size);
- this.strokeWidth = strokeWidth;
- this.begin = begin;
- this.end = end;
- this.thumbnail = thumbnail;
- }
-}
-
-/**
- * Delete duplicated elements from an array
- * @param {array} array - Array with duplicated elements
- * @return {array} - Array without duplicated elements
- */
-
-function deleteDupicates(array) {
- if (array.length < 2) {
- return array;
- }
-
- return array.filter((item, index) => {
- return array.indexOf(item) === index;
- });
-}
-
-export default Node;
diff --git a/core/models/record.js b/core/models/record.js
index 960266be..18bca04c 100644
--- a/core/models/record.js
+++ b/core/models/record.js
@@ -25,13 +25,12 @@ import fs from 'node:fs';
import path from 'node:path';
import yml from 'yaml';
import Config from './config.js';
-import Node from './node.js';
-import Link from './link.js';
import Bibliography from './bibliography.js';
import Report from './report.js';
import lang from './lang.js';
import { getTimestampTuple, getTimestamp, slugify } from '../utils/misc.js';
import { RecordMaxOutDailyIdError } from './errors.js';
+import * as Citr from '@zettlr/citr';
/**
* @typedef DeepFormatedRecordData
@@ -62,77 +61,23 @@ import { RecordMaxOutDailyIdError } from './errors.js';
* @property {string} thumbnail
*/
-class Record {
- /**
- * Get data from a fromated CSV line
- * @param {object} line
- * @return {DeepFormatedRecordData}
- * ```
- * Record.getFormatedDataFromCsvLine({
- * 'title': 'Paul Otlet',
- * 'type:étude': 'documentation',
- * 'type:relation': 'ami',
- * 'tag:genre': 'homme',
- * 'content:biography': 'Lorem ipsum...',
- * 'content:notes': 'Lorem ipsum...',
- * 'meta:prenom': 'Paul',
- * 'meta:nom': 'Otlet',
- * 'time:begin': '1868',
- * 'time:end': '1944',
- * 'thumbnail': 'photo.jpg',
- * 'references': 'otlet1934'
- *})
- * ```
- */
-
- static getDeepFormatedDataFromCsvLine({ title, id, thumbnail, references = [], ...rest }) {
- let content = {},
- type = {},
- metas = {},
- tags = {};
- for (const [key, value] of Object.entries(rest)) {
- const [field, label] = key.split(':', 2);
- if (field === 'time') {
- continue;
- }
- switch (field) {
- case 'content':
- content[label] = value;
- break;
- case 'type':
- type[label] = value;
- break;
- case 'tag':
- tags[label] = value;
- break;
- case 'meta':
- default:
- if (!!label && !!value) {
- metas[label] = value;
- }
- break;
- }
- }
+/**
+ * @typedef Wikilink
+ * @type {object}
+ * @property {string} type
+ * @property {string} target
+ * @property {string} text
+ * @property {string[]} contexts
+ */
- if (typeof references === 'string') {
- references = references.split(',');
- }
+class Record {
+ static regexParagraph = new RegExp(/[^\r\n]+((\r|\n|\r\n)[^\r\n]+)*/, 'g');
- return {
- id,
- title,
- content,
- type,
- metas,
- tags,
- references,
- time: {
- begin: rest['time:begin'],
- end: rest['time:end'],
- },
- thumbnail: thumbnail,
- };
- }
+ /** @exemple `"[[a:20210424214230|link text]]"` */
+ static regexWikilink = new RegExp(
+ /\[\[((?[^:|\]]+?):)?(?.+?)(\|(?.+?))?\]\]/,
+ 'g',
+ );
/**
* Get data from a fromated CSV line
@@ -245,33 +190,34 @@ class Record {
throw new Error('Need instance of Config to process');
}
- const nodes = data.map(({ id, title, types }) => new Node(id, title, types));
-
return data.map((line) => {
const { id, title, content, types, metas, tags, references, begin, end, thumbnail } = line;
- const { linksReferences, backlinksReferences } = Link.getReferencesFromLinks(
- id,
- links,
- nodes,
- );
- const bibliographicRecords = Bibliography.getBibliographicRecordsFromList(references);
+ const bibliographicLinks = Bibliography.getBibliographicLinksFromList(references);
- return new Record(
+ const record = new Record(
id,
title,
types,
tags,
metas,
content,
- linksReferences,
- backlinksReferences,
begin,
end,
- bibliographicRecords,
+ bibliographicLinks,
thumbnail,
config.opts,
);
+
+ record.wikilinks = links
+ .filter(({ source }) => source === record.id)
+ .map(({ type, target, context }) => ({
+ type,
+ target,
+ contexts: [context],
+ }));
+
+ return record;
});
}
@@ -306,11 +252,9 @@ class Record {
tags,
metas,
content,
- undefined,
- undefined,
begin,
end,
- Bibliography.getBibliographicRecordsFromList(references),
+ Bibliography.getBibliographicLinksFromList(references),
thumbnail,
configOpts,
);
@@ -437,65 +381,18 @@ class Record {
return undefined;
}
- /**
- * @param {Reference[]} referenceArray
- * @returns {boolean}
- */
-
- static verifReferenceArray(referenceArray) {
- if (Array.isArray(referenceArray) === false) {
- return false;
- }
- for (const reference of referenceArray) {
- if (typeof reference !== 'object') {
- return false;
- }
- if (
- typeof reference['context'] !== 'string' ||
- typeof reference['source'] !== 'object' ||
- typeof reference['target'] !== 'object'
- ) {
- return false;
- }
- if (
- Record.verifDirectionArray(reference['source']) === false ||
- Record.verifDirectionArray(reference['target']) === false
- ) {
- return false;
- }
- }
- return true;
- }
-
- /**
- * @param {Direction} direction
- * @returns {boolean}
- */
-
- static verifDirectionArray(direction) {
- if (!direction['id'] || !direction['title'] || !direction['type']) {
- return false;
- }
- if (isNaN(direction['id'])) {
- return false;
- }
- return true;
- }
-
/**
* Generate a record,
* a named dataset, with references to others, validated from a configuration
* @param {string} id - Unique identifier of the record.
* @param {string} title - Title of the record.
* @param {string[]} [type=['undefined']] - Type of the record, registred into the config.
- * @param {string | string[]} tags - List of tags of the record.
+ * @param {string[]} tags - List of tags of the record.
* @param {object} metas - Metas to add to Front Matter.
* @param {string} content - Text content if the record.
- * @param {Reference[]} links - Link, to others records.
- * @param {Reference[]} backlinks - Backlinks, from others records.
* @param {number} begin - Timestamp.
* @param {number} end - Timestamp.
- * @param {BibliographicRecord[]} bibliographicRecords
+ * @param {Wikilink[]} bibliographicLinks
* @param {string} thumbnail - Image path
* @param {object} opts
*/
@@ -507,11 +404,9 @@ class Record {
tags = [],
metas = {},
content = '',
- links = [],
- backlinks = [],
begin,
end,
- bibliographicRecords = [],
+ bibliographicLinks = [],
thumbnail,
opts,
) {
@@ -520,20 +415,21 @@ class Record {
this.types = types;
this.tags = tags;
this.content = content;
- this.bibliographicRecords = bibliographicRecords;
+ this.bibliographicLinks = bibliographicLinks;
/** @type {string[]} */
this.bibliography = [];
this.thumbnail = thumbnail;
- if (tags) {
- if (Array.isArray(tags)) {
- tags = tags.filter((tag) => !!tag);
- this.tags = tags.length === 0 ? [] : tags;
- } else {
- this.tags = tags.split(',').filter((str) => str !== '');
- }
+ /** @type {Wikilink[]} */
+ this.wikilinks = [];
+ this.setWikiLinksFromContent();
+
+ if (!Array.isArray(tags) || !tags.every((tag) => typeof tag === 'string')) {
+ throw new Error('Tags is array of string');
}
+ this.tags = tags;
+
const config = new Config(opts);
const typesRecords = config.getTypesRecords();
const typesLinks = config.getTypesLinks();
@@ -571,8 +467,6 @@ class Record {
this.ymlFrontMatter = this.getYamlFrontMatter();
- this.links = links;
- this.backlinks = backlinks;
this.begin;
if (begin) {
const beginUnix = getTimestamp(begin);
@@ -592,19 +486,6 @@ class Record {
}
}
- this.links = this.links.map((link) => {
- if (typesLinks.has(link.type)) {
- return link;
- }
- new Report(this.id, this.title, 'warning').aboutLinkTypeChange(
- this.title,
- link.target.id,
- link.type,
- );
- link.type = 'undefined';
- return link;
- });
-
this.config = config;
/**
* Invalid fields
@@ -616,7 +497,7 @@ class Record {
}
getYamlFrontMatter() {
- const bibliographicIds = this.bibliographicRecords.map(({ ids }) => Array.from(ids)).flat();
+ const bibliographicIds = this.bibliographicLinks.map(({ target }) => target);
const ymlContent = yml.stringify({
title: this.title,
id: this.id,
@@ -640,17 +521,66 @@ class Record {
throw new Error('Need instance of Bibliography to process');
}
const bibliographyHtml = new Set();
- for (const bibliographicRecord of this.bibliographicRecords) {
- const { record, unknowedIds } = bibliography.get(bibliographicRecord);
- for (const id of unknowedIds) {
- new Report(this.id, this.title, 'error').aboutUnknownBibliographicReference(this.title, id);
+
+ Citr.util.extractCitations(this.content).forEach((quoteText, index) => {
+ let citationItems;
+ try {
+ citationItems = Citr.parseSingle(quoteText);
+ } catch (error) {
+ citationItems = [];
}
- if (record) {
- record.forEach((r) => bibliographyHtml.add(r));
+
+ let ids = new Set(citationItems.map(({ id }) => id));
+
+ bibliography.citeproc.updateItems(Array.from(ids));
+
+ let record = bibliography.citeproc
+ .makeBibliography()[1]
+ .map((t) => Bibliography.getFormatedHtmlBibliographicRecord(t));
+
+ record.forEach((r) => bibliographyHtml.add(r));
+ });
+
+ this.bibliography = Array.from(bibliographyHtml);
+ }
+
+ /**
+ * @returns {Wikilink[]}/*
+ */
+
+ setWikiLinksFromContent() {
+ const links = {};
+
+ let match;
+ while ((match = Record.regexWikilink.exec(this.content))) {
+ const originalText = match[0];
+
+ const { type, text } = match.groups;
+ const targetId = match.groups.id.toLowerCase();
+ links[targetId] = { type, targetId, text, context: new Set(), originalText };
+ }
+
+ const regexParagraph = new RegExp(/[^\r\n]+((\r|\n|\r\n)[^\r\n]+)*/, 'g');
+
+ let paraphs = this.content.match(regexParagraph) || [];
+
+ for (const paraph of paraphs) {
+ let match;
+ while ((match = Record.regexWikilink.exec(paraph))) {
+ // const { id: targetId } = match.groups;
+ const targetId = match.groups.id.toLowerCase();
+ links[targetId].context.add(paraph);
}
}
- this.bibliography = Array.from(bibliographyHtml);
+ this.wikilinks = Object.values(links).map(({ type, targetId, text, context, originalText }) => {
+ return {
+ contexts: Array.from(context),
+ target: targetId,
+ text: originalText,
+ type: type || 'undefined',
+ };
+ });
}
/**
@@ -666,6 +596,7 @@ class Record {
if (this.isValid() === false) {
throw new ErrorRecord(this.writeReport(), 'report');
}
+
if (this.config.canSaveRecords() === false) {
throw new ErrorRecord('Directory for record save is unset', 'no dir');
}
@@ -698,12 +629,6 @@ class Record {
if (!this.title) {
this.report.push('title');
}
-
- // if (this.links !== undefined && Record.verifReferenceArray(this.links) === false) {
- // this.report.push('links'); }
-
- // if (this.backlinks !== undefined && Record.verifReferenceArray(this.backlinks) === false) {
- // this.report.push('backlinks'); }
}
/**
diff --git a/core/models/template.js b/core/models/template.js
index 3afbcba0..e515c033 100644
--- a/core/models/template.js
+++ b/core/models/template.js
@@ -6,7 +6,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import Graph from './graph.js';
+import Record from './record.js';
import Config from './config.js';
import Bibliography from './bibliography.js';
import nunjucks from 'nunjucks';
@@ -20,6 +20,8 @@ import cosmoscopeTemplate from '../../static/template/cosmoscope.njk';
import favicon from '../../static/icons/cosmafavicon.png';
import logo from '../../static/icons/cosmalogo.svg';
import frontendScript from 'front';
+import GraphEngine from 'graphology';
+import { extent } from 'd3';
const md = new mdIt({
html: true,
@@ -34,24 +36,6 @@ const md = new mdIt({
class Template {
static validParams = new Set(['css_custom', 'citeproc', 'dev']);
- /**
- * Match and transform links from context
- * @param {Array} recordLinks Array of link objets
- * @param {Function} fxToHighlight Function return a boolean
- * @returns {String}
- */
-
- static markLinkContext(recordLinks) {
- return recordLinks.map((link) => {
- if (link.context.length > 0) {
- link.context = link.context.join('\n\n');
- } else {
- link.context = '';
- }
- return link;
- });
- }
-
/**
* Convert a path to an image to the base64 encoding of the image source
* @param {string} imgPath
@@ -99,7 +83,8 @@ class Template {
/**
* Get data from graph and make a web app
- * @param {Graph} graph - Graph class
+ * @param {Record[]} records
+ * @param {GraphEngine} graph
* @param {string[]} params
* @exemple
* ```
@@ -108,12 +93,9 @@ class Template {
* ```
*/
- constructor(graph, params = []) {
- if (!graph || graph instanceof Graph === false) {
- throw new Error('Need instance of Config to process');
- }
+ constructor(records, graph, params = [], opts = {}) {
this.params = new Set(params.filter((param) => Template.validParams.has(param)));
- this.config = new Config(graph.config.opts);
+ this.config = Config.get(Config.configFilePath);
if (this.config.isValid() === false) {
throw new Error('Can not template : config invalid');
@@ -139,37 +121,58 @@ class Template {
/** @type {Bibliography} */
let bibliography;
- const filtersFromGraph = {};
- graph.getTypesFromRecords().forEach((nodes, name) => {
- nodes = Array.from(nodes);
- filtersFromGraph[name] = {
- nodes,
- active: true,
- };
- });
+ /** @type {Map} */
+ const recordDict = new Map();
+ /** @type {Map>} */
+ const filtersDict = new Map();
+ /** @type {Map>} */
+ const tagsDict = new Map();
+
+ records.forEach((record) => {
+ recordDict.set(record.id, record);
+
+ record.types.forEach((type) => {
+ if (filtersDict.has(type)) {
+ filtersDict.get(type).add(record.id);
+ } else {
+ filtersDict.set(type, new Set([record.id]));
+ }
+ });
- /** @type {{name: string, nodes: string[]}[]} */
- const tagsFromGraph = [];
- graph.getTagsFromRecords().forEach((nodes, name) => {
- nodes = Array.from(nodes);
- tagsFromGraph.push({ name, nodes });
+ record.tags.forEach((type) => {
+ if (tagsDict.has(type)) {
+ tagsDict.get(type).add(record.id);
+ } else {
+ tagsDict.set(type, new Set([record.id]));
+ }
+ });
+ });
+ /** @type {[string, string[]]} */
+ const filtersDictAsArrays = Array.from(filtersDict, (arr) => {
+ arr[1] = Array.from(arr[1]);
+ return arr;
+ });
+ /** @type {[string, string[]]} */
+ const tagsDictAsArrays = Array.from(tagsDict, (arr) => {
+ arr[1] = Array.from(arr[1]);
+ return arr;
});
- const tagsListAlphabetical = tagsFromGraph
- .map(({ name }) => name)
+ const tagsListAlphabetical = tagsDictAsArrays
+ .map(([name]) => name)
.sort((a, b) => a.localeCompare(b));
- const tagsListIncreasing = tagsFromGraph
- .sort((a, b) => {
- if (a.nodes.length < b.nodes.length) return -1;
- if (a.nodes.length > b.nodes.length) return 1;
+ const tagsListIncreasing = tagsDictAsArrays
+ .sort(([, aNodes], [, bNodes]) => {
+ if (aNodes.length < bNodes.length) return -1;
+ if (aNodes.length > bNodes.length) return 1;
return 0;
})
- .map(({ name }) => name);
+ .map(([name]) => name);
- const recordsListAlphabetical = graph.records
+ const recordsListAlphabetical = records
.sort((a, b) => a.title.localeCompare(b.title))
.map(({ title }) => title);
- const recordsListChronological = graph.records
+ const recordsListChronological = records
.sort((a, b) => {
if (a.begin < b.begin) return -1;
if (a.begin > b.begin) return 1;
@@ -180,13 +183,12 @@ class Template {
if (this.params.has('citeproc') && this.config.canCiteproc()) {
const { bib, cslStyle, xmlLocal } = Bibliography.getBibliographicFilesFromConfig(this.config);
bibliography = new Bibliography(bib, cslStyle, xmlLocal);
- for (const record of graph.records) {
+ for (const record of records) {
record.setBibliography(bibliography);
- record.links.forEach(({ target }) => {
- if (bibliography.library[target.id]) {
- references.push(bibliography.library[target.id]);
- }
- });
+
+ record.bibliographicLinks.forEach(({ target }) =>
+ references.push(bibliography.library[target]),
+ );
}
}
@@ -198,7 +200,7 @@ class Template {
path: path.join(imagesPath, recordTypes[type]['fill']),
};
});
- const thumbnailsFromRecords = graph.records
+ const thumbnailsFromRecords = records
.filter(({ thumbnail }) => typeof thumbnail === 'string')
.map(({ thumbnail }) => {
return {
@@ -217,10 +219,10 @@ class Template {
return slugify(input);
});
templateEngine.addFilter('convertLinks', (input, opts, idToHighlight) => {
- input = convertWikilinks(input, graph.records, opts, idToHighlight);
+ input = convertWikilinks(input, records, opts, idToHighlight);
if (bibliography) {
- input = convertQuotes(input, bibliography, graph.records, idToHighlight);
+ input = convertQuotes(input, bibliography, records, idToHighlight);
}
return input;
@@ -233,13 +235,6 @@ class Template {
});
templateEngine.addFilter('imgPathToBase64', Template.imagePathToBase64);
- graph.records = graph.records.map((record) => {
- record.links = Template.markLinkContext(record.links, linkSymbol);
- record.backlinks = Template.markLinkContext(record.backlinks, linkSymbol);
-
- return record;
- });
-
this.custom_css = null;
if (this.params.has('css_custom') === true && this.config.canCssCustom() === true) {
this.custom_css = fs.readFileSync(cssCustomPath, 'utf-8');
@@ -247,18 +242,105 @@ class Template {
this.html = templateEngine.renderString(cosmoscopeTemplate, {
hideIdFromRecordHeader,
- records: graph.records.map(({ thumbnail, ...rest }) => ({
- ...rest,
- thumbnail: !!thumbnail ? path.join(imagesPath, thumbnail) : undefined,
- })),
+ records: records.map(({ thumbnail, wikilinks, bibliographicLinks, ...rest }) => {
+ const backNodes = graph.inNeighbors(rest.id);
+
+ const links = wikilinks.map(({ contexts, type, ...rest }) => {
+ const target = recordDict.get(rest.target);
+
+ return {
+ context: contexts.join(''),
+ target: {
+ id: target.id,
+ title: target.title,
+ types: target.types,
+ },
+ type,
+ };
+ });
+
+ bibliographicLinks.forEach(({ target, type, contexts }) => {
+ const recordTarget = recordDict.get(target);
+
+ if (!recordTarget) {
+ return;
+ }
+
+ links.push({
+ context: contexts.join(''),
+ target: {
+ id: recordTarget.id,
+ title: recordTarget.title,
+ types: recordTarget.types,
+ },
+ type,
+ });
+ });
+
+ const backlinks = [];
+
+ backNodes.forEach((nodeId) => {
+ const record = recordDict.get(nodeId);
+
+ record.wikilinks
+ .filter((link) => {
+ return link.target === rest.id;
+ })
+ .forEach((link) => {
+ backlinks.push({
+ context: link.contexts.join(''),
+ source: {
+ id: record.id,
+ title: record.title,
+ types: record.types,
+ },
+ type: link.type,
+ });
+ });
+
+ record.bibliographicLinks
+ .filter(({ target }) => {
+ return target === rest.id;
+ })
+ .forEach(({ contexts, type }) => {
+ backlinks.push({
+ context: contexts.join(''),
+ source: {
+ id: record.id,
+ title: record.title,
+ types: record.types,
+ },
+ type,
+ });
+ });
+ });
+
+ return {
+ ...rest,
+ backlinks,
+ links,
+ thumbnail: !!thumbnail ? path.join(imagesPath, thumbnail) : undefined,
+ };
+ }),
graph: {
config: this.config.opts,
- data: graph.data,
+ data: graph.export(),
minValues: Config.minValues,
},
- timeline: graph.getTimelineFromRecords(),
+ timeline: (() => {
+ let dates = [];
+ for (const { begin, end } of records) {
+ dates.push(begin, end);
+ }
+ const [begin, end] = extent(dates);
+ return {
+ begin,
+ // Add margin of one second to display oldest node at end of timeline
+ end: end,
+ };
+ })(),
translation: langPck.i,
lang: lang,
@@ -266,8 +348,8 @@ class Template {
customCss: this.custom_css,
views: views || [],
- filters: filtersFromGraph,
- tags: tagsFromGraph,
+ filters: Object.fromEntries(filtersDictAsArrays),
+ tags: Object.fromEntries(tagsDictAsArrays),
references,
@@ -286,16 +368,20 @@ class Template {
// stats
- nblinks: graph.data.links.length,
+ nblinks: graph.size,
- date: new Date().toLocaleDateString('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' }),
+ date: new Date().toLocaleDateString('en-CA', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ }),
sorting: {
- records: graph.records.map(({ title }) => ({
+ records: records.map(({ title }) => ({
alphabetical: recordsListAlphabetical.indexOf(title),
chronological: recordsListChronological.indexOf(title),
})),
- tags: tagsFromGraph.map(({ name }) => ({
+ tags: tagsDictAsArrays.map(([name]) => ({
alphabetical: tagsListAlphabetical.indexOf(name),
digital: tagsListIncreasing.indexOf(name),
})),
diff --git a/core/utils/getGraph.js b/core/utils/getGraph.js
new file mode 100644
index 00000000..5c9fea54
--- /dev/null
+++ b/core/utils/getGraph.js
@@ -0,0 +1,109 @@
+import GraphEngine from 'graphology';
+import { scaleLinear } from 'd3';
+import Config from '../models/config';
+
+/**
+ * @param {number} degree
+ * @param {number} minDegree
+ * @param {number} maxDegree
+ * @param {Config} config
+ * @returns {number}
+ */
+
+function getNodeSize(degree, minDegree, maxDegree, config) {
+ switch (config.opts['node_size_method']) {
+ case 'unique':
+ return config.opts['node_size'];
+ case 'degree':
+ const compute = scaleLinear()
+ .domain([minDegree, maxDegree])
+ .range([config.opts['node_size_min'], config.opts['node_size_max']]);
+
+ const size = compute(degree);
+ // round at most two decimals
+ return Math.round(size * 100) / 100;
+ }
+}
+
+/**
+ * @param {string} linkType
+ * @param {Config} config
+ * @returns {{ stroke: string, dashInterval: string | null }}
+ */
+
+function getLinkShape(linkType, config) {
+ const linkTypeConfig = config.opts.link_types[linkType];
+ const stroke = linkTypeConfig?.stroke || 'simple';
+
+ switch (stroke) {
+ case 'simple':
+ return { stroke: stroke, dashInterval: null };
+ case 'double':
+ return { stroke: stroke, dashInterval: null };
+ case 'dash':
+ return { stroke: stroke, dashInterval: '4, 5' };
+ case 'dotted':
+ return { stroke: stroke, dashInterval: '1, 3' };
+ }
+ return { stroke: 'simple', dashInterval: null };
+}
+
+/**
+ *
+ * @param {Record[]} records
+ * @param {Config} config
+ * @returns GraphEngine
+ */
+
+export default function getGraph(records, config) {
+ const graph = new GraphEngine({ multi: true }, config.opts);
+
+ /**
+ * @param {number} degree Node degree
+ * @returns {number}
+ */
+
+ records.forEach(({ id, title, types, thumbnail, begin, end }) => {
+ graph.addNode(id, {
+ label: title,
+ types,
+ thumbnail,
+ begin,
+ end,
+ });
+ });
+
+ records.forEach(({ id: nodeId, wikilinks, bibliographicLinks }) => {
+ wikilinks.forEach(({ target, type }) => {
+ graph.addEdge(nodeId, target, {
+ type,
+ shape: getLinkShape(type, config),
+ });
+ });
+
+ bibliographicLinks.forEach(({ target, type }) => {
+ if (!graph.hasNode(nodeId) || !graph.hasNode(target)) {
+ return;
+ }
+
+ return graph.addEdge(nodeId, target, {
+ type,
+ shape: getLinkShape(type, config),
+ });
+ });
+ });
+
+ const degrees = graph.mapNodes((node) => graph.degree(node));
+ const minDegree = Math.min(...degrees);
+ const maxDegree = Math.max(...degrees);
+
+ graph.updateEachNodeAttributes((node, attr) => {
+ const size = getNodeSize(graph.degree(node), minDegree, maxDegree, config);
+ return {
+ ...attr,
+ size,
+ };
+ });
+
+ return graph;
+}
diff --git a/core/utils/getGraph.spec.js b/core/utils/getGraph.spec.js
new file mode 100644
index 00000000..6bd23d1a
--- /dev/null
+++ b/core/utils/getGraph.spec.js
@@ -0,0 +1,129 @@
+import getGraph from './getGraph';
+import Record from '../models/record';
+import Config from '../models/config';
+
+jest.mock('../i18n.yml', () => ({}));
+jest.mock('../../static/template/report.njk', () => '');
+
+const opts = {
+ record_types: {
+ undefined: { fill: '#858585', stroke: '#858585' },
+ people: { fill: '#858585', stroke: '#858585' },
+ },
+ link_types: {
+ undefined: { stroke: 'simple', color: '#e1e1e1' },
+ collab: { stroke: 'double', color: '#e1e1e1' },
+ by: { stroke: 'dotted', color: '#e1e1e1' },
+ },
+};
+const config = new Config(opts);
+
+const records = [
+ new Record(
+ 'paul-otlet',
+ 'Paul Otlet',
+ ['people'],
+ [],
+ {},
+ 'Fondateur du Mundaneum',
+ Number(new Date('1868')),
+ Number(new Date('1944')),
+ [
+ {
+ target: 'traite-documentation',
+ type: 'by',
+ },
+ ],
+ 'paulotlet.png',
+ opts,
+ ),
+ new Record(
+ 'suzanne-briet',
+ 'Suzanne Briet',
+ ['people'],
+ [],
+ {},
+ 'Pionnière des SIC. Elle collabore avec [[collab:paul-otlet]]',
+ Number(new Date('1894')),
+ Number(new Date('1989')),
+ [],
+ 'suzannebriet.png',
+ opts,
+ ),
+ new Record(
+ 'traite-documentation',
+ 'Traité de documentation',
+ ['reference'],
+ [],
+ {},
+ 'Otlet, P. (1934). Traité de documentation: Le livre sur le livre, théorie et pratique. Bruxelles: Editiones Mundaneum.',
+ undefined,
+ undefined,
+ [],
+ undefined,
+ opts,
+ ),
+];
+
+const graph = getGraph(records, config);
+const graphData = graph.export();
+
+describe('getGraph', () => {
+ it('should get nodes, with records attrs', () => {
+ expect(graphData.nodes).toEqual([
+ {
+ key: 'paul-otlet',
+ attributes: {
+ label: 'Paul Otlet',
+ types: ['people'],
+ thumbnail: 'paulotlet.png',
+ begin: -3218832000,
+ end: -820540800,
+ size: 20,
+ },
+ },
+ {
+ key: 'suzanne-briet',
+ attributes: {
+ label: 'Suzanne Briet',
+ types: ['people'],
+ thumbnail: 'suzannebriet.png',
+ begin: -2398291200,
+ end: 599616000,
+ size: 2,
+ },
+ },
+ {
+ key: 'traite-documentation',
+ attributes: {
+ label: 'Traité de documentation',
+ types: ['undefined'],
+ size: 2,
+ },
+ },
+ ]);
+ });
+
+ it('should get edges, with shape and type from opts', () => {
+ expect(graphData.edges).toEqual([
+ {
+ key: expect.any(String),
+ source: 'paul-otlet',
+ target: 'traite-documentation',
+ attributes: {
+ type: 'by',
+ shape: { stroke: 'dotted', dashInterval: '1, 3' },
+ },
+ },
+ {
+ key: expect.any(String),
+ source: 'suzanne-briet',
+ target: 'paul-otlet',
+ attributes: {
+ type: 'collab',
+ shape: { stroke: 'double', dashInterval: null },
+ },
+ },
+ ]);
+ });
+});
diff --git a/e2e/e2e-support.js b/e2e/e2e-support.js
index 89434d6e..c9e32b69 100644
--- a/e2e/e2e-support.js
+++ b/e2e/e2e-support.js
@@ -7,6 +7,16 @@ Cypress.Commands.add('shouldGraphHasNodes', (labels) =>
}),
);
+Cypress.Commands.add('shouldIndexHasItems', (labels) =>
+ cy
+ .get('[data-index]:visible')
+ .should('have.length', labels.length)
+ .find('span:nth-child(2)')
+ .each((elt, i) => {
+ expect(elt.text()).to.equal(labels[i]);
+ }),
+);
+
Cypress.Commands.add('openARecord', () => {
cy.get('[data-node]').first().click();
const record = cy.get('.record-container').filter(':visible').first();
diff --git a/e2e/focus.cy.js b/e2e/focus.cy.js
index 2f7f23ab..06c02e1c 100644
--- a/e2e/focus.cy.js
+++ b/e2e/focus.cy.js
@@ -51,6 +51,30 @@ describe('focus', () => {
]);
});
+ it('should toggle index', () => {
+ cy.contains('Index').click();
+
+ cy.shouldIndexHasItems([
+ 'Evergreen notes should be concept-oriented',
+ 'Evergreen notes should be densely linked',
+ ]);
+
+ cy.get('.graph-control-label').click();
+
+ cy.get('#menu-container').scrollTo('bottom');
+
+ cy.shouldIndexHasItems([
+ 'Augmenting Human Intellect: A Conceptual Framework',
+ 'Evergreen note titles are like APIs',
+ 'Evergreen notes',
+ 'Evergreen notes should be atomic',
+ 'Evergreen notes should be concept-oriented',
+ 'Evergreen notes should be densely linked',
+ 'How can we develop transformative tools for thought?',
+ 'Tools for thought',
+ ]);
+ });
+
it('should display more nodes if range inscrease', () => {
cy.get('#focus-input')
.should('have.attr', 'max', 2)
diff --git a/e2e/graph.cy.js b/e2e/graph.cy.js
index 0a09ade1..21ac965d 100644
--- a/e2e/graph.cy.js
+++ b/e2e/graph.cy.js
@@ -70,6 +70,9 @@ describe('graph', () => {
cy.contains('Afficher les liens').click();
cy.get('line:visible').should('have.length', 0);
+
+ cy.contains('Afficher les liens').click();
+ cy.get('line:visible').should('have.length', 7);
});
it('should hide all labels', () => {
diff --git a/e2e/leftPanel.cy.js b/e2e/leftPanel.cy.js
index 6c9c03ce..055cd842 100644
--- a/e2e/leftPanel.cy.js
+++ b/e2e/leftPanel.cy.js
@@ -13,22 +13,13 @@ describe('left panel', () => {
});
describe('records index', () => {
- /** @type {string[]} */
- function shouldIndexHasRecords(labels) {
- cy.get('[data-index]:visible span:nth-child(2)')
- .should('have.length', labels.length)
- .each((elt, i) => {
- expect(elt.text()).to.equal(labels[i]);
- });
- }
-
beforeEach(() => {
cy.contains('Index').click();
cy.get('#menu-container').scrollTo('bottom');
});
it('should display each record with alphabetical order', () => {
- shouldIndexHasRecords([
+ cy.shouldIndexHasItems([
'Augmenting Human Intellect: A Conceptual Framework',
'Evergreen note titles are like APIs',
'Evergreen notes',
@@ -43,12 +34,25 @@ describe('left panel', () => {
it('should display each unfliterd record', () => {
cy.get('#types-form').contains('insight').click();
- shouldIndexHasRecords([
+ cy.shouldIndexHasItems([
'Augmenting Human Intellect: A Conceptual Framework',
'Evergreen notes',
'How can we develop transformative tools for thought?',
'Tools for thought',
]);
+
+ cy.get('#types-form').contains('insight').click();
+
+ cy.shouldIndexHasItems([
+ 'Augmenting Human Intellect: A Conceptual Framework',
+ 'Evergreen note titles are like APIs',
+ 'Evergreen notes',
+ 'Evergreen notes should be atomic',
+ 'Evergreen notes should be concept-oriented',
+ 'Evergreen notes should be densely linked',
+ 'How can we develop transformative tools for thought?',
+ 'Tools for thought',
+ ]);
});
});
});
diff --git a/e2e/tags.cy.js b/e2e/tags.cy.js
index 84c932c7..c191bc5f 100644
--- a/e2e/tags.cy.js
+++ b/e2e/tags.cy.js
@@ -35,6 +35,36 @@ describe('tags', () => {
cy.shouldGraphHasNodes(['Tools for thought']);
});
+ it('should display index items has selected tag from list', () => {
+ assertTagsAreChecked([]);
+
+ cy.contains('Index').click();
+ cy.contains('Mots-clés').click();
+
+ cy.contains('wip').click();
+ assertTagsAreChecked(['wip']);
+ cy.shouldIndexHasItems(['Evergreen notes should be concept-oriented', 'Tools for thought']);
+
+ cy.contains('wip').click();
+ cy.get('#menu-container').scrollTo('bottom');
+ assertTagsAreChecked([]);
+ cy.shouldIndexHasItems([
+ 'Augmenting Human Intellect: A Conceptual Framework',
+ 'Evergreen note titles are like APIs',
+ 'Evergreen notes',
+ 'Evergreen notes should be atomic',
+ 'Evergreen notes should be concept-oriented',
+ 'Evergreen notes should be densely linked',
+ 'How can we develop transformative tools for thought?',
+ 'Tools for thought',
+ ]);
+
+ cy.contains('quotes').click();
+ assertTagsAreChecked(['quotes']);
+ cy.shouldGraphHasNodes(['Tools for thought']);
+ cy.shouldIndexHasItems(['Tools for thought']);
+ });
+
it('should display tags from URL params', () => {
cy.visit('temp/citeproc.html?tags=quotes');
assertTagsAreChecked(['quotes']);
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 00000000..cf261394
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,8 @@
+/** @type {import('jest').Config} */
+const config = {
+ moduleNameMapper: {
+ '^d3$': '/node_modules/d3/dist/d3.js',
+ },
+};
+
+module.exports = config;
diff --git a/static/template/cosmoscope.njk b/static/template/cosmoscope.njk
index d91ff11e..f6bef84d 100644
--- a/static/template/cosmoscope.njk
+++ b/static/template/cosmoscope.njk
@@ -63,11 +63,11 @@
@@ -88,11 +88,11 @@
@@ -428,7 +428,7 @@
{{ record.backlinks | length }}
-
+
{% for backlink in record.backlinks %}
-