From 0ae25d22129c97af992b43af865d0cce898dc7cc Mon Sep 17 00:00:00 2001 From: SiriusXT <1160925501@qq.com> Date: Thu, 4 Sep 2025 10:53:46 +0800 Subject: [PATCH] feat: show source diff between note and revision --- apps/client/src/services/utils.ts | 49 +++++++++++++++ .../src/translations/en/translation.json | 5 ++ apps/client/src/widgets/dialogs/revisions.tsx | 63 +++++++++++++++++-- .../widgets/type_widgets/read_only_code.ts | 51 +-------------- 4 files changed, 115 insertions(+), 53 deletions(-) diff --git a/apps/client/src/services/utils.ts b/apps/client/src/services/utils.ts index 77fec1366..bf3894474 100644 --- a/apps/client/src/services/utils.ts +++ b/apps/client/src/services/utils.ts @@ -297,6 +297,54 @@ function isHtmlEmpty(html: string) { ); } +function formatHtml(html: string) { + let indent = "\n"; + const tab = "\t"; + let i = 0; + let pre: { indent: string; tag: string }[] = []; + + html = html + .replace(new RegExp("
((.|\\t|\\n|\\r)+)?
"), function (x) { + pre.push({ indent: "", tag: x }); + return "<--TEMPPRE" + i++ + "/-->"; + }) + .replace(new RegExp("<[^<>]+>[^<]?", "g"), function (x) { + let ret; + const tagRegEx = /<\/?([^\s/>]+)/.exec(x); + let tag = tagRegEx ? tagRegEx[1] : ""; + let p = new RegExp("<--TEMPPRE(\\d+)/-->").exec(x); + + if (p) { + const pInd = parseInt(p[1]); + pre[pInd].indent = indent; + } + + if (["area", "base", "br", "col", "command", "embed", "hr", "img", "input", "keygen", "link", "menuitem", "meta", "param", "source", "track", "wbr"].indexOf(tag) >= 0) { + // self closing tag + ret = indent + x; + } else { + if (x.indexOf("") ret = indent + x.substr(0, x.length - 1) + indent + tab + x.substr(x.length - 1, x.length); + else ret = indent + x; + !p && (indent += tab); + } else { + //close tag + indent = indent.substr(0, indent.length - 1); + if (x.charAt(x.length - 1) !== ">") ret = indent + x.substr(0, x.length - 1) + indent + x.substr(x.length - 1, x.length); + else ret = indent + x; + } + } + return ret; + }); + + for (i = pre.length; i--;) { + html = html.replace("<--TEMPPRE" + i + "/-->", pre[i].tag.replace("
", "
\n").replace("
", pre[i].indent + "
")); + } + + return html.charAt(0) === "\n" ? html.substr(1, html.length - 1) : html; +} + export async function clearBrowserCache() { if (isElectron()) { const win = dynamicRequire("@electron/remote").getCurrentWindow(); @@ -855,6 +903,7 @@ export default { getNoteTypeClass, getMimeTypeClass, isHtmlEmpty, + formatHtml, clearBrowserCache, copySelectionToClipboard, dynamicRequire, diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index d76843a27..1fb32af95 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -263,6 +263,11 @@ "confirm_delete_all": "Do you want to delete all revisions of this note?", "no_revisions": "No revisions for this note yet...", "restore_button": "Restore", + "diff_button": "Diff", + "content_button": "Content", + "diff_button_title": "Show note source diff", + "content_button_title": "Show revision content", + "diff_not_available": "Diff isn't available.", "confirm_restore": "Do you want to restore this revision? This will overwrite the current title and content of the note with this revision.", "delete_button": "Delete", "confirm_delete": "Do you want to delete this revision?", diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx index 0fa4f956e..78f4468ae 100644 --- a/apps/client/src/widgets/dialogs/revisions.tsx +++ b/apps/client/src/widgets/dialogs/revisions.tsx @@ -18,12 +18,15 @@ import open from "../../services/open"; import ActionButton from "../react/ActionButton"; import options from "../../services/options"; import { useTriliumEvent } from "../react/hooks"; +import { diffWords } from "diff"; export default function RevisionsDialog() { const [ note, setNote ] = useState(); + const [ noteContent, setNoteContent ] = useState(); const [ revisions, setRevisions ] = useState(); const [ currentRevision, setCurrentRevision ] = useState(); const [ shown, setShown ] = useState(false); + const [ showDiff, setShowDiff ] = useState(false); const [ refreshCounter, setRefreshCounter ] = useState(0); useTriliumEvent("showRevisions", async ({ noteId }) => { @@ -37,8 +40,10 @@ export default function RevisionsDialog() { useEffect(() => { if (note?.noteId) { server.get(`notes/${note.noteId}/revisions`).then(setRevisions); + note.getContent().then(setNoteContent); } else { setRevisions(undefined); + setNoteContent(undefined); } }, [ note?.noteId, refreshCounter ]); @@ -70,6 +75,7 @@ export default function RevisionsDialog() { footerStyle={{ paddingTop: 0, paddingBottom: 0 }} onHidden={() => { setShown(false); + setShowDiff(false); setNote(undefined); setCurrentRevision(undefined); setRevisions(undefined); @@ -92,11 +98,15 @@ export default function RevisionsDialog() { marginLeft: "20px", display: "flex", flexDirection: "column", + maxWidth: "calc(100% - 150px)", minWidth: 0 }}> { setRefreshCounter(c => c + 1); setCurrentRevision(undefined); @@ -121,9 +131,12 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re ); } -function RevisionPreview({ revisionItem, setShown, onRevisionDeleted }: { +function RevisionPreview({noteContent, revisionItem, setShown, showDiff, setShowDiff, onRevisionDeleted }: { + noteContent?: string, revisionItem?: RevisionItem, - setShown: Dispatch>, + setShown: Dispatch>, + showDiff: boolean, + setShowDiff: Dispatch>, onRevisionDeleted?: () => void }) { const [ fullRevision, setFullRevision ] = useState(); @@ -143,6 +156,17 @@ function RevisionPreview({ revisionItem, setShown, onRevisionDeleted }: { {(revisionItem &&
{(!revisionItem.isProtected || protected_session_holder.isProtectedSessionAvailable()) && <> + {["text", "code", "mermaid"].includes(revisionItem.type) && ( +
- +
); @@ -197,12 +221,15 @@ const CODE_STYLE: CSSProperties = { whiteSpace: "pre-wrap" }; -function RevisionContent({ revisionItem, fullRevision }: { revisionItem?: RevisionItem, fullRevision?: RevisionPojo }) { +function RevisionContent({ noteContent, revisionItem, fullRevision, showDiff }: { noteContent?:string, revisionItem?: RevisionItem, fullRevision?: RevisionPojo, showDiff: boolean}) { const content = fullRevision?.content; if (!revisionItem || !content) { return <>; } + if (showDiff) { + return + } switch (revisionItem.type) { case "text": return @@ -267,6 +294,34 @@ function RevisionContentText({ content }: { content: string | Buffer } +function RevisionContentDiff({ noteContent, itemContent, itemType }: { noteContent?: string, itemContent: string | Buffer | undefined, itemType: string }) { + let diffHtml: string; + + if (noteContent && typeof itemContent === "string") { + if (itemType === "text") { + noteContent = utils.formatHtml(noteContent); + itemContent = utils.formatHtml(itemContent); + } + const diff = diffWords(noteContent, itemContent); + diffHtml = diff.map(part => { + if (part.added) { + return `${utils.escapeHtml(part.value)}`; + } else if (part.removed) { + return `${utils.escapeHtml(part.value)}`; + } else { + return utils.escapeHtml(part.value); + } + }).join(""); + } else { + return <>{t("revisions.diff_not_available")} + } + + return ( +
+ ); +} + function RevisionFooter({ note }: { note?: FNote }) { if (!note) { return <>; diff --git a/apps/client/src/widgets/type_widgets/read_only_code.ts b/apps/client/src/widgets/type_widgets/read_only_code.ts index fd74aaa5a..cdae4565e 100644 --- a/apps/client/src/widgets/type_widgets/read_only_code.ts +++ b/apps/client/src/widgets/type_widgets/read_only_code.ts @@ -1,6 +1,7 @@ import type { EventData } from "../../components/app_context.js"; import type FNote from "../../entities/fnote.js"; import AbstractCodeTypeWidget from "./abstract_code_type_widget.js"; +import utils from "../../services/utils.js"; const TPL = /*html*/`
@@ -33,7 +34,7 @@ export default class ReadOnlyCodeTypeWidget extends AbstractCodeTypeWidget { if (!blob) return; const isFormattable = note.type === "text" && this.noteContext?.viewScope?.viewMode === "source"; - const content = isFormattable ? this.format(blob.content) : blob.content; + const content = isFormattable ? utils.formatHtml(blob.content) : blob.content; this._update(note, content); this.show(); @@ -54,52 +55,4 @@ export default class ReadOnlyCodeTypeWidget extends AbstractCodeTypeWidget { resolve(this.$editor); } - - format(html: string) { - let indent = "\n"; - const tab = "\t"; - let i = 0; - let pre: { indent: string; tag: string }[] = []; - - html = html - .replace(new RegExp("
((.|\\t|\\n|\\r)+)?
"), function (x) { - pre.push({ indent: "", tag: x }); - return "<--TEMPPRE" + i++ + "/-->"; - }) - .replace(new RegExp("<[^<>]+>[^<]?", "g"), function (x) { - let ret; - const tagRegEx = /<\/?([^\s/>]+)/.exec(x); - let tag = tagRegEx ? tagRegEx[1] : ""; - let p = new RegExp("<--TEMPPRE(\\d+)/-->").exec(x); - - if (p) { - const pInd = parseInt(p[1]); - pre[pInd].indent = indent; - } - - if (["area", "base", "br", "col", "command", "embed", "hr", "img", "input", "keygen", "link", "menuitem", "meta", "param", "source", "track", "wbr"].indexOf(tag) >= 0) { - // self closing tag - ret = indent + x; - } else { - if (x.indexOf("") ret = indent + x.substr(0, x.length - 1) + indent + tab + x.substr(x.length - 1, x.length); - else ret = indent + x; - !p && (indent += tab); - } else { - //close tag - indent = indent.substr(0, indent.length - 1); - if (x.charAt(x.length - 1) !== ">") ret = indent + x.substr(0, x.length - 1) + indent + x.substr(x.length - 1, x.length); - else ret = indent + x; - } - } - return ret; - }); - - for (i = pre.length; i--;) { - html = html.replace("<--TEMPPRE" + i + "/-->", pre[i].tag.replace("
", "
\n").replace("
", pre[i].indent + "
")); - } - - return html.charAt(0) === "\n" ? html.substr(1, html.length - 1) : html; - } }