Skip to content
This repository has been archived by the owner on Jul 6, 2022. It is now read-only.

feat: tag notes index #546

Merged
merged 10 commits into from
Jan 17, 2022
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions packages/snjs/lib/application.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 6 additions & 2 deletions packages/snjs/lib/models/app/note.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion packages/snjs/lib/models/app/tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions packages/snjs/lib/protocol/collection/indexes.ts
Original file line number Diff line number Diff line change
@@ -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;
}
22 changes: 19 additions & 3 deletions packages/snjs/lib/protocol/collection/item_collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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<SNItem> {
export class ItemCollection
extends MutableCollection<SNItem>
implements SNIndex {
private displaySortBy: Partial<
Record<
ContentType,
Expand Down Expand Up @@ -134,9 +137,12 @@ export class ItemCollection extends MutableCollection<SNItem> {
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
Expand Down Expand Up @@ -260,4 +266,14 @@ export class ItemCollection extends MutableCollection<SNItem> {
}
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);
}
}
36 changes: 23 additions & 13 deletions packages/snjs/lib/protocol/collection/item_collection_notes_view.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;

Expand All @@ -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) {
Expand All @@ -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;
}
}
113 changes: 113 additions & 0 deletions packages/snjs/lib/protocol/collection/tag_notes_index.ts
Original file line number Diff line number Diff line change
@@ -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<Record<UuidString, Set<UuidString>>> = {};
private allCountableNotes = new Set<UuidString>();
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);
}
}
}
moughxyz marked this conversation as resolved.
Show resolved Hide resolved

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<UuidString> {
let set = this.tagToNotesMap[uuid];
if (!set) {
set = new Set();
this.tagToNotesMap[uuid] = set;
}
return set;
}
}
55 changes: 54 additions & 1 deletion packages/snjs/lib/services/item_manager.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<NoteMutator>(note1.uuid, (m) => {
m.archived = true;
});

expect(itemManager.allCountableNotesCount()).toBe(1);
expect(itemManager.countableNotesForTag(tag1)).toBe(1);

await itemManager.changeItem<NoteMutator>(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();
Expand Down
Loading