From 21dbcd0543c82042b26574b7b6fbcac82e3d9081 Mon Sep 17 00:00:00 2001 From: Guillaume Brioudes Date: Mon, 2 Dec 2024 18:08:26 +0100 Subject: [PATCH] fix: empty records --- core/models/record.js | 202 +++++++++++++++--------------- core/models/record.spec.js | 59 +++++---- core/utils/parseWikilinks.js | 4 +- core/utils/parseWikilinks.spec.js | 8 ++ 4 files changed, 142 insertions(+), 131 deletions(-) diff --git a/core/models/record.js b/core/models/record.js index 58f4f04..88a737a 100644 --- a/core/models/record.js +++ b/core/models/record.js @@ -23,6 +23,22 @@ const recordLinkSchema = Joi.object({ contexts: Joi.array().items(Joi.string()).required(), }); +function validateTimestamp(value, helpers) { + const err = helpers.error('timestamp.invalid', { + message: `"${value}" can not convert to a valid timestamp.`, + }); + + if (!Number.isInteger(value) || value < 0) { + return err; + } + const date = new Date(value); + if (isNaN(date.getTime())) { + return err; + } + + return value; +} + const schema = Joi.object({ id: Joi.string().required(), title: Joi.string().required(), @@ -30,9 +46,10 @@ const schema = Joi.object({ links: Joi.array().items(recordLinkSchema).optional(), types: Joi.array().items(Joi.string()).optional(), tags: Joi.array().items(Joi.string()).optional(), - begin: Joi.number().optional(), - end: Joi.number().optional(), + begin: Joi.number().custom(validateTimestamp, 'timestamp validation').optional(), + end: Joi.number().custom(validateTimestamp, 'timestamp validation').optional(), thumbnail: Joi.string().optional(), + metas: Joi.object().pattern(Joi.string(), Joi.any()).optional(), }); const schemaKeys = Object.keys(schema.describe().keys); @@ -52,22 +69,12 @@ export default class Record { static recordFromFile(file, config) { const { content, head } = readYmlFm(file, { schema: 'failsafe' }); - const normalizedHead = normalizeWithAliases(aliasTable, head); - const metas = {}; - - for (const key of Object.keys(normalizedHead)) { - if (!schemaKeys.includes(key)) { - metas[key] = normalizedHead[key]; - delete normalizedHead[key]; - } - } - const links = parseWikilinks(content, config); const props = { + ...normalizeInput(head, config), + links, content, - id: head.id || head.title.toLowerCase(), - ...normalizedHead, }; if (typeof props.types === 'string') { @@ -85,7 +92,7 @@ export default class Record { const { error } = schema.validate(props); if (error) { - throw new Error(`Record schema validation failed: ${error.message}`); + throw new Error(`Record contains error: ${error.message}`); } return new Record( @@ -98,7 +105,7 @@ export default class Record { types: props.types, begin: props.begin, end: props.end, - metas, + metas: props.metas, thumbnail: props.thumbnail, }, config, @@ -112,36 +119,11 @@ export default class Record { */ static recordFromCsv(line, config) { - const normalizedHead = normalizeWithAliases(aliasTable, line); - const metas = {}; - - for (const key of Object.keys(normalizedHead)) { - if (!schemaKeys.includes(key)) { - metas[key] = normalizedHead[key]; - delete normalizedHead[key]; - } - } - - const props = { - ...normalizedHead, - }; - - if (typeof props.types === 'string') { - props.types = [props.types]; - } - if (typeof props.tags === 'string') { - props.tags = [props.tags]; - } - if (typeof props.begin === 'string') { - props.begin = new Date(props.begin).getTime(); - } - if (typeof props.end === 'string') { - props.end = new Date(props.end).getTime(); - } + const props = normalizeInput(line, config); const { error } = schema.validate(props); if (error) { - throw new Error(`Record schema validation failed: ${error.message}`); + throw new Error(`Record contains error: ${error.message}`); } return new Record( @@ -172,14 +154,18 @@ export default class Record { static recordFromCiteItem(citeItem, config, bibliography) { const libraryItem = bibliography.library[citeItem.id]; - return new Record( - { - id: citeItem.id, - title: libraryItem['title'], - content: bibliography.getNotes([citeItem])[0], - }, - config, - ); + const props = { + id: citeItem.id, + title: libraryItem['title'], + content: bibliography.getNotes([citeItem])[0], + }; + + const { error } = schema.validate(props); + if (error) { + throw new Error(`Record contains error: ${error.message}`); + } + + return new Record(props, config); } /** @@ -193,25 +179,13 @@ export default class Record { static recordWithTimestamp(props, config) { props = { - ...props, + ...normalizeInput(props, config), id: getTimestampTuple().join(''), }; - const knownTypes = config.getTypesRecords(); - props.types = props.types.reduce((acc, curr, i, arr) => { - if (!knownTypes.has(curr)) { - if (!acc.includes('undefined')) { - acc.push('undefined'); - } - } else { - acc.push(curr); - } - return acc; - }, []); - const { error } = schema.validate(props); if (error) { - throw new Error(`Record schema validation failed: ${error.message}`); + throw new Error(`Record contains error: ${error.message}`); } return new Record(props, config); @@ -224,49 +198,14 @@ export default class Record { */ static recordWithIncrementedTimestamp(props, config, increment) { - const normalizedProps = normalizeWithAliases(aliasTable, props); - const metas = {}; - - for (const key of Object.keys(normalizedProps)) { - if (!schemaKeys.includes(key)) { - metas[key] = normalizedProps[key]; - delete normalizedProps[key]; - } - } - props = { - ...normalizedProps, + ...normalizeInput(line, config), id: timestampIncrement(increment), }; - if (typeof props.types === 'string') { - props.types = [props.types]; - } - if (typeof props.tags === 'string') { - props.tags = [props.tags]; - } - if (typeof props.begin === 'string') { - props.begin = new Date(props.begin).getTime(); - } - if (typeof props.end === 'string') { - props.end = new Date(props.end).getTime(); - } - - const knownTypes = config.getTypesRecords(); - props.types = props.types.reduce((acc, curr, i, arr) => { - if (!knownTypes.has(curr)) { - if (!acc.includes('undefined')) { - acc.push('undefined'); - } - } else { - acc.push(curr); - } - return acc; - }, []); - const { error } = schema.validate(props); if (error) { - throw new Error(`Record schema validation failed: ${error.message}`); + throw new Error(`Record contains error: ${error.message}`); } return new Record(props, config); @@ -281,7 +220,7 @@ export default class Record { * links?: RecordLink[], * types?: string[], * tags?: string[], - * metas?: unknown, + * metas?: Object, * begin?: number, * end?: number, * thumbnail?: string, @@ -347,9 +286,64 @@ export default class Record { addLink(link) { const { error } = recordLinkSchema.validate(link); if (error) { - throw new Error(`Record schema validation failed: ${error.message}`); + throw new Error(`Record contains error: ${error.message}`); } this.links.push(link); } } + +/** + * @param {unknown} head + * @param {import('../models/config').default} config + */ + +function normalizeInput(head, config) { + const normalizedHead = normalizeWithAliases(aliasTable, head); + + const metas = {}; + + for (const key of Object.keys(normalizedHead)) { + if (config.opts.record_metas.includes(key)) { + metas[key] = normalizedHead[key]; + } + + if (!schemaKeys.includes(key)) { + delete normalizedHead[key]; + } + } + + const props = { + metas, + ...normalizedHead, + }; + + if (typeof props.types === 'string') { + props.types = [props.types]; + } + if (typeof props.tags === 'string') { + props.tags = [props.tags]; + } + if (typeof props.begin === 'string') { + props.begin = new Date(props.begin).getTime() / 1000; + } + if (typeof props.end === 'string') { + props.end = new Date(props.end).getTime() / 1000; + } + + if (props.types) { + const knownTypes = config.getTypesRecords(); + props.types = props.types.reduce((acc, curr, i, arr) => { + if (!knownTypes.has(curr)) { + if (!acc.includes('undefined')) { + acc.push('undefined'); + } + } else { + acc.push(curr); + } + return acc; + }, []); + } + + return props; +} diff --git a/core/models/record.spec.js b/core/models/record.spec.js index 75c6b69..0493100 100644 --- a/core/models/record.spec.js +++ b/core/models/record.spec.js @@ -16,12 +16,12 @@ const props = { thumbnail: 'img.jpg', }; -const config = new Config({ - files_origin: './dir', +const config = Config.getFrom({ record_types: { undefined: { fill: '#858585', stroke: '#858585' }, people: { fill: '#858585', stroke: '#858585' }, }, + record_metas: ['author'], }); describe('Record model', () => { @@ -82,7 +82,25 @@ author: John Doe This is a test content`); }); - it('should generate the correct YAML front matter', () => { + it('should generate YAML front matter with id', () => { + const record = new Record({ ...props, tags: undefined }, config); + + const file = record.getFileContent(true); + + expect(file).toEqual(`--- +id: "20200501150208" +title: Test Record +types: + - type1 + - type2 +thumbnail: img.jpg +author: John Doe +--- + +This is a test content`); + }); + + it('should generate YAML front matter without id', () => { const record = new Record({ ...props, tags: undefined }, config); const file = record.getFileContent(false); @@ -99,10 +117,21 @@ author: John Doe This is a test content`); }); + it('should get from empty file', () => { + const file = ''; + + expect(() => { + Record.recordFromFile(file, config); + }).toThrowError(/Record contains error/); + }); + it('should get from file', () => { const file = `--- id: "20200501150208" title: Test Record +type: + - people + - unknown keyword: - test author: Paul Otlet @@ -123,7 +152,7 @@ File linked to [[20210901132906]]`; contexts: ['File linked to [[20210901132906]]'], }, ], - types: ['undefined'], + types: ['people', 'undefined'], tags: ['test'], metas: { author: 'Paul Otlet', @@ -135,28 +164,6 @@ File linked to [[20210901132906]]`; }); }); - it('should get with timestamp as id', () => { - const props = { - title: 'Paul Otlet', - types: ['people'], - }; - - const result = Record.recordWithTimestamp(props, config); - expect(result).toEqual({ - id: expect.any(String), - title: props.title, - types: ['people'], - content: '', - links: [], - metas: {}, - tags: [], - begin: undefined, - end: undefined, - thumbnail: undefined, - config: config, - }); - }); - it('should get from citeproc item', () => { /** @type {import('../utils/citeExtractor.js'.CiteItem)} */ const citeItem = { diff --git a/core/utils/parseWikilinks.js b/core/utils/parseWikilinks.js index c80b6e5..fb33278 100644 --- a/core/utils/parseWikilinks.js +++ b/core/utils/parseWikilinks.js @@ -18,12 +18,14 @@ const wikilinkRE = new RegExp(/\[\[((?[^:|\]]+?):)?(?.+?)(\|(?.+ */ export default function parseWikilinks(markdown, config) { + if (!markdown) return []; + /** * @type {Map} */ const linksDict = new Map(); - for (const match of markdown.matchAll(wikilinkRE)) { + for (const match of markdown.matchAll(wikilinkRE) || []) { const [full, _, type, id, __, placeholder] = match; const target = id.toLowerCase(); diff --git a/core/utils/parseWikilinks.spec.js b/core/utils/parseWikilinks.spec.js index b20721d..4051758 100644 --- a/core/utils/parseWikilinks.spec.js +++ b/core/utils/parseWikilinks.spec.js @@ -14,6 +14,14 @@ const configWithLinkSymbol = new Config({ }); describe('parseWikilinks', () => { + it('should return empty array if empty input', () => { + const paraph = ''; + + const result = parseWikilinks(paraph, config); + + expect(result).toEqual([]); + }); + it('should parse link id', () => { const paraph = 'Lorem ipsum [[20210901132906]] dolor sit amet.';