mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 18:36:30 +01:00 
			
		
		
		
	feat: show source diff between note and revision
This commit is contained in:
		| @@ -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("<pre>((.|\\t|\\n|\\r)+)?</pre>"), 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("</") < 0) { | ||||||
|  |                     //open tag | ||||||
|  |                     if (x.charAt(x.length - 1) !== ">") 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("<pre>", "<pre>\n").replace("</pre>", pre[i].indent + "</pre>")); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return html.charAt(0) === "\n" ? html.substr(1, html.length - 1) : html; | ||||||
|  | } | ||||||
|  |  | ||||||
| export async function clearBrowserCache() { | export async function clearBrowserCache() { | ||||||
|     if (isElectron()) { |     if (isElectron()) { | ||||||
|         const win = dynamicRequire("@electron/remote").getCurrentWindow(); |         const win = dynamicRequire("@electron/remote").getCurrentWindow(); | ||||||
| @@ -855,6 +903,7 @@ export default { | |||||||
|     getNoteTypeClass, |     getNoteTypeClass, | ||||||
|     getMimeTypeClass, |     getMimeTypeClass, | ||||||
|     isHtmlEmpty, |     isHtmlEmpty, | ||||||
|  |     formatHtml, | ||||||
|     clearBrowserCache, |     clearBrowserCache, | ||||||
|     copySelectionToClipboard, |     copySelectionToClipboard, | ||||||
|     dynamicRequire, |     dynamicRequire, | ||||||
|   | |||||||
| @@ -263,6 +263,11 @@ | |||||||
|     "confirm_delete_all": "Do you want to delete all revisions of this note?", |     "confirm_delete_all": "Do you want to delete all revisions of this note?", | ||||||
|     "no_revisions": "No revisions for this note yet...", |     "no_revisions": "No revisions for this note yet...", | ||||||
|     "restore_button": "Restore", |     "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.", |     "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", |     "delete_button": "Delete", | ||||||
|     "confirm_delete": "Do you want to delete this revision?", |     "confirm_delete": "Do you want to delete this revision?", | ||||||
|   | |||||||
| @@ -18,12 +18,15 @@ import open from "../../services/open"; | |||||||
| import ActionButton from "../react/ActionButton"; | import ActionButton from "../react/ActionButton"; | ||||||
| import options from "../../services/options"; | import options from "../../services/options"; | ||||||
| import { useTriliumEvent } from "../react/hooks"; | import { useTriliumEvent } from "../react/hooks"; | ||||||
|  | import { diffWords } from "diff"; | ||||||
|  |  | ||||||
| export default function RevisionsDialog() { | export default function RevisionsDialog() { | ||||||
|     const [ note, setNote ] = useState<FNote>(); |     const [ note, setNote ] = useState<FNote>(); | ||||||
|  |     const [ noteContent, setNoteContent ] = useState<string>(); | ||||||
|     const [ revisions, setRevisions ] = useState<RevisionItem[]>(); |     const [ revisions, setRevisions ] = useState<RevisionItem[]>(); | ||||||
|     const [ currentRevision, setCurrentRevision ] = useState<RevisionItem>(); |     const [ currentRevision, setCurrentRevision ] = useState<RevisionItem>(); | ||||||
|     const [ shown, setShown ] = useState(false); |     const [ shown, setShown ] = useState(false); | ||||||
|  |     const [ showDiff, setShowDiff ] = useState(false); | ||||||
|     const [ refreshCounter, setRefreshCounter ] = useState(0); |     const [ refreshCounter, setRefreshCounter ] = useState(0); | ||||||
|  |  | ||||||
|     useTriliumEvent("showRevisions", async ({ noteId }) => { |     useTriliumEvent("showRevisions", async ({ noteId }) => { | ||||||
| @@ -37,8 +40,10 @@ export default function RevisionsDialog() { | |||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
|         if (note?.noteId) { |         if (note?.noteId) { | ||||||
|             server.get<RevisionItem[]>(`notes/${note.noteId}/revisions`).then(setRevisions); |             server.get<RevisionItem[]>(`notes/${note.noteId}/revisions`).then(setRevisions); | ||||||
|  |             note.getContent().then(setNoteContent); | ||||||
|         } else { |         } else { | ||||||
|             setRevisions(undefined); |             setRevisions(undefined); | ||||||
|  |             setNoteContent(undefined); | ||||||
|         } |         } | ||||||
|     }, [ note?.noteId, refreshCounter ]); |     }, [ note?.noteId, refreshCounter ]); | ||||||
|  |  | ||||||
| @@ -70,6 +75,7 @@ export default function RevisionsDialog() { | |||||||
|             footerStyle={{ paddingTop: 0, paddingBottom: 0 }} |             footerStyle={{ paddingTop: 0, paddingBottom: 0 }} | ||||||
|             onHidden={() => { |             onHidden={() => { | ||||||
|                 setShown(false); |                 setShown(false); | ||||||
|  |                 setShowDiff(false); | ||||||
|                 setNote(undefined); |                 setNote(undefined); | ||||||
|                 setCurrentRevision(undefined); |                 setCurrentRevision(undefined); | ||||||
|                 setRevisions(undefined); |                 setRevisions(undefined); | ||||||
| @@ -92,11 +98,15 @@ export default function RevisionsDialog() { | |||||||
|                     marginLeft: "20px", |                     marginLeft: "20px", | ||||||
|                     display: "flex", |                     display: "flex", | ||||||
|                     flexDirection: "column", |                     flexDirection: "column", | ||||||
|  |                     maxWidth: "calc(100% - 150px)", | ||||||
|                     minWidth: 0                     |                     minWidth: 0                     | ||||||
|                 }}> |                 }}> | ||||||
|                     <RevisionPreview  |                     <RevisionPreview  | ||||||
|  |                         noteContent={noteContent} | ||||||
|                         revisionItem={currentRevision}  |                         revisionItem={currentRevision}  | ||||||
|                         setShown={setShown} |                         setShown={setShown} | ||||||
|  |                         showDiff={showDiff} | ||||||
|  |                         setShowDiff={setShowDiff} | ||||||
|                         onRevisionDeleted={() => { |                         onRevisionDeleted={() => { | ||||||
|                             setRefreshCounter(c => c + 1); |                             setRefreshCounter(c => c + 1); | ||||||
|                             setCurrentRevision(undefined); |                             setCurrentRevision(undefined); | ||||||
| @@ -121,9 +131,12 @@ function RevisionsList({ revisions, onSelect, currentRevision }: { revisions: Re | |||||||
|         </FormList>); |         </FormList>); | ||||||
| } | } | ||||||
|  |  | ||||||
| function RevisionPreview({ revisionItem, setShown, onRevisionDeleted }: {  | function RevisionPreview({noteContent, revisionItem, setShown, showDiff, setShowDiff, onRevisionDeleted }: { | ||||||
|  |     noteContent?: string, | ||||||
|     revisionItem?: RevisionItem,  |     revisionItem?: RevisionItem,  | ||||||
|     setShown: Dispatch<StateUpdater<boolean>>,  |     setShown: Dispatch<StateUpdater<boolean>>,  | ||||||
|  |     showDiff: boolean, | ||||||
|  |     setShowDiff: Dispatch<StateUpdater<boolean>>,  | ||||||
|     onRevisionDeleted?: () => void |     onRevisionDeleted?: () => void | ||||||
| }) { | }) { | ||||||
|     const [ fullRevision, setFullRevision ] = useState<RevisionPojo>(); |     const [ fullRevision, setFullRevision ] = useState<RevisionPojo>(); | ||||||
| @@ -143,6 +156,17 @@ function RevisionPreview({ revisionItem, setShown, onRevisionDeleted }: { | |||||||
|                 {(revisionItem && <div className="revision-title-buttons"> |                 {(revisionItem && <div className="revision-title-buttons"> | ||||||
|                     {(!revisionItem.isProtected || protected_session_holder.isProtectedSessionAvailable()) && |                     {(!revisionItem.isProtected || protected_session_holder.isProtectedSessionAvailable()) && | ||||||
|                         <> |                         <> | ||||||
|  |                             {["text", "code", "mermaid"].includes(revisionItem.type) && ( | ||||||
|  |                                 <Button | ||||||
|  |                                     icon={showDiff ? "bx bx-detail" : "bx bx-outline"} | ||||||
|  |                                     text={showDiff ? t("revisions.content_button") : t("revisions.diff_button")} | ||||||
|  |                                     title={showDiff ? t("revisions.content_button_title") : t("revisions.diff_button_title")} | ||||||
|  |                                     onClick={async () => { | ||||||
|  |                                         setShowDiff(!showDiff); | ||||||
|  |                                     }} | ||||||
|  |                                 /> | ||||||
|  |                             )} | ||||||
|  |                               | ||||||
|                             <Button |                             <Button | ||||||
|                                 icon="bx bx-history" |                                 icon="bx bx-history" | ||||||
|                                 text={t("revisions.restore_button")} |                                 text={t("revisions.restore_button")} | ||||||
| @@ -179,7 +203,7 @@ function RevisionPreview({ revisionItem, setShown, onRevisionDeleted }: { | |||||||
|                 </div>)} |                 </div>)} | ||||||
|             </div> |             </div> | ||||||
|             <div className="revision-content use-tn-links" style={{ overflow: "auto", wordBreak: "break-word" }}> |             <div className="revision-content use-tn-links" style={{ overflow: "auto", wordBreak: "break-word" }}> | ||||||
|                 <RevisionContent revisionItem={revisionItem} fullRevision={fullRevision} /> |                 <RevisionContent noteContent={noteContent} revisionItem={revisionItem} fullRevision={fullRevision} showDiff={showDiff}/> | ||||||
|             </div> |             </div> | ||||||
|         </> |         </> | ||||||
|     ); |     ); | ||||||
| @@ -197,12 +221,15 @@ const CODE_STYLE: CSSProperties = { | |||||||
|     whiteSpace: "pre-wrap" |     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; |     const content = fullRevision?.content; | ||||||
|     if (!revisionItem || !content) { |     if (!revisionItem || !content) { | ||||||
|         return <></>; |         return <></>; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     if (showDiff) { | ||||||
|  |         return <RevisionContentDiff noteContent={noteContent} itemContent={content} itemType={revisionItem.type}/> | ||||||
|  |     } | ||||||
|     switch (revisionItem.type) { |     switch (revisionItem.type) { | ||||||
|         case "text": |         case "text": | ||||||
|             return <RevisionContentText content={content} /> |             return <RevisionContentText content={content} /> | ||||||
| @@ -267,6 +294,34 @@ function RevisionContentText({ content }: { content: string | Buffer<ArrayBuffer | |||||||
|     return <div ref={contentRef} className="ck-content" dangerouslySetInnerHTML={{ __html: content as string }}></div> |     return <div ref={contentRef} className="ck-content" dangerouslySetInnerHTML={{ __html: content as string }}></div> | ||||||
| } | } | ||||||
|  |  | ||||||
|  | function RevisionContentDiff({ noteContent, itemContent, itemType }: { noteContent?: string, itemContent: string | Buffer<ArrayBufferLike> | 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 `<span style="background:#d4fcbc">${utils.escapeHtml(part.value)}</span>`; | ||||||
|  |             } else if (part.removed) { | ||||||
|  |                 return `<span style="background:#ffe6e6;text-decoration:line-through;">${utils.escapeHtml(part.value)}</span>`; | ||||||
|  |             } else { | ||||||
|  |                 return utils.escapeHtml(part.value); | ||||||
|  |             } | ||||||
|  |         }).join(""); | ||||||
|  |     } else { | ||||||
|  |         return <>{t("revisions.diff_not_available")}</> | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |     return ( | ||||||
|  |         <div className="ck-content" style={{ whiteSpace: "pre-wrap" }} | ||||||
|  |             dangerouslySetInnerHTML={{ __html: diffHtml }}></div> | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  |  | ||||||
| function RevisionFooter({ note }: { note?: FNote }) { | function RevisionFooter({ note }: { note?: FNote }) { | ||||||
|     if (!note) { |     if (!note) { | ||||||
|         return <></>; |         return <></>; | ||||||
|   | |||||||
| @@ -1,6 +1,7 @@ | |||||||
| import type { EventData } from "../../components/app_context.js"; | import type { EventData } from "../../components/app_context.js"; | ||||||
| import type FNote from "../../entities/fnote.js"; | import type FNote from "../../entities/fnote.js"; | ||||||
| import AbstractCodeTypeWidget from "./abstract_code_type_widget.js"; | import AbstractCodeTypeWidget from "./abstract_code_type_widget.js"; | ||||||
|  | import utils from "../../services/utils.js"; | ||||||
|  |  | ||||||
| const TPL = /*html*/` | const TPL = /*html*/` | ||||||
| <div class="note-detail-readonly-code note-detail-printable"> | <div class="note-detail-readonly-code note-detail-printable"> | ||||||
| @@ -33,7 +34,7 @@ export default class ReadOnlyCodeTypeWidget extends AbstractCodeTypeWidget { | |||||||
|         if (!blob) return; |         if (!blob) return; | ||||||
|  |  | ||||||
|         const isFormattable = note.type === "text" && this.noteContext?.viewScope?.viewMode === "source"; |         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._update(note, content); | ||||||
|         this.show(); |         this.show(); | ||||||
| @@ -54,52 +55,4 @@ export default class ReadOnlyCodeTypeWidget extends AbstractCodeTypeWidget { | |||||||
|  |  | ||||||
|         resolve(this.$editor); |         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("<pre>((.|\\t|\\n|\\r)+)?</pre>"), 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("</") < 0) { |  | ||||||
|                         //open tag |  | ||||||
|                         if (x.charAt(x.length - 1) !== ">") 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("<pre>", "<pre>\n").replace("</pre>", pre[i].indent + "</pre>")); |  | ||||||
|         } |  | ||||||
|  |  | ||||||
|         return html.charAt(0) === "\n" ? html.substr(1, html.length - 1) : html; |  | ||||||
|     } |  | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user