# onefs

Cross-platform file system abstraction for web, Tauri, and Capacitor.

## Features

- **File System Access API** with handle persistence via IndexedDB
- **Fallback mode** using file picker + IndexedDB storage
- **Tauri integration** via @tauri-apps/plugin-dialog and @tauri-apps/plugin-fs
- **Capacitor integration** via @capacitor/filesystem
- **Automatic platform detection** with configurable overrides
- **Type-safe error handling** with discriminated result types
- **Lazy directory loading** - list entries without loading file contents
- **Automatic storage pruning** - keeps recent files within configured limit

## Installation

```bash
npm install onefs
# or
bun add onefs
```

## Quick Start

```typescript
import { createOneFS } from 'onefs'

const fs = createOneFS({ appName: 'myapp' })

// Open a file
const result = await fs.openFile({ accept: ['.json', '.txt'] })
if (result.ok) {
  const text = fs.readAsText(result.data)
  console.log(text)
} else {
  if (result.error.code === 'cancelled') {
    console.log('User cancelled')
  } else {
    console.error(result.error.message)
  }
}

// Save to the same file
const saveResult = await fs.saveFile(file, 'updated content')

// Save as new file
const newFile = await fs.saveFileAs('content', {
  suggestedName: 'document.txt'
})
```

## Important: Platform Differences

OneFS abstracts platform differences, but some behaviors vary. Always check capabilities before assuming behavior.

### Content is Always `Uint8Array`

File content is always returned as `Uint8Array`, never as a string. Use helper methods to convert:

```typescript
const file = (await fs.openFile()).data

// Convert to string
const text = fs.readAsText(file)

// Parse as JSON
const json = fs.readAsJSON<MyType>(file)

// Get as Blob for images
const blob = fs.readAsBlob(file)
```

### Save Behavior Varies by Platform

The `saveFile()` method behaves differently depending on the platform:

| Platform | Behavior |
|----------|----------|
| web-fs-access | Saves in-place to original file location |
| tauri | Saves in-place to original file location |
| web-fallback | **Triggers a download** (cannot save in-place) |
| capacitor | Saves to app's Data directory (not original location) |

Check `capabilities.canSaveInPlace` to detect this:

```typescript
if (fs.capabilities.canSaveInPlace) {
  // Will save to original location
  await fs.saveFile(file, newContent)
} else {
  // Will trigger download or save to app directory
  // Consider showing a different UI
  await fs.saveFile(file, newContent)
}
```

### Path Property Varies

The `file.path` property has different meanings:

| Platform | `file.path` value |
|----------|------------------|
| web-fs-access | `undefined` (no path access in browser) |
| web-fallback | `undefined` |
| tauri | Real filesystem path (e.g., `/home/user/doc.txt`) |
| capacitor | Synthetic identifier (e.g., `onefs_123_doc.txt`) |

### Directory Support

Directory operations are not available on all platforms:

| Platform | `openDirectory` | `readDirectory` |
|----------|-----------------|-----------------|
| web-fs-access | Full support | Full support |
| web-fallback | Not supported | Not supported |
| tauri | Full support | Full support |
| capacitor | Documents only | Documents only |

```typescript
if (fs.supportsDirectories) {
  const dir = await fs.openDirectory()
  // ...
}
```

## Directory Operations

Directories are loaded lazily to avoid memory issues with large folders:

```typescript
// Open directory picker
const dirResult = await fs.openDirectory()
if (!dirResult.ok) return

// List entries (metadata only - no content loaded)
const entriesResult = await fs.readDirectory(dirResult.data)
if (!entriesResult.ok) return

for (const entry of entriesResult.data) {
  console.log(entry.name, entry.kind, entry.size)

  if (entry.kind === 'file') {
    // Load specific file content on demand
    const fileResult = await fs.readFileFromDirectory(dirResult.data, entry)
    if (fileResult.ok) {
      const content = fs.readAsText(fileResult.data)
    }
  }
}
```

### OneFSEntry

Directory entries include metadata without content:

