Repository: buttercup/buttercup-core (ts)

Issue:
[feature request] tag as addition or instead of folders/groups
Hi, thanks for buttercup
I would like to have tags and an easy option to filter entrys by these tags in buttercup
would this be possible to implement?

I think tags are in some cases ways easier to use than folders, because i can filter for multiple tags and only entrys with all of these tags appear.
For example I have multiple entries with the tag "server" and some of them with "email" and others with "cms". Then I could easily filter for all entrys containg one or multiple of these tags.

Pull request description:
Tags
This PR adds support for entry tags. They can be added and removed, as well as used in searches for entries.

Closes #224

Patch:
diff --git a/source/core/Entry.ts b/source/core/Entry.ts
index 555b65c..f2398e6 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,24 @@ 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<string>): this {
+        const current = new Set(this.getTags());
+        for (const tag of tags) {
+            const tagLower = tag.toLowerCase();
+            if (!isValidTag(tagLower)) {
+                throw new Error(`Cannot add entry tag: Invalid format: ${tagLower}`);
+            }
+            current.add(tagLower);
+        }
+        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 +128,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 +211,22 @@ export class Entry extends VaultItem {
         }, {});
     }
 
+    /**
+     * Get entry tags
+     * @returns An array of tag strings
+     */
+    getTags(): Array<string> {
+        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 +268,21 @@ export class Entry extends VaultItem {
         return this;
     }
 
+    /**
+     * Remove one or more tags
+     * @param tags Collection of tags to remove
+     * @returns Self
+     */
+    removeTags(...tags: Array<string>): this {
+        const current = new Set(this.getTags());
+        for (const tag of tags) {
+            const tagLower = tag.toLowerCase();
+            current.delete(tagLower);
+        }
+        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/core/Vault.ts b/source/core/Vault.ts
index 43ef18d..5a9b292 100644
--- a/source/core/Vault.ts
+++ b/source/core/Vault.ts
@@ -59,6 +59,8 @@ export class Vault extends EventEmitter {
 
     _onCommandExec: () => void;
 
+    _tagMap: Map<string, Array<EntryID>> = new Map();
+
     /**
      * The vault format
      * @readonly
@@ -171,6 +173,31 @@ export class Vault extends EventEmitter {
         return findEntriesByProperty(this._entries, property, value);
     }
 
+    /**
+     * 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<Entry> {
+        const tagLower = tag.toLowerCase();
+        if (!exact) {
+            const entryIDs = new Set<string>();
+            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) : [];
+        console.log("SEARCHING", entryIDs, this._tagMap);
+        return entryIDs.map((id) => this.findEntryByID(id));
+    }
+
     /**
      * Find a group by its ID
      * @param id The group ID to search for
@@ -209,6 +236,14 @@ export class Vault extends EventEmitter {
         return [...this._groups];
     }
 
+    /**
+     * Get all registered entry tags
+     * @returns An array of tag strings
+     */
+    getAllTags(): Array<string> {
+        return [...this._tagMap.keys()];
+    }
+
     /**
      * Get the value of an attribute
      * @param attributeName The attribute to get
@@ -275,6 +310,24 @@ export class Vault extends EventEmitter {
                 this._entries.push(new Entry(this, rawEntry));
             }
         });
+        this._rebuildTags();
+    }
+
+    _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);
+            }
+        });
     }
 
     /**
diff --git a/source/facades/entry.ts b/source/facades/entry.ts
index 3b80ac4..726ffdb 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<EntryFacadeField>): {
+    [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<EntryFacadeField>): {
     [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/io/VaultFormatB.ts b/source/io/VaultFormatB.ts
index 81168d5..390678f 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<FormatBEntry> {
@@ -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");
     }
 }
diff --git a/source/search/BaseSearch.ts b/source/search/BaseSearch.ts
index 5b94c1e..bf06f6a 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, tagsMatchSearch } 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<string>;
     urls: Array<string>;
     vaultID: VaultID;
 }
@@ -137,7 +139,20 @@ export class BaseSearch {
         if (!this._fuse) {
             throw new Error("Searching interface not prepared");
         }
-        this._results = this._fuse.search(term).map((result) => result.item);
+        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) => 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(searchTerm).map((result) => result.item);
         return this._results;
     }
 
diff --git a/source/search/VaultEntrySearch.ts b/source/search/VaultEntrySearch.ts
index 2fa35a4..57aa741 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 a5becc6..fc29f0f 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<ProcessedSearchEntry>, 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 0000000..e71653f
--- /dev/null
+++ b/source/search/tags.ts
@@ -0,0 +1,26 @@
+export function extractTagsFromSearchTerm(term: string): {
+    tags: Array<string>;
+    term: string;
+} {
+    const searchItems: Array<string> = [];
+    const tags = new Set<string>();
+    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()
+    };
+}
+
+export function tagsMatchSearch(searchTags: Array<string>, entryTags: Array<string>): boolean {
+    return searchTags.every((searchTag) =>
+        entryTags.some((entryTag) => entryTag.indexOf(searchTag) === 0)
+    );
+}
diff --git a/source/tools/tag.ts b/source/tools/tag.ts
new file mode 100644
index 0000000..4b15d02
--- /dev/null
+++ b/source/tools/tag.ts
@@ -0,0 +1,3 @@
+export function isValidTag(tag: string): boolean {
+    return /^[a-zA-Z0-9_]+$/.test(tag);
+}


Test patch:
diff --git a/test/integration/search/VaultEntrySearch.spec.js b/test/integration/search/VaultEntrySearch.spec.js
index 8b1e4ac..92e7414 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 cd35158..3865b19 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);
diff --git a/test/unit/core/Entry.spec.js b/test/unit/core/Entry.spec.js
index 9609063..cbac4e3 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");
diff --git a/test/unit/core/Vault.spec.js b/test/unit/core/Vault.spec.js
index 958ee96..518eb3d 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();