Merge remote-tracking branch 'origin/main' into feature/release_bugfixes
; Conflicts: ; apps/client/src/translations/cn/translation.json ; apps/client/src/translations/es/translation.json ; apps/client/src/translations/ga/translation.json ; apps/client/src/translations/it/translation.json ; apps/client/src/translations/tw/translation.json
This commit is contained in:
@@ -21,7 +21,7 @@ runs:
|
||||
# Certificate setup
|
||||
- name: Import Apple certificates
|
||||
if: inputs.os == 'macos'
|
||||
uses: apple-actions/import-codesign-certs@v6
|
||||
uses: apple-actions/import-codesign-certs@v7
|
||||
with:
|
||||
p12-file-base64: ${{ env.APPLE_APP_CERTIFICATE_BASE64 }}
|
||||
p12-password: ${{ env.APPLE_APP_CERTIFICATE_PASSWORD }}
|
||||
@@ -30,7 +30,7 @@ runs:
|
||||
|
||||
- name: Install Installer certificate
|
||||
if: inputs.os == 'macos'
|
||||
uses: apple-actions/import-codesign-certs@v6
|
||||
uses: apple-actions/import-codesign-certs@v7
|
||||
with:
|
||||
p12-file-base64: ${{ env.APPLE_INSTALLER_CERTIFICATE_BASE64 }}
|
||||
p12-password: ${{ env.APPLE_INSTALLER_CERTIFICATE_PASSWORD }}
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
"keywords": [],
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "AGPL-3.0-only",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"packageManager": "pnpm@10.33.1",
|
||||
"devDependencies": {
|
||||
"@redocly/cli": "2.28.1",
|
||||
"@redocly/cli": "2.29.1",
|
||||
"archiver": "7.0.1",
|
||||
"fs-extra": "11.3.4",
|
||||
"js-yaml": "4.1.1",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"languageOptions": {
|
||||
"directionality": {
|
||||
"block": "top-to-bottom",
|
||||
"inline": "left-to-right"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"property-layout-mappings": ["flow-relative", {
|
||||
"ignoreProperties": [
|
||||
"top", "bottom",
|
||||
"width", "height",
|
||||
"min-width", "min-height",
|
||||
"max-width", "max-height",
|
||||
"margin-top", "margin-bottom",
|
||||
"padding-top", "padding-bottom",
|
||||
"border-top", "border-top-width", "border-top-style", "border-top-color",
|
||||
"border-bottom", "border-bottom-width", "border-bottom-style", "border-bottom-color",
|
||||
"scroll-margin-top", "scroll-margin-bottom",
|
||||
"scroll-padding-top", "scroll-padding-bottom",
|
||||
"overflow-x", "overflow-y",
|
||||
"contain-intrinsic-width", "contain-intrinsic-height"
|
||||
]
|
||||
}]
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,11 @@
|
||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=4096 vite build",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest --coverage",
|
||||
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
|
||||
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular",
|
||||
"stylelint": "stylelint --allow-empty-input \"src/**/*.css\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "0.18.0",
|
||||
"@excalidraw/excalidraw": "0.18.1",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"@fullcalendar/interaction": "6.1.20",
|
||||
@@ -49,11 +50,11 @@
|
||||
"clsx": "2.1.1",
|
||||
"color": "5.0.3",
|
||||
"debounce": "3.0.0",
|
||||
"dompurify": "3.4.0",
|
||||
"dompurify": "3.4.1",
|
||||
"draggabilly": "3.0.0",
|
||||
"force-graph": "1.51.4",
|
||||
"htmldiff-js": "1.0.5",
|
||||
"i18next": "26.0.5",
|
||||
"i18next": "26.0.6",
|
||||
"i18next-http-backend": "3.0.5",
|
||||
"jquery": "4.0.0",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
@@ -64,7 +65,7 @@
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "18.0.2",
|
||||
"mermaid": "11.14.0",
|
||||
"mind-elixir": "5.10.0",
|
||||
"mind-elixir": "5.11.0",
|
||||
"panzoom": "9.4.4",
|
||||
"preact": "10.29.1",
|
||||
"react-i18next": "17.0.4",
|
||||
@@ -83,11 +84,12 @@
|
||||
"@types/leaflet": "1.9.21",
|
||||
"@types/leaflet-gpx": "1.3.8",
|
||||
"@types/mark.js": "8.11.12",
|
||||
"@types/tabulator-tables": "6.3.1",
|
||||
"@types/tabulator-tables": "6.3.2",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"happy-dom": "20.9.0",
|
||||
"lightningcss": "1.32.0",
|
||||
"script-loader": "0.7.2",
|
||||
"stylelint": "17.8.0",
|
||||
"vite-plugin-static-copy": "4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
{
|
||||
"about": {
|
||||
"data_directory": "数据目录:",
|
||||
"version_label": "版本:",
|
||||
"version": "{{appVersion}}(数据库:{{dbVersion}},同步协议:{{syncVersion}})",
|
||||
"build_info": "创建: {{buildDate}}, 修订: <buildRevision />",
|
||||
"contributors_label": "贡献者:"
|
||||
"about": {
|
||||
"data_directory": "数据目录:",
|
||||
"version_label": "版本:",
|
||||
"version": "{{appVersion}}(数据库:{{dbVersion}},同步协议:{{syncVersion}})",
|
||||
"build_info": "创建: {{buildDate}}, 修订: <buildRevision />",
|
||||
"contributors_label": "贡献者:",
|
||||
"contributor_roles": {
|
||||
"lead-dev": "首席开发者",
|
||||
"original-dev": "原始开发者"
|
||||
},
|
||||
"role_brief_history": {
|
||||
"lead-dev": "Elian Doran 于 2024 年创立了 TriliumNext,这是在 Zadam 从该项目退出后创建的一个社区分支。Zadam 后来将原始仓库转移给 TriliumNext 团队,将两个项目合并回一个。",
|
||||
"original-dev": "2017年12月25日,Zadam发布了Trilium的第一个测试版(写作时只用一个“l”,不同于那种花)。由于对现有的笔记整理工具不满意,他构建了一个强大的自托管层次化知识库,该项目在GitHub上获得了超过22,000颗星。到了2024年,随着生活变得更加忙碌,他将该项目置于维护模式。"
|
||||
},
|
||||
"contributor_full_list": "查看整个社区",
|
||||
"github_tooltip": "在 GitHub 上报告错误、提出功能建议或贡献代码",
|
||||
"license_tooltip": "查看许可证",
|
||||
"donate": "捐赠",
|
||||
"donate_tooltip": "捐款支持这个项目"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "严重错误",
|
||||
"message": "发生了严重错误,导致客户端应用程序无法启动:\n\n{{message}}\n\n这很可能是由于脚本以意外的方式失败引起的。请尝试以安全模式启动应用程序并解决问题。"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
@@ -2364,4 +2382,591 @@
|
||||
"auto_link_attribute_list": {
|
||||
"title": "系统属性"
|
||||
}
|
||||
},
|
||||
"electron_context_menu": {
|
||||
"add-term-to-dictionary": "将 \"{{term}}\" 添加到字典",
|
||||
"cut": "剪切",
|
||||
"copy": "复制",
|
||||
"copy-as-markdown": "复制为 Markdown",
|
||||
"copy-link": "复制链接",
|
||||
"paste": "粘贴",
|
||||
"paste-as-plain-text": "以纯文本粘贴",
|
||||
"search_online": "用 {{searchEngine}} 搜索 \"{{term}}\"",
|
||||
"search_in_trilium": "在 Trilium 中搜索「{{term}}」"
|
||||
},
|
||||
"image_context_menu": {
|
||||
"copy_reference_to_clipboard": "复制引用到剪贴板",
|
||||
"copy_image_to_clipboard": "复制图片到剪贴板"
|
||||
},
|
||||
"link_context_menu": {
|
||||
"open_note_in_new_tab": "在新标签页中打开笔记",
|
||||
"open_note_in_new_split": "在新分屏中打开笔记",
|
||||
"open_note_in_new_window": "在新窗口中打开笔记",
|
||||
"open_note_in_popup": "快速编辑",
|
||||
"open_note_in_other_split": "在另一个分屏中打开笔记"
|
||||
},
|
||||
"electron_integration": {
|
||||
"desktop-application": "桌面应用程序",
|
||||
"native-title-bar": "原生标题栏",
|
||||
"native-title-bar-description": "对于 Windows 和 macOS,关闭原生标题栏可使应用程序看起来更紧凑。在 Linux 上,保留原生标题栏可以更好地与系统集成。",
|
||||
"background-effects": "启用背景效果",
|
||||
"background-effects-description": "为应用窗口添加模糊且时尚的背景,营造出深度感和现代外观。「原生标题栏」必須被禁用。",
|
||||
"restart-app-button": "重启应用程序以查看更改",
|
||||
"zoom-factor": "缩放系数"
|
||||
},
|
||||
"note_autocomplete": {
|
||||
"search-for": "搜索 \"{{term}}\"",
|
||||
"create-note": "创建并链接子笔记 \"{{term}}\"",
|
||||
"insert-external-link": "插入指向 \"{{term}}\" 的外部链接",
|
||||
"clear-text-field": "清除文本字段",
|
||||
"show-recent-notes": "显示最近的笔记",
|
||||
"full-text-search": "全文搜索"
|
||||
},
|
||||
"note_tooltip": {
|
||||
"note-has-been-deleted": "笔记已被删除。",
|
||||
"quick-edit": "快速编辑"
|
||||
},
|
||||
"geo-map": {
|
||||
"create-child-note-title": "创建一个新的子笔记并将其添加到地图中",
|
||||
"create-child-note-instruction": "单击地图以在该位置创建新笔记,或按 Escape 以取消。",
|
||||
"unable-to-load-map": "无法加载地图。",
|
||||
"create-child-note-text": "添加标记"
|
||||
},
|
||||
"geo-map-context": {
|
||||
"open-location": "打开位置",
|
||||
"remove-from-map": "从地图中移除",
|
||||
"add-note": "在这个位置添加一个标记"
|
||||
},
|
||||
"help-button": {
|
||||
"title": "打开相关帮助页面"
|
||||
},
|
||||
"duration": {
|
||||
"seconds": "秒",
|
||||
"minutes": "分钟",
|
||||
"hours": "小时",
|
||||
"days": "天"
|
||||
},
|
||||
"share": {
|
||||
"title": "共享设置",
|
||||
"redirect_bare_domain": "将裸域重定向到共享页面",
|
||||
"redirect_bare_domain_description": "将匿名用户重定向到共享页面,而不是显示登录",
|
||||
"show_login_link": "在共享主题中显示登录链接",
|
||||
"show_login_link_description": "在共享页面底部添加登录链接",
|
||||
"check_share_root": "检查共享根状态",
|
||||
"share_root_found": "共享根笔记 '{{noteTitle}}' 已准备好",
|
||||
"share_root_not_found": "未找到带有 #shareRoot 标签的笔记",
|
||||
"share_root_not_shared": "笔记 '{{noteTitle}}' 具有 #shareRoot 标签,但未共享"
|
||||
},
|
||||
"time_selector": {
|
||||
"invalid_input": "输入的时间值不是有效数字。",
|
||||
"minimum_input": "输入的时间值需要至少 {{minimumSeconds}} 秒。"
|
||||
},
|
||||
"tasks": {
|
||||
"due": {
|
||||
"today": "今天",
|
||||
"tomorrow": "明天",
|
||||
"yesterday": "昨天"
|
||||
}
|
||||
},
|
||||
"code-editor-options": {
|
||||
"title": "编辑器",
|
||||
"tab_width": "制表符宽度",
|
||||
"tab_width_unit": "空格"
|
||||
},
|
||||
"custom_date_time_format": {
|
||||
"title": "自定义日期/时间格式",
|
||||
"description": "自定义通过 <shortcut /> 或工具栏插入的日期和时间格式。有关日期/时间格式字符串中各个字符的含义,请参阅<doc>Day.js docs</doc>。",
|
||||
"format_string": "日期/时间格式字符串:",
|
||||
"formatted_time": "格式化后日期/时间:"
|
||||
},
|
||||
"content_widget": {
|
||||
"unknown_widget": "未知组件:\"{{id}}\"."
|
||||
},
|
||||
"note_language": {
|
||||
"not_set": "未设置语言",
|
||||
"configure-languages": "设置语言...",
|
||||
"help-on-languages": "内容语言帮助..."
|
||||
},
|
||||
"content_language": {
|
||||
"title": "内容语言",
|
||||
"description": "在只读或可编辑文本笔记的“基本属性”部分,选择一种或多种语言,这些语言将显示在语言选择列表中。这将启用拼写检查、从右到左的阅读支持和文本提取(OCR)等功能。"
|
||||
},
|
||||
"switch_layout_button": {
|
||||
"title_vertical": "将编辑面板移至底部",
|
||||
"title_horizontal": "将编辑面板移至左侧"
|
||||
},
|
||||
"toggle_read_only_button": {
|
||||
"unlock-editing": "解锁编辑",
|
||||
"lock-editing": "锁定编辑"
|
||||
},
|
||||
"png_export_button": {
|
||||
"button_title": "将图表导出为PNG"
|
||||
},
|
||||
"svg": {
|
||||
"export_to_png": "无法将图表导出为PNG。",
|
||||
"export_to_svg": "此图像无法导出为SVG。"
|
||||
},
|
||||
"code_theme": {
|
||||
"title": "外观",
|
||||
"word_wrapping": "自动换行",
|
||||
"color-scheme": "配色方案"
|
||||
},
|
||||
"cpu_arch_warning": {
|
||||
"title": "请下载ARM64版本",
|
||||
"message_macos": "TriliumNext当前正在通过Rosetta 2转译运行,这意味着您在Apple Silicon芯片的Mac上使用的是Intel(x64)版本。这将显著影响性能和电池续航。",
|
||||
"message_windows": "TriliumNext当前正在模拟环境中运行,这意味着您在ARM架构的Windows设备上使用的是Intel(x64)版本。这将显著影响性能和电池续航。",
|
||||
"recommendation": "为获得最佳体验,请从我们的发布页面下载TriliumNext的原生ARM64版本。",
|
||||
"download_link": "下载原生版本",
|
||||
"continue_anyway": "仍然继续",
|
||||
"dont_show_again": "不再显示此警告"
|
||||
},
|
||||
"editorfeatures": {
|
||||
"title": "功能",
|
||||
"emoji_completion_enabled": "启用表情自动补全",
|
||||
"note_completion_enabled": "启用笔记自动补全",
|
||||
"emoji_completion_description": "如果启用,表情可以轻易地经由输入 `:` 加上表情名称来插入。",
|
||||
"note_completion_description": "如果启用,导向笔记的链接可以经由输入 `@` 加上笔记标题来创建。",
|
||||
"slash_commands_enabled": "启用斜杠命令",
|
||||
"slash_commands_description": "如果启用,可以经由输入 `/` 来触发命令,如插入换行符或标题。"
|
||||
},
|
||||
"table_view": {
|
||||
"new-row": "新增行",
|
||||
"new-column": "新增列",
|
||||
"sort-column-by": "按\"{{title}}\"排序",
|
||||
"sort-column-ascending": "升序",
|
||||
"sort-column-descending": "降序",
|
||||
"sort-column-clear": "清除排序",
|
||||
"hide-column": "隐藏\"{{title}}\"列",
|
||||
"show-hide-columns": "显示/隐藏列",
|
||||
"row-insert-above": "在上方插入行",
|
||||
"row-insert-below": "在下方插入行",
|
||||
"row-insert-child": "插入子笔记",
|
||||
"add-column-to-the-left": "在左侧添加列",
|
||||
"add-column-to-the-right": "在右侧添加列",
|
||||
"edit-column": "编辑列",
|
||||
"delete_column_confirmation": "确定要删除此列吗?所有笔记中对应的属性都将被移除。",
|
||||
"delete-column": "删除列",
|
||||
"new-column-label": "标签",
|
||||
"new-column-relation": "关联"
|
||||
},
|
||||
"book_properties_config": {
|
||||
"hide-weekends": "隐藏周末",
|
||||
"display-week-numbers": "显示周数",
|
||||
"map-style": "地图样式",
|
||||
"max-nesting-depth": "最大嵌套深度:",
|
||||
"raster": "栅格",
|
||||
"vector_light": "矢量(浅色)",
|
||||
"vector_dark": "矢量(深色)",
|
||||
"show-scale": "显示比例尺",
|
||||
"show-labels": "显示标记名称"
|
||||
},
|
||||
"table_context_menu": {
|
||||
"delete_row": "删除行"
|
||||
},
|
||||
"board_view": {
|
||||
"delete-note": "删除笔记...",
|
||||
"move-to": "移动到",
|
||||
"insert-above": "在上方插入",
|
||||
"insert-below": "在下方插入",
|
||||
"delete-column": "删除列",
|
||||
"delete-column-confirmation": "确定要删除此列吗?此列下所有笔记中对应的属性也将被删除。",
|
||||
"new-item": "新增项目",
|
||||
"add-column": "添加列",
|
||||
"archive-note": "存档笔记",
|
||||
"unarchive-note": "解压笔记",
|
||||
"new-item-placeholder": "输入笔记标题...",
|
||||
"add-column-placeholder": "请输入列名...",
|
||||
"edit-note-title": "点击编辑笔记标题",
|
||||
"edit-column-title": "点击编辑列标题",
|
||||
"remove-from-board": "从看板上移除",
|
||||
"column-already-exists": "此列已在看板上。"
|
||||
},
|
||||
"command_palette": {
|
||||
"tree-action-name": "树形:{{name}}",
|
||||
"export_note_title": "导出笔记",
|
||||
"export_note_description": "导出当前笔记",
|
||||
"show_attachments_title": "显示附件",
|
||||
"show_attachments_description": "查看笔记附件",
|
||||
"search_notes_title": "搜索笔记",
|
||||
"search_notes_description": "打开高级搜索",
|
||||
"search_subtree_title": "在子树中搜索",
|
||||
"search_subtree_description": "在当前子树范围内搜索",
|
||||
"search_history_title": "显示搜索历史",
|
||||
"search_history_description": "查看之前的搜索记录",
|
||||
"configure_launch_bar_title": "配置启动栏",
|
||||
"configure_launch_bar_description": "打开启动栏配置,添加或移除项目。"
|
||||
},
|
||||
"content_renderer": {
|
||||
"open_externally": "用外部程序打开"
|
||||
},
|
||||
"modal": {
|
||||
"close": "关闭",
|
||||
"help_title": "显示关于此画面的更多信息"
|
||||
},
|
||||
"call_to_action": {
|
||||
"background_effects_title": "背景效果现已推出稳定版本",
|
||||
"background_effects_message": "在 Windows 和 macOS 设备上,背景效果现在已稳定。背景效果通过模糊背后的背景,为使用者界面增添一抹色彩。",
|
||||
"background_effects_button": "启用背景效果",
|
||||
"next_theme_title": "试用新 Trilium 主题",
|
||||
"next_theme_message": "当前使用旧版主题,要试用新主题吗?",
|
||||
"next_theme_button": "试用新主题",
|
||||
"dismiss": "关闭",
|
||||
"new_layout_message": "我们为 Trilium 引入了现代化的布局。Ribbon 界面已被移除并无缝集成到主界面中,新的状态栏和可展开部分(例如“已提升属性”)取代了其主要功能。\n\n新布局默认启用,您可以通过“选项”→“外观”暂时禁用它。",
|
||||
"new_layout_button": "更多信息",
|
||||
"new_layout_title": "新布局"
|
||||
},
|
||||
"settings": {
|
||||
"related_settings": "相关设置"
|
||||
},
|
||||
"settings_appearance": {
|
||||
"related_code_blocks": "文本笔记中代码块的色彩方案",
|
||||
"related_code_notes": "代码笔记的色彩方案",
|
||||
"ui": "用户界面",
|
||||
"ui_old_layout": "旧布局",
|
||||
"ui_new_layout": "新布局"
|
||||
},
|
||||
"units": {
|
||||
"percentage": "%"
|
||||
},
|
||||
"ui-performance": {
|
||||
"title": "性能",
|
||||
"enable-motion": "过渡和动画",
|
||||
"enable-shadows": "阴影",
|
||||
"enable-backdrop-effects": "菜单、弹窗和面板的背景效果",
|
||||
"enable-smooth-scroll": "平滑滚动",
|
||||
"app-restart-required": "需要重启应用"
|
||||
},
|
||||
"pagination": {
|
||||
"total_notes": "{{count}} 篇笔记",
|
||||
"prev_page": "上一页",
|
||||
"next_page": "下一页"
|
||||
},
|
||||
"collections": {
|
||||
"rendering_error": "出现错误无法显示内容。"
|
||||
},
|
||||
"presentation_view": {
|
||||
"edit-slide": "编辑此幻灯片",
|
||||
"start-presentation": "开始演示",
|
||||
"slide-overview": "切换幻灯片概览"
|
||||
},
|
||||
"calendar_view": {
|
||||
"delete_note": "删除笔记..."
|
||||
},
|
||||
"read-only-info": {
|
||||
"read-only-note": "当前正在查看一个只读笔记。",
|
||||
"auto-read-only-note": "这条笔记以只读模式显示便于快速加载。",
|
||||
"edit-note": "编辑笔记"
|
||||
},
|
||||
"note-color": {
|
||||
"clear-color": "清除笔记颜色",
|
||||
"set-color": "设置笔记颜色",
|
||||
"set-custom-color": "设置自定义笔记颜色"
|
||||
},
|
||||
"popup-editor": {
|
||||
"maximize": "切换至完整编辑器"
|
||||
},
|
||||
"server": {
|
||||
"unknown_http_error_title": "与服务器通讯错误",
|
||||
"unknown_http_error_content": "状态码: {{statusCode}}\n地址: {{method}} {{url}}\n信息: {{message}}",
|
||||
"traefik_blocks_requests": "如果您使用 Traefik 反向代理,它引入了一项影响与服务器的通信重大更改。"
|
||||
},
|
||||
"experimental_features": {
|
||||
"title": "实验选项",
|
||||
"disclaimer": "这些选项处于实验阶段,可能导致系统不稳定。请谨慎使用。",
|
||||
"new_layout_name": "新布局",
|
||||
"new_layout_description": "尝试全新布局,呈现更现代的外观并提升易用性。后续版本将进行重大调整。",
|
||||
"llm_name": "AI/大语言模型对话",
|
||||
"llm_description": "启用由大语言模型驱动的 AI对话侧边栏和大语言模型对话笔记。"
|
||||
},
|
||||
"tab_history_navigation_buttons": {
|
||||
"go-back": "返回前一笔记",
|
||||
"go-forward": "前往下一笔记"
|
||||
},
|
||||
"breadcrumb_badges": {
|
||||
"read_only_explicit": "只读",
|
||||
"read_only_auto": "自动只读",
|
||||
"shared_publicly": "公开共享",
|
||||
"shared_locally": "本地共享",
|
||||
"read_only_explicit_description": "此笔记已被手动设置为只读。\n点击可临时编辑。",
|
||||
"read_only_auto_description": "出于性能原因,此笔记已被自动设置为只读模式。此自动限制可以在设置中调整。\n\n点击可临时编辑。",
|
||||
"read_only_temporarily_disabled": "临时编辑",
|
||||
"read_only_temporarily_disabled_description": "此笔记当前可编辑,但通常是只读的。一旦你切换到其他笔记,该笔记将恢复为只读模式。\n\n点击以重新启用只读模式。",
|
||||
"clipped_note": "网页剪辑",
|
||||
"clipped_note_description": "此笔记最初来自 {{url}}。\n\n点击即可跳转至源网页。",
|
||||
"execute_script": "运行脚本",
|
||||
"execute_script_description": "这是一篇脚本笔记。点击即可执行脚本。",
|
||||
"execute_sql": "运行SQL",
|
||||
"execute_sql_description": "这是一篇 SQL 笔记。点击即可执行 SQL 查询。",
|
||||
"shared_copy_to_clipboard": "复制链接到剪贴板",
|
||||
"shared_open_in_browser": "在浏览器中打开链接",
|
||||
"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": "更改内容语言",
|
||||
"note_info_title": "查看笔记信息(例如日期,笔记大小)",
|
||||
"backlinks_title_other": "查看反链",
|
||||
"attachments_title_other": "在新标签页中查看附件",
|
||||
"attributes_other": "{{count}} 个属性",
|
||||
"attributes_title": "拥有的属性和继承的属性",
|
||||
"note_paths_title": "笔记路径",
|
||||
"code_note_switcher": "更改语言模式",
|
||||
"backlinks_other": "{{count}} 个反链",
|
||||
"attachments_other": "{{count}} 个附件",
|
||||
"note_paths_other": "{{count}} 条路径"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"workspace_badge": "工作空间",
|
||||
"scroll_to_top_title": "跳转到笔记开始",
|
||||
"hoisted_badge_title": "取消聚焦",
|
||||
"hoisted_badge": "聚焦",
|
||||
"create_new_note": "新建子笔记",
|
||||
"empty_hide_archived_notes": "隐藏已存档的笔记"
|
||||
},
|
||||
"right_pane": {
|
||||
"empty_button": "隐藏面板",
|
||||
"toggle": "切换右侧面板",
|
||||
"custom_widget_go_to_source": "跳转到源码",
|
||||
"empty_message": "这篇笔记没有展示内容"
|
||||
},
|
||||
"attributes_panel": {
|
||||
"title": "笔记属性"
|
||||
},
|
||||
"pdf": {
|
||||
"attachments_other": "{{count}} 个附件",
|
||||
"pages_other": "共{{count}}页",
|
||||
"pages_alt": "第{{pageNumber}}页",
|
||||
"pages_loading": "加载中...",
|
||||
"layers_other": "{{count}} 层"
|
||||
},
|
||||
"platform_indicator": {
|
||||
"available_on": "在 {{platform}} 上可用"
|
||||
},
|
||||
"mobile_tab_switcher": {
|
||||
"title_other": "{{count}} 选项卡",
|
||||
"more_options": "更多选项"
|
||||
},
|
||||
"bookmark_buttons": {
|
||||
"bookmarks": "书签"
|
||||
},
|
||||
"web_view_setup": {
|
||||
"title": "直接在 Trilium 中创建网页的实时视图",
|
||||
"url_placeholder": "输入或粘贴网站地址,例如 https://triliumnotes.org",
|
||||
"create_button": "创建网页视图",
|
||||
"invalid_url_title": "无效的地址",
|
||||
"invalid_url_message": "请输入有效的网址,例如 https://triliumnotes.org。",
|
||||
"disabled_description": "此网页视图来自外部来源。为保护您免受网络钓鱼或恶意内容侵害,该视图不会自动加载。若您信任该来源,可手动启用加载功能。",
|
||||
"disabled_button_enable": "启用网页视图"
|
||||
},
|
||||
"render": {
|
||||
"setup_title": "在此笔记中显示自定义 HTML 或 Preact JSX",
|
||||
"setup_create_sample_preact": "使用 Preact 建立范例笔记",
|
||||
"setup_create_sample_html": "使用 HTML 建立范例笔记",
|
||||
"setup_sample_created": "已建立一个范例笔记作为子笔记。",
|
||||
"disabled_description": "此渲染笔记来自外部来源。为保护您免受恶意内容侵害,该功能默认处于禁用状态。启用前请确保您信任该来源。",
|
||||
"disabled_button_enable": "启用渲染笔记"
|
||||
},
|
||||
"active_content_badges": {
|
||||
"type_icon_pack": "图标包",
|
||||
"type_backend_script": "后端脚本",
|
||||
"type_frontend_script": "前端脚本",
|
||||
"type_widget": "小部件",
|
||||
"type_app_css": "自定义 CSS",
|
||||
"type_render_note": "渲染笔记",
|
||||
"type_web_view": "网页视图",
|
||||
"type_app_theme": "自定义主题",
|
||||
"toggle_tooltip_enable_tooltip": "点击以启用此 {{type}}。",
|
||||
"toggle_tooltip_disable_tooltip": "点击以禁用此 {{type}}。",
|
||||
"menu_docs": "打开文档",
|
||||
"menu_execute_now": "立即执行脚本",
|
||||
"menu_run": "自动执行",
|
||||
"menu_run_disabled": "手动",
|
||||
"menu_run_backend_startup": "当后端启动时",
|
||||
"menu_run_hourly": "每小时",
|
||||
"menu_run_daily": "每日",
|
||||
"menu_run_frontend_startup": "当桌面前端启动时",
|
||||
"menu_run_mobile_startup": "当移动前端启动时",
|
||||
"menu_change_to_widget": "更改为小部件",
|
||||
"menu_change_to_frontend_script": "更改为前端脚本",
|
||||
"menu_theme_base": "主题基底"
|
||||
},
|
||||
"setup_form": {
|
||||
"more_info": "了解更多"
|
||||
},
|
||||
"media": {
|
||||
"play": "播放(空格)",
|
||||
"pause": "暂停(空格)",
|
||||
"back-10s": "后退10秒(左箭头键)",
|
||||
"forward-30s": "前进30秒",
|
||||
"mute": "静音(M)",
|
||||
"unmute": "取消静音(M)",
|
||||
"playback-speed": "播放速度",
|
||||
"loop": "循环播放",
|
||||
"disable-loop": "禁用循环播放",
|
||||
"rotate": "旋转",
|
||||
"picture-in-picture": "画中画",
|
||||
"exit-picture-in-picture": "退出画中画",
|
||||
"fullscreen": "全屏(F)",
|
||||
"exit-fullscreen": "退出全屏",
|
||||
"unsupported-format": "此文件格式不支持媒体预览:\n{{mime}}",
|
||||
"zoom-to-fit": "缩放以填充",
|
||||
"zoom-reset": "重置缩放以填充"
|
||||
},
|
||||
"mermaid": {
|
||||
"sample_diagrams": "示例图:",
|
||||
"sample_flowchart": "流程图",
|
||||
"sample_class": "类图",
|
||||
"sample_sequence": "时序图",
|
||||
"sample_entity_relationship": "实体关系图",
|
||||
"sample_state": "状态图",
|
||||
"sample_mindmap": "思维导图",
|
||||
"sample_architecture": "架构图",
|
||||
"sample_block": "模块图",
|
||||
"sample_c4": "C4 图",
|
||||
"sample_gantt": "甘特图",
|
||||
"sample_git": "Git 流程图",
|
||||
"sample_kanban": "看板图",
|
||||
"sample_packet": "数据包图",
|
||||
"sample_pie": "饼图",
|
||||
"sample_quadrant": "象限图",
|
||||
"sample_radar": "雷达图",
|
||||
"sample_requirement": "需求图",
|
||||
"sample_sankey": "桑基图",
|
||||
"sample_timeline": "时间轴图",
|
||||
"sample_treemap": "树形图",
|
||||
"sample_user_journey": "用户旅程图",
|
||||
"sample_xy": "散点图",
|
||||
"sample_venn": "韦恩图",
|
||||
"sample_ishikawa": "鱼骨图",
|
||||
"placeholder": "输入你的美人鱼图的内容,或者使用下面的示例图之一。",
|
||||
"sample_treeview": "树形视图",
|
||||
"sample_wardley": "沃德利地图"
|
||||
},
|
||||
"llm_chat": {
|
||||
"placeholder": "输入消息…",
|
||||
"send": "发送",
|
||||
"sending": "正在发送...",
|
||||
"empty_state": "在下方输入消息,即可开始对话。",
|
||||
"searching_web": "在网上搜索…",
|
||||
"web_search": "联网搜索",
|
||||
"sources": "来源",
|
||||
"extended_thinking": "延伸思考",
|
||||
"legacy_models": "传统模型",
|
||||
"thinking": "正在思考...",
|
||||
"thought_process": "思考过程",
|
||||
"tool_calls": "{{count}} 次工具调用",
|
||||
"input": "输入",
|
||||
"result": "结果",
|
||||
"error": "错误",
|
||||
"tool_error": "失败",
|
||||
"total_tokens": "{{total}} 个词元",
|
||||
"tokens_detail": "{{prompt}} 提示词 + {{completion}} 补全",
|
||||
"tokens_used": "{{prompt}} 提示词 + {{completion}} 补全 = {{total}} 个词元",
|
||||
"tokens_used_with_cost": "{{prompt}} 提示词 + {{completion}} 补全 = {{total}} 个词元(约 ${{cost}})",
|
||||
"tokens_used_with_model": "{{model}}: {{prompt}} 提示词 + {{completion}} 补全 = {{total}} 个词元",
|
||||
"tokens_used_with_model_and_cost": "{{model}}: {{prompt}} 提示词 + {{completion}} 补全 = {{total}} 个词元(约 ${{cost}})",
|
||||
"tokens": "词元",
|
||||
"context_used": "{{percentage}}% 使用率",
|
||||
"note_context_enabled": "点击即可禁用笔记上下文:{{title}}",
|
||||
"note_context_disabled": "点击即可将当前注释添加到上下文中",
|
||||
"no_provider_message": "未配置人工智能提供商。添加一个即可开始对话。",
|
||||
"add_provider": "添加人工智能提供商",
|
||||
"note_tools": "笔记访问",
|
||||
"sources_summary": "来自 {{sites}} 个网站的 {{count}} 个来源"
|
||||
},
|
||||
"sidebar_chat": {
|
||||
"title": "AI对话",
|
||||
"launcher_title": "打开AI对话",
|
||||
"new_chat": "开始新对话",
|
||||
"save_chat": "将对话保存到笔记",
|
||||
"empty_state": "开始对话",
|
||||
"history": "对话历史",
|
||||
"recent_chats": "最近对话",
|
||||
"no_chats": "无历史对话"
|
||||
},
|
||||
"ocr": {
|
||||
"extracted_text": "提取文本(OCR)",
|
||||
"extracted_text_title": "提取文本(OCR)",
|
||||
"loading_text": "正在加载OCR文本...",
|
||||
"no_text_available": "暂无OCR文本",
|
||||
"no_text_explanation": "该笔记未进行 OCR 文本提取处理,或未找到文本。",
|
||||
"failed_to_load": "OCR文本加载失败",
|
||||
"process_now": "处理 OCR",
|
||||
"processing": "正在处理...",
|
||||
"processing_started": "OCR识别已开始。请稍候片刻并刷新页面。",
|
||||
"processing_failed": "OCR处理启动失败",
|
||||
"view_extracted_text": "查看提取的文本(OCR)",
|
||||
"processing_complete": "OCR识别处理完成。",
|
||||
"text_filtered_low_confidence": "OCR 检测到文本,置信度为 {{confidence}}% ,但由于您的最小阈值为 {{threshold}}% ,因此该文本已被丢弃。",
|
||||
"open_media_settings": "打开设置"
|
||||
},
|
||||
"mind-map": {
|
||||
"addChild": "添加子节点",
|
||||
"addParent": "添加父节点",
|
||||
"addSibling": "添加同级节点",
|
||||
"removeNode": "删除节点",
|
||||
"focus": "专注模式",
|
||||
"cancelFocus": "退出专注模式",
|
||||
"moveUp": "上移",
|
||||
"moveDown": "下移",
|
||||
"link": "链接",
|
||||
"linkBidirectional": "双向链接",
|
||||
"clickTips": "请点击目标节点",
|
||||
"summary": "总结"
|
||||
},
|
||||
"llm": {
|
||||
"settings_description": "配置人工智能和大语言模型集成。",
|
||||
"add_provider": "添加提供商",
|
||||
"settings_title": "AI / LLM",
|
||||
"feature_not_enabled": "在“设置”→“高级”→“实验性功能”中启用 LLM 实验性功能,即可使用 AI 集成。",
|
||||
"add_provider_title": "添加AI供应商",
|
||||
"configured_providers": "已配置的供应商",
|
||||
"no_providers_configured": "尚未配置任何供应商。",
|
||||
"provider_name": "名称",
|
||||
"provider_type": "供应商"
|
||||
},
|
||||
"revisions": {
|
||||
"note_revisions": "笔记修订",
|
||||
"delete_all_revisions": "删除此笔记的所有修订版本",
|
||||
"delete_all_button": "删除所有修订版本",
|
||||
"help_title": "关于笔记修订的帮助",
|
||||
"confirm_delete_all": "是否要删除此笔记的所有修订版本?",
|
||||
"no_revisions": "这篇笔记目前还没有修改……",
|
||||
"restore_button": "恢复",
|
||||
"diff_on": "显示差异",
|
||||
"diff_off": "显示内容",
|
||||
"diff_on_hint": "点击显示笔记来源差异",
|
||||
"diff_off_hint": "点击显示笔记内容",
|
||||
"diff_not_available": "差异数据不可用。",
|
||||
"confirm_restore": "是否恢复此版本?这将用此版本覆盖笔记的当前标题和内容。",
|
||||
"delete_button": "删除",
|
||||
"confirm_delete": "您要删除此修订吗?",
|
||||
"revisions_deleted": "笔记修订已被删除。",
|
||||
"revision_restored": "笔记修订已恢复。",
|
||||
"revision_deleted": "笔记修订已删除。",
|
||||
"snapshot_interval": "笔记修订快照间隔:{{seconds}}秒。",
|
||||
"maximum_revisions": "笔记修订快照限制:{{number}}。",
|
||||
"settings": "笔记修订设置",
|
||||
"download_button": "下载",
|
||||
"mime": "MIME 类型: ",
|
||||
"file_size": "文件大小:",
|
||||
"preview_not_available": "无法预览此类型的笔记。",
|
||||
"highlight_changes": "高亮变更",
|
||||
"save_revision_now": "立即保存修订",
|
||||
"save_named_revision": "保存已命名的修订...",
|
||||
"snapshot_header": "笔记修订快照",
|
||||
"snapshot_interval_value": "间隔:{{seconds}}秒"
|
||||
},
|
||||
"database": {
|
||||
"title": "数据库"
|
||||
},
|
||||
"auto_link_attribute_list": {
|
||||
"title": "系统属性"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,34 +1,59 @@
|
||||
{
|
||||
"global_menu": {
|
||||
"about": "Maidir le Trilium Notes",
|
||||
"menu": "Roghchlár",
|
||||
"options": "Roghanna",
|
||||
"open_new_window": "Oscail Fuinneog Nua",
|
||||
"switch_to_mobile_version": "Athraigh go Leagan Soghluaiste",
|
||||
"switch_to_desktop_version": "Athraigh go Leagan Deisce",
|
||||
"zoom": "Zúmáil",
|
||||
"toggle_fullscreen": "Lánscáileán a Athsholáthar",
|
||||
"zoom_out": "Zúmáil Amach",
|
||||
"reset_zoom_level": "Athshocraigh Leibhéal Súmála",
|
||||
"zoom_in": "Zúmáil Isteach",
|
||||
"configure_launchbar": "Cumraigh an Barra Seoladh",
|
||||
"show_shared_notes_subtree": "Taispeáin Fo-chrann Nótaí Comhroinnte",
|
||||
"advanced": "Ardleibhéil",
|
||||
"open_dev_tools": "Oscail Uirlisí Forbróra",
|
||||
"open_sql_console": "Oscail Consól SQL",
|
||||
"open_sql_console_history": "Oscail Stair Chonsól SQL",
|
||||
"open_search_history": "Oscail Stair Chuardaigh",
|
||||
"show_backend_log": "Taispeáin Logáil an Chúil",
|
||||
"reload_hint": "Is féidir le hathlódáil cabhrú le roinnt fabhtanna amhairc gan an aip ar fad a atosú.",
|
||||
"reload_frontend": "Athlódáil an Tosaigh",
|
||||
"show_hidden_subtree": "Taispeáin Fo-chrann Folaithe",
|
||||
"show_help": "Taispeáin Cabhair",
|
||||
"logout": "Logáil Amach",
|
||||
"show-cheatsheet": "Taispeáin Bileog leideanna",
|
||||
"toggle-zen-mode": "Mód Zen",
|
||||
"new-version-available": "Nuashonrú Nua ar Fáil",
|
||||
"download-update": "Faigh Leagan {{latestVersion}}",
|
||||
"search_notes": "Cuardaigh nótaí"
|
||||
"global_menu": {
|
||||
"about": "Maidir le Trilium Notes",
|
||||
"menu": "Roghchlár",
|
||||
"options": "Roghanna",
|
||||
"open_new_window": "Oscail Fuinneog Nua",
|
||||
"switch_to_mobile_version": "Athraigh go Leagan Soghluaiste",
|
||||
"switch_to_desktop_version": "Athraigh go Leagan Deisce",
|
||||
"zoom": "Zúmáil",
|
||||
"toggle_fullscreen": "Lánscáileán a Athsholáthar",
|
||||
"zoom_out": "Zúmáil Amach",
|
||||
"reset_zoom_level": "Athshocraigh Leibhéal Súmála",
|
||||
"zoom_in": "Zúmáil Isteach",
|
||||
"configure_launchbar": "Cumraigh an Barra Seoladh",
|
||||
"show_shared_notes_subtree": "Taispeáin Fo-chrann Nótaí Comhroinnte",
|
||||
"advanced": "Ardleibhéil",
|
||||
"open_dev_tools": "Oscail Uirlisí Forbróra",
|
||||
"open_sql_console": "Oscail Consól SQL",
|
||||
"open_sql_console_history": "Oscail Stair Chonsól SQL",
|
||||
"open_search_history": "Oscail Stair Chuardaigh",
|
||||
"show_backend_log": "Taispeáin Logáil an Chúil",
|
||||
"reload_hint": "Is féidir le hathlódáil cabhrú le roinnt fabhtanna amhairc gan an aip ar fad a atosú.",
|
||||
"reload_frontend": "Athlódáil an Tosaigh",
|
||||
"show_hidden_subtree": "Taispeáin Fo-chrann Folaithe",
|
||||
"show_help": "Taispeáin Cabhair",
|
||||
"logout": "Logáil Amach",
|
||||
"show-cheatsheet": "Taispeáin Bileog leideanna",
|
||||
"toggle-zen-mode": "Mód Zen",
|
||||
"new-version-available": "Nuashonrú Nua ar Fáil",
|
||||
"download-update": "Faigh Leagan {{latestVersion}}",
|
||||
"search_notes": "Cuardaigh nótaí"
|
||||
},
|
||||
"about": {
|
||||
"data_directory": "Eolaire sonraí:",
|
||||
"version_label": "Leagan:",
|
||||
"version": "{{appVersion}} (bunachar sonraí: {{dbVersion}}, prótacal sioncrónaithe: {{syncVersion}})",
|
||||
"build_info": "Tógáil: {{buildDate}}, athbhreithniú: <buildRevision />",
|
||||
"contributors_label": "Rannpháirtithe:",
|
||||
"contributor_roles": {
|
||||
"lead-dev": "príomhfhorbróir",
|
||||
"original-dev": "forbróir bunaidh"
|
||||
},
|
||||
"role_brief_history": {
|
||||
"lead-dev": "Bhunaigh Elian Doran TriliumNext sa bhliain 2024, forc pobail a cruthaíodh tar éis do Zadam éirí as an tionscadal. Ina dhiaidh sin, aistrigh Zadam an stór bunaidh chuig foireann TriliumNext, ag cumasc an dá thionscadal ar ais in aon tionscadal amháin.",
|
||||
"original-dev": "Ar an 25 Nollaig 2017, d’eisigh Zadam an chéad béite de Trilium (scríofa le \"l\" aonair, murab ionann agus an bláth). Gan a bheith sásta leis na heagraithe nótaí a bhí ann cheana féin, thóg sé bonn eolais ordlathach cumhachtach féin-óstáilte a bhailigh breis is 22,000 réalta GitHub. Sa bhliain 2024, de réir mar a bhí an saol ag éirí níos gnóthaí, chuir sé an tionscadal i mód cothabhála."
|
||||
},
|
||||
"contributor_full_list": "Féach ar an bpobal ar fad",
|
||||
"github_tooltip": "Tuairiscigh fabhtanna, mol gnéithe, nó cuir le chéile ar GitHub",
|
||||
"license_tooltip": "Féach ar an gceadúnas",
|
||||
"donate": "Tabhair Síntiús",
|
||||
"donate_tooltip": "Tabhair síntiús chun tacú leis an tionscadal seo"
|
||||
},
|
||||
"toast": {
|
||||
"critical-error": {
|
||||
"title": "Earráid chriticiúil",
|
||||
"message": "Tharla earráid chriticiúil a chuireann cosc ar an bhfeidhmchlár cliant tosú:\n\n{{message}}\n\nIs dóichí gur teip gan choinne ar script is cúis leis seo. Bain triail as an bhfeidhmchlár a thosú i mód sábháilte agus an fhadhb a réiteach."
|
||||
},
|
||||
"about": {
|
||||
"data_directory": "Eolaire sonraí:"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -190,7 +190,7 @@ export default function ChatMessage({ message, isStreaming }: Props) {
|
||||
{message.usage.cost != null && (
|
||||
<>
|
||||
<span className="llm-chat-usage-separator">·</span>
|
||||
<span className="llm-chat-usage-cost">~${message.usage.cost.toFixed(4)}</span>
|
||||
<span className="llm-chat-usage-cost">~${message.usage.cost.toFixed(2)}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"@types/electron-squirrel-startup": "1.0.2",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "41.2.1",
|
||||
"electron": "41.3.0",
|
||||
"prebuild-install": "7.1.3"
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
"@triliumnext/desktop": "workspace:*",
|
||||
"@types/fs-extra": "11.0.4",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"electron": "41.2.1",
|
||||
"electron": "41.3.0",
|
||||
"fs-extra": "11.3.4"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
data/
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@triliumnext/script-deployer",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Development harness for writing and deploying Trilium scripts to a local instance",
|
||||
"scripts": {
|
||||
"dev": "tsx watch --clear-screen=false --ignore '../client/node_modules/.vite-temp' src/dev.ts",
|
||||
"test": "vitest run",
|
||||
"clean": "rm -rf data"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* @trilium-script
|
||||
*
|
||||
* id: auto-import-rnote
|
||||
* type: backend
|
||||
* title: Auto-import Rnote to Canvas
|
||||
* executeButton: true
|
||||
* executeDescription: Converts all .rnote children of this note into Excalidraw canvas notes.
|
||||
*/
|
||||
|
||||
import zlib from "zlib";
|
||||
|
||||
// ── Types (rnote v0.9+ / v0.13 JSON format) ────────────────────────────────
|
||||
|
||||
interface RnoteColor {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a: number;
|
||||
}
|
||||
|
||||
interface RnoteElement {
|
||||
pos: [number, number];
|
||||
pressure: number;
|
||||
}
|
||||
|
||||
interface RnoteSegment {
|
||||
lineto?: { end: RnoteElement };
|
||||
quadbezto?: { cp: [number, number]; end: RnoteElement };
|
||||
cubbezto?: { cp1: [number, number]; cp2: [number, number]; end: RnoteElement };
|
||||
}
|
||||
|
||||
interface RnotePath {
|
||||
start: RnoteElement;
|
||||
segments: RnoteSegment[];
|
||||
}
|
||||
|
||||
interface RnoteSmoothStyle {
|
||||
stroke_width: number;
|
||||
stroke_color: RnoteColor;
|
||||
fill_color?: RnoteColor;
|
||||
pressure_curve: string;
|
||||
line_style?: string;
|
||||
line_cap?: string;
|
||||
}
|
||||
|
||||
interface RnoteBrushStroke {
|
||||
path: RnotePath;
|
||||
style: { smooth?: RnoteSmoothStyle; rough?: Record<string, unknown>; textured?: Record<string, unknown> };
|
||||
}
|
||||
|
||||
interface RnoteTextStyle {
|
||||
font_family?: string;
|
||||
font_size?: number;
|
||||
color?: RnoteColor;
|
||||
}
|
||||
|
||||
interface RnoteTextStroke {
|
||||
text: string;
|
||||
transform: { affine: number[] | number[][] };
|
||||
// v0.13: style, v0.14: text_style
|
||||
style?: RnoteTextStyle;
|
||||
text_style?: RnoteTextStyle;
|
||||
}
|
||||
|
||||
interface RnoteBitmapImage {
|
||||
// v0.13: image is base64 string, v0.14: image is { data, pixel_width, pixel_height, ... }
|
||||
image: string | { data: string; pixel_width?: number; pixel_height?: number; memory_format?: string };
|
||||
// v0.13: { mins, maxs }, v0.14: { cuboid: { half_extents }, transform: { affine } }
|
||||
rectangle:
|
||||
| { mins: [number, number]; maxs: [number, number] }
|
||||
| { cuboid: { half_extents: [number, number] }; transform: { affine: number[] } };
|
||||
}
|
||||
|
||||
interface RnoteSlotEntry {
|
||||
value: {
|
||||
brushstroke?: RnoteBrushStroke;
|
||||
textstroke?: RnoteTextStroke;
|
||||
bitmapimage?: RnoteBitmapImage;
|
||||
vectorimage?: Record<string, unknown>;
|
||||
shapestroke?: Record<string, unknown>;
|
||||
} | null;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface RnoteFile {
|
||||
version: string;
|
||||
data: {
|
||||
engine_snapshot: {
|
||||
document: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
stroke_components: RnoteSlotEntry[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ── Raw RGBA → PNG encoding ─────────────────────────────────────────────────
|
||||
|
||||
/** Encode raw RGBA pixels into a minimal PNG buffer using zlib. */
|
||||
function encodeRawRgbaToPng(data: Buffer, width: number, height: number): Buffer {
|
||||
// Un-premultiply alpha (R8g8b8a8Premultiplied → straight RGBA)
|
||||
const straight = Buffer.from(data);
|
||||
for (let i = 0; i < straight.length; i += 4) {
|
||||
const a = straight[i + 3];
|
||||
if (a > 0 && a < 255) {
|
||||
straight[i] = Math.min(255, Math.round((straight[i] * 255) / a));
|
||||
straight[i + 1] = Math.min(255, Math.round((straight[i + 1] * 255) / a));
|
||||
straight[i + 2] = Math.min(255, Math.round((straight[i + 2] * 255) / a));
|
||||
}
|
||||
}
|
||||
|
||||
// Build PNG filter rows: each row prefixed with filter byte 0 (None)
|
||||
const rowLen = width * 4;
|
||||
const rawRows = Buffer.alloc(height * (1 + rowLen));
|
||||
for (let y = 0; y < height; y++) {
|
||||
rawRows[y * (1 + rowLen)] = 0; // filter: None
|
||||
straight.copy(rawRows, y * (1 + rowLen) + 1, y * rowLen, y * rowLen + rowLen);
|
||||
}
|
||||
|
||||
const compressed = zlib.deflateSync(rawRows);
|
||||
|
||||
// CRC-32 (used by PNG chunks)
|
||||
const crcTable: number[] = [];
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
crcTable[n] = c;
|
||||
}
|
||||
function crc32(buf: Buffer): number {
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) crc = crcTable[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function makeChunk(type: string, data: Buffer): Buffer {
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length);
|
||||
const typeB = Buffer.from(type, "ascii");
|
||||
const payload = Buffer.concat([typeB, data]);
|
||||
const crcB = Buffer.alloc(4);
|
||||
crcB.writeUInt32BE(crc32(payload));
|
||||
return Buffer.concat([len, payload, crcB]);
|
||||
}
|
||||
|
||||
// IHDR: width, height, bit depth 8, color type 6 (RGBA)
|
||||
const ihdrData = Buffer.alloc(13);
|
||||
ihdrData.writeUInt32BE(width, 0);
|
||||
ihdrData.writeUInt32BE(height, 4);
|
||||
ihdrData[8] = 8; // bit depth
|
||||
ihdrData[9] = 6; // color type: RGBA
|
||||
ihdrData[10] = 0; // compression
|
||||
ihdrData[11] = 0; // filter
|
||||
ihdrData[12] = 0; // interlace
|
||||
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), // PNG signature
|
||||
makeChunk("IHDR", ihdrData),
|
||||
makeChunk("IDAT", compressed),
|
||||
makeChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Color conversion ────────────────────────────────────────────────────────
|
||||
|
||||
function convertColor(c: RnoteColor): string {
|
||||
const r = Math.round(c.r * 255);
|
||||
const g = Math.round(c.g * 255);
|
||||
const b = Math.round(c.b * 255);
|
||||
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function convertOpacity(c: RnoteColor): number {
|
||||
return Math.round(c.a * 100);
|
||||
}
|
||||
|
||||
// ── Excalidraw element helpers ──────────────────────────────────────────────
|
||||
|
||||
function generateId(): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let id = "";
|
||||
for (let i = 0; i < 20; i++) {
|
||||
id += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function makeBaseElement(type: string, x: number, y: number, width: number, height: number) {
|
||||
return {
|
||||
id: generateId(),
|
||||
type,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
angle: 0,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roughness: 0,
|
||||
opacity: 100,
|
||||
groupIds: [] as string[],
|
||||
frameId: null,
|
||||
index: null,
|
||||
roundness: null,
|
||||
seed: Math.floor(Math.random() * 2_000_000_000),
|
||||
version: 1,
|
||||
versionNonce: Math.floor(Math.random() * 2_000_000_000),
|
||||
isDeleted: false,
|
||||
boundElements: null,
|
||||
updated: Date.now(),
|
||||
link: null,
|
||||
locked: false,
|
||||
} as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Bezier flattening ───────────────────────────────────────────────────────
|
||||
|
||||
const BEZIER_STEPS = 8;
|
||||
|
||||
function flattenQuadBezier(
|
||||
p0: [number, number], pr0: number,
|
||||
cp: [number, number],
|
||||
p1: [number, number], pr1: number,
|
||||
): { pos: [number, number]; pressure: number }[] {
|
||||
const result: { pos: [number, number]; pressure: number }[] = [];
|
||||
for (let i = 1; i <= BEZIER_STEPS; i++) {
|
||||
const t = i / BEZIER_STEPS;
|
||||
const u = 1 - t;
|
||||
const x = u * u * p0[0] + 2 * u * t * cp[0] + t * t * p1[0];
|
||||
const y = u * u * p0[1] + 2 * u * t * cp[1] + t * t * p1[1];
|
||||
const p = u * pr0 + t * pr1;
|
||||
result.push({ pos: [x, y], pressure: p });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function flattenCubicBezier(
|
||||
p0: [number, number], pr0: number,
|
||||
cp1: [number, number], cp2: [number, number],
|
||||
p1: [number, number], pr1: number,
|
||||
): { pos: [number, number]; pressure: number }[] {
|
||||
const result: { pos: [number, number]; pressure: number }[] = [];
|
||||
for (let i = 1; i <= BEZIER_STEPS; i++) {
|
||||
const t = i / BEZIER_STEPS;
|
||||
const u = 1 - t;
|
||||
const x = u * u * u * p0[0] + 3 * u * u * t * cp1[0] + 3 * u * t * t * cp2[0] + t * t * t * p1[0];
|
||||
const y = u * u * u * p0[1] + 3 * u * u * t * cp1[1] + 3 * u * t * t * cp2[1] + t * t * t * p1[1];
|
||||
const p = u * pr0 + t * pr1;
|
||||
result.push({ pos: [x, y], pressure: p });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Converters ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface ExcalidrawFile {
|
||||
mimeType: string;
|
||||
id: string;
|
||||
dataURL: string;
|
||||
created: number;
|
||||
lastRetrieved: number;
|
||||
}
|
||||
|
||||
function convertBrushStroke(stroke: RnoteBrushStroke) {
|
||||
const style = stroke.style.smooth;
|
||||
if (!style) return null; // rough/textured styles not yet supported
|
||||
|
||||
// Flatten path into points + pressures
|
||||
const allPoints: { pos: [number, number]; pressure: number }[] = [
|
||||
{ pos: stroke.path.start.pos, pressure: stroke.path.start.pressure },
|
||||
];
|
||||
|
||||
let lastPos = stroke.path.start.pos;
|
||||
let lastPressure = stroke.path.start.pressure;
|
||||
|
||||
for (const seg of stroke.path.segments) {
|
||||
if (seg.lineto) {
|
||||
const end = seg.lineto.end;
|
||||
allPoints.push({ pos: end.pos, pressure: end.pressure });
|
||||
lastPos = end.pos;
|
||||
lastPressure = end.pressure;
|
||||
} else if (seg.quadbezto) {
|
||||
const end = seg.quadbezto.end;
|
||||
const flattened = flattenQuadBezier(lastPos, lastPressure, seg.quadbezto.cp, end.pos, end.pressure);
|
||||
allPoints.push(...flattened);
|
||||
lastPos = end.pos;
|
||||
lastPressure = end.pressure;
|
||||
} else if (seg.cubbezto) {
|
||||
const end = seg.cubbezto.end;
|
||||
const flattened = flattenCubicBezier(lastPos, lastPressure, seg.cubbezto.cp1, seg.cubbezto.cp2, end.pos, end.pressure);
|
||||
allPoints.push(...flattened);
|
||||
lastPos = end.pos;
|
||||
lastPressure = end.pressure;
|
||||
}
|
||||
}
|
||||
|
||||
if (allPoints.length < 2) return null;
|
||||
|
||||
const originX = allPoints[0].pos[0];
|
||||
const originY = allPoints[0].pos[1];
|
||||
|
||||
const points: number[][] = [];
|
||||
const pressures: number[] = [];
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
|
||||
for (const pt of allPoints) {
|
||||
const px = pt.pos[0] - originX;
|
||||
const py = pt.pos[1] - originY;
|
||||
points.push([px, py]);
|
||||
pressures.push(pt.pressure);
|
||||
minX = Math.min(minX, px);
|
||||
minY = Math.min(minY, py);
|
||||
maxX = Math.max(maxX, px);
|
||||
maxY = Math.max(maxY, py);
|
||||
}
|
||||
|
||||
const el = makeBaseElement("freedraw", originX, originY, maxX - minX, maxY - minY);
|
||||
el.points = points;
|
||||
el.pressures = pressures;
|
||||
el.simulatePressure = false;
|
||||
el.strokeColor = convertColor(style.stroke_color);
|
||||
el.strokeWidth = Math.max(1, Math.floor((style.stroke_width || 1) / 2));
|
||||
el.opacity = convertOpacity(style.stroke_color);
|
||||
el.lastCommittedPoint = points[points.length - 1] || null;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function convertTextStroke(text: RnoteTextStroke) {
|
||||
if (!text.text) return null;
|
||||
|
||||
// Extract position from affine transform
|
||||
// v0.13: nested arrays [[1,0,tx],[0,1,ty],[0,0,1]]
|
||||
// v0.14: flat array [1,0,0, 0,1,0, tx,ty,1]
|
||||
const affine = text.transform?.affine;
|
||||
let x = 0, y = 0;
|
||||
if (Array.isArray(affine?.[0])) {
|
||||
// nested: row-major 3x3
|
||||
const nested = affine as number[][];
|
||||
x = nested[0]?.[2] ?? 0;
|
||||
y = nested[1]?.[2] ?? 0;
|
||||
} else if (affine) {
|
||||
// flat 9-element array (column-major: [m00,m10,m20, m01,m11,m21, tx,ty,1])
|
||||
const flat = affine as number[];
|
||||
x = flat[6] ?? 0;
|
||||
y = flat[7] ?? 0;
|
||||
}
|
||||
const ts = text.text_style ?? text.style;
|
||||
const size = ts?.font_size ?? 12;
|
||||
const color = ts?.color ?? { r: 0, g: 0, b: 0, a: 1 };
|
||||
|
||||
const lines = text.text.split("\n");
|
||||
const estWidth = Math.max(...lines.map((l) => l.length)) * size * 0.6;
|
||||
const estHeight = lines.length * size * 1.3;
|
||||
|
||||
const el = makeBaseElement("text", x, y, estWidth, estHeight);
|
||||
el.text = text.text;
|
||||
el.fontSize = size;
|
||||
el.fontFamily = 5;
|
||||
el.textAlign = "left";
|
||||
el.verticalAlign = "top";
|
||||
el.strokeColor = convertColor(color);
|
||||
el.opacity = convertOpacity(color);
|
||||
el.containerId = null;
|
||||
el.originalText = text.text;
|
||||
el.autoResize = true;
|
||||
el.lineHeight = 1.25;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function convertBitmapImage(img: RnoteBitmapImage, files: Record<string, ExcalidrawFile>) {
|
||||
if (!img.image) return null;
|
||||
|
||||
// Extract base64 image data and encode as PNG if needed
|
||||
let dataURL: string;
|
||||
if (typeof img.image === "string") {
|
||||
// v0.13: already PNG base64
|
||||
dataURL = `data:image/png;base64,${img.image}`;
|
||||
} else {
|
||||
// v0.14: raw RGBA pixels, need to encode as PNG
|
||||
const rawBuf = Buffer.from(img.image.data, "base64");
|
||||
const pw = img.image.pixel_width ?? 0;
|
||||
const ph = img.image.pixel_height ?? 0;
|
||||
if (!pw || !ph) return null;
|
||||
const pngBuf = encodeRawRgbaToPng(rawBuf, pw, ph);
|
||||
dataURL = `data:image/png;base64,${pngBuf.toString("base64")}`;
|
||||
}
|
||||
|
||||
// Extract bounds: v0.13 uses mins/maxs, v0.14 uses cuboid + transform
|
||||
let minX: number, minY: number, w: number, h: number;
|
||||
if ("mins" in img.rectangle) {
|
||||
[minX, minY] = img.rectangle.mins;
|
||||
const [maxX, maxY] = img.rectangle.maxs;
|
||||
w = maxX - minX;
|
||||
h = maxY - minY;
|
||||
} else {
|
||||
const [halfW, halfH] = img.rectangle.cuboid.half_extents;
|
||||
const affine = img.rectangle.transform.affine;
|
||||
// flat affine: tx=affine[6], ty=affine[7]
|
||||
const cx = affine[6] ?? 0;
|
||||
const cy = affine[7] ?? 0;
|
||||
minX = cx - halfW;
|
||||
minY = cy - halfH;
|
||||
w = halfW * 2;
|
||||
h = halfH * 2;
|
||||
}
|
||||
|
||||
const fileId = generateId();
|
||||
files[fileId] = {
|
||||
mimeType: "image/png",
|
||||
id: fileId,
|
||||
dataURL,
|
||||
created: Date.now(),
|
||||
lastRetrieved: Date.now(),
|
||||
};
|
||||
|
||||
const el = makeBaseElement("image", minX, minY, w, h);
|
||||
el.fileId = fileId;
|
||||
el.status = "saved";
|
||||
el.scale = [1, 1];
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
// ── Main conversion ─────────────────────────────────────────────────────────
|
||||
|
||||
function convertRnote(doc: RnoteFile) {
|
||||
const snapshot = doc.data?.engine_snapshot;
|
||||
if (!snapshot) throw new Error("Not a valid .rnote file: missing engine_snapshot");
|
||||
|
||||
const elements: Record<string, unknown>[] = [];
|
||||
const files: Record<string, ExcalidrawFile> = {};
|
||||
|
||||
for (const entry of snapshot.stroke_components) {
|
||||
if (!entry.value) continue;
|
||||
|
||||
if (entry.value.brushstroke) {
|
||||
const el = convertBrushStroke(entry.value.brushstroke);
|
||||
if (el) elements.push(el);
|
||||
} else if (entry.value.textstroke) {
|
||||
const el = convertTextStroke(entry.value.textstroke);
|
||||
if (el) elements.push(el);
|
||||
} else if (entry.value.bitmapimage) {
|
||||
const el = convertBitmapImage(entry.value.bitmapimage, files);
|
||||
if (el) elements.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
elements,
|
||||
files,
|
||||
appState: {
|
||||
gridModeEnabled: false,
|
||||
viewBackgroundColor: "#ffffff",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Entry point ─────────────────────────────────────────────────────────────
|
||||
|
||||
const scriptNote = api.currentNote;
|
||||
const childNotes = scriptNote.getChildNotes();
|
||||
|
||||
for (const note of childNotes) {
|
||||
if (note.type !== "file" || !note.title?.endsWith(".rnote")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = note.getContent();
|
||||
const jsonBuffer = zlib.gunzipSync(content);
|
||||
const doc: RnoteFile = JSON.parse(jsonBuffer.toString("utf-8"));
|
||||
|
||||
const excalidrawData = convertRnote(doc);
|
||||
const title = note.title.replace(/\.rnote$/, "");
|
||||
|
||||
const { note: canvasNote } = api.createNewNote({
|
||||
parentNoteId: scriptNote.noteId,
|
||||
title,
|
||||
content: JSON.stringify(excalidrawData),
|
||||
type: "canvas",
|
||||
});
|
||||
|
||||
api.log(`auto-import-rnote: converted "${note.title}" → canvas note ${canvasNote.noteId}`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
api.log(`auto-import-rnote: failed for "${note.title}": ${msg}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* @trilium-script
|
||||
*
|
||||
* id: auto-import-xopp
|
||||
* type: backend
|
||||
* title: Auto-import XOPP to Canvas
|
||||
* executeButton: true
|
||||
* executeDescription: Converts all .xopp children of this note into Excalidraw canvas notes.
|
||||
*/
|
||||
|
||||
import zlib from "zlib";
|
||||
|
||||
// ── Color mapping ────────────────────────────────────────────────────────────
|
||||
|
||||
const XOPP_COLORS: Record<string, string> = {
|
||||
black: "#000000",
|
||||
blue: "#3333cc",
|
||||
red: "#ff0000",
|
||||
green: "#008000",
|
||||
gray: "#808080",
|
||||
lightblue: "#00c0ff",
|
||||
lightgreen: "#00ff00",
|
||||
magenta: "#ff00ff",
|
||||
orange: "#ff8000",
|
||||
yellow: "#ffff00",
|
||||
white: "#ffffff",
|
||||
};
|
||||
|
||||
function convertColor(color: string | null): string {
|
||||
if (!color) return "#000000";
|
||||
const lower = color.toLowerCase();
|
||||
if (XOPP_COLORS[lower]) return XOPP_COLORS[lower];
|
||||
if (lower.startsWith("#")) {
|
||||
return lower.length > 7 ? lower.slice(0, 7) : lower;
|
||||
}
|
||||
return "#000000";
|
||||
}
|
||||
|
||||
function extractOpacity(color: string | null): number {
|
||||
if (!color) return 100;
|
||||
if (color.startsWith("#") && color.length === 9) {
|
||||
const alpha = parseInt(color.slice(7, 9), 16);
|
||||
return Math.round((alpha / 255) * 100);
|
||||
}
|
||||
return 100;
|
||||
}
|
||||
|
||||
// ── Excalidraw element helpers ───────────────────────────────────────────────
|
||||
|
||||
function generateId(): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let id = "";
|
||||
for (let i = 0; i < 20; i++) {
|
||||
id += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function makeBaseElement(type: string, x: number, y: number, width: number, height: number) {
|
||||
return {
|
||||
id: generateId(),
|
||||
type,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
angle: 0,
|
||||
strokeColor: "#000000",
|
||||
backgroundColor: "transparent",
|
||||
fillStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
strokeStyle: "solid",
|
||||
roughness: 0,
|
||||
opacity: 100,
|
||||
groupIds: [] as string[],
|
||||
frameId: null,
|
||||
index: null,
|
||||
roundness: null,
|
||||
seed: Math.floor(Math.random() * 2_000_000_000),
|
||||
version: 1,
|
||||
versionNonce: Math.floor(Math.random() * 2_000_000_000),
|
||||
isDeleted: false,
|
||||
boundElements: null,
|
||||
updated: Date.now(),
|
||||
link: null,
|
||||
locked: false,
|
||||
} as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── xml2js element accessors ─────────────────────────────────────────────────
|
||||
|
||||
/** Get attribute from an xml2js node. */
|
||||
function attr(node: Record<string, unknown>, name: string): string | null {
|
||||
const attrs = node.$ as Record<string, string> | undefined;
|
||||
return attrs?.[name] ?? null;
|
||||
}
|
||||
|
||||
/** Get text content from an xml2js node. */
|
||||
function text(node: Record<string, unknown>): string {
|
||||
return (node._ as string | undefined)?.trim() ?? "";
|
||||
}
|
||||
|
||||
/** Get child elements by tag name, always as array. */
|
||||
function children(node: Record<string, unknown>, tag: string): Record<string, unknown>[] {
|
||||
const val = node[tag];
|
||||
if (!val) return [];
|
||||
return Array.isArray(val) ? val : [val as Record<string, unknown>];
|
||||
}
|
||||
|
||||
// ── Converters ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface ExcalidrawFile {
|
||||
mimeType: string;
|
||||
id: string;
|
||||
dataURL: string;
|
||||
created: number;
|
||||
lastRetrieved: number;
|
||||
}
|
||||
|
||||
function convertStroke(strokeNode: Record<string, unknown>, yOffset: number) {
|
||||
const coordText = text(strokeNode);
|
||||
const coords = coordText.split(/\s+/).map(Number);
|
||||
if (coords.length < 4) return null;
|
||||
|
||||
const originX = coords[0];
|
||||
const originY = coords[1] + yOffset;
|
||||
const points: number[][] = [];
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
|
||||
for (let i = 0; i < coords.length; i += 2) {
|
||||
const px = coords[i] - originX;
|
||||
const py = (coords[i + 1] + yOffset) - originY;
|
||||
points.push([px, py]);
|
||||
minX = Math.min(minX, px);
|
||||
minY = Math.min(minY, py);
|
||||
maxX = Math.max(maxX, px);
|
||||
maxY = Math.max(maxY, py);
|
||||
}
|
||||
|
||||
const widthAttr = attr(strokeNode, "width") || "2";
|
||||
const widths = widthAttr.trim().split(/\s+/).map(Number);
|
||||
const hasPressure = widths.length > 1;
|
||||
|
||||
let pressures: number[] = [];
|
||||
if (hasPressure) {
|
||||
const maxW = Math.max(...widths);
|
||||
pressures = widths.map((w) => maxW > 0 ? w / maxW : 0.5);
|
||||
while (pressures.length < points.length) {
|
||||
pressures.push(pressures[pressures.length - 1] || 0.5);
|
||||
}
|
||||
pressures = pressures.slice(0, points.length);
|
||||
}
|
||||
|
||||
const color = attr(strokeNode, "color") || "black";
|
||||
const tool = attr(strokeNode, "tool") || "pen";
|
||||
const isHighlighter = tool === "highlighter";
|
||||
|
||||
const el = makeBaseElement("freedraw", originX, originY, maxX - minX, maxY - minY);
|
||||
el.points = points;
|
||||
el.pressures = hasPressure ? pressures : [];
|
||||
el.simulatePressure = !hasPressure;
|
||||
el.strokeColor = convertColor(color);
|
||||
el.strokeWidth = Math.floor(widths[0] || 1);
|
||||
el.opacity = isHighlighter ? 40 : extractOpacity(color);
|
||||
el.lastCommittedPoint = points[points.length - 1] || null;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function convertText(textNode: Record<string, unknown>, yOffset: number) {
|
||||
const x = parseFloat(attr(textNode, "x") ?? "0");
|
||||
const y = parseFloat(attr(textNode, "y") ?? "0") + yOffset;
|
||||
const size = parseFloat(attr(textNode, "size") ?? "12");
|
||||
const color = attr(textNode, "color") || "black";
|
||||
const content = text(textNode);
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
const lines = content.split("\n");
|
||||
const estWidth = Math.max(...lines.map((l) => l.length)) * size * 0.6;
|
||||
const estHeight = lines.length * size * 1.3;
|
||||
|
||||
const el = makeBaseElement("text", x, y, estWidth, estHeight);
|
||||
el.text = content;
|
||||
el.fontSize = size;
|
||||
el.fontFamily = 5;
|
||||
el.textAlign = "left";
|
||||
el.verticalAlign = "top";
|
||||
el.strokeColor = convertColor(color);
|
||||
el.opacity = extractOpacity(color);
|
||||
el.containerId = null;
|
||||
el.originalText = content;
|
||||
el.autoResize = true;
|
||||
el.lineHeight = 1.25;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function convertImage(
|
||||
imageNode: Record<string, unknown>,
|
||||
yOffset: number,
|
||||
files: Record<string, ExcalidrawFile>,
|
||||
) {
|
||||
const left = parseFloat(attr(imageNode, "left") ?? "");
|
||||
const top = parseFloat(attr(imageNode, "top") ?? "") + yOffset;
|
||||
const right = parseFloat(attr(imageNode, "right") ?? "");
|
||||
const bottom = parseFloat(attr(imageNode, "bottom") ?? "") + yOffset;
|
||||
|
||||
if (isNaN(left) || isNaN(top) || isNaN(right) || isNaN(bottom)) return null;
|
||||
|
||||
const imgData = text(imageNode);
|
||||
if (!imgData) return null;
|
||||
|
||||
const fileId = generateId();
|
||||
files[fileId] = {
|
||||
mimeType: "image/png",
|
||||
id: fileId,
|
||||
dataURL: `data:image/png;base64,${imgData}`,
|
||||
created: Date.now(),
|
||||
lastRetrieved: Date.now(),
|
||||
};
|
||||
|
||||
const el = makeBaseElement("image", left, top, right - left, bottom - top);
|
||||
el.fileId = fileId;
|
||||
el.status = "saved";
|
||||
el.scale = [1, 1];
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
// ── Main conversion ──────────────────────────────────────────────────────────
|
||||
|
||||
function convertXoppXml(doc: Record<string, unknown>) {
|
||||
const xournal = doc.xournal as Record<string, unknown> | undefined;
|
||||
if (!xournal) throw new Error("Not a valid .xopp file: missing <xournal> root element");
|
||||
|
||||
const pages = children(xournal, "page");
|
||||
const elements: Record<string, unknown>[] = [];
|
||||
const files: Record<string, ExcalidrawFile> = {};
|
||||
let yOffset = 0;
|
||||
const PAGE_GAP = 80;
|
||||
|
||||
for (const page of pages) {
|
||||
const pageHeight = parseFloat(attr(page, "height") ?? "792");
|
||||
const pageWidth = parseFloat(attr(page, "width") ?? "612");
|
||||
|
||||
// Page boundary rectangle
|
||||
const bgRect = makeBaseElement("rectangle", 0, yOffset, pageWidth, pageHeight);
|
||||
bgRect.strokeColor = "#d0d0d0";
|
||||
bgRect.strokeWidth = 1;
|
||||
bgRect.strokeStyle = "dashed";
|
||||
bgRect.backgroundColor = "transparent";
|
||||
bgRect.opacity = 50;
|
||||
elements.push(bgRect);
|
||||
|
||||
const layers = children(page, "layer");
|
||||
for (const layer of layers) {
|
||||
for (const stroke of children(layer, "stroke")) {
|
||||
const el = convertStroke(stroke, yOffset);
|
||||
if (el) elements.push(el);
|
||||
}
|
||||
for (const txt of children(layer, "text")) {
|
||||
const el = convertText(txt, yOffset);
|
||||
if (el) elements.push(el);
|
||||
}
|
||||
for (const img of children(layer, "image")) {
|
||||
const el = convertImage(img, yOffset, files);
|
||||
if (el) elements.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
yOffset += pageHeight + PAGE_GAP;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "excalidraw",
|
||||
version: 2,
|
||||
elements,
|
||||
files,
|
||||
appState: {
|
||||
gridModeEnabled: false,
|
||||
viewBackgroundColor: "#ffffff",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Entry point (manually run) ───────────────────────────────────────────────
|
||||
|
||||
const scriptNote = api.currentNote;
|
||||
const childNotes = scriptNote.getChildNotes();
|
||||
|
||||
for (const note of childNotes) {
|
||||
if (note.type !== "file" || !note.title?.endsWith(".xopp")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = note.getContent();
|
||||
const xmlBuffer = zlib.gunzipSync(content);
|
||||
const xmlString = xmlBuffer.toString("utf-8");
|
||||
|
||||
api.xml2js.parseString(xmlString, (err: Error | null, result: Record<string, unknown>) => {
|
||||
if (err) {
|
||||
api.log(`auto-import-xopp: failed to parse XML for "${note.title}": ${err.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const excalidrawData = convertXoppXml(result);
|
||||
const title = note.title.replace(/\.xopp$/, "");
|
||||
|
||||
const { note: canvasNote } = api.createNewNote({
|
||||
parentNoteId: scriptNote.noteId,
|
||||
title,
|
||||
content: JSON.stringify(excalidrawData),
|
||||
type: "canvas",
|
||||
});
|
||||
|
||||
api.log(`auto-import-xopp: converted "${note.title}" → canvas note ${canvasNote.noteId}`);
|
||||
} catch (convErr) {
|
||||
const msg = convErr instanceof Error ? convErr.message : String(convErr);
|
||||
api.log(`auto-import-xopp: conversion failed for "${note.title}": ${msg}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import becca from "@triliumnext/server/src/becca/becca";
|
||||
import BBranch from "@triliumnext/server/src/becca/entities/bbranch";
|
||||
import { buildNote } from "@triliumnext/server/src/test/becca_easy_mocking";
|
||||
import {
|
||||
parseScriptMeta,
|
||||
transpile,
|
||||
deployScript,
|
||||
codeNoteId,
|
||||
renderNoteId,
|
||||
SCRIPTS_NOTE_ID,
|
||||
type NoteService,
|
||||
} from "./deploy";
|
||||
|
||||
// ── Mocks — only SQL and entity_changes, becca is real ───────────────────────
|
||||
|
||||
vi.mock("@triliumnext/server/src/services/sql", () => ({
|
||||
default: {
|
||||
transactional: (cb: Function) => cb(),
|
||||
upsert: () => {},
|
||||
execute: () => {},
|
||||
replace: () => {},
|
||||
getMap: () => {},
|
||||
getValue: () => null,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@triliumnext/server/src/services/sql_init", () => ({
|
||||
dbReady: () => {},
|
||||
}));
|
||||
|
||||
vi.mock("@triliumnext/server/src/services/entity_changes", () => ({
|
||||
default: { putEntityChange: () => {} },
|
||||
putEntityChange: () => {},
|
||||
}));
|
||||
|
||||
// ── parseScriptMeta ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("parseScriptMeta", () => {
|
||||
it("parses a valid @trilium-script block", () => {
|
||||
const source = `/**
|
||||
* @trilium-script
|
||||
*
|
||||
* id: my-script
|
||||
* type: render
|
||||
* title: My Script
|
||||
*/
|
||||
export default function() {}`;
|
||||
|
||||
const meta = parseScriptMeta(source, "my-script.jsx");
|
||||
expect(meta).toEqual({
|
||||
id: "my-script",
|
||||
type: "render",
|
||||
title: "My Script",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when @trilium-script marker is missing", () => {
|
||||
const source = `/** Just a normal comment */\nexport default function() {}`;
|
||||
expect(parseScriptMeta(source, "test.jsx")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null and logs error when id is missing", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const source = `/**
|
||||
* @trilium-script
|
||||
*
|
||||
* type: render
|
||||
* title: My Script
|
||||
*/`;
|
||||
expect(parseScriptMeta(source, "test.jsx")).toBeNull();
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining("missing required fields: id"));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns null when type is missing", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const source = `/**
|
||||
* @trilium-script
|
||||
*
|
||||
* id: test
|
||||
* title: Test
|
||||
*/`;
|
||||
expect(parseScriptMeta(source, "test.jsx")).toBeNull();
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining("missing required fields: type"));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("returns null when title is missing", () => {
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const source = `/**
|
||||
* @trilium-script
|
||||
*
|
||||
* id: test
|
||||
* type: code
|
||||
*/`;
|
||||
expect(parseScriptMeta(source, "test.jsx")).toBeNull();
|
||||
expect(spy).toHaveBeenCalledWith(expect.stringContaining("missing required fields: title"));
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("preserves extra metadata fields", () => {
|
||||
const source = `/**
|
||||
* @trilium-script
|
||||
*
|
||||
* id: test
|
||||
* type: render
|
||||
* title: Test
|
||||
* icon: bx-rocket
|
||||
*/`;
|
||||
const meta = parseScriptMeta(source, "test.jsx");
|
||||
expect(meta).toMatchObject({ icon: "bx-rocket" });
|
||||
});
|
||||
});
|
||||
|
||||
// ── transpile ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("transpile", () => {
|
||||
it("passes .jsx files through unchanged", () => {
|
||||
const source = `const x: number = 1; return <div>{x}</div>;`;
|
||||
expect(transpile(source, "test.jsx")).toBe(source);
|
||||
});
|
||||
|
||||
it("strips type annotations from .tsx but preserves JSX", () => {
|
||||
const source = `const x: number = 1;\nconst el = <div>{x}</div>;`;
|
||||
const result = transpile(source, "test.tsx");
|
||||
expect(result).not.toContain(": number");
|
||||
expect(result).toContain("<div>");
|
||||
});
|
||||
|
||||
it("strips type annotations from .ts", () => {
|
||||
const source = `const x: number = 1;`;
|
||||
const result = transpile(source, "test.ts");
|
||||
expect(result).not.toContain(": number");
|
||||
expect(result).toContain("const x = 1");
|
||||
});
|
||||
|
||||
it("passes .js files through unchanged", () => {
|
||||
const source = `const x = 1;`;
|
||||
expect(transpile(source, "test.js")).toBe(source);
|
||||
});
|
||||
});
|
||||
|
||||
// ── deployScript ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("deployScript", () => {
|
||||
/**
|
||||
* A NoteService backed by buildNote — each createNewNote call
|
||||
* populates the real becca cache via becca_easy_mocking.
|
||||
*/
|
||||
const notesService: NoteService = {
|
||||
createNewNote(params) {
|
||||
const note = buildNote({
|
||||
id: params.noteId,
|
||||
title: params.title,
|
||||
type: params.type as Parameters<typeof buildNote>[0]["type"],
|
||||
mime: params.mime ?? "text/html",
|
||||
content: params.content,
|
||||
});
|
||||
// buildNote doesn't create a branch to the parent, so add one.
|
||||
new BBranch({
|
||||
branchId: `${params.parentNoteId}_${params.noteId}`,
|
||||
noteId: params.noteId,
|
||||
parentNoteId: params.parentNoteId,
|
||||
});
|
||||
return { note: { noteId: note.noteId } };
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
becca.reset();
|
||||
|
||||
// Root + Scripts folder, using buildNote for the tree structure.
|
||||
buildNote({
|
||||
id: "root",
|
||||
title: "root",
|
||||
children: [
|
||||
{ id: SCRIPTS_NOTE_ID, title: "Scripts", type: "doc" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("render note with JSX", () => {
|
||||
const meta = { id: "xopp-importer", type: "render", title: "XOPP Importer" };
|
||||
const jsxContent = `import { h } from "trilium:preact";\nexport default function() { return <div>Hello</div>; }`;
|
||||
const mime = "text/jsx";
|
||||
|
||||
it("creates a render note as parent with code note child linked via ~renderNote", () => {
|
||||
deployScript(meta, jsxContent, mime, becca, notesService);
|
||||
|
||||
const renderId = renderNoteId(meta.id);
|
||||
const codeId = codeNoteId(meta.id);
|
||||
const renderNote = becca.notes[renderId];
|
||||
const codeNote = becca.notes[codeId];
|
||||
|
||||
// Render note exists with correct type/title
|
||||
expect(renderNote).toBeDefined();
|
||||
expect(renderNote.type).toBe("render");
|
||||
expect(renderNote.title).toBe("XOPP Importer");
|
||||
|
||||
// Code note exists with correct type/title
|
||||
expect(codeNote).toBeDefined();
|
||||
expect(codeNote.type).toBe("code");
|
||||
expect(codeNote.title).toBe("XOPP Importer");
|
||||
|
||||
// Code note is a child of the render note
|
||||
expect(codeNote.getParentBranches().some((b) => b.parentNoteId === renderId)).toBe(true);
|
||||
|
||||
// Render note is under scripts folder
|
||||
expect(renderNote.getParentBranches().some((b) => b.parentNoteId === SCRIPTS_NOTE_ID)).toBe(true);
|
||||
|
||||
// ~renderNote relation points to the code note
|
||||
const relation = renderNote.getRelations().find((a) => a.name === "renderNote");
|
||||
expect(relation).toBeDefined();
|
||||
expect(relation!.value).toBe(codeId);
|
||||
});
|
||||
|
||||
it("returns a 'created' result with both note IDs", () => {
|
||||
const result = deployScript(meta, jsxContent, mime, becca, notesService);
|
||||
expect(result).toEqual({
|
||||
action: "created",
|
||||
codeNoteId: codeNoteId(meta.id),
|
||||
renderNoteId: renderNoteId(meta.id),
|
||||
title: "XOPP Importer",
|
||||
type: "render",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("render note with TSX", () => {
|
||||
const meta = { id: "tsx-widget", type: "render", title: "TSX Widget" };
|
||||
const tsxSource = `const x: number = 1;\nexport default function() { return <div>{x}</div>; }`;
|
||||
const transpiledContent = transpile(tsxSource, "tsx-widget.tsx");
|
||||
const mime = "text/jsx";
|
||||
|
||||
it("creates the same render+code structure as JSX, with types stripped but JSX preserved", () => {
|
||||
// Content was correctly transpiled
|
||||
expect(transpiledContent).not.toContain(": number");
|
||||
expect(transpiledContent).toContain("<div>");
|
||||
|
||||
deployScript(meta, transpiledContent, mime, becca, notesService);
|
||||
|
||||
const renderId = renderNoteId(meta.id);
|
||||
const codeId = codeNoteId(meta.id);
|
||||
|
||||
expect(becca.notes[renderId]).toBeDefined();
|
||||
expect(becca.notes[renderId].type).toBe("render");
|
||||
expect(becca.notes[codeId]).toBeDefined();
|
||||
expect(becca.notes[codeId].type).toBe("code");
|
||||
});
|
||||
});
|
||||
|
||||
describe("updating an existing script", () => {
|
||||
const meta = { id: "existing-script", type: "render", title: "Original" };
|
||||
const mime = "text/jsx";
|
||||
|
||||
it("updates content and title of existing code note", () => {
|
||||
deployScript(meta, "old content", mime, becca, notesService);
|
||||
|
||||
const codeId = codeNoteId(meta.id);
|
||||
const note = becca.notes[codeId];
|
||||
const saveSpy = vi.fn();
|
||||
const setContentSpy = vi.fn();
|
||||
note.save = saveSpy;
|
||||
note.setContent = setContentSpy;
|
||||
|
||||
const result = deployScript({ ...meta, title: "New Title" }, "new content", mime, becca, notesService);
|
||||
expect(result.action).toBe("updated");
|
||||
expect(note.title).toBe("New Title");
|
||||
expect(saveSpy).toHaveBeenCalled();
|
||||
expect(setContentSpy).toHaveBeenCalledWith("new content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plain code note (non-render)", () => {
|
||||
const meta = { id: "backend-helper", type: "code", title: "Backend Helper" };
|
||||
const content = "module.exports = {};";
|
||||
const mime = "application/javascript;env=backend";
|
||||
|
||||
it("creates a single code note under scripts folder, no render note", () => {
|
||||
deployScript(meta, content, mime, becca, notesService);
|
||||
|
||||
const codeId = codeNoteId(meta.id);
|
||||
const codeNote = becca.notes[codeId];
|
||||
|
||||
expect(codeNote).toBeDefined();
|
||||
expect(codeNote.type).toBe("code");
|
||||
expect(codeNote.getParentBranches().some((b) => b.parentNoteId === SCRIPTS_NOTE_ID)).toBe(true);
|
||||
|
||||
// No render note was created
|
||||
expect(becca.notes[renderNoteId(meta.id)]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns a 'created' result without renderNoteId", () => {
|
||||
const result = deployScript(meta, content, mime, becca, notesService);
|
||||
expect(result).toEqual({
|
||||
action: "created",
|
||||
codeNoteId: codeNoteId(meta.id),
|
||||
title: "Backend Helper",
|
||||
type: "code",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("backend script", () => {
|
||||
const meta = { id: "hello-world", type: "backend", run: "backendStartup", title: "Hello World" };
|
||||
const content = `api.log("Hello from script-deployer!");`;
|
||||
const mime = "application/javascript;env=backend";
|
||||
|
||||
it("creates a code note with #run label", () => {
|
||||
deployScript(meta, content, mime, becca, notesService);
|
||||
|
||||
const codeId = codeNoteId(meta.id);
|
||||
const codeNote = becca.notes[codeId];
|
||||
|
||||
expect(codeNote).toBeDefined();
|
||||
expect(codeNote.type).toBe("code");
|
||||
|
||||
const runLabel = codeNote.getLabels().find((a) => a.name === "run");
|
||||
expect(runLabel).toBeDefined();
|
||||
expect(runLabel!.value).toBe("backendStartup");
|
||||
});
|
||||
|
||||
it("places the code note under scripts folder, no render note", () => {
|
||||
deployScript(meta, content, mime, becca, notesService);
|
||||
|
||||
const codeId = codeNoteId(meta.id);
|
||||
const codeNote = becca.notes[codeId];
|
||||
expect(codeNote.getParentBranches().some((b) => b.parentNoteId === SCRIPTS_NOTE_ID)).toBe(true);
|
||||
expect(becca.notes[renderNoteId(meta.id)]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not set #run when run field is absent", () => {
|
||||
const metaNoRun = { id: "no-run", type: "backend", title: "No Run" };
|
||||
deployScript(metaNoRun, content, mime, becca, notesService);
|
||||
|
||||
const codeNote = becca.notes[codeNoteId(metaNoRun.id)];
|
||||
expect(codeNote.getLabels().find((a) => a.name === "run")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("execute labels", () => {
|
||||
const content = `api.log("test");`;
|
||||
const mime = "application/javascript;env=backend";
|
||||
|
||||
it("sets executeButton, executeDescription, and executeTitle on creation", () => {
|
||||
const meta = {
|
||||
id: "exec-test",
|
||||
type: "backend",
|
||||
title: "Exec Test",
|
||||
executeButton: "true",
|
||||
executeDescription: "Does something useful",
|
||||
executeTitle: "Run It",
|
||||
};
|
||||
deployScript(meta, content, mime, becca, notesService);
|
||||
|
||||
const codeNote = becca.notes[codeNoteId(meta.id)];
|
||||
const labels = codeNote.getLabels();
|
||||
expect(labels.find((a) => a.name === "executeButton")?.value).toBe("true");
|
||||
expect(labels.find((a) => a.name === "executeDescription")?.value).toBe("Does something useful");
|
||||
expect(labels.find((a) => a.name === "executeTitle")?.value).toBe("Run It");
|
||||
});
|
||||
|
||||
it("sets execute labels on update of an existing note", () => {
|
||||
const meta = { id: "exec-update", type: "backend", title: "Before" };
|
||||
deployScript(meta, content, mime, becca, notesService);
|
||||
|
||||
const updated = { ...meta, title: "After", executeButton: "true", executeDescription: "Now executable" };
|
||||
const result = deployScript(updated, content, mime, becca, notesService);
|
||||
|
||||
expect(result.action).toBe("updated");
|
||||
const labels = becca.notes[codeNoteId(meta.id)].getLabels();
|
||||
expect(labels.find((a) => a.name === "executeButton")?.value).toBe("true");
|
||||
expect(labels.find((a) => a.name === "executeDescription")?.value).toBe("Now executable");
|
||||
});
|
||||
|
||||
it("does not set execute labels when absent from metadata", () => {
|
||||
const meta = { id: "no-exec", type: "backend", title: "Plain" };
|
||||
deployScript(meta, content, mime, becca, notesService);
|
||||
|
||||
const labels = becca.notes[codeNoteId(meta.id)].getLabels();
|
||||
expect(labels.find((a) => a.name === "executeButton")).toBeUndefined();
|
||||
expect(labels.find((a) => a.name === "executeDescription")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Core deployment logic for Trilium scripts.
|
||||
*
|
||||
* Extracted from dev.ts so it can be tested without the full server lifecycle.
|
||||
*/
|
||||
|
||||
import { extname } from "node:path";
|
||||
import { transformSync } from "esbuild";
|
||||
// ── Public constants ─────────────────────────────────────────────────────────
|
||||
|
||||
export const SCRIPTS_NOTE_ID = "_scripts";
|
||||
|
||||
export const MIME_BY_EXT: Record<string, string> = {
|
||||
".jsx": "text/jsx",
|
||||
".tsx": "text/jsx",
|
||||
".js": "application/javascript;env=frontend",
|
||||
".ts": "application/javascript;env=backend",
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
};
|
||||
|
||||
// ── Front-matter parsing ─────────────────────────────────────────────────────
|
||||
|
||||
export interface ScriptMeta {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the `@trilium-script` YAML front matter from a JSDoc comment block.
|
||||
*
|
||||
* ```
|
||||
* /**
|
||||
* * @trilium-script
|
||||
* *
|
||||
* * id: my-script
|
||||
* * type: render
|
||||
* * title: My Script
|
||||
* *\/
|
||||
* ```
|
||||
*
|
||||
* Returns `null` (with a console error) when the marker is missing or
|
||||
* required fields (`id`, `type`, `title`) are absent.
|
||||
*/
|
||||
export function parseScriptMeta(source: string, filePath: string): ScriptMeta | null {
|
||||
const match = source.match(/\/\*\*[\s\S]*?@trilium-script\s*([\s\S]*?)\*\//);
|
||||
if (!match) return null;
|
||||
|
||||
const block = match[1];
|
||||
const meta: Record<string, string> = {};
|
||||
|
||||
for (const line of block.split("\n")) {
|
||||
const cleaned = line.replace(/^\s*\*\s?/, "").trim();
|
||||
if (!cleaned) continue;
|
||||
|
||||
const colon = cleaned.indexOf(":");
|
||||
if (colon === -1) continue;
|
||||
|
||||
const key = cleaned.slice(0, colon).trim();
|
||||
const value = cleaned.slice(colon + 1).trim();
|
||||
if (key && value) meta[key] = value;
|
||||
}
|
||||
|
||||
const missing = ["id", "type", "title"].filter((k) => !meta[k]);
|
||||
if (missing.length) {
|
||||
console.error(` SKIP ${filePath}: missing required fields: ${missing.join(", ")}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return meta as ScriptMeta;
|
||||
}
|
||||
|
||||
// ── Transpilation ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Extensions that need esbuild transpilation before deployment. */
|
||||
const TRANSPILE_EXTS = new Set([".ts", ".tsx"]);
|
||||
|
||||
/**
|
||||
* Strips TypeScript type annotations via esbuild, preserving JSX intact.
|
||||
* Trilium's frontend handles JSX natively, so we only strip types.
|
||||
*/
|
||||
export function transpile(source: string, filePath: string): string {
|
||||
const ext = extname(filePath);
|
||||
if (!TRANSPILE_EXTS.has(ext)) return source;
|
||||
|
||||
const isBackend = ext === ".ts";
|
||||
const result = transformSync(source, {
|
||||
loader: ext === ".tsx" ? "tsx" : "ts",
|
||||
jsx: "preserve",
|
||||
sourcemap: false,
|
||||
...(isBackend && { format: "cjs" }),
|
||||
});
|
||||
return result.code;
|
||||
}
|
||||
|
||||
// ── Note ID helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
export function codeNoteId(scriptId: string) {
|
||||
return `_sd_${scriptId}`;
|
||||
}
|
||||
|
||||
export function renderNoteId(scriptId: string) {
|
||||
return `_sd_${scriptId}_render`;
|
||||
}
|
||||
|
||||
// ── Deployment ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Abstraction over the note-creation service so that tests can supply
|
||||
* a mock while the real dev server passes the actual `notesService`.
|
||||
*/
|
||||
export interface NoteService {
|
||||
createNewNote(params: {
|
||||
noteId: string;
|
||||
parentNoteId: string;
|
||||
title: string;
|
||||
type: string; // NoteType union at runtime, kept as string for test compatibility
|
||||
mime?: string;
|
||||
content: string;
|
||||
}): { note: { noteId: string } };
|
||||
}
|
||||
|
||||
/** Helper to cast the real notesService (which uses NoteType) to our interface. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function asNoteService(svc: any): NoteService {
|
||||
return svc as NoteService;
|
||||
}
|
||||
|
||||
export interface BeccaLike {
|
||||
notes: Record<string, {
|
||||
noteId: string;
|
||||
title: string;
|
||||
save(): void;
|
||||
setContent(content: string): void;
|
||||
setRelation(name: string, targetNoteId: string): void;
|
||||
setLabel(name: string, value?: string): void;
|
||||
invalidateThisCache(): void;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DeployResult {
|
||||
action: "created" | "updated";
|
||||
codeNoteId: string;
|
||||
renderNoteId?: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/** Labels that map directly from front-matter keys to note attributes. */
|
||||
const PASSTHROUGH_LABELS = ["run", "executeButton", "executeDescription", "executeTitle"] as const;
|
||||
|
||||
function applyLabels(note: BeccaLike["notes"][string], meta: ScriptMeta) {
|
||||
note.setLabel("readOnly");
|
||||
for (const label of PASSTHROUGH_LABELS) {
|
||||
if (meta[label]) {
|
||||
note.setLabel(label, meta[label]);
|
||||
}
|
||||
}
|
||||
// BNote caches attributes eagerly during setLabel's save() path.
|
||||
// Invalidate so subsequent reads see all labels we just set.
|
||||
note.invalidateThisCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploys a single script file into the Trilium note tree.
|
||||
*
|
||||
* - For `type: render`: creates a render note with a child code note
|
||||
* linked via `~renderNote`.
|
||||
* - For other types: creates a plain code note under the scripts folder.
|
||||
* - If the code note already exists, only its content and title are updated.
|
||||
*/
|
||||
export function deployScript(
|
||||
meta: ScriptMeta,
|
||||
content: string,
|
||||
mime: string,
|
||||
becca: BeccaLike,
|
||||
notesService: NoteService,
|
||||
parentNoteId: string = SCRIPTS_NOTE_ID,
|
||||
): DeployResult {
|
||||
const codeId = codeNoteId(meta.id);
|
||||
const existing = becca.notes[codeId];
|
||||
|
||||
if (existing) {
|
||||
existing.title = meta.title;
|
||||
existing.save();
|
||||
existing.setContent(content);
|
||||
applyLabels(existing, meta);
|
||||
return { action: "updated", codeNoteId: codeId, title: meta.title, type: meta.type };
|
||||
}
|
||||
|
||||
if (meta.type === "render") {
|
||||
const renderId = renderNoteId(meta.id);
|
||||
|
||||
notesService.createNewNote({
|
||||
noteId: renderId,
|
||||
parentNoteId,
|
||||
title: meta.title,
|
||||
type: "render",
|
||||
content: "",
|
||||
});
|
||||
|
||||
notesService.createNewNote({
|
||||
noteId: codeId,
|
||||
parentNoteId: renderId,
|
||||
title: meta.title,
|
||||
type: "code",
|
||||
mime,
|
||||
content,
|
||||
});
|
||||
|
||||
const renderNote = becca.notes[renderId];
|
||||
renderNote.setRelation("renderNote", codeId);
|
||||
|
||||
return { action: "created", codeNoteId: codeId, renderNoteId: renderId, title: meta.title, type: meta.type };
|
||||
}
|
||||
|
||||
// Plain code note (backend script, widget, etc.)
|
||||
notesService.createNewNote({
|
||||
noteId: codeId,
|
||||
parentNoteId,
|
||||
title: meta.title,
|
||||
type: "code",
|
||||
mime,
|
||||
content,
|
||||
});
|
||||
|
||||
applyLabels(becca.notes[codeId], meta);
|
||||
|
||||
return { action: "created", codeNoteId: codeId, title: meta.title, type: meta.type };
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Starts a dedicated Trilium instance for script development.
|
||||
*
|
||||
* On first run it initializes a fresh database (no demo content),
|
||||
* creates an ETAPI token and persists it to data/.etapi-token.
|
||||
* On subsequent runs it just boots the server.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, watch, writeFileSync } from "node:fs";
|
||||
import { extname,join, resolve } from "node:path";
|
||||
|
||||
import { asNoteService,codeNoteId, deployScript, MIME_BY_EXT, parseScriptMeta, renderNoteId, SCRIPTS_NOTE_ID, transpile } from "./deploy";
|
||||
|
||||
// ── Environment — must be set before any server module is imported ──────────
|
||||
const DATA_DIR = resolve(__dirname, "../data");
|
||||
const TOKEN_PATH = join(DATA_DIR, ".etapi-token");
|
||||
const PORT = 37842;
|
||||
|
||||
mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
process.env.TRILIUM_DATA_DIR = DATA_DIR;
|
||||
process.env.TRILIUM_PORT = String(PORT);
|
||||
process.env.TRILIUM_RESOURCE_DIR = resolve(__dirname, "../../server/src");
|
||||
process.env.NODE_ENV = "development";
|
||||
process.env.TRILIUM_ENV = "dev";
|
||||
|
||||
// ── Constants ───────────────────────────────────────────────────────────────
|
||||
const SCRIPTS_DIR = resolve(__dirname, "../scripts");
|
||||
const needsInit = !existsSync(join(DATA_DIR, "document.db"));
|
||||
|
||||
async function ensureTranslations() {
|
||||
const i18n = await import("@triliumnext/server/src/services/i18n.js");
|
||||
await i18n.initializeTranslations();
|
||||
}
|
||||
|
||||
async function ensureDatabase() {
|
||||
if (!needsInit) return;
|
||||
|
||||
console.log("No database found — creating a fresh instance…");
|
||||
|
||||
const cls = (await import("@triliumnext/server/src/services/cls.js")).default;
|
||||
const sqlInit = (await import("@triliumnext/server/src/services/sql_init.js")).default;
|
||||
|
||||
// createInitialDatabase must run inside CLS (it touches becca).
|
||||
await cls.init(async () => {
|
||||
await sqlInit.createInitialDatabase(/* skipDemoDb */ true);
|
||||
});
|
||||
|
||||
console.log("Database created.");
|
||||
}
|
||||
|
||||
async function ensureEtapiToken() {
|
||||
if (existsSync(TOKEN_PATH)) {
|
||||
const token = readFileSync(TOKEN_PATH, "utf-8").trim();
|
||||
console.log(`ETAPI token: ${token}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const cls = (await import("@triliumnext/server/src/services/cls.js")).default;
|
||||
const etapiTokens = (await import("@triliumnext/server/src/services/etapi_tokens.js")).default;
|
||||
|
||||
const authToken: string = cls.init(() => {
|
||||
const { authToken } = etapiTokens.createToken("script-deployer");
|
||||
return authToken;
|
||||
});
|
||||
|
||||
writeFileSync(TOKEN_PATH, `${authToken }\n`);
|
||||
console.log(`ETAPI token created and saved to ${TOKEN_PATH}`);
|
||||
console.log(`ETAPI token: ${authToken}`);
|
||||
}
|
||||
|
||||
async function ensureScriptsFolder() {
|
||||
const becca = (await import("@triliumnext/server/src/becca/becca.js")).default;
|
||||
if (becca.notes[SCRIPTS_NOTE_ID]) return;
|
||||
|
||||
const cls = (await import("@triliumnext/server/src/services/cls.js")).default;
|
||||
const notesService = (await import("@triliumnext/server/src/services/notes.js")).default;
|
||||
|
||||
cls.init(() => {
|
||||
notesService.createNewNote({
|
||||
noteId: SCRIPTS_NOTE_ID,
|
||||
parentNoteId: "root",
|
||||
title: "Scripts",
|
||||
type: "doc",
|
||||
content: "",
|
||||
});
|
||||
});
|
||||
|
||||
console.log("Created 'Scripts' folder note.");
|
||||
}
|
||||
|
||||
async function deployScripts() {
|
||||
const files = readdirSync(SCRIPTS_DIR).filter((f) => MIME_BY_EXT[extname(f)]);
|
||||
if (!files.length) {
|
||||
console.log("No scripts to deploy.");
|
||||
return;
|
||||
}
|
||||
|
||||
const becca = (await import("@triliumnext/server/src/becca/becca.js")).default;
|
||||
const cls = (await import("@triliumnext/server/src/services/cls.js")).default;
|
||||
const notesService = (await import("@triliumnext/server/src/services/notes.js")).default;
|
||||
|
||||
console.log(`Deploying ${files.length} script(s)…`);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(SCRIPTS_DIR, file);
|
||||
const source = readFileSync(filePath, "utf-8");
|
||||
const meta = parseScriptMeta(source, file);
|
||||
if (!meta) continue;
|
||||
|
||||
const mime = MIME_BY_EXT[extname(file)]!;
|
||||
const content = transpile(source, file);
|
||||
|
||||
cls.init(() => {
|
||||
const result = deployScript(meta, content, mime, becca, asNoteService(notesService));
|
||||
console.log(` ${result.action === "created" ? "Created" : "Updated"}: ${result.title} (${result.codeNoteId})`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function watchScripts() {
|
||||
// Debounce per file — editors can fire multiple events on a single save.
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
watch(SCRIPTS_DIR, (_eventType, filename) => {
|
||||
if (!filename || !MIME_BY_EXT[extname(filename)]) return;
|
||||
|
||||
if (timers.has(filename)) clearTimeout(timers.get(filename));
|
||||
timers.set(filename, setTimeout(() => {
|
||||
timers.delete(filename);
|
||||
syncFile(filename);
|
||||
}, 100));
|
||||
});
|
||||
|
||||
async function syncFile(filename: string) {
|
||||
const filePath = join(SCRIPTS_DIR, filename);
|
||||
if (!existsSync(filePath)) return;
|
||||
|
||||
const source = readFileSync(filePath, "utf-8");
|
||||
const meta = parseScriptMeta(source, filename);
|
||||
if (!meta) return;
|
||||
|
||||
const noteId = codeNoteId(meta.id);
|
||||
|
||||
const becca = (await import("@triliumnext/server/src/becca/becca.js")).default;
|
||||
const cls = (await import("@triliumnext/server/src/services/cls.js")).default;
|
||||
|
||||
const note = becca.notes[noteId];
|
||||
if (!note) {
|
||||
console.log(` [watch] ${filename}: note ${noteId} not found, restart to create.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = transpile(source, filename);
|
||||
|
||||
cls.init(() => {
|
||||
note.setContent(content);
|
||||
});
|
||||
|
||||
// For render scripts, trigger a refresh of all contexts viewing
|
||||
// the render note by injecting a client-side script via websocket.
|
||||
if (meta.type === "render") {
|
||||
const ws = (await import("@triliumnext/server/src/services/ws.js")).default;
|
||||
const renderId = renderNoteId(meta.id);
|
||||
ws.sendMessageToAllClients({
|
||||
type: "execute-script",
|
||||
script: `function({ renderId, title }) {
|
||||
for (const ctx of api.getNoteContexts()) {
|
||||
if (ctx.noteId === renderId) {
|
||||
api.triggerEvent("refreshData", { ntxId: ctx.ntxId });
|
||||
console.log("[script-deployer] refreshed", title, "in context", ctx.ntxId);
|
||||
}
|
||||
}
|
||||
}`,
|
||||
params: [{
|
||||
renderId,
|
||||
title: meta.title
|
||||
}],
|
||||
currentNoteId: noteId,
|
||||
originEntityName: "notes",
|
||||
originEntityId: noteId,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` [watch] Synced: ${meta.title}`);
|
||||
}
|
||||
|
||||
console.log(`Watching ${SCRIPTS_DIR} for changes…`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureTranslations();
|
||||
await ensureDatabase();
|
||||
await ensureEtapiToken();
|
||||
|
||||
// Start the full HTTP server — this loads becca and makes the note
|
||||
// tree available for subsequent setup steps.
|
||||
const startTriliumServer = (await import("@triliumnext/server/src/www.js")).default;
|
||||
await startTriliumServer();
|
||||
|
||||
await ensureScriptsFolder();
|
||||
await deployScripts();
|
||||
watchScripts();
|
||||
|
||||
console.log(`\nScript-deployer Trilium instance running on http://localhost:${PORT}`);
|
||||
console.log(`Token file: ${TOKEN_PATH}\n`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/**
|
||||
* Ambient type declarations for the virtual modules available inside
|
||||
* Trilium user scripts (`trilium:preact` and `trilium:api`).
|
||||
*
|
||||
* These modules don't exist on disk — the server rewrites imports at
|
||||
* runtime — but providing declarations here gives us editor
|
||||
* intellisense and `tsc` checking for scripts in the `scripts/` dir.
|
||||
*
|
||||
* Types are pulled directly from the real implementations so they
|
||||
* stay in sync automatically.
|
||||
*/
|
||||
|
||||
type FrontendApi = import("@triliumnext/client/src/services/frontend_script_api").Api;
|
||||
type PreactApi = typeof import("@triliumnext/client/src/services/frontend_script_api_preact").preactAPI;
|
||||
|
||||
/**
|
||||
* `trilium:api` — destructured members of the frontend script API.
|
||||
*
|
||||
* At runtime the server rewrites `require("trilium:api")` to `api`,
|
||||
* which is the FrontendApi instance. Scripts destructure named
|
||||
* members from it: `import { runOnBackend, showMessage } from "trilium:api"`.
|
||||
*/
|
||||
declare module "trilium:api" {
|
||||
// Re-export every member of the frontend API as a named export.
|
||||
export const {
|
||||
activateNote,
|
||||
showMessage,
|
||||
showError,
|
||||
runOnBackend,
|
||||
getNote,
|
||||
getActiveContextNote,
|
||||
getActiveContextTextEditor,
|
||||
getActiveContextCodeEditor,
|
||||
getActiveContextNotePath,
|
||||
getComponentByEl,
|
||||
bindGlobalShortcut,
|
||||
triggerEvent,
|
||||
triggerCommand,
|
||||
getNoteContexts,
|
||||
refreshIncludedNote,
|
||||
createLink,
|
||||
startNote,
|
||||
currentNote,
|
||||
originEntity,
|
||||
dayjs,
|
||||
}: FrontendApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* `trilium:preact` — destructured members of the Preact API surface.
|
||||
*
|
||||
* At runtime the server rewrites `require("trilium:preact")` to
|
||||
* `api.preact`, which is the frozen preactAPI object. Scripts
|
||||
* destructure from it: `import { useState, h } from "trilium:preact"`.
|
||||
*/
|
||||
declare module "trilium:preact" {
|
||||
export const {
|
||||
// Core
|
||||
h,
|
||||
Fragment,
|
||||
createContext,
|
||||
defineWidget,
|
||||
defineLauncherWidget,
|
||||
|
||||
// Hooks
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
|
||||
// Built-in components
|
||||
ActionButton,
|
||||
Admonition,
|
||||
Button,
|
||||
CKEditor,
|
||||
Collapsible,
|
||||
Dropdown,
|
||||
FormCheckbox,
|
||||
FormDropdownList,
|
||||
FormFileUploadButton,
|
||||
FormFileUploadActionButton,
|
||||
FormGroup,
|
||||
FormListItem,
|
||||
FormDropdownDivider,
|
||||
FormDropdownSubmenu,
|
||||
FormRadioGroup,
|
||||
FormText,
|
||||
FormTextArea,
|
||||
FormTextBox,
|
||||
FormToggle,
|
||||
Icon,
|
||||
LinkButton,
|
||||
LoadingSpinner,
|
||||
Modal,
|
||||
NoteAutocomplete,
|
||||
NoteLink,
|
||||
RawHtml,
|
||||
Slider,
|
||||
RightPanelWidget,
|
||||
}: PreactApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global `api` object available inside `runOnBackend()` callbacks.
|
||||
* The function body is serialised and executed on the server where
|
||||
* Trilium injects this as a global.
|
||||
*/
|
||||
// eslint-disable-next-line no-var
|
||||
declare var api: import("@triliumnext/server/src/services/backend_script_api").Api;
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
"module": "esnext"
|
||||
},
|
||||
"include": ["src", "scripts", "../server/src/express.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: "node",
|
||||
env: {
|
||||
NODE_ENV: "development",
|
||||
TRILIUM_DATA_DIR: "./data",
|
||||
TRILIUM_INTEGRATION_TEST: "memory",
|
||||
TRILIUM_RESOURCE_DIR: "../server/src",
|
||||
TRILIUM_ENV: "dev",
|
||||
},
|
||||
include: ["src/**/*.{test,spec}.ts"],
|
||||
testTimeout: 20_000,
|
||||
},
|
||||
}));
|
||||
@@ -30,11 +30,11 @@
|
||||
"proxy-nginx-subdir": "docker run --name trilium-nginx-subdir --rm --network=host -v ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro nginx:latest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.69",
|
||||
"@ai-sdk/google": "3.0.63",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
"@ai-sdk/google": "3.0.64",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"ai": "6.0.161",
|
||||
"ai": "6.0.168",
|
||||
"better-sqlite3": "12.9.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"js-yaml": "4.1.1",
|
||||
@@ -88,7 +88,7 @@
|
||||
"debounce": "3.0.0",
|
||||
"debug": "4.4.3",
|
||||
"ejs": "5.0.2",
|
||||
"electron": "41.2.1",
|
||||
"electron": "41.3.0",
|
||||
"electron-window-state": "5.0.3",
|
||||
"escape-html": "1.0.3",
|
||||
"express": "5.2.1",
|
||||
@@ -103,8 +103,8 @@
|
||||
"html2plaintext": "2.1.4",
|
||||
"http-proxy-agent": "9.0.0",
|
||||
"https-proxy-agent": "9.0.0",
|
||||
"i18next": "26.0.5",
|
||||
"i18next-fs-backend": "2.6.3",
|
||||
"i18next": "26.0.6",
|
||||
"i18next-fs-backend": "2.6.4",
|
||||
"image-type": "6.1.0",
|
||||
"ini": "6.0.0",
|
||||
"is-animated": "2.0.2",
|
||||
@@ -130,7 +130,7 @@
|
||||
"tmp": "0.2.5",
|
||||
"turnish": "1.8.0",
|
||||
"unescape": "1.0.1",
|
||||
"vite": "8.0.8",
|
||||
"vite": "8.0.9",
|
||||
"ws": "8.20.0",
|
||||
"xml2js": "0.6.2",
|
||||
"yauzl": "3.3.0"
|
||||
|
||||
@@ -103,7 +103,8 @@
|
||||
"find-in-text": "在文本中查找",
|
||||
"back-in-note-history": "导航到历史记录中的上一个笔记",
|
||||
"forward-in-note-history": "导航到历史记录的下一个笔记",
|
||||
"scroll-to-active-note": "滚动笔记树到当前笔记"
|
||||
"scroll-to-active-note": "滚动笔记树到当前笔记",
|
||||
"save-named-revision": "保存活动笔记的命名修订"
|
||||
},
|
||||
"login": {
|
||||
"title": "登录",
|
||||
@@ -398,7 +399,8 @@
|
||||
"open-developer-tools": "打开开发者工具",
|
||||
"find-in-text": "在文本中查找",
|
||||
"toggle-left-pane": "切换左侧面板",
|
||||
"toggle-full-screen": "切换全屏模式"
|
||||
"toggle-full-screen": "切换全屏模式",
|
||||
"save-named-revision": "保存已命名的修订"
|
||||
},
|
||||
"share_theme": {
|
||||
"site-theme": "网站主题",
|
||||
|
||||
@@ -187,7 +187,8 @@
|
||||
"migration": {
|
||||
"old_version": "La migrazione diretta dalla tua versione attuale non è supportata. Aggiorna prima all'ultima versione v0.60.4 e solo dopo a questa versione.",
|
||||
"error_message": "Errore durante la migrazione alla versione {{version}}: {{stack}}",
|
||||
"wrong_db_version": "La versione del database ({{version}}) è più recente di quanto l'applicazione si aspetti ({{targetVersion}}), il che significa che è stato creato da una versione più nuova e incompatibile di Trilium. Aggiorna Trilium all'ultima versione per risolvere questo problema."
|
||||
"wrong_db_version": "La versione del database ({{version}}) è più recente di quanto l'applicazione si aspetti ({{targetVersion}}), il che significa che è stato creato da una versione più nuova e incompatibile di Trilium. Aggiorna Trilium all'ultima versione per risolvere questo problema.",
|
||||
"invalid_db_version": "La versione del database non è un numero valido. Questo di solito indica che l'opzione «dbVersion» nel database è danneggiata. Si prega di ripristinare il sistema da un backup."
|
||||
},
|
||||
"modals": {
|
||||
"error_title": "Errore"
|
||||
|
||||
@@ -103,7 +103,8 @@
|
||||
"find-in-text": "切換搜尋面板",
|
||||
"toggle-classic-editor-toolbar": "切換固定工具列編輯器的格式分頁",
|
||||
"export-as-pdf": "匯出目前筆記為 PDF",
|
||||
"toggle-zen-mode": "啟用 / 禁用禪模式(極簡界面以專注編輯)"
|
||||
"toggle-zen-mode": "啟用 / 禁用禪模式(極簡界面以專注編輯)",
|
||||
"save-named-revision": "儲存目前筆記的命名修訂"
|
||||
},
|
||||
"login": {
|
||||
"title": "登入",
|
||||
@@ -300,7 +301,8 @@
|
||||
"find-in-text": "在文字中尋找",
|
||||
"toggle-left-pane": "切換左側面板",
|
||||
"toggle-full-screen": "切換全螢幕",
|
||||
"command-palette": "命令面板"
|
||||
"command-palette": "命令面板",
|
||||
"save-named-revision": "儲存命名修訂"
|
||||
},
|
||||
"weekdayNumber": "第 {weekNumber} 週",
|
||||
"quarterNumber": "第 {quarterNumber} 季度",
|
||||
@@ -394,7 +396,8 @@
|
||||
"migration": {
|
||||
"old_version": "您目前的版本不支援直接遷移。請先更新至最新的 v0.60.4 然後再到此版本。",
|
||||
"error_message": "遷移至版本 {{version}} 時發生錯誤:{{stack}}",
|
||||
"wrong_db_version": "資料庫版本({{version}})比程式預期({{targetVersion}})新,這意味著它由一個更新且不相容的 Trilium 版本所創建。升級至最新版的 Trilium 以解決此問題。"
|
||||
"wrong_db_version": "資料庫版本({{version}})比程式預期({{targetVersion}})新,這意味著它由一個更新且不相容的 Trilium 版本所創建。升級至最新版的 Trilium 以解決此問題。",
|
||||
"invalid_db_version": "資料庫版本號不是有效的數字。這通常表示資料庫中的「dbVersion」選項已損毀。請從備份還原。"
|
||||
},
|
||||
"modals": {
|
||||
"error_title": "錯誤"
|
||||
@@ -455,5 +458,11 @@
|
||||
"fulltext-after-expression": "\"{{- token}}\" 不是有效的表達式。若要搜尋文字,請將其置於屬性篩選條件之前(例如:\"{{- token}} #label\",而非 \"#label {{- token}}\")。",
|
||||
"unrecognized-expression": "無法辨識的表達式 \"{{- token}}\""
|
||||
}
|
||||
},
|
||||
"password": {
|
||||
"incorrect": "您輸入的密碼不正確。"
|
||||
},
|
||||
"script": {
|
||||
"wrong-environment": "無法執行筆記「{{- noteTitle}}」({{- noteId}})。這是一個 {{- actualEnv}} 腳本,但嘗試在 {{- expectedEnv}} 中執行。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ import { BaseProvider, buildModelList } from "./base_provider.js";
|
||||
*/
|
||||
const { models: AVAILABLE_MODELS, pricing: MODEL_PRICING } = buildModelList([
|
||||
// ===== Current Models =====
|
||||
{
|
||||
id: "claude-opus-4-7",
|
||||
name: "Claude Opus 4.7",
|
||||
pricing: { input: 5, output: 25 },
|
||||
contextWindow: 1000000
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
@@ -18,12 +24,6 @@ const { models: AVAILABLE_MODELS, pricing: MODEL_PRICING } = buildModelList([
|
||||
contextWindow: 1000000,
|
||||
isDefault: true
|
||||
},
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
pricing: { input: 5, output: 25 },
|
||||
contextWindow: 1000000
|
||||
},
|
||||
{
|
||||
id: "claude-haiku-4-5-20251001",
|
||||
name: "Claude Haiku 4.5",
|
||||
@@ -31,6 +31,13 @@ const { models: AVAILABLE_MODELS, pricing: MODEL_PRICING } = buildModelList([
|
||||
contextWindow: 200000
|
||||
},
|
||||
// ===== Legacy Models =====
|
||||
{
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
pricing: { input: 5, output: 25 },
|
||||
contextWindow: 1000000,
|
||||
isLegacy: true
|
||||
},
|
||||
{
|
||||
id: "claude-sonnet-4-5-20250929",
|
||||
name: "Claude Sonnet 4.5",
|
||||
|
||||
@@ -3,19 +3,38 @@
|
||||
*/
|
||||
|
||||
import type { LlmStreamChunk } from "@triliumnext/commons";
|
||||
import type { LanguageModelUsage } from "ai";
|
||||
|
||||
import type { ModelPricing, StreamResult } from "./types.js";
|
||||
|
||||
// Cache pricing assumes Anthropic's 5-min ephemeral rates (writes 1.25× input, reads 0.1× input).
|
||||
// OpenAI/Gemini bill caches differently but the gap is small compared to the current behaviour
|
||||
// of treating cached tokens at full input price.
|
||||
const CACHE_WRITE_MULTIPLIER = 1.25;
|
||||
const CACHE_READ_MULTIPLIER = 0.1;
|
||||
|
||||
/**
|
||||
* Calculate estimated cost in USD based on token usage and pricing.
|
||||
* Accounts for cache read/write pricing tiers when the provider reports them;
|
||||
* reasoning tokens are already included in `outputTokens` per the AI SDK.
|
||||
*/
|
||||
function calculateCost(inputTokens: number, outputTokens: number, pricing?: ModelPricing): number | undefined {
|
||||
function calculateCost(usage: LanguageModelUsage, pricing?: ModelPricing): number | undefined {
|
||||
if (!pricing) return undefined;
|
||||
|
||||
const inputCost = (inputTokens / 1_000_000) * pricing.input;
|
||||
const outputCost = (outputTokens / 1_000_000) * pricing.output;
|
||||
const details = usage.inputTokenDetails;
|
||||
const cacheReadTokens = details?.cacheReadTokens ?? usage.cachedInputTokens ?? 0;
|
||||
const cacheWriteTokens = details?.cacheWriteTokens ?? 0;
|
||||
const noCacheInputTokens = details?.noCacheTokens
|
||||
?? Math.max(0, (usage.inputTokens ?? 0) - cacheReadTokens - cacheWriteTokens);
|
||||
const outputTokens = usage.outputTokens ?? 0;
|
||||
|
||||
return inputCost + outputCost;
|
||||
const M = 1_000_000;
|
||||
return (
|
||||
(noCacheInputTokens / M) * pricing.input
|
||||
+ (cacheReadTokens / M) * pricing.input * CACHE_READ_MULTIPLIER
|
||||
+ (cacheWriteTokens / M) * pricing.input * CACHE_WRITE_MULTIPLIER
|
||||
+ (outputTokens / M) * pricing.output
|
||||
);
|
||||
}
|
||||
|
||||
export interface StreamOptions {
|
||||
@@ -85,7 +104,7 @@ export async function* streamToChunks(result: StreamResult, options: StreamOptio
|
||||
// Get usage information after stream completes
|
||||
const usage = await result.usage;
|
||||
if (usage && typeof usage.inputTokens === "number" && typeof usage.outputTokens === "number") {
|
||||
const cost = calculateCost(usage.inputTokens, usage.outputTokens, options.pricing);
|
||||
const cost = calculateCost(usage, options.pricing);
|
||||
yield {
|
||||
type: "usage",
|
||||
usage: {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"postinstall": "wxt prepare"
|
||||
},
|
||||
"keywords": [],
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"packageManager": "pnpm@10.33.1",
|
||||
"devDependencies": {
|
||||
"@wxt-dev/auto-icons": "1.1.1",
|
||||
"wxt": "0.20.25"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"preview": "pnpm build && vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "26.0.5",
|
||||
"i18next": "26.0.6",
|
||||
"preact": "10.29.1",
|
||||
"preact-iso": "2.11.1",
|
||||
"preact-render-to-string": "6.6.7",
|
||||
@@ -21,8 +21,8 @@
|
||||
"eslint-config-preact": "2.0.0",
|
||||
"typescript": "6.0.3",
|
||||
"user-agent-data-types": "0.4.3",
|
||||
"vite": "8.0.8",
|
||||
"vitest": "4.1.4"
|
||||
"vite": "8.0.9",
|
||||
"vitest": "4.1.5"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "preact"
|
||||
|
||||
+10
-9
@@ -9,6 +9,7 @@
|
||||
"client:test": "pnpm run --filter client test",
|
||||
"client:build": "pnpm run --filter client build",
|
||||
"client:coverage": "pnpm run --filter client test --coverage",
|
||||
"client:stylelint": "pnpm run --filter client stylelint",
|
||||
"server:test": "pnpm run --filter server test",
|
||||
"server:build": "pnpm run --filter server build",
|
||||
"server:coverage": "pnpm run --filter server test --coverage",
|
||||
@@ -47,16 +48,16 @@
|
||||
},
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "4.0.3",
|
||||
"@electron/rebuild": "4.0.4",
|
||||
"@fast-csv/parse": "5.0.5",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@triliumnext/server": "workspace:*",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/node": "24.12.2",
|
||||
"@vitest/browser-webdriverio": "4.1.4",
|
||||
"@vitest/coverage-v8": "4.1.4",
|
||||
"@vitest/ui": "4.1.4",
|
||||
"@vitest/browser-webdriverio": "4.1.5",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"@vitest/ui": "4.1.5",
|
||||
"chalk": "5.6.2",
|
||||
"cross-env": "10.1.0",
|
||||
"dpdm": "4.0.1",
|
||||
@@ -64,7 +65,7 @@
|
||||
"eslint": "10.2.1",
|
||||
"eslint-config-preact": "2.0.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-playwright": "2.10.1",
|
||||
"eslint-plugin-playwright": "2.10.2",
|
||||
"eslint-plugin-simple-import-sort": "13.0.0",
|
||||
"happy-dom": "20.9.0",
|
||||
"http-server": "14.1.1",
|
||||
@@ -76,10 +77,10 @@
|
||||
"tslib": "2.8.1",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.58.2",
|
||||
"vite": "8.0.8",
|
||||
"typescript-eslint": "8.59.0",
|
||||
"vite": "8.0.9",
|
||||
"vite-plugin-dts": "4.5.4",
|
||||
"vitest": "4.1.4"
|
||||
"vitest": "4.1.5"
|
||||
},
|
||||
"license": "AGPL-3.0-only",
|
||||
"author": {
|
||||
@@ -95,7 +96,7 @@
|
||||
"url": "https://github.com/TriliumNext/Trilium/issues"
|
||||
},
|
||||
"homepage": "https://triliumnotes.org",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"packageManager": "pnpm@10.33.1",
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@ckeditor/ckeditor5-mention": "patches/@ckeditor__ckeditor5-mention.patch",
|
||||
|
||||
@@ -18,22 +18,22 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.2",
|
||||
"@typescript-eslint/parser": "8.58.2",
|
||||
"@vitest/browser": "4.1.4",
|
||||
"@vitest/coverage-istanbul": "4.1.4",
|
||||
"ckeditor5": "48.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.0",
|
||||
"@typescript-eslint/parser": "8.59.0",
|
||||
"@vitest/browser": "4.1.5",
|
||||
"@vitest/coverage-istanbul": "4.1.5",
|
||||
"ckeditor5": "48.0.1",
|
||||
"eslint": "10.2.1",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
"stylelint": "17.7.0",
|
||||
"stylelint": "17.8.0",
|
||||
"stylelint-config-ckeditor5": ">=9.1.0",
|
||||
"typescript": "6.0.3",
|
||||
"vite-plugin-svgo": "2.0.0",
|
||||
"vitest": "4.1.4",
|
||||
"vitest": "4.1.5",
|
||||
"webdriverio": "9.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ckeditor5": "48.0.0"
|
||||
"ckeditor5": "48.0.1"
|
||||
},
|
||||
"author": "Elian Doran <contact@eliandoran.me>",
|
||||
"license": "GPL-2.0-or-later",
|
||||
|
||||
@@ -19,22 +19,22 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.2",
|
||||
"@typescript-eslint/parser": "8.58.2",
|
||||
"@vitest/browser": "4.1.4",
|
||||
"@vitest/coverage-istanbul": "4.1.4",
|
||||
"ckeditor5": "48.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.0",
|
||||
"@typescript-eslint/parser": "8.59.0",
|
||||
"@vitest/browser": "4.1.5",
|
||||
"@vitest/coverage-istanbul": "4.1.5",
|
||||
"ckeditor5": "48.0.1",
|
||||
"eslint": "10.2.1",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
"stylelint": "17.7.0",
|
||||
"stylelint": "17.8.0",
|
||||
"stylelint-config-ckeditor5": ">=9.1.0",
|
||||
"typescript": "6.0.3",
|
||||
"vite-plugin-svgo": "2.0.0",
|
||||
"vitest": "4.1.4",
|
||||
"vitest": "4.1.5",
|
||||
"webdriverio": "9.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ckeditor5": "48.0.0"
|
||||
"ckeditor5": "48.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint \"**/*.{js,ts}\" --quiet",
|
||||
|
||||
@@ -21,22 +21,22 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.2",
|
||||
"@typescript-eslint/parser": "8.58.2",
|
||||
"@vitest/browser": "4.1.4",
|
||||
"@vitest/coverage-istanbul": "4.1.4",
|
||||
"ckeditor5": "48.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.0",
|
||||
"@typescript-eslint/parser": "8.59.0",
|
||||
"@vitest/browser": "4.1.5",
|
||||
"@vitest/coverage-istanbul": "4.1.5",
|
||||
"ckeditor5": "48.0.1",
|
||||
"eslint": "10.2.1",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
"stylelint": "17.7.0",
|
||||
"stylelint": "17.8.0",
|
||||
"stylelint-config-ckeditor5": ">=9.1.0",
|
||||
"typescript": "6.0.3",
|
||||
"vite-plugin-svgo": "2.0.0",
|
||||
"vitest": "4.1.4",
|
||||
"vitest": "4.1.5",
|
||||
"webdriverio": "9.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ckeditor5": "48.0.0"
|
||||
"ckeditor5": "48.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint \"**/*.{js,ts}\" --quiet",
|
||||
|
||||
@@ -21,22 +21,22 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.2",
|
||||
"@typescript-eslint/parser": "8.58.2",
|
||||
"@vitest/browser": "4.1.4",
|
||||
"@vitest/coverage-istanbul": "4.1.4",
|
||||
"ckeditor5": "48.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.0",
|
||||
"@typescript-eslint/parser": "8.59.0",
|
||||
"@vitest/browser": "4.1.5",
|
||||
"@vitest/coverage-istanbul": "4.1.5",
|
||||
"ckeditor5": "48.0.1",
|
||||
"eslint": "10.2.1",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
"stylelint": "17.7.0",
|
||||
"stylelint": "17.8.0",
|
||||
"stylelint-config-ckeditor5": ">=9.1.0",
|
||||
"typescript": "6.0.3",
|
||||
"vite-plugin-svgo": "2.0.0",
|
||||
"vitest": "4.1.4",
|
||||
"vitest": "4.1.5",
|
||||
"webdriverio": "9.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ckeditor5": "48.0.0"
|
||||
"ckeditor5": "48.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint \"**/*.{js,ts}\" --quiet",
|
||||
@@ -45,7 +45,7 @@
|
||||
"test:debug": "vitest --inspect-brk --no-file-parallelism --browser.headless=false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ckeditor/ckeditor5-icons": "48.0.0",
|
||||
"@ckeditor/ckeditor5-icons": "48.0.1",
|
||||
"mathlive": "0.109.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,22 +21,22 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.2",
|
||||
"@typescript-eslint/parser": "8.58.2",
|
||||
"@vitest/browser": "4.1.4",
|
||||
"@vitest/coverage-istanbul": "4.1.4",
|
||||
"ckeditor5": "48.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.0",
|
||||
"@typescript-eslint/parser": "8.59.0",
|
||||
"@vitest/browser": "4.1.5",
|
||||
"@vitest/coverage-istanbul": "4.1.5",
|
||||
"ckeditor5": "48.0.1",
|
||||
"eslint": "10.2.1",
|
||||
"eslint-config-ckeditor5": ">=9.1.0",
|
||||
"stylelint": "17.7.0",
|
||||
"stylelint": "17.8.0",
|
||||
"stylelint-config-ckeditor5": ">=9.1.0",
|
||||
"typescript": "6.0.3",
|
||||
"vite-plugin-svgo": "2.0.0",
|
||||
"vitest": "4.1.4",
|
||||
"vitest": "4.1.5",
|
||||
"webdriverio": "9.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ckeditor5": "48.0.0"
|
||||
"ckeditor5": "48.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint \"**/*.{js,ts}\" --quiet",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"@triliumnext/ckeditor5-keyboard-marker": "workspace:*",
|
||||
"@triliumnext/ckeditor5-math": "workspace:*",
|
||||
"@triliumnext/ckeditor5-mermaid": "workspace:*",
|
||||
"ckeditor5": "48.0.0",
|
||||
"ckeditor5-premium-features": "48.0.0"
|
||||
"ckeditor5": "48.0.1",
|
||||
"ckeditor5-premium-features": "48.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@codemirror/lang-vue": "0.1.3",
|
||||
"@codemirror/lang-xml": "6.1.0",
|
||||
"@codemirror/legacy-modes": "6.5.2",
|
||||
"@codemirror/search": "6.6.0",
|
||||
"@codemirror/search": "6.7.0",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.41.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
"devDependencies": {
|
||||
"@digitak/esrun": "3.2.26",
|
||||
"@triliumnext/ckeditor5": "workspace:*",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.2",
|
||||
"@typescript-eslint/parser": "8.58.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.0",
|
||||
"@typescript-eslint/parser": "8.59.0",
|
||||
"dotenv": "17.4.2",
|
||||
"esbuild": "0.28.0",
|
||||
"eslint": "10.2.1",
|
||||
|
||||
@@ -39,6 +39,6 @@
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-buble": "1.0.3",
|
||||
"happy-dom": "20.9.0",
|
||||
"vitest": "4.1.4"
|
||||
"vitest": "4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,6 @@
|
||||
"devDependencies": {
|
||||
"happy-dom": "20.9.0",
|
||||
"turndown": "7.2.4",
|
||||
"vitest": "4.1.4"
|
||||
"vitest": "4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1428
-1629
File diff suppressed because it is too large
Load Diff
@@ -71,6 +71,9 @@
|
||||
},
|
||||
{
|
||||
"path": "./scripts"
|
||||
},
|
||||
{
|
||||
"path": "./apps/script-deployer"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user