Compare commits

..

2 Commits

Author SHA1 Message Date
Elian Doran
54c8322960 chore(react/promoted_attributes): multiplicity 2025-11-13 08:17:32 +02:00
Elian Doran
3d0d1fa36e chore(react/promoted_attributes): basic structures 2025-11-12 10:21:28 +02:00
35 changed files with 687 additions and 820 deletions

2
.nvmrc
View File

@@ -1 +1 @@
24.11.1 24.11.0

View File

@@ -38,7 +38,7 @@
"@playwright/test": "1.56.1", "@playwright/test": "1.56.1",
"@stylistic/eslint-plugin": "5.5.0", "@stylistic/eslint-plugin": "5.5.0",
"@types/express": "5.0.5", "@types/express": "5.0.5",
"@types/node": "24.10.1", "@types/node": "24.10.0",
"@types/yargs": "17.0.34", "@types/yargs": "17.0.34",
"@vitest/coverage-v8": "3.2.4", "@vitest/coverage-v8": "3.2.4",
"eslint": "9.39.1", "eslint": "9.39.1",

View File

@@ -9,7 +9,7 @@
"keywords": [], "keywords": [],
"author": "Elian Doran <contact@eliandoran.me>", "author": "Elian Doran <contact@eliandoran.me>",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"packageManager": "pnpm@10.22.0", "packageManager": "pnpm@10.21.0",
"devDependencies": { "devDependencies": {
"@redocly/cli": "2.11.1", "@redocly/cli": "2.11.1",
"archiver": "7.0.1", "archiver": "7.0.1",

View File

@@ -59,7 +59,7 @@
"normalize.css": "8.0.1", "normalize.css": "8.0.1",
"panzoom": "9.4.3", "panzoom": "9.4.3",
"preact": "10.27.2", "preact": "10.27.2",
"react-i18next": "16.3.1", "react-i18next": "16.2.4",
"reveal.js": "5.2.1", "reveal.js": "5.2.1",
"svg-pan-zoom": "3.6.2", "svg-pan-zoom": "3.6.2",
"tabulator-tables": "6.3.1", "tabulator-tables": "6.3.1",

View File

@@ -6,7 +6,6 @@ import type { Froca } from "../services/froca-interface.js";
import type FAttachment from "./fattachment.js"; import type FAttachment from "./fattachment.js";
import type { default as FAttribute, AttributeType } from "./fattribute.js"; import type { default as FAttribute, AttributeType } from "./fattribute.js";
import utils from "../services/utils.js"; import utils from "../services/utils.js";
import search from "../services/search.js";
const LABEL = "label"; const LABEL = "label";
const RELATION = "relation"; const RELATION = "relation";
@@ -256,21 +255,6 @@ export default class FNote {
return this.children; return this.children;
} }
async getChildNoteIdsWithArchiveFiltering(includeArchived = false) {
if (!includeArchived) {
const unorderedIds = new Set(await search.searchForNoteIds(`note.parents.noteId="${this.noteId}" #!archived`));
const results: string[] = [];
for (const id of this.children) {
if (unorderedIds.has(id)) {
results.push(id);
}
}
return results;
} else {
return this.children;
}
}
async getSubtreeNoteIds(includeArchived = false) { async getSubtreeNoteIds(includeArchived = false) {
let noteIds: (string | string[])[] = []; let noteIds: (string | string[])[] = [];
for (const child of await this.getChildNotes()) { for (const child of await this.getChildNotes()) {
@@ -855,7 +839,8 @@ export default class FNote {
return []; return [];
} }
const promotedAttrs = this.getAttributeDefinitions() const promotedAttrs = this.getAttributes()
.filter((attr) => attr.isDefinition())
.filter((attr) => { .filter((attr) => {
const def = attr.getDefinition(); const def = attr.getDefinition();
@@ -875,11 +860,6 @@ export default class FNote {
return promotedAttrs; return promotedAttrs;
} }
getAttributeDefinitions() {
return this.getAttributes()
.filter((attr) => attr.isDefinition());
}
hasAncestor(ancestorNoteId: string, followTemplates = false, visitedNoteIds: Set<string> | null = null) { hasAncestor(ancestorNoteId: string, followTemplates = false, visitedNoteIds: Set<string> | null = null) {
if (this.noteId === ancestorNoteId) { if (this.noteId === ancestorNoteId) {
return true; return true;

View File

@@ -3,7 +3,7 @@ import { DESKTOP_FLOATING_BUTTONS } from "../widgets/FloatingButtonsDefinitions.
import ApiLog from "../widgets/api_log.jsx"; import ApiLog from "../widgets/api_log.jsx";
import ClosePaneButton from "../widgets/buttons/close_pane_button.js"; import ClosePaneButton from "../widgets/buttons/close_pane_button.js";
import CloseZenModeButton from "../widgets/close_zen_button.jsx"; import CloseZenModeButton from "../widgets/close_zen_button.jsx";
import ContentHeader from "../widgets/containers/content_header.js"; import ContentHeader from "../widgets/containers/content-header.js";
import CreatePaneButton from "../widgets/buttons/create_pane_button.js"; import CreatePaneButton from "../widgets/buttons/create_pane_button.js";
import FindWidget from "../widgets/find.js"; import FindWidget from "../widgets/find.js";
import FlexContainer from "../widgets/containers/flex_container.js"; import FlexContainer from "../widgets/containers/flex_container.js";
@@ -21,7 +21,6 @@ import NoteTreeWidget from "../widgets/note_tree.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js"; import NoteWrapperWidget from "../widgets/note_wrapper.js";
import options from "../services/options.js"; import options from "../services/options.js";
import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js"; import PasswordNoteSetDialog from "../widgets/dialogs/password_not_set.js";
import PromotedAttributesWidget from "../widgets/promoted_attributes.js";
import QuickSearchWidget from "../widgets/quick_search.js"; import QuickSearchWidget from "../widgets/quick_search.js";
import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx"; import ReadOnlyNoteInfoBar from "../widgets/ReadOnlyNoteInfoBar.jsx";
import Ribbon from "../widgets/ribbon/Ribbon.jsx"; import Ribbon from "../widgets/ribbon/Ribbon.jsx";
@@ -44,6 +43,7 @@ import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
import utils from "../services/utils.js"; import utils from "../services/utils.js";
import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js"; import WatchedFileUpdateStatusWidget from "../widgets/watched_file_update_status.js";
import NoteDetail from "../widgets/NoteDetail.jsx"; import NoteDetail from "../widgets/NoteDetail.jsx";
import PromotedAttributes from "../widgets/PromotedAttributes.jsx";
export default class DesktopLayout { export default class DesktopLayout {
@@ -140,7 +140,7 @@ export default class DesktopLayout {
.child(<ReadOnlyNoteInfoBar />) .child(<ReadOnlyNoteInfoBar />)
.child(<SharedInfo />) .child(<SharedInfo />)
) )
.child(new PromotedAttributesWidget()) .child(<PromotedAttributes />)
.child(<SqlTableSchemas />) .child(<SqlTableSchemas />)
.child(<NoteDetail />) .child(<NoteDetail />)
.child(<NoteList media="screen" />) .child(<NoteList media="screen" />)

View File

@@ -10,7 +10,7 @@ import LauncherContainer from "../widgets/containers/launcher_container.js";
import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js"; import MobileDetailMenu from "../widgets/mobile_widgets/mobile_detail_menu.js";
import NoteList from "../widgets/collections/NoteList.jsx"; import NoteList from "../widgets/collections/NoteList.jsx";
import NoteTitleWidget from "../widgets/note_title.js"; import NoteTitleWidget from "../widgets/note_title.js";
import ContentHeader from "../widgets/containers/content_header.js"; import ContentHeader from "../widgets/containers/content-header.js";
import NoteTreeWidget from "../widgets/note_tree.js"; import NoteTreeWidget from "../widgets/note_tree.js";
import NoteWrapperWidget from "../widgets/note_wrapper.js"; import NoteWrapperWidget from "../widgets/note_wrapper.js";
import PromotedAttributesWidget from "../widgets/promoted_attributes.js"; import PromotedAttributesWidget from "../widgets/promoted_attributes.js";

View File

@@ -90,8 +90,7 @@ const HIDDEN_ATTRIBUTES = [
"viewType", "viewType",
"geolocation", "geolocation",
"docName", "docName",
"webViewSrc", "webViewSrc"
"archived"
]; ];
async function renderNormalAttributes(note: FNote) { async function renderNormalAttributes(note: FNote) {

View File

@@ -76,11 +76,6 @@ function getHue(color: ColorInstance) {
} }
} }
export function getReadableTextColor(bgColor: string) {
const colorInstance = Color(bgColor);
return colorInstance.isLight() ? "#000" : "#fff";
}
export default { export default {
createClassForColor createClassForColor
}; };

View File

@@ -0,0 +1,91 @@
body.mobile .promoted-attributes-widget {
/* https://github.com/zadam/trilium/issues/4468 */
flex-shrink: 0.4;
overflow: auto;
}
.component.promoted-attributes-widget {
contain: none;
}
.promoted-attributes-container {
margin: 0 1.5em;
overflow: auto;
max-height: 400px;
flex-wrap: wrap;
display: table;
}
.promoted-attribute-cell {
display: flex;
align-items: center;
margin: 10px;
display: table-row;
}
.promoted-attribute-cell > label {
user-select: none;
font-weight: bold;
vertical-align: middle;
}
.promoted-attribute-cell > * {
display: table-cell;
padding: 1px 0;
}
.promoted-attribute-cell div.input-group {
margin-inline-start: 10px;
display: flex;
min-height: 40px;
}
.promoted-attribute-cell strong {
word-break:keep-all;
white-space: nowrap;
}
.promoted-attribute-cell input[type="checkbox"] {
width: 22px !important;
flex-grow: 0;
width: unset;
}
/* Restore default apperance */
.promoted-attribute-cell input[type="number"],
.promoted-attribute-cell input[type="checkbox"] {
appearance: auto;
}
.promoted-attribute-cell input[type="color"] {
width: 24px;
height: 24px;
margin-top: 2px;
appearance: none;
padding: 0;
border: 0;
outline: none;
border-radius: 25% !important;
}
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 25%;
}
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"] {
position: relative;
opacity: 0.5;
}
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"]:after {
content: "";
position: absolute;
top: 10px;
inset-inline-start: 0px;
inset-inline-end: 0;
height: 2px;
background: rgba(0, 0, 0, 0.5);
transform: rotate(45deg);
pointer-events: none;
}

View File

@@ -0,0 +1,120 @@
import { useEffect, useState } from "preact/hooks";
import "./PromotedAttributes.css";
import { useNoteContext, useNoteLabel } from "./react/hooks";
import { Attribute } from "../services/attribute_parser";
import { ComponentChild } from "preact";
import FAttribute from "../entities/fattribute";
import { t } from "../services/i18n";
import ActionButton from "./react/ActionButton";
export default function PromotedAttributes() {
const { note } = useNoteContext();
const [ promotedAttributes, setPromotedAttributes ] = useState<ComponentChild[]>();
const [ viewType ] = useNoteLabel(note, "viewType");
useEffect(() => {
if (!note) {
setPromotedAttributes([]);
return;
}
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
const ownedAttributes = note.getOwnedAttributes();
// attrs are not resorted if position changes after the initial load
// promoted attrs are sorted primarily by order of definitions, but with multi-valued promoted attrs
// the order of attributes is important as well
ownedAttributes.sort((a, b) => a.position - b.position);
let promotedAttributes: ComponentChild[] = [];
for (const definitionAttr of promotedDefAttrs) {
const valueType = definitionAttr.name.startsWith("label:") ? "label" : "relation";
const valueName = definitionAttr.name.substr(valueType.length + 1);
let valueAttrs = ownedAttributes.filter((el) => el.name === valueName && el.type === valueType) as Attribute[];
if (valueAttrs.length === 0) {
valueAttrs.push({
attributeId: "",
type: valueType,
name: valueName,
value: ""
});
}
if (definitionAttr.getDefinition().multiplicity === "single") {
valueAttrs = valueAttrs.slice(0, 1);
}
for (const valueAttr of valueAttrs) {
promotedAttributes.push(<PromotedAttributeCell
noteId={note.noteId}
definitionAttr={definitionAttr}
valueAttr={valueAttr} valueName={valueName} />)
}
}
setPromotedAttributes(promotedAttributes);
console.log("Got ", promotedAttributes);
}, [ note ]);
return (
<div className="promoted-attributes-widget">
{viewType !== "table" && (
<div className="promoted-attributes-container">
{promotedAttributes}
</div>
)}
</div>
);
}
function PromotedAttributeCell({ noteId, definitionAttr, valueAttr, valueName }: {
noteId: string;
definitionAttr: FAttribute;
valueAttr: Attribute;
valueName: string;
}) {
const definition = definitionAttr.getDefinition();
const id = `value-${valueAttr.attributeId}`;
return (
<div className="promoted-attribute-cell">
<label
for={id}
>{definition.promotedAlias ?? valueName}</label>
<div className="input-group">
<input
className="form-control promoted-attribute-input"
tabindex={200 + definitionAttr.position}
id={id}
// if not owned, we'll force creation of a new attribute instead of updating the inherited one
data-attribute-id={valueAttr.noteId === noteId ? valueAttr.attributeId ?? "" : ""}
data-attribute-type={valueAttr.type}
data-attribute-name={valueAttr.name}
value={valueAttr.value}
placeholder={t("promoted_attributes.unset-field-placeholder")}
/>
</div>
<div />
{definition.multiplicity === "multi" && (
<td className="multiplicity">
<ActionButton
icon="bx bx-plus"
className="pointer tn-tool-button"
text={t("promoted_attributes.add_new_attribute")}
noIconActionClass
/>
<ActionButton
icon="bx bx-trash"
className="pointer tn-tool-button"
text={t("promoted_attributes.remove_this_attribute")}
noIconActionClass
/>
</td>
)}
</div>
)
}

View File

@@ -1,31 +0,0 @@
.promoted-attributes {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-top: 8px;
}
.promoted-attributes .promoted-attribute {
padding: 2px 10px;
border-radius: 9999px;
white-space: nowrap;
background-color: var(--chip-bg, rgba(0, 0, 0, 0.08));
color: var(--chip-fg, inherit);
border: 1px solid var(--chip-border, rgba(0, 0, 0, 0.15));
font-size: 12px;
line-height: 1.2;
}
.promoted-attributes .promoted-attribute:hover {
background-color: var(--chip-bg-hover, rgba(0, 0, 0, 0.12));
border-color: var(--chip-border-hover, rgba(0, 0, 0, 0.22));
}
.promoted-attributes .promoted-attribute .name {
font-weight: 600;
}
.promoted-attributes .promoted-attribute .value {
opacity: 0.9;
}

View File

@@ -1,134 +0,0 @@
import { useState } from "preact/hooks";
import FNote from "../../entities/fnote";
import "./PromotedAttributesDisplay.css";
import { useTriliumEvent } from "../react/hooks";
import attributes from "../../services/attributes";
import { DefinitionObject } from "../../services/promoted_attribute_definition_parser";
import { formatDateTime } from "../../utils/formatters";
import { ComponentChild, ComponentChildren, CSSProperties } from "preact";
import Icon from "../react/Icon";
import NoteLink from "../react/NoteLink";
import { getReadableTextColor } from "../../services/css_class_manager";
interface PromotedAttributesDisplayProps {
note: FNote;
ignoredAttributes?: string[];
}
interface AttributeWithDefinitions {
friendlyName: string;
name: string;
type: string;
value: string;
def: DefinitionObject;
}
export default function PromotedAttributesDisplay({ note, ignoredAttributes }: PromotedAttributesDisplayProps) {
const promotedDefinitionAttributes = useNoteAttributesWithDefinitions(note, ignoredAttributes);
return promotedDefinitionAttributes?.length > 0 && (
<div className="promoted-attributes">
{promotedDefinitionAttributes?.map(attr => buildPromotedAttribute(attr))}
</div>
)
}
function useNoteAttributesWithDefinitions(note: FNote, attributesToIgnore: string[] = []): AttributeWithDefinitions[] {
const [ promotedDefinitionAttributes, setPromotedDefinitionAttributes ] = useState<AttributeWithDefinitions[]>(getAttributesWithDefinitions(note, attributesToIgnore));
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
if (loadResults.getAttributeRows().some(attr => attributes.isAffecting(attr, note))) {
setPromotedDefinitionAttributes(getAttributesWithDefinitions(note, attributesToIgnore));
}
});
return promotedDefinitionAttributes;
}
function PromotedAttribute({ attr, children, style }: { attr: AttributeWithDefinitions, children: ComponentChildren, style?: CSSProperties }) {
const className = `${attr.type === "label" ? "label" + " " + attr.def.labelType : "relation"}`;
return (
<span key={attr.friendlyName} className={`promoted-attribute type-${className}`} style={style}>
{children}
</span>
)
}
function buildPromotedAttribute(attr: AttributeWithDefinitions): ComponentChildren {
const defaultLabel = <><strong>{attr.friendlyName}:</strong>{" "}</>;
let content: ComponentChildren;
let style: CSSProperties | undefined;
if (attr.type === "label") {
let value = attr.value;
switch (attr.def.labelType) {
case "number":
let formattedValue = value;
const numberValue = Number(value);
if (!Number.isNaN(numberValue) && attr.def.numberPrecision) formattedValue = numberValue.toFixed(attr.def.numberPrecision);
content = <>{defaultLabel}{formattedValue}</>;
break;
case "date":
case "datetime": {
const date = new Date(value);
const timeFormat = attr.def.labelType !== "date" ? "short" : "none";
const formattedValue = formatDateTime(date, "short", timeFormat);
content = <>{defaultLabel}{formattedValue}</>;
break;
}
case "time": {
const date = new Date(`1970-01-01T${value}Z`);
const formattedValue = formatDateTime(date, "none", "short");
content = <>{defaultLabel}{formattedValue}</>;
break;
}
case "boolean":
content = <><Icon icon={value === "true" ? "bx bx-check-square" : "bx bx-square"} />{" "}<strong>{attr.friendlyName}</strong></>;
break;
case "url":
content = <a href={value} target="_blank" rel="noopener noreferrer">{attr.friendlyName}</a>;
break;
case "color":
style = { backgroundColor: value, color: getReadableTextColor(value) };
content = <>{attr.friendlyName}</>;
break;
case "text":
default:
content = <>{defaultLabel}{value}</>;
break;
}
} else if (attr.type === "relation") {
content = <>{defaultLabel}<NoteLink notePath={attr.value} showNoteIcon /></>;
}
return <PromotedAttribute attr={attr} style={style}>{content}</PromotedAttribute>
}
function getAttributesWithDefinitions(note: FNote, attributesToIgnore: string[] = []): AttributeWithDefinitions[] {
const promotedDefinitionAttributes = note.getAttributeDefinitions();
const result: AttributeWithDefinitions[] = [];
for (const attr of promotedDefinitionAttributes) {
const def = attr.getDefinition();
const [ type, name ] = attr.name.split(":", 2);
const friendlyName = def?.promotedAlias || name;
const props: Omit<AttributeWithDefinitions, "value"> = { def, name, type, friendlyName };
if (attributesToIgnore.includes(name)) continue;
if (type === "label") {
const labels = note.getLabels(name);
for (const label of labels) {
if (!label.value) continue;
result.push({ ...props, value: label.value } );
}
} else if (type === "relation") {
const relations = note.getRelations(name);
for (const relation of relations) {
if (!relation.value) continue;
result.push({ ...props, value: relation.value } );
}
}
}
return result;
}

View File

@@ -141,7 +141,7 @@ export function useNoteIds(note: FNote | null | undefined, viewType: ViewTypeOpt
async function getNoteIds(note: FNote) { async function getNoteIds(note: FNote) {
if (viewType === "list" || viewType === "grid" || viewType === "table" || note.type === "search") { if (viewType === "list" || viewType === "grid" || viewType === "table" || note.type === "search") {
return await note.getChildNoteIdsWithArchiveFiltering(includeArchived); return note.getChildNoteIds();
} else { } else {
return await note.getSubtreeNoteIds(includeArchived); return await note.getSubtreeNoteIds(includeArchived);
} }

View File

@@ -16,7 +16,7 @@ export default class BoardApi {
private byColumn: ColumnMap | undefined, private byColumn: ColumnMap | undefined,
public columns: string[], public columns: string[],
private parentNote: FNote, private parentNote: FNote,
readonly statusAttribute: string, private statusAttribute: string,
private viewConfig: BoardViewData, private viewConfig: BoardViewData,
private saveConfig: (newConfig: BoardViewData) => void, private saveConfig: (newConfig: BoardViewData) => void,
private setBranchIdToEdit: (branchId: string | undefined) => void private setBranchIdToEdit: (branchId: string | undefined) => void

View File

@@ -6,7 +6,6 @@ import { BoardViewContext, TitleEditor } from ".";
import { ContextMenuEvent } from "../../../menus/context_menu"; import { ContextMenuEvent } from "../../../menus/context_menu";
import { openNoteContextMenu } from "./context_menu"; import { openNoteContextMenu } from "./context_menu";
import { t } from "../../../services/i18n"; import { t } from "../../../services/i18n";
import PromotedAttributesDisplay from "../../attribute_widgets/PromotedAttributesDisplay";
export const CARD_CLIPBOARD_TYPE = "trilium/board-card"; export const CARD_CLIPBOARD_TYPE = "trilium/board-card";
@@ -109,7 +108,6 @@ export default function Card({
title={t("board_view.edit-note-title")} title={t("board_view.edit-note-title")}
onClick={handleEdit} onClick={handleEdit}
/> />
<PromotedAttributesDisplay note={note} ignoredAttributes={[api.statusAttribute]} />
</> </>
) : ( ) : (
<TitleEditor <TitleEditor

View File

@@ -104,8 +104,6 @@ export default function Column({
{!isEditing ? ( {!isEditing ? (
<> <>
<span className="title">{column}</span> <span className="title">{column}</span>
<span className="counter-badge">{columnItems?.length ?? 0}</span>
<div className="spacer" />
<span <span
className="edit-icon icon bx bx-edit-alt" className="edit-icon icon bx bx-edit-alt"
title={t("board_view.edit-column-title")} title={t("board_view.edit-column-title")}

View File

@@ -53,16 +53,7 @@
align-items: center; align-items: center;
} }
.board-view-container .board-column h3 .counter-badge { .board-view-container .board-column h3 > .title {
background-color: var(--muted-text-color);
color: var(--main-background-color);
border-radius: 12px;
padding: 0.1em 0.6em;
font-size: 0.75em;
margin-inline-start: 0.5em;
}
.board-view-container .board-column h3 > .spacer {
flex-grow: 1; flex-grow: 1;
} }

View File

@@ -16,10 +16,6 @@
flex-grow: 1; flex-grow: 1;
} }
.note-book-card.archived {
opacity: 0.5;
}
.note-book-card:not(.expanded) .note-book-content { .note-book-card:not(.expanded) .note-book-content {
padding: 10px padding: 10px
} }

View File

@@ -64,7 +64,7 @@ function ListNoteCard({ note, parentNote, expand, highlightedTokens }: { note: F
return ( return (
<div <div
className={`note-book-card no-tooltip-preview ${isExpanded ? "expanded" : ""} ${note.isArchived ? "archived" : ""}`} className={`note-book-card no-tooltip-preview ${isExpanded ? "expanded" : ""}`}
data-note-id={note.noteId} data-note-id={note.noteId}
> >
<h5 className="note-book-header"> <h5 className="note-book-header">
@@ -100,7 +100,7 @@ function GridNoteCard({ note, parentNote, highlightedTokens }: { note: FNote, pa
return ( return (
<div <div
className={`note-book-card no-tooltip-preview block-link ${note.isArchived ? "archived" : ""}`} className={`note-book-card no-tooltip-preview block-link`}
data-href={`#${notePath}`} data-href={`#${notePath}`}
data-note-id={note.noteId} data-note-id={note.noteId}
onClick={(e) => link.goToLink(e)} onClick={(e) => link.goToLink(e)}

View File

@@ -15,7 +15,6 @@ export default class ContentHeader extends Container<BasicWidget> {
constructor() { constructor() {
super(); super();
this.class("content-header-widget");
this.css("contain", "unset"); this.css("contain", "unset");
this.resizeObserver = new ResizeObserver(this.onResize.bind(this)); this.resizeObserver = new ResizeObserver(this.onResize.bind(this));
} }

View File

@@ -12,102 +12,6 @@ import type { Attribute } from "../services/attribute_parser.js";
import type FAttribute from "../entities/fattribute.js"; import type FAttribute from "../entities/fattribute.js";
import type { EventData } from "../components/app_context.js"; import type { EventData } from "../components/app_context.js";
const TPL = /*html*/`
<div class="promoted-attributes-widget">
<style>
body.mobile .promoted-attributes-widget {
/* https://github.com/zadam/trilium/issues/4468 */
flex-shrink: 0.4;
overflow: auto;
}
.promoted-attributes-container {
margin: 0 1.5em;
overflow: auto;
max-height: 400px;
flex-wrap: wrap;
display: table;
}
.promoted-attribute-cell {
display: flex;
align-items: center;
margin: 10px;
display: table-row;
}
.promoted-attribute-cell > label {
user-select: none;
font-weight: bold;
vertical-align: middle;
}
.promoted-attribute-cell > * {
display: table-cell;
padding: 1px 0;
}
.promoted-attribute-cell div.input-group {
margin-inline-start: 10px;
display: flex;
min-height: 40px;
}
.promoted-attribute-cell strong {
word-break:keep-all;
white-space: nowrap;
}
.promoted-attribute-cell input[type="checkbox"] {
width: 22px !important;
flex-grow: 0;
width: unset;
}
/* Restore default apperance */
.promoted-attribute-cell input[type="number"],
.promoted-attribute-cell input[type="checkbox"] {
appearance: auto;
}
.promoted-attribute-cell input[type="color"] {
width: 24px;
height: 24px;
margin-top: 2px;
appearance: none;
padding: 0;
border: 0;
outline: none;
border-radius: 25% !important;
}
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch-wrapper {
padding: 0;
}
.promoted-attribute-cell input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 25%;
}
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"] {
position: relative;
opacity: 0.5;
}
.promoted-attribute-label-color input[type="hidden"][value=""] + input[type="color"]:after {
content: "";
position: absolute;
top: 10px;
inset-inline-start: 0px;
inset-inline-end: 0;
height: 2px;
background: rgba(0, 0, 0, 0.5);
transform: rotate(45deg);
pointer-events: none;
}
</style>
<div class="promoted-attributes-container"></div>
</div>`;
// TODO: Deduplicate // TODO: Deduplicate
interface AttributeResult { interface AttributeResult {
attributeId: string; attributeId: string;
@@ -117,115 +21,17 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget {
private $container!: JQuery<HTMLElement>; private $container!: JQuery<HTMLElement>;
get name() {
return "promotedAttributes";
}
get toggleCommand() {
return "toggleRibbonTabPromotedAttributes";
}
doRender() { doRender() {
this.$widget = $(TPL); this.$widget = $("");
this.contentSized(); this.contentSized();
this.$container = this.$widget.find(".promoted-attributes-container"); this.$container = this.$widget.find(".promoted-attributes-container");
} }
getTitle(note: FNote) {
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
if (promotedDefAttrs.length === 0) {
return { show: false };
}
return {
show: true,
activate: options.is("promotedAttributesOpenInRibbon"),
title: t("promoted_attributes.promoted_attributes"),
icon: "bx bx-table"
};
}
async refreshWithNote(note: FNote) {
this.$container.empty();
const promotedDefAttrs = note.getPromotedDefinitionAttributes();
const ownedAttributes = note.getOwnedAttributes();
// attrs are not resorted if position changes after the initial load
// promoted attrs are sorted primarily by order of definitions, but with multi-valued promoted attrs
// the order of attributes is important as well
ownedAttributes.sort((a, b) => a.position - b.position);
if (promotedDefAttrs.length === 0 || note.getLabelValue("viewType") === "table") {
this.toggleInt(false);
return;
}
const $cells: JQuery<HTMLElement>[] = [];
for (const definitionAttr of promotedDefAttrs) {
const valueType = definitionAttr.name.startsWith("label:") ? "label" : "relation";
const valueName = definitionAttr.name.substr(valueType.length + 1);
let valueAttrs = ownedAttributes.filter((el) => el.name === valueName && el.type === valueType) as Attribute[];
if (valueAttrs.length === 0) {
valueAttrs.push({
attributeId: "",
type: valueType,
name: valueName,
value: ""
});
}
if (definitionAttr.getDefinition().multiplicity === "single") {
valueAttrs = valueAttrs.slice(0, 1);
}
for (const valueAttr of valueAttrs) {
const $cell = await this.createPromotedAttributeCell(definitionAttr, valueAttr, valueName);
if ($cell) {
$cells.push($cell);
}
}
}
// we replace the whole content in one step, so there can't be any race conditions
// (previously we saw promoted attributes doubling)
this.$container.empty().append(...$cells);
this.toggleInt(true);
}
async createPromotedAttributeCell(definitionAttr: FAttribute, valueAttr: Attribute, valueName: string) { async createPromotedAttributeCell(definitionAttr: FAttribute, valueAttr: Attribute, valueName: string) {
const definition = definitionAttr.getDefinition(); // .on("change", (event) => this.promotedAttributeChanged(event));
const id = `value-${valueAttr.attributeId}`;
const $input = $("<input>")
.prop("tabindex", 200 + definitionAttr.position)
.prop("id", id)
.attr("data-attribute-id", valueAttr.noteId === this.noteId ? valueAttr.attributeId ?? "" : "") // if not owned, we'll force creation of a new attribute instead of updating the inherited one
.attr("data-attribute-type", valueAttr.type)
.attr("data-attribute-name", valueAttr.name)
.prop("value", valueAttr.value)
.prop("placeholder", t("promoted_attributes.unset-field-placeholder"))
.addClass("form-control")
.addClass("promoted-attribute-input")
.on("change", (event) => this.promotedAttributeChanged(event));
const $actionCell = $("<div>");
const $multiplicityCell = $("<td>").addClass("multiplicity").attr("nowrap", "true"); const $multiplicityCell = $("<td>").addClass("multiplicity").attr("nowrap", "true");
const $wrapper = $('<div class="promoted-attribute-cell">')
.append(
$("<label>")
.prop("for", id)
.text(definition.promotedAlias ?? valueName)
)
.append($("<div>").addClass("input-group").append($input))
.append($actionCell)
.append($multiplicityCell);
if (valueAttr.type === "label") { if (valueAttr.type === "label") {
$wrapper.addClass(`promoted-attribute-label-${definition.labelType}`); $wrapper.addClass(`promoted-attribute-label-${definition.labelType}`);
if (definition.labelType === "text") { if (definition.labelType === "text") {
@@ -359,8 +165,6 @@ export default class PromotedAttributesWidget extends NoteContextAwareWidget {
if (definition.multiplicity === "multi") { if (definition.multiplicity === "multi") {
const $addButton = $("<span>") const $addButton = $("<span>")
.addClass("bx bx-plus pointer tn-tool-button")
.prop("title", t("promoted_attributes.add_new_attribute"))
.on("click", async () => { .on("click", async () => {
const $new = await this.createPromotedAttributeCell( const $new = await this.createPromotedAttributeCell(
definitionAttr, definitionAttr,

View File

@@ -406,17 +406,14 @@ export function useNoteLabelWithDefault(note: FNote | undefined | null, labelNam
} }
export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: FilterLabelsByType<boolean>): [ boolean, (newValue: boolean) => void] { export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: FilterLabelsByType<boolean>): [ boolean, (newValue: boolean) => void] {
const [, forceRender] = useState({}); const [ labelValue, setLabelValue ] = useState<boolean>(!!note?.hasLabel(labelName));
useEffect(() => { useEffect(() => setLabelValue(!!note?.hasLabel(labelName)), [ note ]);
forceRender({});
}, [ note ]);
useTriliumEvent("entitiesReloaded", ({ loadResults }) => { useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
for (const attr of loadResults.getAttributeRows()) { for (const attr of loadResults.getAttributeRows()) {
if (attr.type === "label" && attr.name === labelName && attributes.isAffecting(attr, note)) { if (attr.type === "label" && attr.name === labelName && attributes.isAffecting(attr, note)) {
forceRender({}); setLabelValue(!attr.isDeleted);
break;
} }
} }
}); });
@@ -433,7 +430,6 @@ export function useNoteLabelBoolean(note: FNote | undefined | null, labelName: F
useDebugValue(labelName); useDebugValue(labelName);
const labelValue = !!note?.hasLabel(labelName);
return [ labelValue, setter ] as const; return [ labelValue, setter ] as const;
} }

View File

@@ -65,6 +65,7 @@ function BookProperties({ viewType, note, properties }: { viewType: ViewTypeOpti
</div> </div>
))} ))}
{viewType !== "list" && viewType !== "grid" && (
<CheckboxPropertyView <CheckboxPropertyView
note={note} property={{ note={note} property={{
bindToLabel: "includeArchived", bindToLabel: "includeArchived",
@@ -72,6 +73,7 @@ function BookProperties({ viewType, note, properties }: { viewType: ViewTypeOpti
type: "checkbox" type: "checkbox"
}} }}
/> />
)}
</> </>
) )
} }

View File

@@ -81,7 +81,7 @@ export const bookPropertiesConfig: Record<ViewTypeOptions, BookConfig> = {
await attributes.removeAttributeById(noteId, expandedAttr.attributeId); await attributes.removeAttributeById(noteId, expandedAttr.attributeId);
} }
triggerCommand("refreshNoteList", { noteId }); triggerCommand("refreshNoteList", { noteId: noteId });
}, },
}, },
{ {

View File

@@ -1,4 +1,4 @@
FROM node:24.11.1-bullseye-slim AS builder FROM node:24.11.0-bullseye-slim AS builder
RUN corepack enable RUN corepack enable
# Install native dependencies since we might be building cross-platform. # Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches # We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.11.1-bullseye-slim FROM node:24.11.0-bullseye-slim
# Install only runtime dependencies # Install only runtime dependencies
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \

View File

@@ -1,4 +1,4 @@
FROM node:24.11.1-alpine AS builder FROM node:24.11.0-alpine AS builder
RUN corepack enable RUN corepack enable
# Install native dependencies since we might be building cross-platform. # Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches # We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.11.1-alpine FROM node:24.11.0-alpine
# Install runtime dependencies # Install runtime dependencies
RUN apk add --no-cache su-exec shadow RUN apk add --no-cache su-exec shadow

View File

@@ -1,4 +1,4 @@
FROM node:24.11.1-alpine AS builder FROM node:24.11.0-alpine AS builder
RUN corepack enable RUN corepack enable
# Install native dependencies since we might be building cross-platform. # Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches # We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.11.1-alpine FROM node:24.11.0-alpine
# Create a non-root user with configurable UID/GID # Create a non-root user with configurable UID/GID
ARG USER=trilium ARG USER=trilium
ARG UID=1001 ARG UID=1001

View File

@@ -1,4 +1,4 @@
FROM node:24.11.1-bullseye-slim AS builder FROM node:24.11.0-bullseye-slim AS builder
RUN corepack enable RUN corepack enable
# Install native dependencies since we might be building cross-platform. # Install native dependencies since we might be building cross-platform.
@@ -7,7 +7,7 @@ COPY ./docker/package.json ./docker/pnpm-workspace.yaml /usr/src/app/
# We have to use --no-frozen-lockfile due to CKEditor patches # We have to use --no-frozen-lockfile due to CKEditor patches
RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild RUN pnpm install --no-frozen-lockfile --prod && pnpm rebuild
FROM node:24.11.1-bullseye-slim FROM node:24.11.0-bullseye-slim
# Create a non-root user with configurable UID/GID # Create a non-root user with configurable UID/GID
ARG USER=trilium ARG USER=trilium
ARG UID=1001 ARG UID=1001

View File

@@ -104,7 +104,7 @@
"is-animated": "2.0.2", "is-animated": "2.0.2",
"is-svg": "6.1.0", "is-svg": "6.1.0",
"jimp": "1.6.0", "jimp": "1.6.0",
"js-yaml": "4.1.1", "js-yaml": "4.1.0",
"marked": "16.4.2", "marked": "16.4.2",
"mime-types": "3.0.1", "mime-types": "3.0.1",
"multer": "2.0.2", "multer": "2.0.2",

View File

@@ -113,16 +113,7 @@ class NoteContentFulltextExp extends Expression {
const normalizedFlatText = normalizeSearchText(flatText); const normalizedFlatText = normalizeSearchText(flatText);
// Check if =phrase appears in flatText (indicates attribute value match) // Check if =phrase appears in flatText (indicates attribute value match)
// For single words, use word-boundary matching to avoid substring matches
if (!normalizedPhrase.includes(' ')) {
// Single word: look for =word with word boundaries
// Split by = to get attribute values, then check each value for exact word match
const parts = normalizedFlatText.split('=');
matches = parts.slice(1).some(part => this.exactWordMatch(normalizedPhrase, part));
} else {
// Multi-word phrase: check for substring match
matches = normalizedFlatText.includes(`=${normalizedPhrase}`); matches = normalizedFlatText.includes(`=${normalizedPhrase}`);
}
if ((this.operator === "=" && matches) || (this.operator === "!=" && !matches)) { if ((this.operator === "=" && matches) || (this.operator === "!=" && !matches)) {
resultNoteSet.add(noteFromBecca); resultNoteSet.add(noteFromBecca);
@@ -133,17 +124,6 @@ class NoteContentFulltextExp extends Expression {
return resultNoteSet; return resultNoteSet;
} }
/**
* Helper method to check if a single word appears as an exact match in text
* @param wordToFind - The word to search for (should be normalized)
* @param text - The text to search in (should be normalized)
* @returns true if the word is found as an exact match (not substring)
*/
private exactWordMatch(wordToFind: string, text: string): boolean {
const words = text.split(/\s+/);
return words.some(word => word === wordToFind);
}
/** /**
* Checks if content contains the exact word (with word boundaries) or exact phrase * Checks if content contains the exact word (with word boundaries) or exact phrase
* This is case-insensitive since content and token are already normalized * This is case-insensitive since content and token are already normalized
@@ -159,8 +139,9 @@ class NoteContentFulltextExp extends Expression {
return normalizedContent.includes(normalizedToken); return normalizedContent.includes(normalizedToken);
} }
// For single words, use exact word matching to avoid substring matches // For single words, split content into words and check for exact match
return this.exactWordMatch(normalizedToken, normalizedContent); const words = normalizedContent.split(/\s+/);
return words.some(word => word === normalizedToken);
} }
/** /**
@@ -174,14 +155,7 @@ class NoteContentFulltextExp extends Expression {
// Join tokens with single space to form the phrase // Join tokens with single space to form the phrase
const phrase = normalizedTokens.join(" "); const phrase = normalizedTokens.join(" ");
// For single-word phrases, use word-boundary matching to avoid substring matches // Check if the phrase appears as a substring (consecutive words)
// e.g., "asd" should not match "asdfasdf"
if (!phrase.includes(' ')) {
// Single word: use exact word matching to avoid substring matches
return this.exactWordMatch(phrase, normalizedContent);
}
// For multi-word phrases, check if the phrase appears as consecutive words
if (normalizedContent.includes(phrase)) { if (normalizedContent.includes(phrase)) {
return true; return true;
} }

View File

@@ -14,7 +14,7 @@
"preact": "10.27.2", "preact": "10.27.2",
"preact-iso": "2.11.0", "preact-iso": "2.11.0",
"preact-render-to-string": "6.6.3", "preact-render-to-string": "6.6.3",
"react-i18next": "16.3.1" "react-i18next": "16.2.4"
}, },
"devDependencies": { "devDependencies": {
"@preact/preset-vite": "2.10.2", "@preact/preset-vite": "2.10.2",

View File

@@ -43,7 +43,7 @@
"@playwright/test": "1.56.1", "@playwright/test": "1.56.1",
"@triliumnext/server": "workspace:*", "@triliumnext/server": "workspace:*",
"@types/express": "5.0.5", "@types/express": "5.0.5",
"@types/node": "24.10.1", "@types/node": "24.10.0",
"@vitest/coverage-v8": "3.2.4", "@vitest/coverage-v8": "3.2.4",
"@vitest/ui": "3.2.4", "@vitest/ui": "3.2.4",
"chalk": "5.6.2", "chalk": "5.6.2",
@@ -83,7 +83,7 @@
"url": "https://github.com/TriliumNext/Trilium/issues" "url": "https://github.com/TriliumNext/Trilium/issues"
}, },
"homepage": "https://triliumnotes.org", "homepage": "https://triliumnotes.org",
"packageManager": "pnpm@10.22.0", "packageManager": "pnpm@10.21.0",
"pnpm": { "pnpm": {
"patchedDependencies": { "patchedDependencies": {
"@ckeditor/ckeditor5-mention": "patches/@ckeditor__ckeditor5-mention.patch", "@ckeditor/ckeditor5-mention": "patches/@ckeditor__ckeditor5-mention.patch",

View File

@@ -15,7 +15,7 @@
"ckeditor5-premium-features": "47.2.0" "ckeditor5-premium-features": "47.2.0"
}, },
"devDependencies": { "devDependencies": {
"@smithy/middleware-retry": "4.4.10", "@smithy/middleware-retry": "4.4.7",
"@types/jquery": "3.5.33" "@types/jquery": "3.5.33"
} }
} }

773
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff