LCOV - code coverage report
Current view: top level - dashql-app/src/storage - session_tree.ts Coverage Total Hit
Test: DashQL 915828b Lines: 97.9 % 146 143
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import type * as app_folder from '@ankoh/dashql-jsonschema/app_folder.js';
       2              : 
       3              : /// The folder of the session tree, it registers every session, e.g. `sessiontree/clients/acme/dashql-folder.json`
       4            1 : export const STORAGE_SESSION_TREE_FOLDER = 'sessiontree';
       5              : /// The file of a folder of the session tree with the sessions of the folder
       6            1 : export const STORAGE_FOLDER_FILE = 'dashql-folder.json';
       7              : /// The folder that a new storage starts with, the migration moves the sessions of older storages into it
       8            1 : export const INITIAL_SESSION_FOLDER: readonly string[] = ['My Sessions'];
       9              : 
      10              : export type SessionEntry = app_folder.SessionEntry;
      11              : export type SessionFolderFile = app_folder.SessionFolder;
      12              : 
      13              : /// A folder of the session tree.
      14              : ///
      15              : /// The directories of `sessiontree/` are the folders of the sidebar, the directory name is the folder name.
      16              : /// The subfolders come before the sessions, in the natural order of their names, the sessions keep the order
      17              : /// of their folder file.
      18              : export interface SessionTreeFolder {
      19              :     /// The folder name, empty for the root
      20              :     name: string;
      21              :     /// The folder names from the root, empty for the root
      22              :     path: string[];
      23              :     /// The subfolders, in the natural order of their names
      24              :     folders: SessionTreeFolder[];
      25              :     /// The sessions of the folder, in the order of the sidebar
      26              :     sessions: SessionEntry[];
      27              : }
      28              : 
      29              : /// The tree without folders and sessions
      30            1 : export function emptySessionTree(): SessionTreeFolder {
      31            1 :     return { name: '', path: [], folders: [], sessions: [] };
      32            1 : }
      33              : 
      34              : /// Natural order of folder names (e.g. "q2" < "q10")
      35            1 : export function compareFolderNames(left: string, right: string): number {
      36            1 :     return left.localeCompare(right, undefined, { numeric: true, sensitivity: 'base' });
      37            1 : }
      38              : 
      39              : /// Are two folder paths the same?
      40            1 : export function sameFolderPath(left: readonly string[], right: readonly string[]): boolean {
      41            1 :     return left.length === right.length && left.every((name, index) => name === right[index]);
      42            1 : }
      43              : 
      44              : /// Is a folder path the path of a folder or of one of its subfolders?
      45            1 : export function isFolderPathWithin(path: readonly string[], ancestor: readonly string[]): boolean {
      46            1 :     return path.length >= ancestor.length && ancestor.every((name, index) => name === path[index]);
      47            1 : }
      48              : 
      49              : /// The sessions of a tree in the order of the sidebar: the subfolders of a folder before its sessions
      50            1 : export function flattenSessionTree(folder: SessionTreeFolder): SessionEntry[] {
      51            1 :     const entries: SessionEntry[] = [];
      52            1 :     const visit = (current: SessionTreeFolder) => {
      53            1 :         for (const child of current.folders) visit(child);
      54            1 :         entries.push(...current.sessions);
      55            1 :     };
      56            1 :     visit(folder);
      57            1 :     return entries;
      58            1 : }
      59              : 
      60              : /// The folder at a path, null if there is none
      61            1 : export function folderAt(tree: SessionTreeFolder, path: readonly string[]): SessionTreeFolder | null {
      62            1 :     let current: SessionTreeFolder | undefined = tree;
      63            1 :     for (const name of path) {
      64            1 :         current = current.folders.find(folder => folder.name === name);
      65            1 :         if (current == null) return null;
      66            1 :     }
      67            1 :     return current;
      68            1 : }
      69              : 
      70              : /// The path of the folder that holds a session, null if the tree doesn't register it
      71            1 : export function findSessionFolder(tree: SessionTreeFolder, sessionId: string): string[] | null {
      72            1 :     if (tree.sessions.some(entry => entry.path === sessionId)) return tree.path;
      73            1 :     for (const folder of tree.folders) {
      74            1 :         const path = findSessionFolder(folder, sessionId);
      75            1 :         if (path != null) return path;
      76            1 :     }
      77            1 :     return null;
      78            1 : }
      79              : 
      80              : /// Why a name can't name a new folder next to its siblings, null if it can.
      81              : /// The names are compared without case, so that folders stay apart on file systems that ignore it.
      82            1 : export function folderNameError(name: string, siblings: readonly string[]): string | null {
      83            1 :     if (name.length === 0) return 'The folder needs a name';
      84            1 :     if (name === '.' || name === '..' || /[/\\\0]/.test(name)) return `"${name}" is not a valid folder name`;
      85            1 :     const lower = name.toLowerCase();
      86            1 :     if (siblings.some(sibling => sibling.toLowerCase() === lower)) return `There is already a folder "${name}"`;
      87            1 :     return null;
      88            1 : }
      89              : 
      90              : /// Drop the later registrations of sessions that the tree registers twice, the first one in tree order wins.
      91              : /// A folder move that is interrupted between its copy and its delete leaves such duplicates.
      92            1 : export function dropDuplicateSessions(tree: SessionTreeFolder): SessionTreeFolder {
      93            1 :     const seen = new Set<string>();
      94            1 :     const visit = (folder: SessionTreeFolder): SessionTreeFolder => {
      95            1 :         const folders = folder.folders.map(visit);
      96            1 :         const sessions = folder.sessions.filter(entry => {
      97            1 :             if (seen.has(entry.path)) return false;
      98            1 :             seen.add(entry.path);
      99            1 :             return true;
     100            1 :         });
     101            1 :         return { ...folder, folders, sessions };
     102            1 :     };
     103            1 :     return visit(tree);
     104            1 : }
     105              : 
     106              : /// Replace the folder at a path, the folders on the way are copied
     107            1 : function replaceFolder(tree: SessionTreeFolder, path: readonly string[], replace: (folder: SessionTreeFolder) => SessionTreeFolder): SessionTreeFolder {
     108            1 :     if (path.length === 0) return replace(tree);
     109            1 :     const [head, ...rest] = path;
     110            1 :     return {
     111            1 :         ...tree,
     112            1 :         folders: tree.folders.map(folder => folder.name === head ? replaceFolder(folder, rest, replace) : folder),
     113            1 :     };
     114            1 : }
     115              : 
     116              : /// Remove a session from every folder of the tree
     117            1 : export function removeSessionFromTree(tree: SessionTreeFolder, sessionId: string): SessionTreeFolder {
     118            1 :     const visit = (folder: SessionTreeFolder): SessionTreeFolder => ({
     119            1 :         ...folder,
     120            1 :         folders: folder.folders.map(visit),
     121            1 :         sessions: folder.sessions.filter(entry => entry.path !== sessionId),
     122            1 :     });
     123            1 :     return visit(tree);
     124            1 : }
     125              : 
     126              : /// Insert or replace the entry of a session.
     127              : /// A registered session keeps its place, a new one is appended to the folder at `folderPath`, or to the root
     128              : /// if there is no such folder.
     129            1 : export function upsertSessionInTree(tree: SessionTreeFolder, entry: SessionEntry, folderPath: readonly string[] = []): SessionTreeFolder {
     130            1 :     const current = findSessionFolder(tree, entry.path);
     131            1 :     if (current != null) {
     132            1 :         return replaceFolder(tree, current, folder => ({
     133            1 :             ...folder,
     134            1 :             sessions: folder.sessions.map(existing => existing.path === entry.path ? entry : existing),
     135            1 :         }));
     136              :     }
     137            1 :     const target = folderAt(tree, folderPath) != null ? folderPath : [];
     138            1 :     return replaceFolder(tree, target, folder => ({ ...folder, sessions: [...folder.sessions, entry] }));
     139            1 : }
     140              : 
     141              : /// Move a session to a position in a folder. The index counts the sessions of the target folder without the
     142              : /// moved session, an index past the end appends. A session that the tree doesn't register yet is placed as an
     143              : /// OPFS session, e.g. a new session that is created in a folder before its first write registers it.
     144              : /// Returns the tree unchanged if there is no folder at the path.
     145            1 : export function moveSessionInTree(tree: SessionTreeFolder, sessionId: string, folderPath: readonly string[], index: number): SessionTreeFolder {
     146            1 :     if (folderAt(tree, folderPath) == null) return tree;
     147            1 :     const from = findSessionFolder(tree, sessionId);
     148            1 :     const entry = from == null
     149            1 :         ? { path: sessionId }
     150            1 :         : folderAt(tree, from)!.sessions.find(session => session.path === sessionId)!;
     151            1 :     const removed = removeSessionFromTree(tree, sessionId);
     152            1 :     return replaceFolder(removed, folderPath, folder => {
     153            1 :         const sessions = [...folder.sessions];
     154            1 :         sessions.splice(Math.max(0, Math.min(index, sessions.length)), 0, entry);
     155            1 :         return { ...folder, sessions };
     156            1 :     });
     157            1 : }
     158              : 
     159              : /// Every folder of a tree with its path, the root included
     160            1 : export function listFolders(tree: SessionTreeFolder): SessionTreeFolder[] {
     161            1 :     const folders: SessionTreeFolder[] = [];
     162            1 :     const visit = (folder: SessionTreeFolder) => {
     163            1 :         folders.push(folder);
     164            1 :         for (const child of folder.folders) visit(child);
     165            1 :     };
     166            1 :     visit(tree);
     167            1 :     return folders;
     168            1 : }
     169              : 
     170              : /// The key of a folder path, e.g. for maps of folders
     171            1 : export function folderPathKey(path: readonly string[]): string {
     172            1 :     return JSON.stringify(path);
     173            1 : }
     174              : 
     175              : /// Read the session tree below a directory, `sessiontree/` of the OPFS root.
     176              : /// A directory without a folder file is a folder without sessions, entries without a session id are skipped.
     177            1 : export async function readSessionTreeDirectory(dir: FileSystemDirectoryHandle, path: string[] = []): Promise<SessionTreeFolder> {
     178            1 :     const folders: SessionTreeFolder[] = [];
     179            1 :     let sessions: SessionEntry[] = [];
     180            1 :     for await (const [name, handle] of dir.entries()) {
     181            1 :         if (handle.kind === 'directory') {
     182            1 :             folders.push(await readSessionTreeDirectory(handle as FileSystemDirectoryHandle, [...path, name]));
     183            1 :         } else if (name === STORAGE_FOLDER_FILE) {
     184            1 :             sessions = parseSessionFolderFile(await (await (handle as FileSystemFileHandle).getFile()).text(), path);
     185              :         }
     186              :     }
     187            1 :     folders.sort((left, right) => compareFolderNames(left.name, right.name));
     188            1 :     return { name: path[path.length - 1] ?? '', path, folders, sessions };
     189            1 : }
     190              : 
     191              : /// Read the session tree of a storage root, the empty tree if it has none
     192            1 : export async function readSessionTree(root: FileSystemDirectoryHandle): Promise<SessionTreeFolder> {
     193            1 :     let dir: FileSystemDirectoryHandle;
     194            1 :     try {
     195            1 :         dir = await root.getDirectoryHandle(STORAGE_SESSION_TREE_FOLDER, { create: false });
     196            1 :     } catch (error) {
     197            1 :         if ((error as DOMException).name === 'NotFoundError') return emptySessionTree();
     198            1 :         throw error;
     199            1 :     }
     200            1 :     return dropDuplicateSessions(await readSessionTreeDirectory(dir));
     201            1 : }
     202              : 
     203              : /// Parse a folder file
     204            1 : function parseSessionFolderFile(text: string, path: readonly string[]): SessionEntry[] {
     205            1 :     const data: unknown = JSON.parse(text);
     206            1 :     const sessions = (data as Partial<SessionFolderFile> | null)?.sessions;
     207            1 :     if (!Array.isArray(sessions)) {
     208            1 :         throw new Error(`Invalid ${STORAGE_SESSION_TREE_FOLDER}/${[...path, STORAGE_FOLDER_FILE].join('/')}: sessions must be an array`);
     209              :     }
     210            1 :     return sessions.filter((entry): entry is SessionEntry => typeof entry?.path === 'string' && entry.path.length > 0);
     211            1 : }
     212              : 
     213              : /// A free name for a new folder next to its siblings: "New folder", "New folder 2", …
     214            1 : export function freeFolderName(siblings: readonly string[]): string {
     215            1 :     const taken = new Set(siblings.map(name => name.toLowerCase()));
     216            1 :     if (!taken.has('new folder')) return 'New folder';
     217            0 :     let n = 2;
     218            0 :     while (taken.has(`new folder ${n}`)) ++n;
     219            0 :     return `New folder ${n}`;
     220            1 : }
        

Generated by: LCOV version 2.0-1