Group Obsidian startup notices by operation

This commit is contained in:
vorotamoroz
2026-07-19 07:04:54 +00:00
parent ed3f81e9f9
commit d28282edad
10 changed files with 613 additions and 164 deletions
@@ -57,6 +57,9 @@ import { tryGetFilePath } from "@vrtmrz/livesync-commonlib/compat/common/utils.d
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
type SyncDirection = "push" | "pull" | "safe" | "pullForce" | "pushForce";
const HIDDEN_FILE_NOTICE_GROUP = "hidden-file-changes";
const HIDDEN_FILE_NOTICE_DURATION_MS = 20_000;
declare global {
interface OPTIONAL_SYNC_FEATURES {
FETCH: "FETCH";
@@ -1227,6 +1230,8 @@ Offline Changed files: ${files.length}`;
notifyConfigChange() {
const updatedFolders = [...this.queuedNotificationFiles];
this.queuedNotificationFiles.clear();
const noticeGroups = this.services.context.noticeGroups;
let hasNoticeItems = false;
try {
//@ts-ignore
const manifests = Object.values(this.app.plugins.manifests) as unknown as PluginManifest[];
@@ -1238,31 +1243,33 @@ Offline Changed files: ${files.length}`;
// If notified about plug-ins, reloading Obsidian may not be necessary.
const updatePluginId = manifest.id;
const updatePluginName = manifest.name;
this.core.confirm.askInPopup(
`updated-${updatePluginId}`,
`Files in ${updatePluginName} has been updated!\nPress {HERE} to reload ${updatePluginName}, or press elsewhere to dismiss this message.`,
(anchor) => {
anchor.text = "HERE";
anchor.addEventListener("click", () => {
const itemKey = `plugin:${updatePluginId}`;
noticeGroups.setItem(HIDDEN_FILE_NOTICE_GROUP, itemKey, {
message: `Files in ${updatePluginName} were updated.`,
action: {
label: `Reload ${updatePluginName}`,
onSelect: () => {
fireAndForget(async () => {
this._log(
`Unloading plugin: ${updatePluginName}`,
LOG_LEVEL_NOTICE,
"plugin-reload-" + updatePluginId
);
// @ts-ignore
// @ts-ignore -- Obsidian does not expose the plug-in lifecycle methods in its public API.
await this.app.plugins.unloadPlugin(updatePluginId);
// @ts-ignore
// @ts-ignore -- Obsidian does not expose the plug-in lifecycle methods in its public API.
await this.app.plugins.loadPlugin(updatePluginId);
this._log(
`Plugin reloaded: ${updatePluginName}`,
LOG_LEVEL_NOTICE,
"plugin-reload-" + updatePluginId
);
noticeGroups.removeItem(HIDDEN_FILE_NOTICE_GROUP, itemKey);
});
});
}
);
},
},
});
hasNoticeItems = true;
}
} catch (ex) {
this._log("Error on checking plugin status.");
@@ -1272,18 +1279,24 @@ Offline Changed files: ${files.length}`;
// If something changes left, notify for reloading Obsidian.
if (updatedFolders.indexOf(this.services.API.getSystemConfigDir()) >= 0) {
if (!this.services.appLifecycle.isReloadingScheduled()) {
this.core.confirm.askInPopup(
`updated-any-hidden`,
`Some setting files have been modified\nPress {HERE} to schedule a reload of Obsidian, or press elsewhere to dismiss this message.`,
(anchor) => {
anchor.text = "HERE";
anchor.addEventListener("click", () => {
noticeGroups.setItem(HIDDEN_FILE_NOTICE_GROUP, "restart", {
message: "Other Obsidian settings files were updated.",
action: {
label: "Schedule an Obsidian restart",
onSelect: () => {
this.services.appLifecycle.scheduleRestart();
});
}
);
noticeGroups.removeItem(HIDDEN_FILE_NOTICE_GROUP, "restart");
},
},
});
hasNoticeItems = true;
} else {
noticeGroups.removeItem(HIDDEN_FILE_NOTICE_GROUP, "restart");
}
}
if (hasNoticeItems) {
noticeGroups.finish(HIDDEN_FILE_NOTICE_GROUP, { durationMs: HIDDEN_FILE_NOTICE_DURATION_MS });
}
}
queueNotification(key: FilePath) {
@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/deps.ts", () => ({}));
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
JsonResolveModal: class JsonResolveModal {},
}));
vi.mock("@/features/LiveSyncCommands.ts", () => ({
LiveSyncCommands: class LiveSyncCommands {
plugin!: { app: unknown };
core!: { services: unknown };
get app() {
return this.plugin.app;
}
get services() {
return this.core.services;
}
},
}));
vi.mock("./configureHiddenFileSyncMode.ts", () => ({
configureHiddenFileSyncMode: vi.fn(),
}));
import { HiddenFileSync } from "./CmdHiddenFileSync.ts";
describe("HiddenFileSync configuration-change notices", () => {
it("groups plug-in reloads and an Obsidian restart into one finished Notice", async () => {
const noticeGroups = {
setItem: vi.fn(),
finish: vi.fn(() => true),
removeItem: vi.fn(() => true),
};
const plugin = {
app: {
plugins: {
manifests: {
alpha: {
id: "alpha",
name: "Alpha",
dir: ".obsidian/plugins/alpha",
},
beta: {
id: "beta",
name: "Beta",
dir: ".obsidian/plugins/beta",
},
},
enabledPlugins: new Set(["alpha", "beta"]),
unloadPlugin: vi.fn(async () => undefined),
loadPlugin: vi.fn(async () => undefined),
},
},
};
const core = {
confirm: { askInPopup: vi.fn() },
services: {
context: { noticeGroups },
API: { getSystemConfigDir: vi.fn(() => ".obsidian") },
appLifecycle: {
isReloadingScheduled: vi.fn(() => false),
scheduleRestart: vi.fn(),
},
},
};
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
Object.assign(hiddenFileSync, {
plugin,
core,
queuedNotificationFiles: new Set([".obsidian/plugins/alpha", ".obsidian/plugins/beta", ".obsidian"]),
_log: vi.fn(),
});
hiddenFileSync.notifyConfigChange();
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "hidden-file-changes", "plugin:alpha", {
message: "Files in Alpha were updated.",
action: expect.objectContaining({ label: "Reload Alpha" }),
});
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "hidden-file-changes", "plugin:beta", {
message: "Files in Beta were updated.",
action: expect.objectContaining({ label: "Reload Beta" }),
});
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(3, "hidden-file-changes", "restart", {
message: "Other Obsidian settings files were updated.",
action: expect.objectContaining({ label: "Schedule an Obsidian restart" }),
});
expect(noticeGroups.setItem.mock.calls.every(([groupKey]) => groupKey === "hidden-file-changes")).toBe(true);
expect(noticeGroups.finish).toHaveBeenCalledWith("hidden-file-changes", { durationMs: 20_000 });
expect(core.confirm.askInPopup).not.toHaveBeenCalled();
const reloadAction = (noticeGroups.setItem.mock.calls[0]?.[2] as { action: { onSelect: () => void } }).action
.onSelect;
reloadAction();
await vi.waitFor(() => {
expect(plugin.app.plugins.unloadPlugin).toHaveBeenCalledWith("alpha");
expect(plugin.app.plugins.loadPlugin).toHaveBeenCalledWith("alpha");
expect(noticeGroups.removeItem).toHaveBeenCalledWith("hidden-file-changes", "plugin:alpha");
});
const restartAction = (noticeGroups.setItem.mock.calls[2]?.[2] as { action: { onSelect: () => void } }).action
.onSelect;
restartAction();
expect(core.services.appLifecycle.scheduleRestart).toHaveBeenCalledOnce();
expect(noticeGroups.removeItem).toHaveBeenCalledWith("hidden-file-changes", "restart");
});
});
+153 -122
View File
@@ -1,4 +1,9 @@
import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
import {
LOG_LEVEL_INFO,
LOG_LEVEL_NOTICE,
LOG_LEVEL_VERBOSE,
Logger,
} from "@vrtmrz/livesync-commonlib/compat/common/logger";
import {
EVENT_REQUEST_OPEN_P2P,
EVENT_REQUEST_OPEN_SETTING_WIZARD,
@@ -12,7 +17,12 @@ import { $msg } from "@vrtmrz/livesync-commonlib/compat/common/i18n";
import { performDoctorConsultation, RebuildOptions } from "@vrtmrz/livesync-commonlib/compat/common/configForDoc";
import { isValidPath } from "@/common/utils.ts";
import { isMetaEntry } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { isDeletedEntry, isDocContentSame, isLoadedEntry, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
import {
isDeletedEntry,
isDocContentSame,
isLoadedEntry,
readAsBlob,
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
import { countCompromisedChunks } from "@vrtmrz/livesync-commonlib/compat/pouchdb/negotiation";
import type { LiveSyncCore } from "@/main.ts";
import { SetupManager } from "@/modules/features/SetupManager.ts";
@@ -26,7 +36,9 @@ type ErrorInfo = {
isConflicted?: boolean;
};
export class ModuleMigration extends AbstractModule {
const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check";
export class ModuleMigration extends AbstractModule<LiveSyncCore> {
async migrateUsingDoctor(skipRebuild: boolean = false, activateReason = "updated", forceRescan = false) {
const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation(
{
@@ -127,133 +139,152 @@ export class ModuleMigration extends AbstractModule {
return Promise.resolve(true);
}
this._log("Checking for incomplete documents...", LOG_LEVEL_NOTICE, "check-incomplete");
const errorFiles = [] as ErrorInfo[];
for await (const metaDoc of this.localDatabase.findAllNormalDocs({ conflicts: true })) {
const path = this.getPath(metaDoc);
if (!isValidPath(path)) {
continue;
}
if (!(await this.services.vault.isTargetFile(path))) {
continue;
}
if (!isMetaEntry(metaDoc)) {
continue;
}
const doc = await this.localDatabase.getDBEntryFromMeta(metaDoc);
if (!doc || !isLoadedEntry(doc)) {
continue;
}
if (isDeletedEntry(doc)) {
continue;
}
const isConflicted = metaDoc?._conflicts && metaDoc._conflicts.length > 0;
let storageFileContent;
try {
storageFileContent = await this.core.storageAccess.readHiddenFileBinary(path);
} catch (e) {
Logger(`Failed to read file ${path}: Possibly unprocessed or missing`);
Logger(e, LOG_LEVEL_VERBOSE);
continue;
}
// const storageFileBlob = createBlob(storageFileContent);
const sizeOnStorage = storageFileContent.byteLength;
const recordedSize = doc.size;
const docBlob = readAsBlob(doc);
const actualSize = docBlob.size;
if (
recordedSize !== actualSize ||
sizeOnStorage !== actualSize ||
sizeOnStorage !== recordedSize ||
isConflicted
) {
const contentMatched = await isDocContentSame(doc.data, storageFileContent);
errorFiles.push({
path,
recordedSize,
actualSize,
storageSize: sizeOnStorage,
contentMatched,
isConflicted,
});
Logger(
`Size mismatch for ${path}: ${recordedSize} (DB Recorded) , ${actualSize} (DB Stored) , ${sizeOnStorage} (Storage Stored), ${contentMatched ? "Content Matched" : "Content Mismatched"} ${isConflicted ? "Conflicted" : "Not Conflicted"}`
);
}
}
if (errorFiles.length == 0) {
Logger("No size mismatches found", LOG_LEVEL_NOTICE);
await this.core.kvDB.set("checkIncompleteDocs", true);
return Promise.resolve(true);
}
Logger(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_NOTICE);
// We have to repair them following rules and situations:
// A. DB Recorded != DB Stored
// A.1. DB Recorded == Storage Stored
// Possibly recoverable from storage. Just overwrite the DB content with storage content.
// A.2. Neither
// Probably it cannot be resolved on this device. Even if the storage content is larger than DB Recorded, it possibly corrupted.
// We do not fix it automatically. Leave it as is. Possibly other device can do this.
// B. DB Recorded == DB Stored , < Storage Stored
// Very fragile, if DB Recorded size is less than Storage Stored size, we possibly repair the content (The issue was `unexpectedly shortened file`).
// We do not fix it automatically, but it will be automatically overwritten in other process.
// C. DB Recorded == DB Stored , > Storage Stored
// Probably restored by the user by resolving A or B on other device, We should overwrite the storage
// Also do not fix it automatically. It should be overwritten by replication.
const recoverable = errorFiles.filter((e) => {
return e.recordedSize === e.storageSize && !e.isConflicted;
const noticeGroups = this.core.services.context.noticeGroups;
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "checking", {
message: "Checking for incomplete documents...",
});
const unrecoverable = errorFiles.filter((e) => {
return e.recordedSize !== e.storageSize || e.isConflicted;
});
const fileInfo = (e: (typeof errorFiles)[0]) => {
return `${e.path} (M: ${e.recordedSize}, A: ${e.actualSize}, S: ${e.storageSize}) ${e.isConflicted ? "(Conflicted)" : ""}`;
};
const messageUnrecoverable =
unrecoverable.length > 0
? $msg("moduleMigration.fix0256.messageUnrecoverable", {
filesNotRecoverable: unrecoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
})
: "";
this._log("Checking for incomplete documents...", LOG_LEVEL_VERBOSE);
const message = $msg("moduleMigration.fix0256.message", {
files: recoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
messageUnrecoverable,
});
const CHECK_IT_LATER = $msg("moduleMigration.fix0256.buttons.checkItLater");
const FIX = $msg("moduleMigration.fix0256.buttons.fix");
const DISMISS = $msg("moduleMigration.fix0256.buttons.DismissForever");
const ret = await this.core.confirm.askSelectStringDialogue(message, [CHECK_IT_LATER, FIX, DISMISS], {
title: $msg("moduleMigration.fix0256.title"),
defaultAction: CHECK_IT_LATER,
});
if (ret == FIX) {
for (const file of recoverable) {
// Overwrite the database with the files on the storage
const stubFile = await this.core.storageAccess.getFileStub(file.path);
if (stubFile == null) {
Logger(`Could not find stub file for ${file.path}`, LOG_LEVEL_NOTICE);
try {
const errorFiles = [] as ErrorInfo[];
for await (const metaDoc of this.localDatabase.findAllNormalDocs({ conflicts: true })) {
const path = this.getPath(metaDoc);
if (!isValidPath(path)) {
continue;
}
if (!(await this.services.vault.isTargetFile(path))) {
continue;
}
if (!isMetaEntry(metaDoc)) {
continue;
}
stubFile.stat.mtime = Date.now();
const result = await this.core.fileHandler.storeFileToDB(stubFile, true, false);
if (result) {
Logger(`Successfully restored ${file.path} from storage`);
} else {
Logger(`Failed to restore ${file.path} from storage`, LOG_LEVEL_NOTICE);
const doc = await this.localDatabase.getDBEntryFromMeta(metaDoc);
if (!doc || !isLoadedEntry(doc)) {
continue;
}
if (isDeletedEntry(doc)) {
continue;
}
const isConflicted = metaDoc?._conflicts && metaDoc._conflicts.length > 0;
let storageFileContent;
try {
storageFileContent = await this.core.storageAccess.readHiddenFileBinary(path);
} catch (e) {
Logger(`Failed to read file ${path}: Possibly unprocessed or missing`);
Logger(e, LOG_LEVEL_VERBOSE);
continue;
}
// const storageFileBlob = createBlob(storageFileContent);
const sizeOnStorage = storageFileContent.byteLength;
const recordedSize = doc.size;
const docBlob = readAsBlob(doc);
const actualSize = docBlob.size;
if (
recordedSize !== actualSize ||
sizeOnStorage !== actualSize ||
sizeOnStorage !== recordedSize ||
isConflicted
) {
const contentMatched = await isDocContentSame(doc.data, storageFileContent);
errorFiles.push({
path,
recordedSize,
actualSize,
storageSize: sizeOnStorage,
contentMatched,
isConflicted,
});
Logger(
`Size mismatch for ${path}: ${recordedSize} (DB Recorded) , ${actualSize} (DB Stored) , ${sizeOnStorage} (Storage Stored), ${contentMatched ? "Content Matched" : "Content Mismatched"} ${isConflicted ? "Conflicted" : "Not Conflicted"}`
);
}
}
} else if (ret === DISMISS) {
// User chose to dismiss the issue
await this.core.kvDB.set("checkIncompleteDocs", true);
}
if (errorFiles.length == 0) {
Logger("No size mismatches found", LOG_LEVEL_INFO);
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
message: "No size mismatches found",
});
await this.core.kvDB.set("checkIncompleteDocs", true);
return Promise.resolve(true);
}
Logger(`Found ${errorFiles.length} size mismatches`, LOG_LEVEL_INFO);
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
message: `Found ${errorFiles.length} size mismatches`,
});
// We have to repair them following rules and situations:
// A. DB Recorded != DB Stored
// A.1. DB Recorded == Storage Stored
// Possibly recoverable from storage. Just overwrite the DB content with storage content.
// A.2. Neither
// Probably it cannot be resolved on this device. Even if the storage content is larger than DB Recorded, it possibly corrupted.
// We do not fix it automatically. Leave it as is. Possibly other device can do this.
// B. DB Recorded == DB Stored , < Storage Stored
// Very fragile, if DB Recorded size is less than Storage Stored size, we possibly repair the content (The issue was `unexpectedly shortened file`).
// We do not fix it automatically, but it will be automatically overwritten in other process.
// C. DB Recorded == DB Stored , > Storage Stored
// Probably restored by the user by resolving A or B on other device, We should overwrite the storage
// Also do not fix it automatically. It should be overwritten by replication.
const recoverable = errorFiles.filter((e) => {
return e.recordedSize === e.storageSize && !e.isConflicted;
});
const unrecoverable = errorFiles.filter((e) => {
return e.recordedSize !== e.storageSize || e.isConflicted;
});
const fileInfo = (e: (typeof errorFiles)[0]) => {
return `${e.path} (M: ${e.recordedSize}, A: ${e.actualSize}, S: ${e.storageSize}) ${e.isConflicted ? "(Conflicted)" : ""}`;
};
const messageUnrecoverable =
unrecoverable.length > 0
? $msg("moduleMigration.fix0256.messageUnrecoverable", {
filesNotRecoverable: unrecoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
})
: "";
return Promise.resolve(true);
const message = $msg("moduleMigration.fix0256.message", {
files: recoverable.map((e) => `- ${fileInfo(e)}`).join("\n"),
messageUnrecoverable,
});
const CHECK_IT_LATER = $msg("moduleMigration.fix0256.buttons.checkItLater");
const FIX = $msg("moduleMigration.fix0256.buttons.fix");
const DISMISS = $msg("moduleMigration.fix0256.buttons.DismissForever");
const ret = await this.core.confirm.askSelectStringDialogue(message, [CHECK_IT_LATER, FIX, DISMISS], {
title: $msg("moduleMigration.fix0256.title"),
defaultAction: CHECK_IT_LATER,
});
if (ret == FIX) {
for (const file of recoverable) {
// Overwrite the database with the files on the storage
const stubFile = await this.core.storageAccess.getFileStub(file.path);
if (stubFile == null) {
Logger(`Could not find stub file for ${file.path}`, LOG_LEVEL_NOTICE);
continue;
}
stubFile.stat.mtime = Date.now();
const result = await this.core.fileHandler.storeFileToDB(stubFile, true, false);
if (result) {
Logger(`Successfully restored ${file.path} from storage`);
} else {
Logger(`Failed to restore ${file.path} from storage`, LOG_LEVEL_NOTICE);
}
}
} else if (ret === DISMISS) {
// User chose to dismiss the issue
await this.core.kvDB.set("checkIncompleteDocs", true);
}
return Promise.resolve(true);
} catch (error) {
noticeGroups.setItem(INCOMPLETE_DOCUMENT_NOTICE_GROUP, "result", {
message: "The incomplete document check could not be completed.",
});
throw error;
} finally {
noticeGroups.finish(INCOMPLETE_DOCUMENT_NOTICE_GROUP);
}
}
async hasCompromisedChunks(): Promise<boolean> {
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/modules/features/SetupManager.ts", () => ({
SetupManager: class SetupManager {},
}));
vi.mock("@/deps.ts", () => ({}));
vi.mock("@/common/utils.ts", () => ({
isValidPath: () => true,
}));
import { ModuleMigration } from "./ModuleMigration.ts";
async function* noDocuments() {
return;
}
async function* failedDocumentScan() {
throw new Error("scan failed");
}
function createMigration(findAllNormalDocs: typeof noDocuments | typeof failedDocumentScan = noDocuments) {
const noticeGroups = {
setItem: vi.fn(),
finish: vi.fn(() => true),
};
const services = {
API: {
addLog: vi.fn(),
addCommand: vi.fn(),
registerWindow: vi.fn(),
addRibbonIcon: vi.fn(),
registerProtocolHandler: vi.fn(),
},
context: { noticeGroups },
vault: { isTargetFile: vi.fn(async () => true) },
path: { getPath: vi.fn() },
};
const core = {
_services: services,
services,
kvDB: {
get: vi.fn(async () => false),
set: vi.fn(async () => undefined),
},
localDatabase: { findAllNormalDocs },
storageAccess: {},
};
return {
migration: new ModuleMigration(core as never),
noticeGroups,
};
}
describe("ModuleMigration incomplete-document notice", () => {
it("keeps the check and its result in one persistent named group", async () => {
const { migration, noticeGroups } = createMigration();
await expect(migration.hasIncompleteDocs()).resolves.toBe(true);
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(1, "startup-integrity-check", "checking", {
message: "Checking for incomplete documents...",
});
expect(noticeGroups.setItem).toHaveBeenNthCalledWith(2, "startup-integrity-check", "result", {
message: "No size mismatches found",
});
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
});
it("finishes the group with a failure result when the scan throws", async () => {
const { migration, noticeGroups } = createMigration(failedDocumentScan);
await expect(migration.hasIncompleteDocs()).rejects.toThrow("scan failed");
expect(noticeGroups.setItem).toHaveBeenLastCalledWith("startup-integrity-check", "result", {
message: "The incomplete document check could not be completed.",
});
expect(noticeGroups.finish).toHaveBeenCalledWith("startup-integrity-check");
});
});
@@ -0,0 +1,51 @@
import { KeyedNoticeGroupManager } from "@vrtmrz/obsidian-plugin-kit/notice";
export interface ObsidianNoticeGroupItem {
message: string;
action?: {
label: string;
onSelect: () => void;
};
}
interface KeyedNoticeGroupDriver {
setItem(groupKey: string, itemKey: string, item: ObsidianNoticeGroupItem): unknown;
finish(groupKey: string, options?: { durationMs?: number | false }): boolean;
removeItem(groupKey: string, itemKey: string): boolean;
hide(groupKey: string): boolean;
dispose(): void;
}
/** Obsidian-owned interactive Notice capability exposed through the application Context. */
export interface ObsidianNoticeGroups {
setItem(groupKey: string, itemKey: string, item: ObsidianNoticeGroupItem): void;
finish(groupKey: string, options?: { durationMs?: number | false }): boolean;
removeItem(groupKey: string, itemKey: string): boolean;
hide(groupKey: string): boolean;
dispose(): void;
}
/** Adapts Fancy Kit grouped Notices without exposing Obsidian Notice instances to features. */
export class ObsidianNoticeGroupManager implements ObsidianNoticeGroups {
constructor(private readonly manager: KeyedNoticeGroupDriver = new KeyedNoticeGroupManager()) {}
setItem(groupKey: string, itemKey: string, item: ObsidianNoticeGroupItem): void {
this.manager.setItem(groupKey, itemKey, item);
}
finish(groupKey: string, options?: { durationMs?: number | false }): boolean {
return this.manager.finish(groupKey, options);
}
removeItem(groupKey: string, itemKey: string): boolean {
return this.manager.removeItem(groupKey, itemKey);
}
hide(groupKey: string): boolean {
return this.manager.hide(groupKey);
}
dispose(): void {
this.manager.dispose();
}
}
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@vrtmrz/obsidian-plugin-kit/notice", () => ({
KeyedNoticeGroupManager: class KeyedNoticeGroupManager {},
}));
import { ObsidianNoticeGroupManager } from "./ObsidianNoticeGroups";
describe("ObsidianNoticeGroupManager", () => {
it("keeps Fancy Kit rendering behind the Context-owned capability", () => {
const driver = {
setItem: vi.fn(),
finish: vi.fn(() => true),
removeItem: vi.fn(() => true),
hide: vi.fn(() => true),
dispose: vi.fn(),
};
const groups = new ObsidianNoticeGroupManager(driver);
const item = {
message: "Complete",
action: { label: "Review", onSelect: vi.fn() },
};
groups.setItem("integrity", "result", item);
expect(driver.setItem).toHaveBeenCalledWith("integrity", "result", item);
expect(groups.finish("integrity", { durationMs: 1_000 })).toBe(true);
expect(groups.removeItem("integrity", "result")).toBe(true);
expect(groups.hide("integrity")).toBe(true);
groups.dispose();
expect(driver.dispose).toHaveBeenCalledOnce();
});
});
@@ -3,17 +3,20 @@ import type { App, Plugin } from "@/deps";
import { ServiceContext } from "@vrtmrz/livesync-commonlib/context";
import { eventHub } from "@/common/events";
import { translateLiveSyncMessage } from "@/common/translation";
import type { ObsidianNoticeGroups } from "./ObsidianNoticeGroups";
/** Host capabilities owned by one Self-hosted LiveSync plug-in instance. */
export class ObsidianServiceContext extends ServiceContext {
app: App;
plugin: Plugin;
liveSyncPlugin: ObsidianLiveSyncPlugin;
readonly noticeGroups: ObsidianNoticeGroups;
constructor(app: App, plugin: Plugin, liveSyncPlugin: ObsidianLiveSyncPlugin) {
constructor(app: App, plugin: Plugin, liveSyncPlugin: ObsidianLiveSyncPlugin, noticeGroups: ObsidianNoticeGroups) {
super({ events: eventHub, translate: translateLiveSyncMessage });
this.app = app;
this.plugin = plugin;
this.liveSyncPlugin = liveSyncPlugin;
this.noticeGroups = noticeGroups;
}
}
@@ -13,7 +13,8 @@ describe("ObsidianServiceContext contract", () => {
const app = {} as Parameters[0];
const plugin = {} as Parameters[1];
const liveSyncPlugin = {} as Parameters[2];
const context = new ObsidianServiceContext(app, plugin, liveSyncPlugin);
const noticeGroups = {} as Parameters[3];
const context = new ObsidianServiceContext(app, plugin, liveSyncPlugin, noticeGroups);
expect(observeServiceContext(context, TRANSLATION_KEY)).toEqual({
translation: translateLiveSyncMessage(TRANSLATION_KEY),
@@ -23,5 +24,6 @@ describe("ObsidianServiceContext contract", () => {
expect(context.app).toBe(app);
expect(context.plugin).toBe(plugin);
expect(context.liveSyncPlugin).toBe(liveSyncPlugin);
expect(context.noticeGroups).toBe(noticeGroups);
});
});
+4 -1
View File
@@ -25,12 +25,14 @@ import { ObsidianUIService } from "./ObsidianUIService";
import { createScreenWakeLockManager } from "octagonal-wheels/browser/wakeLock";
import { PouchDB } from "@vrtmrz/livesync-commonlib/compat/pouchdb/pouchdb-browser";
import { OpenKeyValueDatabase } from "@/common/KeyValueDB";
import { ObsidianNoticeGroupManager } from "./ObsidianNoticeGroups";
// InjectableServiceHub
export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceContext> {
constructor(plugin: ObsidianLiveSyncPlugin) {
const context = new ObsidianServiceContext(plugin.app, plugin, plugin);
const noticeGroups = new ObsidianNoticeGroupManager();
const context = new ObsidianServiceContext(plugin.app, plugin, plugin, noticeGroups);
const API = new ObsidianAPIService(context);
const conflict = new ObsidianConflictService(context);
@@ -62,6 +64,7 @@ export class ObsidianServiceHub extends InjectableServiceHub<ObsidianServiceCont
const screenWakeLock = createScreenWakeLockManager();
appLifecycle.onUnload.addHandler(async () => {
await screenWakeLock.dispose();
noticeGroups.dispose();
return true;
});
const database = new ObsidianDatabaseService(context, {