Skip to content

Commit cc21f45

Browse files
committed
fix: bidirectional Nostr sync with deletion tracking
- Add timestamp comparison for bidirectional sync (download newer remote, upload newer local) - Implement deletion tracking in localStorage for synced file deletions - Handle folder deletions by tracking all files inside the folder - Add file recovery feature in Settings to restore deleted files from Nostr - Add notifications button to mobile header and nav - Bump version to 0.8.1
1 parent a6fc435 commit cc21f45

10 files changed

Lines changed: 530 additions & 17 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "onyx",
3-
"version": "0.8.0",
3+
"version": "0.8.1",
44
"description": "Open source knowledge base and note-taking app",
55
"type": "module",
66
"scripts": {

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "onyx"
3-
version = "0.8.0"
3+
version = "0.8.1"
44
description = "Open source knowledge base and note-taking app"
55
authors = ["you"]
66
license = "MIT"

src-tauri/src/lib.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,17 @@ fn create_folder(path: String) -> Result<(), String> {
269269
fs::create_dir_all(&path).map_err(|e| e.to_string())
270270
}
271271

272+
#[tauri::command]
273+
fn get_file_modified_time(path: String) -> Result<u64, String> {
274+
let metadata = fs::metadata(&path).map_err(|e| e.to_string())?;
275+
let modified = metadata.modified().map_err(|e| e.to_string())?;
276+
// Convert to Unix timestamp (seconds since epoch)
277+
let duration = modified
278+
.duration_since(std::time::UNIX_EPOCH)
279+
.map_err(|e| e.to_string())?;
280+
Ok(duration.as_secs())
281+
}
282+
272283
#[tauri::command]
273284
fn file_exists(path: String) -> bool {
274285
Path::new(&path).exists()
@@ -1882,6 +1893,7 @@ pub fn run() {
18821893
read_binary_file,
18831894
create_file,
18841895
create_folder,
1896+
get_file_modified_time,
18851897
file_exists,
18861898
delete_file,
18871899
rename_file,

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
33
"productName": "Onyx",
4-
"version": "0.8.0",
4+
"version": "0.8.1",
55
"identifier": "com.onyxnotes.dev",
66
"build": {
77
"frontendDist": "../dist",

src/App.tsx

Lines changed: 154 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,11 +1010,50 @@ const App: Component = () => {
10101010
}
10111011
};
10121012

1013-
const handleFileDeleted = (path: string) => {
1013+
const handleFileDeleted = async (path: string) => {
10141014
const idx = tabs().findIndex(t => t.path === path);
10151015
if (idx >= 0) {
10161016
closeTab(idx);
10171017
}
1018+
1019+
// Track deletion for Nostr sync - store relative path
1020+
const vault = vaultPath();
1021+
if (vault) {
1022+
const deletedPaths = JSON.parse(localStorage.getItem('deleted_paths') || '[]') as string[];
1023+
const relativePath = path.replace(vault + '/', '');
1024+
1025+
// For folder deletions, track all files that were inside
1026+
try {
1027+
const engine = getSyncEngine();
1028+
const signer = engine.getSigner();
1029+
if (signer) {
1030+
const vaults = await engine.fetchVaults();
1031+
const vaultData = vaults[0];
1032+
if (vaultData) {
1033+
// Find all files in vault that start with this folder path
1034+
const folderPrefix = relativePath + '/';
1035+
const filesInFolder = vaultData.data.files
1036+
.filter(f => f.path === relativePath || f.path.startsWith(folderPrefix))
1037+
.map(f => f.path);
1038+
1039+
for (const filePath of filesInFolder) {
1040+
if (!deletedPaths.includes(filePath)) {
1041+
deletedPaths.push(filePath);
1042+
}
1043+
}
1044+
}
1045+
}
1046+
} catch {
1047+
// Could not check for folder contents, will track path as-is
1048+
}
1049+
1050+
// Also track the path itself (in case it's a file, or as a fallback for folders)
1051+
if (!deletedPaths.includes(relativePath)) {
1052+
deletedPaths.push(relativePath);
1053+
}
1054+
1055+
localStorage.setItem('deleted_paths', JSON.stringify(deletedPaths));
1056+
}
10181057
};
10191058

10201059
const toggleBookmark = async (path: string) => {
@@ -1244,18 +1283,18 @@ const App: Component = () => {
12441283
vault = await engine.createVault('My Notes', 'Default vault');
12451284
}
12461285

1247-
// Get local files
1286+
// Get local files with their full paths
12481287
const entries = await invoke<Array<{ name: string; path: string; isDirectory: boolean; children?: unknown[] }>>('list_files', { path: vaultPath() });
12491288

1250-
const localFiles: { path: string; content: string }[] = [];
1289+
const localFiles: { path: string; fullPath: string; content: string }[] = [];
12511290
const processEntries = async (entries: Array<{ name: string; path: string; isDirectory: boolean; children?: unknown[] }>) => {
12521291
for (const entry of entries) {
12531292
if (entry.isDirectory && entry.children) {
12541293
await processEntries(entry.children as typeof entries);
12551294
} else if (entry.name.endsWith('.md')) {
12561295
const content = await invoke<string>('read_file', { path: entry.path });
12571296
const relativePath = entry.path.replace(vaultPath()! + '/', '');
1258-
localFiles.push({ path: relativePath, content });
1297+
localFiles.push({ path: relativePath, fullPath: entry.path, content });
12591298
}
12601299
}
12611300
};
@@ -1265,21 +1304,103 @@ const App: Component = () => {
12651304
const remoteFiles = await engine.fetchVaultFiles(vault);
12661305
const remoteFileMap = new Map(remoteFiles.map(f => [f.data.path, f]));
12671306

1268-
// Sync files
1307+
// Get locally deleted files that need to be synced
1308+
const locallyDeletedPaths = JSON.parse(localStorage.getItem('deleted_paths') || '[]') as string[];
1309+
const localFilePathSet = new Set(localFiles.map(f => f.path));
1310+
1311+
// Sync files - compare timestamps to determine which version is newer
12691312
let downloadedCount = 0;
1313+
let uploadedCount = 0;
1314+
let deletedCount = 0;
12701315

12711316
for (const localFile of localFiles) {
12721317
const remoteFile = remoteFileMap.get(localFile.path);
1273-
if (!remoteFile || remoteFile.data.content !== localFile.content) {
1274-
const result = await engine.publishFile(vault, localFile.path, localFile.content, remoteFile);
1318+
1319+
if (remoteFile) {
1320+
// File exists both locally and remotely
1321+
if (remoteFile.data.content !== localFile.content) {
1322+
// Content differs - compare timestamps to decide direction
1323+
try {
1324+
const localModifiedTime = await invoke<number>('get_file_modified_time', { path: localFile.fullPath });
1325+
const remoteModifiedTime = remoteFile.data.modified;
1326+
1327+
console.log(`[Sync] File ${localFile.path}: local=${localModifiedTime}, remote=${remoteModifiedTime}`);
1328+
1329+
if (remoteModifiedTime > localModifiedTime) {
1330+
// Remote is newer - download
1331+
console.log(`[Sync] Downloading newer remote version: ${localFile.path}`);
1332+
await invoke('write_file', { path: localFile.fullPath, content: remoteFile.data.content });
1333+
downloadedCount++;
1334+
} else {
1335+
// Local is newer or same time - upload
1336+
console.log(`[Sync] Uploading newer local version: ${localFile.path}`);
1337+
const result = await engine.publishFile(vault, localFile.path, localFile.content, remoteFile);
1338+
vault = result.vault;
1339+
uploadedCount++;
1340+
}
1341+
} catch (err) {
1342+
// If we can't get local modified time, default to uploading
1343+
console.warn(`[Sync] Could not get modified time for ${localFile.path}, uploading:`, err);
1344+
const result = await engine.publishFile(vault, localFile.path, localFile.content, remoteFile);
1345+
vault = result.vault;
1346+
uploadedCount++;
1347+
}
1348+
}
1349+
// Remove from map - we've handled this file
1350+
remoteFileMap.delete(localFile.path);
1351+
} else {
1352+
// Local-only file - upload to remote
1353+
console.log(`[Sync] Uploading new local file: ${localFile.path}`);
1354+
const result = await engine.publishFile(vault, localFile.path, localFile.content);
12751355
vault = result.vault;
1356+
uploadedCount++;
1357+
}
1358+
}
1359+
1360+
// Process local deletions - sync them to the vault
1361+
const pathsToKeepTracking: string[] = [];
1362+
1363+
for (const deletedPath of locallyDeletedPaths) {
1364+
const inRemoteMap = remoteFileMap.has(deletedPath);
1365+
const inLocalFiles = localFilePathSet.has(deletedPath);
1366+
1367+
// Only process if the file exists on remote and not locally
1368+
if (inRemoteMap && !inLocalFiles) {
1369+
try {
1370+
vault = await engine.deleteFile(vault, deletedPath);
1371+
deletedCount++;
1372+
} catch {
1373+
// Keep tracking this path since deletion failed
1374+
pathsToKeepTracking.push(deletedPath);
1375+
}
1376+
} else if (inLocalFiles) {
1377+
// File still exists locally (was recreated?), keep tracking
1378+
pathsToKeepTracking.push(deletedPath);
12761379
}
1277-
remoteFileMap.delete(localFile.path);
1380+
// Remove from remoteFileMap so we don't re-download it
1381+
remoteFileMap.delete(deletedPath);
12781382
}
1383+
1384+
// Update the locally deleted paths - only keep those that need continued tracking
1385+
localStorage.setItem('deleted_paths', JSON.stringify(pathsToKeepTracking));
12791386

1280-
// Download remote-only files
1387+
1388+
// Download remote-only files (files that exist on remote but not locally)
1389+
// Skip files that were deleted locally or are in the vault's deleted list
12811390
for (const [path, remoteFile] of remoteFileMap) {
1282-
if (vault.data.deleted?.some(d => d.path === path)) continue;
1391+
// Skip if in vault's deleted list
1392+
if (vault.data.deleted?.some(d => d.path === path)) {
1393+
continue;
1394+
}
1395+
1396+
// Skip if locally deleted (but not yet synced)
1397+
// Also check for folder deletions - if any deleted path is a prefix of this file path
1398+
const isLocallyDeleted = locallyDeletedPaths.some(deletedPath =>
1399+
path === deletedPath || path.startsWith(deletedPath + '/')
1400+
);
1401+
if (isLocallyDeleted) {
1402+
continue;
1403+
}
12831404

12841405
const fullPath = `${vaultPath()}/${path}`;
12851406
const parentDir = fullPath.substring(0, fullPath.lastIndexOf('/'));
@@ -1289,12 +1410,30 @@ const App: Component = () => {
12891410
await invoke('write_file', { path: fullPath, content: remoteFile.data.content });
12901411
downloadedCount++;
12911412
}
1413+
1414+
console.log(`[Sync] Complete: ${uploadedCount} uploaded, ${downloadedCount} downloaded, ${deletedCount} deleted`);
12921415

12931416
setSyncStatus('idle');
12941417

1295-
// Refresh sidebar if files were downloaded
1418+
// Refresh sidebar and reload open tabs if files were downloaded
12961419
if (downloadedCount > 0) {
12971420
refreshSidebar?.();
1421+
1422+
// Reload any open tabs that may have been updated
1423+
const currentTabs = tabs();
1424+
for (let i = 0; i < currentTabs.length; i++) {
1425+
const tab = currentTabs[i];
1426+
try {
1427+
const newContent = await invoke<string>('read_file', { path: tab.path });
1428+
if (newContent !== tab.content) {
1429+
setTabs(prev => prev.map((t, idx) =>
1430+
idx === i ? { ...t, content: newContent, isDirty: false } : t
1431+
));
1432+
}
1433+
} catch {
1434+
// File may have been deleted or moved
1435+
}
1436+
}
12981437
}
12991438
} catch (err) {
13001439
console.error('Sync failed:', err);
@@ -1407,6 +1546,8 @@ const App: Component = () => {
14071546
if (tab === 'files' || tab === 'search' || tab === 'bookmarks') {
14081547
setSidebarView(tab);
14091548
setMobileDrawerOpen(true);
1549+
} else if (tab === 'notifications') {
1550+
setShowNotifications(true);
14101551
} else if (tab === 'settings') {
14111552
setShowSettings(true);
14121553
}
@@ -1428,6 +1569,8 @@ const App: Component = () => {
14281569
title={currentFileTitle()}
14291570
isDirty={currentTab()?.isDirty}
14301571
onMenuClick={() => setMobileDrawerOpen(true)}
1572+
onNotificationsClick={() => setShowNotifications(true)}
1573+
unreadNotifications={unreadShareCount()}
14311574
onSyncClick={() => handleStatusBarSync()}
14321575
syncStatus={syncStatus()}
14331576
/>

0 commit comments

Comments
 (0)