// files.js - connects three worlds: // - the disk (File System Access API), an // - in-memory mirror (`files`) // - and a snapshot of what the server has (`server`, persisted to localStorage). // // Sync model: each batch round-trip sends modified // files, locally-deleted paths, per-dir timestamp cursors, and a global fslog // "commit offset" (`server.serverTime`); receives files to pull, renames, and // server-side deletes from the fslog newer than the watermark. // User can set his own server apiUrl through localstorage. const API_URL = localStorage.getItem('apiUrl') || 'https://api.files.md'; const CURRENT_FILE_SYNC_INTERVAL = 1000; // ms, how often to save currently open file // Matches server's MaxMediaSize (server/sync/sync.go). Server caps the JSON // request body, which holds base64 (~33% inflation), so the effective raw // file limit is roughly 3/4 of this. Files above MAX_MEDIA_SIZE are rejected // outright; files between 3/4 and 1 of MAX_MEDIA_SIZE may still be refused // by the server when base64 pushes the body past the cap. const MAX_MEDIA_SIZE = 65 * 1024 * 1024; let isSaving = false; let isSyncingFiles = false; let isSyncingMedia = false; let isMessingWithCurrentEditor = false; let isSyncingFileWithServer = {}; // path -> bool, prevents concurrent server syncs for the same file let needsResyncWithServer = {}; // path -> bool, flags that another sync was requested while one was in flight let isLoadingLocalFiles = false; // We should know if we had at least one successful // communication with the server (/token), so that // we run sync periodically. We won't run if the app // is not linked to the server. Unfortunately we can't just // check "token" cookie, because it is HttpOnly. const LAST_SERVER_OK_KEY = 'lastServerOk'; const MAX_DIR_NESTING_LEVEL = 10; function markServerOk() { localStorage.setItem(LAST_SERVER_OK_KEY, Date.now().toString()); } // Sync indicator for the current file: a quiet dot that turns orange while // the server hasn't yet acked what's in the editor - either because you just // typed (clears on the next sync tick) or because the server is unreachable. // Hidden for local-only setups. let lastSyncOkAt = null; function renderSyncStatus(state) { // 'ok' | 'edits' | 'error' const dot = document.getElementById('sync-status'); if (dot === null) { return; } const at = lastSyncOkAt === null ? 'never' : new Date(lastSyncOkAt).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}); dot.style.display = 'block'; dot.classList.toggle('bad', state !== 'ok'); dot.title = state === 'ok' ? `Synced at ${at}` : state === 'edits' ? `Unsynced changes. Last synced at ${at}` : `Not synced, server unreachable. Last synced at ${at}`; } // Called on every editor change (see initEditor). function markSyncDirty() { if (!hasLastServerOk()) { return; } renderSyncStatus('edits'); } function hasLastServerOk() { return localStorage.getItem(LAST_SERVER_OK_KEY) !== null; } // The types of files we have: // - serverFile, file on server // - localFile, file on local fs // - memFile, in-memory representation of local file // The latter is needed for quick access to file's handle and metadata. // We operate with absolute paths in our webapp. Server/wasm is currently operating with relative paths (better for safety checks). // In-memory mapping of local file system: // { // 'dir/': [ // { // 'filename': [ // { // content: 'File content here...', // lastModified: , // handle: , // imageUrl: // }, // ... // ] // }, // ... // ] // } let files = {}; // In-memory representation of local files // In-memory snapshot of what the server has, persisted to localStorage // under SERVER_STORAGE_KEY and rehydrated at boot: // { // files: { // 'dir/': { // 'filename.md': { // hash: '', // lastModified: , // lastClientModified: , // path: '/dir/filename.md' // }, // ... // }, // ... // }, // media: { // 'image.png': { // isFile: true, // lastModified: // }, // ... // }, // timestamps: { '': , ... }, // per-path cursor for incremental sync // mediaTimestamp: // single cursor for media sync // } let server = {files: {}, media: {}, timestamps: {}, mediaTimestamp: 0}; // In-memory representation of server // Reverse index for non-md files (currently just images): filename -> first. // Lets the editor resolve `![](foo.png)` even when the // image lives in a folder other than media. let mediaIndex = {}; const SERVER_STORAGE_KEY = 'server'; // If scheme is migrated, I believe it's better to introduce a new key, because for now old keys aren't removed. const SUPPORTED_EXTENSIONS = ['md', 'png', 'jpg', 'jpeg', 'webp', 'gif', 'mp4', 'webm', 'mov', 'mp3', 'ogg', 'oga', 'weba', 'wav']; function isMediaPath(path) { return /\.(png|jpg|jpeg|gif|webp|mp4|webm|mov|mp3|ogg|oga|weba|wav)$/i.test(path); } const SYSTEM_DIRS = ['media', 'archive', 'journal', 'habits', 'triggers', 'insights']; const CONFIG_PATH = '/config.json'; async function loadLocalFiles(rootDirHandle, slowMode = false) { if (isLoadingLocalFiles) { return files; } isLoadingLocalFiles = true; // TODO should we wait for editor2 as well? // What if "isClean" is changed mid-through? We have awaits. // Better check per/file right before loading? while (!editor.isClean()) { await new Promise(r => setTimeout(r, 50)); } let newFiles = {}; // Rebuild the filename->path image index from scratch on every load so // renamed/deleted images don't linger in the lookup. mediaIndex = {}; // Loads files recursively async function loadDir(dirHandle, path = '/', depth = 0) { const entries = []; for await (const entry of dirHandle.values()) { entries.push(entry); } entries.sort((a, b) => a.name.localeCompare(b.name)); const dirPromises = []; for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const filename = entry.name.normalize('NFC'); let isSupportedExtension = SUPPORTED_EXTENSIONS.includes(filename.split('.').pop().toLowerCase()); let isConfig = filename === toFilename(CONFIG_PATH); let dirs = path.split('/'); dirs = dirs.filter(d => d !== ''); let currentDir = newFiles; for (let dir of dirs) { dir += '/'; if (!currentDir[dir]) { currentDir[dir] = {}; } currentDir = currentDir[dir]; // Move reference deeper } if (entry.kind === 'directory') { if (filename.startsWith('.') || depth >= MAX_DIR_NESTING_LEVEL) continue; currentDir[filename + '/'] = {}; const dir = `${path}${filename}/`; dirPromises.push({handle: entry, path: dir, depth: depth + 1}); } else if (entry.kind === 'file' && (isSupportedExtension || isConfig)) { // Reuse existing file handle if it exists let existingDir = files; for (let dir of dirs) { dir += '/'; if (existingDir === undefined || existingDir[dir] === undefined) { existingDir = undefined; break; } existingDir = existingDir[dir]; } const fileWasPreviouslyLoaded = existingDir && existingDir[filename] !== undefined if (fileWasPreviouslyLoaded) { currentDir[filename] = existingDir[filename]; } else { currentDir[filename] = {path: `${path}${filename}`, isFile: true, handle: entry}; entry.getFile().then(file => { currentDir[filename].lastModified = file.lastModified; }); } if (!isMediaPath(filename)) { continue } if (!currentDir[filename].imageUrl) { getImageUrl(entry).then(imageUrl => { currentDir[filename].imageUrl = imageUrl; }); } // Index every image by its bare filename. First write wins. if (!mediaIndex[filename]) { mediaIndex[filename] = currentDir[filename]; } } if (slowMode && i % 50 === 0) { await new Promise(r => setTimeout(r, 0)); } } if (debug) { if (!debug.loaded) { debug.loaded = true await loadDir(rootDirHandle, debug.dir, 1); } return; } if (!slowMode) { await Promise.all(dirPromises.map(({handle, path, depth}) => loadDir(handle, path, depth) )); return; } const batchSize = 6; for (let i = 0; i < dirPromises.length; i += batchSize) { const batch = dirPromises.slice(i, i + batchSize); await Promise.all(batch.map(({handle, path, depth}) => loadDir(handle, path, depth) )); await new Promise(r => setTimeout(r, 0)); } } try { await loadDir(rootDirHandle); } catch (error) { log('Load Local files: ', error); isLoadingLocalFiles = false; throw error; } // Load server files const savedServerFiles = localStorage.getItem(SERVER_STORAGE_KEY); if (savedServerFiles) { server = JSON.parse(savedServerFiles); } isLoadingLocalFiles = false; return newFiles; } async function syncFilesWithServer() { // We should have at least one 200 response from service. // The first 200 response we get from /token, meaning that // our application is linked to the server for sync. if (!hasLastServerOk()) { return; } if (files === undefined || Object.keys(files).length === 0) { return; } if (debug) { return; } if (isSyncingFiles) return; isSyncingFiles = true; const startTime = performance.now(); log('Starting sync with server...'); // Send locally modified files and timestamps of last seen dirs from the server // TODO check if we fully synced at least once (timestamps exists) let modified = []; let deleted = []; // TODO is it possible that the server has zero files? I think at least '.' is sent let hasFullySyncedFilesAtLeastOnce = server['timestamps'] !== undefined && Object.keys(server['timestamps']).length > 0; ; if (hasFullySyncedFilesAtLeastOnce) { log('SYNCED AT LEAST ONCE, collecting local files', server['timestamps']); ({modified, deleted} = await collectModifiedAndDeletedFiles()); } else { log('NEVER SYNCED BEFORE'); } const { json: response, error } = await post('syncFilenames', { modified: modified, deleted: deleted, timestamps: server['timestamps'] || [], serverTime: server['serverTime'] || 0, }); if (error) { logError('syncFilenames failed:', error); isSyncingFiles = false; return; } // Remove info about server files on client for (const path of deleted) { removeServerFile(path); } try { // Write files received from the server let failedAtLeastOnce = false; for (const fileInfo of response.files) { let {path, content, lastModified} = fileInfo; // We get relative paths from server, and in our app we use absolute paths const relPath = path; path = joinPath('/', relPath); // If it is current file, skip, because we sync it separately // TODO if we skip current, don't take it's timestamp? We had a bug when sync was broken for 1 file // TODO fix missing / for root files if (path === editor.path || path === editor2.path) { log('Skip receiving current file during bath sync', path); continue; } try { const lastClientModified = await writeIfContentIsDifferent(path, content) addMemFile(path, { isFile: true, content: content, lastModified: lastModified, lastClientModified: lastClientModified, path: path, handle: await getFileHandle(path), }); log('SYNC texts: write file: ', path); setServerFile(path, content, lastModified, lastClientModified); // Unfortunately rename is not working, so we have to delete the old file const shouldRemoveOldFile = response.renames !== null && relPath in response.renames; // TODO write e2e for renames if (shouldRemoveOldFile) { const oldPath = joinPath('/', response.renames[relPath]); try { log('DELETED due to renaming', oldPath); await remove(oldPath); } catch (err) { log('RENAME: cant remove file: ', err, path); } } saveServerFiles(); } catch (error) { logError(`Error saving file ${path}:`, error); if (!error.message.includes('Name is not allowed')) { failedAtLeastOnce = true; } } } // Apply server-side deletions: drop any local file that was deleted on // server. Local copies older than the recorded deletedAt are deleted. // If local change is newer than deletedAt - we skip deletion. if (response.deleted) { const serverTime = server['serverTime'] || 0; for (const [relPath, deletedAt] of Object.entries(response.deleted)) { const path = joinPath('/', relPath); const local = getMemFile(path); if (!local) continue; if (local.lastModified > deletedAt) continue; try { log('SYNC: deleting locally due to server fslog:', path); // await remove(path); // removeServerFile(path); } catch (err) { logError('SYNC: cant delete locally:', err, path); } } server['serverTime'] = serverTime; saveServerFiles(); } // Only move timestamp pointers when we were able to sync all the files. // Otherwise we can have situation when we synced files only partially, // let's say serverFiles is having only half files from server, then they // will be sent by subsequent syncTexts call, because collectLocalFiles // would report them as new. if (!failedAtLeastOnce) { log('BATCH sync ok, moving timestamps'); server['timestamps'] = response.timestamps; saveServerFiles(); } else { log("BATCH sync error, timestamps aren't moved"); } } catch (error) { logError("Can't sync:", error.message) } log('Sync completed in ' + (performance.now() - startTime) + 'ms'); isSyncingFiles = false; } async function syncLocalFileWithServer(path) { // We should have at least one 200 response from service. // The first 200 response we get from /token, meaning that // our application is linked to the server for sync. if (!hasLastServerOk()) { return } if (isSyncingFileWithServer[path]) { needsResyncWithServer[path] = true; return; } isSyncingFileWithServer[path] = true; try { let file = await (await getFileHandle(path)).getFile(); // TODO we might only need to send content when modifying let content = await file.text(); let serverTimestamp = getServerFile(path)?.lastModified || 0; let serverFile = {}; const clientLastModified = file.lastModified; const { json, error } = await post('syncFile', { path: path, lastModified: serverTimestamp, clientLastModified: clientLastModified, // We take the last client timestamp known to the server. Server can // decide whether the file was modified on client or not. clientLastSynced: getServerFile(path)?.lastClientModified || 0, content: content, }); if (error) { logError(`syncText ${path} failed:`, error); if (window.currentEditor?.path === path) { renderSyncStatus('error'); } return; } // The server acked `content` - but only report "synced" if the source // of truth still holds exactly that; edits made mid-flight stay orange // until the next tick confirms them. In chat mode messages are written // straight to the file (not through the editor), so compare against a // fresh read of the file; for the editor, getCurrentContent() (not // getValue()) because the `# Filename` header line is stripped from // what is written and synced. if (window.currentEditor?.path === path) { const truth = (typeof isChat !== 'undefined' && isChat && path === CHAT_PATH) ? await (await (await getFileHandle(path)).getFile()).text() : getCurrentContent(); if (truth === content) { lastSyncOkAt = Date.now(); renderSyncStatus('ok'); } } // For the cases when server was updated only on server, we move lastSyncedAt pointer, // meaning that there are no "dirty" changes on client. if (json.status === 'notModified') { setServerFileLastClientModified(path, clientLastModified); return; } if (json.status === 'updatedOnServer') { // TODO maybe RC here? When file was updated, but during this time we already changed it setServerFile(path, content, json.lastModified, clientLastModified); log(`Moved lastModified for ${path} with timestamp ${json.lastModified}`, json); saveServerFiles(); return; } // if status is "merged" or "ok", it means it means we have changes to write. serverFile = json; // We have either of these: // 1) New file from server // 2) Modified only on server // 3) Merged on server const lastClientModified = await writeIfContentIsDifferent(path, serverFile.content); setServerFile(path, serverFile.content, serverFile.lastModified, lastClientModified); log(`Saved server file for ${path} with timestamp ${serverFile.lastModified}`); saveServerFiles(); if (path === editor.path) { log('Opening file after sync'); await openFile(path); } if (path === editor2.path) { log('Opening file after sync in editor2'); await openFile(path, true, 'editor2-textarea'); } log('File synced with server'); } finally { isSyncingFileWithServer[path] = false; if (needsResyncWithServer[path]) { needsResyncWithServer[path] = false; await syncLocalFileWithServer(path); } } } async function syncMediaFiles() { // We should have at least one 200 response from service. // The first 200 response we get from /token, meaning that // our application is linked to the server for sync. if (!hasLastServerOk()) { return; } if (files === undefined) { return; } if (isSyncingMedia) { return; } if (debug) { return; } isSyncingMedia = true; const startTime = performance.now(); const mediaTimestamp = server['mediaTimestamp'] || 0; if (mediaTimestamp !== 0) { // Send new files from client to server let newMedias = await collectNewMediaFiles(); for (const mediaFilename of newMedias) { try { // TODO improve that hardcode :D let fileHandle = await getFileHandle('media/' + mediaFilename) let file = await fileHandle.getFile(); if (file.size > MAX_MEDIA_SIZE) { logError(`Skipping ${mediaFilename}: ${(file.size / 1024 / 1024).toFixed(1)} MB exceeds ${(MAX_MEDIA_SIZE / 1024 / 1024).toFixed(0)} MB limit`); continue; } const arrayBuffer = await file.arrayBuffer(); const uint8Array = new Uint8Array(arrayBuffer); let binaryString = ''; for (let i = 0; i < uint8Array.length; i++) { binaryString += String.fromCharCode(uint8Array[i]); } const base64String = btoa(binaryString); // Raw fetch: the upload reply is empty (not JSON), so the // JSON-based post() helper doesn't apply here. const response = await fetch(`${API_URL}/syncMediaFile`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'Version': getCurrentVersion(), }, body: JSON.stringify({ filename: mediaFilename, data: base64String, }), }); if (!response.ok) { let body = ''; try { body = await response.text(); } catch (_) {} logError(`Failed to sync media file ${mediaFilename}: ${response.status} ${response.statusText}: ${body}`.trim()); } else { markServerOk(); server['media'][mediaFilename] = { isFile: true, lastModified: 0, // We don't track binary files modifications. }; saveServerFiles(); log(`Successfully synced media file: ${mediaFilename}`); } } catch (error) { logError(`Error syncing media file ${mediaFilename}:`, error); } } } try { const { json: serverData, error } = await post('syncMediaFilenames', { timestamp: mediaTimestamp, }); if (error) { logError('syncMediaFilenames failed:', error); isSyncingMedia = false; return; } let filesProcessed = 0; for (const fileInfo of serverData.files) { const {filename, lastModified} = fileInfo; log(`Downloading media file: ${filename}`); try { // Raw fetch: this endpoint streams a binary blob, not JSON, // so the JSON-based post() helper doesn't apply here. const response = await fetch(`${API_URL}/syncMediaFile`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ filename: filename, timestamp: mediaTimestamp }) }); if (!response.ok) { logError(`Failed to download ${filename}: ${response.status}`); continue; } markServerOk(); const blob = await response.blob(); await saveMediaFile(`media/${filename}`, blob, lastModified); filesProcessed++; } catch (error) { logError(`Error processing media file ${filename}:`, error); } } log(`Media sync completed in ${(performance.now() - startTime).toFixed(2)}ms. Downloaded ${filesProcessed} files.`); } catch (error) { logError('Network error during media sync:', error.message); } isSyncingMedia = false; } // Saves media file and moves pointer async function saveMediaFile(path, blob, lastModified) { const fileHandle = await getFileHandle(path, true); if (fileHandle === null) { log(`Malformed name for ${path}, skipping file...`); return; } // Check if file exists already try { const file = await fileHandle.getFile(); const fileExists = file.size > 0; if (fileExists) { if (server['mediaTimestamp'] === undefined || lastModified > server['mediaTimestamp']) { server['mediaTimestamp'] = lastModified; } server['media'][file.name] = { isFile: true, lastModified: lastModified, } saveServerFiles(); log(`File ${path} already exists and is up to date, skipping...`); return; } } catch (error) { log(`File ${path} doesn't exist or can't be read, will create it`); } try { const parts = path.split('/'); let filename = parts.pop(); const writable = await fileHandle.createWritable(); await writable.write(blob); await writable.close(); log(`Successfully wrote media file: ${path}`); if (lastModified > server['mediaTimestamp']) { server['mediaTimestamp'] = lastModified; } server['media'][filename] = { isFile: true, lastModified: lastModified, } saveServerFiles(); // Load file handle into files files['media/'][filename] = {isFile: true, handle: fileHandle}; fileHandle.getFile().then(file => { files['media/'][filename].lastModified = file.lastModified; }); getImageUrl(fileHandle).then(imageUrl => { files['media/'][filename].imageUrl = imageUrl; }); } catch (error) { logError(`Error writing media file ${path}:`, error); throw error; } } // TODO rename textFiles? async function collectModifiedAndDeletedFiles() { const modifiedFiles = []; const existingFiles = {}; const promises = []; // Freeze paths to prevent RC. Current file can change during collecting. const editorPath = editor.path; const editor2Path = editor2.path; log('Frozen paths:', editorPath, editor2Path); walk(files, (path, isFile) => { if (!isFile) { return; } if (path.startsWith('/media/') || path === LOG_PATH) { return; } // Binary media files (images, video) anywhere in the tree must not // go through the text sync path - file.text() corrupts them and the // JSON-escaped string can balloon past MaxFilenamesSize, returning // 400 from syncFilenames. They sync via syncMediaFile when in /media/. if (isMediaPath(path)) { return; } // TODO write tests for that? if (path === editorPath || path === editor2Path) { log('Skip sending current file: ' + path); return; } const promise = getFileStatus(path) .then(result => { if (result.status === 'modified' || result.status === 'new') { modifiedFiles.push(result); } if (result.status !== 'error') { existingFiles[result.path] = true; } else { console.warn(`Error getting status for file ${path}:`, result); } }); promises.push(promise); }); await Promise.all(promises); // Find deleted files that are in server files but not in existing files. let deleted = []; walk(server.files, (path, isFile) => { if (!isFile) { return; } // Chromium doesn't support those chars on any OS if (/[<>:'|?*\\/\x00-\x1F\x7F]/.test(toFilename(path))) { return; } // Skip current files. if (path === editorPath || path === editor2Path) { return; } if (existingFiles[path] === undefined) { log('DELETED because not in existing or modified files:', path); log('Current editors paths:', editor.path, editor2.path); // Log files entry log('Mem file:', getMemFile(path)); deleted.push(path); } }); // If there are too many deleted files, prob something is wrong, throw an alert if (deleted.length > 20) { alert(`Trying to delete more than 20 deleted files during sync (${deleted.length}). I won't proceed, please resolve the issue manually. Probably "files" is empty in local stroage for some reason, but there are actual files on the disk.`); // Show first 10 files alert('First 10 files: \n' + deleted.slice(0, 10).join('\n')); localStorage.removeItem("server"); throw new Error('Too many deleted files during sync, aborting.'); deleted = []; } return { modified: modifiedFiles, deleted: deleted, }; } async function collectNewMediaFiles() { if (!files['media/']) { return { newMedia: [], }; } const newMediaFiles = []; for (const filename in files['media/']) { if (server['media'] === undefined || !(filename in server['media'])) { newMediaFiles.push(filename); } } log('NEW FILENAMES', newMediaFiles); return newMediaFiles; } async function getFileStatus(path) { let content; try { const memFile = getMemFile(path); let fileHandle = memFile?.handle; // First try to get the file from memory, if not found try to open from local fs. if (!(fileHandle instanceof FileSystemFileHandle)) { fileHandle = await getFileHandle(path, false); } if (!(fileHandle instanceof FileSystemFileHandle)) { logError("Error while getting file handle for status check", path); return { status: 'error', } } const file = await memFile.handle.getFile(); content = await file.text(); } catch (error) { logError('Error while getting status for file', path, error); return { status: 'error', } } // TODO why path is stored at all? // const path = serverFiles?.files?.[dir]?.[filename]?.path; let serverFile = getServerFile(path); // log('STATUS', path, serverFile); if (serverFile === null) { log('NEW LOCAL FILE ' + path); return { status: 'new', content: content, path: path, lastModified: 0 // new file } } const serverHash = serverFile.hash; const serverTime = serverFile.lastModified; if (serverHash !== hash(content)) { log('NEW MODIFIED LOCAL FILE ' + path); return { status: 'modified', content: content, path: path, lastModified: serverTime, }; } return { status: 'notModified', path: path, }; } // TODO split into two, sometimes we need just compare async function isContentEqual(path, content) { let fileHandle = await getFileHandle(path); if (fileHandle === null) { // TODO fix once Chromium fixes the bug console.warn('Malformed name, skipping file...'); return false; } let file = await fileHandle.getFile() let clientHash = hash(normNewLines(await file.text())); let serverHash = hash(normNewLines(content)); if (clientHash !== serverHash) { // Log string differences in content, not hash const clientContent = normNewLines(await file.text()); const serverContent = normNewLines(content); const clientLines = clientContent.split('\n'); const serverLines = serverContent.split('\n'); const diff = []; for (let i = 0; i < Math.max(clientLines.length, serverLines.length); i++) { const clientLine = clientLines[i] || ''; const serverLine = serverLines[i] || ''; if (clientLine !== serverLine) { diff.push(`Line ${i + 1}: '${clientLine}' vs '${serverLine}'`); } } // log(diff); return false; } else { return true; } } function getImageExtension(mimeType) { const extensions = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', 'video/mp4': 'mp4', 'video/webm': 'webm', 'video/quicktime': 'mov', 'audio/mpeg': 'mp3', 'audio/mp3': 'mp3', 'audio/ogg': 'ogg', 'audio/wav': 'wav', 'audio/x-wav': 'wav', // audio-only WebM gets the dedicated .weba extension so fold-image.js // routes it through the