```typescript
interface OneFSEntry {
  name: string              // "document.txt" or "subfolder"
  kind: 'file' | 'directory'
  size?: number             // File size in bytes (files only)
  lastModified?: number     // Timestamp (files only)
  path?: string             // Full path (Tauri/Capacitor only)
  handle?: FileSystemHandle // Native handle (web-fs-access only)
}
```

## Error Handling

All async operations return `OneFSResult<T>`:

```typescript
type OneFSResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: OneFSError }

interface OneFSError {
  code: OneFSErrorCode
  message: string
  cause?: unknown  // Original error if available
}

type OneFSErrorCode =
  | 'cancelled'           // User cancelled operation
  | 'permission_denied'   // No permission to access file/directory
  | 'not_supported'       // Operation not supported on this platform
  | 'not_found'           // File/handle not found
  | 'io_error'            // Generic I/O error
  | 'unknown'             // Unknown error
```

Example:

```typescript
const result = await fs.openFile()

if (!result.ok) {
  switch (result.error.code) {
    case 'cancelled':
      // User clicked cancel - not an error
      break
    case 'permission_denied':
      showPermissionDialog()
      break
    case 'not_supported':
      showFallbackUI()
      break
    default:
      console.error('Failed:', result.error.message)
  }
  return
}

const file = result.data
```

## Configuration

```typescript
const fs = createOneFS({
  appName: 'myapp',           // Required - used for IndexedDB database name
  maxRecentFiles: 10,         // Max files to remember (default: 10)
  persistByDefault: true,     // Store files/handles in IndexedDB (default: true)
  useNativeFSAccess: true,    // Use File System Access API when available (default: true)
  preferredAdapter: 'tauri',  // Force specific adapter (optional)
})
```

## Per-Operation Options

```typescript
// Don't persist this file to recent list
const file = await fs.openFile({ persist: false })

// Save without adding to recent
await fs.saveFileAs(content, { persist: false })
```

## Platform Detection

```typescript
console.log(fs.platform)
// 'web-fs-access' | 'web-fallback' | 'tauri' | 'capacitor'

console.log(fs.capabilities)
// {
//   openFile: true,
//   saveFile: true,
//   saveFileAs: true,
//   openDirectory: true,
//   readDirectory: true,
//   handlePersistence: true,
//   canSaveInPlace: true,
// }

console.log(fs.supportsDirectories)      // boolean
console.log(fs.supportsHandlePersistence) // boolean
```

## Platform Capabilities Matrix

| Capability | web-fs-access | web-fallback | tauri | capacitor |
|------------|---------------|--------------|-------|-----------|
| openFile | Yes | Yes | Yes | Yes |
| saveFile | Yes | Yes (download) | Yes | Yes (app dir) |
| saveFileAs | Yes | Yes (download) | Yes | Yes (app dir) |
| openDirectory | Yes | No | Yes | Limited |
| readDirectory | Yes | No | Yes | Limited |
| handlePersistence | Yes | No | No | No |
| canSaveInPlace | Yes | No | Yes | No |

## OneFSFile

```typescript
interface OneFSFile {
  id: string              // Unique identifier
  name: string            // File name (e.g., "document.txt")
  path?: string           // Full path (Tauri/Capacitor only)
  content: Uint8Array     // File content as bytes
  mimeType: string        // MIME type (e.g., "text/plain")
  size: number            // File size in bytes
  lastModified: number    // Timestamp (ms since epoch)
  handle?: FileSystemFileHandle  // Native handle (web-fs-access only)
}
```

## Helper Methods

```typescript
fs.readAsText(file)       // string (UTF-8)
fs.readAsJSON(file)       // parsed JSON
fs.readAsDataURL(file)    // data:mime;base64,...
fs.readAsBlob(file)       // Blob
fs.readAsObjectURL(file)  // blob:... (remember to revoke!)
```

## Recent Files

