mirror of
https://github.com/zadam/trilium.git
synced 2025-12-27 02:30:03 +01:00
Compare commits
4 Commits
feature/ic
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6c515aea0 | ||
|
|
850710926e | ||
|
|
904da14895 | ||
|
|
4c5bc3a3d3 |
@@ -29,7 +29,7 @@
|
||||
"widget-render-error": {
|
||||
"title": "渲染自定义 React 小部件失败"
|
||||
},
|
||||
"widget-missing-parent": "自定义小部件未定义强制性的 \"{{property}}\" 属性。",
|
||||
"widget-missing-parent": "自定义小部件未定义强制性的 \"{{property}}\" 属性。\n\n如果此脚本需要在没有 UI 元素的情况下运行,请改用“#run=frontendStartup”。",
|
||||
"open-script-note": "打开脚本笔记",
|
||||
"scripting-error": "自定义脚本错误:{{title}}"
|
||||
},
|
||||
@@ -1597,7 +1597,11 @@
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "找不到类型为 '{{type}}' 的 typeWidget",
|
||||
"printing": "正在打印…",
|
||||
"printing_pdf": "正在导出为PDF…"
|
||||
"printing_pdf": "正在导出为PDF…",
|
||||
"print_report_title": "打印报告",
|
||||
"print_report_collection_content_other": "集合中的 {{count}} 篇笔记无法打印,因为它们不受支持或受到保护。",
|
||||
"print_report_collection_details_button": "查看详情",
|
||||
"print_report_collection_details_ignored_notes": "忽略的笔记"
|
||||
},
|
||||
"note_title": {
|
||||
"placeholder": "请输入笔记标题...",
|
||||
|
||||
@@ -765,10 +765,9 @@
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "Change note icon",
|
||||
"category": "Category:",
|
||||
"search": "Search:",
|
||||
"reset-default": "Reset to default icon",
|
||||
"filter-none": "All icons",
|
||||
"filter-default": "Default icons"
|
||||
"reset-default": "Reset to default icon"
|
||||
},
|
||||
"basic_properties": {
|
||||
"note_type": "Note type",
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"widget-render-error": {
|
||||
"title": "カスタム React ウィジェットのレンダリングに失敗しました"
|
||||
},
|
||||
"widget-missing-parent": "カスタムウィジェットに必須の '{{property}}' プロパティが定義されていません。",
|
||||
"widget-missing-parent": "カスタムウィジェットに必須の '{{property}}' プロパティが定義されていません。\n\nこのスクリプトを UI 要素なしで実行する場合は、代わりに '#run=frontendStartup' を使用してください。",
|
||||
"open-script-note": "スクリプトノートを開く",
|
||||
"scripting-error": "カスタムスクリプトエラー: {{title}}"
|
||||
},
|
||||
@@ -1930,7 +1930,11 @@
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "タイプ {{type}} の typeWidget が見つかりませんでした",
|
||||
"printing": "印刷中です...",
|
||||
"printing_pdf": "PDF へのエクスポート中です..."
|
||||
"printing_pdf": "PDF へのエクスポート中です...",
|
||||
"print_report_title": "レポートを印刷",
|
||||
"print_report_collection_content_other": "コレクション内の {{count}} 件のノートは、サポートされていないか保護されているため、印刷できませんでした。",
|
||||
"print_report_collection_details_button": "詳細を見る",
|
||||
"print_report_collection_details_ignored_notes": "無視されたノート"
|
||||
},
|
||||
"watched_file_update_status": {
|
||||
"ignore_this_change": "この変更を無視する",
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"widget-render-error": {
|
||||
"title": "Не удалось отобразить пользовательский React виджет"
|
||||
},
|
||||
"widget-missing-parent": "В пользовательском виджете не определено обязательное свойство '{{property}}'.",
|
||||
"widget-missing-parent": "В пользовательском виджете не определено обязательное свойство '{{property}}'.\n\nЕсли этот скрипт предназначен для запуска без элемента пользовательского интерфейса, используйте '#run=frontendStartup'.",
|
||||
"open-script-note": "Открыть заметку со скриптом",
|
||||
"scripting-error": "Ошибка пользовательского скрипта: {{title}}"
|
||||
},
|
||||
@@ -2112,7 +2112,13 @@
|
||||
"note_detail": {
|
||||
"could_not_find_typewidget": "Не удалось найти typeWidget для типа '{{type}}'",
|
||||
"printing_pdf": "Выполняется экспорт PDF...",
|
||||
"printing": "Выполняется печать..."
|
||||
"printing": "Выполняется печать...",
|
||||
"print_report_title": "Отчет по печати",
|
||||
"print_report_collection_content_one": "{{count}} заметка в коллекции не удалось распечатать, поскольку она не поддерживается или защищена.",
|
||||
"print_report_collection_content_few": "{{count}} заметки в коллекции не удалось распечатать, поскольку они не поддерживаются или защищены.",
|
||||
"print_report_collection_content_many": "{{count}} заметок в коллекции не удалось распечатать, поскольку они не поддерживаются или защищены.",
|
||||
"print_report_collection_details_button": "Подробнее",
|
||||
"print_report_collection_details_ignored_notes": "Пропущенные заметки"
|
||||
},
|
||||
"book": {
|
||||
"no_children_help": "В этой коллекции нет дочерних заметок, поэтому отображать нечего. Подробности см. в <a href=\"https://triliumnext.github.io/Docs/Wiki/book-note.html\">wiki</a>.",
|
||||
|
||||
3
apps/client/src/types.d.ts
vendored
3
apps/client/src/types.d.ts
vendored
@@ -1,5 +1,3 @@
|
||||
import { IconRegistry } from "@triliumnext/commons";
|
||||
|
||||
import appContext, { AppContext } from "./components/app_context";
|
||||
import type FNote from "./entities/fnote";
|
||||
import type { PrintReport } from "./print";
|
||||
@@ -48,7 +46,6 @@ interface CustomGlobals {
|
||||
linter: typeof lint;
|
||||
hasNativeTitleBar: boolean;
|
||||
isRtl: boolean;
|
||||
iconRegistry: IconRegistry;
|
||||
}
|
||||
|
||||
type RequireMethod = (moduleName: string) => any;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,14 +32,17 @@ div.note-icon-widget {
|
||||
}
|
||||
|
||||
.note-icon-widget .filter-row {
|
||||
padding: 10px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
padding-inline-end: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.note-icon-widget .filter-row span {
|
||||
display: block;
|
||||
padding-inline-start: 15px;
|
||||
padding-inline-end: 15px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -108,4 +111,4 @@ body.experimental-feature-new-layout {
|
||||
transition: background 200ms ease-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,15 @@
|
||||
import Dropdown from "./react/Dropdown";
|
||||
import "./note_icon.css";
|
||||
|
||||
import { t } from "i18next";
|
||||
import { useNoteContext, useNoteLabel } from "./react/hooks";
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
import server from "../services/server";
|
||||
import type { Category, Icon } from "./icon_list";
|
||||
import FormTextBox from "./react/FormTextBox";
|
||||
import FormSelect from "./react/FormSelect";
|
||||
import FNote from "../entities/fnote";
|
||||
import attributes from "../services/attributes";
|
||||
import server from "../services/server";
|
||||
import type { Icon } from "./icon_list";
|
||||
import ActionButton from "./react/ActionButton";
|
||||
import Button from "./react/Button";
|
||||
import Dropdown from "./react/Dropdown";
|
||||
import { FormDropdownDivider, FormListItem } from "./react/FormList";
|
||||
import FormTextBox from "./react/FormTextBox";
|
||||
import { useNoteContext, useNoteLabel } from "./react/hooks";
|
||||
import Icon from "./react/Icon";
|
||||
|
||||
interface IconToCountCache {
|
||||
iconClassToCountMap: Record<string, number>;
|
||||
@@ -21,10 +17,12 @@ interface IconToCountCache {
|
||||
|
||||
interface IconData {
|
||||
iconToCount: Record<string, number>;
|
||||
categories: Category[];
|
||||
icons: Icon[];
|
||||
}
|
||||
|
||||
let fullIconData: {
|
||||
categories: Category[];
|
||||
icons: Icon[];
|
||||
};
|
||||
let iconToCountCache!: Promise<IconToCountCache> | null;
|
||||
@@ -47,18 +45,17 @@ export default function NoteIcon() {
|
||||
buttonClassName={`note-icon tn-focusable-button ${icon ?? "bx bx-empty"}`}
|
||||
hideToggleArrow
|
||||
disabled={viewScope?.viewMode !== "default"}
|
||||
dropdownOptions={{ autoClose: "outside" }}
|
||||
>
|
||||
{ note && <NoteIconList note={note} /> }
|
||||
</Dropdown>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function NoteIconList({ note }: { note: FNote }) {
|
||||
const searchBoxRef = useRef<HTMLInputElement>(null);
|
||||
const [ search, setSearch ] = useState<string>();
|
||||
const [ categoryId, setCategoryId ] = useState<string>("0");
|
||||
const [ iconData, setIconData ] = useState<IconData>();
|
||||
const [ filterByPrefix, setFilterByPrefix ] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadIcons() {
|
||||
@@ -67,21 +64,12 @@ function NoteIconList({ note }: { note: FNote }) {
|
||||
}
|
||||
|
||||
// Filter by text and/or category.
|
||||
let icons: Pick<Icon, "name" | "term" | "className">[] = [
|
||||
...fullIconData.icons,
|
||||
...glob.iconRegistry.sources.map(s => s.icons.map(icon => ({
|
||||
name: icon.terms.at(0) ?? "",
|
||||
term: icon.terms.slice(1),
|
||||
className: icon.id
|
||||
}))).flat()
|
||||
];
|
||||
let icons: Icon[] = fullIconData.icons;
|
||||
const processedSearch = search?.trim()?.toLowerCase();
|
||||
if (processedSearch || filterByPrefix !== null) {
|
||||
if (processedSearch || categoryId) {
|
||||
icons = icons.filter((icon) => {
|
||||
if (filterByPrefix) {
|
||||
if (!icon.className?.startsWith(`${filterByPrefix} `)) {
|
||||
return false;
|
||||
}
|
||||
if (categoryId !== "0" && String(icon.category_id) !== categoryId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (processedSearch) {
|
||||
@@ -108,16 +96,25 @@ function NoteIconList({ note }: { note: FNote }) {
|
||||
|
||||
setIconData({
|
||||
iconToCount,
|
||||
icons
|
||||
});
|
||||
icons,
|
||||
categories: fullIconData.categories
|
||||
})
|
||||
}
|
||||
|
||||
loadIcons();
|
||||
}, [ search, filterByPrefix ]);
|
||||
}, [ search, categoryId ]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="filter-row">
|
||||
<span>{t("note_icon.category")}</span>
|
||||
<FormSelect
|
||||
name="icon-category"
|
||||
values={fullIconData?.categories ?? []}
|
||||
currentValue={categoryId} onChange={setCategoryId}
|
||||
keyProperty="id" titleProperty="name"
|
||||
/>
|
||||
|
||||
<span>{t("note_icon.search")}</span>
|
||||
<FormTextBox
|
||||
inputRef={searchBoxRef}
|
||||
@@ -126,24 +123,16 @@ function NoteIconList({ note }: { note: FNote }) {
|
||||
currentValue={search} onChange={setSearch}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{glob.iconRegistry.sources.length > 0 && <Dropdown
|
||||
buttonClassName="bx bx-filter-alt"
|
||||
hideToggleArrow
|
||||
noSelectButtonStyle
|
||||
noDropdownListStyle
|
||||
iconAction
|
||||
>
|
||||
<IconFilterContent filterByPrefix={filterByPrefix} setFilterByPrefix={setFilterByPrefix} />
|
||||
</Dropdown>}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="icon-list"
|
||||
onClick={(e) => {
|
||||
// Make sure we are not clicking on something else than a button.
|
||||
const clickedTarget = e.target as HTMLElement;
|
||||
if (clickedTarget.tagName !== "SPAN" || clickedTarget.classList.length !== 2) return;
|
||||
|
||||
if (!clickedTarget.classList.contains("bx")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const iconClass = Array.from(clickedTarget.classList.values()).join(" ");
|
||||
if (note) {
|
||||
@@ -169,41 +158,13 @@ function NoteIconList({ note }: { note: FNote }) {
|
||||
)}
|
||||
|
||||
{(iconData?.icons ?? []).map(({className, name}) => (
|
||||
<span class={className} title={name} />
|
||||
<span class={`bx ${className}`} title={name} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function IconFilterContent({ filterByPrefix, setFilterByPrefix }: {
|
||||
filterByPrefix: string | null;
|
||||
setFilterByPrefix: (value: string | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<FormListItem
|
||||
checked={filterByPrefix === null}
|
||||
onClick={() => setFilterByPrefix(null)}
|
||||
>{t("note_icon.filter-none")}</FormListItem>
|
||||
<FormListItem
|
||||
checked={filterByPrefix === "bx"}
|
||||
onClick={() => setFilterByPrefix("bx")}
|
||||
>{t("note_icon.filter-default")}</FormListItem>
|
||||
<FormDropdownDivider />
|
||||
|
||||
{glob.iconRegistry.sources.map(({ prefix, name, icon }) => (
|
||||
<FormListItem
|
||||
key={prefix}
|
||||
onClick={() => setFilterByPrefix(prefix)}
|
||||
icon={icon}
|
||||
checked={filterByPrefix === prefix}
|
||||
>{name}</FormListItem>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function getIconToCountMap() {
|
||||
if (!iconToCountCache) {
|
||||
iconToCountCache = server.get<IconToCountCache>("other/icon-usage");
|
||||
@@ -219,5 +180,5 @@ function getIconLabels(note: FNote) {
|
||||
}
|
||||
return note.getOwnedLabels()
|
||||
.filter((label) => ["workspaceIconClass", "iconClass"]
|
||||
.includes(label.name));
|
||||
.includes(label.name));
|
||||
}
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover" />
|
||||
<link rel="manifest" crossorigin="use-credentials" href="manifest.webmanifest">
|
||||
<title>Trilium Notes</title>
|
||||
<style id="trilium-icon-packs">
|
||||
<%- iconPackCss %>
|
||||
</style>
|
||||
<script src="<%= appPath %>/runtime.js" crossorigin type="module"></script>
|
||||
</head>
|
||||
<body
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
platform: "<%= platform %>",
|
||||
hasNativeTitleBar: <%= hasNativeTitleBar %>,
|
||||
TRILIUM_SAFE_MODE: <%= !!process.env.TRILIUM_SAFE_MODE %>,
|
||||
isRtl: <%= !!currentLocale.rtl %>,
|
||||
iconRegistry: <%- JSON.stringify(iconRegistry) %>
|
||||
isRtl: <%= !!currentLocale.rtl %>
|
||||
};
|
||||
</script>
|
||||
@@ -1,16 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
|
||||
import type { AttachmentRow } from "@triliumnext/commons";
|
||||
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import log from "../../services/log.js";
|
||||
import noteService from "../../services/notes.js";
|
||||
import protectedSessionService from "../../services/protected_session.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import AbstractBeccaEntity from "./abstract_becca_entity.js";
|
||||
import type BBranch from "./bbranch.js";
|
||||
import sql from "../../services/sql.js";
|
||||
import protectedSessionService from "../../services/protected_session.js";
|
||||
import log from "../../services/log.js";
|
||||
import type { AttachmentRow } from "@triliumnext/commons";
|
||||
import type BNote from "./bnote.js";
|
||||
import type BBranch from "./bbranch.js";
|
||||
import noteService from "../../services/notes.js";
|
||||
|
||||
const attachmentRoleToNoteTypeMapping = {
|
||||
image: "image",
|
||||
|
||||
@@ -1623,7 +1623,7 @@ class BNote extends AbstractBeccaEntity<BNote> {
|
||||
* @param matchBy - choose by which property we detect if to update an existing attachment.
|
||||
* Supported values are either 'attachmentId' (default) or 'title'
|
||||
*/
|
||||
saveAttachment({ attachmentId, role, mime, title, content, position }: Omit<AttachmentRow, "ownerId">, matchBy: "attachmentId" | "title" | undefined = "attachmentId") {
|
||||
saveAttachment({ attachmentId, role, mime, title, content, position }: AttachmentRow, matchBy: "attachmentId" | "title" | undefined = "attachmentId") {
|
||||
if (!["attachmentId", "title"].includes(matchBy)) {
|
||||
throw new Error(`Unsupported value '${matchBy}' for matchBy param, has to be either 'attachmentId' or 'title'.`);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import type { Request, Response } from "express";
|
||||
"use strict";
|
||||
|
||||
import packageJson from "../../package.json" with { type: "json" };
|
||||
import type BNote from "../becca/entities/bnote.js";
|
||||
import appPath from "../services/app_path.js";
|
||||
import assetPath from "../services/asset_path.js";
|
||||
import sql from "../services/sql.js";
|
||||
import attributeService from "../services/attributes.js";
|
||||
import config from "../services/config.js";
|
||||
import { getCurrentLocale } from "../services/i18n.js";
|
||||
import { generateCss, generateIconRegistry, getIconPacks } from "../services/icon_packs.js";
|
||||
import log from "../services/log.js";
|
||||
import optionService from "../services/options.js";
|
||||
import protectedSessionService from "../services/protected_session.js";
|
||||
import sql from "../services/sql.js";
|
||||
import log from "../services/log.js";
|
||||
import { isDev, isElectron, isWindows11 } from "../services/utils.js";
|
||||
import protectedSessionService from "../services/protected_session.js";
|
||||
import packageJson from "../../package.json" with { type: "json" };
|
||||
import assetPath from "../services/asset_path.js";
|
||||
import appPath from "../services/app_path.js";
|
||||
import { generateToken as generateCsrfToken } from "./csrf_protection.js";
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type BNote from "../becca/entities/bnote.js";
|
||||
import { getCurrentLocale } from "../services/i18n.js";
|
||||
|
||||
type View = "desktop" | "mobile" | "print";
|
||||
|
||||
function index(req: Request, res: Response) {
|
||||
@@ -34,11 +35,10 @@ function index(req: Request, res: Response) {
|
||||
const theme = options.theme;
|
||||
const themeNote = attributeService.getNoteWithLabel("appTheme", theme);
|
||||
const nativeTitleBarVisible = options.nativeTitleBarVisible === "true";
|
||||
const iconPacks = getIconPacks();
|
||||
|
||||
res.render(view, {
|
||||
device: view,
|
||||
csrfToken,
|
||||
csrfToken: csrfToken,
|
||||
themeCssUrl: getThemeCssUrl(theme, themeNote),
|
||||
themeUseNextAsBase: themeNote?.getAttributeValue("label", "appThemeBase"),
|
||||
headingStyle: options.headingStyle,
|
||||
@@ -61,12 +61,7 @@ function index(req: Request, res: Response) {
|
||||
assetPath,
|
||||
appPath,
|
||||
baseApiUrl: 'api/',
|
||||
currentLocale: getCurrentLocale(),
|
||||
iconPackCss: iconPacks
|
||||
.map(p => generateCss(p))
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
iconRegistry: generateIconRegistry(iconPacks)
|
||||
currentLocale: getCurrentLocale()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -123,9 +118,10 @@ function getThemeCssUrl(theme: string, themeNote: BNote | null) {
|
||||
return `${assetPath}/stylesheets/theme-next-dark.css`;
|
||||
} else if (!process.env.TRILIUM_SAFE_MODE && themeNote) {
|
||||
return `api/notes/download/${themeNote.noteId}`;
|
||||
} else {
|
||||
// baseline light theme
|
||||
return false;
|
||||
}
|
||||
// baseline light theme
|
||||
return false;
|
||||
}
|
||||
|
||||
function getAppCssNoteIds() {
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { buildNote } from "../test/becca_easy_mocking";
|
||||
import { determineBestFontAttachment, generateCss, generateIconRegistry, IconPackManifest, processIconPack } from "./icon_packs";
|
||||
|
||||
const manifest: IconPackManifest = {
|
||||
prefix: "bx",
|
||||
icons: {
|
||||
"bx-ball": {
|
||||
glyph: "\ue9c2",
|
||||
terms: [ "ball" ]
|
||||
},
|
||||
"bxs-party": {
|
||||
glyph: "\uec92",
|
||||
terms: [ "party" ]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const defaultAttachment = {
|
||||
role: "file",
|
||||
title: "Font",
|
||||
mime: "font/woff2"
|
||||
};
|
||||
|
||||
describe("Processing icon packs", () => {
|
||||
it("doesn't crash if icon pack is incorrect type", () => {
|
||||
const iconPack = processIconPack(buildNote({
|
||||
type: "text",
|
||||
content: "Foo"
|
||||
}));
|
||||
expect(iconPack).toBeFalsy();
|
||||
});
|
||||
|
||||
it("processes manifest", () => {
|
||||
const iconPack = processIconPack(buildNote({
|
||||
title: "Boxicons v2",
|
||||
type: "text",
|
||||
content: JSON.stringify(manifest),
|
||||
attachments: [ defaultAttachment ]
|
||||
}));
|
||||
expect(iconPack).toBeTruthy();
|
||||
expect(iconPack?.manifest).toMatchObject(manifest);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Mapping attachments", () => {
|
||||
it("handles woff2", () => {
|
||||
const iconPackNote = buildNote({
|
||||
type: "text",
|
||||
attachments: [ defaultAttachment ]
|
||||
});
|
||||
const attachment = determineBestFontAttachment(iconPackNote);
|
||||
expect(attachment?.mime).toStrictEqual("font/woff2");
|
||||
});
|
||||
|
||||
it("handles woff", () => {
|
||||
const iconPackNote = buildNote({
|
||||
type: "text",
|
||||
attachments: [
|
||||
{
|
||||
role: "file",
|
||||
title: "Font",
|
||||
mime: "font/woff"
|
||||
}
|
||||
]
|
||||
});
|
||||
const attachment = determineBestFontAttachment(iconPackNote);
|
||||
expect(attachment?.mime).toStrictEqual("font/woff");
|
||||
});
|
||||
|
||||
it("handles ttf", () => {
|
||||
const iconPackNote = buildNote({
|
||||
type: "text",
|
||||
attachments: [
|
||||
{
|
||||
role: "file",
|
||||
title: "Font",
|
||||
mime: "font/ttf"
|
||||
}
|
||||
]
|
||||
});
|
||||
const attachment = determineBestFontAttachment(iconPackNote);
|
||||
expect(attachment?.mime).toStrictEqual("font/ttf");
|
||||
});
|
||||
|
||||
it("prefers woff2", () => {
|
||||
const iconPackNote = buildNote({
|
||||
type: "text",
|
||||
attachments: [
|
||||
{
|
||||
role: "file",
|
||||
title: "Font",
|
||||
mime: "font/woff"
|
||||
},
|
||||
{
|
||||
role: "file",
|
||||
title: "Font",
|
||||
mime: "font/ttf"
|
||||
},
|
||||
{
|
||||
role: "file",
|
||||
title: "Font",
|
||||
mime: "font/woff2"
|
||||
}
|
||||
]
|
||||
});
|
||||
const attachment = determineBestFontAttachment(iconPackNote);
|
||||
expect(attachment?.mime).toStrictEqual("font/woff2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSS generation", () => {
|
||||
it("generates the CSS", () => {
|
||||
const manifest: IconPackManifest = {
|
||||
prefix: "bx",
|
||||
icons: {
|
||||
"bx-ball": {
|
||||
"glyph": "\ue9c2",
|
||||
"terms": [ "ball" ]
|
||||
},
|
||||
"bxs-party": {
|
||||
"glyph": "\uec92",
|
||||
"terms": [ "party" ]
|
||||
}
|
||||
}
|
||||
};
|
||||
const processedResult = processIconPack(buildNote({
|
||||
type: "text",
|
||||
title: "Boxicons v2",
|
||||
content: JSON.stringify(manifest),
|
||||
attachments: [
|
||||
{
|
||||
role: "file",
|
||||
title: "Font",
|
||||
mime: "font/woff2"
|
||||
}
|
||||
]
|
||||
}));
|
||||
expect(processedResult).toBeTruthy();
|
||||
const css = generateCss(processedResult!);
|
||||
|
||||
console.log(css);
|
||||
expect(css).toContain("@font-face");
|
||||
expect(css).toContain("font-family: 'trilium-icon-pack-bx'");
|
||||
expect(css).toContain(`src: url('/api/attachments/${processedResult?.fontAttachmentId}/download') format('woff2');`);
|
||||
|
||||
expect(css).toContain("@font-face");
|
||||
expect(css).toContain("font-family: 'trilium-icon-pack-bx' !important;");
|
||||
expect(css).toContain(".bx.bx-ball::before { content: '\\e9c2'; }");
|
||||
expect(css).toContain(".bx.bxs-party::before { content: '\\ec92'; }");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Icon registery", () => {
|
||||
it("generates the registry", () => {
|
||||
const iconPack = processIconPack(buildNote({
|
||||
title: "Boxicons v2",
|
||||
type: "text",
|
||||
content: JSON.stringify(manifest),
|
||||
attachments: [ defaultAttachment ]
|
||||
}));
|
||||
const registry = generateIconRegistry([ iconPack! ]);
|
||||
expect(registry.sources).toHaveLength(1);
|
||||
expect(registry.sources[0]).toMatchObject({
|
||||
name: "Boxicons v2",
|
||||
prefix: "bx",
|
||||
icons: [
|
||||
{
|
||||
id: "bx bx-ball",
|
||||
terms: [ "ball" ]
|
||||
},
|
||||
{
|
||||
id: "bx bxs-party",
|
||||
terms: [ "party" ]
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores incorrect manifest", () => {
|
||||
const iconPack = processIconPack(buildNote({
|
||||
type: "text",
|
||||
content: JSON.stringify({
|
||||
name: "Boxicons v2",
|
||||
prefix: "bx",
|
||||
icons: {
|
||||
"bx-ball": "\ue9c2",
|
||||
"bxs-party": "\uec92"
|
||||
}
|
||||
}),
|
||||
attachments: [ defaultAttachment ]
|
||||
}));
|
||||
const registry = generateIconRegistry([ iconPack! ]);
|
||||
expect(registry.sources).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
import { IconRegistry } from "@triliumnext/commons";
|
||||
|
||||
import type BAttachment from "../becca/entities/battachment";
|
||||
import type BNote from "../becca/entities/bnote";
|
||||
import log from "./log";
|
||||
import search from "./search/services/search";
|
||||
import { safeExtractMessageAndStackFromError } from "./utils";
|
||||
|
||||
const PREFERRED_MIME_TYPE = [
|
||||
"font/woff2",
|
||||
"font/woff",
|
||||
"font/ttf"
|
||||
] as const;
|
||||
|
||||
const MIME_TO_CSS_FORMAT_MAPPINGS: Record<typeof PREFERRED_MIME_TYPE[number], string> = {
|
||||
"font/ttf": "truetype",
|
||||
"font/woff": "woff",
|
||||
"font/woff2": "woff2"
|
||||
};
|
||||
|
||||
export interface IconPackManifest {
|
||||
prefix: string;
|
||||
icons: Record<string, {
|
||||
glyph: string,
|
||||
terms: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ProcessResult {
|
||||
manifest: IconPackManifest;
|
||||
fontMime: string;
|
||||
fontAttachmentId: string;
|
||||
manifestNote: BNote;
|
||||
}
|
||||
|
||||
export function getIconPacks() {
|
||||
return search.searchNotes("#iconPack")
|
||||
.map(iconPackNote => processIconPack(iconPackNote))
|
||||
.filter(Boolean) as ProcessResult[];
|
||||
}
|
||||
|
||||
export function generateIconRegistry(iconPacks: ProcessResult[]): IconRegistry {
|
||||
const sources: IconRegistry["sources"] = [];
|
||||
|
||||
for (const { manifest, manifestNote } of iconPacks) {
|
||||
const icons: IconRegistry["sources"][number]["icons"] = Object.entries(manifest.icons)
|
||||
.map(( [id, { terms }] ) => {
|
||||
if (!id || !terms) return null;
|
||||
return { id: `${manifest.prefix} ${id}`, terms };
|
||||
})
|
||||
.filter(Boolean) as IconRegistry["sources"][number]["icons"];
|
||||
if (!icons.length) continue;
|
||||
|
||||
sources.push({
|
||||
prefix: manifest.prefix,
|
||||
name: manifestNote.title,
|
||||
icon: manifestNote.getIcon(),
|
||||
icons
|
||||
});
|
||||
}
|
||||
|
||||
return { sources };
|
||||
}
|
||||
|
||||
export function processIconPack(iconPackNote: BNote): ProcessResult | undefined {
|
||||
const manifest = iconPackNote.getJsonContentSafely() as IconPackManifest;
|
||||
if (!manifest) {
|
||||
log.error(`Icon pack is missing JSON manifest (or has syntax errors): ${iconPackNote.title} (${iconPackNote.noteId})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const attachment = determineBestFontAttachment(iconPackNote);
|
||||
if (!attachment || !attachment.attachmentId) {
|
||||
log.error(`Icon pack is missing WOFF/WOFF2/TTF attachment: ${iconPackNote.title} (${iconPackNote.noteId})`);
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
fontMime: attachment.mime,
|
||||
fontAttachmentId: attachment.attachmentId,
|
||||
manifestNote: iconPackNote
|
||||
};
|
||||
}
|
||||
|
||||
export function determineBestFontAttachment(iconPackNote: BNote) {
|
||||
// Map all the attachments by their MIME.
|
||||
const mappings = new Map<string, BAttachment>();
|
||||
for (const attachment of iconPackNote.getAttachmentsByRole("file")) {
|
||||
mappings.set(attachment.mime, attachment);
|
||||
}
|
||||
|
||||
// Return the icon formats in order of preference.
|
||||
for (const preferredMimeType of PREFERRED_MIME_TYPE) {
|
||||
const correspondingAttachment = mappings.get(preferredMimeType);
|
||||
if (correspondingAttachment) return correspondingAttachment;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function generateCss({ manifest, fontAttachmentId, fontMime }: ProcessResult) {
|
||||
try {
|
||||
const iconDeclarations: string[] = [];
|
||||
for (const [ key, mapping ] of Object.entries(manifest.icons)) {
|
||||
iconDeclarations.push(`.${manifest.prefix}.${key}::before { content: '\\${mapping.glyph.charCodeAt(0).toString(16)}'; }`);
|
||||
}
|
||||
|
||||
return `\
|
||||
@font-face {
|
||||
font-family: 'trilium-icon-pack-${manifest.prefix}';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
src: url('/api/attachments/${fontAttachmentId}/download') format('${MIME_TO_CSS_FORMAT_MAPPINGS[fontMime]}');
|
||||
}
|
||||
|
||||
.${manifest.prefix} {
|
||||
font-family: 'trilium-icon-pack-${manifest.prefix}' !important;
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-variant: normal;
|
||||
line-height: 1;
|
||||
text-rendering: auto;
|
||||
display: inline-block;
|
||||
text-transform: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
${iconDeclarations.join("\n")}
|
||||
`;
|
||||
} catch (e) {
|
||||
log.error(safeExtractMessageAndStackFromError(e));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { NoteType } from "@triliumnext/commons";
|
||||
|
||||
import BAttachment from "../becca/entities/battachment.js";
|
||||
import BAttribute from "../becca/entities/battribute.js";
|
||||
import BBranch from "../becca/entities/bbranch.js";
|
||||
import BNote from "../becca/entities/bnote.js";
|
||||
import utils, { randomString } from "../services/utils.js";
|
||||
import utils from "../services/utils.js";
|
||||
|
||||
type AttributeDefinitions = { [key in `#${string}`]: string; };
|
||||
type RelationDefinitions = { [key in `~${string}`]: string; };
|
||||
@@ -16,11 +15,6 @@ interface NoteDefinition extends AttributeDefinitions, RelationDefinitions {
|
||||
type?: NoteType;
|
||||
mime?: string;
|
||||
children?: NoteDefinition[];
|
||||
attachments?: {
|
||||
title: string;
|
||||
role: string;
|
||||
mime: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,23 +106,5 @@ export function buildNote(noteDef: NoteDefinition) {
|
||||
|
||||
position++;
|
||||
}
|
||||
|
||||
// Handle attachments.
|
||||
if (noteDef.attachments) {
|
||||
const allAttachments: BAttachment[] = [];
|
||||
for (const { title, role, mime } of noteDef.attachments) {
|
||||
const attachment = new BAttachment({
|
||||
attachmentId: randomString(10),
|
||||
ownerId: note.noteId,
|
||||
title,
|
||||
role,
|
||||
mime
|
||||
});
|
||||
allAttachments.push(attachment);
|
||||
}
|
||||
|
||||
note.getAttachmentsByRole = (role) => allAttachments.filter(a => a.role === role);
|
||||
}
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
export interface AttachmentRow {
|
||||
attachmentId?: string;
|
||||
ownerId: string;
|
||||
ownerId?: string;
|
||||
role: string;
|
||||
mime: string;
|
||||
title: string;
|
||||
|
||||
@@ -285,16 +285,3 @@ export interface RenderMarkdownResponse {
|
||||
export interface ToMarkdownResponse {
|
||||
markdownContent: string;
|
||||
}
|
||||
|
||||
export interface IconRegistry {
|
||||
sources: {
|
||||
prefix: string;
|
||||
name: string;
|
||||
/** An icon class to identify this icon pack. */
|
||||
icon: string;
|
||||
icons: {
|
||||
id: string;
|
||||
terms: string[];
|
||||
}[]
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const inputDir = process.argv[2];
|
||||
if (!inputDir) {
|
||||
console.error('Please provide the input directory as the first argument.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const weight of [ "200", "400" ]) {
|
||||
const jsonPath = `${inputDir}/${weight}/boxicons.json`;
|
||||
const inputData = JSON.parse(readFileSync(jsonPath, "utf-8"));
|
||||
const icons = {};
|
||||
|
||||
for (const [ key, value ] of Object.entries(inputData)) {
|
||||
let name = key;
|
||||
if (name.startsWith('bx-')) {
|
||||
name = name.slice(3);
|
||||
}
|
||||
if (name.startsWith('bxs-')) {
|
||||
name = name.slice(4);
|
||||
}
|
||||
icons[key] = {
|
||||
glyph: String.fromCharCode(value as number),
|
||||
terms: [ name ]
|
||||
};
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
prefix: `bx3-${weight}`,
|
||||
icons
|
||||
};
|
||||
const outputPath = join(`${inputDir}/${weight}/generated-manifest.json`);
|
||||
writeFileSync(outputPath, JSON.stringify(manifest, null, 2));
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
function processIconPack(packName, prefix) {
|
||||
const path = join(packName);
|
||||
const selectionMeta = JSON.parse(readFileSync(join(path, "selection.json"), "utf-8"));
|
||||
const icons = {};
|
||||
|
||||
for (const icon of selectionMeta.icons) {
|
||||
let name = icon.properties.name;
|
||||
if (name.endsWith(`-${packName}`)) {
|
||||
name = name.split("-").slice(0, -1).join("-");
|
||||
}
|
||||
|
||||
const id = `ph-${name}`;
|
||||
icons[id] = {
|
||||
glyph: `${String.fromCharCode(icon.properties.code)}`,
|
||||
terms: [ name ]
|
||||
};
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
prefix,
|
||||
icons
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
console.log(processIconPack("light", "ph-light"));
|
||||
Reference in New Issue
Block a user