import * as fs from '@std/fs'; import { FSDB_INDEXER, FSDB_SEARCH_OPTIONS } from '../fsdb.ts'; import * as path from '@std/path'; import sanitize from '../utils/sanitize.ts'; interface FSDB_INDEXER_SYMLINKS_CONFIG_SHARED { name: string; root?: string; id_field?: string; to_many?: boolean; organize?: (value: string) => string[]; } interface FSDB_INDEXER_SYMLINKS_CONFIG_WITH_FIELD extends FSDB_INDEXER_SYMLINKS_CONFIG_SHARED { field: string; get_values_to_index?: never; } interface FSDB_INDEXER_SYMLINKS_CONFIG_WITH_GET_VALUE extends FSDB_INDEXER_SYMLINKS_CONFIG_SHARED { field?: never; get_values_to_index: (item: T) => string[]; } export type FSDB_INDEXER_SYMLINKS_CONFIG = FSDB_INDEXER_SYMLINKS_CONFIG_WITH_FIELD | FSDB_INDEXER_SYMLINKS_CONFIG_WITH_GET_VALUE; async function cleanup_empty_directories(initial_path: string): Promise { let current_directory: string = path.dirname(path.resolve(initial_path)); let has_children: boolean = false; const removed: string[] = []; do { for await (const _dir_entry of Deno.readDir(current_directory)) { has_children = true; break; } if (!has_children) { const removed_dir = current_directory; await Deno.remove(removed_dir); current_directory = path.dirname(removed_dir); removed.push(removed_dir); if (current_directory === Deno.cwd()) { break; } } } while (!has_children && current_directory.length); return removed; } export class FSDB_INDEXER_SYMLINKS implements FSDB_INDEXER { constructor(private config: FSDB_INDEXER_SYMLINKS_CONFIG) { this.config.id_field = this.config.id_field ?? 'id'; } public set_fsdb_root(root: string) { this.config.root = this.config.root ?? path.resolve(path.join(root, '.indexes/', sanitize(this.config.name))); } private get_values_to_index(item: any): string[] { if (this.config.get_values_to_index) { return this.config.get_values_to_index(item); } const typed_field: any = this.config.field as keyof T; const value: string = item[typed_field]; return [value]; } async lookup(value: string, options?: FSDB_SEARCH_OPTIONS): Promise { if (typeof this.config.root !== 'string') { throw new Error('root should have been set by FSDB instance'); } if (typeof this.config.organize === 'undefined') { throw new Error('symlink indexer must have an organizer set!'); } const results: string[] = []; const organized_paths: string[] = this.config.organize(value); if (organized_paths.length === 0) { return results; } if (this.config.to_many) { const filename: string = organized_paths.pop() ?? ''; // remove filename const parsed_filename = path.parse(filename); organized_paths.push(parsed_filename.name); // add back filename without extension for a directory organized_paths.push('*'); // wildcard to get all references } const limit = options?.limit ?? 100; const offset = options?.offset ?? 0; let counter = 0; const glob_pattern = path.resolve(path.join(this.config.root, ...organized_paths)); for await (const item_file of fs.expandGlob(glob_pattern)) { const file_info: Deno.FileInfo = await Deno.lstat(item_file.path); if (file_info.isSymlink) { if (counter < offset) { ++counter; continue; } const resolved_item_path = await Deno.readLink(item_file.path); results.push(resolved_item_path); ++counter; } if (counter >= (offset + limit)) { break; } } return results; } async index(item: T, authoritative_path: string): Promise { if (typeof this.config.root !== 'string') { throw new Error('root should have been set by FSDB instance'); } if (typeof this.config.organize === 'undefined') { throw new Error('symlink indexer must have an organizer set!'); } const results: string[] = []; const values: string[] = this.get_values_to_index(item); if (values.length === 0) { return results; } for (const value of values) { const organized_paths: string[] = this.config.organize(value); if (organized_paths.length === 0) { continue; } if (this.config.to_many) { const filename: string = organized_paths.pop() ?? ''; // remove filename const parsed_filename = path.parse(filename); organized_paths.push(parsed_filename.name); // add back filename without extension for a directory const item_id: string = item[this.config.id_field as keyof T] as string; if (typeof item_id !== 'string') { continue; } organized_paths.push(`${item_id}.json`); } const symlink_path = path.resolve(path.join(this.config.root, ...organized_paths)); const item_dir = path.dirname(authoritative_path); const reverse_link_path = path.join(item_dir, `.index.symlink.${this.config.name}.${sanitize(value)}`); // clean up old indexes try { const previous_symlink_index_link_path = await Deno.readLink(reverse_link_path); await Deno.remove(previous_symlink_index_link_path); if (this.config.to_many) { await cleanup_empty_directories(previous_symlink_index_link_path); } await Deno.remove(reverse_link_path); } catch (error) { if (!(error instanceof Deno.errors.NotFound)) { throw error; } } // create index symlink and reverse link await Deno.mkdir(path.dirname(symlink_path), { recursive: true }); await fs.ensureSymlink(authoritative_path, symlink_path); await fs.ensureSymlink(symlink_path, reverse_link_path); results.push(symlink_path); } return results; } async remove(item: T, item_path: string): Promise { const results: string[] = []; const values: string[] = this.get_values_to_index(item); const item_dir: string = path.dirname(item_path); for (const value of values) { const item_dir_reverse_link: string = path.join(item_dir, `.index.symlink.${this.config.name}.${sanitize(value)}`); if (!fs.existsSync(item_dir_reverse_link)) { continue; } const index_symlink_path: string = await Deno.readLink(item_dir_reverse_link); if (fs.existsSync(index_symlink_path)) { await Deno.remove(index_symlink_path); results.push(index_symlink_path); } await Deno.remove(item_dir_reverse_link); results.push(...await cleanup_empty_directories(index_symlink_path)); } return results; } }