mirror of
https://github.com/zadam/trilium.git
synced 2025-12-27 02:30:03 +01:00
Compare commits
26 Commits
main
...
feature/ic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59b691d670 | ||
|
|
ecec661b72 | ||
|
|
fb629f7693 | ||
|
|
13fff33aa4 | ||
|
|
8053221b12 | ||
|
|
ba699f9842 | ||
|
|
eb5ebb53cb | ||
|
|
c26357be40 | ||
|
|
db4af96040 | ||
|
|
5cb3983fe0 | ||
|
|
92292de0ff | ||
|
|
a26923cc6d | ||
|
|
2c4ac4ba30 | ||
|
|
254511bfbf | ||
|
|
e2f6f8a4e4 | ||
|
|
e346963e76 | ||
|
|
5f1bdf7264 | ||
|
|
93a3b29677 | ||
|
|
b157cd909c | ||
|
|
2f24703690 | ||
|
|
27efa8844e | ||
|
|
98de4b6dc3 | ||
|
|
d121de5152 | ||
|
|
5ad7323d03 | ||
|
|
183020a4e3 | ||
|
|
a56a5fe1f5 |
@@ -765,9 +765,10 @@
|
||||
},
|
||||
"note_icon": {
|
||||
"change_note_icon": "Change note icon",
|
||||
"category": "Category:",
|
||||
"search": "Search:",
|
||||
"reset-default": "Reset to default icon"
|
||||
"reset-default": "Reset to default icon",
|
||||
"filter-none": "All icons",
|
||||
"filter-default": "Default icons"
|
||||
},
|
||||
"basic_properties": {
|
||||
"note_type": "Note type",
|
||||
|
||||
3
apps/client/src/types.d.ts
vendored
3
apps/client/src/types.d.ts
vendored
@@ -1,3 +1,5 @@
|
||||
import { IconRegistry } from "@triliumnext/commons";
|
||||
|
||||
import appContext, { AppContext } from "./components/app_context";
|
||||
import type FNote from "./entities/fnote";
|
||||
import type { PrintReport } from "./print";
|
||||
@@ -46,6 +48,7 @@ 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,17 +32,14 @@ div.note-icon-widget {
|
||||
}
|
||||
|
||||
.note-icon-widget .filter-row {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
padding-inline-end: 20px;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.note-icon-widget .filter-row span {
|
||||
display: block;
|
||||
padding-inline-start: 15px;
|
||||
padding-inline-end: 15px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -111,4 +108,4 @@ body.experimental-feature-new-layout {
|
||||
transition: background 200ms ease-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
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>;
|
||||
@@ -17,12 +21,10 @@ interface IconToCountCache {
|
||||
|
||||
interface IconData {
|
||||
iconToCount: Record<string, number>;
|
||||
categories: Category[];
|
||||
icons: Icon[];
|
||||
}
|
||||
|
||||
let fullIconData: {
|
||||
categories: Category[];
|
||||
icons: Icon[];
|
||||
};
|
||||
let iconToCountCache!: Promise<IconToCountCache> | null;
|
||||
@@ -45,17 +47,18 @@ 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() {
|
||||
@@ -64,12 +67,21 @@ function NoteIconList({ note }: { note: FNote }) {
|
||||
}
|
||||
|
||||
// Filter by text and/or category.
|
||||
let icons: Icon[] = fullIconData.icons;
|
||||
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()
|
||||
];
|
||||
const processedSearch = search?.trim()?.toLowerCase();
|
||||
if (processedSearch || categoryId) {
|
||||
if (processedSearch || filterByPrefix !== null) {
|
||||
icons = icons.filter((icon) => {
|
||||
if (categoryId !== "0" && String(icon.category_id) !== categoryId) {
|
||||
return false;
|
||||
if (filterByPrefix) {
|
||||
if (!icon.className?.startsWith(`${filterByPrefix} `)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (processedSearch) {
|
||||
@@ -96,25 +108,16 @@ function NoteIconList({ note }: { note: FNote }) {
|
||||
|
||||
setIconData({
|
||||
iconToCount,
|
||||
icons,
|
||||
categories: fullIconData.categories
|
||||
})
|
||||
icons
|
||||
});
|
||||
}
|
||||
|
||||
loadIcons();
|
||||
}, [ search, categoryId ]);
|
||||
}, [ search, filterByPrefix ]);
|
||||
|
||||
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}
|
||||
@@ -123,16 +126,24 @@ 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.classList.contains("bx")) {
|
||||
return;
|
||||
}
|
||||
if (clickedTarget.tagName !== "SPAN" || clickedTarget.classList.length !== 2) return;
|
||||
|
||||
const iconClass = Array.from(clickedTarget.classList.values()).join(" ");
|
||||
if (note) {
|
||||
@@ -158,13 +169,41 @@ function NoteIconList({ note }: { note: FNote }) {
|
||||
)}
|
||||
|
||||
{(iconData?.icons ?? []).map(({className, name}) => (
|
||||
<span class={`bx ${className}`} title={name} />
|
||||
<span class={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");
|
||||
@@ -180,5 +219,5 @@ function getIconLabels(note: FNote) {
|
||||
}
|
||||
return note.getOwnedLabels()
|
||||
.filter((label) => ["workspaceIconClass", "iconClass"]
|
||||
.includes(label.name));
|
||||
.includes(label.name));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<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,6 +19,7 @@
|
||||
platform: "<%= platform %>",
|
||||
hasNativeTitleBar: <%= hasNativeTitleBar %>,
|
||||
TRILIUM_SAFE_MODE: <%= !!process.env.TRILIUM_SAFE_MODE %>,
|
||||
isRtl: <%= !!currentLocale.rtl %>
|
||||
isRtl: <%= !!currentLocale.rtl %>,
|
||||
iconRegistry: <%- JSON.stringify(iconRegistry) %>
|
||||
};
|
||||
</script>
|
||||
@@ -1,15 +1,16 @@
|
||||
"use strict";
|
||||
|
||||
import utils from "../../services/utils.js";
|
||||
import dateUtils from "../../services/date_utils.js";
|
||||
import AbstractBeccaEntity from "./abstract_becca_entity.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 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 AbstractBeccaEntity from "./abstract_becca_entity.js";
|
||||
import type BBranch from "./bbranch.js";
|
||||
import type BNote from "./bnote.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 }: AttachmentRow, matchBy: "attachmentId" | "title" | undefined = "attachmentId") {
|
||||
saveAttachment({ attachmentId, role, mime, title, content, position }: Omit<AttachmentRow, "ownerId">, 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,19 @@
|
||||
"use strict";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
import sql from "../services/sql.js";
|
||||
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 attributeService from "../services/attributes.js";
|
||||
import config from "../services/config.js";
|
||||
import optionService from "../services/options.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";
|
||||
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 { isDev, isElectron, isWindows11 } from "../services/utils.js";
|
||||
import { generateToken as generateCsrfToken } from "./csrf_protection.js";
|
||||
|
||||
type View = "desktop" | "mobile" | "print";
|
||||
|
||||
@@ -35,10 +34,11 @@ 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,7 +61,12 @@ function index(req: Request, res: Response) {
|
||||
assetPath,
|
||||
appPath,
|
||||
baseApiUrl: 'api/',
|
||||
currentLocale: getCurrentLocale()
|
||||
currentLocale: getCurrentLocale(),
|
||||
iconPackCss: iconPacks
|
||||
.map(p => generateCss(p))
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
iconRegistry: generateIconRegistry(iconPacks)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,10 +123,9 @@ 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() {
|
||||
|
||||
195
apps/server/src/services/icon_packs.spec.ts
Normal file
195
apps/server/src/services/icon_packs.spec.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
136
apps/server/src/services/icon_packs.ts
Normal file
136
apps/server/src/services/icon_packs.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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,9 +1,10 @@
|
||||
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 from "../services/utils.js";
|
||||
import utils, { randomString } from "../services/utils.js";
|
||||
|
||||
type AttributeDefinitions = { [key in `#${string}`]: string; };
|
||||
type RelationDefinitions = { [key in `~${string}`]: string; };
|
||||
@@ -15,6 +16,11 @@ interface NoteDefinition extends AttributeDefinitions, RelationDefinitions {
|
||||
type?: NoteType;
|
||||
mime?: string;
|
||||
children?: NoteDefinition[];
|
||||
attachments?: {
|
||||
title: string;
|
||||
role: string;
|
||||
mime: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,5 +112,23 @@ 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,3 +285,16 @@ 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[];
|
||||
}[]
|
||||
}[];
|
||||
}
|
||||
|
||||
35
scripts/icon-packs/boxicons-v3.ts
Normal file
35
scripts/icon-packs/boxicons-v3.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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));
|
||||
}
|
||||
28
scripts/icon-packs/phosphor.js
Normal file
28
scripts/icon-packs/phosphor.js
Normal file
@@ -0,0 +1,28 @@
|
||||
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