file-presentation.ts6.0 KBView on GitHub
import type { FileEditor, FileKind, FileListItem } from '@/modules/files/components/list/types';

/**
 * The adapter: a stored node → a row.
 *
 * Every file surface has its own idea of what a node is — `FsNode` in the store, `TreeNode`
 * in the conversation tree, `DriveNode` from Google, `AttachedFile` from mail sync,
 * `AgentOutputFile` from an agent. Rather than teach `FileListRow` about five shapes, each
 * surface maps into `FileListItem` here, structurally. Nothing in this file imports a
 * component, so it is testable as plain functions.
 */

/** The `documentType` values the tree can hand us, mapped onto what the icon should draw. */
const KIND_BY_DOCUMENT_TYPE: Record<string, FileKind> = {
  folder: 'folder',
  document: 'document',
  html: 'html',
  table: 'table',
  board: 'board',
  card: 'document',
  playbook: 'playbook',
  agent: 'agent',
  attachment: 'attachment',
};

/**
 * A node's display name.
 *
 * Title first; failing that the last path segment, title-cased with separators removed —
 * `meeting-prep/latest` becomes "Latest", not "latest" and not the raw path. This is the
 * behaviour `formatDocLabel` and `nodeLabel` both already had, in two places, differently.
 */
export function fileLabel(node: { title?: string | null; path?: string | null }): string {
  const titled = node.title?.trim();
  if (titled) return titled;
  const segment = (node.path ?? '').split('/').findLast(Boolean);
  if (!segment) return 'Untitled';
  return segment
    .split(/[_-]/)
    .filter(Boolean)
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ');
}

/** `metadata.mimeType`, where the blob has one. Attachments and Drive pins do. */
function mimeTypeOf(metadata: unknown): string | null {
  if (!metadata || typeof metadata !== 'object') return null;
  const mime = (metadata as { mimeType?: unknown }).mimeType;
  return typeof mime === 'string' ? mime : null;
}

/**
 * A filesystem node (`FsNode` / `TreeNode`) → a row.
 *
 * `sizeBytes` comes off the node when the server put it there — `toFsNode` derives it. A
 * node that predates that (an optimistic client-built one, say) falls back to unknown
 * rather than the client re-deriving the discriminants, because two implementations of
 * that rule would drift within a week.
 */
export function toFileListItem(
  node: {
    id: string;
    title?: string | null;
    path?: string | null;
    documentType?: string | null;
    emoji?: string | null;
    metadata?: unknown;
    sizeBytes?: unknown;
    updatedAt?: Date | string | number | null;
    /** `documents.inherits_grants`; false makes the folder a barrier. */
    inheritsGrants?: unknown;
  },
  overrides: Partial<FileListItem> = {},
): FileListItem {
  const documentType = node.documentType ?? 'document';
  return {
    id: node.id,
    title: fileLabel(node),
    kind: KIND_BY_DOCUMENT_TYPE[documentType] ?? 'document',
    emoji: node.emoji ?? null,
    mimeType: mimeTypeOf(node.metadata),
    sizeBytes: typeof node.sizeBytes === 'number' ? node.sizeBytes : null,
    updatedAt: node.updatedAt ?? null,
    // Only an explicit `false` is a barrier. `undefined` means a node the server has not
    // told us about — an optimistic client-built row, or one from a surface that projects
    // fewer fields — and drawing a lock on an unknown is worse than drawing none: it
    // asserts a folder is closed when nobody said so.
    barrier: node.inheritsGrants === false,
    ...overrides,
  };
}

/**
 * A Google Drive row → a row.
 *
 * Drive tells us the size and the modified time but never who touched it, so `editedBy`
 * stays null and the column renders an em dash. Filling it with the connected account's
 * name would be a guess, and a wrong one for anything shared into the drive.
 */
export function driveToFileListItem(node: {
  id: string;
  name: string;
  mimeType?: string | null;
  isFolder: boolean;
  sizeBytes?: number | null;
  modifiedTime?: string | null;
}): FileListItem {
  return {
    id: node.id,
    title: node.name,
    kind: node.isFolder ? 'folder' : 'drive',
    mimeType: node.mimeType ?? null,
    // Google-native docs (a Google Doc, a Sheet) report no size at all — that is a null,
    // not a zero, and the column must say "—" rather than claim the file is empty.
    sizeBytes: node.sizeBytes ?? null,
    updatedAt: node.modifiedTime ?? null,
  };
}

/** An email attachment row (`files.listAttachedForConversation`) → a row. */
export function attachmentToFileListItem(file: {
  id: string;
  filename: string;
  mimeType?: string | null;
  sizeBytes?: number | null;
  updatedAt?: Date | string | null;
}): FileListItem {
  return {
    id: file.id,
    title: file.filename,
    kind: 'attachment',
    mimeType: file.mimeType ?? null,
    sizeBytes: file.sizeBytes ?? null,
    updatedAt: file.updatedAt ?? null,
  };
}

/**
 * The `documents.lastEditors` result → the `editedBy` column, keyed by document id.
 *
 * A document with no entry is one nothing was ever recorded for — history capture is newer
 * than most of the corpus. Absent, so the column renders "—"; see the route's comment for
 * why we do not fall back to the document's owner.
 */
export function editorsById(
  rows: ReadonlyArray<{
    documentId: string;
    name: string;
    kind: string;
    userId?: string | null;
    image?: string | null;
    agentId?: string | null;
    avatar?: string | null;
  }>,
  /**
   * The viewer. Their own edits render as "you" — a column where every second row repeats
   * your own name is a column that tells you nothing, and "13h ago by you" is how a person
   * would say it out loud.
   */
  viewerUserId?: string | null,
): Map<string, FileEditor> {
  const byId = new Map<string, FileEditor>();
  for (const row of rows) {
    const isViewer = !!viewerUserId && row.userId === viewerUserId;
    byId.set(row.documentId, {
      name: isViewer ? 'you' : row.name,
      kind: row.kind === 'human' || row.kind === 'system' ? row.kind : 'agent',
      image: row.image ?? null,
      agentId: row.agentId ?? null,
      avatar: row.avatar ?? null,
    });
  }
  return byId;
}