diff --git a/.mailmap b/.mailmap index 33287fa2ca5..1f9f14f5a8b 100644 --- a/.mailmap +++ b/.mailmap @@ -1,2 +1,116 @@ -zadam -zadam \ No newline at end of file +# Format: Canonical Name +# Merges aliases so `git shortlog`, `git log --use-mailmap`, etc. group commits per person. + +# Core maintainers +zadam +zadam +zadam + +Elian Doran +Elian Doran + +Adorian Doran +Adorian Doran + +# Contributors with multiple emails / name variants +Panagiotis Papadopoulos <102623907+pano9000@users.noreply.github.com> + +Jon Fuller + +SiriusXT <1160925501@qq.com> +SiriusXT <1160925501@qq.com> <11609255001@qq.com> +SiriusXT <1160925501@qq.com> <37627919+SiriusXT@users.noreply.github.com> + +JYC333 <22962980+JYC333@users.noreply.github.com> +JYC333 <22962980+JYC333@users.noreply.github.com> + +Nriver <6752679+Nriver@users.noreply.github.com> + +Francis C. +Francis C. + +Thomas Frei <7283497+thfrei@users.noreply.github.com> + +hasecilu + +meinzzzz + +FliegendeWurst +FliegendeWurst <2012gdwu@web.de> +FliegendeWurst <2012gdwu+github@posteo.de> + +MeIchthys +MeIchthys <10717998+meichthys@users.noreply.github.com> + +Marcel Wiechmann +Marcel Wiechmann + +Tomas Adamek +Tomas Adamek <50672285+Kureii@users.noreply.github.com> + +soulsands <407221377@qq.com> + +chesspro13 + +sigaloid <69441971+sigaloid@users.noreply.github.com> + +Marek Lewandowski +Marek Lewandowski +Marek Lewandowski + +lzinga +lzinga + +Sukant Gujar + +Matt Wilkie +Matt Wilkie + +Andreas Haan + +Potjoe-97 <42873357+Potjoe-97@users.noreply.github.com> +Potjoe-97 <42873357+Potjoe-97@users.noreply.github.com> + +Alex Pietsch <54153428+alexpietsch@users.noreply.github.com> + +Laurent Cozic +Laurent Cozic + +Zexin Yuan +Zexin Yuan + +hulmgulm +hulmgulm <12165268+hulmgulm@users.noreply.github.com> +hulmgulm + +Jules Bertholet + +Charles Dagenais + +Giulia Ye + +baddate <37013819+baddate@users.noreply.github.com> + +DerVogel101 <128903814+DerVogel101@users.noreply.github.com> +DerVogel101 <128903814+DerVogel101@users.noreply.github.com> + +Marcello Fuschi + +Jiahao Lee + +Dmitry Matveyev +Dmitry Matveyev + +Grant Zhu + +Sylvain Pasche +Sylvain Pasche + +mm21 <8033134+mm21@users.noreply.github.com> +mm21 <8033134+mm21@users.noreply.github.com> + +BeatLink +BeatLink + +Florian Meißner <161936+Mystler@users.noreply.github.com> +Florian Meißner <161936+Mystler@users.noreply.github.com> diff --git a/apps/client/src/assets/icon-classic.svg b/apps/client/src/assets/icon-classic.svg new file mode 100644 index 00000000000..0fbc903d8ad --- /dev/null +++ b/apps/client/src/assets/icon-classic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/client/src/assets/icon-nightly.svg b/apps/client/src/assets/icon-nightly.svg new file mode 100644 index 00000000000..dae45a42220 --- /dev/null +++ b/apps/client/src/assets/icon-nightly.svg @@ -0,0 +1,17 @@ + + + Trilium Notes + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/client/src/assets/icon.svg b/apps/client/src/assets/icon.svg new file mode 100644 index 00000000000..943f1bfe89e --- /dev/null +++ b/apps/client/src/assets/icon.svg @@ -0,0 +1,28 @@ + + + Trilium Notes + + + + + + + + + + + + + + + diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index 1fdfe9aeeaa..e571c39415b 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -1750,6 +1750,22 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu { display: flex; } + body.mobile .modal-dialog.modal-dialog-full-page-on-mobile { + width: 100%; + height: 100%; + max-height: unset; + max-width: unset; + + .modal-content { + border-radius: 0; + border: 0; + + .modal-body { + overflow: scroll; + } + } + } + body.mobile .modal-content { overflow-y: auto; border-radius: var(--bs-modal-border-radius) var(--bs-modal-border-radius) 0 0; diff --git a/apps/client/src/stylesheets/theme-next-light.css b/apps/client/src/stylesheets/theme-next-light.css index fe604906dc9..05211e4e026 100644 --- a/apps/client/src/stylesheets/theme-next-light.css +++ b/apps/client/src/stylesheets/theme-next-light.css @@ -269,9 +269,9 @@ --timeline-connector-active-color: #ddd; --timeline-connector-hover-blend-mode: multiply; - --tooltip-background-color: rgba(255, 255, 255, 0.85); - --tooltip-foreground-color: #000000ba; - --tooltip-shadow-color: rgba(0, 0, 0, 0.15); + --tooltip-background-color: rgba(0, 0, 0, 0.818); + --tooltip-foreground-color: #ffffffeb; + --tooltip-shadow-color: rgba(0, 0, 0, 0.2); --help-background-color: #fffc; --help-card-background: var(--card-background-color); diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 2baa838fb1e..d5d8286a627 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1,13 +1,23 @@ { - "about": { - "title": "About Trilium Notes", - "homepage": "Homepage:", - "app_version": "App version:", - "db_version": "DB version:", - "sync_version": "Sync version:", - "build_date": "Build date:", - "build_revision": "Build revision:", - "data_directory": "Data directory:" + "about": { + "version_label": "Version:", + "version": "{{appVersion}} (database: {{dbVersion}}, sync protocol: {{syncVersion}})", + "build_info": "Build: {{buildDate}}, revision: ", + "contributors_label": "Contributors:", + "contributor_roles": { + "lead-dev": "lead developer", + "original-dev": "original developer" + }, + "role_brief_history": { + "lead-dev": "Elian Doran founded TriliumNext in 2024, a community fork created after Zadam stepped back from the project. Zadam later transferred the original repository to the TriliumNext team, merging both projects back into one.", + "original-dev": "On 25th December 2017, Zadam released the first beta of Trilium (written with a single \"l\", unlike the flower). Dissatisfied with existing note organizers, he built a powerful self-hosted hierarchical knowledge base that gathered over 22,000 GitHub stars. In 2024, as life got busier, he placed the project into maintenance mode." + }, + "contributor_full_list": "See the entire community", + "data_directory": "Data directory:", + "github_tooltip": "Report bugs, suggest features, or contribute on GitHub", + "license_tooltip": "View license", + "donate": "Donate", + "donate_tooltip": "Donate to support this project" }, "toast": { "critical-error": { diff --git a/apps/client/src/widgets/dialogs/about.css b/apps/client/src/widgets/dialogs/about.css new file mode 100644 index 00000000000..c3ad80b2356 --- /dev/null +++ b/apps/client/src/widgets/dialogs/about.css @@ -0,0 +1,179 @@ +.about-dialog { + :where(body.light-theme &) { + --donate-button-color: #e33f3b; + + &.nightly { + --modal-background-color: #f2e1ff; + } + } + + :where(body.dark-theme &) { + --donate-button-color: #fba6a5; + + &.nightly { + --modal-background-color: #23182b; + } + } + + --bs-modal-width: 680px; + + .icon { + width: 160px; + height: 160px; + + &[data-icon="default"] { + background-image: url(../../assets/icon.svg); + } + + &[data-icon="nightly"] { + background-image: url(../../assets/icon-nightly.svg); + } + + &[data-icon="default"], + &[data-icon="nightly"] { + animation: icon-intro 500ms ease-out; + will-change: opacity, transform; + } + + &[data-icon="classic"] { + mask-image: url(../../assets/icon-classic.svg); + background-color: var(--muted-text-color); + animation: icon-classic-intro 300ms ease-in-out; + will-change: opacity, transform; + } + } + + h2 { + all: unset; + font-size: 2em; + font-weight: 300; + letter-spacing: 1pt; + + .channel-name { + opacity: .75; + } + } + + .about-dialog-content { + display: flex; + flex-direction: column; + align-items: center; + } + + .about-dialog-property-sheet { + margin-block: 30px; + + &.wide { + font-size: .85em; + margin-inline: 20px; + } + } + + .build-info { + color: var(--muted-text-color); + font-size: .9em; + } + + .contributor-list { + a, span { + white-space: nowrap; + } + + .contributor-role { + text-decoration: underline dotted var(--main-text-color); + text-underline-offset: 3px; + text-decoration-color: var(--muted-text-color); + cursor: help; + } + + .about-dialog-property-sheet.narrow & { + line-height: 1.75; + } + } + + footer { + display: flex; + justify-content: center; + gap: 10px; + margin-bottom: 30px; + + a { + --_icon-size: 28px; + + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 10px; + border-radius: 6px; + font-size: .9rem; + color: var(--main-text-color); + + &:hover { + background: var(--icon-button-hover-background); + } + + &::after { + display: none; + } + + i { + font-size: var(--_icon-size); + } + + svg { + fill: currentColor; + height: var(--_icon-size); + } + + &.donate-link { + color: var(--donate-button-color); + + &:hover i { + animation: heartbeat 600ms ease-in-out; + animation-iteration-count: 3; + } + } + } + } +} + +.about-dialog-brief-history-tooltip { + --main-font-size: .9em; + padding-inline: 30px; + + .tooltip-inner { + max-width: 600px; + } +} + +@keyframes icon-intro { + from { + opacity: 0; + transform: scale(.5); + } to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes icon-classic-intro { + from { + opacity: 0; + transform: rotate(50deg) scale(.5); + } to { + opacity: 1; + transform: rotate(0deg) scale(1.25); + } +} + +@keyframes heartbeat { + 0% { + transform: scale(1); + } 50% { + transform: scale(1.15); + } 75% { + transform: scale(1); + } 100% { + transform: scale(1); + } +} \ No newline at end of file diff --git a/apps/client/src/widgets/dialogs/about.tsx b/apps/client/src/widgets/dialogs/about.tsx index f09cca31915..056426eaa97 100644 --- a/apps/client/src/widgets/dialogs/about.tsx +++ b/apps/client/src/widgets/dialogs/about.tsx @@ -4,80 +4,233 @@ import { formatDateTime } from "../../utils/formatters.js"; import server from "../../services/server.js"; import utils from "../../services/utils.js"; import openService from "../../services/open.js"; -import { useState } from "preact/hooks"; -import type { CSSProperties } from "preact/compat"; -import type { AppInfo } from "@triliumnext/commons"; -import { useTriliumEvent } from "../react/hooks.jsx"; +import { useState, useCallback, useRef } from "preact/hooks"; +import type { AppInfo, Contributor, ContributorList } from "@triliumnext/commons"; +import { useTooltip, useTriliumEvent } from "../react/hooks.jsx"; +import { PropertySheet, PropertySheetItem } from "../react/PropertySheet.js"; +import "./about.css"; +import { Trans } from "react-i18next"; +import type React from "react"; +import contributors from "../../../../../contributors.json"; +import { Fragment } from "preact/jsx-runtime"; +import type { ComponentChildren } from "preact"; +import { useMemo, memo } from "preact/compat"; +import clsx from "clsx"; export default function AboutDialog() { const [appInfo, setAppInfo] = useState(null); - const [shown, setShown] = useState(false); - const forceWordBreak: CSSProperties = { wordBreak: "break-all" }; + const [isShown, setIsShown] = useState(false); + const [isNightly, setNightly] = useState(false); + const [icon, setIcon] = useState("default"); + const [altIcon, setAltIcon] = useState(null); - useTriliumEvent("openAboutDialog", () => setShown(true)); + const hasLoaded = useRef(false); + + const onLoad = useCallback(async () => { + if (!hasLoaded.current) { + const info = await server.get("app-info"); + if (info.appVersion.includes("test")) { + setNightly(true); + setIcon("nightly"); + } + setAppInfo(info); + hasLoaded.current = true; + + } + setIsShown(true); + }, []); + + useTriliumEvent("openAboutDialog", onLoad); + + const createContributorHoverHandler = () => { + let timeoutID: ReturnType; + return (contributor: Contributor, isHovering: boolean, part: "name" | "role") => { + if (part === "role" && contributor.role === "original-dev") { + if (isHovering) { + timeoutID = setTimeout(() => { + setAltIcon("classic"); + }, 500); + } else { + clearTimeout(timeoutID); + setAltIcon(null); + } + } + } + }; + + /* Cache the contributor list to prevent its rerendering. + * When the icon changes, it triggers a rerender of the dialog. If this happens while an + * element with a tooltip is hovered, its tooltip will break. */ + const CachedContributors = useMemo(() => memo(function CachedContributors() { + return + }), []); return ( - { - const appInfo = await server.get("app-info"); - setAppInfo(appInfo); - }} - onHidden={() => setShown(false)} + setIsShown(false)} > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{t("about.homepage")}https://github.com/TriliumNext/Trilium
{t("about.app_version")}{appInfo?.appVersion}
{t("about.db_version")}{appInfo?.dbVersion}
{t("about.sync_version")}{appInfo?.syncVersion}
{t("about.build_date")} - {appInfo?.buildDate ? formatDateTime(appInfo.buildDate) : ""} -
{t("about.build_revision")} - {appInfo?.buildRevision && {appInfo.buildRevision}} -
{t("about.data_directory")} - {appInfo?.dataDirectory && ()} -
+
+ +
+

