A bot's folder chip went to bare /admin/files instead of the folder it named, and even once there the computer volume pointed at the wrong directory, so the folder a bot linked to looked empty. The chip lost its folder because folderRefUrl only accepted a ref's volume name (computer) while the chip hands it the browser's (computers), so storageVolumeFromRef found nothing and the whole query was dropped. Both spellings resolve now, through parseStorageSpec, so a bad owner falls back cleanly instead of emitting owner=0. The deeper bug: a computer's home was assumed to be DATA_DIR/computers/<id>, but on a bind mount it can be anywhere on the docker host, and a computer the app didn't provision (the main one on a server reached at MAIN_COMPUTER_URL, i.e. a dev server outside docker) records nothing at all. resolveComputerHomeDir holds the precedence: the directory provisionComputer actually bound in (containers.metadata.data_dir), else the new MAIN_COMPUTER_DATA_DIR app setting for the main computer, else the conventional folder. A path that isn't on disk also no longer reads as an empty folder: listStorageEntries reports it as missing and the page says so, so a stale folder link can't look like a folder nothing has written yet.
119 lines
3.5 KiB
TypeScript
119 lines
3.5 KiB
TypeScript
/**
|
|
* How a bot names a folder on the app's data volume in a message:
|
|
* `[folder:bot:3:reports]`, `[folder:computer:3:seo/coderank/raw]`,
|
|
* `[folder:workspace:2]`.
|
|
*
|
|
* A folder ref is always a folder: the chat turns it into a chip that opens
|
|
* the files browser (`/admin/files`) at that folder, and nothing is
|
|
* downloaded. A file the bot saved is named with a `[file:ID]` ref instead
|
|
* (`fileRef`), which is what downloads. There is no guessing from the
|
|
* name's extension: the ref says which one it is.
|
|
*/
|
|
import {
|
|
isStorageVolume,
|
|
parseStorageSpec,
|
|
StorageBrowserPath,
|
|
storageSpecHref,
|
|
storageVolumeFromRef,
|
|
type StorageSpec,
|
|
} from "./storage-volumes";
|
|
|
|
/**
|
|
* The link target a folder ref becomes, e.g.
|
|
* `#folder-bot%3A3%3Areports`.
|
|
*/
|
|
export const FolderRefHrefPrefix = "#folder-";
|
|
|
|
type RefParams = {
|
|
/**
|
|
* The volume by the name a ref uses: `bot`, `workspace`, `computer`,
|
|
* `media`, `mcp` or `db`.
|
|
*/
|
|
volume: string;
|
|
owner_id: number | string;
|
|
path?: string | null;
|
|
};
|
|
|
|
function cleanPath({ path }: { path?: string | null }) {
|
|
return (path || "").trim().replace(/^\/+|\/+$/g, "");
|
|
}
|
|
|
|
/**
|
|
* How a bot names a folder in a message. The path is written as it is; the
|
|
* chat encodes it into the link.
|
|
*/
|
|
export default function folderRef({ volume, owner_id, path }: RefParams) {
|
|
const clean = cleanPath({ path });
|
|
|
|
return clean
|
|
? `[folder:${volume}:${owner_id}:${clean}]`
|
|
: `[folder:${volume}:${owner_id}]`;
|
|
}
|
|
|
|
/**
|
|
* The href a folder ref becomes: everything after `#folder-`, URI encoded,
|
|
* so spaces, `#` and `:` in a path survive markdown.
|
|
*/
|
|
export function folderRefHref({ volume, owner_id, path }: RefParams) {
|
|
const clean = cleanPath({ path });
|
|
|
|
return `${FolderRefHrefPrefix}${encodeURIComponent(
|
|
clean ? `${volume}:${owner_id}:${clean}` : `${volume}:${owner_id}`,
|
|
)}`;
|
|
}
|
|
|
|
/**
|
|
* Reads a folder ref's link back into a folder and a path inside it, or
|
|
* null when it names a volume that isn't one (or has no owner).
|
|
*/
|
|
export function parseFolderRefHref({
|
|
href,
|
|
}: {
|
|
href?: string | null;
|
|
}): { spec: StorageSpec; path: string } | null {
|
|
if (!href?.startsWith(FolderRefHrefPrefix)) return null;
|
|
|
|
let decoded = "";
|
|
|
|
try {
|
|
decoded = decodeURIComponent(href.slice(FolderRefHrefPrefix.length));
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const [name, raw_owner, ...rest] = decoded.split(":");
|
|
const volume = storageVolumeFromRef({ name });
|
|
const owner_id = Number(raw_owner);
|
|
|
|
if (!volume) return null;
|
|
|
|
if (!Number.isFinite(owner_id) || owner_id < 0) return null;
|
|
|
|
return {
|
|
spec: { volume, owner_id: Math.trunc(owner_id) },
|
|
path: rest.join(":").replace(/^\/+|\/+$/g, ""),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Where a folder ref opens: the files browser, on that folder. A volume the
|
|
* browser doesn't know lands on its first folder.
|
|
*
|
|
* Both spellings of a volume come through here: a ref names it the way a
|
|
* bot writes it (`computer`), the files browser the way it stores it
|
|
* (`computers`). Either one resolves, so a chip never loses its folder and
|
|
* drops back to the files browser's first folder.
|
|
*/
|
|
export function folderRefUrl({ volume, owner_id, path }: RefParams) {
|
|
const from_ref = storageVolumeFromRef({ name: volume });
|
|
const known = from_ref || (isStorageVolume(volume) ? volume : null);
|
|
|
|
if (!known) return StorageBrowserPath;
|
|
|
|
const spec = parseStorageSpec({ volume: known, owner_id });
|
|
|
|
if (!spec) return StorageBrowserPath;
|
|
|
|
return storageSpecHref({ spec, path });
|
|
}
|