```typescript
// Get recent files
const recent = await fs.getRecentFiles()
// Returns StoredHandle[] with { id, name, path?, type, storedAt }

// Restore a file
const file = await fs.restoreFile(recent[0])

// On web-fs-access: Re-reads from disk (may prompt for permission)
// On other platforms: Returns cached content from IndexedDB

// Restore a directory with specific permission mode
const dir = await fs.restoreDirectory(recent[0], 'readwrite')

// Remove from recent
await fs.removeFromRecent(id)

// Clear all
await fs.clearRecent()
```

## Permission Management (web-fs-access only)

Check and request permissions on files and directories:

```typescript
// Check current permission status
const status = await fs.queryPermission(directory, 'readwrite')
// Returns: 'granted' | 'denied' | 'prompt'

// Request permission (must be called during user gesture)
const result = await fs.requestPermission(directory, 'readwrite')
if (result.ok) {
  // Permission granted
}

// On non-web-fs-access platforms, these return 'granted' and ok(true)
```

## Named Directory Storage (web-fs-access only)

Store directories by key, separate from the recent files list. Useful for app preferences like output directories:

```typescript
// Open and store a directory by key
const dir = await fs.openDirectory({ mode: 'readwrite' })
if (dir.ok) {
  await fs.setNamedDirectory('outputDir', dir.data)
}

// Retrieve later (automatically requests permission)
const stored = await fs.getNamedDirectory('outputDir', 'readwrite')
if (stored.ok) {
  // Use stored.data.handle for file operations
}

// Remove
await fs.removeNamedDirectory('outputDir')
```

## Exports

```typescript
// Main factory
import { createOneFS, OneFS } from 'onefs'

// Types
import type {
  OneFSFile,
  OneFSDirectory,
  OneFSEntry,
  OneFSResult,
  OneFSError,
  OneFSErrorCode,
  OneFSCapabilities,
  Platform,
  StoredHandle,
} from 'onefs'

// Helpers
import { ok, err, PLATFORM_CAPABILITIES } from 'onefs'

// Individual adapters (for advanced use)
import {
  FSAccessAdapter,
  PickerIDBAdapter,
  TauriAdapter,
  CapacitorAdapter,
} from 'onefs'
```

## Platform-Specific Setup

### Tauri

Add the required plugins to your `Cargo.toml`:

```toml
[dependencies]
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
```

And initialize them in your Tauri app:

```rust
fn main() {
    tauri::Builder::default()
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_fs::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
```

### Capacitor

Install the filesystem plugin:

```bash
npm install @capacitor/filesystem
npx cap sync
```

For iOS Files app integration (users can drop files into your app's folder):

Add to your `ios/App/App/Info.plist`:

```xml
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
```

This exposes your app's Documents folder in the iOS Files app. Users can drag files there, and your app can scan them with `openDirectory()` and `scanDirectory()`.

Optional: Install `@capawesome/capacitor-file-picker` for native file picker (otherwise falls back to HTML input).

## Scanning Directories

Recursively scan directories for files with optional filtering:

```typescript
const dir = await fs.openDirectory()
if (!dir.ok) return

// Scan for specific file types
const result = await fs.scanDirectory(dir.data, {
  extensions: ['.mp3', '.flac', '.wav'],
  skipStats: true,  // Faster - don't fetch size/mtime
  onProgress: (scanned, found) => {
    console.log(`Scanned ${scanned} entries, found ${found} matches`)
  },
  signal: abortController.signal,  // Optional cancellation
})

if (result.ok) {
  for (const entry of result.data) {
    console.log(entry.name, entry.path)
  }
}
```

## Streaming URLs (Tauri/Capacitor)

Get efficient URLs for media playback without loading files into memory:

```typescript
// From a directory entry (recommended for media apps)
const url = await fs.getEntryUrl(entry)
if (url) {
  audioElement.src = url
}

// From a file object
const url = await fs.getFileUrl(file)
```

On Tauri/Capacitor, this uses `convertFileSrc()` for efficient native streaming.
On web platforms, falls back to blob URLs.

## Future Improvements

The following features are planned but not yet implemented:

- **Streaming support** for large files (ReadableStream)
- **File watching** for external changes (FileSystemObserver)

## License

MIT