Trilium Notes {isNightly && Nightly}

+ + triliumnotes.org + + + + + {t("about.version", { + appVersion: appInfo?.appVersion, + dbVersion: appInfo?.dbVersion, + syncVersion: appInfo?.syncVersion + })} +
+ as React.ReactElement + }} + /> +
+
+ + + + + + {t("about.contributor_full_list")} + + + + +
+ {appInfo?.dataDirectory && ()} +
+
+
+
+ +
+ + + + + + + + {/* https://pictogrammers.com/library/mdi/icon/scale-balance/ */} + + + + + + + +
); } -function DirectoryLink({ directory, style }: { directory: string, style?: CSSProperties }) { +function RevisionLink({appInfo}: {appInfo: AppInfo | null}) { + return <> + {appInfo?.buildRevision && + {appInfo.buildRevision.substring(0, 7)} + } + ; +} + +function FooterLink(props: {children: ComponentChildren, text: string, url: string, tooltip: string, className?: string}) { + + const linkRef = useRef(null); + + useTooltip(linkRef, { + title: props.tooltip, + delay: 250, + placement: "bottom" + }) + + return + {props.children} + {props.text} + +} + +type HoverCallback = (contributor: Contributor, isHovering: boolean, part: "name" | "role") => void; + +function Contributors({data, onHover}: {data: ContributorList, onHover?: HoverCallback}) { + return data.contributors.map((c, index, array) => { + return + + + {/* Add a comma between items */} + {(index < array.length - 1) ? ", " : ". "} + + }); +} + + +function ContributorListItem({data, onHover}: {data: Contributor, onHover?: HoverCallback}) { + const roleRef = useRef(null); + const roleString = (data.role) ? t(`about.contributor_roles.${data.role}`) : ""; + + useTooltip(roleRef, (data.role) ? { + title: t(`about.role_brief_history.${data.role}`), + customClass: "about-dialog-brief-history-tooltip", + placement: "bottom", + offset: [0, 10], + delay: 500 + }: {}); + + return <> + onHover?.(data, true, "name")} + onMouseLeave={() => onHover?.(data, false, "name")}> + + {data.fullName ?? data.name} + + + {roleString && onHover?.(data, true, "role")} + onMouseLeave={() => onHover?.(data, false, "role")}> + + ({roleString}) + } + +} + +function DirectoryLink({ directory }: { directory: string}) { if (utils.isElectron()) { const onClick = (e: MouseEvent) => { e.preventDefault(); openService.openDirectory(directory); }; - return {directory} + return {directory} } else { - return {directory}; + return {directory}; } -} +} \ No newline at end of file diff --git a/apps/client/src/widgets/react/FluidWrapper.tsx b/apps/client/src/widgets/react/FluidWrapper.tsx new file mode 100644 index 00000000000..09f47ff55e0 --- /dev/null +++ b/apps/client/src/widgets/react/FluidWrapper.tsx @@ -0,0 +1,43 @@ +import clsx from "clsx"; +import { ComponentChildren } from "preact"; +import { useEffect, useMemo, useRef, useState } from "preact/hooks"; + +interface FluidWrapperParams { + className?: string; + breakpoints: {[key: string]: number}; + children: ComponentChildren; +} + +export function FluidWrapper({className, breakpoints, children}: FluidWrapperParams) { + const ref = useRef(null); + const sortedBreakpoints = useMemo(() => { + return Object.entries(breakpoints).sort(([, a], [, b]) => a - b) + }, [breakpoints]); + const [activeBreakpoint, setActiveBreakpoint] = useState(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const onWidthChanged = (width: number) => { + let match = sortedBreakpoints[0]?.[0] ?? null; + for (const [name, min] of sortedBreakpoints) { + if (width >= min) match = name; + else break; + } + setActiveBreakpoint(match); + }; + + const observer = new ResizeObserver(([entry]) => onWidthChanged(entry.contentRect.width)); + observer.observe(el); + onWidthChanged(el.getBoundingClientRect().width); + + return () => observer.disconnect(); + }, [sortedBreakpoints]); + + return
+
+ {children} +
+
+} \ No newline at end of file diff --git a/apps/client/src/widgets/react/Modal.tsx b/apps/client/src/widgets/react/Modal.tsx index dcbb884961c..f84bac32226 100644 --- a/apps/client/src/widgets/react/Modal.tsx +++ b/apps/client/src/widgets/react/Modal.tsx @@ -15,7 +15,7 @@ interface CustomTitleBarButton { export interface ModalProps { className: string; - title: string | ComponentChildren; + title?: string | ComponentChildren; customTitleBarButtons?: (CustomTitleBarButton | null)[]; size: "xl" | "lg" | "md" | "sm"; children: ComponentChildren; @@ -83,9 +83,13 @@ export interface ModalProps { * spanning the entire height alongside the header, body and footer. */ sidebar?: ComponentChildren; + /** + * Indicates if the dialog will be displayed as a full page on mobile devices. + */ + isFullPageOnMobile?: boolean; } -export default function Modal({ children, className, size, title, customTitleBarButtons: titleBarButtons, header, footer, footerStyle, footerAlignment, onShown, onSubmit, helpPageId, minWidth, maxWidth, zIndex, scrollable, onHidden, modalRef: externalModalRef, formRef, bodyStyle, show, stackable, keepInDom, noFocus, sidebar }: ModalProps) { +export default function Modal({ children, className, size, title, customTitleBarButtons: titleBarButtons, header, footer, footerStyle, footerAlignment, onShown, onSubmit, helpPageId, minWidth, maxWidth, zIndex, scrollable, onHidden, modalRef: externalModalRef, formRef, bodyStyle, show, stackable, keepInDom, noFocus, sidebar, isFullPageOnMobile }: ModalProps) { const modalRef = useSyncedRef(externalModalRef); const modalInstanceRef = useRef(); const elementToFocus = useRef(); @@ -149,7 +153,7 @@ export default function Modal({ children, className, size, title, customTitleBar return (
- {(show || keepInDom) &&
+ {(show || keepInDom) &&
{sidebar &&
{title &&
diff --git a/apps/client/src/widgets/react/PropertySheet.css b/apps/client/src/widgets/react/PropertySheet.css new file mode 100644 index 00000000000..4416e47cb5e --- /dev/null +++ b/apps/client/src/widgets/react/PropertySheet.css @@ -0,0 +1,82 @@ +:where(.property-sheet) { + --border-radius: 8px; +} + +.property-sheet { + dl { + background: var(--card-background-color); + + dt { + opacity: .75; + } + + dd { + user-select: text; + } + } +} + +.property-sheet-container.wide .property-sheet { + display: table; + border-spacing: 0 2px; + border-collapse: separate; + + dl { + display: table-row; + --_br: var(--border-radius); + + &:first-child { + clip-path: inset(0 round var(--_br) var(--_br) 0 0); + } + + &:last-child { + clip-path: inset(0 round 0 0 var(--_br) var(--_br)); + } + } + + dt, dd { + display: table-cell; + padding: 10px 16px; + vertical-align: top; + } + + dt { + white-space: nowrap; + font-weight: normal; + } + + dl { + width: 100%; + } +} + +.property-sheet-container.narrow .property-sheet { + display: flex; + flex-direction: column; + gap: 2px; + + dl { + margin: 0; + padding: 16px 20px; + + &:first-child { + border-radius: var(--border-radius) var(--border-radius) 0 0; + } + + &:last-child { + border-radius: 0 0 var(--border-radius) var(--border-radius); + } + + dt { + margin-bottom: 8px; + font-size: .85em; + font-weight: 550; + text-transform: uppercase; + letter-spacing: .75pt; + } + + dd { + margin: 0; + } + } +} diff --git a/apps/client/src/widgets/react/PropertySheet.tsx b/apps/client/src/widgets/react/PropertySheet.tsx new file mode 100644 index 00000000000..b196a956b3b --- /dev/null +++ b/apps/client/src/widgets/react/PropertySheet.tsx @@ -0,0 +1,28 @@ +import { ComponentChildren } from "preact"; +import clsx from "clsx"; +import "./PropertySheet.css"; +import { FluidWrapper } from "./FluidWrapper"; + +interface PropertySheetParams { + className?: string; + children: ComponentChildren; + wideLayoutBreakpoint?: number; +} + +export function PropertySheet({className, children, wideLayoutBreakpoint}: PropertySheetParams) { + return + +
+ {children} +
+
+} + +export function PropertySheetItem({className, label, children}: {className?: string, label: string, children: ComponentChildren}) { + return
+
{label}
+
{children}
+
+} \ No newline at end of file diff --git a/contributors.json b/contributors.json new file mode 100644 index 00000000000..fe732880330 --- /dev/null +++ b/contributors.json @@ -0,0 +1,35 @@ +{ + "⚠️": "Auto-generated file. Run `pnpm run update-contributors` to regenerate.", + "contributors": [ + { + "name": "eliandoran", + "fullName": "Elian Doran", + "url": "https://github.com/eliandoran", + "role": "lead-dev" + }, + { + "name": "zadam", + "fullName": "Zadam", + "url": "https://github.com/zadam", + "role": "original-dev" + }, + { + "name": "adoriandoran", + "fullName": "Adorian Doran", + "url": "https://github.com/adoriandoran" + }, + { + "name": "perfectra1n", + "fullName": "Jon Fuller", + "url": "https://github.com/perfectra1n" + }, + { + "name": "JYC333", + "url": "https://github.com/JYC333" + }, + { + "name": "Nriver", + "url": "https://github.com/Nriver" + } + ] +} diff --git a/package.json b/package.json index 8db21627a8f..cd3505b422f 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "dev:linter-check": "cross-env NODE_OPTIONS=--max_old_space_size=4096 eslint .", "dev:linter-fix": "cross-env NODE_OPTIONS=--max_old_space_size=4096 eslint . --fix", "postinstall": "tsx scripts/electron-rebuild.mts && pnpm prepare", - "prepare": "pnpm run --filter pdfjs-viewer --filter share-theme build && pnpm run --filter web-clipper postinstall" + "prepare": "pnpm run --filter pdfjs-viewer --filter share-theme build && pnpm run --filter web-clipper postinstall", + "update-contributors": "tsx ./scripts/update-contributors.ts" }, "private": true, "devDependencies": { diff --git a/packages/commons/src/index.ts b/packages/commons/src/index.ts index 71a451468ab..8c2cfc595cf 100644 --- a/packages/commons/src/index.ts +++ b/packages/commons/src/index.ts @@ -8,6 +8,7 @@ export * from "./lib/mime_type.js"; export * from "./lib/bulk_actions.js"; export * from "./lib/server_api.js"; export * from "./lib/shared_constants.js"; +export * from "./lib/shared_types.js"; export * from "./lib/ws_api.js"; export * from "./lib/attribute_names.js"; export * from "./lib/utils.js"; diff --git a/packages/commons/src/lib/shared_types.ts b/packages/commons/src/lib/shared_types.ts new file mode 100644 index 00000000000..17b701eca2c --- /dev/null +++ b/packages/commons/src/lib/shared_types.ts @@ -0,0 +1,10 @@ +export interface ContributorList { + contributors: Contributor[]; +} + +export interface Contributor { + name: string; + fullName?: string; + url: string; + role?: "lead-dev" | "original-dev"; +} \ No newline at end of file diff --git a/scripts/update-contributors.ts b/scripts/update-contributors.ts new file mode 100644 index 00000000000..62a209b0661 --- /dev/null +++ b/scripts/update-contributors.ts @@ -0,0 +1,223 @@ +import { execSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +interface ContributorEntry { + name: string; + fullName?: string; + url: string; + role?: string; +} + +interface ContributorFile { + contributors: ContributorEntry[]; +} + +interface ContributorInfo { + name: string; + fullName?: string; + email?: string; + commitCount: number; + url?: string; +} + +interface ShowTableParams { + title: string; + comment?: string; + contributors: ContributorInfo[]; + columns: (keyof ContributorInfo)[]; +} + +const TRANSLATION_PATHS = [ + "apps/client/src/translations/", + "apps/server/src/assets/translations/" +]; + +/** Authors that are bots or automated tools, not real contributors. */ +const EXCLUDED_AUTHORS = new Set([ + "Languages add-on", + "Hosted Weblate", + "renovate[bot]" +]); + +const NOREPLY_PATTERN = /^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/; + +/** + * Manual mapping for contributors whose git email doesn't reveal their + * GitHub username (i.e. no noreply email in .mailmap). + */ +const EMAIL_TO_GITHUB: Record = { + "contact@eliandoran.me": "eliandoran", + "zadam.apps@gmail.com": "zadam", + "adorian@esevo.ro": "adoriandoran", + "jonfuller2012@gmail.com": "perfectra1n", +}; + +const CONTRIBUTORS_PATH = join(__dirname, "..", "contributors.json"); + +/** + * Resolves a GitHub username from an email address. + * + * 1. Checks the manual mapping. + * 2. Extracts from GitHub noreply emails (e.g. "12345+user@…"). + * 3. Scans .mailmap for alternate emails that match the noreply pattern. + */ +function resolveGitHub(email: string, name: string): string | undefined { + if (EMAIL_TO_GITHUB[email]) return EMAIL_TO_GITHUB[email]; + + const noreply = email.match(NOREPLY_PATTERN); + if (noreply) return noreply[1]; + + // Grep .mailmap for alternate emails that match the noreply pattern + try { + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const mailmapContent = execSync(`grep -i "${escapedName}" .mailmap 2>/dev/null`).toString(); + for (const line of mailmapContent.split("\n")) { + // Extract all emails from the line (inside angle brackets) + for (const [, email] of line.matchAll(/<([^>]+)>/g)) { + const match = email.match(NOREPLY_PATTERN); + if (match) return match[1]; + } + } + } catch { /* no matches */ } + + return undefined; +} + +function parseShortlog(rawOutput: string): Map { + const result = new Map(); + for (const line of rawOutput.split("\n")) { + const match = line.match(/^\s*(\d+)\s+(.+?)\s+<(.+)>$/); + if (match) { + result.set(match[2], { email: match[3], commitCount: parseInt(match[1]) }); + } + } + return result; +} + +async function main() { + const { developers } = listLocalGitContributors(); + await listGitHubContributors(); + updateContributorsJson(developers); +} + +function listLocalGitContributors() { + const allOutput = execSync("git shortlog -sne --no-merges HEAD -- src/ apps/").toString(); + const translationOutput = execSync(`git shortlog -sne --no-merges HEAD -- ${TRANSLATION_PATHS.join(" ")}`).toString(); + + const allContribs = parseShortlog(allOutput); + const translationContribs = parseShortlog(translationOutput); + + const developers: ContributorInfo[] = []; + const translators: ContributorInfo[] = []; + const MIN_COMMITS = 100; + for (const [name, { email, commitCount }] of allContribs) { + if (EXCLUDED_AUTHORS.has(name)) continue; + + const translationCommitCount = translationContribs.get(name)?.commitCount ?? 0; + const isTranslator = translationCommitCount > commitCount * 0.5; + + const githubUsername = resolveGitHub(email, name); + const url = githubUsername ? `https://github.com/${githubUsername}` : undefined; + const entry: ContributorInfo = { name, email, commitCount, url }; + + if (isTranslator) { + if (commitCount >= 20) translators.push(entry); + } else if (commitCount >= MIN_COMMITS) { + developers.push(entry); + } + } + + // showTable({ + // title: "Local Git Contributors (Developers)", + // columns: ["name", "url", "commitCount"], + // contributors: developers + // }); + + // showTable({ + // title: "Local Git Contributors (Translators)", + // comment: "Contributors where >50% of commits are to translation files.", + // columns: ["name", "url", "commitCount"], + // contributors: translators + // }); + + return { developers, translators }; +} + +async function listGitHubContributors() { + let list: any[] | null = null; + + const response = await fetch("https://api.github.com/repos/TriliumNext/Trilium/contributors"); + if (response.ok) { + list = await response.json(); + } else { + console.error(`Unable to request the contributor list from GitHub. Reason: ${response.statusText}`); + } + + if (!list) { + return; + } + + const MIN_CONTRIBUTIONS = 125; + const contributors: ContributorInfo[] = list + .filter((c) => c.contributions >= MIN_CONTRIBUTIONS) + .map((c) => { + return { + name: c.login, + url: c.html_url, + commitCount: c.contributions + } as ContributorInfo; + }); + + // showTable({ + // title: "GitHub Contributor List", + // comment: "Note: the GitHub list also include contributors that did not directly contribute to Trilium, but to submodules used in the Trilium's repo.", + // contributors: contributors, + // columns: ["name", "url", "commitCount"] + // }); +} + +/** + * Updates contributors.json, preserving pinned entries (those with special + * roles like lead-dev, original-dev) and regenerating the rest from git data. + */ +function updateContributorsJson(developers: ContributorInfo[]) { + // Read existing file to preserve pinned entries + const existing: ContributorFile = JSON.parse(readFileSync(CONTRIBUTORS_PATH, "utf-8")); + const pinnedRoles = new Set(["lead-dev", "original-dev"]); + const pinned = existing.contributors.filter((c) => c.role && pinnedRoles.has(c.role)); + + // Build a set of pinned GitHub usernames to avoid duplicates + const pinnedNames = new Set(pinned.map((c) => c.name)); + + const contributors: ContributorEntry[] = [...pinned]; + + // Add developers (skip those already pinned) + for (const dev of developers) { + const githubName = dev.url?.replace("https://github.com/", ""); + if (!githubName || pinnedNames.has(githubName)) continue; + + contributors.push({ + name: githubName, + fullName: dev.name !== githubName ? dev.name : undefined, + url: dev.url! + }); + } + + const output = { + "⚠️": "Auto-generated file. Run `pnpm run update-contributors` to regenerate.", + contributors + }; + writeFileSync(CONTRIBUTORS_PATH, JSON.stringify(output, null, 4) + "\n"); + console.log(`\n✅ Updated ${CONTRIBUTORS_PATH} with ${contributors.length} contributors.`); +} + +function showTable(params: ShowTableParams) { + console.log(`\n──── ${params.title} ────`); + if (params.comment) { + console.log(`\n${params.comment}\n`); + } + console.table(params.contributors, params.columns); +} + +main();