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 : }
|