mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-09 17:26:26 +03:00
Compare commits
11 Commits
fix/issue-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b94f0ce47 | ||
|
|
08b55b7677 | ||
|
|
21d904cfd6 | ||
|
|
00de35e5d4 | ||
|
|
f2976bc89a | ||
|
|
b65deede79 | ||
|
|
9203bdd40e | ||
|
|
fcd30d07be | ||
|
|
cfb75a05db | ||
|
|
a3a09df3c8 | ||
|
|
4393a49cba |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "obsidian-livesync",
|
||||
"name": "Self-hosted LiveSync",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"minAppVersion": "1.7.2",
|
||||
"description": "Community implementation of self-hosted livesync. Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"author": "vorotamoroz",
|
||||
|
||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"src/apps/cli",
|
||||
@@ -23,7 +23,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.8",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.9",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
@@ -4775,9 +4775,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.8.tgz",
|
||||
"integrity": "sha512-Kn1AF41h2Dog37ThU7KgLcKxItCCerLEBWg1eSGAUoTk3TyPBYynvtmVFwWhy6LCePcuwB/+x7EQNTL3Sgzkyg==",
|
||||
"version": "0.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.9.tgz",
|
||||
"integrity": "sha512-qacNUbRAqt32j756XTTdI5K+/co8oc2BKjzsUAhEsDHC8tc18q4a3cQrUHNJuxYO+nYnx0FKhf9lHMZcCX+Scg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
@@ -15924,7 +15924,7 @@
|
||||
},
|
||||
"src/apps/cli": {
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"version": "1.0.9-cli",
|
||||
"version": "1.0.10-cli",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"minimatch": "^10.2.5",
|
||||
@@ -15949,7 +15949,7 @@
|
||||
},
|
||||
"src/apps/webapp": {
|
||||
"name": "livesync-webapp",
|
||||
"version": "1.0.9-webapp",
|
||||
"version": "1.0.10-webapp",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
@@ -15961,7 +15961,7 @@
|
||||
}
|
||||
},
|
||||
"src/apps/webpeer": {
|
||||
"version": "1.0.9-webpeer",
|
||||
"version": "1.0.10-webpeer",
|
||||
"dependencies": {
|
||||
"octagonal-wheels": "^0.1.52"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsidian-livesync",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"description": "Reflect your vault changes to some other devices immediately. Please make sure to disable other synchronize solutions to avoid content corruption or duplication.",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
@@ -177,7 +177,7 @@
|
||||
"@smithy/types": "^4.14.3",
|
||||
"@smithy/util-retry": "^4.4.5",
|
||||
"@vrtmrz/browser-ui-kit": "0.1.0",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.8",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.9",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.3",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
|
||||
@@ -121,8 +121,10 @@ class CLIWatchAdapter implements IStorageEventWatchAdapter {
|
||||
return {
|
||||
path: path.relative(this.basePath, filePath).replace(/\\/g, "/") as FilePath,
|
||||
stat: {
|
||||
ctime: stats?.ctimeMs ?? Date.now(),
|
||||
mtime: stats?.mtimeMs ?? Date.now(),
|
||||
// Floor to integer milliseconds; Linux fs.Stats.*Ms carry sub-millisecond
|
||||
// precision, and timestamps are stored as integer ms everywhere else.
|
||||
ctime: Math.floor(stats?.ctimeMs ?? Date.now()),
|
||||
mtime: Math.floor(stats?.mtimeMs ?? Date.now()),
|
||||
size: stats?.size ?? 0,
|
||||
type: "file",
|
||||
},
|
||||
|
||||
@@ -84,6 +84,27 @@ describe("CLIStorageEventManagerAdapter", () => {
|
||||
expect(created.stat?.size).toBe(42);
|
||||
});
|
||||
|
||||
it("floors sub-millisecond stat timestamps so mobile clients do not receive floats", async () => {
|
||||
const basePath = "/vault/base";
|
||||
const adapter = new CLIStorageEventManagerAdapter(basePath, undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
await adapter.watch.beginWatch(handlers);
|
||||
|
||||
const addCallback = mockWatcher.on.mock.calls.find(([event]) => event === "add")![1] as (
|
||||
filePath: string,
|
||||
stats: any
|
||||
) => void;
|
||||
|
||||
// Linux fs.Stats carry nanosecond-derived sub-millisecond precision.
|
||||
const floatStats = { ctimeMs: 1778511180024.462, mtimeMs: 1778511180999.913, size: 7 };
|
||||
addCallback(`${basePath}/note.md`, floatStats);
|
||||
|
||||
const created = (handlers.onCreate as ReturnType<typeof vi.fn>).mock.calls[0][0] as NodeFile;
|
||||
expect(created.stat?.ctime).toBe(1778511180024);
|
||||
expect(created.stat?.mtime).toBe(1778511180999);
|
||||
});
|
||||
|
||||
it("close() calls watcher.close()", async () => {
|
||||
const adapter = new CLIStorageEventManagerAdapter("/base", undefined, true);
|
||||
const handlers = makeHandlers();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "self-hosted-livesync-cli",
|
||||
"private": true,
|
||||
"version": "1.0.9-cli",
|
||||
"version": "1.0.10-cli",
|
||||
"main": "dist/index.cjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "livesync-webapp",
|
||||
"private": true,
|
||||
"version": "1.0.9-webapp",
|
||||
"version": "1.0.10-webapp",
|
||||
"type": "module",
|
||||
"description": "Browser-based Self-hosted LiveSync using FileSystem API",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webpeer",
|
||||
"private": true,
|
||||
"version": "1.0.9-webpeer",
|
||||
"version": "1.0.10-webpeer",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/commo
|
||||
import type { IStorageAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
|
||||
import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
|
||||
import type { Stat, App } from "obsidian";
|
||||
import { toIntegerTimestamps } from "./sanitizeWriteOptions";
|
||||
|
||||
/**
|
||||
* Storage adapter implementation for Obsidian
|
||||
@@ -40,15 +41,15 @@ export class ObsidianStorageAdapter implements IStorageAdapter<Stat> {
|
||||
}
|
||||
|
||||
async write(path: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.adapter.write(path, data, options);
|
||||
return await this.app.vault.adapter.write(path, data, toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
async writeBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), options);
|
||||
return await this.app.vault.adapter.writeBinary(path, toArrayBuffer(data), toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
async append(path: string, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.adapter.append(path, data, options);
|
||||
return await this.app.vault.adapter.append(path, data, toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
list(basePath: string): Promise<{ files: string[]; folders: string[] }> {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { App } from "obsidian";
|
||||
import { ObsidianStorageAdapter } from "./ObsidianStorageAdapter";
|
||||
|
||||
describe("ObsidianStorageAdapter", () => {
|
||||
it("floors write-option timestamps before calling Obsidian storage methods", async () => {
|
||||
const write = vi.fn().mockResolvedValue(undefined);
|
||||
const writeBinary = vi.fn().mockResolvedValue(undefined);
|
||||
const append = vi.fn().mockResolvedValue(undefined);
|
||||
const app = {
|
||||
vault: {
|
||||
adapter: {
|
||||
write,
|
||||
writeBinary,
|
||||
append,
|
||||
},
|
||||
},
|
||||
} as unknown as App;
|
||||
const adapter = new ObsidianStorageAdapter(app);
|
||||
const options = { ctime: 1778511180024.462, mtime: 1778511180999.913 };
|
||||
const expectedOptions = { ctime: 1778511180024, mtime: 1778511180999 };
|
||||
|
||||
await adapter.write("note.md", "text", options);
|
||||
await adapter.writeBinary("image.bin", new ArrayBuffer(0), options);
|
||||
await adapter.append("log.md", "text", options);
|
||||
|
||||
expect(write).toHaveBeenCalledWith("note.md", "text", expectedOptions);
|
||||
expect(writeBinary).toHaveBeenCalledWith("image.bin", expect.any(ArrayBuffer), expectedOptions);
|
||||
expect(append).toHaveBeenCalledWith("log.md", "text", expectedOptions);
|
||||
expect(options).toEqual({ ctime: 1778511180024.462, mtime: 1778511180999.913 });
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/commo
|
||||
import type { IVaultAdapter } from "@vrtmrz/livesync-commonlib/compat/serviceModules/adapters";
|
||||
import { toArrayBuffer } from "@vrtmrz/livesync-commonlib/compat/serviceModules/FileAccessBase";
|
||||
import type { TFile, App, TFolder } from "obsidian";
|
||||
import { toIntegerTimestamps } from "./sanitizeWriteOptions";
|
||||
|
||||
/**
|
||||
* Vault adapter implementation for Obsidian
|
||||
@@ -23,19 +24,19 @@ export class ObsidianVaultAdapter implements IVaultAdapter<TFile, TFolder> {
|
||||
}
|
||||
|
||||
async modify(file: TFile, data: string, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.modify(file, data, options);
|
||||
return await this.app.vault.modify(file, data, toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
async modifyBinary(file: TFile, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<void> {
|
||||
return await this.app.vault.modifyBinary(file, toArrayBuffer(data), options);
|
||||
return await this.app.vault.modifyBinary(file, toArrayBuffer(data), toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
async create(path: string, data: string, options?: UXDataWriteOptions): Promise<TFile> {
|
||||
return await this.app.vault.create(path, data, options);
|
||||
return await this.app.vault.create(path, data, toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
async createBinary(path: string, data: ArrayBuffer, options?: UXDataWriteOptions): Promise<TFile> {
|
||||
return await this.app.vault.createBinary(path, toArrayBuffer(data), options);
|
||||
return await this.app.vault.createBinary(path, toArrayBuffer(data), toIntegerTimestamps(options));
|
||||
}
|
||||
|
||||
async rename(file: TFile, newPath: string): Promise<void> {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import type { App, TFile } from "obsidian";
|
||||
import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter";
|
||||
|
||||
describe("ObsidianVaultAdapter.read", () => {
|
||||
describe("ObsidianVaultAdapter", () => {
|
||||
it("preserves a UTF-8 BOM so the content size matches the file stat", async () => {
|
||||
const path = "Transcripts/字幕.md";
|
||||
const contentWithoutBom = "字幕の検証行です。\n";
|
||||
@@ -34,4 +34,34 @@ describe("ObsidianVaultAdapter.read", () => {
|
||||
expect(adapterRead).toHaveBeenCalledWith(path);
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("floors write-option timestamps before calling Obsidian vault methods", async () => {
|
||||
const modify = vi.fn().mockResolvedValue(undefined);
|
||||
const modifyBinary = vi.fn().mockResolvedValue(undefined);
|
||||
const create = vi.fn().mockResolvedValue({});
|
||||
const createBinary = vi.fn().mockResolvedValue({});
|
||||
const app = {
|
||||
vault: {
|
||||
modify,
|
||||
modifyBinary,
|
||||
create,
|
||||
createBinary,
|
||||
},
|
||||
} as unknown as App;
|
||||
const file = { path: "note.md" } as TFile;
|
||||
const adapter = new ObsidianVaultAdapter(app);
|
||||
const options = { ctime: 1778511180024.462, mtime: 1778511180999.913 };
|
||||
const expectedOptions = { ctime: 1778511180024, mtime: 1778511180999 };
|
||||
|
||||
await adapter.modify(file, "text", options);
|
||||
await adapter.modifyBinary(file, new ArrayBuffer(0), options);
|
||||
await adapter.create("created.md", "text", options);
|
||||
await adapter.createBinary("created.bin", new ArrayBuffer(0), options);
|
||||
|
||||
expect(modify).toHaveBeenCalledWith(file, "text", expectedOptions);
|
||||
expect(modifyBinary).toHaveBeenCalledWith(file, expect.any(ArrayBuffer), expectedOptions);
|
||||
expect(create).toHaveBeenCalledWith("created.md", "text", expectedOptions);
|
||||
expect(createBinary).toHaveBeenCalledWith("created.bin", expect.any(ArrayBuffer), expectedOptions);
|
||||
expect(options).toEqual({ ctime: 1778511180024.462, mtime: 1778511180999.913 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { UXDataWriteOptions } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
/**
|
||||
* Coerce the timestamp fields of a write-options object to integer milliseconds.
|
||||
*
|
||||
* On mobile, Obsidian forwards `mtime`/`ctime` to Capacitor's
|
||||
* Filesystem.setTimes, whose native binding casts the value to a Java `Long`.
|
||||
* A non-integer (float) timestamp makes that cast throw
|
||||
* `ClassCastException: Double cannot be cast to Long`, which crashes the app on
|
||||
* launch as soon as such a document is replicated in. Float timestamps can
|
||||
* enter the database from any client that stores `fs.Stats.mtimeMs` without
|
||||
* flooring. Flooring at the storage boundary guarantees every Obsidian write
|
||||
* carries an integer, so a float timestamp already present in the mesh cannot
|
||||
* brick the app.
|
||||
*
|
||||
* Returns a shallow copy so the caller's options object is not mutated; passes
|
||||
* `undefined` through unchanged.
|
||||
*/
|
||||
export function toIntegerTimestamps(options?: UXDataWriteOptions): UXDataWriteOptions | undefined {
|
||||
if (!options) return options;
|
||||
const sanitized: UXDataWriteOptions = { ...options };
|
||||
if (typeof sanitized.mtime === "number") sanitized.mtime = Math.floor(sanitized.mtime);
|
||||
if (typeof sanitized.ctime === "number") sanitized.ctime = Math.floor(sanitized.ctime);
|
||||
return sanitized;
|
||||
}
|
||||
17
updates.md
17
updates.md
@@ -12,6 +12,23 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Synchronisation and storage
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Fractional file timestamps no longer cause affected mobile clients to crash after synchronisation (#1087, PR #1039). Thank you to @andrewleech for the contribution!
|
||||
- Timestamps are now normalised in the command-line tool and before Obsidian's native file-system writes.
|
||||
|
||||
## 1.0.10
|
||||
|
||||
9th August, 2026
|
||||
|
||||
### Setup and compatibility
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Fast Setup now sends configured CouchDB custom headers with every changes-feed request, allowing reverse proxies such as Cloudflare Access to authenticate initial setup in the same way as ordinary synchronisation ([Commonlib PR #82](https://github.com/vrtmrz/livesync-commonlib/pull/82)). Thank you to @nimula for the contribution!
|
||||
|
||||
## 1.0.9
|
||||
|
||||
8th August, 2026
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
"1.0.6": "1.7.2",
|
||||
"1.0.7": "1.7.2",
|
||||
"1.0.8": "1.7.2",
|
||||
"1.0.9": "1.7.2"
|
||||
"1.0.9": "1.7.2",
|
||||
"1.0.10": "1.7.2"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user