File size: 12,341 Bytes
73063cc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
// workspace.js β€” carpeta de trabajo, copia legible en disco e inventario.
//
// CORE compartido por Claw y Code. El mΓ³dulo no sabe quΓ© es una Β«app generadaΒ»
// ni una Β«conversaciΓ³nΒ»: cada app le pasa un MANIFIESTO de almacenes y Γ©l sabe
// leerlos, medirlos, escribirlos a disco, reimportarlos y borrarlos.
//
// Por quΓ© existe: todo vive en IndexedDB/localStorage, que el navegador puede
// desalojar (o el usuario borrar con Β«limpiar datos del sitioΒ») sin avisar. Con
// una carpeta de trabajo, tu trabajo queda en ficheros normales que puedes ver,
// copiar y respaldar tΓΊ.
//
// Lo que NUNCA sale a disco en claro: el vault (secretos cifrados). Se marca
// `sensitive` en el manifiesto y se excluye salvo peticiΓ³n explΓ­cita.

const HANDLE_KEY = 'workspace-handle';
const ROOT = '.elffuss';

let cfg = null;             // { app, ns, db, stores }
let dirHandle = null;       // FileSystemDirectoryHandle de la carpeta de trabajo
let ready = false;          // permiso concedido AHORA
let dirty = new Set();
let listeners = [];
let lastSaveAt = null, lastError = null;
let autosaveCfg = { enabled: false, debounceMs: 3000 };
let autosaveTimer = null;

export function init({ app, ns, db, stores }) {
  cfg = { app, ns, db, stores: stores || [] };
}

export function supported() {
  return {
    pick: typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function',
    persist: !!(navigator.storage && navigator.storage.persist),
  };
}

const emit = () => { const s = statusSync(); listeners.forEach(f => { try { f(s); } catch { /* */ } }); };
export function onChange(fn) { listeners.push(fn); return () => { listeners = listeners.filter(f => f !== fn); }; }

function statusSync() {
  return {
    supported: supported(), folder: dirHandle ? dirHandle.name : null, ready,
    dirty: [...dirty], lastSaveAt, lastError, autosave: autosaveCfg.enabled,
  };
}
export function status() { return statusSync(); }

// ── carpeta de trabajo ───────────────────────────────────────────────────────
async function perm(handle, mode = 'readwrite') {
  if (!handle?.queryPermission) return true;              // OPFS u otros: dados
  if ((await handle.queryPermission({ mode })) === 'granted') return true;
  return false;
}

// En el arranque: recupera el handle SIN pedir permiso ni abrir diΓ‘logos.
export async function restore() {
  if (!cfg) throw new Error('workspace.init() primero');
  let h = null;
  try { h = await cfg.db.get('kv', HANDLE_KEY); } catch { return null; }
  if (!h) return null;
  dirHandle = h;
  ready = await perm(h);
  emit();
  return { name: h.name, ready, reason: ready ? null : 'prompt' };
}

// Elegir carpeta (requiere gesto de usuario).
export async function pick() {
  if (!supported().pick) throw new Error('Este navegador no permite elegir una carpeta. Usa Β«Descargar copiaΒ» para guardar tu trabajo.');
  const h = await window.showDirectoryPicker({ mode: 'readwrite', id: 'elffuss-workspace', startIn: 'documents' });
  dirHandle = h;
  ready = true;
  await cfg.db.set('kv', HANDLE_KEY, h);
  emit();
  return { name: h.name, ready: true };
}

// Reutilizar un handle que la app ya tiene (Code: la carpeta del proyecto).
export async function adopt(handle) {
  if (!handle) throw new Error('sin handle');
  dirHandle = handle;
  ready = await perm(handle);
  await cfg.db.set('kv', HANDLE_KEY, handle);
  emit();
  return { name: handle.name, ready };
}

// Re-conceder permiso tras recargar (requiere gesto de usuario).
export async function regrant() {
  if (!dirHandle) throw new Error('no hay carpeta elegida');
  const ok = dirHandle.requestPermission
    ? (await dirHandle.requestPermission({ mode: 'readwrite' })) === 'granted'
    : true;
  ready = ok;
  emit();
  if (!ok) throw new Error('permiso denegado');
  return { name: dirHandle.name, ready: true };
}

