diff --git a/apps/client/package.json b/apps/client/package.json index 1b819e6141..ae411787d3 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -35,7 +35,13 @@ "@triliumnext/highlightjs": "workspace:*", "@triliumnext/share-theme": "workspace:*", "@triliumnext/split.js": "workspace:*", + "@univerjs/preset-sheets-conditional-formatting": "0.16.1", "@univerjs/preset-sheets-core": "0.16.1", + "@univerjs/preset-sheets-data-validation": "0.16.1", + "@univerjs/preset-sheets-filter": "0.16.1", + "@univerjs/preset-sheets-find-replace": "0.16.1", + "@univerjs/preset-sheets-note": "0.16.1", + "@univerjs/preset-sheets-sort": "0.16.1", "@univerjs/presets": "0.16.1", "@zumer/snapdom": "2.0.2", "autocomplete.js": "0.38.1", diff --git a/apps/client/src/services/content_renderer.ts b/apps/client/src/services/content_renderer.ts index afd53c53f9..ec29f094b5 100644 --- a/apps/client/src/services/content_renderer.ts +++ b/apps/client/src/services/content_renderer.ts @@ -54,7 +54,7 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo await renderText(entity, $renderedContent, options); } else if (type === "code") { await renderCode(entity, $renderedContent); - } else if (["image", "canvas", "mindMap"].includes(type)) { + } else if (["image", "canvas", "mindMap", "spreadsheet"].includes(type)) { renderImage(entity, $renderedContent, options); } else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) { await renderFile(entity, type, $renderedContent); diff --git a/apps/client/src/services/server.ts b/apps/client/src/services/server.ts index fb1e598ec2..627e28622a 100644 --- a/apps/client/src/services/server.ts +++ b/apps/client/src/services/server.ts @@ -89,7 +89,7 @@ async function remove(url: string, componentId?: string) { return await call("DELETE", url, componentId); } -async function upload(url: string, fileToUpload: File, componentId?: string) { +async function upload(url: string, fileToUpload: File, componentId?: string, method = "PUT") { const formData = new FormData(); formData.append("upload", fileToUpload); @@ -99,7 +99,7 @@ async function upload(url: string, fileToUpload: File, componentId?: string) { "trilium-component-id": componentId } : undefined), data: formData, - type: "PUT", + type: method, timeout: 60 * 60 * 1000, contentType: false, // NEEDED, DON'T REMOVE THIS processData: false // NEEDED, DON'T REMOVE THIS diff --git a/apps/client/src/widgets/NoteDetail.tsx b/apps/client/src/widgets/NoteDetail.tsx index 206b2a325a..a466af7d77 100644 --- a/apps/client/src/widgets/NoteDetail.tsx +++ b/apps/client/src/widgets/NoteDetail.tsx @@ -40,6 +40,19 @@ export default function NoteDetail() { const widgetRequestId = useRef(0); const hasFixedTree = note && noteContext?.hoistedNoteId === "_lbMobileRoot" && isMobile() && note.noteId.startsWith("_lbMobile"); + // Defer loading for tabs that haven't been active yet (e.g. on app refresh). + const [ hasTabBeenActive, setHasTabBeenActive ] = useState(() => noteContext?.isActive() ?? false); + useEffect(() => { + if (!hasTabBeenActive && noteContext?.isActive()) { + setHasTabBeenActive(true); + } + }, [ noteContext, hasTabBeenActive ]); + useTriliumEvent("activeNoteChanged", ({ ntxId: eventNtxId }) => { + if (eventNtxId === ntxId && !hasTabBeenActive) { + setHasTabBeenActive(true); + } + }); + const props: TypeWidgetProps = { note: note!, viewScope, @@ -49,7 +62,7 @@ export default function NoteDetail() { }; useEffect(() => { - if (!type) return; + if (!type || !hasTabBeenActive) return; const requestId = ++widgetRequestId.current; if (!noteTypesToRender[type]) { @@ -68,7 +81,7 @@ export default function NoteDetail() { } else { setActiveNoteType(type); } - }, [ note, viewScope, type, noteTypesToRender ]); + }, [ note, viewScope, type, noteTypesToRender, hasTabBeenActive ]); // Detect note type changes. useTriliumEvent("entitiesReloaded", async ({ loadResults }) => { @@ -247,9 +260,8 @@ function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: { useEffect(() => { if (isVisible) { setCachedProps(props); - } else { - // Do nothing, keep the old props. } + // When not visible, keep the old props to avoid re-rendering in the background. }, [ props, isVisible ]); const typeMapping = TYPE_MAPPINGS[type]; @@ -260,7 +272,7 @@ function NoteDetailWrapper({ Element, type, isVisible, isFullHeight, props }: { height: isFullHeight ? "100%" : "" }} > - { } + ); } diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx index 41d22864f3..66ce763a3d 100644 --- a/apps/client/src/widgets/dialogs/revisions.tsx +++ b/apps/client/src/widgets/dialogs/revisions.tsx @@ -272,7 +272,8 @@ function RevisionContent({ noteContent, revisionItem, fullRevision, showDiff }: return ; case "canvas": case "mindMap": - case "mermaid": { + case "mermaid": + case "spreadsheet": { const encodedTitle = encodeURIComponent(revisionItem.title); return = { isFullHeight: true }, spreadsheet: { - view: () => import("./type_widgets/Spreadsheet"), + view: () => import("./type_widgets/spreadsheet/Spreadsheet"), className: "note-detail-spreadsheet", printable: true, isFullHeight: true diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 7616b9d9b1..46d83a5614 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -98,6 +98,7 @@ export interface SavedData { mime: string; content: string; position: number; + encoding?: "base64"; }[]; } diff --git a/apps/client/src/widgets/ribbon/NoteActions.tsx b/apps/client/src/widgets/ribbon/NoteActions.tsx index 7810cfe1ae..46a0d9d6b7 100644 --- a/apps/client/src/widgets/ribbon/NoteActions.tsx +++ b/apps/client/src/widgets/ribbon/NoteActions.tsx @@ -75,7 +75,7 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote const noteType = useNoteProperty(note, "type") ?? ""; const [viewType] = useNoteLabel(note, "viewType"); const canBeConvertedToAttachment = note?.isEligibleForConversionToAttachment(); - const isSearchable = ["text", "code", "book", "mindMap", "doc"].includes(noteType); + const isSearchable = ["text", "code", "book", "mindMap", "doc", "spreadsheet"].includes(noteType); const isInOptionsOrHelp = note?.noteId.startsWith("_options") || note?.noteId.startsWith("_help"); const isExportableToImage = ["mermaid", "mindMap"].includes(noteType); const isContentAvailable = note.isContentAvailable(); diff --git a/apps/client/src/widgets/ribbon/NoteActionsCustom.tsx b/apps/client/src/widgets/ribbon/NoteActionsCustom.tsx index 482cd1a693..4c91c1323b 100644 --- a/apps/client/src/widgets/ribbon/NoteActionsCustom.tsx +++ b/apps/client/src/widgets/ribbon/NoteActionsCustom.tsx @@ -189,7 +189,7 @@ function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: N export function ToggleReadOnlyButton({ note, isDefaultViewMode }: NoteActionsCustomInnerProps) { const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly"); const isSavedSqlite = note.isTriliumSqlite() && !note.isHiddenCompletely(); - const isEnabled = ([ "mermaid", "mindMap", "canvas" ].includes(note.type) || isSavedSqlite) + const isEnabled = ([ "mermaid", "mindMap", "canvas", "spreadsheet" ].includes(note.type) || isSavedSqlite) && note.isContentAvailable() && isDefaultViewMode; return isEnabled && [0]; -} - -export default function Spreadsheet({ note, noteContext }: TypeWidgetProps) { - const containerRef = useRef(null); - const apiRef = useRef(); - - useInitializeSpreadsheet(containerRef, apiRef); - useDarkMode(apiRef); - usePersistence(note, noteContext, apiRef); - - return
; -} - -function useInitializeSpreadsheet(containerRef: MutableRef, apiRef: MutableRef) { - useEffect(() => { - if (!containerRef.current) return; - - const { univerAPI } = createUniver({ - locale: LocaleType.EN_US, - locales: { - [LocaleType.EN_US]: mergeLocales( - UniverPresetSheetsCoreEnUS - ), - }, - presets: [ - UniverSheetsCorePreset({ - container: containerRef.current, - }) - ] - }); - apiRef.current = univerAPI; - return () => univerAPI.dispose(); - }, [ apiRef, containerRef ]); -} - -function useDarkMode(apiRef: MutableRef) { - const colorScheme = useColorScheme(); - - // React to dark mode. - useEffect(() => { - const univerAPI = apiRef.current; - if (!univerAPI) return; - univerAPI.toggleDarkMode(colorScheme === 'dark'); - }, [ colorScheme, apiRef ]); -} - -function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: MutableRef) { - const changeListener = useRef(null); - - const spacedUpdate = useEditorSpacedUpdate({ - noteType: "spreadsheet", - note, - noteContext, - getData() { - const univerAPI = apiRef.current; - if (!univerAPI) return undefined; - const workbook = univerAPI.getActiveWorkbook(); - if (!workbook) return undefined; - const content = { - version: 1, - workbook: workbook.save() - }; - return { - content: JSON.stringify(content) - }; - }, - onContentChange(newContent) { - const univerAPI = apiRef.current; - if (!univerAPI) return undefined; - - // Dispose the existing workbook. - const existingWorkbook = univerAPI.getActiveWorkbook(); - if (existingWorkbook) { - univerAPI.disposeUnit(existingWorkbook.getId()); - } - - let workbookData: Partial = {}; - if (newContent) { - try { - const parsedContent = JSON.parse(newContent) as unknown; - if (parsedContent && typeof parsedContent === "object" && "workbook" in parsedContent) { - const persistedData = parsedContent as PersistedData; - workbookData = persistedData.workbook; - } - } catch (e) { - console.error("Failed to parse spreadsheet content", e); - } - } - - const workbook = univerAPI.createWorkbook(workbookData); - if (changeListener.current) { - changeListener.current.dispose(); - } - changeListener.current = workbook.onCommandExecuted(command => { - if (command.type !== CommandType.MUTATION) return; - spacedUpdate.scheduleUpdate(); - }); - }, - }); - - useEffect(() => { - return () => { - if (changeListener.current) { - changeListener.current.dispose(); - changeListener.current = null; - } - }; - }, []); -} diff --git a/apps/client/src/widgets/type_widgets/Spreadsheet.css b/apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.css similarity index 100% rename from apps/client/src/widgets/type_widgets/Spreadsheet.css rename to apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.css diff --git a/apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx b/apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx new file mode 100644 index 0000000000..6b1a4af417 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/spreadsheet/Spreadsheet.tsx @@ -0,0 +1,120 @@ +import "./Spreadsheet.css"; +import "@univerjs/preset-sheets-core/lib/index.css"; +import "@univerjs/preset-sheets-sort/lib/index.css"; +import "@univerjs/preset-sheets-conditional-formatting/lib/index.css"; +import "@univerjs/preset-sheets-find-replace/lib/index.css"; +import "@univerjs/preset-sheets-note/lib/index.css"; +import "@univerjs/preset-sheets-filter/lib/index.css"; +import "@univerjs/preset-sheets-data-validation/lib/index.css"; + +import { UniverSheetsConditionalFormattingPreset } from '@univerjs/preset-sheets-conditional-formatting'; +import UniverPresetSheetsConditionalFormattingEnUS from '@univerjs/preset-sheets-conditional-formatting/locales/en-US'; +import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core'; +import sheetsCoreEnUS from '@univerjs/preset-sheets-core/locales/en-US'; +import { UniverSheetsDataValidationPreset } from '@univerjs/preset-sheets-data-validation'; +import UniverPresetSheetsDataValidationEnUS from '@univerjs/preset-sheets-data-validation/locales/en-US'; +import { UniverSheetsFilterPreset } from '@univerjs/preset-sheets-filter'; +import UniverPresetSheetsFilterEnUS from '@univerjs/preset-sheets-filter/locales/en-US'; +import { UniverSheetsFindReplacePreset } from '@univerjs/preset-sheets-find-replace'; +import sheetsFindReplaceEnUS from '@univerjs/preset-sheets-find-replace/locales/en-US'; +import { UniverSheetsNotePreset } from '@univerjs/preset-sheets-note'; +import sheetsNoteEnUS from '@univerjs/preset-sheets-note/locales/en-US'; +import { UniverSheetsSortPreset } from '@univerjs/preset-sheets-sort'; +import UniverPresetSheetsSortEnUS from '@univerjs/preset-sheets-sort/locales/en-US'; +import { createUniver, FUniver, LocaleType, mergeLocales } from '@univerjs/presets'; +import { MutableRef, useEffect, useRef } from "preact/hooks"; + +import { useColorScheme, useNoteLabelBoolean, useTriliumEvent } from "../../react/hooks"; +import { TypeWidgetProps } from "../type_widget"; +import usePersistence from "./persistence"; + +export default function Spreadsheet(props: TypeWidgetProps) { + const [ readOnly ] = useNoteLabelBoolean(props.note, "readOnly"); + + // Use readOnly as key to force full remount (and data reload) when it changes. + return ; +} + +function SpreadsheetEditor({ note, noteContext, readOnly }: TypeWidgetProps & { readOnly: boolean }) { + const containerRef = useRef(null); + const apiRef = useRef(); + + useInitializeSpreadsheet(containerRef, apiRef, readOnly); + useDarkMode(apiRef); + usePersistence(note, noteContext, apiRef, containerRef, readOnly); + useSearchIntegration(apiRef); + + // Focus the spreadsheet when the note is focused. + useTriliumEvent("focusOnDetail", () => { + const focusable = containerRef.current?.querySelector('[data-u-comp="editor"]'); + if (focusable instanceof HTMLElement) { + focusable.focus(); + } + }); + + return
; +} + +function useInitializeSpreadsheet(containerRef: MutableRef, apiRef: MutableRef, readOnly: boolean) { + useEffect(() => { + if (!containerRef.current) return; + + const { univerAPI } = createUniver({ + locale: LocaleType.EN_US, + locales: { + [LocaleType.EN_US]: mergeLocales( + sheetsCoreEnUS, + sheetsFindReplaceEnUS, + sheetsNoteEnUS, + UniverPresetSheetsFilterEnUS, + UniverPresetSheetsSortEnUS, + UniverPresetSheetsDataValidationEnUS, + UniverPresetSheetsConditionalFormattingEnUS, + ), + }, + presets: [ + UniverSheetsCorePreset({ + container: containerRef.current, + toolbar: !readOnly, + contextMenu: !readOnly, + formulaBar: !readOnly, + footer: readOnly ? false : undefined, + menu: { + "sheet.contextMenu.permission": { hidden: true }, + "sheet-permission.operation.openPanel": { hidden: true }, + "sheet.command.add-range-protection-from-toolbar": { hidden: true }, + }, + }), + UniverSheetsFindReplacePreset(), + UniverSheetsNotePreset(), + UniverSheetsFilterPreset(), + UniverSheetsSortPreset(), + UniverSheetsDataValidationPreset(), + UniverSheetsConditionalFormattingPreset() + ] + }); + apiRef.current = univerAPI; + return () => univerAPI.dispose(); + }, [ apiRef, containerRef, readOnly ]); +} + +function useDarkMode(apiRef: MutableRef) { + const colorScheme = useColorScheme(); + + // React to dark mode. + useEffect(() => { + const univerAPI = apiRef.current; + if (!univerAPI) return; + univerAPI.toggleDarkMode(colorScheme === 'dark'); + }, [ colorScheme, apiRef ]); +} + +function useSearchIntegration(apiRef: MutableRef) { + useTriliumEvent("findInText", () => { + const univerAPI = apiRef.current; + if (!univerAPI) return; + + // Open find/replace panel and populate the search term. + univerAPI.executeCommand("ui.operation.open-find-dialog"); + }); +} diff --git a/apps/client/src/widgets/type_widgets/spreadsheet/persistence.tsx b/apps/client/src/widgets/type_widgets/spreadsheet/persistence.tsx new file mode 100644 index 0000000000..b3717c9ace --- /dev/null +++ b/apps/client/src/widgets/type_widgets/spreadsheet/persistence.tsx @@ -0,0 +1,194 @@ +import { CommandType, FUniver, IDisposable, IWorkbookData } from "@univerjs/presets"; +import { MutableRef, useEffect, useRef } from "preact/hooks"; + +import NoteContext from "../../../components/note_context"; +import FNote from "../../../entities/fnote"; +import { SavedData, useEditorSpacedUpdate } from "../../react/hooks"; + +interface PersistedData { + version: number; + workbook: Parameters[0]; +} + +interface SpreadsheetViewState { + activeSheetId?: string; + cursorRow?: number; + cursorCol?: number; + scrollRow?: number; + scrollCol?: number; +} + +export default function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: MutableRef, containerRef: MutableRef, readOnly: boolean) { + const changeListener = useRef(null); + const pendingContent = useRef(null); + + function saveViewState(univerAPI: FUniver): SpreadsheetViewState { + const state: SpreadsheetViewState = {}; + try { + const workbook = univerAPI.getActiveWorkbook(); + if (!workbook) return state; + + const activeSheet = workbook.getActiveSheet(); + state.activeSheetId = activeSheet?.getSheetId(); + + const currentCell = activeSheet?.getSelection()?.getCurrentCell(); + if (currentCell) { + state.cursorRow = currentCell.actualRow; + state.cursorCol = currentCell.actualColumn; + } + + const scrollState = activeSheet?.getScrollState?.(); + if (scrollState) { + state.scrollRow = scrollState.sheetViewStartRow; + state.scrollCol = scrollState.sheetViewStartColumn; + } + } catch { + // Ignore errors when reading state from a workbook being disposed. + } + return state; + } + + function restoreViewState(workbook: ReturnType, state: SpreadsheetViewState) { + try { + if (state.activeSheetId) { + const targetSheet = workbook.getSheetBySheetId(state.activeSheetId); + if (targetSheet) { + workbook.setActiveSheet(targetSheet); + } + } + if (state.cursorRow !== undefined && state.cursorCol !== undefined) { + workbook.getActiveSheet().getRange(state.cursorRow, state.cursorCol).activate(); + } + if (state.scrollRow !== undefined && state.scrollCol !== undefined) { + workbook.getActiveSheet().scrollToCell(state.scrollRow, state.scrollCol); + } + } catch { + // Ignore errors when restoring state (e.g. sheet no longer exists). + } + } + + function applyContent(univerAPI: FUniver, newContent: string) { + const viewState = saveViewState(univerAPI); + + // Dispose the existing workbook. + const existingWorkbook = univerAPI.getActiveWorkbook(); + if (existingWorkbook) { + univerAPI.disposeUnit(existingWorkbook.getId()); + } + + let workbookData: Partial = {}; + if (newContent) { + try { + const parsedContent = JSON.parse(newContent) as unknown; + if (parsedContent && typeof parsedContent === "object" && "workbook" in parsedContent) { + const persistedData = parsedContent as PersistedData; + workbookData = persistedData.workbook; + } + } catch (e) { + console.error("Failed to parse spreadsheet content", e); + } + } + + const workbook = univerAPI.createWorkbook(workbookData); + if (readOnly) { + workbook.disableSelection(); + const permission = workbook.getPermission(); + permission.setWorkbookEditPermission(workbook.getId(), false); + permission.setPermissionDialogVisible(false); + } + + restoreViewState(workbook, viewState); + + if (changeListener.current) { + changeListener.current.dispose(); + } + changeListener.current = workbook.onCommandExecuted(command => { + if (command.type !== CommandType.MUTATION) return; + spacedUpdate.scheduleUpdate(); + }); + } + + function isContainerVisible() { + const el = containerRef.current; + if (!el) return false; + return el.offsetWidth > 0 && el.offsetHeight > 0; + } + + const spacedUpdate = useEditorSpacedUpdate({ + noteType: "spreadsheet", + note, + noteContext, + async getData() { + const univerAPI = apiRef.current; + if (!univerAPI) return undefined; + const workbook = univerAPI.getActiveWorkbook(); + if (!workbook) return undefined; + const content = { + version: 1, + workbook: workbook.save() + }; + + const attachments: SavedData["attachments"] = []; + const canvasEl = containerRef.current?.querySelector("canvas[id]"); + if (canvasEl) { + const dataUrl = canvasEl.toDataURL("image/png"); + const base64 = dataUrl.split(",")[1]; + attachments.push({ + role: "image", + title: "spreadsheet-export.png", + mime: "image/png", + content: base64, + position: 0, + encoding: "base64" + }); + } + + return { + content: JSON.stringify(content), + attachments + }; + }, + onContentChange(newContent) { + const univerAPI = apiRef.current; + if (!univerAPI) return undefined; + + // Defer content application if the container is hidden (zero size), + // since the spreadsheet library cannot calculate layout in that state. + if (!isContainerVisible()) { + pendingContent.current = newContent; + return; + } + + pendingContent.current = null; + applyContent(univerAPI, newContent); + }, + }); + + // Apply pending content once the container becomes visible (non-zero size). + useEffect(() => { + if (!containerRef.current) return; + + const observer = new ResizeObserver(() => { + if (pendingContent.current === null || !isContainerVisible()) return; + + const univerAPI = apiRef.current; + if (!univerAPI) return; + + const content = pendingContent.current; + pendingContent.current = null; + applyContent(univerAPI, content); + }); + observer.observe(containerRef.current); + return () => observer.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally stable: applyContent/isContainerVisible use refs + }, [ containerRef ]); + + useEffect(() => { + return () => { + if (changeListener.current) { + changeListener.current.dispose(); + changeListener.current = null; + } + }; + }, []); +} diff --git a/apps/server/src/routes/api/image.ts b/apps/server/src/routes/api/image.ts index 60854e6f24..026e069c0d 100644 --- a/apps/server/src/routes/api/image.ts +++ b/apps/server/src/routes/api/image.ts @@ -23,7 +23,7 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) { if (!image) { res.set("Content-Type", "image/png"); return res.send(fs.readFileSync(`${RESOURCE_DIR}/db/image-deleted.png`)); - } else if (!["image", "canvas", "mermaid", "mindMap"].includes(image.type)) { + } else if (!["image", "canvas", "mermaid", "mindMap", "spreadsheet"].includes(image.type)) { return res.sendStatus(400); } @@ -33,6 +33,8 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) { renderSvgAttachment(image, res, "mermaid-export.svg"); } else if (image.type === "mindMap") { renderSvgAttachment(image, res, "mindmap-export.svg"); + } else if (image.type === "spreadsheet") { + renderPngAttachment(image, res, "spreadsheet-export.png"); } else { res.set("Content-Type", image.mime); res.set("Cache-Control", "no-cache, no-store, must-revalidate"); @@ -60,6 +62,18 @@ export function renderSvgAttachment(image: BNote | BRevision, res: Response, att res.send(svg); } +export function renderPngAttachment(image: BNote | BRevision, res: Response, attachmentName: string) { + const attachment = image.getAttachmentByTitle(attachmentName); + + if (attachment) { + res.set("Content-Type", "image/png"); + res.set("Cache-Control", "no-cache, no-store, must-revalidate"); + res.send(attachment.getContent()); + } else { + res.sendStatus(404); + } +} + function returnAttachedImage(req: Request<{ attachmentId: string }>, res: Response) { const attachment = becca.getAttachment(req.params.attachmentId); diff --git a/apps/server/src/services/notes.ts b/apps/server/src/services/notes.ts index f4eb40579a..708cab285d 100644 --- a/apps/server/src/services/notes.ts +++ b/apps/server/src/services/notes.ts @@ -772,16 +772,20 @@ function updateNoteData(noteId: string, content: string, attachments: Attachment if (attachments?.length > 0) { const existingAttachmentsByTitle = toMap(note.getAttachments(), "title"); - for (const { attachmentId, role, mime, title, position, content } of attachments) { + for (const { attachmentId, role, mime, title, position, content, encoding } of attachments) { + const decodedContent = encoding === "base64" && typeof content === "string" + ? Buffer.from(content, "base64") + : content; + const existingAttachment = existingAttachmentsByTitle.get(title); if (attachmentId || !existingAttachment) { - note.saveAttachment({ attachmentId, role, mime, title, content, position }); + note.saveAttachment({ attachmentId, role, mime, title, content: decodedContent, position }); } else { existingAttachment.role = role; existingAttachment.mime = mime; existingAttachment.position = position; - if (content) { - existingAttachment.setContent(content, { forceSave: true }); + if (decodedContent) { + existingAttachment.setContent(decodedContent, { forceSave: true }); } } } diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index e37ac8ab45..035f714b61 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -1,4 +1,5 @@ import { sanitizeUrl } from "@braintree/sanitize-url"; +import { renderSpreadsheetToHtml } from "@triliumnext/commons"; import { highlightAuto } from "@triliumnext/highlightjs"; import ejs from "ejs"; import escapeHtml from "escape-html"; @@ -286,6 +287,8 @@ export function getContent(note: SNote | BNote) { result.isEmpty = true; } else if (note.type === "webView") { renderWebView(note, result); + } else if (note.type === "spreadsheet") { + renderSpreadsheet(result); } else { result.content = `

${t("content_renderer.note-cannot-be-displayed")}

`; } @@ -487,6 +490,14 @@ function renderFile(note: SNote | BNote, result: Result) { } } +function renderSpreadsheet(result: Result) { + if (typeof result.content !== "string" || !result.content?.trim()) { + result.isEmpty = true; + } else { + result.content = renderSpreadsheetToHtml(result.content); + } +} + function renderWebView(note: SNote | BNote, result: Result) { const url = note.getLabelValue("webViewSrc"); if (!url) return; diff --git a/packages/commons/src/index.ts b/packages/commons/src/index.ts index d140c3ee24..b208bfd3b6 100644 --- a/packages/commons/src/index.ts +++ b/packages/commons/src/index.ts @@ -15,3 +15,4 @@ export * from "./lib/dayjs.js"; export * from "./lib/notes.js"; export * from "./lib/week_utils.js"; export { default as BUILTIN_ATTRIBUTES } from "./lib/builtin_attributes.js"; +export * from "./lib/spreadsheet/render_to_html.js"; diff --git a/packages/commons/src/lib/rows.ts b/packages/commons/src/lib/rows.ts index e35d10e05f..2b06e5a6e1 100644 --- a/packages/commons/src/lib/rows.ts +++ b/packages/commons/src/lib/rows.ts @@ -17,6 +17,8 @@ export interface AttachmentRow { deleteId?: string; contentLength?: number; content?: Buffer | string; + /** If set to `"base64"`, the `content` string will be decoded from base64 to binary before storage. */ + encoding?: "base64"; } export interface RevisionRow { diff --git a/packages/commons/src/lib/spreadsheet/render_to_html.spec.ts b/packages/commons/src/lib/spreadsheet/render_to_html.spec.ts new file mode 100644 index 0000000000..dd3f10e26a --- /dev/null +++ b/packages/commons/src/lib/spreadsheet/render_to_html.spec.ts @@ -0,0 +1,421 @@ +import { describe, expect, it } from "vitest"; +import { renderSpreadsheetToHtml } from "./render_to_html.js"; + +describe("renderSpreadsheetToHtml", () => { + it("renders a basic spreadsheet with values and styles", () => { + const input = JSON.stringify({ + version: 1, + workbook: { + id: "test", + sheetOrder: ["sheet1"], + name: "", + appVersion: "0.16.1", + locale: "zhCN", + styles: { + boldStyle: { bl: 1 } + }, + sheets: { + sheet1: { + id: "sheet1", + name: "Sheet1", + hidden: 0, + rowCount: 1000, + columnCount: 20, + defaultColumnWidth: 88, + defaultRowHeight: 24, + mergeData: [], + cellData: { + "1": { + "1": { v: "lol", t: 1 } + }, + "3": { + "0": { v: "wut", t: 1 }, + "2": { s: "boldStyle", v: "Bold string", t: 1 } + } + }, + rowData: {}, + columnData: {}, + showGridlines: 1 + } + } + } + }); + + const html = renderSpreadsheetToHtml(input); + + // Should contain a table. + expect(html).toContain(""); + + // Should contain cell values. + expect(html).toContain("lol"); + expect(html).toContain("wut"); + expect(html).toContain("Bold string"); + + // Bold cell should have font-weight:bold. + expect(html).toContain("font-weight:bold"); + + // Should not render sheet header for single sheet. + expect(html).not.toContain("

"); + }); + + it("renders multiple visible sheets with headers", () => { + const input = JSON.stringify({ + version: 1, + workbook: { + sheetOrder: ["s1", "s2"], + styles: {}, + sheets: { + s1: { + id: "s1", + name: "Data", + hidden: 0, + rowCount: 10, + columnCount: 5, + mergeData: [], + cellData: { "0": { "0": { v: "A1" } } }, + rowData: {}, + columnData: {} + }, + s2: { + id: "s2", + name: "Summary", + hidden: 0, + rowCount: 10, + columnCount: 5, + mergeData: [], + cellData: { "0": { "0": { v: "B1" } } }, + rowData: {}, + columnData: {} + } + } + } + }); + + const html = renderSpreadsheetToHtml(input); + expect(html).toContain("

Data

"); + expect(html).toContain("

Summary

"); + expect(html).toContain("A1"); + expect(html).toContain("B1"); + }); + + it("skips hidden sheets", () => { + const input = JSON.stringify({ + version: 1, + workbook: { + sheetOrder: ["s1", "s2"], + styles: {}, + sheets: { + s1: { + id: "s1", + name: "Visible", + hidden: 0, + rowCount: 10, + columnCount: 5, + mergeData: [], + cellData: { "0": { "0": { v: "shown" } } }, + rowData: {}, + columnData: {} + }, + s2: { + id: "s2", + name: "Hidden", + hidden: 1, + rowCount: 10, + columnCount: 5, + mergeData: [], + cellData: { "0": { "0": { v: "secret" } } }, + rowData: {}, + columnData: {} + } + } + } + }); + + const html = renderSpreadsheetToHtml(input); + expect(html).toContain("shown"); + expect(html).not.toContain("secret"); + // Single visible sheet, no header. + expect(html).not.toContain("

"); + }); + + it("handles merged cells", () => { + const input = JSON.stringify({ + version: 1, + workbook: { + sheetOrder: ["s1"], + styles: {}, + sheets: { + s1: { + id: "s1", + name: "Sheet1", + hidden: 0, + rowCount: 10, + columnCount: 5, + mergeData: [ + { startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 } + ], + cellData: { + "0": { "0": { v: "merged" } } + }, + rowData: {}, + columnData: {} + } + } + } + }); + + const html = renderSpreadsheetToHtml(input); + expect(html).toContain('rowspan="2"'); + expect(html).toContain('colspan="2"'); + expect(html).toContain("merged"); + }); + + it("escapes HTML in cell values", () => { + const input = JSON.stringify({ + version: 1, + workbook: { + sheetOrder: ["s1"], + styles: {}, + sheets: { + s1: { + id: "s1", + name: "Sheet1", + hidden: 0, + rowCount: 10, + columnCount: 5, + mergeData: [], + cellData: { + "0": { "0": { v: "" } } + }, + rowData: {}, + columnData: {} + } + } + } + }); + + const html = renderSpreadsheetToHtml(input); + expect(html).not.toContain("" + } + } + } + }, + rowData: {}, + columnData: {} + } + } + } + }); + + const html = renderSpreadsheetToHtml(input); + expect(html).not.toContain("