Files
webmail/lib/files-trash.ts
T

95 lines
3.5 KiB
TypeScript

import type { FileNode } from '@/lib/jmap/types';
import { fileNodeKind } from '@/lib/jmap/filenode-kind';
/*
* The Files trash. Neither Stalwart 0.16 nor 1.0 keeps deleted FileNodes:
* FileNode/set destroy removes a node and everything below it for good (1.0
* can label a folder `role: "trash"`, 0.16 ignores the role, and neither moves
* anything there). So the trash is a folder the app manages: `.Trash` at the
* root, hidden from every listing. Each delete puts its items into a folder
* of its own inside it, named after when it happened and where the items came
* from, so Restore can put them back and two deleted files of the same name
* never clash:
*
* /.Trash/1730112000000~<parentId>/report.pdf
*
* The name is the record, so a WebDAV client or another device sees the same
* trash, and nothing else has to be stored.
*/
export const TRASH_FOLDER_NAME = '.Trash';
/** The trash as a place in the Files view. JMAP ids never contain "*". */
export const TRASH_LOCATION = '*trash';
/** The trash's path, for links and the breadcrumb. */
export const TRASH_PATH = `/${TRASH_FOLDER_NAME}`;
export function isTrashLocation(id: string | null | undefined): boolean {
return id === TRASH_LOCATION;
}
/** The trash folder of the account's own drive, if there is one yet. */
export function findTrashFolder(nodes: FileNode[]): FileNode | undefined {
return nodes.find(n =>
(n.parentId ?? null) === null
&& n.name === TRASH_FOLDER_NAME
&& !n.isShared
&& !n.id.includes(':')
&& fileNodeKind(n) === 'directory');
}
/** The name of the folder one delete's items go into. */
export function trashBatchName(parentId: string | null, deletedAt: number = Date.now()): string {
return `${deletedAt}~${parentId ?? ''}`;
}
/** When a batch was deleted and from which folder (null: the root); null for any other name. */
export function parseTrashBatchName(name: string): { deletedAt: number; parentId: string | null } | null {
const match = /^(\d{10,})~([A-Za-z0-9_-]*)$/.exec(name);
if (!match) return null;
return { deletedAt: Number(match[1]), parentId: match[2] || null };
}
/** The trash folder and everything in it, to keep out of normal listings. */
export function trashSubtreeIds(nodes: FileNode[], trashId: string | undefined): Set<string> {
const ids = new Set<string>();
if (!trashId) return ids;
ids.add(trashId);
let grew = true;
while (grew) {
grew = false;
for (const n of nodes) {
if (n.parentId && ids.has(n.parentId) && !ids.has(n.id)) {
ids.add(n.id);
grew = true;
}
}
}
return ids;
}
export interface TrashedItem {
node: FileNode;
/** The delete's folder in the trash; null for something put into the trash directly. */
batchId: string | null;
deletedAt: number | null;
/** Where it came from; null for the root or when unknown. */
fromParentId: string | null;
}
/** What the trash holds, one entry per deleted item. */
export function trashedItems(nodes: FileNode[], trash: FileNode | undefined): TrashedItem[] {
if (!trash) return [];
const items: TrashedItem[] = [];
for (const child of nodes.filter(n => n.parentId === trash.id)) {
const batch = fileNodeKind(child) === 'directory' ? parseTrashBatchName(child.name) : null;
if (!batch) {
items.push({ node: child, batchId: null, deletedAt: null, fromParentId: null });
continue;
}
for (const node of nodes.filter(n => n.parentId === child.id)) {
items.push({ node, batchId: child.id, deletedAt: batch.deletedAt, fromParentId: batch.parentId });
}
}
return items;
}