Skip to content

Commit

Permalink
migrate to adapter pattern
Browse files Browse the repository at this point in the history
  • Loading branch information
brianpetro committed Jun 5, 2024
1 parent f309de1 commit 37f7e5c
Show file tree
Hide file tree
Showing 19 changed files with 649 additions and 284 deletions.
149 changes: 99 additions & 50 deletions smart-collections/Collection.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,28 @@ class Collection {
* Constructs a new Collection instance.
* @param {Object} env - The environment context containing configurations and adapters.
*/
constructor(env) {
constructor(env, opts = {}) {
this.env = env;
this.brain = this.env; // DEPRECATED: use env instead of brain
// this.brain = this.env; // DEPRECATED: use env instead of brain
this.config = this.env.config;
this.items = {};
this.LTM = this.env.ltm_adapter.wake_up(this, this.env.ltm_adapter);
this.opts = opts;
if(this.opts.adapter_class) this.adapter = new opts.adapter_class(this);
this.save_queue = {};
}

// STATIC METHODS
/**
* Loads a collection based on the environment and optional configuration.
* @param {Object} env - The environment context.
* @param {Object} [config={}] - Optional configuration for the collection.
* @returns {Promise<Collection>|Collection} The loaded collection instance.
*/
static load(env, config = {}) {
const { custom_collection_name } = config;
env[this.collection_name] = new this(env);
static load(env, opts = {}) {
if(typeof opts.adapter_class?.load === 'function') return opts.adapter_class.load(env, opts);
// if no static load method in adapter_class, load collection as normal
const { custom_collection_name } = opts;
env[this.collection_name] = new this(env, opts);
if (custom_collection_name) {
env[this.collection_name].collection_name = custom_collection_name;
env.collections[custom_collection_name] = this.constructor;
Expand All @@ -39,46 +44,12 @@ class Collection {
return env[this.collection_name];
}
/**
* Merges default configurations from all classes in the inheritance chain.
*/
merge_defaults() {
let current_class = this.constructor;
while (current_class) { // merge collection config into item config
const col_conf = this.config?.collections?.[current_class.collection_name];
Object.entries((typeof col_conf === 'object') ? col_conf : {})
.forEach(([key, value]) => this[key] = value)
;
current_class = Object.getPrototypeOf(current_class);
}
// console.log(Object.keys(this));
}

/**
* Saves the current state of the collection.
*/
save() { this.LTM.save(); }

/**
* Loads the collection state.
* Gets the collection name derived from the class name.
* @return {String} The collection name.
*/
load() { this.LTM.load(); }
static get collection_name() { return this.name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase(); }

/**
* Revives items from a serialized state.
* @param {string} key - The key of the item.
* @param {*} value - The serialized item value.
* @returns {CollectionItem|*} The revived item or the original value if not an object.
*/
reviver(key, value) {
if (typeof value !== 'object' || value === null) return value; // skip non-objects, quick return
if (value.class_name) return new (this.env.item_types[value.class_name])(this.env, value);
return value;
}
replacer(key, value) {
if (value instanceof this.item_type) return value.data;
if (value instanceof CollectionItem) return value.ref;
return value;
}
// INSTANCE METHODS

/**
* Creates or updates an item in the collection based on the provided data.
Expand Down Expand Up @@ -187,14 +158,12 @@ class Collection {
* @param {String[]} keys - The keys of the items to delete.
*/
delete_many(keys = []) {
keys.forEach((key) => delete this.items[key]);
// keys.forEach((key) => delete this.items[key]);
keys.forEach((key) => {
this.items[key].delete();
});
}
// CONVENIENCE METHODS (namespace getters)
/**
* Gets the collection name derived from the class name.
* @return {String} The collection name.
*/
static get collection_name() { return this.name.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase(); }
/**
* Gets or sets the collection name. If a name is set, it overrides the default name.
* @param {String} name - The new collection name.
Expand All @@ -221,6 +190,86 @@ class Collection {
* @return {Function} The item type constructor.
*/
get item_type() { return this.env.item_types[this.item_class_name]; }

/**
* Gets the data path from the environment.
* @returns {string} The data path.
*/
get data_path() { return this.env.data_path + '/multi'; }

// ADAPTER METHODS
/**
* Saves the current state of the collection.
*/
async save() {
if(typeof this.adapter?.save === 'function') {
await this.adapter.save();
this.save_queue = {};
}
else console.warn("No save method found in adapter");
}
save_sync() {
if(typeof this.adapter?.save_sync === 'function') {
this.adapter.save_sync();
this.save_queue = {};
}
else console.warn("No save_sync method found in adapter");
}

/**
* Loads the collection state.
*/
async load() {
if(typeof this.adapter?.load === 'function') return await this.adapter.load();
else console.warn("No load method found in adapter");
}
load_sync() {
if(typeof this.adapter?.load_sync === 'function') return this.adapter.load_sync();
else console.warn("No load_sync method found in adapter");
}

// BACKWARD COMPATIBILITY
get LTM() { return this.adapter; }

// UTILITY METHODS
/**
* Merges default configurations from all classes in the inheritance chain for Collection types;
* e.g. EntityCollection, NoteCollection, etc.
*/
merge_defaults() {
let current_class = this.constructor;
while (current_class) { // merge collection config into item config
const col_conf = this.config?.collections?.[current_class.collection_name];
Object.entries((typeof col_conf === 'object') ? col_conf : {})
.forEach(([key, value]) => this[key] = value)
;
current_class = Object.getPrototypeOf(current_class);
}
// console.log(Object.keys(this));
}



// CHOPPING BLOCK
// May be moved to adapter class or removed

/**
* Revives items from a serialized state.
* @param {string} key - The key of the item.
* @param {*} value - The serialized item value.
* @returns {CollectionItem|*} The revived item or the original value if not an object.
*/
reviver(key, value) {
if (typeof value !== 'object' || value === null) return value; // skip non-objects, quick return
if (value.class_name) return new (this.env.item_types[value.class_name])(this.env, value);
return value;
}
replacer(key, value) {
if (value instanceof this.item_type) return value.data;
if (value instanceof CollectionItem) return value.ref;
return value;
}

}
exports.Collection = Collection;

Expand Down
18 changes: 17 additions & 1 deletion smart-collections/CollectionItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ class CollectionItem {
this.config = this.env?.config;
this.merge_defaults();
if (data) this.data = data;
// only keep this.embeddings[this.embed_model], remove other embeddings
if (this.data.embeddings) {
for (let key in this.data.embeddings) {
if (key !== this.embed_model) delete this.data.embeddings[key];
}
if(this.data.embedding) delete this.data.embedding;
}
if(!this.data.class_name) this.data.class_name = this.constructor.name;
}

Expand Down Expand Up @@ -96,8 +103,10 @@ class CollectionItem {
return console.error("Invalid save: ", { data: this.data, stack: new Error().stack });
}
this.collection.set(this); // set entity in collection
this.queue_save();
this.collection.save(); // save collection
}
queue_save() { this.collection.save_queue[this.key] = true; }

/**
* Validates the item's data before saving.
Expand All @@ -113,7 +122,12 @@ class CollectionItem {
/**
* Deletes the item from its collection.
*/
delete() { this.collection.delete(this.key); }
delete() {
this.deleted = true;
this.queue_save();
// this.data = undefined;
// delete this.collection.items[this.key];
}

// functional filter (returns true or false) for filtering items in collection; called by collection class
/**
Expand All @@ -126,12 +140,14 @@ class CollectionItem {
exclude_key,
exclude_keys = exclude_key ? [exclude_key] : [],
exclude_key_starts_with,
exclude_key_starts_with_any,
key_ends_with,
key_starts_with,
key_starts_with_any,
} = opts;
if (exclude_keys?.includes(this.key)) return false;
if (exclude_key_starts_with && this.key.startsWith(exclude_key_starts_with)) return false;
if (exclude_key_starts_with_any && exclude_key_starts_with_any.some((prefix) => this.key.startsWith(prefix))) return false;
if (key_ends_with && !this.key.endsWith(key_ends_with)) return false;
if (key_starts_with && !this.key.startsWith(key_starts_with)) return false;
if (key_starts_with_any && !key_starts_with_any.some((prefix) => this.key.startsWith(prefix))) return false;
Expand Down
129 changes: 4 additions & 125 deletions smart-collections/ObsAJSON.js
Original file line number Diff line number Diff line change
@@ -1,128 +1,7 @@
const { LongTermMemory } = require('./long_term_memory');
/**
* Class ObsAJSON extends LongTermMemory to handle JSON-based storage of collections.
* This class provides methods to load and save collections to a JSON file with asynchronous operations,
* ensuring data integrity and error handling. It uses an adapter to interact with the file system.
*/
class ObsAJSON extends LongTermMemory {
/**
* Constructs an instance of ObsAJSON.
* @param {Object} collection - The collection to be managed.
*/
constructor(collection) {
super(collection);
this.adapter = this.env.main.app.vault.adapter;
// DEPRECATED: file for backward compatibility with Smart Collections v2.1
class ObsAJSON {
constructor(env) {
this.env = env;
}

/**
* Asynchronously loads the collection from a JSON file.
* Parses the file content and initializes collection items based on the stored data.
* Handles file not found errors by creating necessary directories and files.
*/
async load() {
console.log("Loading: " + this.file_path);
try {
(await this.adapter.read(this.file_path))
.split(",\n")
.filter(batch => batch) // remove empty strings
.forEach((batch, i) => {
const items = JSON.parse(`{${batch}}`);
Object.entries(items).forEach(([key, value]) => {
this.collection.items[key] = new (this.env.item_types[value.class_name])(this.env, value);
});
})
;
console.log("Loaded: " + this.file_name);
} catch (err) {
console.log("Error loading: " + this.file_path);
console.log(err.stack); // stack trace
// Create folder and file if they don't exist
if (err.code === 'ENOENT') {
this.items = {};
// this.keys = []; // replaced by getter
try {
await this.adapter.mkdir(this.data_path);
await this.adapter.write(this.file_path, "");
} catch (creationErr) {
console.log("Failed to create folder or file: ", creationErr);
}
}
}
}

// wraps _save in timeout to prevent multiple saves at once
save() {
if(this.save_timeout) clearTimeout(this.save_timeout);
this.save_timeout = setTimeout(() => { this._save(); }, 10000);
}

/**
* Saves the collection to a JSON file. This method is throttled to prevent multiple saves at once.
* @param {boolean} [force=false] - Forces the save operation even if currently saving.
*/
async _save(force=false) {
if(this.save_timeout) clearTimeout(this.save_timeout);
this.save_timeout = null;
if(this._saving) return console.log("Already saving: " + this.file_name);
this._saving = true; // prevent multiple saves at once
setTimeout(() => { this._saving = false; }, 10000); // set _saving to false after 10 seconds
const start = Date.now();
console.log("Saving: " + this.file_name);
// rename temp file
const temp_file_path = this.file_path.replace('.ajson', '.temp.ajson');
if(await this.adapter.exists(temp_file_path)) await this.adapter.remove(temp_file_path);
try {
// init temp file
await this.adapter.write(temp_file_path, "");
let file_content = [];
const items = Object.values(this.items).filter(i => i.vec);
const batches = Math.ceil(items.length / 1000);
for(let i = 0; i < batches; i++) {
file_content = items.slice(i * 1000, (i + 1) * 1000).map(i => i.ajson);
const batch_content = file_content.join(",");
await this.adapter.append(temp_file_path, batch_content + ",\n");
}
// append last batch
if(items.length > batches * 1000) {
await this.adapter.append(temp_file_path, items.slice(batches * 1000).map(i => i.ajson).join(",") + ",\n");
}
const end = Date.now(); // log time
const time = end - start;
if(force || await this.validate_save(temp_file_path, this.file_path)) {
if(await this.adapter.exists(this.file_path)) await this.adapter.remove(this.file_path);
await this.adapter.rename(temp_file_path, this.file_path);
console.log("Saved " + this.file_name + " in " + time + "ms");
}else{
console.log("Not saving " + this.file_name + " because new file is less than 50% of old file");
}
} catch (err) {
console.error("Error saving: " + this.file_name);
console.error(err.stack);
// set new file to "failed" and rename to inlclude datetime
const failed_file_path = temp_file_path.replace('.temp.', '.failed-' + Date.now() + '.');
await this.adapter.rename(temp_file_path, failed_file_path);
}
this._saving = false;
// remove temp file after new file is saved
if(await this.adapter.exists(temp_file_path) && await this.adapter.exists(this.file_path)) await this.adapter.remove(temp_file_path);
}

/**
* Validates the new file size against the old file size to ensure data integrity.
* @param {string} new_file_path - Path to the new file.
* @param {string} old_file_path - Path to the old file.
* @returns {Promise<boolean>} True if the new file size is more than 50% of the old file size, otherwise false.
*/
async validate_save(new_file_path, old_file_path) {
const new_file_size = (await this.adapter.stat(new_file_path))?.size;
const old_file_size = (await this.adapter.stat(old_file_path))?.size;
if(!old_file_size) return true;
console.log("New file size: " + new_file_size + " bytes");
console.log("Old file size: " + old_file_size + " bytes");
return new_file_size > (old_file_size * 0.5);
}

get file_name() { return super.file_name + '.ajson'; }
}

exports.ObsAJSON = ObsAJSON;
Loading

0 comments on commit 37f7e5c

Please sign in to comment.