export async function forget() {
  const name = dirHandle?.name || null;
  dirHandle = null; ready = false;
  try { await cfg.db.del('kv', HANDLE_KEY); } catch { /* */ }
  emit();
  return { forgot: name, deleted: false };   // nunca borra los ficheros del disco
}

export async function ensurePersistence() {
  if (!navigator.storage?.persist) return { persisted: false, asked: false };
  const already = navigator.storage.persisted ? await navigator.storage.persisted() : false;
  if (already) return { persisted: true, asked: false };
  const got = await navigator.storage.persist().catch(() => false);
  return { persisted: got, asked: true };
}

// ── inventario: Β«ver quΓ© hayΒ» ────────────────────────────────────────────────
const sizeOf = v => { try { return new Blob([typeof v === 'string' ? v : JSON.stringify(v)]).size; } catch { return 0; } };

async function readStore(st) {
  if (st.kind === 'ls') {
    const items = [];
    for (const k of st.keys || []) {
      const v = localStorage.getItem(k);
      if (v != null) items.push({ key: k, value: v });
    }
    return items;
  }
  if (st.kind === 'idb-store') {                      // store entero (apps, tasks…)
    const [ks, vs] = await Promise.all([cfg.db.keys(st.store), cfg.db.all(st.store)]);
    return ks.map((k, i) => ({ key: String(k), value: vs[i] }));
  }
  // 'idb-key': una clave concreta dentro de kv (history, conversations, skills…)
  const v = await cfg.db.get(st.store || 'kv', st.key);
  if (v == null) return [];
  return Array.isArray(v) ? v.map((x, i) => ({ key: String(x.id ?? x.name ?? i), value: x })) : [{ key: st.key, value: v }];
}

export async function inventory() {
  const out = [];
  for (const st of cfg.stores) {
    let items = [];
    let err = null;
    try { items = await readStore(st); } catch (e) { err = e.message; }
    out.push({
      id: st.id, label: st.label, icon: st.icon || 'β€’', sensitive: !!st.sensitive,
      count: items.length, bytes: items.reduce((a, b) => a + sizeOf(b.value), 0),
      note: st.note || '', error: err,
    });
  }
  return out;
}

export async function peek(id, { limit = 50 } = {}) {
  const st = cfg.stores.find(s => s.id === id);
  if (!st) throw new Error('almacΓ©n desconocido: ' + id);
  const items = await readStore(st);
  return {
    id, truncated: items.length > limit,
    items: items.slice(0, limit).map(it => ({
      key: it.key, bytes: sizeOf(it.value),
      preview: st.sensitive ? '(cifrado β€” no se muestra)'
        : (typeof it.value === 'string' ? it.value : JSON.stringify(it.value)).slice(0, 240),
    })),
  };
}

// Espacio real del origen, incluidas TODAS las caches (los modelos son GB).
export async function storageReport() {
  const est = navigator.storage?.estimate ? await navigator.storage.estimate().catch(() => ({})) : {};
  const persisted = navigator.storage?.persisted ? await navigator.storage.persisted().catch(() => false) : false;
  const out = { usage: est.usage || 0, quota: est.quota || 0, persisted, caches: [] };
  if (typeof caches !== 'undefined') {
    for (const name of await caches.keys().catch(() => [])) {
      const c = await caches.open(name);
      const reqs = await c.keys();
      let bytes = 0;
      for (const r of reqs) {
        const res = await c.match(r);
        const len = +(res?.headers.get('content-length') || 0);
        bytes += len;
      }
      out.caches.push({ name, entries: reqs.length, bytes });
    }
  }
  return out;
}

// ── guardar en disco ─────────────────────────────────────────────────────────
export function touch(id) {
  dirty.add(id);
  emit();
  if (autosaveCfg.enabled) {
    clearTimeout(autosaveTimer);
    autosaveTimer = setTimeout(() => { save({ reason: 'auto' }).catch(() => {}); }, autosaveCfg.debounceMs);
  }
}

