diff --git a/README.md b/README.md index 4ba670616..b4a621dcc 100644 --- a/README.md +++ b/README.md @@ -138,10 +138,10 @@ From the root of the repository, run: ``` # Starts browser-navigable web page -yarn run start:test-server:dev +yarn run start:e2e:mocha # Starts backend servers -yarn run test:e2e:dev-setup +yarn run start:e2e:docker ``` Then choose between the following run options: diff --git a/package.json b/package.json index c4618ee33..1ff18c725 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "bootstrap": "lerna bootstrap", "start": "lerna run start --parallel", "test:unit": "lerna run test:unit --parallel", + "start:e2e:mocha": "yarn run start:test-server:dev", + "start:e2e:docker": "yarn run test:e2e:dev-setup", "start:test-server": "node e2e-server.js", "start:test-server:dev": "node e2e-server.js --dev", "test:e2e": "bash test.sh stable", diff --git a/packages/snjs/lib/application.ts b/packages/snjs/lib/application.ts index 2aff2dadf..ce7c7a3cb 100644 --- a/packages/snjs/lib/application.ts +++ b/packages/snjs/lib/application.ts @@ -1,3 +1,4 @@ +import { TagNoteCountChangeObserver } from './protocol/collection/tag_notes_index'; import { TransactionalMutation } from './services/item_manager'; import { FeatureStatus } from '@Lib/services/features_service'; import { Settings } from './services/settings_service'; @@ -857,6 +858,20 @@ export class SNApplication { return this.itemManager.notesMatchingSmartTag(smartTag); } + public addNoteCountChangeObserver( + observer: TagNoteCountChangeObserver + ): () => void { + return this.itemManager.addNoteCountChangeObserver(observer); + } + + public allCountableNotesCount(): number { + return this.itemManager.allCountableNotesCount(); + } + + public countableNotesForTag(tag: SNTag | SNSmartTag): number { + return this.itemManager.countableNotesForTag(tag); + } + /** Returns an item's direct references */ public referencesForItem(item: SNItem, contentType?: ContentType): SNItem[] { let references = this.itemManager.referencesForItem(item.uuid); diff --git a/packages/snjs/lib/models/app/note.ts b/packages/snjs/lib/models/app/note.ts index 6fb26702d..5d636984d 100644 --- a/packages/snjs/lib/models/app/note.ts +++ b/packages/snjs/lib/models/app/note.ts @@ -1,7 +1,8 @@ -import { PayloadContent } from '@Payloads/generator'; -import { PayloadFormat } from './../../protocol/payloads/formats'; +import { ContentType } from '@Lib/index'; import { isNullOrUndefined } from '@Lib/utils'; import { AppDataField, ItemMutator, SNItem } from '@Models/core/item'; +import { PayloadContent } from '@Payloads/generator'; +import { PayloadFormat } from './../../protocol/payloads/formats'; import { PurePayload } from './../../protocol/payloads/pure_payload'; export interface NoteContent extends PayloadContent { @@ -14,6 +15,9 @@ export interface NoteContent extends PayloadContent { spellcheck?: boolean; } +export const isNote = (x: SNItem): x is SNNote => + x.content_type === ContentType.Note; + /** A note item */ export class SNNote extends SNItem implements NoteContent { public readonly title!: string; diff --git a/packages/snjs/lib/models/app/tag.ts b/packages/snjs/lib/models/app/tag.ts index 68cd0f82d..8346d3e02 100644 --- a/packages/snjs/lib/models/app/tag.ts +++ b/packages/snjs/lib/models/app/tag.ts @@ -9,6 +9,9 @@ export interface TagContent extends ItemContent { title: string; } +export const isTag = (x: SNItem): x is SNTag => + x.content_type === ContentType.Tag; + /** * Allows organization of notes into groups. * A tag can have many notes, and a note can have many tags. @@ -23,7 +26,7 @@ export class SNTag extends SNItem implements TagContent { get noteReferences(): ContentReference[] { const references = this.payload.safeReferences; - return references.filter(ref => ref.content_type === ContentType.Note) + return references.filter((ref) => ref.content_type === ContentType.Note); } get noteCount(): number { diff --git a/packages/snjs/lib/protocol/collection/indexes.ts b/packages/snjs/lib/protocol/collection/indexes.ts new file mode 100644 index 000000000..d51e67981 --- /dev/null +++ b/packages/snjs/lib/protocol/collection/indexes.ts @@ -0,0 +1,12 @@ +import { SNItem } from '../../models/core/item'; + +export interface ItemDelta { + changed: SNItem[]; + inserted: SNItem[]; + discarded: SNItem[]; + ignored: SNItem[]; +} + +export interface SNIndex { + onChange(delta: ItemDelta): void; +} diff --git a/packages/snjs/lib/protocol/collection/item_collection.ts b/packages/snjs/lib/protocol/collection/item_collection.ts index 0b5d31cd7..e8d28c251 100644 --- a/packages/snjs/lib/protocol/collection/item_collection.ts +++ b/packages/snjs/lib/protocol/collection/item_collection.ts @@ -3,6 +3,7 @@ import { compareValues, isNullOrUndefined, uniqueArrayByKey } from '@Lib/utils'; import { SNItem } from './../../models/core/item'; import { ContentType } from '@standardnotes/common'; import { UuidString } from './../../types'; +import { ItemDelta, SNIndex } from './indexes'; export enum CollectionSort { CreatedAt = 'created_at', @@ -13,7 +14,9 @@ export type SortDirection = 'asc' | 'dsc'; /** The item collection class builds on mutable collection by providing an option to keep * items sorted and filtered. */ -export class ItemCollection extends MutableCollection { +export class ItemCollection + extends MutableCollection + implements SNIndex { private displaySortBy: Partial< Record< ContentType, @@ -134,9 +137,12 @@ export class ItemCollection extends MutableCollection { const previousElement = !isNullOrUndefined(previousIndex) ? sortedElements[previousIndex] : undefined; - /** If the element is deleted, or if it no longer exists in the primary map (because + + /** + * If the element is deleted, or if it no longer exists in the primary map (because * it was discarded without neccessarily being marked as deleted), it does not pass - * the filter. If no filter the element passes by default. */ + * the filter. If no filter the element passes by default. + */ const passes = element.deleted || !this.map[element.uuid] ? false @@ -260,4 +266,14 @@ export class ItemCollection extends MutableCollection { } this.sortedMap[contentType] = cleaned; } + + public onChange(delta: ItemDelta): void { + const changedOrInserted = delta.changed.concat(delta.inserted); + + if (changedOrInserted.length > 0) { + this.set(changedOrInserted); + } + + this.discard(delta.discarded); + } } diff --git a/packages/snjs/lib/protocol/collection/item_collection_notes_view.ts b/packages/snjs/lib/protocol/collection/item_collection_notes_view.ts index b253b0b55..d03187f71 100644 --- a/packages/snjs/lib/protocol/collection/item_collection_notes_view.ts +++ b/packages/snjs/lib/protocol/collection/item_collection_notes_view.ts @@ -1,7 +1,8 @@ +import { ContentType } from '@Lib/index'; +import { SNNote, SNTag } from '../../models'; import { SNSmartTag } from './../../models/app/smartTag'; +import { ItemDelta, SNIndex } from './indexes'; import { ItemCollection } from './item_collection'; -import { SNNote, SNTag } from '../../models'; -import { ContentType } from '@standardnotes/common'; import { criteriaForSmartTag, NotesDisplayCriteria, @@ -11,7 +12,7 @@ import { /** * A view into ItemCollection that allows filtering by tag and smart tag. */ -export class ItemCollectionNotesView { +export class ItemCollectionNotesView implements SNIndex { private displayedNotes: SNNote[] = []; private needsRebuilding = true; @@ -35,7 +36,22 @@ export class ItemCollectionNotesView { return notesMatchingCriteria(criteria, this.collection); } + public displayElements(): SNNote[] { + if (this.needsRebuilding) { + this.rebuildList(); + } + return this.displayedNotes.slice(); + } + private rebuildList(): void { + this.displayedNotes = notesMatchingCriteria( + this.currentCriteria, + this.collection + ); + this.needsRebuilding = false; + } + + private get currentCriteria(): NotesDisplayCriteria { const mostRecentVersionOfTags = this.criteria.tags .map((tag) => { if (tag.isSystemSmartTag) { @@ -45,21 +61,15 @@ export class ItemCollectionNotesView { } }) .filter((tag) => tag != undefined); + const criteria = NotesDisplayCriteria.Copy(this.criteria, { tags: mostRecentVersionOfTags, }); - this.displayedNotes = notesMatchingCriteria(criteria, this.collection); - } - setNeedsRebuilding() { - this.needsRebuilding = true; + return criteria; } - displayElements() { - if (this.needsRebuilding) { - this.rebuildList(); - this.needsRebuilding = false; - } - return this.displayedNotes.slice(); + public onChange(_delta: ItemDelta): void { + this.needsRebuilding = true; } } diff --git a/packages/snjs/lib/protocol/collection/tag_notes_index.ts b/packages/snjs/lib/protocol/collection/tag_notes_index.ts new file mode 100644 index 000000000..0a2c4798d --- /dev/null +++ b/packages/snjs/lib/protocol/collection/tag_notes_index.ts @@ -0,0 +1,113 @@ +import { removeFromArray } from '@Lib/utils'; +import { ItemCollection } from './item_collection'; +import { SNNote } from '@Lib/models'; +import { SNTag } from '@Lib/index'; +import { UuidString } from '@Lib/types'; +import { isNote } from '@Lib/models/app/note'; +import { isTag } from '@Lib/models/app/tag'; +import { ItemDelta, SNIndex } from './indexes'; + +/** tagUuid undefined signifies all notes count change */ +export type TagNoteCountChangeObserver = ( + tagUuid: UuidString | undefined +) => void; + +export class TagNotesIndex implements SNIndex { + private tagToNotesMap: Partial>> = {}; + private allCountableNotes = new Set(); + private observers: TagNoteCountChangeObserver[] = []; + + constructor(private collection: ItemCollection) {} + + private isNoteCountable = (note: SNNote) => { + return !note.archived && !note.trashed; + }; + + public addCountChangeObserver( + observer: TagNoteCountChangeObserver + ): () => void { + this.observers.push(observer); + + return () => { + removeFromArray(this.observers, observer); + }; + } + + private notifyObservers(tagUuid: UuidString | undefined) { + for (const observer of this.observers) { + observer(tagUuid); + } + } + + public allCountableNotesCount(): number { + return this.allCountableNotes.size; + } + + public countableNotesForTag(tag: SNTag): number { + return this.tagToNotesMap[tag.uuid]?.size || 0; + } + + public onChange(delta: ItemDelta): void { + const changedOrInserted = delta.changed.concat(delta.inserted); + const notes = changedOrInserted.filter(isNote); + const tags = changedOrInserted.filter(isTag); + + this.receiveNoteChanges(notes); + this.receiveTagChanges(tags); + } + + private receiveTagChanges(tags: SNTag[]): void { + for (const tag of tags) { + const uuids = tag.noteReferences.map((ref) => ref.uuid); + const countableUuids = uuids.filter((uuid) => + this.allCountableNotes.has(uuid) + ); + const previousSet = this.tagToNotesMap[tag.uuid]; + this.tagToNotesMap[tag.uuid] = new Set(countableUuids); + + if (previousSet?.size !== countableUuids.length) { + this.notifyObservers(tag.uuid); + } + } + } + + private receiveNoteChanges(notes: SNNote[]): void { + for (const note of notes) { + const isCountable = this.isNoteCountable(note); + const previousAllCount = this.allCountableNotes.size; + if (isCountable) { + this.allCountableNotes.add(note.uuid); + } else { + this.allCountableNotes.delete(note.uuid); + } + if (previousAllCount !== this.allCountableNotes.size) { + this.notifyObservers(undefined); + } + + const associatedTagUuids = this.collection.uuidsThatReferenceUuid( + note.uuid + ); + for (const tagUuid of associatedTagUuids) { + const set = this.setForTag(tagUuid); + const previousCount = set.size; + if (isCountable) { + set.add(note.uuid); + } else { + set.delete(note.uuid); + } + if (previousCount !== set.size) { + this.notifyObservers(tagUuid); + } + } + } + } + + private setForTag(uuid: UuidString): Set { + let set = this.tagToNotesMap[uuid]; + if (!set) { + set = new Set(); + this.tagToNotesMap[uuid] = set; + } + return set; + } +} diff --git a/packages/snjs/lib/services/item_manager.spec.ts b/packages/snjs/lib/services/item_manager.spec.ts index a7f7ad2e3..bb4fed17f 100644 --- a/packages/snjs/lib/services/item_manager.spec.ts +++ b/packages/snjs/lib/services/item_manager.spec.ts @@ -1,5 +1,5 @@ import { ItemManager, SNItem } from '@Lib/index'; -import { SNNote } from '@Lib/models'; +import { SNNote, NoteMutator } from '@Lib/models'; import { SmartTagPredicateContent, SNSmartTag } from '@Lib/models/app/smartTag'; import { Uuid } from '@Lib/uuid'; import { SNTag, TagMutator } from '@Models/app/tag'; @@ -334,6 +334,59 @@ describe('itemManager', () => { }); }); + describe('tags notes index', () => { + it('counts countable notes', async () => { + itemManager = createService(); + + const parentTag = createTag('parent'); + const childTag = createTag('child'); + await itemManager.insertItems([parentTag, childTag]); + await itemManager.setTagParent(parentTag, childTag); + + const parentNote = createNote('parentNote'); + const childNote = createNote('childNote'); + await itemManager.insertItems([parentNote, childNote]); + + await itemManager.addTagToNote(parentNote, parentTag); + await itemManager.addTagToNote(childNote, childTag); + + expect(itemManager.countableNotesForTag(parentTag)).toBe(1); + expect(itemManager.countableNotesForTag(childTag)).toBe(1); + expect(itemManager.allCountableNotesCount()).toBe(2); + }); + + it('archiving a note should update count index', async () => { + itemManager = createService(); + + const tag1 = createTag('tag 1'); + await itemManager.insertItems([tag1]); + + const note1 = createNote('note 1'); + const note2 = createNote('note 2'); + await itemManager.insertItems([note1, note2]); + + await itemManager.addTagToNote(note1, tag1); + await itemManager.addTagToNote(note2, tag1); + + expect(itemManager.countableNotesForTag(tag1)).toBe(2); + expect(itemManager.allCountableNotesCount()).toBe(2); + + await itemManager.changeItem(note1.uuid, (m) => { + m.archived = true; + }); + + expect(itemManager.allCountableNotesCount()).toBe(1); + expect(itemManager.countableNotesForTag(tag1)).toBe(1); + + await itemManager.changeItem(note1.uuid, (m) => { + m.archived = false; + }); + + expect(itemManager.allCountableNotesCount()).toBe(2); + expect(itemManager.countableNotesForTag(tag1)).toBe(2); + }); + }); + describe('tags and smart tags', () => { it('lets me create a smart tag', async () => { itemManager = createService(); diff --git a/packages/snjs/lib/services/item_manager.ts b/packages/snjs/lib/services/item_manager.ts index 8caf50e4f..1ee4514bb 100644 --- a/packages/snjs/lib/services/item_manager.ts +++ b/packages/snjs/lib/services/item_manager.ts @@ -1,4 +1,5 @@ import { createMutatorForItem } from '@Lib/models/mutator'; +import { ItemDelta } from '@Lib/protocol/collection/indexes'; import { ItemCollectionNotesView } from '@Lib/protocol/collection/item_collection_notes_view'; import { NotesDisplayCriteria } from '@Lib/protocol/collection/notes_display_criteria'; import { PureService } from '@Lib/services/pure_service'; @@ -15,7 +16,7 @@ import { ItemCollection, SortDirection, } from '@Protocol/collection/item_collection'; -import { ContentType } from '@standardnotes/common' +import { ContentType } from '@standardnotes/common'; import { ComponentMutator } from './../models/app/component'; import { ActionsExtensionMutator, @@ -35,6 +36,10 @@ import { import { TagMutator } from './../models/app/tag'; import { ItemMutator, MutationType, SNItem } from './../models/core/item'; import { SNPredicate } from './../models/core/predicate'; +import { + TagNoteCountChangeObserver, + TagNotesIndex, +} from './../protocol/collection/tag_notes_index'; import { PayloadContent, PayloadOverride, @@ -69,6 +74,9 @@ export type TransactionalMutation = { mutationType?: MutationType; }; +export const isTagOrNote = (x: SNItem): x is SNNote | SNTag => + x.content_type === ContentType.Note || x.content_type === ContentType.Tag; + /** * The item manager is backed by the Payload Manager. Think of the item manager as a * more user-friendly or item-specific interface to creating and updating data. @@ -85,6 +93,7 @@ export class ItemManager extends PureService { private collection!: ItemCollection; private notesView!: ItemCollectionNotesView; private systemSmartTags: SNSmartTag[]; + private tagNotesIndex!: TagNotesIndex; constructor(private payloadManager: PayloadManager) { super(); @@ -130,6 +139,7 @@ export class ItemManager extends PureService { 'asc' ); this.notesView = new ItemCollectionNotesView(this.collection); + this.tagNotesIndex = new TagNotesIndex(this.collection); } public setDisplayOptions( @@ -253,6 +263,29 @@ export class ItemManager extends PureService { ) as SNComponent[]; } + public addNoteCountChangeObserver( + observer: TagNoteCountChangeObserver + ): () => void { + return this.tagNotesIndex.addCountChangeObserver(observer); + } + + public allCountableNotesCount(): number { + return this.tagNotesIndex.allCountableNotesCount(); + } + + public countableNotesForTag(tag: SNTag | SNSmartTag): number { + if (tag.isSmartTag) { + if (tag.isAllTag) { + return this.tagNotesIndex.allCountableNotesCount(); + } + + throw Error( + 'countableNotesForTag is not meant to be used for smart tags.' + ); + } + return this.tagNotesIndex.countableNotesForTag(tag); + } + public addObserver( contentType: ContentType | ContentType[], callback: ObserverCallback @@ -301,23 +334,25 @@ export class ItemManager extends PureService { source: PayloadSource, sourceKey?: string ) { - const changedItems = changed.map((p) => CreateItemFromPayload(p)); - const insertedItems = inserted.map((p) => CreateItemFromPayload(p)); - const ignoredItems = ignored.map((p) => CreateItemFromPayload(p)); - const changedOrInserted = changedItems.concat(insertedItems); - if (changedOrInserted.length > 0) { - this.collection.set(changedOrInserted); - } - const discardedItems = discarded.map((p) => CreateItemFromPayload(p)); - for (const item of discardedItems) { - this.collection.discard(item); - } - this.notesView.setNeedsRebuilding(); + const createItems = (items: PurePayload[]) => + items.map((item) => CreateItemFromPayload(item)); + + const delta: ItemDelta = { + changed: createItems(changed), + inserted: createItems(inserted), + discarded: createItems(discarded), + ignored: createItems(ignored), + }; + + this.collection.onChange(delta); + this.notesView.onChange(delta); + this.tagNotesIndex.onChange(delta); + this.notifyObservers( - changedItems, - insertedItems, - discardedItems, - ignoredItems, + delta.changed, + delta.inserted, + delta.discarded, + delta.ignored, source, sourceKey );