import type { AutomergeUrl } from '../storage/document-runtime-contract' /** Reams-owned folder/file marker; it does not change Pushwork's directory entry shape. */ export const REAMS_STATE_MARKER = '@reamsState' /** * Entry in a directory document's docs array. * * `type` is intentionally `string` rather than `'file' | 'folder'` — * different producers populate it with different conventions: * * - Pushwork 1.x: `'file'` or `'folder'`. * - Pushwork 2.x (`patchwork-folder` shape): `'folder'` for * subdirectories, but the file's extension (e.g. `'md'`, `'png'`) * for files. The producer treats `type` as a content-type hint, * not a file-vs-folder discriminator. * - Reams (when it creates entries via `DocumentManager`): always * literally `'file'` or `'folder'` (Reams predates the 2.x * content-type convention). * * To stay compatible with all three, Reams treats `type === 'folder'` * as the only thing meaning "this entry points at a directory doc"; * anything else is treated as a file leaf. Use {@link isFolderEntry} * everywhere the choice matters; never compare `type === 'file'` * directly (it would mis-classify pushwork-2 files as folders). */ export interface DirectoryEntry { name: string type: string url: AutomergeUrl } /** * True if a `DirectoryEntry` points at a sub-directory document. * False for any file leaf, regardless of how the producer encoded the * file's `type` (`'file'`, `'md'`, `'png'`, etc.). See the * {@link DirectoryEntry} docstring for the multi-producer history. */ export function isFolderEntry(entry: { type: unknown }): boolean { return entry.type === 'folder' } /** * Convenience predicate: a file leaf. Requires `type` to actually be * a non-empty string — `undefined`, `null`, and `''` are NOT files * (they're malformed entries we don't classify either way, and the * callers that branch off this predicate go into the file-leaf code * path with no recovery if we let them through). The empty-string * filter also catches the case where a buggy producer wrote * `type: ''` (Automerge round-trips through a default-construct path * on schema gaps). * * The asymmetry with `isFolderEntry` (which returns true only for * the exact string `'folder'`) is intentional. Folder is the * narrow case across every producer we ship to; everything else * NAMED is a file leaf; everything UNNAMED is broken and should not * silently masquerade as a file. */ export function isFileEntry(entry: { type: unknown }): boolean { return typeof entry.type === 'string' && entry.type.length > 0 && entry.type !== 'folder' } /** * Pushwork Directory Document. * Represents a folder in the document hierarchy. * * `name` / `title` were added in pushwork 1.0.20 and carry the local * directory's basename across the wire. Earlier pushwork versions (and * Reams-created folders that don't go through pushwork init) omit these * fields — they are optional for that reason. Callers that need a * user-facing name (e.g. adopting a folder by URL) should read `name`, * fall back to `title`, and only synthesize a default when both are * absent. * * `lastSyncAt` and `with` are pushwork-specific metadata that Reams * does not read but preserves on the wire (Automerge is structural — * unknown fields round-trip). */ export interface PushworkDirectoryDocument { /** Forward-compatible fields authored by other Pushwork producers. */ [key: string]: unknown '@patchwork': { type: 'folder' } docs: DirectoryEntry[] name?: string title?: string lastSyncAt?: number with?: string } /** * Pushwork File Document. * Represents a file with content and metadata. * * `contentType` is an optional Reams UTI-style identifier (e.g. * `public.markdown`, `public.png`) that drives content-type → view-type * routing. Reams authors it on file creation; external Pushwork documents may * omit it, in which case routing infers it from the name and MIME type without * writing back to the document. */ export interface PushworkFileDocument { /** Forward-compatible fields authored by other Pushwork producers. */ [key: string]: unknown '@patchwork': { type: 'file' } name: string extension: string mimeType: string contentType?: string content: string | Uint8Array metadata: { permissions: number } } /** * Union type for any Pushwork document. */ export type PushworkDocument = PushworkDirectoryDocument | PushworkFileDocument /** * Type guard: is this a Pushwork directory document? */ export function isPushworkDirectory(doc: unknown): doc is PushworkDirectoryDocument { return ( typeof doc === 'object' && doc !== null && '@patchwork' in doc && typeof (doc as any)['@patchwork'] === 'object' && (doc as any)['@patchwork'] !== null && 'type' in (doc as any)['@patchwork'] && (doc as any)['@patchwork'].type === 'folder' && 'docs' in doc && Array.isArray((doc as any).docs) ) } /** * Type guard: is this a Pushwork file document? */ export function isPushworkFile(doc: unknown): doc is PushworkFileDocument { return ( typeof doc === 'object' && doc !== null && '@patchwork' in doc && typeof (doc as any)['@patchwork'] === 'object' && (doc as any)['@patchwork'] !== null && 'type' in (doc as any)['@patchwork'] && (doc as any)['@patchwork'].type === 'file' && 'name' in doc && 'content' in doc ) } /** * Type guard: is file content text (string) vs binary (Uint8Array)? */ export function isTextContent(content: string | Uint8Array): content is string { return typeof content === 'string' } /** * Extract file extension from a file name. * Returns empty string if no extension is present. * * Examples: * - "note.md" -> "md" * - "archive.tar.gz" -> "gz" * - "Makefile" -> "" * - ".gitignore" -> "gitignore" * - "" -> "" */ export function extensionFromFileName(fileName: string): string { if (!fileName || !fileName.includes('.')) return '' return fileName.split('.').pop()! } const MIME_TYPES: Record = { md: 'text/markdown', txt: 'text/plain', json: 'application/json', base: 'text/yaml', js: 'application/javascript', ts: 'application/typescript', jsx: 'application/javascript', tsx: 'application/typescript', css: 'text/css', html: 'text/html', htm: 'text/html', // Image types png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon', } /** * Infer MIME type from a file extension. * Returns "application/octet-stream" for unknown extensions. */ export function mimeTypeFromExtension(ext: string): string { return MIME_TYPES[ext] || 'application/octet-stream' }