import { TFile, Modal, App, DIFF_DELETE, DIFF_EQUAL, DIFF_INSERT, diff_match_patch } from "../../../deps.ts"; import { getPathFromTFile, isValidPath } from "../../../common/utils.ts"; import { decodeBinary, readString } from "../../../lib/src/string_and_binary/convert.ts"; import ObsidianLiveSyncPlugin from "../../../main.ts"; import { type DocumentID, type FilePathWithPrefix, type LoadedEntry, LOG_LEVEL_INFO, LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE, } from "../../../lib/src/common/types.ts"; import { Logger } from "../../../lib/src/common/logger.ts"; import { isErrorOfMissingDoc } from "../../../lib/src/pouchdb/utils_couchdb.ts"; import { fireAndForget, getDocData, readContent } from "../../../lib/src/common/utils.ts"; import { isPlainText, stripPrefix } from "../../../lib/src/string_and_binary/path.ts"; import { scheduleOnceIfDuplicated } from "octagonal-wheels/concurrency/lock"; import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts"; import { compatGlobal } from "@lib/common/coreEnvFunctions.ts"; function isImage(path: string) { const ext = path.split(".").splice(-1)[0].toLowerCase(); return ["png", "jpg", "jpeg", "gif", "bmp", "webp"].includes(ext); } function isComparableText(path: string) { const ext = path.split(".").splice(-1)[0].toLowerCase(); return isPlainText(path) || ["md", "mdx", "txt", "json"].includes(ext); } function isComparableTextDecode(path: string) { const ext = path.split(".").splice(-1)[0].toLowerCase(); return ["json"].includes(ext); } function readDocument(w: LoadedEntry) { if (w.data.length == 0) return ""; if (isImage(w.path)) { return new Uint8Array(decodeBinary(w.data)); } if (w.type == "plain" || w.datatype == "plain") return getDocData(w.data); if (isComparableTextDecode(w.path)) return readString(new Uint8Array(decodeBinary(w.data))); if (isComparableText(w.path)) return getDocData(w.data); try { return readString(new Uint8Array(decodeBinary(w.data))); } catch (ex) { Logger(ex, LOG_LEVEL_VERBOSE); // NO OP. } return getDocData(w.data); } export class DocumentHistoryModal extends Modal { plugin: ObsidianLiveSyncPlugin; core: LiveSyncBaseCore; get services() { return this.core.services; } range!: HTMLInputElement; contentView!: HTMLDivElement; info!: HTMLDivElement; fileInfo!: HTMLDivElement; showDiff = false; diffOnly = false; id?: DocumentID; file: FilePathWithPrefix; revs_info: PouchDB.Core.RevisionInfo[] = []; currentDoc?: LoadedEntry; currentText = ""; currentDeleted = false; initialRev?: string; // Diff navigation state currentDiffIndex = -1; diffNavContainer!: HTMLDivElement; diffNavIndicator!: HTMLSpanElement; diffOnlyLabel!: HTMLLabelElement; // Search state searchKeyword = ""; searchResults: { rev: string; index: number; matchType: "Content" | "Diff" }[] = []; currentSearchIndex = -1; searchResultIndicator!: HTMLSpanElement; searchProgressIndicator!: HTMLSpanElement; searchTimeout: number | null = null; constructor( app: App, core: LiveSyncBaseCore, plugin: ObsidianLiveSyncPlugin, file: TFile | FilePathWithPrefix, id?: DocumentID, revision?: string ) { super(app); this.plugin = plugin; this.core = core; this.file = file instanceof TFile ? getPathFromTFile(file) : file; this.id = id; this.initialRev = revision; if (!file && id) { this.file = this.services.path.id2path(id); } if (this.app.loadLocalStorage("ols-history-highlightdiff") == "1") { this.showDiff = true; } if (this.app.loadLocalStorage("ols-history-diffonly") == "1") { this.diffOnly = true; } } async loadFile(initialRev?: string) { if (!this.id) { this.id = await this.services.path.path2id(this.file); } const db = this.core.localDatabase; try { const w = await db.getRaw(this.id, { revs_info: true }); this.revs_info = w._revs_info?.filter((e) => e?.status == "available") ?? []; this.range.max = `${Math.max(this.revs_info.length - 1, 0)}`; this.range.value = this.range.max; this.fileInfo.setText(`${this.file} / ${this.revs_info.length} revisions`); await this.loadRevs(initialRev); } catch (ex) { if (isErrorOfMissingDoc(ex)) { this.range.max = "0"; this.range.value = ""; this.range.disabled = true; this.contentView.setText(`We don't have any history for this note.`); } else { this.contentView.setText(`Error while loading file.`); Logger(ex, LOG_LEVEL_VERBOSE); } } } async loadRevs(initialRev?: string) { if (this.revs_info.length == 0) return; if (initialRev) { const rIndex = this.revs_info.findIndex((e) => e.rev == initialRev); if (rIndex >= 0) { this.range.value = `${this.revs_info.length - 1 - rIndex}`; } } const index = this.revs_info.length - 1 - (this.range.value as any) / 1; const rev = this.revs_info[index]; await this.showExactRev(rev.rev); } BlobURLs = new Map(); revokeURL(key: string) { const v = this.BlobURLs.get(key); if (v) { URL.revokeObjectURL(v); } this.BlobURLs.delete(key); } generateBlobURL(key: string, data: Uint8Array) { this.revokeURL(key); const v = URL.createObjectURL(new Blob([data], { endings: "transparent", type: "application/octet-stream" })); this.BlobURLs.set(key, v); return v; } prepareContentView(usePreformatted = true) { this.contentView.empty(); this.contentView.toggleClass("op-pre", usePreformatted); } appendTextDiff(diff: [number, string][]) { let hasOmitted = false; for (const [operation, text] of diff) { if (operation == DIFF_DELETE) { this.appendSearchHighlightedText(this.contentView.createSpan({ cls: "history-deleted" }), text); hasOmitted = false; } else if (operation == DIFF_EQUAL) { if (this.diffOnly) { if (!hasOmitted) { this.contentView.appendText("\n...\n"); hasOmitted = true; } } else { this.appendSearchHighlightedText(this.contentView.createSpan({ cls: "history-normal" }), text); } } else if (operation == DIFF_INSERT) { this.appendSearchHighlightedText(this.contentView.createSpan({ cls: "history-added" }), text); hasOmitted = false; } } } appendSearchHighlightedText(container: HTMLElement, text: string) { if (!this.searchKeyword) { container.appendText(text); return; } const escapedKeyword = this.searchKeyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const regex = new RegExp(escapedKeyword, "gi"); let lastIndex = 0; for (const match of text.matchAll(regex)) { const index = match.index ?? 0; if (index > lastIndex) { container.appendText(text.slice(lastIndex, index)); } container.createEl("mark", { text: match[0] }); lastIndex = index + match[0].length; } if (lastIndex < text.length) { container.appendText(text.slice(lastIndex)); } } appendImageDiff(baseSrc: string, overlaySrc?: string) { const wrap = this.contentView.createDiv({ cls: "ls-imgdiff-wrap" }); const overlay = wrap.createDiv({ cls: "overlay" }); overlay.createEl("img", { cls: "img-base" }, (img) => { img.src = baseSrc; }); if (overlaySrc) { overlay.createEl("img", { cls: "img-overlay" }, (img) => { img.src = overlaySrc; }); } } appendDeletedNotice(usePreformatted = true) { const notice = "(At this revision, the file has been deleted)"; if (usePreformatted) { this.contentView.appendText(`${notice}\n`); } else { this.contentView.createDiv({ text: notice }); } } async showExactRev(rev: string) { const db = this.core.localDatabase; const w = await db.getDBEntry(this.file, { rev: rev }, false, false, true); this.currentText = ""; this.currentDeleted = false; this.prepareContentView(); if (w === false) { this.currentDeleted = true; this.info.empty(); this.contentView.appendText("Could not read this revision"); this.contentView.createEl("br"); this.contentView.appendText(`(${rev})`); } else { this.currentDoc = w; this.info.setText(`Modified:${new Date(w.mtime).toLocaleString()}`); const w1data = readDocument(w); this.currentDeleted = !!w.deleted; if (typeof w1data == "string") { this.currentText = w1data; } let rendered = false; if (this.showDiff) { const prevRevIdx = this.revs_info.length - 1 - ((this.range.value as any) / 1 - 1); if (prevRevIdx >= 0 && prevRevIdx < this.revs_info.length) { const oldRev = this.revs_info[prevRevIdx].rev; const w2 = await db.getDBEntry(this.file, { rev: oldRev }, false, false, true); if (w2 != false) { if (typeof w1data == "string") { const w2data = readDocument(w2); if (typeof w2data == "string") { const dmp = new diff_match_patch(); const diff = dmp.diff_main(w2data, w1data); dmp.diff_cleanupSemantic(diff); if (this.currentDeleted) { this.appendDeletedNotice(); } this.appendTextDiff(diff); rendered = true; } } else if (isImage(this.file)) { const src = this.generateBlobURL("base", w1data); const overlay = this.generateBlobURL( "overlay", readDocument(w2) as Uint8Array ); this.prepareContentView(false); if (this.currentDeleted) { this.appendDeletedNotice(false); } this.appendImageDiff(src, overlay); rendered = true; } } } } if (!rendered) { if (typeof w1data != "string") { if (isImage(this.file)) { const src = this.generateBlobURL("base", w1data); this.prepareContentView(false); if (this.currentDeleted) { this.appendDeletedNotice(false); } this.appendImageDiff(src); } else { if (this.currentDeleted) { this.appendDeletedNotice(); } this.contentView.appendText("Binary file"); } } else { if (this.currentDeleted) { this.appendDeletedNotice(); } this.appendSearchHighlightedText(this.contentView, w1data); } } } // Reset diff navigation after content changes this.resetDiffNavigation(); if (this.showDiff) { this.navigateDiff("next"); } else if (this.searchKeyword) { const firstMark = this.contentView.querySelector("mark"); if (firstMark) { firstMark.scrollIntoView({ behavior: "smooth", block: "center" }); } } } /** * Navigate to the previous or next diff block in the content view. * Only effective when diff highlighting is enabled. */ navigateDiff(direction: "prev" | "next") { const diffElements = this.contentView.querySelectorAll(".history-added, .history-deleted"); if (diffElements.length === 0) return; // Remove previous focus highlight const prevFocused = this.contentView.querySelector(".diff-focused"); if (prevFocused) { prevFocused.classList.remove("diff-focused"); } if (direction === "next") { this.currentDiffIndex = (this.currentDiffIndex + 1) % diffElements.length; } else { this.currentDiffIndex = this.currentDiffIndex <= 0 ? diffElements.length - 1 : this.currentDiffIndex - 1; } const target = diffElements[this.currentDiffIndex]; target.classList.add("diff-focused"); target.scrollIntoView({ behavior: "smooth", block: "center" }); this.diffNavIndicator.setText(`${this.currentDiffIndex + 1}/${diffElements.length}`); } /** * Reset the diff navigation index and update the indicator. */ resetDiffNavigation() { this.currentDiffIndex = -1; if (this.diffNavIndicator) { if (this.showDiff) { const diffElements = this.contentView.querySelectorAll(".history-added, .history-deleted"); this.diffNavIndicator.setText(diffElements.length > 0 ? `0/${diffElements.length}` : "\u2014"); } else { this.diffNavIndicator.setText("\u2014"); } } this.updateDiffNavVisibility(); } /** * Show or hide the diff navigation buttons based on the showDiff state. */ updateDiffNavVisibility() { if (this.diffNavContainer) { this.diffNavContainer.style.display = this.showDiff ? "flex" : "none"; } if (this.diffOnlyLabel) { this.diffOnlyLabel.style.display = this.showDiff ? "inline-block" : "none"; } } /** * Search through the last 100 revisions for the given keyword. */ async performSearch(keyword: string) { this.searchKeyword = keyword; this.searchResults = []; this.currentSearchIndex = -1; if (!keyword) { this.searchResultIndicator.setText(""); this.searchProgressIndicator.setText(""); return; } const db = this.core.localDatabase; const limit = 100; const totalRevs = this.revs_info.length; const end = Math.min(totalRevs, limit); this.searchProgressIndicator.setText("Searching..."); const dmp = new diff_match_patch(); // 0 is the newest, higher index is older. for (let i = 0; i < end; i++) { const revInfo = this.revs_info[i]; const rev = revInfo.rev; this.searchProgressIndicator.setText(`Searching ${i + 1}/${end}...`); const doc = await db.getDBEntry(this.file, { rev: rev }, false, false, true); if (doc === false) continue; const content = readDocument(doc); if (typeof content !== "string") continue; const keywordLower = keyword.toLocaleLowerCase(); // Search in content if (content.toLocaleLowerCase().includes(keywordLower)) { this.searchResults.push({ rev, index: i, matchType: "Content" }); this.updateSearchUI(); continue; } // Search in diff (from older version to this version) // Older version is at i + 1 if (i < totalRevs - 1) { const olderRev = this.revs_info[i + 1].rev; const olderDoc = await db.getDBEntry(this.file, { rev: olderRev }, false, false, true); if (olderDoc !== false) { const olderContent = readDocument(olderDoc); if (typeof olderContent === "string") { const diffs = dmp.diff_main(olderContent, content); let foundInDiff = false; for (const d of diffs) { if ( (d[0] === DIFF_INSERT || d[0] === DIFF_DELETE) && d[1].toLocaleLowerCase().includes(keywordLower) ) { foundInDiff = true; break; } } if (foundInDiff) { this.searchResults.push({ rev, index: i, matchType: "Diff" }); this.updateSearchUI(); } } } } } this.searchProgressIndicator.setText("Done"); this.updateSearchUI(); } updateSearchUI() { if (this.searchResults.length === 0) { this.searchResultIndicator.setText(this.searchKeyword ? "No matches found" : ""); } else { const current = this.currentSearchIndex >= 0 ? this.currentSearchIndex + 1 : 0; this.searchResultIndicator.setText(`${current}/${this.searchResults.length} matches`); } } navigateSearch(direction: "prev" | "next") { if (this.searchResults.length === 0) return; if (direction === "next") { this.currentSearchIndex = (this.currentSearchIndex + 1) % this.searchResults.length; } else { this.currentSearchIndex = this.currentSearchIndex <= 0 ? this.searchResults.length - 1 : this.currentSearchIndex - 1; } const match = this.searchResults[this.currentSearchIndex]; this.range.value = `${this.revs_info.length - 1 - match.index}`; void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs()); this.updateSearchUI(); // If it's a diff match, make sure Highlight diff is on if (match.matchType === "Diff" && !this.showDiff) { // We could auto-enable it, but maybe just notify the user? // For now, let's just let the user toggle it if they want to see the diff. } } override onOpen() { const { contentEl } = this; this.titleEl.setText("Document History"); contentEl.empty(); this.fileInfo = contentEl.createDiv(""); this.fileInfo.addClass("op-info"); // Search Row const searchRow = contentEl.createDiv(""); searchRow.addClass("op-info"); searchRow.addClass("search-row"); searchRow.addClass("history-search-row"); const searchInput = searchRow.createEl("input", { type: "text", placeholder: "Search in history (last 100)...", }); searchInput.addClass("history-search-input"); searchInput.addEventListener("input", () => { if (this.searchTimeout) { compatGlobal.clearTimeout(this.searchTimeout); } this.searchTimeout = compatGlobal.setTimeout(() => { void this.performSearch(searchInput.value); }, 500); }); searchRow.createEl("button", { text: "\u25B2" }, (e) => { e.title = "Previous match"; e.addEventListener("click", () => this.navigateSearch("prev")); }); searchRow.createEl("button", { text: "\u25BC" }, (e) => { e.title = "Next match"; e.addEventListener("click", () => this.navigateSearch("next")); }); this.searchResultIndicator = searchRow.createEl("span", { text: "" }); this.searchResultIndicator.addClass("history-search-result-indicator"); this.searchProgressIndicator = searchRow.createEl("span", { text: "" }); this.searchProgressIndicator.addClass("history-search-progress-indicator"); const divView = contentEl.createDiv(""); divView.addClass("op-flex"); divView.createEl("input", { type: "range" }, (e) => { this.range = e; e.addEventListener("change", (e) => { void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs()); }); e.addEventListener("input", (e) => { void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs()); }); }); const diffOptionsRow = contentEl.createDiv(""); diffOptionsRow.addClass("op-info"); diffOptionsRow.addClass("diff-options-row"); diffOptionsRow.addClass("history-diff-options-row"); const highlightDiffContainer = diffOptionsRow.createDiv(""); highlightDiffContainer.addClass("history-highlight-diff-container"); highlightDiffContainer.createEl("label", {}, (label) => { label.addClass("history-highlight-diff-label"); label.createEl("input", { type: "checkbox" }, (checkbox) => { if (this.showDiff) { checkbox.checked = true; } checkbox.addEventListener("input", (evt: any) => { this.showDiff = checkbox.checked; this.app.saveLocalStorage("ols-history-highlightdiff", this.showDiff == true ? "1" : null); this.updateDiffNavVisibility(); void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs()); }); }); label.appendText("Highlight diff"); }); const diffOnlyLabel = diffOptionsRow.createEl("label", {}); diffOnlyLabel.createEl("input", { type: "checkbox" }, (checkbox) => { if (this.diffOnly) { checkbox.checked = true; } checkbox.addEventListener("input", (evt: any) => { this.diffOnly = checkbox.checked; this.app.saveLocalStorage("ols-history-diffonly", this.diffOnly == true ? "1" : null); void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs()); }); }); diffOnlyLabel.appendText("Diff only"); diffOnlyLabel.addClass("diff-only-label"); diffOnlyLabel.style.display = this.showDiff ? "inline-block" : "none"; this.diffOnlyLabel = diffOnlyLabel; // Diff navigation buttons this.diffNavContainer = diffOptionsRow.createDiv(""); this.diffNavContainer.addClass("diff-nav"); this.diffNavContainer.style.display = this.showDiff ? "flex" : "none"; this.diffNavContainer.createEl("button", { text: "\u25B2 Prev" }, (e) => { e.addClass("diff-nav-btn"); e.addEventListener("click", () => { this.navigateDiff("prev"); }); }); this.diffNavContainer.createEl("button", { text: "\u25BC Next" }, (e) => { e.addClass("diff-nav-btn"); e.addEventListener("click", () => { this.navigateDiff("next"); }); }); this.diffNavIndicator = this.diffNavContainer.createEl("span", { text: "\u2014" }); this.diffNavIndicator.addClass("diff-nav-indicator"); this.info = contentEl.createDiv(""); this.info.addClass("op-info"); fireAndForget(async () => await this.loadFile(this.initialRev)); const div = contentEl.createDiv({ text: "Loading old revisions..." }); this.contentView = div; div.addClass("op-scrollable"); div.addClass("op-pre"); const buttons = contentEl.createDiv(""); buttons.createEl("button", { text: "Copy to clipboard" }, (e) => { e.addClass("mod-cta"); e.addEventListener("click", () => { fireAndForget(async () => { await navigator.clipboard.writeText(this.currentText); Logger(`Old content copied to clipboard`, LOG_LEVEL_NOTICE); }); }); }); const focusFile = async (path: string) => { const targetFile = this.plugin.app.vault.getFileByPath(path); if (targetFile) { const leaf = this.plugin.app.workspace.getLeaf(false); await leaf.openFile(targetFile); } else { Logger("Unable to display the file in the editor", LOG_LEVEL_NOTICE); } }; buttons.createEl("button", { text: "Back to this revision" }, (e) => { e.addClass("mod-cta"); e.addEventListener("click", () => { fireAndForget(async () => { // const pathToWrite = this.plugin.id2path(this.id, true); const pathToWrite = stripPrefix(this.file); if (!isValidPath(pathToWrite)) { Logger("Path is not valid to write content.", LOG_LEVEL_INFO); return; } if (!this.currentDoc) { Logger("No active file loaded.", LOG_LEVEL_INFO); return; } const d = readContent(this.currentDoc); await this.core.storageAccess.writeHiddenFileAuto(pathToWrite, d); await focusFile(pathToWrite); this.close(); }); }); }); } override onClose() { const { contentEl } = this; contentEl.empty(); this.BlobURLs.forEach((value) => { console.log(value); if (value) URL.revokeObjectURL(value); }); } }