diff --git a/.gitignore b/.gitignore
index 1d82de1ef..4dd7bde62 100644
--- a/.gitignore
+++ b/.gitignore
@@ -51,3 +51,4 @@ upload
# docs
site/
apps/*/coverage
+scripts/translation/.language*.json
\ No newline at end of file
diff --git a/apps/client/src/services/content_renderer.ts b/apps/client/src/services/content_renderer.ts
index aef64291f..7137efae7 100644
--- a/apps/client/src/services/content_renderer.ts
+++ b/apps/client/src/services/content_renderer.ts
@@ -194,7 +194,7 @@ function renderFile(entity: FNote | FAttachment, type: string, $renderedContent:
if (type === "pdf") {
const $pdfPreview = $('');
- $pdfPreview.attr("src", openService.getUrlForDownload(`api/${entityType}/${entityId}/open`));
+ $pdfPreview.attr("src", openService.getUrlForDownload(`pdfjs/web/viewer.html?file=../../api/${entityType}/${entityId}/open`));
$content.append($pdfPreview);
} else if (type === "audio") {
@@ -218,14 +218,14 @@ function renderFile(entity: FNote | FAttachment, type: string, $renderedContent:
// in attachment list
const $downloadButton = $(`
`);
const $openButton = $(`
`);
diff --git a/apps/client/src/translations/cn/translation.json b/apps/client/src/translations/cn/translation.json
index 90f785272..60487553c 100644
--- a/apps/client/src/translations/cn/translation.json
+++ b/apps/client/src/translations/cn/translation.json
@@ -1596,7 +1596,9 @@
"toggle-sidebar": "切换侧边栏",
"dropping-not-allowed": "不允许移动笔记到此处。",
"shared-indicator-tooltip": "此笔记已公开分享",
- "shared-indicator-tooltip-with-url": "此笔记已公开分享至:{{- url}}"
+ "shared-indicator-tooltip-with-url": "此笔记已公开分享至:{{- url}}",
+ "clone-indicator-tooltip": "此笔记有 {{- count}} 个父级: {{- parents}}",
+ "clone-indicator-tooltip-single": "此笔记已克隆(1 个额外的父级:{{- parent}})"
},
"title_bar_buttons": {
"window-on-top": "保持此窗口置顶"
@@ -2194,7 +2196,14 @@
"execute_sql_description": "这是一篇 SQL 笔记。点击即可执行 SQL 查询。",
"shared_copy_to_clipboard": "复制链接到剪贴板",
"shared_open_in_browser": "在浏览器中打开链接",
- "shared_unshare": "取消共享"
+ "shared_unshare": "取消共享",
+ "save_status_saved": "已保存",
+ "save_status_saving": "保存中...",
+ "save_status_unsaved": "未保存",
+ "save_status_error": "保存失败",
+ "save_status_unsaved_tooltip": "还有一些更改尚未保存。它们将稍后自动保存。",
+ "save_status_error_tooltip": "保存笔记时出错。如果可以,请尝试将笔记内容复制到其他位置并重新加载应用程序。",
+ "save_status_saving_tooltip": "更改正在保存。"
},
"status_bar": {
"language_title": "更改内容语言",
diff --git a/apps/client/src/translations/ja/translation.json b/apps/client/src/translations/ja/translation.json
index 9cde96ee8..7fb1bd58b 100644
--- a/apps/client/src/translations/ja/translation.json
+++ b/apps/client/src/translations/ja/translation.json
@@ -2196,7 +2196,14 @@
"execute_sql_description": "このノートは SQL ノートです。クリックすると SQL クエリが実行されます。",
"shared_copy_to_clipboard": "リンクをクリップボードにコピー",
"shared_open_in_browser": "ブラウザでリンクを開く",
- "shared_unshare": "共有を削除"
+ "shared_unshare": "共有を削除",
+ "save_status_saved": "保存されました",
+ "save_status_saving": "保存中...",
+ "save_status_unsaved": "未保存",
+ "save_status_error": "保存に失敗しました",
+ "save_status_saving_tooltip": "変更を保存しています。",
+ "save_status_unsaved_tooltip": "未保存の変更があります。すぐに自動的に保存されます。",
+ "save_status_error_tooltip": "ノートの保存中にエラーが発生しました。可能であれば、ノートの内容を別の場所にコピーして、アプリケーションを再読み込みしてください。"
},
"status_bar": {
"language_title": "コンテンツの言語を変更",
diff --git a/apps/client/src/translations/tw/translation.json b/apps/client/src/translations/tw/translation.json
index e3766db85..c51d511ed 100644
--- a/apps/client/src/translations/tw/translation.json
+++ b/apps/client/src/translations/tw/translation.json
@@ -2200,7 +2200,14 @@
"read_only_temporarily_disabled_description": "此筆記目前可編輯,但通常為唯讀狀態。當您切換至其他筆記時,本筆記將立即恢復為唯讀模式。\n\n點擊此處重新啟用唯讀模式。",
"clipped_note_description": "本筆記原始來源為 {{url}}。\n\n點擊此處前往原網頁。",
"execute_script_description": "此筆記為腳本筆記。點擊以執行腳本。",
- "execute_sql_description": "此筆記為 SQL 筆記。點擊以執行 SQL 查詢。"
+ "execute_sql_description": "此筆記為 SQL 筆記。點擊以執行 SQL 查詢。",
+ "save_status_saved": "已儲存",
+ "save_status_saving": "正在儲存…",
+ "save_status_unsaved": "未儲存",
+ "save_status_error": "儲存失敗",
+ "save_status_saving_tooltip": "正在儲存更動。",
+ "save_status_unsaved_tooltip": "仍有更動尚未儲存。它們將在稍後自動儲存。",
+ "save_status_error_tooltip": "在儲存筆記時發生錯誤。如果可以,請嘗試將筆記內容複製至他處並重新載入應用程式。"
},
"breadcrumb": {
"hoisted_badge": "聚焦",
@@ -2246,6 +2253,6 @@
"pages_one": "共 {{count}} 頁",
"pages_other": "",
"pages_alt": "第 {{pageNumber}} 頁",
- "pages_loading": "載入中…"
+ "pages_loading": "正在載入…"
}
}
diff --git a/apps/client/src/types-pdfjs.d.ts b/apps/client/src/types-pdfjs.d.ts
index 12756778e..3a4b3f16d 100644
--- a/apps/client/src/types-pdfjs.d.ts
+++ b/apps/client/src/types-pdfjs.d.ts
@@ -45,6 +45,10 @@ interface WithContext {
interface PdfDocumentModifiedMessage extends WithContext {
type: "pdfjs-viewer-document-modified";
+}
+
+interface PdfDocumentBlobResultMessage extends WithContext {
+ type: "pdfjs-viewer-blob";
data: Uint8Array;
}
@@ -113,4 +117,5 @@ type PdfMessageEvent = MessageEvent<
| PdfViewerThumbnailMessage
| PdfViewerAttachmentsMessage
| PdfViewerLayersMessage
+ | PdfDocumentBlobResultMessage
>;
diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx
index 493640d25..9d1721a97 100644
--- a/apps/client/src/widgets/collections/calendar/index.tsx
+++ b/apps/client/src/widgets/collections/calendar/index.tsx
@@ -1,27 +1,29 @@
-import { DateSelectArg, EventChangeArg, EventMountArg, EventSourceFuncArg, LocaleInput, PluginDef } from "@fullcalendar/core/index.js";
-import { ViewModeProps } from "../interface";
-import Calendar from "./calendar";
-import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import "./index.css";
-import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks";
-import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
+
import { Calendar as FullCalendar } from "@fullcalendar/core";
-import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils";
-import dialog from "../../../services/dialog";
-import { t } from "../../../services/i18n";
-import { buildEvents, buildEventsForCalendar } from "./event_builder";
-import { changeEvent, newEvent } from "./api";
-import froca from "../../../services/froca";
-import date_notes from "../../../services/date_notes";
-import appContext from "../../../components/app_context";
+import { DateSelectArg, EventChangeArg, EventMountArg, EventSourceFuncArg, LocaleInput, PluginDef } from "@fullcalendar/core/index.js";
import { DateClickArg } from "@fullcalendar/interaction";
-import FNote from "../../../entities/fnote";
-import Button, { ButtonGroup } from "../../react/Button";
-import ActionButton from "../../react/ActionButton";
+import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons";
import { RefObject } from "preact";
-import TouchBar, { TouchBarButton, TouchBarLabel, TouchBarSegmentedControl, TouchBarSpacer } from "../../react/TouchBar";
-import { openCalendarContextMenu } from "./context_menu";
+import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
+
+import appContext from "../../../components/app_context";
+import FNote from "../../../entities/fnote";
+import date_notes from "../../../services/date_notes";
+import dialog from "../../../services/dialog";
+import froca from "../../../services/froca";
+import { t } from "../../../services/i18n";
import { isMobile } from "../../../services/utils";
+import ActionButton from "../../react/ActionButton";
+import Button, { ButtonGroup } from "../../react/Button";
+import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks";
+import TouchBar, { TouchBarButton, TouchBarLabel, TouchBarSegmentedControl, TouchBarSpacer } from "../../react/TouchBar";
+import { ViewModeProps } from "../interface";
+import { changeEvent, newEvent } from "./api";
+import Calendar from "./calendar";
+import { openCalendarContextMenu } from "./context_menu";
+import { buildEvents, buildEventsForCalendar } from "./event_builder";
+import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils";
interface CalendarViewData {
@@ -59,7 +61,7 @@ const CALENDAR_VIEWS = [
previousText: t("calendar.month_previous"),
nextText: t("calendar.month_next")
}
-]
+];
const SUPPORTED_CALENDAR_VIEW_TYPE = CALENDAR_VIEWS.map(v => v.type);
@@ -75,6 +77,7 @@ export const LOCALE_MAPPINGS: Record Promise<{ de
ru: () => import("@fullcalendar/core/locales/ru"),
ja: () => import("@fullcalendar/core/locales/ja"),
pt: () => import("@fullcalendar/core/locales/pt"),
+ pl: () => import("@fullcalendar/core/locales/pl"),
"pt_br": () => import("@fullcalendar/core/locales/pt-br"),
uk: () => import("@fullcalendar/core/locales/uk"),
en: null,
@@ -102,9 +105,9 @@ export default function CalendarView({ note, noteIds }: ViewModeProps {
if (!isCalendarRoot) {
return async () => await buildEvents(noteIds);
- } else {
- return async (e: EventSourceFuncArg) => await buildEventsForCalendar(note, e);
- }
+ }
+ return async (e: EventSourceFuncArg) => await buildEventsForCalendar(note, e);
+
}, [isCalendarRoot, noteIds]);
const plugins = usePlugins(isEditable, isCalendarRoot);
@@ -178,7 +181,7 @@ function CalendarHeader({ calendarRef }: { calendarRef: RefObject
calendarRef.current?.next()} />
- )
+ );
}
function usePlugins(isEditable: boolean, isCalendarRoot: boolean) {
@@ -293,7 +296,7 @@ function useEventDisplayCustomization(parentNote: FNote) {
if (promotedAttributes) {
let promotedAttributesHtml = "";
for (const [name, value] of promotedAttributes) {
- promotedAttributesHtml = promotedAttributesHtml + /*html*/`\
+ promotedAttributesHtml = `${promotedAttributesHtml /*html*/}\
${name}: ${value}
`;
diff --git a/apps/client/src/widgets/collections/legacy/ListOrGridView.css b/apps/client/src/widgets/collections/legacy/ListOrGridView.css
index c6b21c0b1..1bfa389e5 100644
--- a/apps/client/src/widgets/collections/legacy/ListOrGridView.css
+++ b/apps/client/src/widgets/collections/legacy/ListOrGridView.css
@@ -142,4 +142,10 @@
border: 1px solid var(--main-border-color);
background: var(--more-accented-background-color);
}
+
+.note-list.grid-view .note-path {
+ margin-left: 0.5em;
+ vertical-align: middle;
+ opacity: 0.5;
+}
/* #endregion */
diff --git a/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx b/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx
index 8180e6d65..5b0d47d97 100644
--- a/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx
+++ b/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx
@@ -7,7 +7,6 @@ import attribute_renderer from "../../../services/attribute_renderer";
import content_renderer from "../../../services/content_renderer";
import { t } from "../../../services/i18n";
import link from "../../../services/link";
-import tree from "../../../services/tree";
import { useImperativeSearchHighlighlighting, useNoteLabel, useNoteLabelBoolean } from "../../react/hooks";
import Icon from "../../react/Icon";
import NoteLink from "../../react/NoteLink";
@@ -103,16 +102,7 @@ function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expan
}
function GridNoteCard({ note, parentNote, highlightedTokens }: { note: FNote, parentNote: FNote, highlightedTokens: string[] | null | undefined }) {
- const titleRef = useRef(null);
- const [ noteTitle, setNoteTitle ] = useState();
const notePath = getNotePath(parentNote, note);
- const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens);
-
- useEffect(() => {
- tree.getNoteTitle(note.noteId, parentNote.noteId).then(setNoteTitle);
- }, [ note ]);
-
- useEffect(() => highlightSearch(titleRef.current), [ noteTitle, highlightedTokens ]);
return (
- {noteTitle}
+
Promise | Blob | undefined,
+ onContentChange: (newBlob: FBlob) => void,
+ dataSaved?: (savedData: Blob) => void,
+ updateInterval?: number;
+ /** If set to true, then the blob is replaced directly without saving a revision before. */
+ replaceWithoutRevision?: boolean;
+}) {
+ const parentComponent = useContext(ParentComponent);
+ const blob = useNoteBlob(note, parentComponent?.componentId);
+
+ const callback = useMemo(() => {
+ return async () => {
+ const data = await getData();
+
+ // for read only notes
+ if (data === undefined || note.type !== noteType) return;
+
+ protected_session_holder.touchProtectedSessionIfNecessary(note);
+ await server.upload(`notes/${note.noteId}/file?replace=${replaceWithoutRevision ? "1" : "0"}`, new File([ data ], note.title, { type: note.mime }), parentComponent?.componentId);
+ dataSaved?.(data);
+ };
+ }, [ note, getData, dataSaved, noteType, parentComponent, replaceWithoutRevision ]);
+ const stateCallback = useCallback((state) => {
+ noteContext?.setContextData("saveState", {
+ state
+ });
+ }, [ noteContext ]);
+ const spacedUpdate = useSpacedUpdate(callback, updateInterval, stateCallback);
+
+ // React to note/blob changes.
+ useEffect(() => {
+ if (!blob) return;
+ spacedUpdate.allowUpdateWithoutChange(() => onContentChange(blob));
+ }, [ blob ]);
+
+ // React to update interval changes.
+ useEffect(() => {
+ if (!updateInterval) return;
+ spacedUpdate.setUpdateInterval(updateInterval);
+ }, [ updateInterval ]);
+
+ // Save if needed upon switching tabs.
+ useTriliumEvent("beforeNoteSwitch", async ({ noteContext: eventNoteContext }) => {
+ if (eventNoteContext.ntxId !== noteContext?.ntxId) return;
+ await spacedUpdate.updateNowIfNecessary();
+ });
+
+ // Save if needed upon tab closing.
+ useTriliumEvent("beforeNoteContextRemove", async ({ ntxIds }) => {
+ if (!noteContext?.ntxId || !ntxIds.includes(noteContext.ntxId)) return;
+ await spacedUpdate.updateNowIfNecessary();
+ });
+
+ // Save if needed upon window/browser closing.
+ useEffect(() => {
+ const listener = () => spacedUpdate.isAllSavedAndTriggerUpdate();
+ appContext.addBeforeUnloadListener(listener);
+ return () => appContext.removeBeforeUnloadListener(listener);
+ }, []);
+
+ return spacedUpdate;
+}
+
export function useNoteSavedData(noteId: string | undefined) {
return useSyncExternalStore(
(cb) => noteId ? noteSavedDataStore.subscribe(noteId, cb) : () => {},
diff --git a/apps/client/src/widgets/type_widgets/MindMap.tsx b/apps/client/src/widgets/type_widgets/MindMap.tsx
index 3b5631338..8c6b36eb7 100644
--- a/apps/client/src/widgets/type_widgets/MindMap.tsx
+++ b/apps/client/src/widgets/type_widgets/MindMap.tsx
@@ -37,6 +37,7 @@ const LOCALE_MAPPINGS: Record
it: "it",
ja: "ja",
pt: "pt",
+ pl: null,
pt_br: "pt",
ro: "ro",
ru: "ru",
diff --git a/apps/client/src/widgets/type_widgets/canvas/i18n.ts b/apps/client/src/widgets/type_widgets/canvas/i18n.ts
index 47324abfc..ba044032b 100644
--- a/apps/client/src/widgets/type_widgets/canvas/i18n.ts
+++ b/apps/client/src/widgets/type_widgets/canvas/i18n.ts
@@ -13,6 +13,7 @@ export const LANGUAGE_MAPPINGS: Record(null);
const historyConfig = useViewModeConfig(note, "pdfHistory");
+ const spacedUpdate = useBlobEditorSpacedUpdate({
+ note,
+ noteType: "file",
+ noteContext,
+ getData() {
+ if (!iframeRef.current?.contentWindow) return undefined;
+
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error("Timeout while waiting for blob response"));
+ }, 10_000);
+
+ const onMessageReceived = (event: PdfMessageEvent) => {
+ if (event.data.type !== "pdfjs-viewer-blob") return;
+ if (event.data.noteId !== note.noteId || event.data.ntxId !== noteContext.ntxId) return;
+ const blob = new Blob([event.data.data as Uint8Array], { type: note.mime });
+
+ clearTimeout(timeout);
+ window.removeEventListener("message", onMessageReceived);
+ resolve(blob);
+ };
+
+ window.addEventListener("message", onMessageReceived);
+ iframeRef.current?.contentWindow?.postMessage({
+ type: "trilium-request-blob",
+ }, window.location.origin);
+ });
+ },
+ onContentChange() {
+ if (iframeRef.current?.contentWindow) {
+ iframeRef.current.contentWindow.location.reload();
+ }
+ },
+ replaceWithoutRevision: true
+ });
+
useEffect(() => {
function handleMessage(event: PdfMessageEvent) {
if (event.data?.type === "pdfjs-viewer-document-modified") {
- const blob = new Blob([event.data.data as Uint8Array], { type: note.mime });
if (event.data.noteId === note.noteId && event.data.ntxId === noteContext.ntxId) {
- server.upload(`notes/${note.noteId}/file`, new File([blob], note.title, { type: note.mime }), componentId);
+ spacedUpdate.resetUpdateTimer();
+ spacedUpdate.scheduleUpdate();
}
}
@@ -138,13 +173,6 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: {
};
}, [ note, historyConfig, componentId, blob, noteContext ]);
- // Refresh when blob changes.
- useEffect(() => {
- if (iframeRef.current?.contentWindow) {
- iframeRef.current.contentWindow.location.reload();
- }
- }, [ blob ]);
-
useTriliumEvent("customDownload", ({ ntxId }) => {
if (ntxId !== noteContext.ntxId) return;
iframeRef.current?.contentWindow?.postMessage({
@@ -171,6 +199,7 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: {
});
}
}}
+ editable
/>
);
}
diff --git a/apps/client/src/widgets/type_widgets/file/PdfViewer.tsx b/apps/client/src/widgets/type_widgets/file/PdfViewer.tsx
index 82bc4a2b9..7e6870dd4 100644
--- a/apps/client/src/widgets/type_widgets/file/PdfViewer.tsx
+++ b/apps/client/src/widgets/type_widgets/file/PdfViewer.tsx
@@ -15,12 +15,16 @@ interface PdfViewerProps extends Pick, "tabInd
/** Note: URLs are relative to /pdfjs/web. */
pdfUrl: string;
onLoad?(): void;
+ /**
+ * If set, enables editable mode which includes persistence of user settings, annotations as well as specific features such as sending table of contents data for the sidebar.
+ */
+ editable?: boolean;
}
/**
* Reusable component displaying a PDF. The PDF needs to be provided via a URL.
*/
-export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad }: PdfViewerProps) {
+export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad, editable }: PdfViewerProps) {
const iframeRef = useSyncedRef(externalIframeRef, null);
const [ locale ] = useTriliumOption("locale");
const [ newLayout ] = useTriliumOptionBool("newLayout");
@@ -30,7 +34,7 @@ export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad