Module FS EnableExplicit ;- Globals ; Pending operation state ; Safe because JS is single-threaded — one Read_ and one Write in flight at most. Global _R_ID.s, _R_Path.s, _R_Content.s, *_R_Cb Global _W_ID.s, _W_Path.s, _W_Content.s, *_W_Cb ;- Private declarations Declare _FindLastSlash(Path.s) Declare _ReadExistsCallback(Success, DataString.s) Declare _ReadCachedCallback(Success, DataString.s) Declare _ReadServerCallback(Success, DataString.s) Declare _ReadCacheStoreCallback(Success, DataString.s) Declare _WriteCacheCallback(Success, DataString.s) Declare _WriteServerCallback(Success, DataString.s) ;- Public procedures ; List directory contents. ; DataString = JSON array of {id, name, is_dir, mime_type, size, modified_at} Procedure List(Path.s, *Callback) HTTPRequest(#PB_HTTP_Get, "/api/fs/list?path=" + URLEncoder(Path, #PB_UTF8), "", *Callback) EndProcedure ; Stat a single node. ; DataString = JSON object with id, name, is_dir, size, etc. or error. Procedure Stat(Path.s, *Callback) HTTPRequest(#PB_HTTP_Get, "/api/fs/stat?path=" + URLEncoder(Path, #PB_UTF8), "", *Callback) EndProcedure ; Read file content. Cache-first: if the file is in IndexedDB, returns it ; immediately without a server round-trip. Otherwise fetches from server ; and populates the cache for next time. ; FileID = string form of the numeric node id (from Stat or List). ; Path = virtual path, stored in cache meta for Sync to use later. Procedure Read_(FileID.s, Path.s, *Callback) _R_ID = FileID _R_Path = Path *_R_Cb = *Callback FileCache::Exists(FileID, @_ReadExistsCallback()) EndProcedure ; Write content. Hits the cache immediately (dirty), fires the callback, ; then syncs to the server best-effort in the background. ; If the server write fails the file stays dirty — Sync() will retry it. Procedure Write(FileID.s, Path.s, Content.s, *Callback) _W_ID = FileID _W_Path = Path _W_Content = Content *_W_Cb = *Callback FileCache::Write(FileID, Content, @_WriteCacheCallback()) EndProcedure ; Create a directory. ; DataString = JSON {success:true, id:N} or error. Procedure Mkdir(Path.s, *Callback) HTTPRequest(#PB_HTTP_Post, "/api/fs/mkdir", "path=" + URLEncoder(Path, #PB_UTF8), *Callback) EndProcedure ; Delete a node by ID. Evicts from cache too. ; DataString = JSON {success:true} or error. Procedure Delete_(FileID.s, *Callback) FileCache::Evict(FileID, #Null) ; best-effort, don't wait HTTPRequest(#PB_HTTP_Post, "/api/fs/delete", "id=" + FileID, *Callback) EndProcedure ; Move/rename a node by ID. ; DataString = JSON {success:true} or error. Procedure Move(FileID.s, NewParentID.s, NewName.s, *Callback) HTTPRequest(#PB_HTTP_Post, "/api/fs/move", "id=" + FileID + "&to_parent_id=" + NewParentID + "&name=" + URLEncoder(NewName, #PB_UTF8), *Callback) EndProcedure ; Push all dirty cached files to the server sequentially. Procedure Sync(*Callback) !var _cb = p_callback; !var _db = window._kumos_idb; !if (!_db) { if (_cb) _cb(0, 'IDB not open'); return; } ! !// Collect dirty entries from file_meta !var tx = _db.transaction(['file_meta'], 'readonly'); !var store = tx.objectStore('file_meta'); !var req = store.openCursor(); !var dirty = []; ! !req.onsuccess = function(ev) { ! var cursor = ev.target.result; ! if (cursor) { ! try { ! var meta = JSON.parse(cursor.value); ! if (meta.dirty) dirty.push({ id: String(cursor.key), path: meta.path }); ! } catch(e) {} ! cursor.continue(); ! } !}; ! !tx.oncomplete = function() { ! if (dirty.length === 0) { ! if (_cb) _cb(1, JSON.stringify({ synced: 0, failed: 0 })); ! return; ! } ! ! var synced = 0, failed = 0, total = dirty.length; ! ! function markClean(id, onDone) { ! var wtx = _db.transaction(['file_meta'], 'readwrite'); ! var ws = wtx.objectStore('file_meta'); ! var gr = ws.get(id); ! gr.onsuccess = function(ev) { ! var m = {}; ! try { m = JSON.parse(ev.target.result || '{}'); } catch(e) {} ! m.dirty = false; ! ws.put(JSON.stringify(m), id); ! }; ! wtx.oncomplete = onDone; ! wtx.onerror = onDone; ! } ! ! function syncNext(i) { ! if (i >= total) { ! if (_cb) _cb(failed === 0 ? 1 : 0, ! JSON.stringify({ synced: synced, failed: failed })); ! return; ! } ! var entry = dirty[i]; ! ! // Read content from IDB ! var rtx = _db.transaction(['file_content'], 'readonly'); ! var rreq = rtx.objectStore('file_content').get(entry.id); ! ! rreq.onsuccess = function(ev) { ! var content = ev.target.result !== undefined ? String(ev.target.result) : ''; ! ! // POST to server ! fetch('/api/fs/write', { ! method: 'POST', ! headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, ! body: 'path=' + encodeURIComponent(entry.path) + ! '&content=' + encodeURIComponent(content) ! }) ! .then(function(r) { return r.json(); }) ! .then(function(json) { ! if (json.success) { ! markClean(entry.id, function() { synced++; syncNext(i + 1); }); ! } else { ! failed++; syncNext(i + 1); ! } ! }) ! .catch(function() { failed++; syncNext(i + 1); }); ! }; ! ! rreq.onerror = function() { failed++; syncNext(i + 1); }; ! } ! ! syncNext(0); !}; ! !tx.onerror = function(e) { ! if (_cb) _cb(0, e.target.error ? e.target.error.message : 'sync scan failed'); !}; EndProcedure Procedure.s GetFilePart(Path.s) Protected Last = _FindLastSlash(Path) If Last = 0 : ProcedureReturn Path : EndIf ProcedureReturn Mid(Path, Last + 1) EndProcedure Procedure.s GetPathPart(Path.s) Protected Last = _FindLastSlash(Path) If Last = 0 : ProcedureReturn "/" : EndIf ; no slash — return root ProcedureReturn Left(Path, Last) ; includes the trailing slash EndProcedure ;- Private procedures Procedure _FindLastSlash(Path.s) Protected Pos, Last Pos = FindString(Path, "/") While Pos > 0 Last = Pos Pos = FindString(Path, "/", Pos + 1) Wend ProcedureReturn Last EndProcedure Procedure _ReadExistsCallback(Success, DataString.s) Debug "Exists → Success=" + Success + " Data=" + DataString If Success And DataString = "1" ; Cache hit — return directly FileCache::Read_(_R_ID, @_ReadCachedCallback()) Else ; Cache miss — fetch from server HTTPRequest(#PB_HTTP_Get, "/api/fs/read?id=" + _R_ID, "", @_ReadServerCallback()) EndIf EndProcedure Procedure _ReadCachedCallback(Success, DataString.s) If *_R_Cb !fs$g__r_cb(v_success, v_datastring); EndIf EndProcedure Procedure _ReadServerCallback(Success, DataString.s) If Success _R_Content = DataString FileCache::Cache(_R_ID, _R_Path, DataString, @_ReadCacheStoreCallback()) Else !fs$g__r_cb(0, v_datastring); EndIf EndProcedure Procedure _ReadCacheStoreCallback(Success, DataString.s) Protected Content.s = _R_Content If *_R_Cb !fs$g__r_cb(1, v_content); EndIf EndProcedure Procedure _WriteCacheCallback(Success, DataString.s) If *_W_Cb !fs$g__w_cb(v_success, v_datastring); EndIf If Success HTTPRequest(#PB_HTTP_Post, "/api/fs/write", "path=" + URLEncoder(_W_Path, #PB_UTF8) + "&content=" + URLEncoder(_W_Content, #PB_UTF8), @_WriteServerCallback()) EndIf EndProcedure Procedure _WriteServerCallback(Success, DataString.s) If Success If ParseJSON(0, DataString) If GetJSONBoolean(GetJSONMember(JSONValue(0), "success")) FileCache::MarkClean(_W_ID, #Null) EndIf FreeJSON(0) EndIf EndIf EndProcedure EndModule ; IDE Options = SpiderBasic 3.20 (Windows - x86) ; CursorPosition = 21 ; Folding = BAA- ; iOSAppOrientation = 0 ; AndroidAppCode = 0 ; AndroidAppOrientation = 0 ; EnableXP ; DPIAware ; CompileSourceDirectory