Source: 05b-filesystem.js

/* ── File System Access API ── */
let _cachedDirHandle = null;

/**
 * Opens (or creates) the IndexedDB database used to persist the FSA directory handle.
 * @returns {Promise<IDBDatabase>} Resolves with the opened database instance.
 */
function openIDB() {
  return new Promise((res, rej) => {
    const req = indexedDB.open('wl_fs_v1', 1);
    req.onupgradeneeded = (e) => e.target.result.createObjectStore('handles');
    req.onsuccess = (e) => res(e.target.result);
    req.onerror = () => rej(req.error);
  });
}

/**
 * Retrieves the previously granted File System Access directory handle from
 * IndexedDB (with an in-memory cache).
 * @returns {Promise<FileSystemDirectoryHandle|null>} The handle, or null if none saved.
 */
async function getSavedDir() {
  if (_cachedDirHandle) return _cachedDirHandle;
  try {
    const db = await openIDB();
    return new Promise((res) => {
      const tx = db.transaction('handles', 'readonly');
      const get = tx.objectStore('handles').get('saveDir');
      get.onsuccess = () => {
        _cachedDirHandle = get.result || null;
        res(_cachedDirHandle);
      };
      get.onerror = () => {
        wlLog.warn('getSavedDir: IndexedDB read failed; treating as no saved folder', get.error);
        res(null);
      };
    });
  } catch (e) {
    wlLog.warn('getSavedDir: could not open IndexedDB; treating as no saved folder', e);
    return null;
  }
}

/**
 * Persists a File System Access directory handle to IndexedDB for reuse
 * across sessions, and updates the in-memory cache.
 * @param {FileSystemDirectoryHandle} handle - The directory handle to store.
 * @returns {Promise<void>}
 */
async function storeDirHandle(handle) {
  _cachedDirHandle = handle;
  try {
    const db = await openIDB();
    return new Promise((res) => {
      const tx = db.transaction('handles', 'readwrite');
      tx.objectStore('handles').put(handle, 'saveDir');
      tx.oncomplete = () => res();
      tx.onerror = () => {
        wlLog.warn(
          'storeDirHandle: IndexedDB write failed; the chosen folder will not persist across sessions',
          tx.error
        );
        res();
      };
    });
  } catch (e) {
    wlLog.warn('saveDirHandle: failed to persist FSA handle to IndexedDB', e);
    // Future exports will fall back to browser downloads — data is not lost
  }
}

/**
 * Clears the persisted FSA directory handle from both IndexedDB and the
 * in-memory cache so future exports fall back to browser downloads.
 * @returns {Promise<void>}
 */
async function clearDirHandle() {
  _cachedDirHandle = null;
  try {
    const db = await openIDB();
    const tx = db.transaction('handles', 'readwrite');
    tx.objectStore('handles').delete('saveDir');
  } catch (e) {
    wlLog.warn('clearDirHandle: failed to remove FSA handle from IndexedDB', e);
    // In-memory cache is already cleared — future exports will fall back to browser downloads
  }
}

/**
 * Writes a Blob to `subfolder/filename` inside the user's chosen FSA directory.
 * Creates the subfolder if it does not exist. Falls back to a browser `<a>`
 * download if the FSA handle is missing or permission is not granted.
 * @param {string} subfolder - Name of the subfolder to write into.
 * @param {string} filename  - Name of the file to create or overwrite.
 * @param {Blob}   blob      - File content.
 * @returns {Promise<void>}
 */
async function writeExportFile(subfolder, filename, blob) {
  const dir = await getSavedDir();
  if (dir) {
    try {
      const perm = await dir.queryPermission({ mode: 'readwrite' });
      const granted =
        perm === 'granted'
          ? true
          : (await dir.requestPermission({ mode: 'readwrite' })) === 'granted';
      if (granted) {
        const subDir = await dir.getDirectoryHandle(subfolder, { create: true });
        const fh = await subDir.getFileHandle(filename, { create: true });
        const writable = await fh.createWritable();
        await writable.write(blob);
        await writable.close();
        renderFolderStatus();
        return;
      }
    } catch (e) {
      wlLog.warn('writeExportFile: FSA write failed, falling back to browser download', e);
    }
  }
  // Fallback: browser download
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
}

/**
 * Prompts the user to select a save folder via the File System Access API
 * and persists the resulting directory handle. Shows a fallback alert in
 * browsers that do not support the API.
 * @returns {Promise<void>}
 */
async function pickSaveFolder() {
  if (!window.showDirectoryPicker) {
    alert(
      "Your browser doesn't support the File System Access API.\nUse Chrome or Edge for automatic subfolder saving.\nFiles will download normally for now."
    );
    return;
  }
  try {
    const handle = await window.showDirectoryPicker({ mode: 'readwrite' });
    await storeDirHandle(handle);
    renderFolderStatus();
  } catch (e) {
    // AbortError = user dismissed the folder picker; not an error worth logging
    if (e.name !== 'AbortError') wlLog.error('pickSaveFolder: folder selection failed', e);
  }
}

/**
 * Updates the `#folderStatus` element to show the currently selected save
 * folder name (green) or a "pick save folder" prompt (default colour).
 */
function renderFolderStatus() {
  const el = document.getElementById('folderStatus');
  if (!el) return;
  getSavedDir().then((dir) => {
    if (dir) {
      el.textContent = `📁 ${dir.name}`;
      el.title =
        'Timesheets → ' +
        dir.name +
        '/timesheets/\nJSON backups → ' +
        dir.name +
        '/JSON backups/\nClick to change';
      el.style.color = '#1D9E75';
    } else {
      el.textContent = 'pick save folder';
      el.title =
        'Choose where exports are saved (creates timesheets/ and JSON backups/ subfolders)';
      el.style.color = '';
    }
  });
}