diff --git a/packages/core/__tests__/database.test.js b/packages/core/__tests__/database.test.js index 027f52e4e..d23e33c48 100644 --- a/packages/core/__tests__/database.test.js +++ b/packages/core/__tests__/database.test.js @@ -141,6 +141,57 @@ test("update note", () => expect(note.colors).toStrictEqual(["red", "blue"]); })); +test("updating empty note should delete it", () => + noteTest().then(async ({ db, timestamp }) => { + let updateTimestamp = await db.addNote({ + dateCreated: timestamp, + title: "\n\n", + content: { + text: "", + delta: [] + }, + pinned: true, + favorite: true, + colors: ["red", "blue"] + }); + expect(updateTimestamp).toBeUndefined(); + })); + +test("updating note with duplicate colors", () => + noteTest({ + ...TEST_NOTE, + colors: ["red", "blue"] + }).then(async ({ db, timestamp }) => { + let updateTimestamp = await db.addNote({ + dateCreated: timestamp, + colors: ["red", "red", "blue", "blue"] + }); + expect(updateTimestamp).toBe(timestamp); + let note = db.getNote(updateTimestamp); + expect(note.colors).toStrictEqual(["red", "blue"]); + })); + +test("updating note with new tags", () => + noteTest({ + ...TEST_NOTE, + tags: ["hello", "world"] + }).then(async ({ db, timestamp }) => { + let updateTimestamp = await db.addNote({ + dateCreated: timestamp, + tags: ["new", "tag", "goes", "here"] + }); + expect(updateTimestamp).toBe(timestamp); + let note = db.getNote(updateTimestamp); + expect(note.tags).toStrictEqual([ + "hello", + "world", + "new", + "tag", + "goes", + "here" + ]); + })); + test("note with text longer than 150 characters should have ... in the headline", () => noteTest({ content: { diff --git a/packages/core/api/database.js b/packages/core/api/database.js index a9e54bad6..43c5b49b4 100644 --- a/packages/core/api/database.js +++ b/packages/core/api/database.js @@ -10,6 +10,7 @@ import { months, getLastWeekTimestamp } from "../utils/date"; +import setManipulator from "../utils/set"; const KEYS = { notes: "notes", @@ -19,6 +20,10 @@ const KEYS = { user: "user" }; +const TYPES = { + note: "note" +}; + function checkInitialized() { if (!this.isInitialized) { throw new Error( @@ -120,80 +125,56 @@ class Database { } } - async addNote(_note) { - if (!_note || !_note.content) return; - let timestamp = _note.dateCreated || Date.now(); - let note = { ...this.notes[timestamp], ..._note }; + async addNote(n) { + if (!n) return; - if ( - !this.notes[timestamp] && - (note.content.text.length <= 0 || !note.content.delta) && - (!note.title || note.title.length <= 0) - ) { + let timestamp = n.dateCreated || Date.now(); + let oldNote = this.notes[timestamp]; + let note = { + ...oldNote, + ...n + }; + + if (isNoteEmpty(note)) { + if (oldNote) await this.deleteNotes(note); return; } - if ( - (!note.title || note.title.length <= 0) && - !note.locked && - note.content.text.length <= 0 && - this.notes[timestamp] - ) { - //delete the note - await this.deleteNotes(note); - return; - } - - //add or update a note into the database - let title = - note.title || - note.content.text - .split(" ") - .slice(0, 3) - .join(" "); - - //if note exists - if (this.notes[timestamp] !== undefined) { - let oldNote = this.notes[timestamp]; - //if we are having new colors - if (oldNote.colors !== note.colors && note.colors) { - note.colors = mergeDedupe([oldNote.colors, note.colors]); - } - //if we are having new tags - //TODO add new tags to the tags collection... - if (oldNote.tags !== note.tags && note.tags) { - note.tags = mergeDedupe([oldNote.tags, note.tags]); - } - } - - this.notes[timestamp] = { - type: "note", - title, - content: note.content, - pinned: note.pinned || false, + note = { + type: TYPES.note, + title: getNoteTitle(note), + content: getNoteContent(note), + pinned: !!note.pinned, tags: note.tags || [], - locked: note.locked || false, + locked: !!note.locked, notebook: note.notebook || {}, colors: note.colors || [], - favorite: note.favorite || false, - headline: - !note.locked && - note.content.text.substring(0, 150) + - (note.content.text.length > 150 ? "..." : ""), - length: note.content.text.length, + favorite: !!note.favorite, + headline: getNoteHeadline(note), dateEditted: Date.now(), dateCreated: timestamp }; + + if (oldNote) { + note.colors = setManipulator.union(oldNote.colors, note.colors); + await this.updateTags(setManipulator.complement(note.tags, oldNote.tags)); + note.tags = setManipulator.union(oldNote.tags, note.tags); + } else { + await this.updateTags(note.tags); + } + + this.notes[timestamp] = note; await this.storage.write(KEYS.notes, this.notes); return timestamp; } - //TODO only send unique values here... async updateTags(tags) { for (let tag of tags) { - this[KEYS.tags][tag] = { + if (!tag || tag.trim().length <= 0) continue; + let oldCount = this.tags[tag] ? this.tags[tag].count : 0; + this.tags[tag] = { title: tag, - count: this[KEYS.tags][tag].count + 1 + count: oldCount + 1 }; } await this.storage.write(KEYS.tags, this[KEYS.tags]); @@ -442,15 +423,15 @@ export default Database; async function deleteItems(items, key) { if (!items || items.length <= 0 || !this[key] || this[key].length <= 0) { - console.log(items, items.length); return false; } for (let item of items) { if (!item) continue; if (this[key].hasOwnProperty(item.dateCreated)) { //delete note from the notebook too. - if (item.type === "note" && item.notebook.hasOwnProperty("topic")) { + if (item.type === "note") { if ( + item.notebook.hasOwnProperty("topic") && !(await this.deleteNoteFromTopic( item.notebook.notebook, item.notebook.topic, @@ -459,6 +440,15 @@ async function deleteItems(items, key) { ) { continue; } + //TODO test + if (item.tags.length > 0) { + for (let tag of item.tags) { + this.tags[tag] = { + ...this.tags[tag], + count: this.tags[tag].count - 1 + }; + } + } } else if (item.type === "notebook") { let skip = false; for (let topic in item.topics) { @@ -471,9 +461,7 @@ async function deleteItems(items, key) { } } } - if (skip) { - continue; - } + if (skip) continue; } //put into trash this[KEYS.trash][item.dateCreated] = this[key][item.dateCreated]; @@ -567,3 +555,40 @@ async function editItem(type, id, prop) { function mergeDedupe(arr) { return [...new Set([].concat(...arr))]; } + +function isNoteEmpty(note) { + return ( + !note.content || + !note.content.delta || + (!note.locked && + (!note.title || note.title.trim().length <= 0) && + (!note.content.text || note.content.text.trim().length <= 0)) + ); +} + +function getNoteHeadline(note) { + if (note.locked) return ""; + return ( + note.content.text.substring(0, 150) + + (note.content.text.length > 150 ? "..." : "") + ); +} + +function getNoteTitle(note) { + if (note.title && note.title.length > 0) return note.title.trim(); + return note.content.text + .split(" ") + .slice(0, 3) + .join(" ") + .trim(); +} + +function getNoteContent(note) { + if (note.locked) { + return note.content; + } + return { + text: note.content.text.trim(), + delta: note.content.delta + }; +} diff --git a/packages/core/utils/set.js b/packages/core/utils/set.js new file mode 100644 index 000000000..343303bd3 --- /dev/null +++ b/packages/core/utils/set.js @@ -0,0 +1,111 @@ +// SetManipulator MIT Licence © 2016 Edwin Monk-Fromont http://github.com/edmofro +// Based on setOps.js MIT License © 2014 James Abney http://github.com/jabney + +// Set operations union, intersection, symmetric difference, +// relative complement, equals. Set operations are fast. +class SetManipulator { + constructor(identityExtractor) { + // Create and push the uid identity method. + identityExtractor = identityExtractor || (identity => identity); + this.uidList = [identityExtractor]; + this.uid = identityExtractor; + } + + // Push a new uid method onto the stack. Call this and + // supply a unique key generator for sets of objects. + pushIdentityExtractor(method) { + this.uidList.push(method); + this.uid = method; + return method; + } + + // Pop the previously pushed uid method off the stack and + // assign top of stack to uid. Return the previous method. + popIdentityExtractor() { + let prev; + if (this.uidList.length > 1) prev = this.uidList.pop(); + this.uid = this.uidList[this.uidList.length - 1]; + return prev || null; + } + + // Processes a histogram consructed from two arrays, 'a' and 'b'. + // This function is used generically by the below set operation + // methods, a.k.a, 'evaluators', to return some subset of + // a set union, based on frequencies in the histogram. + process(a, b, evaluator, identityExtractor) { + // If identity extractor passed in, push it on the stack + if (identityExtractor) this.pushIdentityExtractor(identityExtractor); + // Create a histogram of 'a'. + const hist = {}; + const out = []; + let ukey; + a.forEach(value => { + ukey = this.uid(value); + if (!hist[ukey]) { + hist[ukey] = { value: value, freq: 1 }; + } + }); + // Merge 'b' into the histogram. + b.forEach(value => { + ukey = this.uid(value); + if (hist[ukey]) { + if (hist[ukey].freq === 1) hist[ukey].freq = 3; + } else hist[ukey] = { value: value, freq: 2 }; + }); + // Pop any new identity extractor + if (identityExtractor) this.popIdentityExtractor(identityExtractor); + // Call the given evaluator. + if (evaluator) { + for (const key in hist) { + if (!hist.hasOwnProperty(key)) continue; // Property from object prototype, skip + if (evaluator(hist[key].freq)) out.push(hist[key].value); + } + return out; + } + return hist; + } + + // Join two sets together. + // Set.union([1, 2, 2], [2, 3]) => [1, 2, 3] + union(a, b, identityExtractor) { + return this.process(a, b, () => true, identityExtractor); + } + + // Return items common to both sets. + // Set.intersection([1, 1, 2], [2, 2, 3]) => [2] + intersection(a, b, identityExtractor) { + return this.process(a, b, freq => freq === 3, identityExtractor); + } + + // Symmetric difference. Items from either set that + // are not in both sets. + // Set.difference([1, 1, 2], [2, 3, 3]) => [1, 3] + difference(a, b, identityExtractor) { + return this.process(a, b, freq => freq < 3, identityExtractor); + } + + // Relative complement. Items from 'a' which are + // not also in 'b'. + // Set.complement([1, 2, 2], [2, 2, 3]) => [3] + complement(a, b, identityExtractor) { + return this.process(a, b, freq => freq === 1, identityExtractor); + } + + // Returns true if both sets are equivalent, false otherwise. + // Set.equals([1, 1, 2], [1, 2, 2]) => true + // Set.equals([1, 1, 2], [1, 2, 3]) => false + equals(a, b, identityExtractor) { + let max = 0; + let min = Math.pow(2, 53); + const hist = this.process(a, b, identityExtractor); + for (const key in hist) { + if (!hist.hasOwnProperty(key)) continue; // Property from object prototype, skip + max = Math.max(max, hist[key].freq); + min = Math.min(min, hist[key].freq); + } + return min === 3 && max === 3; + } +} + +const setManipulator = new SetManipulator(); +export default setManipulator;