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 }} -