export function autosave({ enabled, debounceMs }) {
  autosaveCfg = { enabled: !!enabled, debounceMs: debounceMs || autosaveCfg.debounceMs };
  try { localStorage.setItem(cfg.ns + '.workspace.autosave', enabled ? '1' : '0'); } catch { /* */ }
  emit();
  return autosaveCfg;
}
export function autosaveEnabled() {
  try { return localStorage.getItem(cfg.ns + '.workspace.autosave') === '1'; } catch { return false; }
}

async function dirFor(path) {                 // crea .elffuss/<app>/<sub> y devuelve el handle
  let d = dirHandle;
  for (const part of path.split('/').filter(Boolean)) d = await d.getDirectoryHandle(part, { create: true });
  return d;
}
async function writeFile(dir, name, text) {
  const fh = await dir.getFileHandle(name, { create: true });
  const w = await fh.createWritable();
  await w.write(text);
  await w.close();
}
const safeName = s => String(s).replace(/[^\w.\-]+/g, '_').slice(0, 60) || 'item';

export async function save({ only = null, reason = 'manual' } = {}) {
  if (!dirHandle) throw new Error('no-folder');
  if (!(await perm(dirHandle))) { ready = false; emit(); throw new Error('needs-regrant'); }
  const targets = cfg.stores.filter(st => !st.sensitive && (only ? only.includes(st.id) : true));
  const wrote = [], errors = [];
  for (const st of targets) {
    try {
      const items = await readStore(st);
      const base = `${ROOT}/${cfg.app}/${st.id}`;
      if (st.files) {                              // uno por fichero (apps .html, skills .md)
        const d = await dirFor(base);
        for (const it of items) {
          const body = st.files.body ? st.files.body(it.value) : JSON.stringify(it.value, null, 1);
          await writeFile(d, safeName(st.files.name ? st.files.name(it.value, it.key) : it.key) + (st.files.ext || '.json'), body);
        }
      } else {                                      // uno solo (historial, ajustes)
        const d = await dirFor(`${ROOT}/${cfg.app}`);
        await writeFile(d, st.id + '.json', JSON.stringify(items.map(i => i.value), null, 1));
      }
      wrote.push({ id: st.id, files: items.length });
      dirty.delete(st.id);
    } catch (e) { errors.push({ id: st.id, error: e.message }); }
  }
  lastSaveAt = Date.now();
  lastError = errors.length ? errors[0].error : null;
  // Γ­ndice legible con lo que hay
  try {
    const d = await dirFor(`${ROOT}/${cfg.app}`);
    await writeFile(d, 'index.json', JSON.stringify({ app: cfg.app, at: lastSaveAt, reason, wrote }, null, 1));
  } catch { /* */ }
  emit();
  return { ok: !errors.length, at: lastSaveAt, wrote, errors };
}

// ── copia descargable (Safari/Firefox o backup manual) ───────────────────────
export async function bundle({ only = null, includeSensitive = false } = {}) {
  const data = { app: cfg.app, at: Date.now(), stores: {} };
  for (const st of cfg.stores) {
    if (st.sensitive && !includeSensitive) continue;
    if (only && !only.includes(st.id)) continue;
    try { data.stores[st.id] = (await readStore(st)).map(i => i.value); } catch { /* */ }
  }
  return new Blob([JSON.stringify(data, null, 1)], { type: 'application/json' });
}

// ── borrar ───────────────────────────────────────────────────────────────────
export async function wipe({ only = null, confirm } = {}) {
  if (confirm !== 'BORRAR') throw new Error('confirmaciΓ³n requerida');
  const removed = [];
  for (const st of cfg.stores) {
    if (only && !only.includes(st.id)) continue;
    try {
      if (st.kind === 'ls') { for (const k of st.keys || []) localStorage.removeItem(k); removed.push({ id: st.id }); }
      else if (st.kind === 'idb-store') { for (const k of await cfg.db.keys(st.store)) await cfg.db.del(st.store, k); removed.push({ id: st.id }); }
      else { await cfg.db.del(st.store || 'kv', st.key); removed.push({ id: st.id }); }
    } catch (e) { removed.push({ id: st.id, error: e.message }); }
  }
  emit();
  return { removed };
}

export async function clearCaches({ names } = {}) {
  if (typeof caches === 'undefined') return { deleted: [] };
  const target = names || await caches.keys();
  const deleted = [];
  for (const n of target) if (await caches.delete(n)) deleted.push(n);
  return { deleted };
}