Compare commits

...

26 Commits

Author SHA1 Message Date
Elian Doran
59b691d670 chore(scripts): process boxicons v3 icons 2025-12-27 00:26:33 +02:00
Elian Doran
ecec661b72 chore(scripts): add icon to process phosphor meta 2025-12-26 22:43:03 +02:00
Elian Doran
fb629f7693 feat(note_icon): display note pack icon 2025-12-26 21:14:13 +02:00
Elian Doran
13fff33aa4 feat(icon_packs): use note title isntead of manifest 2025-12-26 21:08:37 +02:00
Elian Doran
8053221b12 chore(note_icon): hide filter if no custom icon packs 2025-12-26 21:01:48 +02:00
Elian Doran
ba699f9842 refactor(note_icon): split filter content into a component 2025-12-26 21:01:19 +02:00
Elian Doran
eb5ebb53cb fix(icon_list): border-right icon missing 2025-12-26 20:57:10 +02:00
Elian Doran
c26357be40 feat(note_icon): allow filtering default icons 2025-12-26 20:49:20 +02:00
Elian Doran
db4af96040 feat(note_icon): filter by icon pack 2025-12-26 20:42:19 +02:00
Elian Doran
5cb3983fe0 chore(note_icon): get rid of categories 2025-12-26 20:03:34 +02:00
Elian Doran
92292de0ff chore(client): basic integration of icon packs in icon selector 2025-12-26 19:52:54 +02:00
Elian Doran
a26923cc6d fix(icon_pack): listing definitions even if parsing fails 2025-12-26 19:42:23 +02:00
Elian Doran
2c4ac4ba30 fix(server): crashing due to bad icon pack 2025-12-26 19:37:10 +02:00
Elian Doran
254511bfbf chore(icon_pack): switch schema to support multiple terms per icon 2025-12-26 19:25:31 +02:00
Elian Doran
e2f6f8a4e4 feat(icon_pack): generate icon registry for client 2025-12-26 19:10:28 +02:00
Elian Doran
e346963e76 feat(icon_pack): inject the icon pack into the client 2025-12-26 18:36:36 +02:00
Elian Doran
5f1bdf7264 chore(icon_pack): generate icon declarations 2025-12-26 18:16:33 +02:00
Elian Doran
93a3b29677 chore(icon_pack): generate root declaration 2025-12-26 18:08:26 +02:00
Elian Doran
b157cd909c chore(icon_pack): generate src declaration 2025-12-26 18:04:39 +02:00
Elian Doran
2f24703690 chore(icon_pack): generate font face declaration without source 2025-12-26 17:42:44 +02:00
Elian Doran
27efa8844e refactor(server): mark ownerId in AttachmentRow as mandatory 2025-12-26 17:32:28 +02:00
Elian Doran
98de4b6dc3 chore(icon_pack): map ttf 2025-12-26 17:31:35 +02:00
Elian Doran
d121de5152 chore(icon_pack): map woff attachment 2025-12-26 17:30:19 +02:00
Elian Doran
5ad7323d03 chore(icon_pack): map woff2 attachment 2025-12-26 17:28:57 +02:00
Elian Doran
183020a4e3 chore(icon_pack): return icon mappings 2025-12-26 16:04:56 +02:00
Elian Doran
a56a5fe1f5 feat(icon_pack): check if JSON is parsable 2025-12-26 16:00:21 +02:00
17 changed files with 558 additions and 1853 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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;
}
}
}
}

View File

@@ -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));
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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",

View File

@@ -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'.`);
}

View File

@@ -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() {

View 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);
});
});

View 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;
}
}

View File

@@ -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;
}

View File

@@ -3,7 +3,7 @@
export interface AttachmentRow {
attachmentId?: string;
ownerId?: string;
ownerId: string;
role: string;
mime: string;
title: string;

View File

@@ -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[];
}[]
}[];
}

View 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));
}

View 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"));