Spaces:
Running
Running
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 };
}
|