From 20412859d7037b669d7cce3dd027006287249c47 Mon Sep 17 00:00:00 2001 From: Perry Mitchell Date: Thu, 25 Jan 2024 19:44:18 +0200 Subject: [PATCH 1/8] Add entry tags --- source/core/Entry.ts | 52 ++++++++++++++++++++++++++++++++++++++++++-- source/tools/tag.ts | 3 +++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 source/tools/tag.ts diff --git a/source/core/Entry.ts b/source/core/Entry.ts index 555b65c0..fb441890 100644 --- a/source/core/Entry.ts +++ b/source/core/Entry.ts @@ -3,6 +3,7 @@ import { generateUUID } from "../tools/uuid.js"; import { getEntryURLs, EntryURLType } from "../tools/entry.js"; import { Group } from "./Group.js"; import { Vault } from "./Vault.js"; +import { isValidTag } from "../tools/tag.js"; import { EntryChange, EntryPropertyValueType, @@ -20,7 +21,8 @@ export class Entry extends VaultItem { static Attributes = Object.freeze({ AttachmentPrefix: "BC_ENTRY_ATTACHMENT:", FacadeType: "BC_ENTRY_FACADE_TYPE", - FieldTypePrefix: "BC_ENTRY_FIELD_TYPE:" + FieldTypePrefix: "BC_ENTRY_FIELD_TYPE:", + Tags: "BC_ENTRY_TAGS" }); /** @@ -47,6 +49,23 @@ export class Entry extends VaultItem { return vault.findEntryByID(id); } + /** + * Add one or more tags to the entry + * @param tags A collection of tag strings + * @returns Self + */ + addTags(...tags: Array): this { + const current = new Set(this.getTags()); + for (const tag of tags) { + if (!isValidTag(tag)) { + throw new Error(`Cannot add entry tag: Invalid format: ${tag}`); + } + current.add(tag); + } + this.setAttribute(Entry.Attributes.Tags, [...current].join(",")); + return this; + } + /** * Delete the entry - either trashes the entry, or removes it completely. * If the entry is in the trash already, it is removed (including if there is no @@ -108,6 +127,8 @@ export class Entry extends VaultItem { * containing all attribute keys and their values if no attribute name * is provided */ + getAttribute(): PropertyKeyValueObject; + getAttribute(attribute: string): string | undefined; getAttribute(attribute?: string): PropertyKeyValueObject | string | undefined { const attributes = this.vault.format.getEntryAttributes(this._source) || {}; if (typeof attribute === "undefined") { @@ -189,9 +210,22 @@ export class Entry extends VaultItem { }, {}); } + /** + * Get entry tags + * @returns An array of tag strings + */ + getTags(): Array { + const tags = this.getAttribute(Entry.Attributes.Tags); + return typeof tags === "string" + ? tags.split(",").reduce((output, tag) => { + return isValidTag(tag) ? [...output, tag] : output; + }, []) + : []; + } + /** * Get the entry type - * @returns + * @returns The entry type */ getType(): EntryType { return ( @@ -233,6 +267,20 @@ export class Entry extends VaultItem { return this; } + /** + * Remove one or more tags + * @param tags Collection of tags to remove + * @returns Self + */ + removeTags(...tags: Array): this { + const current = new Set(this.getTags()); + for (const tag of tags) { + current.delete(tag); + } + this.setAttribute(Entry.Attributes.Tags, [...current].join(",")); + return this; + } + /** * Set an attribute on the entry * @param attribute The name of the attribute diff --git a/source/tools/tag.ts b/source/tools/tag.ts new file mode 100644 index 00000000..4b15d025 --- /dev/null +++ b/source/tools/tag.ts @@ -0,0 +1,3 @@ +export function isValidTag(tag: string): boolean { + return /^[a-zA-Z0-9_]+$/.test(tag); +} From 44e57a3d4d81d1f788760c5547fa0a2886a7329d Mon Sep 17 00:00:00 2001 From: Perry Mitchell Date: Sat, 27 Jan 2024 19:12:58 +0200 Subject: [PATCH 2/8] Add tag support --- source/core/Entry.ts | 10 ++++++---- source/core/Vault.ts | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/source/core/Entry.ts b/source/core/Entry.ts index fb441890..f2398e66 100644 --- a/source/core/Entry.ts +++ b/source/core/Entry.ts @@ -57,10 +57,11 @@ export class Entry extends VaultItem { addTags(...tags: Array): this { const current = new Set(this.getTags()); for (const tag of tags) { - if (!isValidTag(tag)) { - throw new Error(`Cannot add entry tag: Invalid format: ${tag}`); + const tagLower = tag.toLowerCase(); + if (!isValidTag(tagLower)) { + throw new Error(`Cannot add entry tag: Invalid format: ${tagLower}`); } - current.add(tag); + current.add(tagLower); } this.setAttribute(Entry.Attributes.Tags, [...current].join(",")); return this; @@ -275,7 +276,8 @@ export class Entry extends VaultItem { removeTags(...tags: Array): this { const current = new Set(this.getTags()); for (const tag of tags) { - current.delete(tag); + const tagLower = tag.toLowerCase(); + current.delete(tagLower); } this.setAttribute(Entry.Attributes.Tags, [...current].join(",")); return this; diff --git a/source/core/Vault.ts b/source/core/Vault.ts index 43ef18d0..52044317 100644 --- a/source/core/Vault.ts +++ b/source/core/Vault.ts @@ -59,6 +59,8 @@ export class Vault extends EventEmitter { _onCommandExec: () => void; + _tagMap: Map> = new Map(); + /** * The vault format * @readonly @@ -171,6 +173,12 @@ export class Vault extends EventEmitter { return findEntriesByProperty(this._entries, property, value); } + findEntriesByTag(tag: string): Array { + const tagLower = tag.toLowerCase(); + const entryIDs = this._tagMap.has(tagLower) ? this._tagMap.get(tagLower) : []; + return entryIDs.map((id) => this.findEntryByID(id)); + } + /** * Find a group by its ID * @param id The group ID to search for @@ -209,6 +217,14 @@ export class Vault extends EventEmitter { return [...this._groups]; } + /** + * Get all registered entry tags + * @returns An array of tag strings + */ + getAllTags(): Array { + return [...this._tagMap.keys()]; + } + /** * Get the value of an attribute * @param attributeName The attribute to get @@ -277,6 +293,23 @@ export class Vault extends EventEmitter { }); } + _rebuildTags() { + this._tagMap = new Map(); + this.getAllEntries().forEach((entry) => { + const tags = entry.getTags(); + for (const tag of tags) { + const tagLower = tag.toLowerCase(); + const existingIDs = this._tagMap.has(tagLower) + ? [...this._tagMap.get(tagLower)] + : []; + if (!existingIDs.includes(entry.id)) { + existingIDs.push(entry.id); + } + this._tagMap.set(tagLower, existingIDs); + } + }); + } + /** * Update the format reference * @param format The new format instance From 9a309aea53e4ec17dbeb02d9572f68144411357d Mon Sep 17 00:00:00 2001 From: Perry Mitchell Date: Sun, 28 Jan 2024 19:23:16 +0200 Subject: [PATCH 3/8] Add tag searching --- source/core/Vault.ts | 20 +++++++++++++++++++- source/facades/entry.ts | 18 +++++++++++++++++- source/search/BaseSearch.ts | 17 +++++++++++++++-- source/search/VaultEntrySearch.ts | 9 +++++---- source/search/VaultFacadeEntrySearch.ts | 19 ++++++++++++++----- source/search/tags.ts | 20 ++++++++++++++++++++ 6 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 source/search/tags.ts diff --git a/source/core/Vault.ts b/source/core/Vault.ts index 52044317..0fbdc21e 100644 --- a/source/core/Vault.ts +++ b/source/core/Vault.ts @@ -173,8 +173,26 @@ export class Vault extends EventEmitter { return findEntriesByProperty(this._entries, property, value); } - findEntriesByTag(tag: string): Array { + /** + * Find entries by a certain tag + * @param tag The case-insensitive tag name + * @param exact Whether to match exact tag names or use partial + * matching. Default is true (exact). + * @returns An array of entries + */ + findEntriesByTag(tag: string, exact: boolean = true): Array { const tagLower = tag.toLowerCase(); + if (!exact) { + const entryIDs = new Set(); + for (const [currentTag, currentIDs] of this._tagMap.entries()) { + if (currentTag.toLowerCase().indexOf(tagLower) === 0) { + for (const id of currentIDs) { + entryIDs.add(id); + } + } + } + return [...entryIDs].map((id) => this.findEntryByID(id)); + } const entryIDs = this._tagMap.has(tagLower) ? this._tagMap.get(tagLower) : []; return entryIDs.map((id) => this.findEntryByID(id)); } diff --git a/source/facades/entry.ts b/source/facades/entry.ts index 3b80ac4d..726ffdb1 100644 --- a/source/facades/entry.ts +++ b/source/facades/entry.ts @@ -197,6 +197,22 @@ export function createEntryFromFacade(group: Group, facade: EntryFacade): Entry return entry; } +/** + * Convert an array of entry facade fields to a + * key-value object with only attributes + * @param facadeFields Array of fields + * @memberof module:Buttercup + */ +export function fieldsToAttributes(facadeFields: Array): { + [key: string]: string; +} { + return facadeFields.reduce((output, field) => { + if (field.propertyType !== EntryPropertyType.Attribute) return output; + output[field.property] = field.value; + return output; + }, {}); +} + /** * Convert an array of entry facade fields to a * key-value object with only properties @@ -207,7 +223,7 @@ export function fieldsToProperties(facadeFields: Array): { [key: string]: string; } { return facadeFields.reduce((output, field) => { - if (field.propertyType !== "property") return output; + if (field.propertyType !== EntryPropertyType.Property) return output; output[field.property] = field.value; return output; }, {}); diff --git a/source/search/BaseSearch.ts b/source/search/BaseSearch.ts index 5b94c1ea..0de499a2 100644 --- a/source/search/BaseSearch.ts +++ b/source/search/BaseSearch.ts @@ -3,6 +3,7 @@ import { StorageInterface } from "../storage/StorageInterface.js"; import { buildSearcher } from "./searcher.js"; import { Vault } from "../core/Vault.js"; import { EntryID, EntryType, GroupID, VaultFacade, VaultID } from "../types.js"; +import { extractTagsFromSearchTerm } from "./tags.js"; interface DomainScores { [domain: string]: number; @@ -22,10 +23,11 @@ export interface SearcherFactory { } export interface SearchResult { - id: EntryID; + entryType: EntryType; groupID: GroupID; + id: EntryID; properties: { [property: string]: string }; - entryType: EntryType; + tags: Array; urls: Array; vaultID: VaultID; } @@ -137,6 +139,17 @@ export class BaseSearch { if (!this._fuse) { throw new Error("Searching interface not prepared"); } + const { tags, term: searchTerm } = extractTagsFromSearchTerm(term); + if (tags.length > 0) { + // Instantiate new searcher based on a subset of entries + const subset = this._entries.filter((entry) => + entry.tags.some((entryTag) => tags.includes(entryTag)) + ); + this._fuse = this._searcherFactory(subset); + } else { + // Reset instance + this._fuse = this._searcherFactory(this._entries); + } this._results = this._fuse.search(term).map((result) => result.item); return this._results; } diff --git a/source/search/VaultEntrySearch.ts b/source/search/VaultEntrySearch.ts index 2fa35a4a..57aa7419 100644 --- a/source/search/VaultEntrySearch.ts +++ b/source/search/VaultEntrySearch.ts @@ -25,13 +25,14 @@ async function extractEntries( const properties = entry.getProperties(); const urls = getEntryURLs(properties, EntryURLType.General); return { + domainScores: vaultScore[entry.id] || {}, + entryType: entry.getType(), + groupID: entry.getGroup().id, id: entry.id, properties, - entryType: entry.getType(), + tags: entry.getTags(), urls, - groupID: entry.getGroup().id, - vaultID: vault.id, - domainScores: vaultScore[entry.id] || {} + vaultID: vault.id }; }); } diff --git a/source/search/VaultFacadeEntrySearch.ts b/source/search/VaultFacadeEntrySearch.ts index a5becc6f..fc29f0f0 100644 --- a/source/search/VaultFacadeEntrySearch.ts +++ b/source/search/VaultFacadeEntrySearch.ts @@ -1,8 +1,10 @@ +import { Entry } from "../core/Entry.js"; import { BaseSearch, ProcessedSearchEntry, SearcherFactory } from "./BaseSearch.js"; import { EntryURLType, getEntryURLs } from "../tools/entry.js"; -import { fieldsToProperties } from "../facades/entry.js"; +import { fieldsToAttributes, fieldsToProperties } from "../facades/entry.js"; import { StorageInterface } from "../storage/StorageInterface.js"; import { EntryFacade, VaultFacade } from "../types.js"; +import { isValidTag } from "../tools/tag.js"; async function extractEntries( facade: VaultFacade, @@ -20,16 +22,23 @@ async function extractEntries( // Get entries return facade.entries.reduce((entries: Array, nextEntry: EntryFacade) => { // @todo in trash + const attributes = fieldsToAttributes(nextEntry.fields); + const tags = attributes[Entry.Attributes.Tags] + ? attributes[Entry.Attributes.Tags].split(",").reduce((output, tag) => { + return isValidTag(tag) ? [...output, tag] : output; + }, []) + : []; const properties = fieldsToProperties(nextEntry.fields); const urls = getEntryURLs(properties, EntryURLType.General); entries.push({ + domainScores: vaultScore[nextEntry.id] || {}, + entryType: nextEntry.type, + groupID: nextEntry.parentID, id: nextEntry.id, properties, - entryType: nextEntry.type, + tags, urls, - groupID: nextEntry.parentID, - vaultID: facade.id, - domainScores: vaultScore[nextEntry.id] || {} + vaultID: facade.id }); return entries; }, []); diff --git a/source/search/tags.ts b/source/search/tags.ts new file mode 100644 index 00000000..784ab49f --- /dev/null +++ b/source/search/tags.ts @@ -0,0 +1,20 @@ +export function extractTagsFromSearchTerm(term: string): { + tags: Array; + term: string; +} { + const searchItems: Array = []; + const tags = new Set(); + const parts = term.split(/\s+/g); + for (const part of parts) { + if (/^#.+/.test(part)) { + const raw = part.replace(/^#/, ""); + tags.add(raw.toLowerCase()); + } else if (part.length > 0) { + searchItems.push(part); + } + } + return { + tags: [...tags], + term: searchItems.join(" ").trim() + }; +} From d35676f528182f056fb8f6936cd911001a100a67 Mon Sep 17 00:00:00 2001 From: Perry Mitchell Date: Sun, 28 Jan 2024 19:23:28 +0200 Subject: [PATCH 4/8] Add entry tag tests --- test/unit/core/Entry.spec.js | 42 +++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/test/unit/core/Entry.spec.js b/test/unit/core/Entry.spec.js index 96090631..cbac4e36 100644 --- a/test/unit/core/Entry.spec.js +++ b/test/unit/core/Entry.spec.js @@ -1,5 +1,6 @@ import { expect } from "chai"; import { + Entry, EntryChangeType, EntryPropertyValueType, Group, @@ -22,6 +23,7 @@ describe("core/Entry", function () { this.group = this.vault.createGroup("test"); this.otherGroup = this.vault.createGroup("second"); this.entry = this.group.createEntry("entry"); + this.entry.setAttribute(Entry.Attributes.Tags, "one,two,three"); this.entry.setAttribute("attrib", "ok"); this.entry.setAttribute("attrib2", "also-ok"); this.entry.setProperty("metakey", "metaval"); @@ -51,6 +53,31 @@ describe("core/Entry", function () { }); }); + describe("addTags", function () { + it("adds tags", function () { + this.entry.addTags("four", "five"); + expect(this.entry.getTags()).to.deep.equal([ + "one", + "two", + "three", + "four", + "five" + ]); + }); + + it("throws adding tags with invalid characters", function () { + expect(() => { + this.entry.addTags("ok", "!"); + }).to.throw(/Invalid format/i); + }); + + it("throws adding empty", function () { + expect(() => { + this.entry.addTags("ok", ""); + }).to.throw(/Invalid format/i); + }); + }); + describe("delete", function () { it("moves itself to the Trash group", function () { const trash = this.vault.getTrashGroup(); @@ -94,7 +121,7 @@ describe("core/Entry", function () { }); it("returns an object if no parameter is provided", function () { - expect(this.entry.getAttribute()).to.deep.equal({ + expect(this.entry.getAttribute()).to.deep.include({ attrib: "ok", attrib2: "also-ok" }); @@ -218,6 +245,12 @@ describe("core/Entry", function () { }); }); + describe("getTags", function () { + it("returns an array of strings", function () { + expect(this.entry.getTags()).to.deep.equal(["one", "two", "three"]); + }); + }); + describe("getURLs", function () { it("returns an array of URLs", function () { const urls = this.entry.getURLs(); @@ -243,6 +276,13 @@ describe("core/Entry", function () { }); }); + describe("removeTags", function () { + it("removes tags", function () { + this.entry.removeTags("one", "two"); + expect(this.entry.getTags()).to.deep.equal(["three"]); + }); + }); + describe("setAttribute", function () { it("sets attributes", function () { this.entry.setAttribute("one", "two"); From c7822263ae52c1879bf3854081a5892c9ecadbf8 Mon Sep 17 00:00:00 2001 From: Perry Mitchell Date: Sun, 28 Jan 2024 19:38:41 +0200 Subject: [PATCH 5/8] Fix vault (B) command exec event, tag reading --- source/core/Vault.ts | 2 ++ source/io/VaultFormatB.ts | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/source/core/Vault.ts b/source/core/Vault.ts index 0fbdc21e..5a9b292e 100644 --- a/source/core/Vault.ts +++ b/source/core/Vault.ts @@ -194,6 +194,7 @@ export class Vault extends EventEmitter { return [...entryIDs].map((id) => this.findEntryByID(id)); } const entryIDs = this._tagMap.has(tagLower) ? this._tagMap.get(tagLower) : []; + console.log("SEARCHING", entryIDs, this._tagMap); return entryIDs.map((id) => this.findEntryByID(id)); } @@ -309,6 +310,7 @@ export class Vault extends EventEmitter { this._entries.push(new Entry(this, rawEntry)); } }); + this._rebuildTags(); } _rebuildTags() { diff --git a/source/io/VaultFormatB.ts b/source/io/VaultFormatB.ts index 81168d50..390678fc 100644 --- a/source/io/VaultFormatB.ts +++ b/source/io/VaultFormatB.ts @@ -130,6 +130,7 @@ export class VaultFormatB extends VaultFormat { this.cloneGroup(childGroup, newGroup.id); } }); + this.emit("commandsExecuted"); } createEntry(groupID: GroupID, entryID: EntryID) { @@ -140,6 +141,7 @@ export class VaultFormatB extends VaultFormat { a: {} }; this.source.e.push(entry); + this.emit("commandsExecuted"); } createGroup(parentID: GroupID, groupID: GroupID) { @@ -150,6 +152,7 @@ export class VaultFormatB extends VaultFormat { a: {} }; this.source.g.push(group); + this.emit("commandsExecuted"); } deleteEntry(entryID: EntryID) { @@ -157,6 +160,7 @@ export class VaultFormatB extends VaultFormat { if (ind >= 0) { this.source.e.splice(ind, 1); this.source.del.e[entryID] = Date.now(); + this.emit("commandsExecuted"); } } @@ -164,12 +168,14 @@ export class VaultFormatB extends VaultFormat { const entry = this.source.e.find((e: FormatBEntry) => e.id === entryID); if (!entry.a[attribute]) return; entry.a[attribute].deleted = getTimestamp(); + this.emit("commandsExecuted"); } deleteEntryProperty(entryID: EntryID, property: string) { const entry = this.source.e.find((e) => e.id === entryID); if (!entry.p[property]) return; entry.p[property].deleted = getTimestamp(); + this.emit("commandsExecuted"); } deleteGroup(groupID: GroupID) { @@ -177,6 +183,7 @@ export class VaultFormatB extends VaultFormat { if (ind >= 0) { this.source.g.splice(ind, 1); this.source.del.g[groupID] = Date.now(); + this.emit("commandsExecuted"); } } @@ -184,11 +191,13 @@ export class VaultFormatB extends VaultFormat { const group = this.source.g.find((g) => g.id === groupID); if (!group.a[attribute]) return; group.a[attribute].deleted = getTimestamp(); + this.emit("commandsExecuted"); } deleteVaultAttribute(attribute: string) { if (!this.source.a[attribute]) return; this.source.a[attribute].deleted = getTimestamp(); + this.emit("commandsExecuted"); } erase() { @@ -239,6 +248,7 @@ export class VaultFormatB extends VaultFormat { generateID() { this.source.id = generateUUID(); + this.emit("commandsExecuted"); } getAllEntries(parentID: GroupID = null): Array { @@ -338,18 +348,23 @@ export class VaultFormatB extends VaultFormat { this.source.del.g = {}; } if (!this.source.id) { + // Emits "commandsExecuted" this.generateID(); + } else { + this.emit("commandsExecuted"); } } moveEntry(entryID: EntryID, groupID: GroupID) { const entry = this.source.e.find((e: FormatBEntry) => e.id === entryID); entry.g = groupID; + this.emit("commandsExecuted"); } moveGroup(groupID: GroupID, newParentID: GroupID) { const group = this.source.g.find((g: FormatBGroup) => g.id === groupID); group.g = newParentID; + this.emit("commandsExecuted"); } optimise() { @@ -393,6 +408,7 @@ export class VaultFormatB extends VaultFormat { delete this.source.del.g[groupID]; } } + this.emit("commandsExecuted"); } prepareOrphansGroup(): FormatBGroup { @@ -419,6 +435,7 @@ export class VaultFormatB extends VaultFormat { item.value = value; item.updated = getTimestamp(); } + this.emit("commandsExecuted"); } setEntryProperty(entryID: EntryID, property: string, value: string) { @@ -431,6 +448,7 @@ export class VaultFormatB extends VaultFormat { item.value = value; item.updated = getTimestamp(); } + this.emit("commandsExecuted"); } setGroupAttribute(groupID: GroupID, attribute: string, value: string) { @@ -443,11 +461,13 @@ export class VaultFormatB extends VaultFormat { item.value = value; item.updated = getTimestamp(); } + this.emit("commandsExecuted"); } setGroupTitle(groupID: GroupID, title: string) { const group = this.source.g.find((g: FormatBGroup) => g.id === groupID); group.t = title; + this.emit("commandsExecuted"); } setVaultAttribute(key: string, value: string) { @@ -459,5 +479,6 @@ export class VaultFormatB extends VaultFormat { item.value = value; item.updated = getTimestamp(); } + this.emit("commandsExecuted"); } } From d15e641c096955c69aa354cbead569a65d641c86 Mon Sep 17 00:00:00 2001 From: Perry Mitchell Date: Sun, 28 Jan 2024 19:39:03 +0200 Subject: [PATCH 6/8] Add tag finding tests --- test/unit/core/Vault.spec.js | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/unit/core/Vault.spec.js b/test/unit/core/Vault.spec.js index 958ee96a..518eb3d7 100644 --- a/test/unit/core/Vault.spec.js +++ b/test/unit/core/Vault.spec.js @@ -124,6 +124,40 @@ describe("core/Vault", function () { }); }); + describe("findEntriesByTag", function () { + beforeEach(function () { + this.vault = new Vault(); + const group = this.vault.createGroup("test"); + this.entry1 = group.createEntry("one").addTags("alpha", "new", "metal_material"); + this.entry2 = group.createEntry("two").addTags("beta", "new", "metal_finish"); + this.entry3 = group.createEntry("three").addTags("gamma", "old"); + }); + + describe("using exact matching", function () { + it("returns correct results for single-matching tag", function () { + const foundEntries = this.vault.findEntriesByTag("alpha"); + expect(foundEntries).to.have.lengthOf(1); + expect(foundEntries[0].id).to.equal(this.entry1.id); + }); + + it("returns correct results for multi-matching tag", function () { + const foundEntries = this.vault.findEntriesByTag("new"); + expect(foundEntries).to.have.lengthOf(2); + expect(foundEntries[0].id).to.equal(this.entry1.id); + expect(foundEntries[1].id).to.equal(this.entry2.id); + }); + }); + + describe("using partial matching", function () { + it("returns correct results for matching prefix", function () { + const foundEntries = this.vault.findEntriesByTag("metal_", false); + expect(foundEntries).to.have.lengthOf(2); + expect(foundEntries[0].id).to.equal(this.entry1.id); + expect(foundEntries[1].id).to.equal(this.entry2.id); + }); + }); + }); + describe("findGroupByID", function () { beforeEach(function () { this.vault = new Vault(); From dd53b2ce0c9192cb58188d8eb214b0d09ecf4577 Mon Sep 17 00:00:00 2001 From: Perry Mitchell Date: Mon, 29 Jan 2024 17:30:30 +0200 Subject: [PATCH 7/8] Fix tag search --- source/search/BaseSearch.ts | 12 +++++++----- source/search/tags.ts | 6 ++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/source/search/BaseSearch.ts b/source/search/BaseSearch.ts index 0de499a2..bf06f6a6 100644 --- a/source/search/BaseSearch.ts +++ b/source/search/BaseSearch.ts @@ -3,7 +3,7 @@ import { StorageInterface } from "../storage/StorageInterface.js"; import { buildSearcher } from "./searcher.js"; import { Vault } from "../core/Vault.js"; import { EntryID, EntryType, GroupID, VaultFacade, VaultID } from "../types.js"; -import { extractTagsFromSearchTerm } from "./tags.js"; +import { extractTagsFromSearchTerm, tagsMatchSearch } from "./tags.js"; interface DomainScores { [domain: string]: number; @@ -142,15 +142,17 @@ export class BaseSearch { const { tags, term: searchTerm } = extractTagsFromSearchTerm(term); if (tags.length > 0) { // Instantiate new searcher based on a subset of entries - const subset = this._entries.filter((entry) => - entry.tags.some((entryTag) => tags.includes(entryTag)) - ); + const subset = this._entries.filter((entry) => tagsMatchSearch(tags, entry.tags)); + if (searchTerm.trim().length === 0) { + // Tags only, return all entries + return subset; + } this._fuse = this._searcherFactory(subset); } else { // Reset instance this._fuse = this._searcherFactory(this._entries); } - this._results = this._fuse.search(term).map((result) => result.item); + this._results = this._fuse.search(searchTerm).map((result) => result.item); return this._results; } diff --git a/source/search/tags.ts b/source/search/tags.ts index 784ab49f..e71653fd 100644 --- a/source/search/tags.ts +++ b/source/search/tags.ts @@ -18,3 +18,9 @@ export function extractTagsFromSearchTerm(term: string): { term: searchItems.join(" ").trim() }; } + +export function tagsMatchSearch(searchTags: Array, entryTags: Array): boolean { + return searchTags.every((searchTag) => + entryTags.some((entryTag) => entryTag.indexOf(searchTag) === 0) + ); +} From 07ee57a4ed5160306a53fe812932dcb3fa5059d3 Mon Sep 17 00:00:00 2001 From: Perry Mitchell Date: Mon, 29 Jan 2024 17:30:40 +0200 Subject: [PATCH 8/8] Add tests for tag searching --- .../search/VaultEntrySearch.spec.js | 31 ++++++++++++++-- .../search/VaultFacadeEntrySearch.spec.js | 37 ++++++++++++++++--- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/test/integration/search/VaultEntrySearch.spec.js b/test/integration/search/VaultEntrySearch.spec.js index 8b1e4acd..92e7414f 100644 --- a/test/integration/search/VaultEntrySearch.spec.js +++ b/test/integration/search/VaultEntrySearch.spec.js @@ -22,22 +22,26 @@ describe("VaultEntrySearch", function () { .createEntry("Work") .setProperty("username", "j.crowley@gmov.edu.au") .setProperty("password", "#f05c.*skU3") - .setProperty("URL", "gmov.edu.au/portal/auth"); + .setProperty("URL", "gmov.edu.au/portal/auth") + .addTags("job"); groupA .createEntry("Work logs") .setProperty("username", "j.crowley@gmov.edu.au") .setProperty("password", "#f05c.*skU3") - .setProperty("URL", "https://logs.gmov.edu.au/sys30/atc.php"); + .setProperty("URL", "https://logs.gmov.edu.au/sys30/atc.php") + .addTags("job"); const groupB = vault.createGroup("Bank"); groupB .createEntry("MyBank") .setProperty("username", "324654356346") .setProperty("PIN", "1234") - .setAttribute(Entry.Attributes.FacadeType, EntryType.Login); + .setAttribute(Entry.Attributes.FacadeType, EntryType.Login) + .addTags("finance", "banking"); groupB .createEntry("Insurance") .setProperty("username", "testing-user") - .setProperty("URL", "http://test.org/portal-int/login.aspx"); + .setProperty("URL", "http://test.org/portal-int/login.aspx") + .addTags("finance"); const groupC = vault.createGroup("General"); groupC .createEntry("Clipart") @@ -123,6 +127,25 @@ describe("VaultEntrySearch", function () { const [res] = this.search.searchByTerm("Personal Mail"); expect(res).to.have.property("groupID").that.is.a("string"); }); + + it("returns results using a single tag, no search", function () { + const results = this.search.searchByTerm("#job").map((res) => res.properties.title); + expect(results).to.deep.equal(["Work", "Work logs"]); + }); + + it("returns results using multiple tags, no search", function () { + const results = this.search + .searchByTerm("#finance #banking") + .map((res) => res.properties.title); + expect(results).to.deep.equal(["MyBank"]); + }); + + it("returns results using tags and search", function () { + const results = this.search + .searchByTerm("#job logs") + .map((res) => res.properties.title); + expect(results).to.deep.equal(["Work logs"]); + }); }); describe("searchByURL", function () { diff --git a/test/integration/search/VaultFacadeEntrySearch.spec.js b/test/integration/search/VaultFacadeEntrySearch.spec.js index cd35158a..3865b197 100644 --- a/test/integration/search/VaultFacadeEntrySearch.spec.js +++ b/test/integration/search/VaultFacadeEntrySearch.spec.js @@ -1,5 +1,7 @@ import { expect } from "chai"; import { + Entry, + EntryType, Group, MemoryStorageInterface, Vault, @@ -15,26 +17,32 @@ describe("VaultFacadeEntrySearch", function () { .createEntry("Personal Mail") .setProperty("username", "green.monkey@fastmail.com") .setProperty("password", "df98Sm2.109x{91") - .setProperty("url", "https://fastmail.com"); + .setProperty("url", "https://fastmail.com") + .setAttribute(Entry.Attributes.FacadeType, EntryType.Website); groupA .createEntry("Work") .setProperty("username", "j.crowley@gmov.edu.au") .setProperty("password", "#f05c.*skU3") - .setProperty("URL", "gmov.edu.au/portal/auth"); + .setProperty("URL", "gmov.edu.au/portal/auth") + .addTags("job"); groupA .createEntry("Work logs") .setProperty("username", "j.crowley@gmov.edu.au") .setProperty("password", "#f05c.*skU3") - .setProperty("URL", "https://logs.gmov.edu.au/sys30/atc.php"); + .setProperty("URL", "https://logs.gmov.edu.au/sys30/atc.php") + .addTags("job"); const groupB = vault.createGroup("Bank"); groupB .createEntry("MyBank") .setProperty("username", "324654356346") - .setProperty("PIN", "1234"); + .setProperty("PIN", "1234") + .setAttribute(Entry.Attributes.FacadeType, EntryType.Login) + .addTags("finance", "banking"); groupB .createEntry("Insurance") .setProperty("username", "testing-user") - .setProperty("URL", "http://test.org/portal-int/login.aspx"); + .setProperty("URL", "http://test.org/portal-int/login.aspx") + .addTags("finance"); const groupC = vault.createGroup("General"); groupC .createEntry("Clipart") @@ -77,6 +85,25 @@ describe("VaultFacadeEntrySearch", function () { expect(results[2]).to.equal("Wordpress"); }); + it("returns results using a single tag, no search", function () { + const results = this.search.searchByTerm("#job").map((res) => res.properties.title); + expect(results).to.deep.equal(["Work", "Work logs"]); + }); + + it("returns results using multiple tags, no search", function () { + const results = this.search + .searchByTerm("#finance #banking") + .map((res) => res.properties.title); + expect(results).to.deep.equal(["MyBank"]); + }); + + it("returns results using tags and search", function () { + const results = this.search + .searchByTerm("#job logs") + .map((res) => res.properties.title); + expect(results).to.deep.equal(["Work logs"]); + }); + it.skip("excludes trash entries", function () { const results = this.search.searchByTerm("ebay"); expect(results).to.have.lengthOf(0);