import { computed, reactive, reactiveSource, type ReactiveValue } from "octagonal-wheels/dataobject/reactive"; import { LOG_LEVEL_DEBUG, LOG_LEVEL_INFO, LOG_LEVEL_VERBOSE, PREFIXMD_LOGFILE, type DatabaseConnectingStatus, type LOG_LEVEL, } from "../../lib/src/common/types.ts"; import { cancelTask, scheduleTask } from "octagonal-wheels/concurrency/task"; import { fireAndForget, isDirty, throttle } from "../../lib/src/common/utils.ts"; import { collectingChunks, pluginScanningCount, hiddenFilesEventCount, hiddenFilesProcessingCount, type LogEntry, logMessages, } from "../../lib/src/mock_and_interop/stores.ts"; import { eventHub } from "../../lib/src/hub/hub.ts"; import { EVENT_FILE_RENAMED, EVENT_LAYOUT_READY, EVENT_LEAF_ACTIVE_CHANGED, EVENT_ON_UNRESOLVED_ERROR, } from "../../common/events.ts"; import { AbstractObsidianModule } from "../AbstractObsidianModule.ts"; import { addIcon, normalizePath, Notice } from "../../deps.ts"; import { LOG_LEVEL_NOTICE, setGlobalLogFunction } from "octagonal-wheels/common/logger"; import { LogPaneView, VIEW_TYPE_LOG } from "./Log/LogPaneView.ts"; import { serialized } from "octagonal-wheels/concurrency/lock"; import { $msg } from "src/lib/src/common/i18n.ts"; import { P2PLogCollector } from "@/lib/src/replication/trystero/P2PLogCollector.ts"; import type { LiveSyncCore } from "../../main.ts"; import { LiveSyncError } from "@lib/common/LSError.ts"; import { isValidPath } from "@/common/utils.ts"; import { isValidFilenameInAndroid, isValidFilenameInDarwin, isValidFilenameInWidows, } from "@lib/string_and_binary/path.ts"; import { MARK_LOG_NETWORK_ERROR, MARK_LOG_SEPARATOR } from "@lib/services/lib/logUtils.ts"; import { NetworkWarningStyles } from "@lib/common/models/setting.const.ts"; // This module cannot be a core module because it depends on the Obsidian UI. // DI the log again. const recentLogEntries = reactiveSource([]); const globalLogFunction = (message: any, level?: number, key?: string) => { const messageX = message instanceof Error ? new LiveSyncError("[Error Logged]: " + message.message, { cause: message }) : message; const entry = { message: messageX, level, key } as LogEntry; recentLogEntries.value = [...recentLogEntries.value, entry]; }; setGlobalLogFunction(globalLogFunction); let recentLogs = [] as string[]; function addLog(log: string) { recentLogs = [...recentLogs, log].splice(-200); logMessages.value = recentLogs; } // logStore.intercept(e => e.slice(Math.min(e.length - 200, 0))); const showDebugLog = false; export const MARK_DONE = "\u{2009}\u{2009}"; export class ModuleLog extends AbstractObsidianModule { statusBar?: HTMLElement; statusDiv?: HTMLElement; statusLine?: HTMLDivElement; logMessage?: HTMLDivElement; logHistory?: HTMLDivElement; messageArea?: HTMLDivElement; statusBarLabels!: ReactiveValue<{ message: string; status: string }>; statusLog = reactiveSource(""); activeFileStatus = reactiveSource(""); notifies: { [key: string]: { notice: Notice; count: number } } = {}; p2pLogCollector = new P2PLogCollector(); observeForLogs() { const padSpaces = `\u{2007}`.repeat(10); // const emptyMark = `\u{2003}`; function padLeftSpComputed(numI: ReactiveValue, mark: string) { const formatted = reactiveSource(""); let timer: ReturnType | undefined = undefined; let maxLen = 1; numI.onChanged((numX) => { const num = numX.value; const numLen = `${Math.abs(num)}`.length + 1; maxLen = maxLen < numLen ? numLen : maxLen; if (timer) clearTimeout(timer); if (num == 0) { timer = setTimeout(() => { formatted.value = ""; maxLen = 1; }, 3000); } formatted.value = ` ${mark}${`${padSpaces}${num}`.slice(-maxLen)}`; }); return computed(() => formatted.value); } const labelReplication = padLeftSpComputed(this.services.replication.replicationResultCount, `📥`); const labelDBCount = padLeftSpComputed(this.services.replication.databaseQueueCount, `📄`); const labelStorageCount = padLeftSpComputed(this.services.replication.storageApplyingCount, `💾`); const labelChunkCount = padLeftSpComputed(collectingChunks, `🧩`); const labelPluginScanCount = padLeftSpComputed(pluginScanningCount, `🔌`); const labelConflictProcessCount = padLeftSpComputed(this.services.conflict.conflictProcessQueueCount, `🔩`); const hiddenFilesCount = reactive(() => hiddenFilesEventCount.value - hiddenFilesProcessingCount.value); const labelHiddenFilesCount = padLeftSpComputed(hiddenFilesCount, `⚙️`); const queueCountLabelX = reactive(() => { return `${labelReplication()}${labelDBCount()}${labelStorageCount()}${labelChunkCount()}${labelPluginScanCount()}${labelHiddenFilesCount()}${labelConflictProcessCount()}`; }); const queueCountLabel = () => queueCountLabelX.value; const requestingStatLabel = computed(() => { const diff = this.services.API.requestCount.value - this.services.API.responseCount.value; return diff != 0 ? "📲 " : ""; }); const replicationStatLabel = computed(() => { const e = this.services.replicator.replicationStatics.value; const sent = e.sent; const arrived = e.arrived; const maxPullSeq = e.maxPullSeq; const maxPushSeq = e.maxPushSeq; const lastSyncPullSeq = e.lastSyncPullSeq; const lastSyncPushSeq = e.lastSyncPushSeq; let pushLast = ""; let pullLast = ""; let w = ""; const labels: Partial> = { CONNECTED: "⚡", JOURNAL_SEND: "📦↑", JOURNAL_RECEIVE: "📦↓", }; switch (e.syncStatus) { case "CLOSED": case "COMPLETED": case "NOT_CONNECTED": w = "⏹"; break; case "STARTED": w = "🌀"; break; case "PAUSED": w = "💤"; break; case "CONNECTED": case "JOURNAL_SEND": case "JOURNAL_RECEIVE": w = labels[e.syncStatus] || "⚡"; pushLast = lastSyncPushSeq == 0 ? "" : lastSyncPushSeq >= maxPushSeq ? " (LIVE)" : ` (${maxPushSeq - lastSyncPushSeq})`; pullLast = lastSyncPullSeq == 0 ? "" : lastSyncPullSeq >= maxPullSeq ? " (LIVE)" : ` (${maxPullSeq - lastSyncPullSeq})`; break; case "ERRORED": w = "⚠"; break; default: w = "?"; } return { w, sent, pushLast, arrived, pullLast }; }); const labelProc = padLeftSpComputed(this.services.fileProcessing.processing, `⏳`); const labelPend = padLeftSpComputed(this.services.fileProcessing.totalQueued, `🛫`); const labelInBatchDelay = padLeftSpComputed(this.services.fileProcessing.batched, `📬`); const waitingLabel = computed(() => { return `${labelProc()}${labelPend()}${labelInBatchDelay()}`; }); const statusLineLabel = computed(() => { const { w, sent, pushLast, arrived, pullLast } = replicationStatLabel(); const queued = queueCountLabel(); const waiting = waitingLabel(); const networkActivity = requestingStatLabel(); const p2p = this.p2pLogCollector.p2pReplicationLine.value; return { message: `${networkActivity}Sync: ${w} ↑ ${sent}${pushLast} ↓ ${arrived}${pullLast}${waiting}${queued}${p2p == "" ? "" : "\n" + p2p}`, }; }); const statusBarLabels = reactive(() => { const scheduleMessage = this.services.appLifecycle.isReloadingScheduled() ? `WARNING! RESTARTING OBSIDIAN IS SCHEDULED!\n` : ""; const { message } = statusLineLabel(); const fileStatus = this.activeFileStatus.value; const status = scheduleMessage + this.statusLog.value; const fileStatusIcon = `${fileStatus && this.settings.hideFileWarningNotice ? " ⛔ SKIP" : ""}`; return { message: `${message}${fileStatusIcon}`, status, }; }); this.statusBarLabels = statusBarLabels; const applyToDisplay = throttle((label: typeof statusBarLabels.value) => { // const v = label; this.applyStatusBarText(); }, 20); statusBarLabels.onChanged((label) => applyToDisplay(label.value)); this.activeFileStatus.onChanged(() => this.updateMessageArea()); } private _everyOnload(): Promise { eventHub.onEvent(EVENT_LEAF_ACTIVE_CHANGED, () => this.onActiveLeafChange()); eventHub.onceEvent(EVENT_LAYOUT_READY, () => this.onActiveLeafChange()); eventHub.onEvent(EVENT_ON_UNRESOLVED_ERROR, () => this.updateMessageArea()); return Promise.resolve(true); } adjustStatusDivPosition() { const mdv = this.app.workspace.getMostRecentLeaf(); if (mdv && this.statusDiv) { this.statusDiv.remove(); // this.statusDiv.pa(); const container = mdv.view.containerEl; container.insertBefore(this.statusDiv, container.lastChild); } } async getActiveFileStatus() { const reason = [] as string[]; const reasonWarn = [] as string[]; const thisFile = this.app.workspace.getActiveFile(); if (!thisFile) return ""; const validPath = isValidPath(thisFile.path); if (!validPath) { reason.push("This file has an invalid path under the current settings"); } else { // The most narrow check: Filename validity on Windows const validOnWindows = isValidFilenameInWidows(thisFile.name); const validOnDarwin = isValidFilenameInDarwin(thisFile.name); const validOnAndroid = isValidFilenameInAndroid(thisFile.name); const labels = []; if (!validOnWindows) labels.push("🪟"); if (!validOnDarwin) labels.push("🍎"); if (!validOnAndroid) labels.push("🤖"); if (labels.length > 0) { reasonWarn.push("Some platforms may be unable to process this file correctly: " + labels.join(" ")); } } // Case Sensitivity if (this.services.vault.shouldCheckCaseInsensitively()) { const f = (await this.core.storageAccess.getFiles()) .map((e) => e.path) .filter((e) => e.toLowerCase() == thisFile.path.toLowerCase()); if (f.length > 1) { reason.push("There are multiple files with the same name (case-insensitive match)"); } } if (!(await this.services.vault.isTargetFile(thisFile.path))) { reason.push("This file is ignored by the ignore rules"); } if (this.services.vault.isFileSizeTooLarge(thisFile.stat.size)) { reason.push("This file size exceeds the configured limit"); } const result = reason.length > 0 ? "Not synchronised: " + reason.join(", ") : ""; const warnResult = reasonWarn.length > 0 ? "Warning: " + reasonWarn.join(", ") : ""; return [result, warnResult].filter((e) => e).join("\n"); } async setFileStatus() { const fileStatus = await this.getActiveFileStatus(); this.activeFileStatus.value = fileStatus; } async updateMessageArea() { if (this.messageArea) { const messageLines = []; const fileStatus = this.activeFileStatus.value; if (fileStatus && !this.settings.hideFileWarningNotice) messageLines.push(fileStatus); const messages = (await this.services.appLifecycle.getUnresolvedMessages()).flat().filter((e) => e); const stringMessages = messages.filter((m): m is string => typeof m === "string"); // for 'startsWith' const networkMessages = stringMessages.filter((m) => m.startsWith(MARK_LOG_NETWORK_ERROR)); const otherMessages = stringMessages.filter((m) => !m.startsWith(MARK_LOG_NETWORK_ERROR)); messageLines.push(...otherMessages); if ( this.settings.networkWarningStyle !== NetworkWarningStyles.ICON && this.settings.networkWarningStyle !== NetworkWarningStyles.HIDDEN ) { messageLines.push(...networkMessages); } else if (this.settings.networkWarningStyle === NetworkWarningStyles.ICON) { if (networkMessages.length > 0) messageLines.push("🔗❌"); } this.messageArea.innerText = messageLines.map((e) => `⚠️ ${e}`).join("\n"); } } onActiveLeafChange() { fireAndForget(async () => { this.adjustStatusDivPosition(); await this.setFileStatus(); }); } nextFrameQueue: ReturnType | undefined = undefined; logLines: { ttl: number; message: string }[] = []; applyStatusBarText() { if (this.nextFrameQueue) { return; } this.nextFrameQueue = requestAnimationFrame(() => { this.nextFrameQueue = undefined; const { message, status } = this.statusBarLabels.value; // const recent = logMessages.value; const newMsg = message; let newLog = this.settings?.showOnlyIconsOnEditor ? "" : status; const moduleTagEnd = newLog.indexOf(`]${MARK_LOG_SEPARATOR}`); if (moduleTagEnd != -1) { newLog = newLog.substring(moduleTagEnd + MARK_LOG_SEPARATOR.length + 1); } this.statusBar?.setText(newMsg.split("\n")[0]); if (this.settings?.showStatusOnEditor && this.statusDiv) { if (this.settings.showLongerLogInsideEditor) { const now = new Date().getTime(); this.logLines = this.logLines.filter((e) => e.ttl > now); const minimumNext = this.logLines.reduce( (a, b) => (a < b.ttl ? a : b.ttl), Number.MAX_SAFE_INTEGER ); if (this.logLines.length > 0) setTimeout(() => this.applyStatusBarText(), minimumNext - now); const recent = this.logLines.map((e) => e.message); const recentLogs = recent.reverse().join("\n"); if (isDirty("recentLogs", recentLogs)) this.logHistory!.innerText = recentLogs; } if (isDirty("newMsg", newMsg)) this.statusLine!.innerText = newMsg; if (isDirty("newLog", newLog)) this.logMessage!.innerText = newLog; } else { // const root = activeDocument.documentElement; // root.style.setProperty("--log-text", "'" + (newMsg + "\\A " + newLog) + "'"); } }); scheduleTask("log-hide", 3000, () => { this.statusLog.value = ""; }); } private _allStartOnUnload(): Promise { if (this.statusDiv) { this.statusDiv.remove(); } document.querySelectorAll(`.livesync-status`)?.forEach((e) => e.remove()); return Promise.resolve(true); } _everyOnloadStart(): Promise { addIcon( "view-log", ` ` ); this.addRibbonIcon("view-log", $msg("moduleLog.showLog"), () => { void this.services.API.showWindow(VIEW_TYPE_LOG); }).addClass("livesync-ribbon-showlog"); this.addCommand({ id: "view-log", name: "Show log", callback: () => { void this.services.API.showWindow(VIEW_TYPE_LOG); }, }); this.registerView(VIEW_TYPE_LOG, (leaf) => new LogPaneView(leaf, this.plugin)); return Promise.resolve(true); } private _everyOnloadAfterLoadSettings(): Promise { recentLogEntries.onChanged((entries) => { if (entries.value.length === 0) return; const newEntries = [...entries.value]; recentLogEntries.value = []; newEntries.forEach((e) => this.__addLog(e.message, e.level, e.key)); }); eventHub.onEvent(EVENT_FILE_RENAMED, (data) => { void this.setFileStatus(); }); const w = document.querySelectorAll(`.livesync-status`); w.forEach((e) => e.remove()); this.observeForLogs(); this.statusDiv = this.app.workspace.containerEl.createDiv({ cls: "livesync-status" }); this.statusLine = this.statusDiv.createDiv({ cls: "livesync-status-statusline" }); this.messageArea = this.statusDiv.createDiv({ cls: "livesync-status-messagearea" }); this.logMessage = this.statusDiv.createDiv({ cls: "livesync-status-logmessage" }); this.logHistory = this.statusDiv.createDiv({ cls: "livesync-status-loghistory" }); eventHub.onEvent(EVENT_LAYOUT_READY, () => this.adjustStatusDivPosition()); if (this.settings?.showStatusOnStatusbar) { this.statusBar = this.services.API.addStatusBarItem(); this.statusBar?.addClass("syncstatusbar"); } this.adjustStatusDivPosition(); return Promise.resolve(true); } writeLogToTheFile(now: Date, vaultName: string, newMessage: string) { fireAndForget(() => serialized("writeLog", async () => { const time = now.toISOString().split("T")[0]; const logDate = `${PREFIXMD_LOGFILE}${time}.md`; const file = await this.core.storageAccess.isExists(normalizePath(logDate)); if (!file) { await this.core.storageAccess.appendHiddenFile(normalizePath(logDate), "```\n"); } await this.core.storageAccess.appendHiddenFile( normalizePath(logDate), vaultName + ":" + newMessage + "\n" ); }) ); } __addLog(message: any, level: LOG_LEVEL = LOG_LEVEL_INFO, key = ""): void { if (level == LOG_LEVEL_DEBUG && !showDebugLog) { return; } if (level <= LOG_LEVEL_INFO && this.settings && this.settings.lessInformationInLog) { return; } if (this.settings && !this.settings.showVerboseLog && level == LOG_LEVEL_VERBOSE) { return; } const vaultName = this.services.vault.getVaultName(); const now = new Date(); const timestamp = now.toLocaleString(); let errorInfo = ""; if (message instanceof Error) { if (message instanceof LiveSyncError) { errorInfo = `${message.cause?.name}:${message.cause?.message}\n[StackTrace]: ${message.stack}\n[CausedBy]: ${message.cause?.stack}`; } else { const thisStack = new Error().stack; errorInfo = `${message.name}:${message.message}\n[StackTrace]: ${message.stack}\n[LogCallStack]: ${thisStack}`; } } const messageContent = typeof message == "string" ? message : message instanceof Error ? `${errorInfo}` : JSON.stringify(message, null, 2); const newMessage = timestamp + "->" + messageContent; if (message instanceof Error) { console.error(vaultName + ":" + newMessage); } else if (level >= LOG_LEVEL_INFO) { console.log(vaultName + ":" + newMessage); } else { console.debug(vaultName + ":" + newMessage); } if (!this.settings?.showOnlyIconsOnEditor) { this.statusLog.value = messageContent; } if (this.settings?.writeLogToTheFile) { this.writeLogToTheFile(now, vaultName, newMessage); } addLog(newMessage); this.logLines.push({ ttl: now.getTime() + 3000, message: newMessage }); if (level >= LOG_LEVEL_NOTICE) { if (!key) key = messageContent; if (key in this.notifies) { // @ts-ignore const isShown = this.notifies[key].notice.noticeEl?.isShown(); if (!isShown) { this.notifies[key].notice = new Notice(messageContent, 0); } cancelTask(`notify-${key}`); if (key == messageContent) { this.notifies[key].count++; this.notifies[key].notice.setMessage(`(${this.notifies[key].count}):${messageContent}`); } else { this.notifies[key].notice.setMessage(`${messageContent}`); } } else { const notify = new Notice(messageContent, 0); this.notifies[key] = { count: 0, notice: notify, }; } const timeout = 5000; if (!key.startsWith("keepalive-") || messageContent.indexOf(MARK_DONE) !== -1) { scheduleTask(`notify-${key}`, timeout, () => { const notify = this.notifies[key].notice; delete this.notifies[key]; try { notify.hide(); } catch { // NO OP } }); } } } override onBindFunction(core: LiveSyncCore, services: typeof core.services): void { services.API.addLog.setHandler(globalLogFunction); services.appLifecycle.onInitialise.addHandler(this._everyOnloadStart.bind(this)); services.appLifecycle.onSettingLoaded.addHandler(this._everyOnloadAfterLoadSettings.bind(this)); services.appLifecycle.onLoaded.addHandler(this._everyOnload.bind(this)); services.appLifecycle.onBeforeUnload.addHandler(this._allStartOnUnload.bind(this)); } }