mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-09-06 02:37:06 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1195629b9 | ||
|
|
0ecb73924a | ||
|
|
188b749326 | ||
|
|
6abc5cba64 | ||
|
|
045a328697 |
@@ -238,6 +238,21 @@ Commonlib owns the typed English fallback for messages requested by its services
|
||||
- Dev mode creates `ls-debug/` folder in `.obsidian/` for debug outputs (e.g., missing translations)
|
||||
- This causes pretty significant performance overhead.
|
||||
|
||||
#### Diagnostic and notice ownership
|
||||
|
||||
- A Commonlib or service operation should normally record detailed diagnostics at `LOG_LEVEL_VERBOSE` and return a typed result which lets its caller distinguish complete, partial, and failed outcomes. Do not make callers infer an outcome by parsing log text.
|
||||
- Detailed diagnostics may be long and remain in English when they are intended for tracing and the generated report. Include enough context to identify the operation, affected target, and remaining state or retry behaviour.
|
||||
- The application boundary which owns the workflow should decide whether to raise `LOG_LEVEL_NOTICE`. It has the interaction context to describe the user-visible consequence and the next useful action; an internal stage description alone is not a useful notice.
|
||||
- When several files fail, issue one concise summary notice after the operation returns. Keep the per-file paths and technical causes at verbose level so that the notice remains readable and the generated report remains traceable.
|
||||
- Commonlib should raise a notice only when its contract explicitly owns user presentation and no higher-level caller can add the required workflow context.
|
||||
|
||||
The ordinary start-up scan provides a concrete comparison:
|
||||
|
||||
- Good verbose diagnostic: `Offline scan failed to synchronise ${path} between storage and the local database; this path remains eligible for a later scan.` It identifies the operation, the two states being reconciled, the exact target, and what can happen next. Its length is appropriate for a report.
|
||||
- Notice which needs more context: `Local database initialisation did not complete. See the log for details.` It describes an internal stage, but does not tell the user whether synchronisation can continue, what may be affected, or how to obtain the detailed log.
|
||||
- Good application notice for a partial result: `Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.` It states the observable consequence, gives a proportionate action, and leaves the per-file evidence in the report.
|
||||
- Good application notice for a failed result: `Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.` It states the operational consequence without exposing the internal initialisation stage.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Service feature implementation
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
date: 2026-09-04
|
||||
commonlib-version: "0.1.21"
|
||||
self-hosted-livesync-version: "1.0.24"
|
||||
status: unreleased
|
||||
---
|
||||
|
||||
# Path component length compatibility
|
||||
|
||||
## Purpose
|
||||
|
||||
File systems place limits on each file or folder name, rather than applying one
|
||||
common limit to an entire Vault-relative path. Those limits are also expressed
|
||||
in different units. Self-hosted LiveSync therefore treats 255 UTF-8 bytes as a
|
||||
focused Android and Linux compatibility warning, not as a universal definition
|
||||
of a valid path.
|
||||
|
||||
## Basis for the 255-byte warning
|
||||
|
||||
- The Linux kernel documentation gives ext4 a maximum file-name length of
|
||||
[255 bytes](https://www.kernel.org/doc/html/latest/filesystems/ext4/directory.html).
|
||||
- The F2FS on-disk header defines
|
||||
[`F2FS_NAME_LEN` as 255](https://android.googlesource.com/kernel/common/+/88d92fb1c034922572bab93482ac9cc61d4ba43c/include/linux/f2fs_fs.h)
|
||||
and stores names in byte arrays.
|
||||
- Android's MediaProvider uses a
|
||||
[`MAX_FILENAME_BYTES` value of 255](https://android.googlesource.com/platform/packages/providers/MediaProvider/+/bae279463/src/com/android/providers/media/util/FileUtils.java)
|
||||
when building file names. Its source notes that emulated storage can write to
|
||||
ext4 through FUSE, where names are encoded as UTF-8.
|
||||
- Android 11 and later use
|
||||
[FUSE for emulated storage](https://source.android.com/docs/core/storage/fuse-passthrough),
|
||||
with requests passing through to the underlying file system.
|
||||
|
||||
Together, these provide a conservative compatibility boundary for file names
|
||||
which may reach Android or Linux storage. They do not show that every Android
|
||||
device, storage provider, or Linux file system has the same limit.
|
||||
|
||||
## Why the rule is not universal
|
||||
|
||||
Other platforms describe component limits differently. Microsoft's file-system
|
||||
comparison documents limits in
|
||||
[Unicode characters](https://learn.microsoft.com/en-us/windows/win32/fileio/filesystem-functionality-comparison),
|
||||
not UTF-8 bytes. Apple's HFS Plus format stores a name as up to
|
||||
[255 16-bit `UniChar` values](https://developer.apple.com/library/archive/technotes/tn/tn1150.html).
|
||||
Apple's APFS guidance discusses valid UTF-8 names, normalisation, and case
|
||||
sensitivity, but does not establish a universal
|
||||
[255-byte component rule](https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/APFS_Guide/FAQ/FAQ.html).
|
||||
|
||||
A name can consequently exceed 255 UTF-8 bytes and still work on one platform,
|
||||
or fail for another platform-specific reason while remaining below this
|
||||
boundary.
|
||||
|
||||
## Product policy
|
||||
|
||||
Self-hosted LiveSync applies the warning as follows:
|
||||
|
||||
1. split the Vault-relative path on `/` and inspect each non-empty component;
|
||||
2. measure each component after UTF-8 encoding;
|
||||
3. accept 255 bytes without this warning and warn at 256 bytes or more;
|
||||
4. identify every over-limit file or folder name in the active-file status;
|
||||
5. do not reject, truncate, or rename the path; and
|
||||
6. treat the result of the real storage operation as authoritative.
|
||||
|
||||
If a scan cannot process an individual file, its path is recorded in the
|
||||
verbose log and remains eligible for a later retry. Ordinary start-up may still
|
||||
become ready so that unaffected files can synchronise. Explicit Fetch and
|
||||
Rebuild operations retain strict scan completion because they establish an
|
||||
authoritative local or remote state.
|
||||
|
||||
This policy does not replace the existing checks for reserved characters,
|
||||
case collisions, ignore rules, or configured file-size limits.
|
||||
@@ -22,6 +22,9 @@ Note: The figure is drawn as single-directional, between two devices for demonst
|
||||
defines the current revision-tree and file-provenance rules.
|
||||
- [Chunk Retrieval and Waiting](design_docs/chunk_retrieval_and_waiting.md)
|
||||
defines missing-Chunk arrival and quiescence handling.
|
||||
- [Path component length compatibility](design_docs/path_component_length_compatibility.md)
|
||||
explains why 255 UTF-8 bytes is an Android and Linux compatibility warning,
|
||||
rather than a universal rule for deciding whether a path is valid.
|
||||
- [Data Compression](specs_data_compression.md) and [Garbage Collection
|
||||
V3](specs_garbage_collection.md) describe their respective storage and
|
||||
maintenance contracts.
|
||||
|
||||
Generated
+4
-4
@@ -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.21",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.22",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
@@ -4620,9 +4620,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vrtmrz/livesync-commonlib": {
|
||||
"version": "0.1.21",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.21.tgz",
|
||||
"integrity": "sha512-AGuZ3eqBP37HJXEkTSpJ5M5bvTx2lYNq+6Q5NuCPZeGdbv7g6cujGvccVR5ozGfKdHGSyFZND5x1oFS9crRhUg==",
|
||||
"version": "0.1.22",
|
||||
"resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.22.tgz",
|
||||
"integrity": "sha512-8TsFo6xgEO/uZzkQ4TE3yydUyK8pCbuMm0C4DC/8KhG8z06N6hQQmwR7bV+a3Zgt9A5tXPImTFJWTMUIxJYV2g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.808.0",
|
||||
|
||||
+1
-1
@@ -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.21",
|
||||
"@vrtmrz/livesync-commonlib": "0.1.22",
|
||||
"@vrtmrz/obsidian-plugin-kit": "0.1.4",
|
||||
"@vrtmrz/ui-interactions": "0.1.2",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
|
||||
@@ -15,7 +15,10 @@ import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_b
|
||||
import type { CLICommandContext, CLIOptions } from "./types";
|
||||
import { toArrayBuffer, toDatabaseRelativePath } from "./utils";
|
||||
import { collectPeers, openP2PHost, parseTimeoutSeconds, syncWithPeer } from "./p2p";
|
||||
import { performFullScan } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import {
|
||||
performFullScan,
|
||||
VaultScanResults,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import { UnresolvedErrorManager } from "@vrtmrz/livesync-commonlib/compat/services/base/UnresolvedErrorManager";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { fsPromises as fs, path } from "@vrtmrz/livesync-commonlib/node";
|
||||
@@ -529,7 +532,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
|
||||
writeStderrLine(standardIo, "[Command] mirror");
|
||||
const log = (msg: unknown) => writeStderrLine(standardIo, `[Mirror] ${String(msg)}`);
|
||||
const errorManager = new UnresolvedErrorManager(core.services.appLifecycle, core.services.context.events);
|
||||
return await performFullScan(core, log, errorManager, false, true);
|
||||
return (await performFullScan(core, log, errorManager, false, true)) === VaultScanResults.COMPLETED;
|
||||
}
|
||||
|
||||
if (options.command === "remote-add") {
|
||||
|
||||
@@ -4212,6 +4212,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "等待就绪...",
|
||||
"zh-tw": "正在等待就緒⋯",
|
||||
},
|
||||
"moduleLog.pathComponentTooLong": {
|
||||
def: "A file or folder name exceeds ${maxBytes} UTF-8 bytes and may not work on some Android and Linux file systems: ${components}",
|
||||
},
|
||||
"moduleLog.showLog": {
|
||||
def: "Show Log",
|
||||
es: "Mostrar registro",
|
||||
@@ -10414,6 +10417,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "Use Remote Configuration",
|
||||
"zh-tw": "使用遠端設定",
|
||||
},
|
||||
"Ui.Common.LocalDatabaseInitialisationFailed": {
|
||||
def: "Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.",
|
||||
},
|
||||
"Ui.Common.Signal.Caution": {
|
||||
def: "CAUTION",
|
||||
es: "PRECAUCIÓN",
|
||||
@@ -10442,6 +10448,9 @@ export const allMessages: Readonly<Record<string, Readonly<Record<string, string
|
||||
zh: "警告",
|
||||
"zh-tw": "警告",
|
||||
},
|
||||
"Ui.Common.SomeFilesCouldNotBeSynchronised": {
|
||||
def: "Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.",
|
||||
},
|
||||
"Ui.Settings.Advanced.LocalDatabaseTweak": {
|
||||
def: "Local Database Tweak",
|
||||
es: "Ajuste fino de la base de datos local",
|
||||
|
||||
@@ -483,6 +483,7 @@
|
||||
"moduleLiveSyncMain.optionResumeAndRestart": "Resume and restart Obsidian",
|
||||
"moduleLiveSyncMain.titleScramEnabled": "Scram Enabled",
|
||||
"moduleLocalDatabase.logWaitingForReady": "Waiting for ready...",
|
||||
"moduleLog.pathComponentTooLong": "A file or folder name exceeds ${maxBytes} UTF-8 bytes and may not work on some Android and Linux file systems: ${components}",
|
||||
"moduleLog.showLog": "Show Log",
|
||||
"moduleMigration.fix0256.buttons.checkItLater": "Check it later",
|
||||
"moduleMigration.fix0256.buttons.DismissForever": "I have fixed it, and do not ask again",
|
||||
@@ -1142,10 +1143,12 @@
|
||||
"TweakMismatchResolve.Title.AutoAcceptCompatible": "Auto-Accept Available",
|
||||
"TweakMismatchResolve.Title.TweakResolving": "Configuration Mismatch Detected",
|
||||
"TweakMismatchResolve.Title.UseRemoteConfig": "Use Remote Configuration",
|
||||
"Ui.Common.LocalDatabaseInitialisationFailed": "Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.",
|
||||
"Ui.Common.Signal.Caution": "CAUTION",
|
||||
"Ui.Common.Signal.Danger": "DANGER",
|
||||
"Ui.Common.Signal.Notice": "NOTICE",
|
||||
"Ui.Common.Signal.Warning": "WARNING",
|
||||
"Ui.Common.SomeFilesCouldNotBeSynchronised": "Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.",
|
||||
"Ui.Settings.Advanced.LocalDatabaseTweak": "Local Database Tweak",
|
||||
"Ui.Settings.Advanced.MemoryCache": "Memory Cache",
|
||||
"Ui.Settings.Advanced.TransferTweak": "Transfer Tweak",
|
||||
|
||||
@@ -732,6 +732,9 @@ moduleLiveSyncMain:
|
||||
moduleLocalDatabase:
|
||||
logWaitingForReady: Waiting for ready...
|
||||
moduleLog:
|
||||
pathComponentTooLong: >-
|
||||
A file or folder name exceeds ${maxBytes} UTF-8 bytes and may not work on
|
||||
some Android and Linux file systems: ${components}
|
||||
showLog: Show Log
|
||||
moduleMigration:
|
||||
fix0256:
|
||||
@@ -2126,6 +2129,8 @@ xxhash64 (Fastest): xxhash64 (Fastest)
|
||||
"This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer.": "This feature enables direct synchronisation between devices. No server is required, but both devices must be online at the same time for synchronisation to occur, and some features may be limited. Internet connection is only required to signalling (detecting peers) and not for data transfer."
|
||||
Ui:
|
||||
Common:
|
||||
LocalDatabaseInitialisationFailed: Self-hosted LiveSync cannot synchronise. Generate a report to review the detailed log.
|
||||
SomeFilesCouldNotBeSynchronised: Not all files could be synchronised. Check the affected files. Generate a report to review the detailed log.
|
||||
Signal:
|
||||
Caution: CAUTION
|
||||
Danger: DANGER
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export const ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY = 255;
|
||||
|
||||
export interface OversizedPathComponent {
|
||||
component: string;
|
||||
utf8Bytes: number;
|
||||
}
|
||||
|
||||
const utf8Encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Return path components which exceed the conservative Android/Linux
|
||||
* compatibility boundary.
|
||||
*
|
||||
* Obsidian paths use forward slashes. The limit applies to each file or
|
||||
* folder name, not to the combined Vault-relative path.
|
||||
*/
|
||||
export function findPathComponentsExceedingUtf8Limit(
|
||||
path: string,
|
||||
maxBytes: number = ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY
|
||||
): OversizedPathComponent[] {
|
||||
return path
|
||||
.split("/")
|
||||
.filter((component) => component.length > 0)
|
||||
.map((component) => ({ component, utf8Bytes: utf8Encoder.encode(component).byteLength }))
|
||||
.filter(({ utf8Bytes }) => utf8Bytes > maxBytes);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY,
|
||||
findPathComponentsExceedingUtf8Limit,
|
||||
} from "./pathCompatibility.ts";
|
||||
|
||||
describe("findPathComponentsExceedingUtf8Limit", () => {
|
||||
it("accepts 255 UTF-8 bytes and reports 256 UTF-8 bytes", () => {
|
||||
expect(findPathComponentsExceedingUtf8Limit("a".repeat(255))).toEqual([]);
|
||||
expect(findPathComponentsExceedingUtf8Limit("a".repeat(256))).toEqual([
|
||||
{
|
||||
component: "a".repeat(256),
|
||||
utf8Bytes: 256,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("counts UTF-8 bytes rather than JavaScript characters", () => {
|
||||
expect(findPathComponentsExceedingUtf8Limit("界".repeat(85))).toEqual([]);
|
||||
expect(findPathComponentsExceedingUtf8Limit(`${"界".repeat(85)}a`)).toEqual([
|
||||
{
|
||||
component: `${"界".repeat(85)}a`,
|
||||
utf8Bytes: 256,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not apply the component limit to the whole path", () => {
|
||||
const path = `${"a".repeat(200)}/${"b".repeat(200)}`;
|
||||
|
||||
expect(new TextEncoder().encode(path).byteLength).toBeGreaterThan(
|
||||
ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY
|
||||
);
|
||||
expect(findPathComponentsExceedingUtf8Limit(path)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports an oversized folder component as well as an oversized file name", () => {
|
||||
const folder = "界".repeat(86);
|
||||
const file = `${"b".repeat(256)}.md`;
|
||||
|
||||
expect(findPathComponentsExceedingUtf8Limit(`parent/${folder}/${file}`)).toEqual([
|
||||
{ component: folder, utf8Bytes: 258 },
|
||||
{ component: file, utf8Bytes: 259 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,10 @@ import { MARK_LOG_NETWORK_ERROR, MARK_LOG_SEPARATOR } from "@vrtmrz/livesync-com
|
||||
import { NetworkWarningStyles } from "@vrtmrz/livesync-commonlib/compat/common/models/setting.const";
|
||||
import { compatGlobal } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvFunctions";
|
||||
import { generateReport } from "@/common/reportTool.ts";
|
||||
import {
|
||||
ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY,
|
||||
findPathComponentsExceedingUtf8Limit,
|
||||
} from "@/common/pathCompatibility.ts";
|
||||
|
||||
// This module cannot be a core module because it depends on the Obsidian UI.
|
||||
|
||||
@@ -293,6 +297,18 @@ export class ModuleLog extends AbstractObsidianModule {
|
||||
reasonWarn.push("Some platforms may be unable to process this file correctly: " + labels.join(" "));
|
||||
}
|
||||
}
|
||||
const oversizedPathComponents = findPathComponentsExceedingUtf8Limit(thisFile.path);
|
||||
if (oversizedPathComponents.length > 0) {
|
||||
const components = oversizedPathComponents
|
||||
.map(({ component, utf8Bytes }) => `${component} (${utf8Bytes} bytes)`)
|
||||
.join(", ");
|
||||
reasonWarn.push(
|
||||
$msg("moduleLog.pathComponentTooLong", {
|
||||
maxBytes: `${ANDROID_LINUX_PATH_COMPONENT_UTF8_WARNING_BOUNDARY}`,
|
||||
components,
|
||||
})
|
||||
);
|
||||
}
|
||||
// Case Sensitivity
|
||||
if (this.services.vault.shouldCheckCaseInsensitively()) {
|
||||
const f = (await this.core.storageAccess.getFiles())
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
MetadataDocumentRepairResults,
|
||||
OfflineScanUnresolvedReasons,
|
||||
repairMetadataDocumentIdentity,
|
||||
VaultScanResults,
|
||||
type MetadataDocumentIdentityIssue,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
import {
|
||||
@@ -292,7 +293,8 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
)) === repairAction,
|
||||
repair: async (repairRequest) =>
|
||||
await repairMetadataDocumentIdentity(this.core, repairRequest),
|
||||
requestOrdinaryScan: async () => await this.services.vault.scanVault(true, false),
|
||||
requestOrdinaryScan: async () =>
|
||||
(await this.services.vault.scanVault(true, false)) === VaultScanResults.COMPLETED,
|
||||
});
|
||||
|
||||
if (execution.status === MetadataIdentityRepairExecutions.CANCELLED) return;
|
||||
|
||||
@@ -412,7 +412,9 @@ export function paneMaintenance(
|
||||
.setDisabled(false)
|
||||
.onClick(async () => {
|
||||
await this.services.database.resetDatabase();
|
||||
await this.services.databaseEvents.initialiseDatabase();
|
||||
if (!(await this.services.databaseEvents.initialiseDatabase())) {
|
||||
Logger($msg("Ui.Common.LocalDatabaseInitialisationFailed"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -93,7 +93,7 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("paneMaintenance Fresh Start Wipe", () => {
|
||||
describe("paneMaintenance", () => {
|
||||
it("does not announce success when the remote wipe reports failure", async () => {
|
||||
const updateCheckPointInfo = vi.fn(async () => undefined);
|
||||
const resetRemoteBucket = vi.fn(async () => false);
|
||||
@@ -140,4 +140,49 @@ describe("paneMaintenance Fresh Start Wipe", () => {
|
||||
);
|
||||
expect(maintenanceHarness.logger).not.toHaveBeenCalledWith("Deleted all data on remote server", "notice");
|
||||
});
|
||||
|
||||
it("reports when database initialisation after a local reset does not complete", async () => {
|
||||
const resetDatabase = vi.fn(async () => undefined);
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
const addPanel = vi.fn((_parent: HTMLElement, heading: string) => ({
|
||||
then(callback: (paneEl: HTMLElement) => void) {
|
||||
if (heading === "Reset") {
|
||||
callback({} as HTMLElement);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
const host = {
|
||||
core: {},
|
||||
createEl: vi.fn(),
|
||||
editingSettings: {},
|
||||
isConfiguredAs: vi.fn(),
|
||||
onlyOnCouchDB: vi.fn(),
|
||||
onlyOnCouchDBOrMinIO: vi.fn(),
|
||||
onlyOnMinIO: vi.fn(),
|
||||
services: {
|
||||
appLifecycle: { askRestart: vi.fn() },
|
||||
database: { resetDatabase },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
setting: { saveSettingData: vi.fn() },
|
||||
},
|
||||
};
|
||||
|
||||
paneMaintenance.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
const deleteLocalDatabase = maintenanceHarness.createdSettings.find(
|
||||
({ name }) => name === "Delete local database to reset or uninstall Self-hosted LiveSync"
|
||||
);
|
||||
if (!deleteLocalDatabase?.click) {
|
||||
throw new Error("Delete local database action was not registered");
|
||||
}
|
||||
|
||||
await deleteLocalDatabase.click();
|
||||
|
||||
expect(resetDatabase).toHaveBeenCalledOnce();
|
||||
expect(initialiseDatabase).toHaveBeenCalledOnce();
|
||||
expect(maintenanceHarness.logger).toHaveBeenCalledWith(
|
||||
"Ui.Common.LocalDatabaseInitialisationFailed",
|
||||
"notice"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-brows
|
||||
import { ExtraSuffixIndexedDB } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { migrateDatabases } from "./settingUtils.ts";
|
||||
import { usesLegacyIndexedDBAdapter } from "@/common/compatibilitySettings.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
|
||||
export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
|
||||
void addPanel(paneEl, "Compatibility (Metadata)").then((paneEl) => {
|
||||
@@ -142,7 +143,9 @@ export function panePatches(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElemen
|
||||
|
||||
this.addOnSaved("additionalSuffixOfDatabaseName", async (key) => {
|
||||
Logger("Suffix has been changed. Reopening database...", LOG_LEVEL_NOTICE);
|
||||
await this.services.databaseEvents.initialiseDatabase();
|
||||
if (!(await this.services.databaseEvents.initialiseDatabase())) {
|
||||
Logger($msg("Ui.Common.LocalDatabaseInitialisationFailed"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
});
|
||||
|
||||
new Setting(paneEl).autoWireDropDown("hashAlg", {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { panePatches } from "./PanePatches.ts";
|
||||
|
||||
const remediationHarness = vi.hoisted(() => {
|
||||
@@ -14,11 +15,13 @@ const remediationHarness = vi.hoisted(() => {
|
||||
};
|
||||
const setButtonClassState = vi.fn();
|
||||
const setSettingClassState = vi.fn();
|
||||
const logger = vi.fn();
|
||||
|
||||
return {
|
||||
createSpan,
|
||||
dateElement,
|
||||
inputEl,
|
||||
logger,
|
||||
setButtonClassState,
|
||||
setSettingClassState,
|
||||
textComponent,
|
||||
@@ -59,9 +62,25 @@ vi.mock("./LiveSyncSetting.ts", () => ({
|
||||
autoWireToggle(): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
autoWireText(): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
autoWireDropDown(): this {
|
||||
return this;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: (message: string) => message,
|
||||
}));
|
||||
|
||||
vi.mock("@vrtmrz/livesync-commonlib/compat/common/logger", () => ({
|
||||
Logger: remediationHarness.logger,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, "activeDocument");
|
||||
vi.clearAllMocks();
|
||||
@@ -69,7 +88,7 @@ afterEach(() => {
|
||||
remediationHarness.inputEl.type = "";
|
||||
});
|
||||
|
||||
describe("panePatches remediation setting", () => {
|
||||
describe("panePatches", () => {
|
||||
it("creates the status element in the setting control instead of the document", () => {
|
||||
const hierarchyError = new DOMException(
|
||||
"Failed to execute 'appendChild' on 'Node': Only one element on document allowed.",
|
||||
@@ -115,4 +134,36 @@ describe("panePatches remediation setting", () => {
|
||||
);
|
||||
expect(remediationHarness.setButtonClassState).toHaveBeenCalledWith("sls-setting-additional-action", true);
|
||||
});
|
||||
|
||||
it("reports when database reinitialisation after a suffix change does not complete", async () => {
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
let onSuffixSaved: (() => Promise<void>) | undefined;
|
||||
const host = {
|
||||
addOnSaved: vi.fn((key: string, callback: () => Promise<void>) => {
|
||||
if (key === "additionalSuffixOfDatabaseName") onSuffixSaved = callback;
|
||||
}),
|
||||
services: {
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
};
|
||||
const addPanel = vi.fn((_paneEl: HTMLElement, title: string) => ({
|
||||
then(callback: (paneEl: HTMLElement) => void) {
|
||||
if (title === "Edge case addressing (Database)") {
|
||||
callback({} as HTMLElement);
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
}));
|
||||
|
||||
panePatches.call(host as never, {} as HTMLElement, { addPanel } as never);
|
||||
if (!onSuffixSaved) throw new Error("Database suffix save handler was not registered");
|
||||
|
||||
await onSuffixSaved();
|
||||
|
||||
expect(initialiseDatabase).toHaveBeenCalledOnce();
|
||||
expect(remediationHarness.logger).toHaveBeenCalledWith(
|
||||
"Ui.Common.LocalDatabaseInitialisationFailed",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { InjectableServiceHub } from "@vrtmrz/livesync-commonlib/compat/ser
|
||||
import type { LiveSyncCore } from "@/main.ts";
|
||||
import { initialiseWorkerModule } from "@vrtmrz/livesync-commonlib/compat/worker/bgWorker";
|
||||
import { manifestVersion, packageVersion } from "@vrtmrz/livesync-commonlib/compat/common/coreEnvVars";
|
||||
import { VaultScanResults } from "@vrtmrz/livesync-commonlib/compat/serviceFeatures/offlineScanner";
|
||||
|
||||
export class ModuleLiveSyncMain extends AbstractModule {
|
||||
async _onLiveSyncReady() {
|
||||
@@ -42,11 +43,17 @@ export class ModuleLiveSyncMain extends AbstractModule {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const isInitialized = await this.services.databaseEvents.initialiseDatabase(false, false);
|
||||
if (!isInitialized) {
|
||||
// Ordinary start-up may continue when individual files could not be
|
||||
// processed. Explicit Fetch and Rebuild flows retain the strict default.
|
||||
const initialisationResult = await this.services.databaseEvents.initialiseDatabase(false, false, false, true);
|
||||
if (initialisationResult === VaultScanResults.FAILED) {
|
||||
this._log($msg("Ui.Common.LocalDatabaseInitialisationFailed"), LOG_LEVEL_NOTICE);
|
||||
//TODO:stop all sync.
|
||||
return false;
|
||||
}
|
||||
if (initialisationResult === VaultScanResults.COMPLETED_WITH_FILE_FAILURES) {
|
||||
this._log($msg("Ui.Common.SomeFilesCouldNotBeSynchronised"), LOG_LEVEL_NOTICE);
|
||||
}
|
||||
if (!(await this.core.services.appLifecycle.onFirstInitialise())) return false;
|
||||
// await this.core.$$realizeSettingSyncMode();
|
||||
await this.services.control.applySettings();
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/common/events.ts", () => ({
|
||||
EVENT_LAYOUT_READY: "layout-ready",
|
||||
EVENT_PLUGIN_LOADED: "plugin-loaded",
|
||||
EVENT_REQUEST_RELOAD_SETTING_TAB: "reload-setting-tab",
|
||||
EVENT_SETTING_SAVED: "setting-saved",
|
||||
eventHub: {
|
||||
emitEvent: vi.fn(),
|
||||
onEvent: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/common/translation", () => ({
|
||||
$msg: (message: string) => message,
|
||||
setLang: vi.fn(),
|
||||
}));
|
||||
|
||||
import { ModuleLiveSyncMain } from "./ModuleLiveSyncMain.ts";
|
||||
|
||||
describe("ModuleLiveSyncMain", () => {
|
||||
it("reports a database preparation failure at the application boundary", async () => {
|
||||
const initialiseDatabase = vi.fn(async () => false);
|
||||
const log = vi.fn();
|
||||
const host = {
|
||||
core: {
|
||||
services: {
|
||||
appLifecycle: {
|
||||
onLayoutReady: vi.fn(async () => true),
|
||||
},
|
||||
},
|
||||
},
|
||||
services: {
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
_log: log,
|
||||
};
|
||||
|
||||
const result = await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(initialiseDatabase).toHaveBeenCalledWith(false, false, false, true);
|
||||
expect(log).toHaveBeenCalledWith("Ui.Common.LocalDatabaseInitialisationFailed", LOG_LEVEL_NOTICE);
|
||||
});
|
||||
|
||||
it("warns when start-up continues with individual file failures", async () => {
|
||||
const initialiseDatabase = vi.fn(async () => "completed-with-file-failures");
|
||||
const log = vi.fn();
|
||||
const appLifecycle = {
|
||||
onLayoutReady: vi.fn(async () => true),
|
||||
onFirstInitialise: vi.fn(async () => true),
|
||||
onScanningStartupIssues: vi.fn(async () => true),
|
||||
};
|
||||
const host = {
|
||||
core: {
|
||||
services: { appLifecycle },
|
||||
},
|
||||
services: {
|
||||
appLifecycle,
|
||||
control: { applySettings: vi.fn(async () => undefined) },
|
||||
databaseEvents: { initialiseDatabase },
|
||||
},
|
||||
settings: {
|
||||
suspendFileWatching: false,
|
||||
suspendParseReplicationResult: false,
|
||||
},
|
||||
_log: log,
|
||||
};
|
||||
|
||||
const result = await ModuleLiveSyncMain.prototype._onLiveSyncReady.call(host as never);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(log).toHaveBeenCalledWith("Ui.Common.SomeFilesCouldNotBeSynchronised", LOG_LEVEL_NOTICE);
|
||||
});
|
||||
});
|
||||
@@ -17,10 +17,13 @@ Earlier releases remain available in the 1.0 release history, the 1.0 preview hi
|
||||
#### Fixed
|
||||
|
||||
- Conflict resolution dialogues now close when the same file is resolved elsewhere or the plug-in unloads. Requests for different files are shown one at a time, while a newer request for the same file replaces the stale dialogue.
|
||||
- An individual file-processing failure during ordinary start-up no longer keeps the entire application unready. A start-up notice asks the user to check the affected files and generate a report for details; each path is recorded in verbose logs and remains eligible for retry, while explicit Fetch and Rebuild operations retain strict completion.
|
||||
- Replication readiness diagnostics now state that application initialisation is incomplete instead of reporting only 'Not ready'. Database-preparation failures show a short notice, with the failed stage available in verbose logs.
|
||||
|
||||
#### Improved
|
||||
|
||||
- Start-up now keeps unconfigured Vaults on the onboarding path without running configured-only checks or accepting Config Doctor and incomplete-document repair requests. Returning a configured Vault to an unconfigured state also retires those requests for the current plug-in process, so completing setup admits them only after the requested restart.
|
||||
- The active-file warning now identifies file or folder names longer than 255 UTF-8 bytes as an Android and Linux compatibility risk, without rejecting or changing the path.
|
||||
|
||||
### Testing
|
||||
|
||||
|
||||
Reference in New Issue
Block a user