├── CHANGELOG.md ├── src ├── service │ ├── backlink │ │ ├── BacklinkService.ts │ │ └── backlink-sql.ts │ ├── plugin │ │ ├── TopBarService.ts │ │ ├── DockServices.ts │ │ ├── TabService.ts │ │ └── DocumentService.ts │ └── setting │ │ ├── SettingService.ts │ │ └── BacklinkPanelFilterCriteriaService.ts ├── utils │ ├── Instance.ts │ ├── json-util.ts │ ├── timing-util.ts │ ├── object-util.ts │ ├── siyuan-util.ts │ ├── array-util.ts │ ├── datetime-util.ts │ ├── cache-util.ts │ ├── icon-util.ts │ ├── string-util.ts │ ├── html-util.ts │ └── api.ts ├── components │ ├── setting │ │ ├── setting-item.svelte │ │ ├── inputs │ │ │ ├── setting-switch.svelte │ │ │ ├── setting-select.svelte │ │ │ └── setting-input.svelte │ │ ├── setting-util.ts │ │ └── setting-page.svelte │ └── dock │ │ └── backlink-filter-panel-dock.svelte ├── types │ ├── api.d.ts │ ├── custom.d.ts │ └── index.d.ts ├── index.scss ├── config │ ├── EnvConfig.ts │ └── CacheManager.ts ├── index.ts ├── i18n │ ├── zh_CN.json │ └── en_US.json └── models │ ├── setting-model.ts │ ├── backlink-constant.ts │ ├── setting-constant.ts │ ├── icon-constant.ts │ └── backlink-model.ts ├── scripts ├── .gitignore ├── elevate.ps1 ├── make_install.js ├── make_dev_link.js ├── update_version.js └── utils.js ├── icon.png ├── preview.png ├── asset ├── action.png ├── image-20240725232501-y7lqwin.png ├── image-20240725232551-jc174jq.png ├── image-20240813225800-ncnfkrq.png ├── image-20240903111116-oq73dy0.png ├── image-20240903111620-7h2q3bn.png ├── image-20241111193027-qjaj4by.png ├── image-20241111194306-v7zompa.png └── image-20241116174719-i9k9qih.png ├── .gitignore ├── tsconfig.node.json ├── plugin.json ├── svelte.config.js ├── LICENSE ├── package.json ├── .github └── workflows │ └── release.yml ├── tsconfig.json ├── yaml-plugin.js ├── public └── i18n │ ├── zh_CN.json │ └── en_US.json ├── README.md ├── README_zh_CN.md └── vite.config.ts /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/service/backlink/BacklinkService.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | build 3 | dist 4 | *.exe 5 | *.spec 6 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Misuzu2027/syplugin-backlink-panel/HEAD/icon.png -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Misuzu2027/syplugin-backlink-panel/HEAD/preview.png -------------------------------------------------------------------------------- /asset/action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Misuzu2027/syplugin-backlink-panel/HEAD/asset/action.png -------------------------------------------------------------------------------- /asset/image-20240725232501-y7lqwin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Misuzu2027/syplugin-backlink-panel/HEAD/asset/image-20240725232501-y7lqwin.png -------------------------------------------------------------------------------- /asset/image-20240725232551-jc174jq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Misuzu2027/syplugin-backlink-panel/HEAD/asset/image-20240725232551-jc174jq.png -------------------------------------------------------------------------------- /asset/image-20240813225800-ncnfkrq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Misuzu2027/syplugin-backlink-panel/HEAD/asset/image-20240813225800-ncnfkrq.png -------------------------------------------------------------------------------- /asset/image-20240903111116-oq73dy0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Misuzu2027/syplugin-backlink-panel/HEAD/asset/image-20240903111116-oq73dy0.png -------------------------------------------------------------------------------- /asset/image-20240903111620-7h2q3bn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Misuzu2027/syplugin-backlink-panel/HEAD/asset/image-20240903111620-7h2q3bn.png -------------------------------------------------------------------------------- /asset/image-20241111193027-qjaj4by.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Misuzu2027/syplugin-backlink-panel/HEAD/asset/image-20241111193027-qjaj4by.png -------------------------------------------------------------------------------- /asset/image-20241111194306-v7zompa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Misuzu2027/syplugin-backlink-panel/HEAD/asset/image-20241111194306-v7zompa.png -------------------------------------------------------------------------------- /asset/image-20241116174719-i9k9qih.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Misuzu2027/syplugin-backlink-panel/HEAD/asset/image-20241116174719-i9k9qih.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | .DS_Store 4 | pnpm-lock.yaml 5 | package-lock.json 6 | package.zip 7 | node_modules 8 | dev 9 | dist 10 | build 11 | tmp 12 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "Node", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": [ 10 | "vite.config.ts" 11 | ] 12 | } -------------------------------------------------------------------------------- /src/utils/Instance.ts: -------------------------------------------------------------------------------- 1 | export type IClazz = new (...param: any[]) => T; 2 | 3 | export default class Instance { 4 | 5 | public static get(clazz: IClazz, ...param: any[]): T { 6 | if (clazz["__Instance__"] == null) { 7 | clazz["__Instance__"] = new clazz(...param); 8 | } 9 | return clazz["__Instance__"]; 10 | } 11 | } -------------------------------------------------------------------------------- /src/utils/json-util.ts: -------------------------------------------------------------------------------- 1 | // 自定义 replacer 函数,在序列化时将 Set 对象转换为数组 2 | export function setReplacer(key, value) { 3 | if (value instanceof Set) { 4 | return { 5 | _type: 'Set', 6 | _value: [...value] 7 | }; 8 | } 9 | return value; 10 | } 11 | 12 | // 自定义 reviver 函数,在反序列化时将数组转换回 Set 对象 13 | export function setReviver(key, value) { 14 | if (value && value._type === 'Set') { 15 | return new Set(value._value); 16 | } 17 | return value; 18 | } 19 | 20 | -------------------------------------------------------------------------------- /src/components/setting/setting-item.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |
9 | {itemProperty.name} 10 |
11 | {@html itemProperty.description} 12 |
13 |
14 |
15 | 16 |
17 | -------------------------------------------------------------------------------- /src/components/setting/inputs/setting-switch.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 19 | -------------------------------------------------------------------------------- /src/utils/timing-util.ts: -------------------------------------------------------------------------------- 1 | export function delayedTwiceRefresh(executeFun: () => void, firstTimeout: number) { 2 | if (!executeFun) { 3 | return; 4 | } 5 | if (!firstTimeout) { 6 | firstTimeout = 0; 7 | } 8 | let refreshPreviewHighlightTimeout = 140; 9 | setTimeout(() => { 10 | executeFun(); 11 | 12 | if ( 13 | refreshPreviewHighlightTimeout && 14 | refreshPreviewHighlightTimeout > 0 15 | ) { 16 | setTimeout(() => { 17 | executeFun(); 18 | }, refreshPreviewHighlightTimeout); 19 | } 20 | 21 | }, firstTimeout); 22 | } 23 | 24 | 25 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syplugin-backlink-panel", 3 | "author": "Misuzu2027", 4 | "url": "https://github.com/Misuzu2027/syplugin-backlink-panel", 5 | "version": "0.0.31", 6 | "minAppVersion": "3.1.4", 7 | "backends": [ 8 | "all" 9 | ], 10 | "frontends": [ 11 | "all" 12 | ], 13 | "displayName": { 14 | "en_US": "Filterable Backlink Panel", 15 | "zh_CN": "反链过滤面板" 16 | }, 17 | "description": { 18 | "en_US": "Filterable Backlink Panel", 19 | "zh_CN": "支持过滤的反链面板" 20 | }, 21 | "readme": { 22 | "en_US": "README.md", 23 | "zh_CN": "README_zh_CN.md" 24 | }, 25 | "keywords": [ 26 | "dock","backlink","反链" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/components/setting/inputs/setting-select.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | -------------------------------------------------------------------------------- /src/components/setting/setting-util.ts: -------------------------------------------------------------------------------- 1 | import { EnvConfig } from "@/config/EnvConfig"; 2 | import { Dialog } from "siyuan"; 3 | import SettingPageSvelte from "@/components/setting/setting-page.svelte" 4 | 5 | 6 | 7 | 8 | export function openSettingsDialog() { 9 | let isMobile = EnvConfig.ins.isMobile; 10 | // 生成Dialog内容 11 | const dialogId = "backlink-panel-setting-" + Date.now(); 12 | // 创建dialog 13 | const settingDialog = new Dialog({ 14 | title: "反链面板插件设置", 15 | content: ` 16 |
17 | `, 18 | width: isMobile ? "92vw" : "1040px", 19 | height: isMobile ? "50vw" : "80vh", 20 | }); 21 | 22 | new SettingPageSvelte({ 23 | target: settingDialog.element.querySelector(`#${dialogId}`), 24 | }); 25 | 26 | 27 | } -------------------------------------------------------------------------------- /scripts/elevate.ps1: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2024 by frostime. All Rights Reserved. 2 | # @Author : frostime 3 | # @Date : 2024-09-06 19:15:53 4 | # @FilePath : /scripts/elevate.ps1 5 | # @LastEditTime : 2024-09-06 19:39:13 6 | # @Description : Force to elevate the script to admin privilege. 7 | 8 | param ( 9 | [string]$scriptPath 10 | ) 11 | 12 | $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition 13 | $projectDir = Split-Path -Parent $scriptDir 14 | 15 | if (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) { 16 | $args = "-NoProfile -ExecutionPolicy Bypass -File `"" + $MyInvocation.MyCommand.Path + "`" -scriptPath `"" + $scriptPath + "`"" 17 | Start-Process powershell.exe -Verb RunAs -ArgumentList $args -WorkingDirectory $projectDir 18 | exit 19 | } 20 | 21 | Set-Location -Path $projectDir 22 | & node $scriptPath 23 | 24 | pause 25 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2023-05-19 19:49:13 5 | * @FilePath : /svelte.config.js 6 | * @LastEditTime : 2024-04-19 19:01:55 7 | * @Description : 8 | */ 9 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte" 10 | 11 | const NoWarns = new Set([ 12 | "a11y-click-events-have-key-events", 13 | "a11y-no-static-element-interactions", 14 | "a11y-no-noninteractive-element-interactions" 15 | ]); 16 | 17 | export default { 18 | // Consult https://svelte.dev/docs#compile-time-svelte-preprocess 19 | // for more information about preprocessors 20 | preprocess: vitePreprocess(), 21 | onwarn: (warning, handler) => { 22 | if (warning.code.startsWith('A11y:')) { 23 | return; 24 | } 25 | if (warning.code.startsWith('a11y-')) return 26 | // suppress warnings on `vite dev` and `vite build`; but even without this, things still work 27 | if (NoWarns.has(warning.code)) return; 28 | handler(warning); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 SiYuan 思源笔记 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "syplugin-backlink-panel", 3 | "version": "0.0.4", 4 | "type": "module", 5 | "description": "SiYuan Note Plugin: Filterable Backlink Panel", 6 | "repository": "https://github.com/Misuzu2027/syplugin-backlink-panel", 7 | "homepage": "https://github.com/Misuzu2027/syplugin-backlink-panel", 8 | "author": "Misuzu2027", 9 | "license": "MIT", 10 | "scripts": { 11 | "make-link": "node --no-warnings ./scripts/make_dev_link.js", 12 | "dev": "vite build --watch", 13 | "build": "vite build", 14 | "make-install": "vite build && node --no-warnings ./scripts/make_install.js" 15 | }, 16 | "devDependencies": { 17 | "@sveltejs/vite-plugin-svelte": "^3.0.0", 18 | "@tsconfig/svelte": "^4.0.1", 19 | "@types/node": "^20.3.0", 20 | "fast-glob": "^3.2.12", 21 | "glob": "^7.2.3", 22 | "js-yaml": "^4.1.0", 23 | "minimist": "^1.2.8", 24 | "rollup-plugin-livereload": "^2.0.5", 25 | "sass": "^1.63.3", 26 | "siyuan": "1.0.6", 27 | "svelte": "^4.2.0", 28 | "ts-node": "^10.9.1", 29 | "typescript": "^5.1.3", 30 | "vite": "^5.0.0", 31 | "vite-plugin-static-copy": "^1.0.2", 32 | "vite-plugin-zip-pack": "^1.0.5" 33 | } 34 | } -------------------------------------------------------------------------------- /src/utils/object-util.ts: -------------------------------------------------------------------------------- 1 | export function getObjectSizeInKB(obj: any): number { 2 | try { 3 | // 将 JSON 对象转换为字符串 4 | const jsonString = JSON.stringify(obj); 5 | 6 | // 计算字符串的字节数 7 | const bytes = new Blob([jsonString]).size; 8 | 9 | // 将字节数转换为 KB 10 | const kilobytes = bytes / 1024; 11 | return kilobytes; 12 | } catch (err) { 13 | console.log("计算对象大小报错") 14 | } 15 | return 0; 16 | } 17 | 18 | 19 | export function isBoolean(value: any): value is boolean { 20 | return typeof value === 'boolean'; 21 | } 22 | 23 | export function isObject(value: any): value is object { 24 | return typeof value === 'object' && value !== null && !Array.isArray(value); 25 | } 26 | 27 | 28 | /** 29 | * obj1 字段为空的值由 obj2 补上。 30 | * @param obj1 31 | * @param obj2 默认配置对象 32 | * @returns 33 | */ 34 | export function mergeObjects(obj1: T, obj2: U): T & U { 35 | 36 | const result = { ...obj1 } as T & U; 37 | 38 | for (const key in obj2) { 39 | if (obj2.hasOwnProperty(key)) { 40 | // 仅当 obj1[key] 为 null 或 undefined 时才覆盖 41 | if (result[key] === null || result[key] === undefined) { 42 | (result as any)[key] = obj2[key]; 43 | } 44 | } 45 | } 46 | 47 | return result; 48 | } 49 | 50 | -------------------------------------------------------------------------------- /src/components/setting/inputs/setting-input.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 | {#if itemProperty.type === "text"} 26 | 32 | {:else if itemProperty.type === "number"} 33 | 41 | {/if} 42 | -------------------------------------------------------------------------------- /src/service/plugin/TopBarService.ts: -------------------------------------------------------------------------------- 1 | import { EnvConfig } from "@/config/EnvConfig"; 2 | import Instance from "@/utils/Instance"; 3 | import { CUSTOM_ICON_MAP } from "@/models/icon-constant"; 4 | import { getActiveTab } from "@/utils/html-util"; 5 | import { TabService } from "@/service/plugin/TabService"; 6 | 7 | 8 | 9 | 10 | export class TopBarService { 11 | 12 | public static get ins(): TopBarService { 13 | return Instance.get(TopBarService); 14 | } 15 | 16 | public init() { 17 | 18 | if (!EnvConfig.ins.isMobile) { 19 | EnvConfig.ins.plugin.addTopBar({ 20 | icon: CUSTOM_ICON_MAP.BacklinkPanelFilter.id, 21 | title: "反链面板页签", 22 | position: "right", 23 | callback: () => { 24 | let currentDocument: HTMLDivElement = getActiveTab(); 25 | if (!currentDocument) { 26 | return; 27 | } 28 | 29 | const docTitleElement = currentDocument.querySelector(".protyle-title"); 30 | let docTitle = currentDocument.querySelector("div.protyle-title__input").textContent; 31 | let docId = docTitleElement.getAttribute("data-node-id"); 32 | TabService.ins.openBacklinkTab(docTitle, docId, null); 33 | } 34 | }); 35 | 36 | 37 | } 38 | 39 | 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/types/api.d.ts: -------------------------------------------------------------------------------- 1 | interface IResGetNotebookConf { 2 | box: string; 3 | conf: NotebookConf; 4 | name: string; 5 | } 6 | 7 | interface IReslsNotebooks { 8 | notebooks: Notebook[]; 9 | } 10 | 11 | interface IResUpload { 12 | errFiles: string[]; 13 | succMap: { [key: string]: string }; 14 | } 15 | 16 | interface IResdoOperations { 17 | doOperations: doOperation[]; 18 | undoOperations: doOperation[] | null; 19 | } 20 | 21 | interface IResGetBlockKramdown { 22 | id: BlockId; 23 | kramdown: string; 24 | } 25 | 26 | interface IResGetChildBlock { 27 | id: BlockId; 28 | type: BlockType; 29 | subtype?: BlockSubType; 30 | } 31 | 32 | interface IResGetTemplates { 33 | content: string; 34 | path: string; 35 | } 36 | 37 | interface IResReadDir { 38 | isDir: boolean; 39 | isSymlink: boolean; 40 | name: string; 41 | } 42 | 43 | interface IResExportMdContent { 44 | hPath: string; 45 | content: string; 46 | } 47 | 48 | interface IResBootProgress { 49 | progress: number; 50 | details: string; 51 | } 52 | 53 | interface IResForwardProxy { 54 | body: string; 55 | contentType: string; 56 | elapsed: number; 57 | headers: { [key: string]: string }; 58 | status: number; 59 | url: string; 60 | } 61 | 62 | interface IResExportResources { 63 | path: string; 64 | } 65 | 66 | 67 | interface IResCheckBlockFold{ 68 | isFolded: boolean; 69 | isRoot: boolean; 70 | } 71 | -------------------------------------------------------------------------------- /src/utils/siyuan-util.ts: -------------------------------------------------------------------------------- 1 | import { Constants, TProtyleAction } from "siyuan"; 2 | import { isStrBlank } from "./string-util"; 3 | 4 | // 用于生成随机字符串 5 | function randStr(length: number): string { 6 | const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; 7 | let result = ''; 8 | for (let i = 0; i < length; i++) { 9 | result += chars.charAt(Math.floor(Math.random() * chars.length)); 10 | } 11 | return result; 12 | } 13 | 14 | export function NewNodeID(): string { 15 | 16 | const now = new Date(); 17 | const formattedDate = now.toISOString().replace(/[-T:.Z]/g, '').slice(0, 14); // 格式化为 "YYYYMMDDHHMMSS" 18 | return `${formattedDate}-${randStr(7)}`; 19 | } 20 | 21 | export function getQueryStrByBlock(block: DefBlock | Block) { 22 | if (!block) { 23 | return ""; 24 | } 25 | let markdown = block.markdown; 26 | if (isStrBlank(markdown)) { 27 | markdown = block.content; 28 | } 29 | return markdown + " " + block.name + " " + block.alias + " " + block.memo + " " + block.tag; 30 | 31 | } 32 | 33 | export function getOpenTabActionByZoomIn(zoomIn: boolean): TProtyleAction[] { 34 | let actions: TProtyleAction[] = zoomIn 35 | ? [ 36 | Constants.CB_GET_HL, 37 | Constants.CB_GET_FOCUS, 38 | Constants.CB_GET_ALL, 39 | ] 40 | : [ 41 | Constants.CB_GET_HL, 42 | // Constants.CB_GET_FOCUS, 43 | Constants.CB_GET_CONTEXT, 44 | Constants.CB_GET_ROOTSCROLL, 45 | ]; 46 | return actions; 47 | } 48 | -------------------------------------------------------------------------------- /src/types/custom.d.ts: -------------------------------------------------------------------------------- 1 | 2 | type BacklinkParentBlock = DefBlock & { 3 | childIdPath: string; 4 | inAttrConcat: string; 5 | subMarkdown: string; 6 | }; 7 | 8 | 9 | type BacklinkChildBlock = DefBlock & { 10 | parentIdPath: string; 11 | parentInAttrConcat: string; 12 | subMarkdown: string; 13 | subInAttrConcat: string; 14 | }; 15 | 16 | 17 | 18 | type BacklinkBlock = DefBlock & { 19 | parentBlockType: string; 20 | parentListItemMarkdown: string; 21 | }; 22 | 23 | 24 | type DefBlock = Block & { 25 | refCount: number; 26 | backlinkBlockIdConcat: string; 27 | dynamicAnchor: string; 28 | staticAnchor: string; 29 | selectionStatus: string; 30 | filterStatus: boolean; 31 | // 额外字段,用来保存引用的定义块所在的块id 32 | // refBlockId?: string; 33 | // refBlockType?: string; 34 | }; 35 | 36 | type BlockSortMethod = 37 | | "type" 38 | | "content" 39 | | "typeAndContent" 40 | | "modifiedAsc" 41 | | "modifiedDesc" 42 | | "createdAsc" 43 | | "createdDesc" 44 | | "rankAsc" 45 | | "rankDesc" 46 | | "refCountAsc" 47 | | "refCountDesc" 48 | | "alphabeticAsc" 49 | | "alphabeticDesc" 50 | | "documentAlphabeticAsc" 51 | | "documentAlphabeticDesc" 52 | ; 53 | 54 | 55 | interface IBacklinkCacheData { 56 | backlinks: IBacklinkData[]; 57 | usedCache: boolean; 58 | } 59 | 60 | 61 | type IItemPropertyType = 62 | "select" | 63 | "text" | 64 | "number" | 65 | "button" | 66 | "textarea" | 67 | "switch" | 68 | "order" | 69 | "tips"; -------------------------------------------------------------------------------- /src/utils/array-util.ts: -------------------------------------------------------------------------------- 1 | export function paginate(array: T[], pageNumber: number, pageSize: number): T[] { 2 | // 计算起始索引 3 | const startIndex = (pageNumber - 1) * pageSize; 4 | // 计算结束索引 5 | const endIndex = startIndex + pageSize; 6 | // 返回对应的数组片段 7 | return array.slice(startIndex, endIndex); 8 | } 9 | 10 | export function getLastItem(list: T[]): T | undefined { 11 | return list.length > 0 ? list[list.length - 1] : undefined; 12 | } 13 | 14 | export function isArrayEmpty(array: T[]): boolean { 15 | return !array || array.length == 0; 16 | } 17 | 18 | 19 | export function isArrayNotEmpty(array: T[]): boolean { 20 | return Array.isArray(array) && array.length > 0; 21 | } 22 | 23 | export function isSetEmpty(set: Set): boolean { 24 | return !set || set.size == 0; 25 | } 26 | 27 | export function isSetNotEmpty(set: Set): boolean { 28 | return set && set.size > 0; 29 | } 30 | 31 | // 求交集。 32 | export function intersectionArray(array1: T[], array2: T[]): T[] { 33 | if (isArrayEmpty(array1) || isArrayEmpty(array2)) { 34 | return []; 35 | } 36 | // 使用 Set 来提高查找的效率 37 | // const set1 = new Set(array1); 38 | const set2 = new Set(array2); 39 | 40 | // 过滤 array1 中的元素,只保留那些也在 set2 中的元素 41 | return array1.filter(item => set2.has(item)); 42 | } 43 | 44 | 45 | // 求交集。 46 | export function intersectionSet(set1: Set, set2: Set): T[] { 47 | if (isSetEmpty(set1) || isSetEmpty(set2)) { 48 | return []; 49 | } 50 | 51 | const result = []; 52 | for (const item of set1) { 53 | if (set2.has(item)) { 54 | result.push(item); 55 | } 56 | } 57 | return result; 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release on Tag Push 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | # Checkout 13 | - name: Checkout 14 | uses: actions/checkout@v3 15 | 16 | # Install Node.js 17 | - name: Install Node.js 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 18 21 | registry-url: "https://registry.npmjs.org" 22 | 23 | # Install pnpm 24 | - name: Install pnpm 25 | uses: pnpm/action-setup@v2 26 | id: pnpm-install 27 | with: 28 | version: 8 29 | run_install: false 30 | 31 | # Get pnpm store directory 32 | - name: Get pnpm store directory 33 | id: pnpm-cache 34 | shell: bash 35 | run: | 36 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 37 | 38 | # Setup pnpm cache 39 | - name: Setup pnpm cache 40 | uses: actions/cache@v3 41 | with: 42 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 43 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 44 | restore-keys: | 45 | ${{ runner.os }}-pnpm-store- 46 | 47 | # Install dependencies 48 | - name: Install dependencies 49 | run: pnpm install 50 | 51 | # Build for production, 这一步会生成一个 package.zip 52 | - name: Build for production 53 | run: pnpm build 54 | 55 | - name: Release 56 | uses: ncipollo/release-action@v1 57 | with: 58 | allowUpdates: true 59 | artifactErrorsFailBuild: true 60 | artifacts: "package.zip" 61 | token: ${{ secrets.GITHUB_TOKEN }} 62 | prerelease: false 63 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": [ 7 | "ES2020", 8 | "DOM", 9 | "DOM.Iterable" 10 | ], 11 | "skipLibCheck": true, 12 | /* Bundler mode */ 13 | "moduleResolution": "Node", 14 | // "allowImportingTsExtensions": true, 15 | "allowSyntheticDefaultImports": true, 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "noEmit": true, 19 | "jsx": "preserve", 20 | /* Linting */ 21 | "strict": false, 22 | "noUnusedLocals": true, 23 | "noUnusedParameters": true, 24 | "noFallthroughCasesInSwitch": true, 25 | /* Svelte */ 26 | /** 27 | * Typecheck JS in `.svelte` and `.js` files by default. 28 | * Disable checkJs if you'd like to use dynamic types in JS. 29 | * Note that setting allowJs false does not prevent the use 30 | * of JS in `.svelte` files. 31 | */ 32 | "allowJs": true, 33 | "checkJs": true, 34 | "types": [ 35 | "node", 36 | "vite/client", 37 | "svelte" 38 | ], 39 | // "baseUrl": "./src", 40 | "paths": { 41 | "@/*": [ 42 | "./src/*" 43 | ], 44 | "@/libs/*": [ 45 | "./src/libs/*" 46 | ], 47 | } 48 | }, 49 | "include": [ 50 | "tools/**/*.ts", 51 | "src/**/*.ts", 52 | "src/**/*.d.ts", 53 | "src/**/*.tsx", 54 | "src/**/*.vue", 55 | "src/**/*.svelte" 56 | ], 57 | "references": [ 58 | { 59 | "path": "./tsconfig.node.json" 60 | } 61 | ], 62 | "root": "." 63 | } -------------------------------------------------------------------------------- /scripts/make_install.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2024-03-28 20:03:59 5 | * @FilePath : /scripts/make_install.js 6 | * @LastEditTime : 2024-09-06 18:08:19 7 | * @Description : 8 | */ 9 | // make_install.js 10 | import fs from 'fs'; 11 | import { log, error, getSiYuanDir, chooseTarget, copyDirectory, getThisPluginName } from './utils.js'; 12 | 13 | let targetDir = ''; 14 | 15 | /** 16 | * 1. Get the parent directory to install the plugin 17 | */ 18 | log('>>> Try to visit constant "targetDir" in make_install.js...'); 19 | if (targetDir === '') { 20 | log('>>> Constant "targetDir" is empty, try to get SiYuan directory automatically....'); 21 | let res = await getSiYuanDir(); 22 | 23 | if (res === null || res === undefined || res.length === 0) { 24 | error('>>> Can not get SiYuan directory automatically'); 25 | process.exit(1); 26 | } else { 27 | targetDir = await chooseTarget(res); 28 | } 29 | log(`>>> Successfully got target directory: ${targetDir}`); 30 | } 31 | if (!fs.existsSync(targetDir)) { 32 | error(`Failed! Plugin directory not exists: "${targetDir}"`); 33 | error('Please set the plugin directory in scripts/make_install.js'); 34 | process.exit(1); 35 | } 36 | 37 | /** 38 | * 2. The dist directory, which contains the compiled plugin code 39 | */ 40 | const distDir = `${process.cwd()}/dist`; 41 | if (!fs.existsSync(distDir)) { 42 | fs.mkdirSync(distDir); 43 | } 44 | 45 | /** 46 | * 3. The target directory to install the plugin 47 | */ 48 | const name = getThisPluginName(); 49 | if (name === null) { 50 | process.exit(1); 51 | } 52 | const targetPath = `${targetDir}/${name}`; 53 | 54 | /** 55 | * 4. Copy the compiled plugin code to the target directory 56 | */ 57 | copyDirectory(distDir, targetPath); 58 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | /* 反链面板区域的样式 */ 2 | div.backlink-panel__area div.protyle-wysiwyg.protyle-wysiwyg--attr { 3 | padding-bottom: 0px; 4 | margin-bottom: 16px; 5 | } 6 | 7 | div.backlink-panel__area .sy__backlink .protyle-wysiwyg>[data-node-id]:last-child { 8 | margin-bottom: 0px; 9 | } 10 | 11 | div.backlink-panel__area .sy__backlink .list-item__document-name { 12 | line-height: 28px; 13 | min-height: 28px; 14 | padding: 0 4px; 15 | display: flex; 16 | z-index: 0; 17 | align-items: center; 18 | position: relative; 19 | background-color: var(--b3-theme-surface); 20 | text-align: left; 21 | border: 0; 22 | color: var(--b3-theme-on-background); 23 | margin: 8px 3px 1px; 24 | border-radius: var(--b3-border-radius); 25 | border-radius: 10px; 26 | } 27 | 28 | div.backlink-panel__area .sy__backlink div.protyle { 29 | margin: 0px 4px; 30 | } 31 | 32 | div.backlink-panel__area .sy__backlink .protyle .protyle-wysiwyg [data-node-id].li>.protyle-action svg { 33 | z-index: 0; 34 | } 35 | 36 | 37 | div.backlink-panel__area .block__icon--show.block__icon.disabled { 38 | opacity: 0.38; 39 | cursor: not-allowed; 40 | } 41 | 42 | 43 | div.backlink-panel__area .hide-breadcrumb .protyle-breadcrumb__bar.protyle-breadcrumb__bar--nowrap { 44 | display: none; 45 | } 46 | 47 | 48 | ::highlight(search-result-mark) { 49 | background-color: var(--b3-protyle-inline-mark-background); 50 | color: var(--b3-protyle-inline-mark-color); 51 | } 52 | 53 | ::highlight(search-result-focus) { 54 | background-color: var(--b3-theme-primary-lighter); 55 | color: var(--b3-protyle-inline-mark-color); 56 | } 57 | 58 | /* 文档底部样式 */ 59 | div.backlink-panel-document-bottom__area { 60 | padding: 8px 48px; 61 | 62 | } 63 | 64 | 65 | 66 | /* 手机端样式 */ 67 | div.document-panel-plugin-mobile.backlink-panel-document-bottom__area { 68 | padding: 8px 10px; 69 | } -------------------------------------------------------------------------------- /src/config/EnvConfig.ts: -------------------------------------------------------------------------------- 1 | import { lsNotebooks } from "@/utils/api"; 2 | import { convertIconInIal } from "@/utils/icon-util"; 3 | import Instance from "@/utils/Instance"; 4 | import { App, I18N, IDockModel, IPluginDockTab, Plugin, getFrontend } from "siyuan"; 5 | 6 | export class EnvConfig { 7 | 8 | 9 | public static get ins(): EnvConfig { 10 | return Instance.get(EnvConfig); 11 | } 12 | 13 | private _isMobile: boolean; 14 | get isMobile(): boolean { 15 | return this._isMobile; 16 | } 17 | 18 | private _plugin: Plugin; 19 | get plugin(): Plugin { 20 | return this._plugin; 21 | } 22 | 23 | get app(): App { 24 | return this._plugin.app; 25 | } 26 | 27 | get i18n(): I18N { 28 | if (this._plugin) { 29 | return this._plugin.i18n; 30 | } 31 | const i18nObject: I18N = { 32 | // 添加你需要的属性和方法 33 | }; 34 | return i18nObject; 35 | } 36 | 37 | public lastViewedDocId: string; 38 | 39 | 40 | public init(plugin: Plugin) { 41 | let frontEnd: string = getFrontend(); 42 | this._isMobile = frontEnd === "mobile" || frontEnd === "browser-mobile"; 43 | this._plugin = plugin; 44 | } 45 | 46 | 47 | // docSearchDock: { config: IPluginDockTab, model: IDockModel }; 48 | // flatDocTreeDock: { config: IPluginDockTab, model: IDockModel }; 49 | 50 | private _notebookMap: Map = new Map(); 51 | public async notebookMap(cache: boolean): Promise> { 52 | if (cache && this._notebookMap && this._notebookMap.size > 0) { 53 | return this._notebookMap 54 | } else { 55 | let notebooks: Notebook[] = (await lsNotebooks()).notebooks; 56 | this._notebookMap.clear(); 57 | for (const notebook of notebooks) { 58 | notebook.icon = convertIconInIal(notebook.icon); 59 | this._notebookMap.set(notebook.id, notebook); 60 | } 61 | } 62 | return this._notebookMap; 63 | } 64 | 65 | 66 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Plugin, 3 | } from "siyuan"; 4 | import "@/index.scss"; 5 | 6 | 7 | import { EnvConfig } from "./config/EnvConfig"; 8 | import { CUSTOM_ICON_MAP } from "./models/icon-constant"; 9 | import { SettingService } from "./service/setting/SettingService"; 10 | import { openSettingsDialog } from "./components/setting/setting-util"; 11 | import { DocumentService } from "./service/plugin/DocumentService"; 12 | import { DockService } from "./service/plugin/DockServices"; 13 | import { TopBarService } from "./service/plugin/TopBarService"; 14 | import { TabService } from "./service/plugin/TabService"; 15 | 16 | 17 | export default class PluginSample extends Plugin { 18 | 19 | 20 | async onload() { 21 | EnvConfig.ins.init(this); 22 | await SettingService.ins.init() 23 | DocumentService.ins.init(); 24 | DockService.ins.init(); 25 | TabService.ins.init(); 26 | TopBarService.ins.init(); 27 | 28 | 29 | // 图标的制作参见帮助文档 30 | for (const key in CUSTOM_ICON_MAP) { 31 | if (Object.prototype.hasOwnProperty.call(CUSTOM_ICON_MAP, key)) { 32 | const item = CUSTOM_ICON_MAP[key]; 33 | this.addIcons(item.source); 34 | } 35 | } 36 | 37 | this.eventBus.on('switch-protyle', (e: any) => { 38 | EnvConfig.ins.lastViewedDocId = e.detail.protyle.block.rootID; 39 | }) 40 | this.eventBus.on('loaded-protyle-static', (e: any) => { 41 | // console.log("index loaded-protyle-static ") 42 | if (EnvConfig.ins.isMobile && !EnvConfig.ins.lastViewedDocId) { 43 | EnvConfig.ins.lastViewedDocId = e.detail.protyle.block.rootID; 44 | } 45 | }) 46 | } 47 | 48 | 49 | 50 | 51 | onLayoutReady() { 52 | 53 | } 54 | 55 | async onunload() { 56 | DocumentService.ins.destory(); 57 | } 58 | 59 | uninstall() { 60 | // console.log("uninstall"); 61 | } 62 | 63 | 64 | openSetting(): void { 65 | openSettingsDialog(); 66 | } 67 | 68 | 69 | } 70 | -------------------------------------------------------------------------------- /scripts/make_dev_link.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2023-07-15 15:31:31 5 | * @FilePath : /scripts/make_dev_link.js 6 | * @LastEditTime : 2024-09-06 18:13:53 7 | * @Description : 8 | */ 9 | // make_dev_link.js 10 | import fs from 'fs'; 11 | import { log, error, getSiYuanDir, chooseTarget, getThisPluginName, makeSymbolicLink } from './utils.js'; 12 | 13 | let targetDir = ''; 14 | 15 | /** 16 | * 1. Get the parent directory to install the plugin 17 | */ 18 | log('>>> Try to visit constant "targetDir" in make_dev_link.js...'); 19 | if (targetDir === '') { 20 | log('>>> Constant "targetDir" is empty, try to get SiYuan directory automatically....'); 21 | let res = await getSiYuanDir(); 22 | 23 | if (!res || res.length === 0) { 24 | log('>>> Can not get SiYuan directory automatically, try to visit environment variable "SIYUAN_PLUGIN_DIR"....'); 25 | let env = process.env?.SIYUAN_PLUGIN_DIR; 26 | if (env) { 27 | targetDir = env; 28 | log(`\tGot target directory from environment variable "SIYUAN_PLUGIN_DIR": ${targetDir}`); 29 | } else { 30 | error('\tCan not get SiYuan directory from environment variable "SIYUAN_PLUGIN_DIR", failed!'); 31 | process.exit(1); 32 | } 33 | } else { 34 | targetDir = await chooseTarget(res); 35 | } 36 | 37 | log(`>>> Successfully got target directory: ${targetDir}`); 38 | } 39 | if (!fs.existsSync(targetDir)) { 40 | error(`Failed! Plugin directory not exists: "${targetDir}"`); 41 | error('Please set the plugin directory in scripts/make_dev_link.js'); 42 | process.exit(1); 43 | } 44 | 45 | /** 46 | * 2. The dev directory, which contains the compiled plugin code 47 | */ 48 | const devDir = `${process.cwd()}/dev`; 49 | if (!fs.existsSync(devDir)) { 50 | fs.mkdirSync(devDir); 51 | } 52 | 53 | 54 | /** 55 | * 3. The target directory to make symbolic link to dev directory 56 | */ 57 | const name = getThisPluginName(); 58 | if (name === null) { 59 | process.exit(1); 60 | } 61 | const targetPath = `${targetDir}/${name}`; 62 | 63 | /** 64 | * 4. Make symbolic link 65 | */ 66 | makeSymbolicLink(devDir, targetPath); 67 | -------------------------------------------------------------------------------- /src/utils/datetime-util.ts: -------------------------------------------------------------------------------- 1 | 2 | export function parseDateTimeInBlock(dateTimeString: string): Date | null { 3 | if (dateTimeString.length !== 14) { 4 | console.error("Invalid date time string format. It should be 'yyyyMMddhhmmss'."); 5 | return null; 6 | } 7 | 8 | const year = parseInt(dateTimeString.slice(0, 4), 10); 9 | const month = parseInt(dateTimeString.slice(4, 6), 10) - 1; // 月份从 0 开始 10 | const day = parseInt(dateTimeString.slice(6, 8), 10); 11 | const hour = parseInt(dateTimeString.slice(8, 10), 10); 12 | const minute = parseInt(dateTimeString.slice(10, 12), 10); 13 | const second = parseInt(dateTimeString.slice(12, 14), 10); 14 | 15 | return new Date(year, month, day, hour, minute, second); 16 | } 17 | 18 | 19 | export function convertDateTimeInBlock(dateTimeString: string): string { 20 | if (dateTimeString.length !== 14) { 21 | console.error("Invalid date time string format. It should be 'yyyyMMddhhmmss'."); 22 | return null; 23 | } 24 | const year = dateTimeString.slice(0, 4); 25 | const month = dateTimeString.slice(4, 6); 26 | const day = dateTimeString.slice(6, 8); 27 | const hour = dateTimeString.slice(8, 10); 28 | const minute = dateTimeString.slice(10, 12); 29 | const second = dateTimeString.slice(12, 14); 30 | 31 | return `${year}-${month}-${day} ${hour}:${minute}:${second}`; 32 | } 33 | 34 | 35 | 36 | export function formatRelativeTimeInBlock(dateTimeString: string): string { 37 | let timestamp = parseDateTimeInBlock(dateTimeString).getTime(); 38 | return formatRelativeTime(timestamp); 39 | } 40 | 41 | 42 | export function formatRelativeTime(timestamp: number): string { 43 | const now = Date.now(); 44 | const diff = now - timestamp; 45 | const minute = 60 * 1000; 46 | const hour = 60 * minute; 47 | const day = 24 * hour; 48 | const month = 30 * day; 49 | const year = 365 * day; 50 | 51 | if (diff < minute) { 52 | return `${Math.floor(diff / 1000)}秒前`; 53 | } else if (diff < hour) { 54 | return `${Math.floor(diff / minute)}分钟前`; 55 | } else if (diff < day) { 56 | return `${Math.floor(diff / hour)}小时前`; 57 | } else if (diff < month) { 58 | return `${Math.floor(diff / day)}天前`; 59 | } else if (diff < year) { 60 | return `${Math.floor(diff / month)}个月前`; 61 | } else { 62 | return `${Math.floor(diff / year)}年前`; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/service/plugin/DockServices.ts: -------------------------------------------------------------------------------- 1 | import { EnvConfig } from "@/config/EnvConfig"; 2 | import { CUSTOM_ICON_MAP } from "@/models/icon-constant"; 3 | import Instance from "@/utils/Instance"; 4 | import BacklinkPanelDockSvelte from "@/components/dock/backlink-filter-panel-dock.svelte"; 5 | import { SettingService } from "@/service/setting/SettingService"; 6 | import { clearProtyleGutters } from "@/utils/html-util"; 7 | 8 | const BACKLINK_PANEL_DOCK_TYPE = "backlink-panel-dock"; 9 | export class DockService { 10 | 11 | public static get ins(): DockService { 12 | return Instance.get(DockService); 13 | } 14 | 15 | init() { 16 | addBacklinkPanelDock(); 17 | 18 | } 19 | 20 | 21 | } 22 | 23 | 24 | function addBacklinkPanelDock() { 25 | if (!EnvConfig.ins || !EnvConfig.ins.plugin) { 26 | console.log("添加反链面板 dock 失败。") 27 | return; 28 | } 29 | let dockDisplay = SettingService.ins.SettingConfig.dockDisplay; 30 | if (!dockDisplay) { 31 | return; 32 | } 33 | 34 | let plugin = EnvConfig.ins.plugin; 35 | let docSearchSvelet: BacklinkPanelDockSvelte; 36 | let dockRet = plugin.addDock({ 37 | config: { 38 | position: "RightBottom", 39 | size: { width: 300, height: 0 }, 40 | icon: CUSTOM_ICON_MAP.BacklinkPanelFilter.id, 41 | title: "反链过滤面板 Dock", 42 | hotkey: "⌥⇧B", 43 | show: false, 44 | }, 45 | data: {}, 46 | type: BACKLINK_PANEL_DOCK_TYPE, 47 | resize() { 48 | if (docSearchSvelet) { 49 | docSearchSvelet.resize(this.element.clientWidth); 50 | } 51 | }, 52 | update() { 53 | }, 54 | init() { 55 | this.element.innerHTML = ""; 56 | docSearchSvelet = new BacklinkPanelDockSvelte({ 57 | target: this.element, 58 | props: { 59 | } 60 | }); 61 | this.element.addEventListener( 62 | "scroll", 63 | () => { 64 | clearProtyleGutters(this.element); 65 | }, 66 | ); 67 | 68 | if (EnvConfig.ins.isMobile) { 69 | docSearchSvelet.resize(1); 70 | } 71 | }, 72 | destroy() { 73 | docSearchSvelet.$destroy(); 74 | } 75 | }); 76 | // EnvConfig.ins.docSearchDock = dockRet; 77 | } 78 | -------------------------------------------------------------------------------- /src/utils/cache-util.ts: -------------------------------------------------------------------------------- 1 | import { isArrayEmpty, isArrayNotEmpty } from "./array-util"; 2 | 3 | 4 | export class CacheUtil { 5 | 6 | 7 | private cache: Map = new Map(); 8 | 9 | /** 10 | * 设置缓存 11 | * @param key 缓存键 12 | * @param value 缓存值 13 | * @param ttl 缓存有效时间(毫秒) 14 | */ 15 | set(key: string, value: any, ttl: number): void { 16 | const expiry = Date.now() + ttl; 17 | this.cache.set(key, { value, expiry }); 18 | } 19 | 20 | 21 | setByPrefix(prefix: string, suffix: string, value: any, ttl: number) { 22 | let key = prefix + suffix; 23 | this.set(key, value, ttl); 24 | } 25 | 26 | 27 | /** 28 | * 获取缓存 29 | * @param key 缓存键 30 | * @returns 缓存值或 null 31 | */ 32 | get(key: string): any | null { 33 | const cachedItem = this.cache.get(key); 34 | if (cachedItem) { 35 | if (cachedItem.expiry > Date.now()) { 36 | return cachedItem.value; 37 | } else { 38 | this.cache.delete(key); 39 | } 40 | } 41 | return null; 42 | } 43 | 44 | popByPrefix(prefix: string): any[] { 45 | let result: any[] = []; 46 | for (const [key, value] of this.cache.entries()) { 47 | if (key.startsWith(prefix)) { 48 | if (value.expiry > Date.now()) { 49 | result.push(value); 50 | } 51 | this.cache.delete(key); 52 | } 53 | } 54 | return result; 55 | } 56 | 57 | 58 | 59 | /** 60 | * 主动丢弃缓存 61 | * @param key 缓存键 62 | */ 63 | delete(key: string): void { 64 | this.cache.delete(key); 65 | } 66 | 67 | /** 68 | * 清除所有过期的缓存项 69 | */ 70 | cleanUp(): void { 71 | const now = Date.now(); 72 | for (const [key, { expiry }] of this.cache) { 73 | if (expiry <= now) { 74 | this.cache.delete(key); 75 | } 76 | } 77 | } 78 | 79 | clearByPrefix(prefix: string): void { 80 | for (const key of this.cache.keys()) { 81 | if (key.startsWith(prefix)) { 82 | this.cache.delete(key); 83 | } 84 | } 85 | } 86 | } 87 | 88 | 89 | export function generateKey(...parts: string[]): string { 90 | // 使用指定的分隔符连接所有字符串 91 | const separator = ':'; 92 | return parts.join(separator); 93 | } 94 | -------------------------------------------------------------------------------- /yaml-plugin.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2024-04-05 21:27:55 5 | * @FilePath : /yaml-plugin.js 6 | * @LastEditTime : 2024-04-05 22:53:34 7 | * @Description : 去妮玛的 json 格式,我就是要用 yaml 写 i18n 8 | */ 9 | // plugins/vite-plugin-parse-yaml.js 10 | import fs from 'fs'; 11 | import yaml from 'js-yaml'; 12 | import { resolve } from 'path'; 13 | 14 | export default function vitePluginYamlI18n(options = {}) { 15 | // Default options with a fallback 16 | const DefaultOptions = { 17 | inDir: 'src/i18n', 18 | outDir: 'dist/i18n', 19 | }; 20 | 21 | const finalOptions = { ...DefaultOptions, ...options }; 22 | 23 | return { 24 | name: 'vite-plugin-yaml-i18n', 25 | buildStart() { 26 | console.log('🌈 Parse I18n: YAML to JSON..'); 27 | const inDir = finalOptions.inDir; 28 | const outDir = finalOptions.outDir 29 | 30 | if (!fs.existsSync(outDir)) { 31 | fs.mkdirSync(outDir, { recursive: true }); 32 | } 33 | 34 | //Parse yaml file, output to json 35 | const files = fs.readdirSync(inDir); 36 | for (const file of files) { 37 | if (file.endsWith('.yaml') || file.endsWith('.yml')) { 38 | console.log(`-- Parsing ${file}`) 39 | //检查是否有同名的json文件 40 | const jsonFile = file.replace(/\.(yaml|yml)$/, '.json'); 41 | if (files.includes(jsonFile)) { 42 | console.log(`---- File ${jsonFile} already exists, skipping...`); 43 | continue; 44 | } 45 | try { 46 | const filePath = resolve(inDir, file); 47 | const fileContents = fs.readFileSync(filePath, 'utf8'); 48 | const parsed = yaml.load(fileContents); 49 | const jsonContent = JSON.stringify(parsed, null, 2); 50 | const outputFilePath = resolve(outDir, file.replace(/\.(yaml|yml)$/, '.json')); 51 | console.log(`---- Writing to ${outputFilePath}`); 52 | fs.writeFileSync(outputFilePath, jsonContent); 53 | } catch (error) { 54 | this.error(`---- Error parsing YAML file ${file}: ${error.message}`); 55 | } 56 | } 57 | } 58 | }, 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /src/i18n/zh_CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "openDocumentSearchTab": "打开搜索页签", 3 | "table": "表格", 4 | "mathBlock": "公式块", 5 | "quoteBlock": "引述块", 6 | "superBlock": "超级块", 7 | "paragraph": "段落", 8 | "doc": "文档", 9 | "headings": "标题", 10 | "list": "列表", 11 | "listItem": "列表项", 12 | "codeBlock": "代码块", 13 | "htmlBlock": "HTML 块", 14 | "embedBlock": "嵌入块", 15 | "database": "数据库", 16 | "video": "视频", 17 | "audio": "音频", 18 | "IFrame": "IFrame", 19 | "widget": "挂件", 20 | "name": "name", 21 | "alias": "别名", 22 | "memo": "备注", 23 | "allAttrs": "所有属性名和属性值", 24 | "sortByRankASC": "按相关度升序", 25 | "sortByRankDESC": "按相关度降序", 26 | "modifiedASC": "修改时间升序", 27 | "modifiedDESC": "修改时间降序", 28 | "createdASC": "创建时间升序", 29 | "createdDESC": "创建时间降序", 30 | "type": "类型", 31 | "sortByContent": "按原文内容顺序", 32 | "sortByTypeAndContent": "类型和原文内容顺序", 33 | "show": "显示", 34 | "hide": "隐藏", 35 | "refCountASC": "引用数升序", 36 | "refCountDESC": "引用数降序", 37 | "fileNameASC": "名称字母升序", 38 | "fileNameDESC": "名称字母降序", 39 | "fileNameNatASC": "名称自然升序", 40 | "fileNameNatDESC": "名称自然降序", 41 | "notebook": "笔记本", 42 | "path": "路径", 43 | "sort": "排序", 44 | "clear": "清空", 45 | "refresh": "刷新", 46 | "reference": "引用", 47 | "documentBasedSearch": "基于文档搜索", 48 | "flatDocumentTree": "扁平化文档树", 49 | "documentBasedSearchDock": "基于文档搜索 Dock", 50 | "flatDocumentTreeDock": "扁平化文档树 Dock", 51 | "previousLabel": "上一页", 52 | "nextLabel": "下一页", 53 | "findInDoc": "匹配到 ${x} 个文档", 54 | "notebookFilter": "笔记本过滤", 55 | "attr": "属性", 56 | "other": "其他", 57 | "expand": "展开", 58 | "collapse": "折叠", 59 | "noContentBelow": "下不存在符合条件的内容", 60 | "dockModifyTips": "注:修改 Dock 会刷新界面。", 61 | "settingDock": "🌈 Dock 设置", 62 | "settingNotebookFilter": "🌈 笔记本过滤", 63 | "settingType": "🌈 类型", 64 | "settingAttr": "🌈 属性", 65 | "settingOther": "🌈 其他", 66 | "docSortMethod": "文档排序方式", 67 | "contentBlockSortMethod": "内容块排序方式", 68 | "documentsPerPage": "每页文档数量", 69 | "blockCountBehaviorTips": "如果查询结果的块数量小于当前值,默认展开全部文档;反之会默认折叠全部文档。", 70 | "defaultExpansionCount": "默认展开数", 71 | "alwaysExpandSingleDoc": "单篇文档始终展开", 72 | "displayDocBlock": "显示文档块", 73 | "doubleClickTimeThreshold": "双击时间阈值(毫秒)", 74 | "previewRefreshHighlightDelayTips": "用于代码块、数据库这种需要时间渲染的块高亮,太短可能会失败,不需要可以设置为0", 75 | "previewRefreshHighlightDelay": "刷新预览区高亮延迟(毫秒)", 76 | "settingHub": "设置中心", 77 | "switchCurrentDocumentSearchFailureMessage": "文档搜索插件: 没有获取到打开的文档,可切换页签或重新打开文档。", 78 | "swapDocumentItemClickLogic": "交换文档项点击逻辑", 79 | "swapDocumentItemClickLogicTips": "禁用时,单击展开搜索结果,双击打开文档;启用时,单击打开文档,双击展开搜索结果。", 80 | "allNotebooks": "所有笔记本", 81 | "specifyNotebook": "指定笔记本", 82 | "searchInTheCurrentDocument": "在当前文档查询" 83 | 84 | } -------------------------------------------------------------------------------- /public/i18n/zh_CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "openDocumentSearchTab": "打开搜索页签", 3 | "table": "表格", 4 | "mathBlock": "公式块", 5 | "quoteBlock": "引述块", 6 | "superBlock": "超级块", 7 | "paragraph": "段落", 8 | "doc": "文档", 9 | "headings": "标题", 10 | "list": "列表", 11 | "listItem": "列表项", 12 | "codeBlock": "代码块", 13 | "htmlBlock": "HTML 块", 14 | "embedBlock": "嵌入块", 15 | "database": "数据库", 16 | "video": "视频", 17 | "audio": "音频", 18 | "IFrame": "IFrame", 19 | "widget": "挂件", 20 | "name": "name", 21 | "alias": "别名", 22 | "memo": "备注", 23 | "allAttrs": "所有属性名和属性值", 24 | "sortByRankASC": "按相关度升序", 25 | "sortByRankDESC": "按相关度降序", 26 | "modifiedASC": "修改时间升序", 27 | "modifiedDESC": "修改时间降序", 28 | "createdASC": "创建时间升序", 29 | "createdDESC": "创建时间降序", 30 | "type": "类型", 31 | "sortByContent": "按原文内容顺序", 32 | "sortByTypeAndContent": "类型和原文内容顺序", 33 | "show": "显示", 34 | "hide": "隐藏", 35 | "refCountASC": "引用数升序", 36 | "refCountDESC": "引用数降序", 37 | "fileNameASC": "名称字母升序", 38 | "fileNameDESC": "名称字母降序", 39 | "fileNameNatASC": "名称自然升序", 40 | "fileNameNatDESC": "名称自然降序", 41 | "notebook": "笔记本", 42 | "path": "路径", 43 | "sort": "排序", 44 | "clear": "清空", 45 | "refresh": "刷新", 46 | "reference": "引用", 47 | "documentBasedSearch": "基于文档搜索", 48 | "flatDocumentTree": "扁平化文档树", 49 | "documentBasedSearchDock": "基于文档搜索 Dock", 50 | "flatDocumentTreeDock": "扁平化文档树 Dock", 51 | "previousLabel": "上一页", 52 | "nextLabel": "下一页", 53 | "findInBacklink": "共 ${x} 个反链块", 54 | "notebookFilter": "笔记本过滤", 55 | "attr": "属性", 56 | "other": "其他", 57 | "expand": "展开", 58 | "collapse": "折叠", 59 | "noContentBelow": "下不存在符合条件的内容", 60 | "dockModifyTips": "注:修改 Dock 会刷新界面。", 61 | "settingDock": "🌈 Dock 设置", 62 | "settingNotebookFilter": "🌈 笔记本过滤", 63 | "settingType": "🌈 类型", 64 | "settingAttr": "🌈 属性", 65 | "settingOther": "🌈 其他", 66 | "docSortMethod": "文档排序方式", 67 | "contentBlockSortMethod": "内容块排序方式", 68 | "documentsPerPage": "每页文档数量", 69 | "blockCountBehaviorTips": "如果查询结果的块数量小于当前值,默认展开全部文档;反之会默认折叠全部文档。", 70 | "defaultExpansionCount": "默认展开数", 71 | "alwaysExpandSingleDoc": "单篇文档始终展开", 72 | "displayDocBlock": "显示文档块", 73 | "doubleClickTimeThreshold": "双击时间阈值(毫秒)", 74 | "previewRefreshHighlightDelayTips": "用于代码块、数据库这种需要时间渲染的块高亮,太短可能会失败,不需要可以设置为0", 75 | "previewRefreshHighlightDelay": "刷新预览区高亮延迟(毫秒)", 76 | "settingHub": "设置中心", 77 | "switchCurrentDocumentSearchFailureMessage": "文档搜索插件: 没有获取到打开的文档,可切换页签或重新打开文档。", 78 | "swapDocumentItemClickLogic": "交换文档项点击逻辑", 79 | "swapDocumentItemClickLogicTips": "禁用时,单击展开搜索结果,双击打开文档;启用时,单击打开文档,双击展开搜索结果。", 80 | "allNotebooks": "所有笔记本", 81 | "specifyNotebook": "指定笔记本", 82 | "searchInTheCurrentDocument": "在当前文档查询" 83 | 84 | } -------------------------------------------------------------------------------- /src/models/setting-model.ts: -------------------------------------------------------------------------------- 1 | import { isStrNotBlank } from "@/utils/string-util"; 2 | 3 | 4 | export class SettingConfig { 5 | /* 插件设置 */ 6 | dockDisplay: Boolean; 7 | // 文档底部显示反链面板 8 | documentBottomDisplay: boolean; 9 | // 闪卡底部显示反链面板 10 | flashCardBottomDisplay: boolean; 11 | // 顶部栏显示页签按钮 12 | topBarDisplay: boolean; 13 | // 缓存 14 | cacheAfterResponseMs: number; 15 | cacheExpirationTime: number; 16 | usePraentIdIdx: boolean; 17 | // 双击阈值 18 | doubleClickTimeout: number; 19 | 20 | // 文档底部反链面板宽度 21 | documentBottomBacklinkPaddingWidth: number 22 | 23 | 24 | 25 | /* 筛选面板 */ 26 | filterPanelViewExpand: boolean; 27 | queryParentDefBlock: boolean; 28 | querrChildDefBlockForListItem: boolean; 29 | queryChildDefBlockForHeadline: boolean; 30 | filterPanelCurDocDefBlockSortMethod: BlockSortMethod; 31 | filterPanelRelatedDefBlockSortMethod: BlockSortMethod; 32 | filterPanelBacklinkDocumentSortMethod: BlockSortMethod; 33 | // 默认选中查看块 34 | defaultSelectedViewBlock: boolean; 35 | 36 | 37 | /* 反链面板 */ 38 | docBottomBacklinkPanelViewExpand: boolean; 39 | pageSize: number; 40 | backlinkBlockSortMethod: BlockSortMethod; 41 | hideBacklinkProtyleBreadcrumb: boolean; 42 | defaultExpandedListItemLevel: number; 43 | // queryAllContentUnderHeadline: boolean; 44 | 45 | 46 | 47 | } 48 | 49 | 50 | interface ITabProperty { 51 | key: string; 52 | name: string; 53 | props: Array; 54 | iconKey?: string; 55 | } 56 | 57 | 58 | export class TabProperty { 59 | key: string; 60 | name: string; 61 | iconKey: string; 62 | props: ItemProperty[]; 63 | 64 | constructor({ key, name, iconKey, props }: ITabProperty) { 65 | this.key = key; 66 | this.name = name; 67 | if (isStrNotBlank(iconKey)) { 68 | this.iconKey = iconKey; 69 | } else { 70 | this.iconKey = "setting"; 71 | } 72 | this.props = props; 73 | 74 | } 75 | 76 | } 77 | 78 | export interface IOption { 79 | name: string; 80 | desc?: string; 81 | value: string; 82 | } 83 | 84 | 85 | 86 | 87 | export class ItemProperty { 88 | key: string; 89 | type: IItemPropertyType; 90 | name: string; 91 | description: string; 92 | tips?: string; 93 | 94 | min?: number; 95 | max?: number; 96 | btndo?: () => void; 97 | options?: IOption[]; 98 | 99 | 100 | constructor({ key, type, name, description, tips, min, max, btndo, options }: ItemProperty) { 101 | this.key = key; 102 | this.type = type; 103 | this.min = min; 104 | this.max = max; 105 | this.btndo = btndo; 106 | this.options = options ?? []; 107 | this.name = name; 108 | this.description = description; 109 | this.tips = tips; 110 | } 111 | 112 | } 113 | -------------------------------------------------------------------------------- /src/components/setting/setting-page.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 |
25 |
    26 | {#each tabArray as tab} 27 |
  • { 32 | activeTab = tab.key; 33 | }} 34 | on:keydown={handleKeyDownDefault} 35 | > 36 | 37 | 38 | 39 | {tab.name} 40 |
  • 41 | {/each} 42 |
43 |
44 | {#each tabArray as tab} 45 | {#if activeTab === tab.key} 46 |
47 | {#each tab.props as itemProperty} 48 | 49 | {#if itemProperty.type == "switch"} 50 | 51 | {:else if itemProperty.type == "select"} 52 | 53 | {:else if itemProperty.type == "number" || itemProperty.type == "text"} 54 | 55 | {:else} 56 | 不能载入设置项,请检查设置代码实现。 Key: {itemProperty.key} 57 |
58 | can't load settings, check code please. Key: 59 | {itemProperty.key} 60 | {/if} 61 |
62 | {/each} 63 |
64 | {/if} 65 | {/each} 66 |
67 |
68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 相关概念 2 | 3 | 本插件中涉及的概念如下:(例如块A引用了文档1的块B和文档2块C,现在进入块B所在文档1查看反链) 4 | 5 | * 定义块:文档1中被引用的内容块,即块B 6 | * 反链块:引用定义块的内容块,即块A 7 | * 关联的定义块:反链块所在路径/父级路径/子级路径引用的其他文档的内容块,即块C 8 | 9 | ## 功能简述 10 | 11 | *注:大部分功能都是“所见即所得”;根据本插件设置中的三项,依次对功能简述* 12 | 13 | ### (1)整体功能 14 | 15 | 本插件包括: 16 | 17 | * 文档底部反链 18 | * docker侧栏 19 | * 页签分栏显示 20 | 21 | 前两个可在插件设置中调节是否显示,后一个的按钮在顶栏 22 | 23 | ### (2)筛选面板 24 | 25 | * 筛选面板锚文本筛选状态解释: 26 | 27 | ![image-20240903111116-oq73dy0](asset/image-20240903111116-oq73dy0.png)​ 28 | 29 | * 绿色:选定包含的定义块 30 | * 黑色:选定排除的定义块(右上角的的数字会一直是0,如果不为0说明出bug了) 31 | * 白色:待选中 32 | 33 | *注:如果定义块没有块标(图片中箭头所指块),说明该定义块所链接的块不存在(可能是被删除了)* 34 | * 筛选面板对锚文本的操作: 35 | 36 | * 电脑端: 37 | 38 | * 包含:左键点击锚文本 39 | * 排除:Shift + click​、右击、双击(需要先设置双击间隔) 40 | * 手机端: 41 | 42 | * 包含:单击锚文本 43 | * 排除:长按锚文本 44 | * 正如其名,“当前文档定义块”和“关联的定义块”**显示的其实都是定义块的文本内容**,而非双链自身的锚文本内容 45 | * `当前文档定义块`​:用于汇总查询分布在不同文档定义块的反链,分为如下几种: 46 | 47 | 1. `当前文档定义块`​:本文档被引用的内容块 48 | 2. `引用其他文档的定义块`​:在文档中引用其他块,选择此项就可以查看引用的所有定义块它们的反链 49 | 50 | ![image](asset/image-20241111193027-qjaj4by.png)​ 51 | * `关联的定义块`​:`当前文档定义块`​对应反链内容中引用其他文档的内容块 52 | 53 | * 可在设置中选择关联的定义块的范围: 54 | 55 | *解释:如果在插件设置中启用*​*​`查询父级关联定义块`​*​ *,那么反链面板中反链块的上级路径中的所有引用的定义块也会出现在筛选面板* 56 | 57 | ![image](asset/image-20240813225800-ncnfkrq.png) 58 | * 可筛选“静态锚文本”和“动态锚文本”。鼠标悬浮在静态锚文本上**会同时显示锚文本和定义块的文本内容** 59 | * 这里的搜索功能**既可以搜索双链锚文本,也可以搜索双链引用的内容** 60 | * `反链所在文档`​: 61 | 62 | 排序和筛选反链块所在的文档块。目前同时只能选中一个文档,不支持多个 63 | 64 | ### (3)反链面板 65 | 66 | *注:这里所说的反链面板是指显示反链块的区域* 67 | 68 | * 本插件此处的展示逻辑与官方不一样:官方是以含有反链块的文档分组排序;本插件以反链块为单位展示和排序 69 | * 插件设置中关于底部反链的显示: 70 | 71 | ![image-20240903111620-7h2q3bn](asset/image-20240903111620-7h2q3bn.png)​ 72 | 73 | 除了这个总设置外,可在特定文档中设置是否显示底部反链。点击`文档块标 - 插件 - 反链过滤面板`​快捷选择,本质是通过添加自定义属性「document-bottom-show-backlink-filter-panel」选择特定文档是否显示底部反链区域: 74 | 75 | 1. `恢复默认`​:删除自定义属性「document-bottom-show-backlink-filter-panel」,该文档是否显示底部反链与插件设置保持一致 76 | 2. `始终显示文档底部反链`​:添加自定义属性「document-bottom-show-backlink-filter-panel」,值为1,无论插件设置如何,该文档都显示底部反链 77 | 3. `始终隐藏文档底部反链`​:添加自定义属性「document-bottom-show-backlink-filter-panel」,值为-1,无论插件设置如何,该文档都隐藏底部反链 78 | 79 | * 反链面板的搜索功能: 80 | 81 | * 已支持搜索文档名,可以同时搜索文档名+反链块中的关键词 82 | 83 | ![image](asset/image-20241111194306-v7zompa.png) 84 | * 搜索框支持一些搜索语法: `%`​匹配锚文本内容,`-`​排除内容, `-%`​ 或`%-`​排除锚文本内容。 可以混合使用 85 | 86 | |筛选语法前缀|内容|锚文本内容| 87 | | --------------| --------| ------------| 88 | |筛选|无前缀|​`%`​| 89 | |包含|空格|空格| 90 | |排除|​`-`​|​`%-`​ 或 `-%`​| 91 | 92 | *注:此处所说的“内容”是指反链块所有的文本内容(包括反链块所在的文档名称);而“锚文本内容”仅指反链块中用作块引用锚文本的文本内容(无论静态还是动态锚文本)。因此“内容”是包含“锚文本内容”的* 93 | * 反链面板在搜索关键词时,筛选面板中的各项可筛选范围也会相应改变 94 | * Ctrl + click​ 反链区域的文档名/面包屑可以跳转到指定文档,这与官方反链面板逻辑一致 95 | 96 | ### (4)其他技巧 97 | 98 | * 定义块的时间排序是根据定义块所在的反链块时间进行排序 99 | * 已支持「保存当前条件」,保存的是包含筛选面板和反链面板的所有条件 100 | * 开启本插件设置的`筛选面板 - 默认选中查看块`​,筛选面板会默认选中文档块,实现类似于思源设置中取消“反向链接包含子块”的效果: 101 | 102 | ![image](asset/image-20241116174719-i9k9qih.png)​ 103 | 104 | -------------------------------------------------------------------------------- /README_zh_CN.md: -------------------------------------------------------------------------------- 1 | ## 相关概念 2 | 3 | 本插件中涉及的概念如下:(例如块A引用了文档1的块B和文档2块C,现在进入块B所在文档1查看反链) 4 | 5 | * 定义块:文档1中被引用的内容块,即块B 6 | * 反链块:引用定义块的内容块,即块A 7 | * 关联的定义块:反链块所在路径/父级路径/子级路径引用的其他文档的内容块,即块C 8 | 9 | ## 功能简述 10 | 11 | *注:大部分功能都是“所见即所得”;根据本插件设置中的三项,依次对功能简述* 12 | 13 | ### (1)整体功能 14 | 15 | 本插件包括: 16 | 17 | * 文档底部反链 18 | * docker侧栏 19 | * 页签分栏显示 20 | 21 | 前两个可在插件设置中调节是否显示,后一个的按钮在顶栏 22 | 23 | ### (2)筛选面板 24 | 25 | * 筛选面板锚文本筛选状态解释: 26 | 27 | ![image-20240903111116-oq73dy0](asset/image-20240903111116-oq73dy0.png)​ 28 | 29 | * 绿色:选定包含的定义块 30 | * 黑色:选定排除的定义块(右上角的的数字会一直是0,如果不为0说明出bug了) 31 | * 白色:待选中 32 | 33 | *注:如果定义块没有块标(图片中箭头所指块),说明该定义块所链接的块不存在(可能是被删除了)* 34 | * 筛选面板对锚文本的操作: 35 | 36 | * 电脑端: 37 | 38 | * 包含:左键点击锚文本 39 | * 排除:Shift + click​、右击、双击(需要先设置双击间隔) 40 | * 手机端: 41 | 42 | * 包含:单击锚文本 43 | * 排除:长按锚文本 44 | * 正如其名,“当前文档定义块”和“关联的定义块”**显示的其实都是定义块的文本内容**,而非双链自身的锚文本内容 45 | * `当前文档定义块`​:用于汇总查询分布在不同文档定义块的反链,分为如下几种: 46 | 47 | 1. `当前文档定义块`​:本文档被引用的内容块 48 | 2. `引用其他文档的定义块`​:在文档中引用其他块,选择此项就可以查看引用的所有定义块它们的反链 49 | 50 | ![image](asset/image-20241111193027-qjaj4by.png)​ 51 | * `关联的定义块`​:`当前文档定义块`​对应反链内容中引用其他文档的内容块 52 | 53 | * 可在设置中选择关联的定义块的范围: 54 | 55 | *解释:如果在插件设置中启用*​*​`查询父级关联定义块`​*​ *,那么反链面板中反链块的上级路径中的所有引用的定义块也会出现在筛选面板* 56 | 57 | ![image](asset/image-20240813225800-ncnfkrq.png) 58 | * 可筛选“静态锚文本”和“动态锚文本”。鼠标悬浮在静态锚文本上**会同时显示锚文本和定义块的文本内容** 59 | * 这里的搜索功能**既可以搜索双链锚文本,也可以搜索双链引用的内容** 60 | * `反链所在文档`​: 61 | 62 | 排序和筛选反链块所在的文档块。目前同时只能选中一个文档,不支持多个 63 | 64 | ### (3)反链面板 65 | 66 | *注:这里所说的反链面板是指显示反链块的区域* 67 | 68 | * 本插件此处的展示逻辑与官方不一样:官方是以含有反链块的文档分组排序;本插件以反链块为单位展示和排序 69 | * 插件设置中关于底部反链的显示: 70 | 71 | ![image-20240903111620-7h2q3bn](asset/image-20240903111620-7h2q3bn.png)​ 72 | 73 | 除了这个总设置外,可在特定文档中设置是否显示底部反链。点击`文档块标 - 插件 - 反链过滤面板`​快捷选择,本质是通过添加自定义属性「document-bottom-show-backlink-filter-panel」选择特定文档是否显示底部反链区域: 74 | 75 | 1. `恢复默认`​:删除自定义属性「document-bottom-show-backlink-filter-panel」,该文档是否显示底部反链与插件设置保持一致 76 | 2. `始终显示文档底部反链`​:添加自定义属性「document-bottom-show-backlink-filter-panel」,值为1,无论插件设置如何,该文档都显示底部反链 77 | 3. `始终隐藏文档底部反链`​:添加自定义属性「document-bottom-show-backlink-filter-panel」,值为-1,无论插件设置如何,该文档都隐藏底部反链 78 | 79 | * 反链面板的搜索功能: 80 | 81 | * 已支持搜索文档名,可以同时搜索文档名+反链块中的关键词 82 | 83 | ![image](asset/image-20241111194306-v7zompa.png) 84 | * 搜索框支持一些搜索语法: `%`​匹配锚文本内容,`-`​排除内容, `-%`​ 或`%-`​排除锚文本内容。 可以混合使用 85 | 86 | |筛选语法前缀|内容|锚文本内容| 87 | | --------------| --------| ------------| 88 | |筛选|无前缀|​`%`​| 89 | |包含|空格|空格| 90 | |排除|​`-`​|​`%-`​ 或 `-%`​| 91 | 92 | *注:此处所说的“内容”是指反链块所有的文本内容(包括反链块所在的文档名称);而“锚文本内容”仅指反链块中用作块引用锚文本的文本内容(无论静态还是动态锚文本)。因此“内容”是包含“锚文本内容”的* 93 | * 反链面板在搜索关键词时,筛选面板中的各项可筛选范围也会相应改变 94 | * Ctrl + click​ 反链区域的文档名/面包屑可以跳转到指定文档,这与官方反链面板逻辑一致 95 | 96 | ### (4)其他技巧 97 | 98 | * 定义块的时间排序是根据定义块所在的反链块时间进行排序 99 | * 已支持「保存当前条件」,保存的是包含筛选面板和反链面板的所有条件 100 | * 开启本插件设置的`筛选面板 - 默认选中查看块`​,筛选面板会默认选中文档块,实现类似于思源设置中取消“反向链接包含子块”的效果: 101 | 102 | ![image](asset/image-20241116174719-i9k9qih.png)​ 103 | 104 | -------------------------------------------------------------------------------- /src/config/CacheManager.ts: -------------------------------------------------------------------------------- 1 | import { BacklinkPanelFilterCriteria, IBacklinkFilterPanelData } from "@/models/backlink-model"; 2 | import { CacheUtil, generateKey } from "@/utils/cache-util"; 3 | import Instance from "@/utils/Instance"; 4 | 5 | 6 | const DESTROY_BACKLINK_TAB_CACHE_KEY = "DESTORY_BACKLINK_TAB_CACHE_KEY" 7 | 8 | export class CacheManager { 9 | 10 | 11 | public static get ins(): CacheManager { 12 | return Instance.get(CacheManager); 13 | } 14 | 15 | private backlinkPanelBaseDataCache: CacheUtil = new CacheUtil(); 16 | private backlinkDocApiDataCache: CacheUtil = new CacheUtil(); 17 | private backlinkFilterPanelLastCriteriaCache: CacheUtil = new CacheUtil(); 18 | private backlinkPanelSavedCriteriaCache: CacheUtil = new CacheUtil(); 19 | 20 | // 毫秒 21 | private dayTtl: number = 24 * 60 * 60 * 1000; 22 | 23 | 24 | 25 | public setBacklinkPanelBaseData(rootId: string, value: IBacklinkFilterPanelData, ttlSeconds: number) { 26 | this.backlinkPanelBaseDataCache.set(rootId, value, ttlSeconds * 1000); 27 | } 28 | public getBacklinkPanelBaseData(rootId: string): IBacklinkFilterPanelData { 29 | return this.backlinkPanelBaseDataCache.get(rootId); 30 | } 31 | public deleteBacklinkPanelBaseData(rootId: string) { 32 | this.backlinkPanelBaseDataCache.delete(rootId); 33 | } 34 | 35 | public setBacklinkDocApiData(defId: string, rootId: string, keyword: string, value: any, ttlSeconds: number) { 36 | let key = generateKey(defId, rootId, keyword); 37 | this.backlinkDocApiDataCache.set(key, value, ttlSeconds * 1000); 38 | } 39 | public getBacklinkDocApiData(rootId: string, defId: string, backlinkRootId: string, keyword: string): any { 40 | let key = generateKey(rootId, defId, backlinkRootId, keyword); 41 | return this.backlinkDocApiDataCache.get(key); 42 | } 43 | public deleteBacklinkDocApiData(defId: string,) { 44 | this.backlinkDocApiDataCache.clearByPrefix(defId); 45 | } 46 | 47 | public deleteBacklinkPanelAllCache(rootId: string) { 48 | this.deleteBacklinkPanelBaseData(rootId); 49 | this.deleteBacklinkDocApiData(rootId); 50 | } 51 | 52 | 53 | public setBacklinkFilterPanelLastCriteria(rootId: string, value: BacklinkPanelFilterCriteria) { 54 | this.backlinkFilterPanelLastCriteriaCache.set(rootId, value, this.dayTtl); 55 | } 56 | public getBacklinkFilterPanelLastCriteria(rootId: string): BacklinkPanelFilterCriteria { 57 | return this.backlinkFilterPanelLastCriteriaCache.get(rootId); 58 | } 59 | 60 | 61 | public setBacklinkPanelSavedCriteria(rootId: string, value: any) { 62 | this.backlinkPanelSavedCriteriaCache.set(rootId, value, this.dayTtl); 63 | } 64 | public getBacklinkPanelSavedCriteria(rootId: string): any { 65 | return this.backlinkPanelSavedCriteriaCache.get(rootId); 66 | } 67 | 68 | 69 | public addBacklinkDestoryTabDocId(docId: string) { 70 | this.backlinkPanelSavedCriteriaCache.setByPrefix(DESTROY_BACKLINK_TAB_CACHE_KEY, docId, docId, 2 * 3600 * 1000); 71 | } 72 | 73 | public getAndRemoveBacklinkDestoryTabDocIdArray(): string[] { 74 | return this.backlinkPanelSavedCriteriaCache.popByPrefix(DESTROY_BACKLINK_TAB_CACHE_KEY); 75 | } 76 | } -------------------------------------------------------------------------------- /src/components/dock/backlink-filter-panel-dock.svelte: -------------------------------------------------------------------------------- 1 | 87 | 88 | {#if isMobile} 89 | 95 |
96 | 102 |
103 | {:else} 104 |
105 | 111 |
112 | {/if} 113 | -------------------------------------------------------------------------------- /src/utils/icon-util.ts: -------------------------------------------------------------------------------- 1 | 2 | export function convertIconInIal(icon: string): string { 3 | if (icon) { 4 | if (icon.includes(".")) { 5 | // 如果包含 ".",则认为是图片,生成标签 6 | return ``; 7 | } else { 8 | // 如果是Emoji,转换为表情符号 9 | let emoji = ""; 10 | try { 11 | icon.split("-").forEach(item => { 12 | if (item.length < 5) { 13 | emoji += String.fromCodePoint(parseInt("0" + item, 16)); 14 | } else { 15 | emoji += String.fromCodePoint(parseInt(item, 16)); 16 | } 17 | }); 18 | } catch (e) { 19 | // 自定义表情搜索报错 https://github.com/siyuan-note/siyuan/issues/5883 20 | // 这里忽略错误不做处理 21 | } 22 | return emoji; 23 | } 24 | } 25 | // 既不是Emoji也不是图片,返回null 26 | return null; 27 | } 28 | 29 | export function convertIalStringToObject(ial: string): { [key: string]: string } { 30 | const keyValuePairs = ial.match(/\w+="[^"]*"/g); 31 | 32 | if (!keyValuePairs) { 33 | return {}; 34 | } 35 | 36 | const resultObject: { [key: string]: string } = {}; 37 | 38 | keyValuePairs.forEach((pair) => { 39 | const [key, value] = pair.split('='); 40 | resultObject[key] = value.replace(/"/g, ''); // 去除值中的双引号 41 | }); 42 | 43 | return resultObject; 44 | } 45 | 46 | 47 | 48 | export function getBlockTypeIconHref(type: string, subType: string): string { 49 | let iconHref = ""; 50 | if (type) { 51 | if (type === "d") { 52 | iconHref = "#iconFile"; 53 | } else if (type === "h") { 54 | if (subType === "h1") { 55 | iconHref = "#iconH1"; 56 | } else if (subType === "h2") { 57 | iconHref = "#iconH2"; 58 | } else if (subType === "h3") { 59 | iconHref = "#iconH3"; 60 | } else if (subType === "h4") { 61 | iconHref = "#iconH4"; 62 | } else if (subType === "h5") { 63 | iconHref = "#iconH5"; 64 | } else if (subType === "h6") { 65 | iconHref = "#iconH6"; 66 | } 67 | } else if (type === "c") { 68 | iconHref = "#iconCode"; 69 | } else if (type === "html") { 70 | iconHref = "#iconHTML5"; 71 | } else if (type === "p") { 72 | iconHref = "#iconParagraph"; 73 | } else if (type === "m") { 74 | iconHref = "#iconMath"; 75 | } else if (type === "t") { 76 | iconHref = "#iconTable"; 77 | } else if (type === "b") { 78 | iconHref = "#iconQuote"; 79 | } else if (type === "l") { 80 | if (subType === "o") { 81 | iconHref = "#iconOrderedList"; 82 | } else if (subType === "u") { 83 | iconHref = "#iconList"; 84 | } else if (subType === "t") { 85 | iconHref = "#iconCheck"; 86 | } 87 | } else if (type === "i") { 88 | iconHref = "#iconListItem"; 89 | } else if (type === "av") { 90 | iconHref = "#iconDatabase"; 91 | } else if (type === "s") { 92 | iconHref = "#iconSuper"; 93 | } else if (type === "audio") { 94 | iconHref = "#iconRecord"; 95 | } else if (type === "video") { 96 | iconHref = "#iconVideo"; 97 | } else if (type === "query_embed") { 98 | iconHref = "#iconSQL"; 99 | } else if (type === "tb") { 100 | iconHref = "#iconLine"; 101 | } else if (type === "widget") { 102 | iconHref = "#iconBoth"; 103 | } else if (type === "iframe") { 104 | iconHref = "#iconLanguage"; 105 | } 106 | } 107 | return iconHref; 108 | } -------------------------------------------------------------------------------- /src/i18n/en_US.json: -------------------------------------------------------------------------------- 1 | { 2 | "openDocumentSearchTab": "Open Document-based Search Tab", 3 | "table": "Table", 4 | "mathBlock": "Formula block", 5 | "quoteBlock": "Blockquote", 6 | "superBlock": "Super block", 7 | "paragraph": "Paragraph", 8 | "doc": "Doc", 9 | "headings": "Headings", 10 | "list": "List", 11 | "listItem": "List item", 12 | "codeBlock": "Code block", 13 | "htmlBlock": "HTML block", 14 | "embedBlock": "Embed block", 15 | "database": "Database", 16 | "video": "Video", 17 | "audio": "Audio", 18 | "IFrame": "IFrame", 19 | "widget": "Widget", 20 | "name": "Name", 21 | "alias": "Alias", 22 | "memo": "Memo", 23 | "allAttrs": "All attribute names and attribute values", 24 | "sortByRankASC": "Relevance ASC", 25 | "sortByRankDESC": "Relevance DESC", 26 | "modifiedASC": "Modified Time ASC", 27 | "modifiedDESC": "Modified Time DESC", 28 | "createdASC": "Created Time ASC", 29 | "createdDESC": "Created Time DESC", 30 | "type": "Type", 31 | "sortByContent": "Original content order", 32 | "sortByTypeAndContent": "Type And Original content order", 33 | "show": "Show", 34 | "hide": "Hide", 35 | "refCountASC": "Ref Count ASC", 36 | "refCountDESC": "Ref Count DESC", 37 | "fileNameASC": "Name Alphabet ASC", 38 | "fileNameDESC": "Name Alphabet DESC", 39 | "fileNameNatASC": "Name Natural ASC", 40 | "fileNameNatDESC": "Name Natural DESC", 41 | "notebook": "Notebook", 42 | "path": "Path", 43 | "sort": "Sort", 44 | "clear": "Clear", 45 | "refresh": "Refresh", 46 | "reference": "Ref", 47 | "documentBasedSearch": "Document-based Search", 48 | "flatDocumentTree": "Flat Document Tree", 49 | "documentBasedSearchDock": "Document-based Search Dock", 50 | "flatDocumentTreeDock": "Flat Document Tree Dock", 51 | "previousLabel": "Previous", 52 | "nextLabel": "Next", 53 | "findInDoc": "Found ${x} documents", 54 | "notebookFilter": "Notebook Filter", 55 | "attr": "Attribute", 56 | "other": "Other", 57 | "expand": "Expand", 58 | "collapse": "Collapse", 59 | "noContentBelow": "No content matching the criteria exists below", 60 | "dockModifyTips": "Note: Modifying Dock will refresh the interface.", 61 | "settingDock": "🌈 Dock Settings", 62 | "settingNotebookFilter": "🌈 Notebook Filter", 63 | "settingType": "🌈 Type", 64 | "settingAttr": "🌈 Attribute", 65 | "settingOther": "🌈 Other", 66 | "docSortMethod": "Document Sorting Method", 67 | "contentBlockSortMethod": "Content Block Sorting Method", 68 | "documentsPerPage": "Documents Per Page", 69 | "blockCountBehaviorTips": "If the number of blocks in the query result is less than the current value, all documents will be expanded by default; otherwise, all documents will be collapsed by default.", 70 | "defaultExpansionCount": "Default Expansion Count", 71 | "alwaysExpandSingleDoc": "Always Expand Single Document", 72 | "displayDocBlock": "Display Document Blocks", 73 | "doubleClickTimeThreshold": "Double Click Time Threshold (Milliseconds)", 74 | "previewRefreshHighlightDelayTips": "For code blocks, databases, and other blocks that require time to render, too short a delay may fail. Set to 0 if not needed.", 75 | "previewRefreshHighlightDelay": "Preview Refresh Highlight Delay (Milliseconds)", 76 | "settingHub": "Settings Hub", 77 | "switchCurrentDocumentSearchFailureMessage": "Document Search Plugin: Failed to retrieve the opened document. You can switch tabs or reopen the document.", 78 | "swapDocumentItemClickLogic": "Swap Document Item Click Logic", 79 | "swapDocumentItemClickLogicTips": "When disabled, click to expand search results and double-click to open the document; when enabled, click to open the document and double-click to expand search results.", 80 | "allNotebooks": "All Notebooks", 81 | "specifyNotebook": "Specify Notebook", 82 | "searchInTheCurrentDocument": "Search in the current document" 83 | } -------------------------------------------------------------------------------- /public/i18n/en_US.json: -------------------------------------------------------------------------------- 1 | { 2 | "openDocumentSearchTab": "Open Document-based Search Tab", 3 | "table": "Table", 4 | "mathBlock": "Formula block", 5 | "quoteBlock": "Blockquote", 6 | "superBlock": "Super block", 7 | "paragraph": "Paragraph", 8 | "doc": "Doc", 9 | "headings": "Headings", 10 | "list": "List", 11 | "listItem": "List item", 12 | "codeBlock": "Code block", 13 | "htmlBlock": "HTML block", 14 | "embedBlock": "Embed block", 15 | "database": "Database", 16 | "video": "Video", 17 | "audio": "Audio", 18 | "IFrame": "IFrame", 19 | "widget": "Widget", 20 | "name": "Name", 21 | "alias": "Alias", 22 | "memo": "Memo", 23 | "allAttrs": "All attribute names and attribute values", 24 | "sortByRankASC": "Relevance ASC", 25 | "sortByRankDESC": "Relevance DESC", 26 | "modifiedASC": "Modified Time ASC", 27 | "modifiedDESC": "Modified Time DESC", 28 | "createdASC": "Created Time ASC", 29 | "createdDESC": "Created Time DESC", 30 | "type": "Type", 31 | "sortByContent": "Original content order", 32 | "sortByTypeAndContent": "Type And Original content order", 33 | "show": "Show", 34 | "hide": "Hide", 35 | "refCountASC": "Ref Count ASC", 36 | "refCountDESC": "Ref Count DESC", 37 | "fileNameASC": "Name Alphabet ASC", 38 | "fileNameDESC": "Name Alphabet DESC", 39 | "fileNameNatASC": "Name Natural ASC", 40 | "fileNameNatDESC": "Name Natural DESC", 41 | "notebook": "Notebook", 42 | "path": "Path", 43 | "sort": "Sort", 44 | "clear": "Clear", 45 | "refresh": "Refresh", 46 | "reference": "Ref", 47 | "documentBasedSearch": "Document-based Search", 48 | "flatDocumentTree": "Flat Document Tree", 49 | "documentBasedSearchDock": "Document-based Search Dock", 50 | "flatDocumentTreeDock": "Flat Document Tree Dock", 51 | "previousLabel": "Previous", 52 | "nextLabel": "Next", 53 | "findInBacklink": "Found ${x} backlink blocks", 54 | "notebookFilter": "Notebook Filter", 55 | "attr": "Attribute", 56 | "other": "Other", 57 | "expand": "Expand", 58 | "collapse": "Collapse", 59 | "noContentBelow": "No content matching the criteria exists below", 60 | "dockModifyTips": "Note: Modifying Dock will refresh the interface.", 61 | "settingDock": "🌈 Dock Settings", 62 | "settingNotebookFilter": "🌈 Notebook Filter", 63 | "settingType": "🌈 Type", 64 | "settingAttr": "🌈 Attribute", 65 | "settingOther": "🌈 Other", 66 | "docSortMethod": "Document Sorting Method", 67 | "contentBlockSortMethod": "Content Block Sorting Method", 68 | "documentsPerPage": "Documents Per Page", 69 | "blockCountBehaviorTips": "If the number of blocks in the query result is less than the current value, all documents will be expanded by default; otherwise, all documents will be collapsed by default.", 70 | "defaultExpansionCount": "Default Expansion Count", 71 | "alwaysExpandSingleDoc": "Always Expand Single Document", 72 | "displayDocBlock": "Display Document Blocks", 73 | "doubleClickTimeThreshold": "Double Click Time Threshold (Milliseconds)", 74 | "previewRefreshHighlightDelayTips": "For code blocks, databases, and other blocks that require time to render, too short a delay may fail. Set to 0 if not needed.", 75 | "previewRefreshHighlightDelay": "Preview Refresh Highlight Delay (Milliseconds)", 76 | "settingHub": "Settings Hub", 77 | "switchCurrentDocumentSearchFailureMessage": "Document Search Plugin: Failed to retrieve the opened document. You can switch tabs or reopen the document.", 78 | "swapDocumentItemClickLogic": "Swap Document Item Click Logic", 79 | "swapDocumentItemClickLogicTips": "When disabled, click to expand search results and double-click to open the document; when enabled, click to open the document and double-click to expand search results.", 80 | "allNotebooks": "All Notebooks", 81 | "specifyNotebook": "Specify Notebook", 82 | "searchInTheCurrentDocument": "Search in the current document" 83 | } -------------------------------------------------------------------------------- /src/utils/string-util.ts: -------------------------------------------------------------------------------- 1 | import { isArrayEmpty } from "./array-util"; 2 | 3 | export function removePrefix(input: string, prefix: string): string { 4 | if (input.startsWith(prefix)) { 5 | return input.substring(prefix.length); 6 | } else { 7 | return input; 8 | } 9 | } 10 | 11 | export function removeSuffix(input: string, suffix: string): string { 12 | if (input.endsWith(suffix)) { 13 | return input.substring(0, input.length - suffix.length); 14 | } else { 15 | return input; 16 | } 17 | } 18 | 19 | export function removePrefixAndSuffix(input: string, prefix: string, suffix: string): string { 20 | let result = input; 21 | 22 | if (result.startsWith(prefix)) { 23 | result = result.substring(prefix.length); 24 | } 25 | 26 | if (result.endsWith(suffix)) { 27 | result = result.substring(0, result.length - suffix.length); 28 | } 29 | 30 | return result; 31 | } 32 | 33 | export function containsAllKeywords( 34 | str: string, 35 | keywords: string[], 36 | ): boolean { 37 | return keywords.every(keyword => str.includes(keyword)); 38 | } 39 | 40 | 41 | export function matchKeywords( 42 | str: string, 43 | includeKeywords: string[], 44 | excludeKeywords: string[], 45 | ): boolean { 46 | // 检查每个包含关键词是否都出现在字符串中 47 | for (const keyword of includeKeywords) { 48 | if (!str.includes(keyword)) { 49 | return false; // 如果某个包含关键词不在字符串中,返回 false 50 | } 51 | } 52 | 53 | // 检查每个排除关键词是否都不出现在字符串中 54 | for (const keyword of excludeKeywords) { 55 | if (str.includes(keyword)) { 56 | return false; // 如果某个排除关键词在字符串中,返回 false 57 | } 58 | } 59 | 60 | return true; // 如果满足条件,返回 true 61 | } 62 | 63 | 64 | 65 | export function longestCommonSubstring(s1: string, s2: string): string { 66 | if (!s1 || !s2) { 67 | return ""; 68 | } 69 | s1 = s1 ? s1 : ""; 70 | s2 = s2 ? s2 : ""; 71 | const len1 = s1.length; 72 | const len2 = s2.length; 73 | const dp: number[][] = Array.from({ length: len1 + 1 }, () => 74 | Array(len2 + 1).fill(0), 75 | ); 76 | 77 | let maxLength = 0; 78 | let endIndex = 0; 79 | 80 | for (let i = 1; i <= len1; i++) { 81 | for (let j = 1; j <= len2; j++) { 82 | if (s1[i - 1] === s2[j - 1]) { 83 | dp[i][j] = dp[i - 1][j - 1] + 1; 84 | if (dp[i][j] > maxLength) { 85 | maxLength = dp[i][j]; 86 | endIndex = i; 87 | } 88 | } 89 | } 90 | } 91 | 92 | return s1.substring(endIndex - maxLength, endIndex); 93 | } 94 | 95 | 96 | export function countOccurrences(str: string, subStr: string): number { 97 | // 创建一个正则表达式,全局搜索指定的子字符串 98 | const regex = new RegExp(subStr, 'g'); 99 | // 使用 match 方法匹配所有出现的子字符串,返回匹配结果数组 100 | const matches = str.match(regex); 101 | // 返回匹配的次数,如果没有匹配到则返回 0 102 | return matches ? matches.length : 0; 103 | } 104 | 105 | /** 106 | * 判定字符串是否有效 107 | * @param s 需要检查的字符串(或其他类型的内容) 108 | * @returns true / false 是否为有效的字符串 109 | */ 110 | export function isStrNotBlank(s: any): boolean { 111 | if (s == undefined || s == null || s === '') { 112 | return false; 113 | } 114 | return true; 115 | } 116 | 117 | export function isStrBlank(s: any): boolean { 118 | return !isStrNotBlank(s); 119 | } 120 | 121 | 122 | export function splitKeywordStringToArray(keywordStr: string): string[] { 123 | let keywordArray = []; 124 | if (!isStrNotBlank(keywordStr)) { 125 | return keywordArray; 126 | } 127 | // 分离空格 128 | keywordArray = keywordStr.trim().replace(/\s+/g, " ").split(" "); 129 | if (isArrayEmpty(keywordArray)) { 130 | return keywordArray; 131 | } 132 | // 去重 133 | keywordArray = Array.from(new Set( 134 | keywordArray.filter((keyword) => keyword.length > 0), 135 | )); 136 | return keywordArray; 137 | 138 | } -------------------------------------------------------------------------------- /src/service/plugin/TabService.ts: -------------------------------------------------------------------------------- 1 | import { EnvConfig } from "@/config/EnvConfig"; 2 | import BacklinkFilterPanelPageSvelte from "@/components/panel/backlink-filter-panel-page.svelte"; 3 | import Instance from "@/utils/Instance"; 4 | import { openTab } from "siyuan"; 5 | import { CUSTOM_ICON_MAP } from "@/models/icon-constant"; 6 | import { isStrBlank } from "@/utils/string-util"; 7 | import { clearProtyleGutters, getActiveTab } from "@/utils/html-util"; 8 | import { CacheManager } from "@/config/CacheManager"; 9 | 10 | 11 | const BACKLINK_TAB_PREFIX = "backlink_tab_" 12 | 13 | export class TabService { 14 | 15 | 16 | public static get ins(): TabService { 17 | return Instance.get(TabService); 18 | } 19 | 20 | public init() { 21 | EnvConfig.ins.plugin.addCommand({ 22 | langKey: "showDocumentBacklinkPanelTab", 23 | langText: "显示当前文反链过滤面板页签", 24 | hotkey: "⌥⇧T", 25 | editorCallback: (protyle: any) => { 26 | // console.log(protyle, "editorCallback"); 27 | // let rootId = protyle.block.rootID; 28 | let currentDocument: HTMLDivElement = getActiveTab(); 29 | if (!currentDocument) { 30 | return; 31 | } 32 | // console.log("显示当前文档反链面板页签") 33 | 34 | const docTitleElement = currentDocument.querySelector(".protyle-title"); 35 | let docTitle = currentDocument.querySelector("div.protyle-title__input").textContent; 36 | let docId = docTitleElement.getAttribute("data-node-id"); 37 | TabService.ins.openBacklinkTab(docTitle, docId, null); 38 | }, 39 | }); 40 | 41 | 42 | 43 | // 用来修复反链面板页签新窗口打开时,没有初始化该页签ID,导致显示空白的问题 https://github.com/Misuzu2027/syplugin-backlink-panel/issues/23 44 | // 初始化时查看有没有销毁的ID,有的话初始化tab。 45 | // let destoryDocIdArray = CacheManager.ins.getAndRemoveBacklinkDestoryTabDocIdArray(); 46 | // console.log("destoryDocIdArray ", destoryDocIdArray) 47 | // for (const docId of destoryDocIdArray) { 48 | // this.pluginAddTab(docId, null); 49 | // } 50 | 51 | } 52 | 53 | public pluginAddTab(docId: string, focusBlockId: string) { 54 | let tabId = BACKLINK_TAB_PREFIX + docId; 55 | let backlinkFilterPanelPageSvelte: BacklinkFilterPanelPageSvelte; 56 | 57 | EnvConfig.ins.plugin.addTab({ 58 | type: tabId, 59 | init() { 60 | backlinkFilterPanelPageSvelte = new BacklinkFilterPanelPageSvelte({ 61 | target: this.element, 62 | props: { 63 | rootId: docId, 64 | focusBlockId: focusBlockId, 65 | panelBacklinkViewExpand: true, 66 | currentTab: this, 67 | } 68 | }); 69 | 70 | this.element.addEventListener( 71 | "scroll", 72 | () => { 73 | clearProtyleGutters(this.element); 74 | }, 75 | ); 76 | }, 77 | beforeDestroy() { 78 | backlinkFilterPanelPageSvelte?.$destroy(); 79 | }, 80 | destroy() { 81 | }, 82 | resize() { 83 | 84 | }, 85 | update() { 86 | } 87 | }); 88 | 89 | } 90 | 91 | 92 | public openBacklinkTab(docTitle: string, docId: string, focusBlockId: string) { 93 | if (isStrBlank(docTitle) || isStrBlank(docId)) { 94 | console.log("反链过滤面板插件 打开反链页签错误,参数缺失") 95 | return; 96 | } 97 | 98 | let tabId = BACKLINK_TAB_PREFIX + docId; 99 | 100 | this.pluginAddTab(docId, focusBlockId); 101 | 102 | CacheManager.ins.addBacklinkDestoryTabDocId(docId); 103 | 104 | openTab({ 105 | app: EnvConfig.ins.app, 106 | custom: { 107 | id: EnvConfig.ins.plugin.name + tabId, 108 | icon: CUSTOM_ICON_MAP.BacklinkPanelFilter.id, 109 | title: docTitle, 110 | // data: { rootId: docId, focusBlockId: focusBlockId } 111 | }, 112 | position: "right", 113 | afterOpen() { 114 | } 115 | }); 116 | } 117 | 118 | } 119 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from "path" 2 | import { defineConfig, loadEnv } from "vite" 3 | import minimist from "minimist" 4 | import { viteStaticCopy } from "vite-plugin-static-copy" 5 | import livereload from "rollup-plugin-livereload" 6 | import { svelte } from "@sveltejs/vite-plugin-svelte" 7 | import zipPack from "vite-plugin-zip-pack"; 8 | import fg from 'fast-glob'; 9 | 10 | import vitePluginYamlI18n from './yaml-plugin'; 11 | 12 | const args = minimist(process.argv.slice(2)) 13 | const isWatch = args.watch || args.w || false 14 | const devDistDir = "dev" 15 | const distDir = isWatch ? devDistDir : "dist" 16 | 17 | console.log("isWatch=>", isWatch) 18 | console.log("distDir=>", distDir) 19 | 20 | export default defineConfig({ 21 | resolve: { 22 | alias: { 23 | "@": resolve(__dirname, "src"), 24 | } 25 | }, 26 | 27 | plugins: [ 28 | svelte(), 29 | 30 | vitePluginYamlI18n({ 31 | inDir: 'public/i18n', 32 | outDir: `${distDir}/i18n` 33 | }), 34 | 35 | viteStaticCopy({ 36 | targets: [ 37 | { 38 | src: "./README*.md", 39 | dest: "./", 40 | }, 41 | { 42 | src: "./plugin.json", 43 | dest: "./", 44 | }, 45 | { 46 | src: "./preview.png", 47 | dest: "./", 48 | }, 49 | { 50 | src: "./icon.png", 51 | dest: "./", 52 | } 53 | ], 54 | }), 55 | ], 56 | 57 | // https://github.com/vitejs/vite/issues/1930 58 | // https://vitejs.dev/guide/env-and-mode.html#env-files 59 | // https://github.com/vitejs/vite/discussions/3058#discussioncomment-2115319 60 | // 在这里自定义变量 61 | define: { 62 | "process.env.DEV_MODE": `"${isWatch}"`, 63 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV) 64 | }, 65 | 66 | build: { 67 | // 输出路径 68 | outDir: distDir, 69 | emptyOutDir: false, 70 | 71 | // 构建后是否生成 source map 文件 72 | sourcemap: isWatch ? 'inline' : false, 73 | 74 | // 设置为 false 可以禁用最小化混淆 75 | // 或是用来指定是应用哪种混淆器 76 | // boolean | 'terser' | 'esbuild' 77 | // 不压缩,用于调试 78 | minify: !isWatch, 79 | 80 | lib: { 81 | // Could also be a dictionary or array of multiple entry points 82 | entry: resolve(__dirname, "src/index.ts"), 83 | // the proper extensions will be added 84 | fileName: "index", 85 | formats: ["cjs"], 86 | }, 87 | rollupOptions: { 88 | plugins: [ 89 | ...( 90 | isWatch ? [ 91 | livereload(devDistDir), 92 | { 93 | //监听静态资源文件 94 | name: 'watch-external', 95 | async buildStart() { 96 | const files = await fg([ 97 | 'public/i18n/**', 98 | './README*.md', 99 | './plugin.json' 100 | ]); 101 | for (let file of files) { 102 | this.addWatchFile(file); 103 | } 104 | } 105 | } 106 | ] : [ 107 | zipPack({ 108 | inDir: './dist', 109 | outDir: './', 110 | outFileName: 'package.zip' 111 | }) 112 | ] 113 | ) 114 | ], 115 | 116 | // make sure to externalize deps that shouldn't be bundled 117 | // into your library 118 | external: ["siyuan", "process"], 119 | 120 | output: { 121 | entryFileNames: "[name].js", 122 | assetFileNames: (assetInfo) => { 123 | if (assetInfo.name === "style.css") { 124 | return "index.css" 125 | } 126 | return assetInfo.name 127 | }, 128 | }, 129 | }, 130 | } 131 | }) 132 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2023-08-15 10:28:10 5 | * @FilePath : /src/types/index.d.ts 6 | * @LastEditTime : 2024-06-08 20:50:53 7 | * @Description : Frequently used data structures in SiYuan 8 | */ 9 | 10 | 11 | type DocumentId = string; 12 | type BlockId = string; 13 | type NotebookId = string; 14 | type PreviousID = BlockId; 15 | type ParentID = BlockId | DocumentId; 16 | 17 | type Notebook = { 18 | id: NotebookId; 19 | name: string; 20 | icon: string; 21 | sort: number; 22 | closed: boolean; 23 | } 24 | 25 | type NotebookConf = { 26 | name: string; 27 | closed: boolean; 28 | refCreateSavePath: string; 29 | createDocNameTemplate: string; 30 | dailyNoteSavePath: string; 31 | dailyNoteTemplatePath: string; 32 | } 33 | 34 | // type BlockType = "d" | "s" | "h" | "t" | "i" | "p" | "f" | "audio" | "video" | "other"; 35 | 36 | type BlockType = "d" // 文档 37 | | "h" // 标题 38 | | "l" // 列表 39 | | "i" // 列表项 40 | | "c" // 代码块 41 | | "m" // 数学公式 42 | | "t" // 表格 43 | | "b" // 引述 44 | | "av" // 属性视图(数据库) 45 | | "s" // 超级块 46 | | "p" // 段落 47 | | "tb" // -- 分隔线 48 | | "html" // HTML 49 | | "video" // 视频 50 | | "audio" // 音频 51 | | "widget" // 挂件 52 | | "iframe" // iframe 53 | | "query_embed" // 嵌入块 54 | ; 55 | 56 | type BlockSubType = "o" | "u" | "t" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; 57 | 58 | type Block = { 59 | id: BlockId; 60 | parent_id?: BlockId; 61 | root_id: DocumentId; 62 | hash: string; 63 | box: string; 64 | path: string; 65 | hpath: string; 66 | name: string; 67 | alias: string; 68 | memo: string; 69 | tag: string; 70 | content: string; 71 | fcontent?: string; 72 | markdown: string; 73 | length: number; 74 | type: BlockType; 75 | subtype: BlockSubType; 76 | /** string of { [key: string]: string } 77 | * For instance: "{: custom-type=\"query-code\" id=\"20230613234017-zkw3pr0\" updated=\"20230613234509\"}" 78 | */ 79 | ial?: string; 80 | sort: number; 81 | created: string; 82 | updated: string; 83 | } 84 | 85 | type doOperation = { 86 | action: string; 87 | data: string; 88 | id: BlockId; 89 | parentID: BlockId | DocumentId; 90 | previousID: BlockId; 91 | retData: null; 92 | } 93 | 94 | interface Window { 95 | siyuan: { 96 | config: any; 97 | notebooks: any; 98 | menus: any; 99 | dialogs: any; 100 | blockPanels: any; 101 | storage: any; 102 | user: any; 103 | ws: any; 104 | languages: any; 105 | emojis: any; 106 | }; 107 | } 108 | 109 | 110 | interface IMenu { 111 | iconClass?: string, 112 | label?: string, 113 | click?: (element: HTMLElement, event: MouseEvent) => boolean | void | Promise 114 | type?: "separator" | "submenu" | "readonly" | "empty", 115 | accelerator?: string, 116 | action?: string, 117 | id?: string, 118 | submenu?: IMenu[] 119 | disabled?: boolean 120 | icon?: string 121 | iconHTML?: string 122 | current?: boolean 123 | bind?: (element: HTMLElement) => void 124 | index?: number 125 | element?: HTMLElement 126 | } 127 | 128 | 129 | type DockPosition = "Hidden" | "LeftTop" | "LeftBottom" | "RightTop" | "RightBottom" | "BottomLeft" | "BottomRight"; 130 | 131 | 132 | 133 | 134 | /** 135 | * 反链相关 136 | */ 137 | 138 | 139 | interface IBreadcrumb { 140 | id: string; 141 | name: string; 142 | type: string; 143 | subType: string; 144 | children: []; 145 | } 146 | 147 | interface IBacklinkData { 148 | blockPaths: IBreadcrumb[]; 149 | dom: string; 150 | expand: boolean; 151 | backlinkBlock: Block; 152 | includeChildListItemIdArray: string[]; 153 | excludeChildLisetItemIdArray: string[]; 154 | } 155 | 156 | 157 | 158 | interface IProtyleOption { 159 | backlinkData?: IBacklinkData[]; 160 | action?: TProtyleAction[]; 161 | mode?: TEditorMode; 162 | toolbar?: Array; 163 | blockId?: string; 164 | key?: string; 165 | scrollAttr?: { 166 | rootId: string, 167 | startId: string, 168 | endId: string, 169 | scrollTop: number, 170 | focusId?: string, 171 | focusStart?: number, 172 | focusEnd?: number, 173 | zoomInId?: string, 174 | }; 175 | defId?: string; 176 | render?: { 177 | background?: boolean, 178 | title?: boolean, 179 | gutter?: boolean, 180 | scroll?: boolean, 181 | breadcrumb?: boolean, 182 | breadcrumbDocName?: boolean, 183 | }; 184 | typewriterMode?: boolean; 185 | 186 | after?(protyle: Protyle): void; 187 | } -------------------------------------------------------------------------------- /src/models/backlink-constant.ts: -------------------------------------------------------------------------------- 1 | import { EnvConfig } from "@/config/EnvConfig"; 2 | 3 | export const DefinitionBlockStatus = { 4 | SELECTED: 'SELECTED', 5 | EXCLUDED: 'EXCLUDED', 6 | OPTIONAL: 'OPTIONAL', 7 | NOT_OPTIONAL: 'NOT_OPTIONAL', 8 | }; 9 | 10 | 11 | export function BACKLINK_BLOCK_SORT_METHOD_ELEMENT(): { name: string, value: BlockSortMethod }[] { 12 | return [ 13 | { 14 | name: EnvConfig.ins.i18n.modifiedASC, 15 | value: "modifiedAsc", 16 | }, 17 | { 18 | name: EnvConfig.ins.i18n.modifiedDESC, 19 | value: "modifiedDesc", 20 | }, 21 | { 22 | name: EnvConfig.ins.i18n.createdASC, 23 | value: "createdAsc", 24 | }, 25 | { 26 | name: EnvConfig.ins.i18n.createdDESC, 27 | value: "createdDesc", 28 | }, 29 | { 30 | name: EnvConfig.ins.i18n.fileNameASC, 31 | value: "alphabeticAsc", 32 | }, 33 | { 34 | name: EnvConfig.ins.i18n.fileNameDESC, 35 | value: "alphabeticDesc", 36 | }, 37 | { 38 | name: "文档名称升序", 39 | value: "documentAlphabeticAsc", 40 | }, 41 | { 42 | name: "文档名称降序", 43 | value: "documentAlphabeticDesc", 44 | }, 45 | ]; 46 | } 47 | 48 | 49 | 50 | export function CUR_DOC_DEF_BLOCK_SORT_METHOD_ELEMENT(): { name: string, value: BlockSortMethod }[] { 51 | 52 | return [ 53 | { 54 | name: EnvConfig.ins.i18n.type, 55 | value: "typeAndContent", 56 | }, 57 | { 58 | name: EnvConfig.ins.i18n.refCountASC, 59 | value: "refCountAsc", 60 | }, 61 | { 62 | name: EnvConfig.ins.i18n.refCountDESC, 63 | value: "refCountDesc", 64 | }, 65 | ]; 66 | } 67 | 68 | export function CUR_DOC_DEF_BLOCK_TYPE_ELEMENT(): { name: string, value: string }[] { 69 | 70 | return [ 71 | { 72 | name: "当前文档定义块", 73 | value: "curDocDefBlock", 74 | }, 75 | { 76 | name: "引用其他文档的定义块", 77 | value: "curDocRefDefBlock", 78 | }, 79 | { 80 | name: "上面所有", 81 | value: "all", 82 | }, 83 | ]; 84 | } 85 | 86 | 87 | 88 | export function RELATED_DEF_BLOCK_TYPE_ELEMENT(): { name: string, value: string }[] { 89 | 90 | return [ 91 | { 92 | name: "所有", 93 | value: "all", 94 | }, 95 | { 96 | name: "动态锚文本", 97 | value: "dynamicAnchorText", 98 | }, 99 | { 100 | name: "静态锚文本", 101 | value: "staticAnchorText", 102 | }, 103 | ]; 104 | } 105 | 106 | 107 | export function RELATED_DEF_BLOCK_SORT_METHOD_ELEMENT(): { name: string, value: BlockSortMethod }[] { 108 | 109 | return [ 110 | { 111 | name: EnvConfig.ins.i18n.refCountASC, 112 | value: "refCountAsc", 113 | }, 114 | { 115 | name: EnvConfig.ins.i18n.refCountDESC, 116 | value: "refCountDesc", 117 | }, { 118 | name: EnvConfig.ins.i18n.modifiedASC, 119 | value: "modifiedAsc", 120 | }, 121 | { 122 | name: EnvConfig.ins.i18n.modifiedDESC, 123 | value: "modifiedDesc", 124 | }, 125 | { 126 | name: EnvConfig.ins.i18n.createdASC, 127 | value: "createdAsc", 128 | }, 129 | { 130 | name: EnvConfig.ins.i18n.createdDESC, 131 | value: "createdDesc", 132 | }, 133 | { 134 | name: EnvConfig.ins.i18n.fileNameASC, 135 | value: "alphabeticAsc", 136 | }, 137 | { 138 | name: EnvConfig.ins.i18n.fileNameDESC, 139 | value: "alphabeticDesc", 140 | }, 141 | ]; 142 | } 143 | 144 | 145 | 146 | export function RELATED_DOCMUMENT_SORT_METHOD_ELEMENT(): { name: string, value: BlockSortMethod }[] { 147 | 148 | return [ 149 | { 150 | name: EnvConfig.ins.i18n.refCountASC, 151 | value: "refCountAsc", 152 | }, 153 | { 154 | name: EnvConfig.ins.i18n.refCountDESC, 155 | value: "refCountDesc", 156 | }, { 157 | name: EnvConfig.ins.i18n.modifiedASC, 158 | value: "modifiedAsc", 159 | }, 160 | { 161 | name: EnvConfig.ins.i18n.modifiedDESC, 162 | value: "modifiedDesc", 163 | }, 164 | { 165 | name: EnvConfig.ins.i18n.createdASC, 166 | value: "createdAsc", 167 | }, 168 | { 169 | name: EnvConfig.ins.i18n.createdDESC, 170 | value: "createdDesc", 171 | }, 172 | { 173 | name: EnvConfig.ins.i18n.fileNameASC, 174 | value: "alphabeticAsc", 175 | }, 176 | { 177 | name: EnvConfig.ins.i18n.fileNameDESC, 178 | value: "alphabeticDesc", 179 | }, 180 | ]; 181 | } 182 | -------------------------------------------------------------------------------- /scripts/update_version.js: -------------------------------------------------------------------------------- 1 | // const fs = require('fs'); 2 | // const path = require('path'); 3 | // const readline = require('readline'); 4 | import fs from 'node:fs'; 5 | import path from 'node:path'; 6 | import readline from 'node:readline'; 7 | 8 | // Utility to read JSON file 9 | function readJsonFile(filePath) { 10 | return new Promise((resolve, reject) => { 11 | fs.readFile(filePath, 'utf8', (err, data) => { 12 | if (err) return reject(err); 13 | try { 14 | const jsonData = JSON.parse(data); 15 | resolve(jsonData); 16 | } catch (e) { 17 | reject(e); 18 | } 19 | }); 20 | }); 21 | } 22 | 23 | // Utility to write JSON file 24 | function writeJsonFile(filePath, jsonData) { 25 | return new Promise((resolve, reject) => { 26 | fs.writeFile(filePath, JSON.stringify(jsonData, null, 2), 'utf8', (err) => { 27 | if (err) return reject(err); 28 | resolve(); 29 | }); 30 | }); 31 | } 32 | 33 | // Utility to prompt the user for input 34 | function promptUser(query) { 35 | const rl = readline.createInterface({ 36 | input: process.stdin, 37 | output: process.stdout 38 | }); 39 | return new Promise((resolve) => rl.question(query, (answer) => { 40 | rl.close(); 41 | resolve(answer); 42 | })); 43 | } 44 | 45 | // Function to parse the version string 46 | function parseVersion(version) { 47 | const [major, minor, patch] = version.split('.').map(Number); 48 | return { major, minor, patch }; 49 | } 50 | 51 | // Function to auto-increment version parts 52 | function incrementVersion(version, type) { 53 | let { major, minor, patch } = parseVersion(version); 54 | 55 | switch (type) { 56 | case 'major': 57 | major++; 58 | minor = 0; 59 | patch = 0; 60 | break; 61 | case 'minor': 62 | minor++; 63 | patch = 0; 64 | break; 65 | case 'patch': 66 | patch++; 67 | break; 68 | default: 69 | break; 70 | } 71 | 72 | return `${major}.${minor}.${patch}`; 73 | } 74 | 75 | // Main script 76 | (async function () { 77 | try { 78 | const pluginJsonPath = path.join(process.cwd(), 'plugin.json'); 79 | const packageJsonPath = path.join(process.cwd(), 'package.json'); 80 | 81 | // Read both JSON files 82 | const pluginData = await readJsonFile(pluginJsonPath); 83 | const packageData = await readJsonFile(packageJsonPath); 84 | 85 | // Get the current version from both files (assuming both have the same version) 86 | const currentVersion = pluginData.version || packageData.version; 87 | console.log(`\n🌟 Current version: \x1b[36m${currentVersion}\x1b[0m\n`); 88 | 89 | // Calculate potential new versions for auto-update 90 | const newPatchVersion = incrementVersion(currentVersion, 'patch'); 91 | const newMinorVersion = incrementVersion(currentVersion, 'minor'); 92 | const newMajorVersion = incrementVersion(currentVersion, 'major'); 93 | 94 | // Prompt the user with formatted options 95 | console.log('🔄 How would you like to update the version?\n'); 96 | console.log(` 1️⃣ Auto update \x1b[33mpatch\x1b[0m version (new version: \x1b[32m${newPatchVersion}\x1b[0m)`); 97 | console.log(` 2️⃣ Auto update \x1b[33mminor\x1b[0m version (new version: \x1b[32m${newMinorVersion}\x1b[0m)`); 98 | console.log(` 3️⃣ Auto update \x1b[33mmajor\x1b[0m version (new version: \x1b[32m${newMajorVersion}\x1b[0m)`); 99 | console.log(` 4️⃣ Input version \x1b[33mmanually\x1b[0m`); 100 | // Press 0 to skip version update 101 | console.log(' 0️⃣ Quit without updating\n'); 102 | 103 | const updateChoice = await promptUser('👉 Please choose (1/2/3/4): '); 104 | 105 | let newVersion; 106 | 107 | switch (updateChoice.trim()) { 108 | case '1': 109 | newVersion = newPatchVersion; 110 | break; 111 | case '2': 112 | newVersion = newMinorVersion; 113 | break; 114 | case '3': 115 | newVersion = newMajorVersion; 116 | break; 117 | case '4': 118 | newVersion = await promptUser('✍️ Please enter the new version (in a.b.c format): '); 119 | break; 120 | case '0': 121 | console.log('\n🛑 Skipping version update.'); 122 | return; 123 | default: 124 | console.log('\n❌ Invalid option, no version update.'); 125 | return; 126 | } 127 | 128 | // Update the version in both plugin.json and package.json 129 | pluginData.version = newVersion; 130 | packageData.version = newVersion; 131 | 132 | // Write the updated JSON back to files 133 | await writeJsonFile(pluginJsonPath, pluginData); 134 | await writeJsonFile(packageJsonPath, packageData); 135 | 136 | console.log(`\n✅ Version successfully updated to: \x1b[32m${newVersion}\x1b[0m\n`); 137 | 138 | } catch (error) { 139 | console.error('❌ Error:', error); 140 | } 141 | })(); 142 | -------------------------------------------------------------------------------- /src/models/setting-constant.ts: -------------------------------------------------------------------------------- 1 | import { BACKLINK_BLOCK_SORT_METHOD_ELEMENT, CUR_DOC_DEF_BLOCK_SORT_METHOD_ELEMENT, RELATED_DEF_BLOCK_SORT_METHOD_ELEMENT, RELATED_DOCMUMENT_SORT_METHOD_ELEMENT } from "./backlink-constant"; 2 | import { ItemProperty, IOption, TabProperty } from "./setting-model"; 3 | 4 | export function getSettingTabArray(): TabProperty[] { 5 | 6 | let tabProperties: TabProperty[] = [ 7 | 8 | ]; 9 | 10 | tabProperties.push( 11 | new TabProperty({ 12 | key: "plugin-setting", name: "插件设置", iconKey: "iconPlugin", props: [ 13 | new ItemProperty({ key: "dockDisplay", type: "switch", name: "显示反链面板 Dock", description: "", tips: "" }), 14 | new ItemProperty({ key: "documentBottomDisplay", type: "switch", name: "文档底部显示反链面板", description: "", tips: "" }), 15 | new ItemProperty({ key: "flashCardBottomDisplay", type: "switch", name: "闪卡底部显示反链面板", description: "", tips: "" }), 16 | new ItemProperty({ key: "cacheAfterResponseMs", type: "number", name: "启用缓存门槛(毫秒)", description: "当接口响应时间超过这个数,就会把这次查询结果存入缓存,-1 不开启缓存", tips: "", min: -1 }), 17 | new ItemProperty({ key: "cacheExpirationTime", type: "number", name: "缓存过期时间(秒)", description: "", tips: "缓存数据失效时间", min: -1, }), 18 | new ItemProperty({ key: "doubleClickTimeout", type: "number", name: "双击时间阈值(毫秒)", description: "", tips: "", min: 0, }), 19 | new ItemProperty({ key: "documentBottomBacklinkPaddingWidth", type: "number", name: "文档底部反链面板左右间距", description: "为空则跟文档宽度一致。", tips: "" }), 20 | ] 21 | }), 22 | new TabProperty({ 23 | key: "filter-panel-setting", name: "筛选面板", iconKey: "iconFilter", props: [ 24 | new ItemProperty({ key: "filterPanelViewExpand", type: "switch", name: "默认展开筛选面板", description: "", tips: "" }), 25 | new ItemProperty({ key: "defaultSelectedViewBlock", type: "switch", name: "默认选中查看块", description: "", tips: "" }), 26 | 27 | new ItemProperty({ key: "queryParentDefBlock", type: "switch", name: "查询父级关联定义块", description: "", tips: "" }), 28 | new ItemProperty({ key: "querrChildDefBlockForListItem", type: "switch", name: "查询列表项下关联定义块", description: "如果反链块的父级是列表项块,则查询该列表项块底下的所有定义块", tips: "" }), 29 | new ItemProperty({ key: "queryChildDefBlockForHeadline", type: "switch", name: "查询标题下关联定义块", description: "如果反链块是标题块,则查询标题下的所有定义块", tips: "" }), 30 | 31 | new ItemProperty({ key: "filterPanelCurDocDefBlockSortMethod", type: "select", name: "当前文档定义块排序方式", description: "", tips: "", options: geturDocDefBlockSortMethodElement() }), 32 | new ItemProperty({ key: "filterPanelRelatedDefBlockSortMethod", type: "select", name: "关联定义块排序方式", description: "", tips: "", options: getRelatedDefBlockSortMethodElement() }), 33 | new ItemProperty({ key: "filterPanelBacklinkDocumentSortMethod", type: "select", name: "关联文档排序方式", description: "", tips: "", options: getRelatedDocmumentSortMethodElement() }), 34 | 35 | ] 36 | 37 | }), 38 | new TabProperty({ 39 | key: "backlink-panel-setting", name: "反链面板", iconKey: "iconLink", props: [ 40 | new ItemProperty({ key: "docBottomBacklinkPanelViewExpand", type: "switch", name: "文档底部默认展开反链面板", description: "", tips: "" }), 41 | new ItemProperty({ key: "pageSize", type: "number", name: "每页反链块数量", description: "每页反链块显示的数量", tips: "", min: 1, max: 50 }), 42 | new ItemProperty({ key: "backlinkBlockSortMethod", type: "select", name: "反链块排序方式", description: "", tips: "", options: getBacklinkBlockSortMethodOptions() }), 43 | new ItemProperty({ key: "defaultExpandedListItemLevel", type: "number", name: "默认展开列表项层数", description: "如果反链所在是列表项,默认展开的子列表深度。", tips: "", min: 0, max: 10 }), 44 | new ItemProperty({ key: "hideBacklinkProtyleBreadcrumb", type: "switch", name: "隐藏面包屑", description: "", tips: "" }), 45 | 46 | ] 47 | 48 | }), 49 | 50 | ); 51 | 52 | return tabProperties; 53 | } 54 | 55 | function getBacklinkBlockSortMethodOptions(): IOption[] { 56 | let backlinkBlockSortMethodElements = BACKLINK_BLOCK_SORT_METHOD_ELEMENT(); 57 | let options: IOption[] = []; 58 | for (const element of backlinkBlockSortMethodElements) { 59 | options.push(element); 60 | } 61 | 62 | return options; 63 | } 64 | 65 | 66 | function geturDocDefBlockSortMethodElement(): IOption[] { 67 | let backlinkBlockSortMethodElements = CUR_DOC_DEF_BLOCK_SORT_METHOD_ELEMENT(); 68 | let options: IOption[] = []; 69 | for (const element of backlinkBlockSortMethodElements) { 70 | options.push(element); 71 | } 72 | 73 | return options; 74 | } 75 | 76 | function getRelatedDefBlockSortMethodElement(): IOption[] { 77 | let backlinkBlockSortMethodElements = RELATED_DEF_BLOCK_SORT_METHOD_ELEMENT(); 78 | let options: IOption[] = []; 79 | for (const element of backlinkBlockSortMethodElements) { 80 | options.push(element); 81 | } 82 | 83 | return options; 84 | } 85 | 86 | function getRelatedDocmumentSortMethodElement(): IOption[] { 87 | let backlinkBlockSortMethodElements = RELATED_DOCMUMENT_SORT_METHOD_ELEMENT(); 88 | let options: IOption[] = []; 89 | for (const element of backlinkBlockSortMethodElements) { 90 | options.push(element); 91 | } 92 | 93 | return options; 94 | } -------------------------------------------------------------------------------- /scripts/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2024 by frostime. All Rights Reserved. 3 | * @Author : frostime 4 | * @Date : 2024-09-06 17:42:57 5 | * @FilePath : /scripts/utils.js 6 | * @LastEditTime : 2024-09-06 19:23:12 7 | * @Description : 8 | */ 9 | // common.js 10 | import fs from 'fs'; 11 | import path from 'node:path'; 12 | import http from 'node:http'; 13 | import readline from 'node:readline'; 14 | 15 | // Logging functions 16 | export const log = (info) => console.log(`\x1B[36m%s\x1B[0m`, info); 17 | export const error = (info) => console.log(`\x1B[31m%s\x1B[0m`, info); 18 | 19 | // HTTP POST headers 20 | export const POST_HEADER = { 21 | "Content-Type": "application/json", 22 | }; 23 | 24 | // Fetch function compatible with older Node.js versions 25 | export async function myfetch(url, options) { 26 | return new Promise((resolve, reject) => { 27 | let req = http.request(url, options, (res) => { 28 | let data = ''; 29 | res.on('data', (chunk) => { 30 | data += chunk; 31 | }); 32 | res.on('end', () => { 33 | resolve({ 34 | ok: true, 35 | status: res.statusCode, 36 | json: () => JSON.parse(data) 37 | }); 38 | }); 39 | }); 40 | req.on('error', (e) => { 41 | reject(e); 42 | }); 43 | req.end(); 44 | }); 45 | } 46 | 47 | /** 48 | * Fetch SiYuan workspaces from port 6806 49 | * @returns {Promise} 50 | */ 51 | export async function getSiYuanDir() { 52 | let url = 'http://127.0.0.1:6806/api/system/getWorkspaces'; 53 | let conf = {}; 54 | try { 55 | let response = await myfetch(url, { 56 | method: 'POST', 57 | headers: POST_HEADER 58 | }); 59 | if (response.ok) { 60 | conf = await response.json(); 61 | } else { 62 | error(`\tHTTP-Error: ${response.status}`); 63 | return null; 64 | } 65 | } catch (e) { 66 | error(`\tError: ${e}`); 67 | error("\tPlease make sure SiYuan is running!!!"); 68 | return null; 69 | } 70 | return conf?.data; // 保持原始返回值 71 | } 72 | 73 | /** 74 | * Choose target workspace 75 | * @param {{path: string}[]} workspaces 76 | * @returns {string} The path of the selected workspace 77 | */ 78 | export async function chooseTarget(workspaces) { 79 | let count = workspaces.length; 80 | log(`>>> Got ${count} SiYuan ${count > 1 ? 'workspaces' : 'workspace'}`); 81 | workspaces.forEach((workspace, i) => { 82 | log(`\t[${i}] ${workspace.path}`); 83 | }); 84 | 85 | if (count === 1) { 86 | return `${workspaces[0].path}/data/plugins`; 87 | } else { 88 | const rl = readline.createInterface({ 89 | input: process.stdin, 90 | output: process.stdout 91 | }); 92 | let index = await new Promise((resolve) => { 93 | rl.question(`\tPlease select a workspace[0-${count - 1}]: `, (answer) => { 94 | resolve(answer); 95 | }); 96 | }); 97 | rl.close(); 98 | return `${workspaces[index].path}/data/plugins`; 99 | } 100 | } 101 | 102 | /** 103 | * Check if two paths are the same 104 | * @param {string} path1 105 | * @param {string} path2 106 | * @returns {boolean} 107 | */ 108 | export function cmpPath(path1, path2) { 109 | path1 = path1.replace(/\\/g, '/'); 110 | path2 = path2.replace(/\\/g, '/'); 111 | if (path1[path1.length - 1] !== '/') { 112 | path1 += '/'; 113 | } 114 | if (path2[path2.length - 1] !== '/') { 115 | path2 += '/'; 116 | } 117 | return path1 === path2; 118 | } 119 | 120 | export function getThisPluginName() { 121 | if (!fs.existsSync('./plugin.json')) { 122 | process.chdir('../'); 123 | if (!fs.existsSync('./plugin.json')) { 124 | error('Failed! plugin.json not found'); 125 | return null; 126 | } 127 | } 128 | 129 | const plugin = JSON.parse(fs.readFileSync('./plugin.json', 'utf8')); 130 | const name = plugin?.name; 131 | if (!name) { 132 | error('Failed! Please set plugin name in plugin.json'); 133 | return null; 134 | } 135 | 136 | return name; 137 | } 138 | 139 | export function copyDirectory(srcDir, dstDir) { 140 | if (!fs.existsSync(dstDir)) { 141 | fs.mkdirSync(dstDir); 142 | log(`Created directory ${dstDir}`); 143 | } 144 | 145 | fs.readdirSync(srcDir, { withFileTypes: true }).forEach((file) => { 146 | const src = path.join(srcDir, file.name); 147 | const dst = path.join(dstDir, file.name); 148 | 149 | if (file.isDirectory()) { 150 | copyDirectory(src, dst); 151 | } else { 152 | fs.copyFileSync(src, dst); 153 | log(`Copied file: ${src} --> ${dst}`); 154 | } 155 | }); 156 | log(`All files copied!`); 157 | } 158 | 159 | 160 | export function makeSymbolicLink(srcPath, targetPath) { 161 | if (!fs.existsSync(targetPath)) { 162 | // fs.symlinkSync(srcPath, targetPath, 'junction'); 163 | //Go 1.23 no longer supports junctions as symlinks 164 | //Please refer to https://github.com/siyuan-note/siyuan/issues/12399 165 | fs.symlinkSync(srcPath, targetPath, 'dir'); 166 | log(`Done! Created symlink ${targetPath}`); 167 | return; 168 | } 169 | 170 | //Check the existed target path 171 | let isSymbol = fs.lstatSync(targetPath).isSymbolicLink(); 172 | if (!isSymbol) { 173 | error(`Failed! ${targetPath} already exists and is not a symbolic link`); 174 | return; 175 | } 176 | let existedPath = fs.readlinkSync(targetPath); 177 | if (cmpPath(existedPath, srcPath)) { 178 | log(`Good! ${targetPath} is already linked to ${srcPath}`); 179 | } else { 180 | error(`Error! Already exists symbolic link ${targetPath}\nBut it links to ${existedPath}`); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/service/setting/SettingService.ts: -------------------------------------------------------------------------------- 1 | import { EnvConfig } from "@/config/EnvConfig"; 2 | import { SettingConfig } from "@/models/setting-model"; 3 | import { sql } from "@/utils/api"; 4 | import Instance from "@/utils/Instance"; 5 | import { getCreateBlocksParentIdIdxSql } from "../backlink/backlink-sql"; 6 | import { setReplacer } from "@/utils/json-util"; 7 | import { mergeObjects } from "@/utils/object-util"; 8 | 9 | const SettingFileName = 'backlink-panel-setting.json'; 10 | 11 | export class SettingService { 12 | 13 | public static get ins(): SettingService { 14 | return Instance.get(SettingService); 15 | } 16 | 17 | private _settingConfig: SettingConfig; 18 | 19 | public get SettingConfig() { 20 | if (this._settingConfig) { 21 | return this._settingConfig; 22 | } 23 | this.init() 24 | return getDefaultSettingConfig() 25 | 26 | } 27 | 28 | public async init() { 29 | let persistentConfig = await getPersistentConfig(); 30 | this._settingConfig = mergeObjects(persistentConfig, getDefaultSettingConfig()); 31 | // console.log("init this._settingConfig ", this._settingConfig) 32 | 33 | if (this._settingConfig.usePraentIdIdx) { 34 | this.createBlocksParentIdIdx(); 35 | } 36 | } 37 | 38 | // public async getSettingConfig(): Promise { 39 | // if (!this.settingConfig) { 40 | // await this.init(); 41 | // } 42 | // if (this.settingConfig) { 43 | // return this.settingConfig; 44 | // } 45 | // let defaultSettingConfig = getDefaultSettingConfig(); 46 | // console.error(`反链面板 异常,返回默认设置: `, defaultSettingConfig); 47 | // return defaultSettingConfig; 48 | // } 49 | 50 | public async updateSettingCofnigValue(key: string, newValue: any) { 51 | let oldValue = this._settingConfig[key]; 52 | if (oldValue == newValue) { 53 | return; 54 | } 55 | 56 | this._settingConfig[key] = newValue; 57 | let paramJson = JSON.stringify(this._settingConfig, setReplacer); 58 | let plugin = EnvConfig.ins.plugin; 59 | if (!plugin) { 60 | return; 61 | } 62 | console.log(`反链面板 更新设置配置文件: ${paramJson}`); 63 | plugin.saveData(SettingFileName, paramJson); 64 | } 65 | 66 | public async updateSettingCofnig(settingConfigParam: SettingConfig) { 67 | let plugin = EnvConfig.ins.plugin; 68 | if (!plugin) { 69 | return; 70 | } 71 | 72 | let curSettingConfigJson = ""; 73 | if (this._settingConfig) { 74 | curSettingConfigJson = JSON.stringify(this._settingConfig, setReplacer); 75 | } 76 | let paramJson = JSON.stringify(settingConfigParam, setReplacer); 77 | if (paramJson == curSettingConfigJson) { 78 | return; 79 | } 80 | console.log(`反链面板 更新设置配置文件: ${paramJson}`); 81 | this._settingConfig = { ...settingConfigParam }; 82 | plugin.saveData(SettingFileName, paramJson); 83 | } 84 | 85 | public async createBlocksParentIdIdx() { 86 | let createdSql = getCreateBlocksParentIdIdxSql(); 87 | sql(createdSql); 88 | } 89 | 90 | } 91 | 92 | 93 | 94 | async function getPersistentConfig(): Promise { 95 | let plugin = EnvConfig.ins.plugin; 96 | let settingConfig = null; 97 | if (!plugin) { 98 | return settingConfig; 99 | } 100 | let loaded = await plugin.loadData(SettingFileName); 101 | if (loaded == null || loaded == undefined || loaded == '') { 102 | console.info(`反链面板插件 没有配置文件,使用默认配置`) 103 | } else { 104 | //如果有配置文件,则使用配置文件 105 | // console.info(`读入配置文件: ${SettingFileName}`) 106 | if (typeof loaded === 'string') { 107 | loaded = JSON.parse(loaded); 108 | } 109 | try { 110 | settingConfig = new SettingConfig(); 111 | for (let key in loaded) { 112 | setKeyValue(settingConfig, key, loaded[key]); 113 | } 114 | } catch (error_msg) { 115 | console.log(`Setting load error: ${error_msg}`); 116 | } 117 | } 118 | return settingConfig; 119 | } 120 | 121 | function setKeyValue(settingConfig, key: any, value: any) { 122 | if (!(key in settingConfig)) { 123 | console.error(`"${key}" is not a setting`); 124 | return; 125 | } 126 | settingConfig[key] = value; 127 | } 128 | 129 | function getDefaultSettingConfig() { 130 | let defaultConfig = new SettingConfig(); 131 | 132 | defaultConfig.dockDisplay = true; 133 | defaultConfig.documentBottomDisplay = false; 134 | defaultConfig.flashCardBottomDisplay = false; 135 | defaultConfig.topBarDisplay = true; 136 | 137 | defaultConfig.cacheAfterResponseMs = -1; 138 | defaultConfig.cacheExpirationTime = 5 * 60; 139 | defaultConfig.usePraentIdIdx = false; 140 | defaultConfig.doubleClickTimeout = 0; 141 | 142 | 143 | defaultConfig.documentBottomBacklinkPaddingWidth = null; 144 | 145 | 146 | // 筛选面板 147 | defaultConfig.filterPanelViewExpand = false; 148 | defaultConfig.queryParentDefBlock = true; 149 | defaultConfig.querrChildDefBlockForListItem = true; 150 | defaultConfig.queryChildDefBlockForHeadline = false; 151 | defaultConfig.filterPanelCurDocDefBlockSortMethod = "typeAndContent"; 152 | defaultConfig.filterPanelRelatedDefBlockSortMethod = "modifiedDesc"; 153 | defaultConfig.filterPanelBacklinkDocumentSortMethod = "createdDesc"; 154 | defaultConfig.defaultSelectedViewBlock = false; 155 | 156 | 157 | // 反链面板 158 | defaultConfig.docBottomBacklinkPanelViewExpand = true; 159 | defaultConfig.pageSize = 8; 160 | defaultConfig.backlinkBlockSortMethod = "modifiedDesc"; 161 | defaultConfig.hideBacklinkProtyleBreadcrumb = false; 162 | defaultConfig.defaultExpandedListItemLevel = 0; 163 | // defaultConfig.queryAllContentUnderHeadline = false; 164 | 165 | 166 | 167 | return defaultConfig; 168 | } 169 | 170 | 171 | -------------------------------------------------------------------------------- /src/models/icon-constant.ts: -------------------------------------------------------------------------------- 1 | 2 | export const CUSTOM_ICON_MAP = 3 | { 4 | BacklinkPanelFilter: { 5 | id: "iconBacklinkPanelFilter", 6 | source: ` 7 | 8 | 9 | 10 | 11 | ` 12 | }, 13 | LiElementExpand: { 14 | id: "iconLiElementExpand", 15 | source: ` 16 | 18 | 19 | ` 20 | }, 21 | 22 | LiElementCollapse: { 23 | id: "iconLiElementCollapse", 24 | source: ` 25 | 26 | ` 27 | }, 28 | ResetInitialization: { 29 | id: "iconResetInitialization", 30 | source: ` 31 | 32 | ` 33 | }, 34 | iconContentSort: { 35 | id: "iconContentSort", 36 | source: ` 37 | 39 | 40 | ` 41 | }, 42 | }; -------------------------------------------------------------------------------- /src/service/setting/BacklinkPanelFilterCriteriaService.ts: -------------------------------------------------------------------------------- 1 | import { CacheManager } from "@/config/CacheManager"; 2 | import { IPanelRednerFilterQueryParams, BacklinkPanelFilterCriteria } from "@/models/backlink-model"; 3 | 4 | import { getBlockAttrs, setBlockAttrs } from "@/utils/api"; 5 | import Instance from "@/utils/Instance"; 6 | import { SettingService } from "./SettingService"; 7 | import { setReplacer, setReviver } from "@/utils/json-util"; 8 | import { mergeObjects } from "@/utils/object-util"; 9 | 10 | const BACKLINK_FILTER_PANEL_LAST_CRITERIA_ATTRIBUTE_KEY = "custom-backlink-filter-panel-last-criteria"; 11 | const BACKLINK_FILTER_PANEL_SAVED_CRITERIA_ATTRIBUTE_KEY = "custom-backlink-filter-panel-saved-criteria"; 12 | export const DOCUMENT_BOTTOM_SHOW_BACKLINK_FILTER_PANEL_ATTRIBUTE_KEY = "custom-document-bottom-show-backlink-filter-panel"; 13 | export class BacklinkFilterPanelAttributeService { 14 | 15 | public static get ins(): BacklinkFilterPanelAttributeService { 16 | return Instance.get(BacklinkFilterPanelAttributeService); 17 | } 18 | 19 | 20 | public async getPanelCriteria(rootId: string): Promise { 21 | let documentPanelCriteria = CacheManager.ins.getBacklinkFilterPanelLastCriteria(rootId); 22 | let defaultQueryParams = this.getDefaultQueryParams(); 23 | let queryParams; 24 | // 存在缓存数据 25 | if (documentPanelCriteria) { 26 | documentPanelCriteria.queryParams.pageNum = 1; 27 | queryParams = mergeObjects(documentPanelCriteria.queryParams, defaultQueryParams); 28 | } else { 29 | let attrsMap = await getBlockAttrs(rootId); 30 | // 存在保存的最后查询条件 31 | if (attrsMap && Object.keys(attrsMap).includes(BACKLINK_FILTER_PANEL_LAST_CRITERIA_ATTRIBUTE_KEY)) { 32 | let json = attrsMap[BACKLINK_FILTER_PANEL_LAST_CRITERIA_ATTRIBUTE_KEY]; 33 | let parseObject = JSON.parse(json) as BacklinkPanelFilterCriteria; 34 | if ("queryParams" in parseObject) { 35 | documentPanelCriteria = parseObject; 36 | parseObject.queryParams.backlinkKeywordStr = ""; 37 | queryParams = mergeObjects(documentPanelCriteria.queryParams, defaultQueryParams); 38 | } 39 | } 40 | if (!documentPanelCriteria) { 41 | queryParams = defaultQueryParams; 42 | documentPanelCriteria = new BacklinkPanelFilterCriteria(); 43 | documentPanelCriteria.backlinkPanelFilterViewExpand = SettingService.ins.SettingConfig.filterPanelViewExpand; 44 | // documentPanelCriteria.backlinkPanelBacklinkViewExpand = SettingService.ins.SettingConfig.backlinkPanelViewExpand;; 45 | } 46 | queryParams.includeRelatedDefBlockIds = new Set(); 47 | queryParams.excludeRelatedDefBlockIds = new Set(); 48 | queryParams.includeDocumentIds = new Set(); 49 | queryParams.excludeDocumentIds = new Set(); 50 | CacheManager.ins.setBacklinkFilterPanelLastCriteria(rootId, documentPanelCriteria); 51 | } 52 | 53 | documentPanelCriteria.queryParams = queryParams; 54 | 55 | 56 | // console.log("getBacklinkPanelFilterCriteria queryParams", queryParams) 57 | return documentPanelCriteria; 58 | } 59 | 60 | 61 | public async updatePanelCriteria(rootId: string, criteria: BacklinkPanelFilterCriteria) { 62 | if (!rootId) { 63 | return; 64 | } 65 | let lastCriteria = await this.getPanelCriteria(rootId); 66 | let lastCriteriaJson = ""; 67 | if (lastCriteria) { 68 | lastCriteriaJson = JSON.stringify(lastCriteria); 69 | } 70 | let criteriaJson = JSON.stringify(criteria); 71 | if (criteriaJson == lastCriteriaJson) { 72 | return; 73 | } 74 | 75 | CacheManager.ins.setBacklinkFilterPanelLastCriteria(rootId, criteria); 76 | 77 | // 持久缓存删除 关键字。 78 | let criteriaCloned : BacklinkPanelFilterCriteria= JSON.parse(criteriaJson); 79 | criteriaCloned.queryParams.backlinkKeywordStr = ""; 80 | let criteriaClonedJson = JSON.stringify(criteriaCloned); 81 | let attrs = {}; 82 | attrs[BACKLINK_FILTER_PANEL_LAST_CRITERIA_ATTRIBUTE_KEY] = criteriaClonedJson; 83 | setBlockAttrs(rootId, attrs); 84 | } 85 | 86 | 87 | public async getPanelSavedCriteriaMap(rootId: string): Promise> { 88 | let savedCriteriaMap = CacheManager.ins.getBacklinkPanelSavedCriteria(rootId); 89 | if (savedCriteriaMap && savedCriteriaMap.size > 0) { 90 | return savedCriteriaMap 91 | } 92 | 93 | let attrsMap = await getBlockAttrs(rootId); 94 | // console.log("getPanelSavedCriteriaMap attrsMap : ", attrsMap) 95 | if (attrsMap && Object.keys(attrsMap).includes(BACKLINK_FILTER_PANEL_SAVED_CRITERIA_ATTRIBUTE_KEY)) { 96 | let json = attrsMap[BACKLINK_FILTER_PANEL_SAVED_CRITERIA_ATTRIBUTE_KEY]; 97 | let parseObject = JSON.parse(json, setReviver); 98 | if (parseObject) { 99 | const resultMap = new Map(Object.entries(parseObject)); 100 | CacheManager.ins.setBacklinkPanelSavedCriteria(rootId, resultMap); 101 | return resultMap; 102 | } 103 | } 104 | 105 | return new Map(); 106 | } 107 | 108 | 109 | public async updatePanelSavedCriteriaMap(rootId: string, criteriaMap: Map) { 110 | if (!rootId) { 111 | return; 112 | } 113 | // let lastCriteriaMap = await this.getPanelSavedCriteriaMap(rootId); 114 | // let lastCriteriaJson = ""; 115 | // if (lastCriteriaMap) { 116 | // const obj = Object.fromEntries(lastCriteriaMap); 117 | // lastCriteriaJson = JSON.stringify(obj, setReplacer); 118 | // } 119 | const mapObject = Object.fromEntries(criteriaMap); 120 | let criteriaJson = JSON.stringify(mapObject, setReplacer); 121 | // if (criteriaJson == lastCriteriaJson) { 122 | // return; 123 | // } 124 | 125 | CacheManager.ins.setBacklinkPanelSavedCriteria(rootId, criteriaMap); 126 | 127 | let attrs = {}; 128 | attrs[BACKLINK_FILTER_PANEL_SAVED_CRITERIA_ATTRIBUTE_KEY] = criteriaJson; 129 | setBlockAttrs(rootId, attrs); 130 | } 131 | 132 | 133 | public async getDocumentBottomShowPanel(rootId: string): Promise { 134 | 135 | let attrsMap = await getBlockAttrs(rootId); 136 | if (attrsMap && Object.keys(attrsMap).includes(DOCUMENT_BOTTOM_SHOW_BACKLINK_FILTER_PANEL_ATTRIBUTE_KEY)) { 137 | let json = attrsMap[DOCUMENT_BOTTOM_SHOW_BACKLINK_FILTER_PANEL_ATTRIBUTE_KEY]; 138 | return Number(json); 139 | } 140 | 141 | return null; 142 | } 143 | 144 | 145 | public async updateDocumentBottomShowPanel(rootId: string, value: number) { 146 | if (!rootId) { 147 | return; 148 | } 149 | let valueStr; 150 | if (value) { 151 | valueStr = String(value); 152 | } 153 | if (!valueStr) { 154 | valueStr = ""; 155 | } 156 | 157 | let attrs = {}; 158 | attrs[DOCUMENT_BOTTOM_SHOW_BACKLINK_FILTER_PANEL_ATTRIBUTE_KEY] = valueStr; 159 | setBlockAttrs(rootId, attrs); 160 | } 161 | 162 | 163 | getDefaultQueryParams(): IPanelRednerFilterQueryParams { 164 | let settingConfig = SettingService.ins.SettingConfig; 165 | let backlinkBlockSortMethod = "modifiedDesc"; 166 | let filterPanelCurDocDefBlockSortMethod = "typeAndContent"; 167 | let filterPanelRelatedDefBlockSortMethod = "modifiedDesc"; 168 | let filterPanelbacklinkDocumentSortMethod = "createdDesc"; 169 | if (settingConfig) { 170 | backlinkBlockSortMethod = settingConfig.backlinkBlockSortMethod ? settingConfig.backlinkBlockSortMethod : backlinkBlockSortMethod; 171 | filterPanelCurDocDefBlockSortMethod = settingConfig.filterPanelCurDocDefBlockSortMethod ? settingConfig.filterPanelCurDocDefBlockSortMethod : filterPanelCurDocDefBlockSortMethod; 172 | filterPanelRelatedDefBlockSortMethod = settingConfig.filterPanelRelatedDefBlockSortMethod ? settingConfig.filterPanelRelatedDefBlockSortMethod : filterPanelRelatedDefBlockSortMethod; 173 | filterPanelbacklinkDocumentSortMethod = settingConfig.filterPanelBacklinkDocumentSortMethod ? settingConfig.filterPanelBacklinkDocumentSortMethod : filterPanelbacklinkDocumentSortMethod; 174 | } 175 | let queryParams = { 176 | pageNum: 1, 177 | backlinkCurDocDefBlockType: "all", 178 | backlinkBlockSortMethod: backlinkBlockSortMethod, 179 | backlinkKeywordStr: "", 180 | includeRelatedDefBlockIds: new Set(), 181 | excludeRelatedDefBlockIds: new Set(), 182 | includeDocumentIds: new Set(), 183 | excludeDocumentIds: new Set(), 184 | filterPanelCurDocDefBlockSortMethod: filterPanelCurDocDefBlockSortMethod, 185 | filterPanelCurDocDefBlockKeywords: "", 186 | filterPanelRelatedDefBlockType: "all", 187 | filterPanelRelatedDefBlockSortMethod: filterPanelRelatedDefBlockSortMethod, 188 | filterPanelRelatedDefBlockKeywords: "", 189 | filterPanelBacklinkDocumentSortMethod: filterPanelbacklinkDocumentSortMethod, 190 | filterPanelBacklinkDocumentKeywords: "", 191 | } as IPanelRednerFilterQueryParams; 192 | 193 | return queryParams; 194 | } 195 | 196 | 197 | } 198 | 199 | 200 | -------------------------------------------------------------------------------- /src/utils/html-util.ts: -------------------------------------------------------------------------------- 1 | import { isArrayEmpty } from "./array-util"; 2 | import { isStrBlank } from "./string-util"; 3 | 4 | export const escapeAttr = (html: string) => { 5 | return html.replace(/"/g, """).replace(/'/g, "'"); 6 | }; 7 | 8 | 9 | 10 | export async function highlightElementTextByCss( 11 | contentElement: HTMLElement, 12 | keywordArray: string[], 13 | 14 | ) { 15 | if (!contentElement || isArrayEmpty(keywordArray)) { 16 | return; 17 | } 18 | // If the CSS Custom Highlight API is not supported, 19 | // display a message and bail-out. 20 | if (!CSS.highlights) { 21 | console.log("CSS Custom Highlight API not supported."); 22 | return; 23 | } 24 | 25 | // Find all text nodes in the article. We'll search within 26 | // these text nodes. 27 | const treeWalker = document.createTreeWalker( 28 | contentElement, 29 | NodeFilter.SHOW_TEXT, 30 | ); 31 | const allTextNodes: Node[] = []; 32 | let currentNode = treeWalker.nextNode(); 33 | while (currentNode) { 34 | allTextNodes.push(currentNode); 35 | currentNode = treeWalker.nextNode(); 36 | } 37 | 38 | 39 | // 默认不清除 40 | // clearCssHighlights(); 41 | 42 | // Clean-up the search query and bail-out if 43 | // if it's empty. 44 | 45 | let allMatchRanges: Range[] = []; 46 | 47 | // Iterate over all text nodes and find matches. 48 | allTextNodes 49 | .map((el: Node) => { 50 | return { el, text: el.textContent.toLowerCase() }; 51 | }) 52 | .map(({ el, text }) => { 53 | const indices: { index: number; length: number }[] = []; 54 | for (const queryStr of keywordArray) { 55 | if (!queryStr) { 56 | continue; 57 | } 58 | let startPos = 0; 59 | while (startPos < text.length) { 60 | const index = text.indexOf( 61 | queryStr.toLowerCase(), 62 | startPos, 63 | ); 64 | if (index === -1) break; 65 | let length = queryStr.length; 66 | indices.push({ index, length }); 67 | startPos = index + length; 68 | } 69 | } 70 | 71 | indices 72 | .sort((a, b) => a.index - b.index) 73 | .map(({ index, length }) => { 74 | const range = new Range(); 75 | range.setStart(el, index); 76 | range.setEnd(el, index + length); 77 | allMatchRanges.push(range); 78 | 79 | }); 80 | }); 81 | 82 | // Create a Highlight object for the ranges. 83 | allMatchRanges = allMatchRanges.flat(); 84 | if (!allMatchRanges || allMatchRanges.length <= 0) { 85 | return; 86 | } 87 | 88 | let searchResultsHighlight = CSS.highlights.get("search-result-mark"); 89 | if (searchResultsHighlight) { 90 | for (const range of allMatchRanges) { 91 | searchResultsHighlight.add(range); 92 | } 93 | } else { 94 | searchResultsHighlight = new Highlight(...allMatchRanges); 95 | } 96 | 97 | // Register the Highlight object in the registry. 98 | CSS.highlights.set("search-result-mark", searchResultsHighlight); 99 | 100 | } 101 | 102 | export function clearCssHighlights() { 103 | CSS.highlights.delete("search-result-mark"); 104 | CSS.highlights.delete("search-result-focus"); 105 | } 106 | 107 | 108 | 109 | export function highlightContent(content: string, keywords: string[]): string { 110 | if (!content) { 111 | return content; 112 | } 113 | let contentHtml = getHighlightedContent(content, keywords); 114 | return contentHtml; 115 | } 116 | 117 | export function clearProtyleGutters(target: HTMLElement) { 118 | if (!target) { 119 | return; 120 | } 121 | target.querySelectorAll(".protyle-gutters").forEach((item) => { 122 | item.classList.add("fn__none"); 123 | item.innerHTML = ""; 124 | }); 125 | } 126 | 127 | 128 | // 查找可滚动的父级元素 129 | export function findScrollableParent(element: HTMLElement) { 130 | if (!element) { 131 | return null; 132 | } 133 | 134 | // const hasScrollableSpace = element.scrollHeight > element.clientHeight; 135 | const hasVisibleOverflow = getComputedStyle(element).overflowY !== 'visible'; 136 | 137 | if (hasVisibleOverflow) { 138 | return element; 139 | } 140 | 141 | return findScrollableParent(element.parentElement); 142 | } 143 | 144 | 145 | 146 | 147 | function getHighlightedContent( 148 | content: string, 149 | keywords: string[], 150 | ): string { 151 | if (!content) { 152 | return content; 153 | } 154 | // let highlightedContent: string = escapeHtml(content); 155 | let highlightedContent: string = content; 156 | 157 | if (keywords) { 158 | highlightedContent = highlightMatches(highlightedContent, keywords); 159 | } 160 | return highlightedContent; 161 | } 162 | 163 | function highlightMatches(content: string, keywords: string[]): string { 164 | if (!keywords.length || !content) { 165 | return content; // 返回原始字符串,因为没有需要匹配的内容 166 | } 167 | 168 | const regexPattern = new RegExp(`(${keywords.join("|")})`, "gi"); 169 | const highlightedString = content.replace( 170 | regexPattern, 171 | "$1", 172 | ); 173 | return highlightedString; 174 | } 175 | 176 | function escapeHtml(input: string): string { 177 | const escapeMap: Record = { 178 | "&": "&", 179 | "<": "<", 180 | ">": ">", 181 | '"': """, 182 | "'": "'", 183 | }; 184 | 185 | return input.replace(/[&<>"']/g, (match) => escapeMap[match]); 186 | } 187 | 188 | 189 | 190 | export function getElementsBeforeDepth(rootElement: HTMLElement, selector: string, depth: number) { 191 | const result = []; 192 | 193 | function recursiveSearch(node: Element, currentDepth: number) { 194 | if (currentDepth > depth || syHasChildListNode(node)) { 195 | return; 196 | } 197 | const targetElements = node.querySelectorAll(':scope > ' + selector); 198 | for (const element of targetElements) { 199 | result.push(element); 200 | const childNodes = element.children; 201 | for (let i = 0; i < childNodes.length; i++) { 202 | recursiveSearch(childNodes[i], currentDepth + 1); 203 | } 204 | } 205 | 206 | } 207 | 208 | recursiveSearch(rootElement, 0) 209 | 210 | return result; 211 | } 212 | 213 | 214 | export function getElementsAtDepth(rootElement: Element, selector: string, depth: number) { 215 | const result = []; 216 | 217 | function recursiveSearch(node: Element, currentDepth: number) { 218 | const targetElements = node.querySelectorAll(':scope > ' + selector); 219 | 220 | if (currentDepth === depth) { 221 | targetElements.forEach(element => result.push(element)); 222 | return; 223 | } 224 | for (const element of targetElements) { 225 | const childNodes = element.children; 226 | for (let i = 0; i < childNodes.length; i++) { 227 | recursiveSearch(childNodes[i], currentDepth + 1); 228 | } 229 | } 230 | 231 | } 232 | 233 | 234 | recursiveSearch(rootElement, 0); 235 | return result; 236 | } 237 | 238 | 239 | export function syHasChildListNode(root: Element): boolean { 240 | if (!root) { 241 | return false; 242 | } 243 | // 获取 root 的所有子节点 244 | const children = Array.from(root.children) as HTMLElement[]; 245 | 246 | // 确保有至少4个子节点 247 | if (children.length < 4) { 248 | return false; 249 | } 250 | // let listNodeElement = root.querySelector(`:scope > [data-type="NodeList"].list`); 251 | 252 | // if ( 253 | // listNodeElement 254 | // ) { 255 | // return true; 256 | // } 257 | 258 | return true; 259 | } 260 | 261 | 262 | export function getActiveTab(): HTMLDivElement { 263 | let tab = document.querySelector("div.layout__wnd--active ul.layout-tab-bar>li.item--focus"); 264 | let dataId: string = tab?.getAttribute("data-id"); 265 | if (!dataId) { 266 | return null; 267 | } 268 | const activeTab: HTMLDivElement = document.querySelector( 269 | `.layout-tab-container.fn__flex-1>div.protyle[data-id="${dataId}"]` 270 | ) as HTMLDivElement; 271 | return activeTab; 272 | } 273 | 274 | 275 | // 将 HTML 字符串转换为 DOM 对象 276 | export function stringToDom(htmlString: string): Element { 277 | if (isStrBlank(htmlString)) { 278 | return null; 279 | } 280 | const container = document.createElement('div') 281 | container.innerHTML = htmlString.trim(); 282 | 283 | return container.children[0]; 284 | } 285 | 286 | // 将 HTML 字符串转换为 DOM 节点数组 287 | export function stringToDomArray(htmlString: string): Element[] { 288 | // 创建一个容器元素,临时用于包含解析后的节点 289 | const container = document.createElement('div'); 290 | container.innerHTML = htmlString.trim(); // 清除字符串两端空白字符 291 | 292 | // 转换 NodeList 为数组返回 293 | return Array.from(container.children); 294 | } 295 | 296 | 297 | export function hasClosestByClassName(element: HTMLElement | null, className: string): HTMLElement | false { 298 | // 检查传入的元素是否存在,并且是否具有指定的类名 299 | if (element && element.classList.contains(className)) { 300 | return element; 301 | } 302 | 303 | // 如果没有找到,检查父级元素 304 | while (element && element.parentElement && element.tagName !== "BODY") { 305 | element = element.parentElement; 306 | if (element.classList.contains(className)) { 307 | return element; 308 | } 309 | } 310 | 311 | return false; 312 | } 313 | 314 | 315 | export function hasClosestById(element: HTMLElement | null, id: string): HTMLElement | false { 316 | // 检查传入的元素是否存在,并且是否具有指定的类名 317 | if (element && element.id == id) { 318 | return element; 319 | } 320 | 321 | // 如果没有找到,检查父级元素 322 | while (element && element.parentElement && element.tagName !== "BODY") { 323 | element = element.parentElement; 324 | if (element.id == id) { 325 | return element; 326 | } 327 | } 328 | 329 | return false; 330 | } -------------------------------------------------------------------------------- /src/models/backlink-model.ts: -------------------------------------------------------------------------------- 1 | import { getRefBlockId } from "@/service/backlink/backlink-data"; 2 | import { isArrayEmpty, isArrayNotEmpty, isSetNotEmpty } from "@/utils/array-util"; 3 | import { isStrNotBlank } from "@/utils/string-util"; 4 | 5 | export interface IBacklinkFilterPanelDataQueryParams { 6 | rootId: string; 7 | focusBlockId?: string; 8 | queryParentDefBlock?: boolean; 9 | querrChildDefBlockForListItem?: boolean; 10 | queryChildDefBlockForHeadline?: boolean; 11 | queryCurDocDefBlockRange: string; 12 | 13 | } 14 | 15 | 16 | export interface IBacklinkBlockQueryParams { 17 | queryParentDefBlock?: boolean; 18 | querrChildDefBlockForListItem?: boolean; 19 | // querrChildDefBlockForListItemSameLogseq?: boolean; 20 | queryChildDefBlockForHeadline?: boolean; 21 | defBlockIds?: string[]; 22 | backlinkBlockIds?: string[]; 23 | backlinkBlocks?: BacklinkBlock[]; 24 | backlinkAllParentBlockIds?: string[]; 25 | backlinkParentListItemBlockIds?: string[]; 26 | // queryAllContentUnderHeadline?: boolean; 27 | // includeTypes: string[]; 28 | // relatedDefBlockIdArray?: string[]; 29 | } 30 | 31 | 32 | 33 | export interface IBacklinkBlockNode { 34 | block: DefBlock; 35 | documentBlock: DefBlock; 36 | parentMarkdown: string; 37 | listItemChildMarkdown: string; 38 | headlineChildMarkdown: string; 39 | // 存放包含当前文档定义块的定义块id 40 | includeDirectDefBlockIds: Set; 41 | // 存放包含关联定义块的id;关联的定义:非当前文档的定义块 42 | includeRelatedDefBlockIds: Set; 43 | // 存放当前块的定义块。 44 | includeCurBlockDefBlockIds: Set; 45 | // 存放子级块的定义块。反链块是列表项或标题就会有子级。 46 | // 废弃,列表项的子级定义块用 parentListItemTreeNode 获取。标题的子级定义块在 includeRelatedDefBlockIds 中。 47 | // includeChildDefBlockIds: Set; 48 | // 存放父级块存在的定义块。 49 | includeParentDefBlockIds: Set; 50 | dynamicAnchorMap: Map>; 51 | staticAnchorMap: Map>; 52 | parentListItemTreeNode?: ListItemTreeNode; 53 | } 54 | 55 | export class ListItemTreeNode { 56 | id: string; 57 | parentId: string; 58 | type: string; 59 | parentIdPath: string; 60 | parentInAttrConcat: string; 61 | subMarkdown: string; 62 | subInAttrConcat: string; 63 | includeDefBlockIds: Set; 64 | children: ListItemTreeNode[]; 65 | excludeChildIdArray: string[]; 66 | includeChildIdArray: string[]; 67 | 68 | constructor(id: string) { 69 | this.id = id; 70 | this.children = []; 71 | } 72 | 73 | existsKeywords(keywordArray: string[]): boolean { 74 | if (keywordArray || keywordArray.length == 0) { 75 | return true; 76 | } 77 | let newKeywordArray = keywordArray.slice(); 78 | for (const keywordStr of keywordArray) { 79 | if (this.subMarkdown.includes(keywordStr)) { 80 | newKeywordArray.filter(element => element !== keywordStr); 81 | } 82 | } 83 | if (newKeywordArray.length == 0) { 84 | return true; 85 | } 86 | // 递归检查子节点 87 | this.children.forEach(child => { 88 | const childMatches = child.existsKeywords(newKeywordArray); 89 | if (childMatches) { 90 | return true; 91 | } 92 | }); 93 | 94 | if (newKeywordArray.length == 0) { 95 | return true; 96 | } else { 97 | return false; 98 | } 99 | } 100 | 101 | resetExcludeItemIdArray(parentDefBlockIdArray: string[], excludeDefBlockIdArray: string[]): string[] { 102 | let result = []; 103 | if (isArrayEmpty(excludeDefBlockIdArray)) { 104 | this.excludeChildIdArray = result; 105 | return result; 106 | } 107 | let newParentDefBlockIdArray = [...parentDefBlockIdArray]; 108 | if (isSetNotEmpty(this.includeDefBlockIds)) { 109 | newParentDefBlockIdArray.push(...this.includeDefBlockIds); 110 | } 111 | if (isSetNotEmpty(this.includeDefBlockIds)) { 112 | let exclude = excludeDefBlockIdArray.some(value => newParentDefBlockIdArray.includes(value)) 113 | if (exclude) { 114 | result.push(this.id); 115 | this.excludeChildIdArray = result; 116 | return result; 117 | } 118 | } 119 | if (isArrayNotEmpty(this.children)) { 120 | this.children.forEach(item => { 121 | let itemResult = item.resetExcludeItemIdArray(newParentDefBlockIdArray, excludeDefBlockIdArray); 122 | if (itemResult) { 123 | result = result.concat(itemResult); 124 | 125 | } 126 | }); 127 | } 128 | this.excludeChildIdArray = result; 129 | return result; 130 | } 131 | 132 | 133 | resetIncludeItemIdArray(parentDefBlockIdArray: string[], includeDefBlockIdArray: string[]): string[] { 134 | let itemArray = this.getIncludeItemArray(parentDefBlockIdArray, includeDefBlockIdArray); 135 | let itemIdSet = new Set(); 136 | for (const item of itemArray) { 137 | itemIdSet.add(item.id); 138 | if (item.parentIdPath) { 139 | let parentIdArray = item.parentIdPath.split("->"); 140 | for (const parentId of parentIdArray) { 141 | itemIdSet.add(parentId); 142 | } 143 | } 144 | let childIdArray = item.getAllChildIds(); 145 | for (const childId of childIdArray) { 146 | itemIdSet.add(childId); 147 | } 148 | } 149 | this.includeChildIdArray = Array.from(itemIdSet); 150 | return this.includeChildIdArray; 151 | } 152 | 153 | getIncludeItemArray(parentDefBlockIdArray: string[], includeDefBlockIdArray: string[]): ListItemTreeNode[] { 154 | let result: ListItemTreeNode[] = []; 155 | let newParentDefBLockIdArray = [...parentDefBlockIdArray]; 156 | if (isSetNotEmpty(this.includeDefBlockIds)) { 157 | newParentDefBLockIdArray.push(...this.includeDefBlockIds); 158 | } 159 | 160 | if (isArrayEmpty(includeDefBlockIdArray)) { 161 | result.push(this); 162 | return result; 163 | } 164 | if (isSetNotEmpty(this.includeDefBlockIds)) { 165 | let includeAll = includeDefBlockIdArray.every(value => newParentDefBLockIdArray.includes(value)) 166 | if (includeAll) { 167 | result.push(this); 168 | return result; 169 | } 170 | } 171 | if (isArrayNotEmpty(this.children)) { 172 | this.children.forEach(item => { 173 | let itemResult = item.getIncludeItemArray(newParentDefBLockIdArray, includeDefBlockIdArray); 174 | if (itemResult) { 175 | result = result.concat(itemResult); 176 | } 177 | }); 178 | } 179 | return result; 180 | 181 | } 182 | // 获取当前节点的所有子节点ID 183 | getAllChildIds(): string[] { 184 | let ids: string[] = []; 185 | 186 | // 递归获取所有子节点的ID 187 | this.children.forEach(child => { 188 | ids.push(child.id); 189 | ids = ids.concat(child.getAllChildIds()); 190 | }); 191 | 192 | return ids; 193 | } 194 | 195 | // 获取当前节点的所有子节点中的引用ID 196 | getAllDefBlockIds(): string[] { 197 | 198 | return this.getFilterDefBlockIds(null, null); 199 | } 200 | 201 | getFilterDefBlockIds(includeChildIdArray: string[], excludeChildIdArray: string[]): string[] { 202 | let childMarkdown = this.getFilterMarkdown(includeChildIdArray, excludeChildIdArray); 203 | let defBlockIds = getRefBlockId(childMarkdown); 204 | return defBlockIds; 205 | } 206 | 207 | getAllMarkdown(): string { 208 | 209 | return this.getFilterMarkdown(null, null); 210 | } 211 | 212 | 213 | getFilterMarkdown(includeChildIdArray: string[], excludeChildIdArray: string[]): string { 214 | let markdown: string = isStrNotBlank(this.subMarkdown) ? this.subMarkdown : "" 215 | markdown += isStrNotBlank(this.parentInAttrConcat) ? this.parentInAttrConcat : ""; 216 | markdown += isStrNotBlank(this.subInAttrConcat) ? this.subInAttrConcat : ""; 217 | 218 | for (const child of this.children) { 219 | if (isArrayNotEmpty(excludeChildIdArray) && excludeChildIdArray.includes(child.id)) { 220 | continue; 221 | } 222 | if (isArrayNotEmpty(includeChildIdArray) && !includeChildIdArray.includes(child.id)) { 223 | continue; 224 | } 225 | let childMarkdown = child.getFilterMarkdown(includeChildIdArray, excludeChildIdArray) 226 | markdown += childMarkdown; 227 | } 228 | 229 | return markdown; 230 | } 231 | 232 | 233 | 234 | static buildTree(data: BacklinkChildBlock[]): ListItemTreeNode[] | null { 235 | const rootNodes: Record = {}; 236 | 237 | data.forEach(item => { 238 | const pathIds = item.parentIdPath.split('->'); 239 | let currentNode: ListItemTreeNode | undefined = rootNodes[pathIds[0]]; 240 | 241 | // 如果根节点不存在,则创建它 242 | if (!currentNode) { 243 | currentNode = new ListItemTreeNode(pathIds[0]); 244 | rootNodes[pathIds[0]] = currentNode; 245 | } 246 | 247 | for (let i = 1; i < pathIds.length; i++) { 248 | const nodeId = pathIds[i]; 249 | let childNode = currentNode.children.find(node => node.id === nodeId); 250 | 251 | // 如果子节点不存在,则创建它 252 | if (!childNode) { 253 | childNode = new ListItemTreeNode(nodeId); 254 | currentNode.children.push(childNode); 255 | } 256 | 257 | currentNode = childNode; 258 | } 259 | 260 | // 为最后一个节点填充内容 261 | if (currentNode) { 262 | currentNode.parentId = item.parent_id; 263 | currentNode.type = item.type; 264 | currentNode.parentIdPath = item.parentIdPath; 265 | currentNode.parentInAttrConcat = item.parentInAttrConcat; 266 | currentNode.subMarkdown = item.subMarkdown; 267 | currentNode.subInAttrConcat = item.subInAttrConcat 268 | 269 | currentNode.includeDefBlockIds = new Set(getRefBlockId(currentNode.subMarkdown)); 270 | 271 | } 272 | }); 273 | 274 | // 返回构建的树的根节点(假设只有一个根节点) 275 | const rootNodeArray = Object.values(rootNodes); 276 | return rootNodeArray; 277 | } 278 | } 279 | 280 | 281 | export interface IBacklinkFilterPanelData { 282 | rootId?: string; 283 | backlinkBlockNodeArray: IBacklinkBlockNode[]; 284 | // 当前文档的定义块 285 | curDocDefBlockArray: DefBlock[]; 286 | // 有关联的定义块 287 | relatedDefBlockArray: DefBlock[]; 288 | // 反链块所属的文档 289 | backlinkDocumentArray: DefBlock[]; 290 | 291 | userCache?: boolean; 292 | 293 | // 关联块文档数据结构,不采用文档方式 294 | // documentNodeArray: DocumentNode[]; 295 | } 296 | 297 | export interface IPanelRenderBacklinkQueryParams { 298 | pageNum: number; 299 | pageSize: number; 300 | backlinkCurDocDefBlockType: string; 301 | backlinkBlockSortMethod: BlockSortMethod; 302 | backlinkKeywordStr: string; 303 | includeRelatedDefBlockIds: Set; 304 | excludeRelatedDefBlockIds: Set; 305 | includeDocumentIds: Set; 306 | excludeDocumentIds: Set; 307 | 308 | } 309 | 310 | export interface IPanelRednerFilterQueryParams extends IPanelRenderBacklinkQueryParams { 311 | filterPanelCurDocDefBlockSortMethod: BlockSortMethod; 312 | filterPanelCurDocDefBlockKeywords: string; 313 | 314 | filterPanelRelatedDefBlockType: string; 315 | filterPanelRelatedDefBlockSortMethod: BlockSortMethod; 316 | filterPanelRelatedDefBlockKeywords: string; 317 | 318 | filterPanelBacklinkDocumentSortMethod: BlockSortMethod; 319 | filterPanelBacklinkDocumentKeywords: string; 320 | } 321 | 322 | 323 | 324 | export interface IBacklinkPanelRenderData { 325 | rootId: string; 326 | 327 | backlinkDataArray: IBacklinkData[]; 328 | 329 | backlinkBlockNodeArray: IBacklinkBlockNode[]; 330 | // 当前文档的定义块 331 | curDocDefBlockArray: DefBlock[]; 332 | // 有关联的定义块 333 | relatedDefBlockArray: DefBlock[]; 334 | // 反链块所属的文档块信息 335 | backlinkDocumentArray: DefBlock[]; 336 | 337 | pageNum: number; 338 | pageSize: number; 339 | totalPage: number; 340 | usedCache: boolean; 341 | } 342 | 343 | export class BacklinkPanelFilterCriteria { 344 | // backlinkPanelBaseDataQueryParams: BacklinkPanelBaseDataQueryParams; 345 | queryParams: IPanelRednerFilterQueryParams; 346 | backlinkPanelFilterViewExpand: boolean; 347 | // backlinkPanelBacklinkViewExpand: boolean; 348 | 349 | } -------------------------------------------------------------------------------- /src/service/plugin/DocumentService.ts: -------------------------------------------------------------------------------- 1 | import { EnvConfig } from "@/config/EnvConfig"; 2 | import BacklinkFilterPanelPageSvelte from "@/components/panel/backlink-filter-panel-page.svelte"; 3 | import { SettingService } from "@/service/setting/SettingService"; 4 | import Instance from "@/utils/Instance"; 5 | import { Menu } from "siyuan"; 6 | import { BacklinkFilterPanelAttributeService, DOCUMENT_BOTTOM_SHOW_BACKLINK_FILTER_PANEL_ATTRIBUTE_KEY } from "@/service/setting/BacklinkPanelFilterCriteriaService"; 7 | import { clearProtyleGutters, hasClosestByClassName, hasClosestById } from "@/utils/html-util"; 8 | import { generateGetDefBlockArraySql } from "../backlink/backlink-sql"; 9 | import { sql } from "@/utils/api"; 10 | import { isArrayEmpty } from "@/utils/array-util"; 11 | import { NewNodeID } from "@/utils/siyuan-util"; 12 | 13 | 14 | let backlinkPanelPageSvelteMap: Map = new Map(); 15 | let documentProtyleElementMap: Map = new Map(); 16 | 17 | 18 | export class DocumentService { 19 | 20 | public static get ins(): DocumentService { 21 | return Instance.get(DocumentService); 22 | } 23 | 24 | public init() { 25 | EnvConfig.ins.plugin.eventBus.on("loaded-protyle-static", (e: any) => { 26 | // console.log("loaded-protyle-static e : ", e) 27 | handleSwitchProtyleOrLoadedProtyleStatic(e); 28 | }); 29 | 30 | EnvConfig.ins.plugin.eventBus.on("switch-protyle", (e: any) => { 31 | // console.log("switch-protyle e : ", e) 32 | handleSwitchProtyleOrLoadedProtyleStatic(e); 33 | }); 34 | 35 | EnvConfig.ins.plugin.eventBus.on("destroy-protyle", (e: any) => { 36 | handleDestroyProtyle(e); 37 | }); 38 | 39 | EnvConfig.ins.plugin.eventBus.on("click-editortitleicon", (e: any) => { 40 | hadnleClickEditorTitleIcon(e); 41 | }); 42 | // EnvConfig.ins.plugin.addCommand({ 43 | // langKey: "showDocumentBottomBacklinkPanel", 44 | // langText: "始终显示底部反链面板", 45 | // hotkey: "⌥⇧⌘A", 46 | // editorCallback: (protyle: any) => { 47 | // console.log(protyle, "editorCallback"); 48 | // }, 49 | // }); 50 | 51 | intervalSetNodePaddingBottom(); 52 | } 53 | 54 | public destory() { 55 | destroyAllPanel(); 56 | destoryIntervalSetNodePaddingBottom(); 57 | } 58 | } 59 | 60 | async function handleSwitchProtyleOrLoadedProtyleStatic(e) { 61 | if (!e || !e.detail || !e.detail.protyle) { 62 | return; 63 | } 64 | 65 | let docuemntContentElement = e.detail.protyle.contentElement as HTMLElement; 66 | let rootId = e.detail.protyle.block.rootID; 67 | // let focusBlockId = e.detail.protyle.block.id; 68 | if (!rootId) { 69 | return; 70 | } 71 | await refreshBacklinkPanelToBottom(docuemntContentElement, rootId, null); 72 | 73 | } 74 | 75 | function handleDestroyProtyle(e) { 76 | // let rootId = e.detail.protyle.block.rootID; 77 | // documentProtyleElementMap.delete(rootId); 78 | 79 | let docuemntContentElement = e.detail.protyle.contentElement as HTMLElement; 80 | if (!docuemntContentElement) { 81 | return; 82 | 83 | } 84 | destroyPanel(docuemntContentElement); 85 | } 86 | 87 | async function getDocumentBottomBacklinkPanelDisplay(docuemntContentElement: HTMLElement, rootId: string) { 88 | // 如果是闪卡界面,不显示底部反链面板 89 | let isCardBlock = hasClosestByClassName(docuemntContentElement, "card__block") 90 | if (isCardBlock) { 91 | let flashCardBottomDisplay = SettingService.ins.SettingConfig.flashCardBottomDisplay; 92 | if (!flashCardBottomDisplay) { 93 | return false; 94 | } 95 | } 96 | // 必须是页签文档或悬浮窗才可以通过。防止 Dock 栏的插件渲染 protyle 加载反链。 97 | let isLayoutCenter = hasClosestByClassName(docuemntContentElement, "layout__center"); 98 | let isPopoverBlock = hasClosestByClassName(docuemntContentElement, "block__popover"); 99 | // 搜索弹窗的预览也显示底部反链面板,fn__flex-1 search__preview protyle 100 | let isSearchDialog = hasClosestById(docuemntContentElement, "searchPreview"); 101 | if (!isLayoutCenter && !isPopoverBlock && !isSearchDialog) { 102 | return false; 103 | } 104 | 105 | let documentBottomDisplay = SettingService.ins.SettingConfig.documentBottomDisplay; 106 | 107 | if (documentBottomDisplay) { 108 | let getDefBlockArraySql = generateGetDefBlockArraySql({ rootId: rootId }); 109 | let curDocDefBlockArray: DefBlock[] = await sql(getDefBlockArraySql); 110 | if (isArrayEmpty(curDocDefBlockArray)) { 111 | documentBottomDisplay = false;; 112 | } 113 | } 114 | let docProtyleElement = null; 115 | if (docuemntContentElement.matches(".protyle-wysiwyg--attr")) { 116 | docProtyleElement = docuemntContentElement; 117 | } else { 118 | docProtyleElement = docuemntContentElement.querySelector(`div.protyle-wysiwyg--attr[${DOCUMENT_BOTTOM_SHOW_BACKLINK_FILTER_PANEL_ATTRIBUTE_KEY}]`); 119 | } 120 | 121 | if (docProtyleElement) { 122 | let attributeValue = docProtyleElement.getAttribute(DOCUMENT_BOTTOM_SHOW_BACKLINK_FILTER_PANEL_ATTRIBUTE_KEY); 123 | if (attributeValue == "1") { 124 | documentBottomDisplay = true; 125 | } else if (attributeValue == "-1") { 126 | documentBottomDisplay = false; 127 | } 128 | } 129 | 130 | return documentBottomDisplay; 131 | } 132 | 133 | 134 | async function refreshBacklinkPanelToBottom(docuemntContentElement: HTMLElement, rootId: string, focusBlockId: string) { 135 | if (!docuemntContentElement || !rootId) { 136 | return; 137 | } 138 | let bottomDisplay = await getDocumentBottomBacklinkPanelDisplay(docuemntContentElement, rootId); 139 | // 如果该文档不需要显示,则尝试删除该元素内部可能存在的底部反链。 140 | if (!bottomDisplay) { 141 | destroyPanel(docuemntContentElement); 142 | return; 143 | } else { 144 | addBacklinkPanelToBottom(docuemntContentElement, rootId, focusBlockId); 145 | } 146 | } 147 | 148 | async function addBacklinkPanelToBottom(docuemntContentElement: HTMLElement, rootId: string, focusBlockId: string) { 149 | if (!docuemntContentElement || !rootId) { 150 | return; 151 | } 152 | // let bottomDisplay = await getDocumentBottomBacklinkPanelDisplay(docuemntContentElement, rootId); 153 | // // 如果该文档不需要显示,则尝试删除该元素内部可能存在的底部反链。 154 | // if (!bottomDisplay) { 155 | // destroyPanel(docuemntContentElement); 156 | // return; 157 | // } 158 | 159 | let protyleWysiwygElement = docuemntContentElement.querySelector(".protyle-wysiwyg.protyle-wysiwyg--attr"); 160 | let backlinkPanelBottomElement = docuemntContentElement.querySelector(".backlink-panel-document-bottom__area"); 161 | if (backlinkPanelBottomElement) { 162 | let panelRootId = backlinkPanelBottomElement.getAttribute("data-root-id"); 163 | if (panelRootId == rootId) { 164 | return; 165 | } else { 166 | destroyPanel(docuemntContentElement); 167 | } 168 | } 169 | 170 | backlinkPanelBottomElement = document.createElement("div"); 171 | backlinkPanelBottomElement.classList.add( 172 | "backlink-panel-document-bottom__area" 173 | ); 174 | 175 | let isMobile = EnvConfig.ins.isMobile; 176 | if (isMobile) { 177 | backlinkPanelBottomElement.classList.add("document-panel-plugin-mobile"); 178 | } 179 | 180 | // console.log("handleDestroyProtyle setAttribute rootId ", rootId) 181 | docuemntContentElement.appendChild(backlinkPanelBottomElement); 182 | let panelId = NewNodeID(); 183 | backlinkPanelBottomElement.setAttribute("data-root-id", rootId); 184 | backlinkPanelBottomElement.setAttribute("misuzu-backlink-panel-id", panelId) 185 | 186 | // let hrElement = document.createElement("hr"); 187 | // backlinkPanelBottomElement.appendChild(hrElement); 188 | 189 | let docBottomBacklinkPanelViewExpand = SettingService.ins.SettingConfig.docBottomBacklinkPanelViewExpand 190 | 191 | let pageSvelte = new BacklinkFilterPanelPageSvelte({ 192 | target: backlinkPanelBottomElement, 193 | props: { 194 | rootId: rootId, 195 | focusBlockId: focusBlockId, 196 | currentTab: null, 197 | panelBacklinkViewExpand: docBottomBacklinkPanelViewExpand, 198 | } 199 | }); 200 | backlinkPanelBottomElement.parentElement.addEventListener( 201 | "scroll", 202 | () => { 203 | clearProtyleGutters(backlinkPanelBottomElement as HTMLElement); 204 | }, 205 | ); 206 | backlinkPanelBottomElement.addEventListener("mouseover", (event: MouseEvent) => { 207 | // const target = event.target as HTMLElement; 208 | // 209 | // // 如果元素包含 aria-label 样式,则不阻止事件传播 210 | // if (target.classList.contains('ariaLabel')) { 211 | // return; // 不做任何操作,继续传播 212 | // } else { 213 | // document.getElementById("tooltip").classList.add("fn__none"); 214 | // } 215 | // 216 | // // 考虑创建一个隐藏的 blockref 来显示悬浮窗,子文档列表挂件中好像有相似代码。 217 | // if (target.getAttribute('data-type') === 'block-ref') { 218 | // event.stopPropagation(); 219 | // 220 | // } 221 | 222 | event.stopPropagation(); 223 | }) 224 | 225 | 226 | backlinkPanelPageSvelteMap.set(panelId, pageSvelte); 227 | documentProtyleElementMap.set(panelId, protyleWysiwygElement as HTMLElement); 228 | // handleProtyleHeightChange(protyleElement) 229 | } 230 | 231 | 232 | function destroyPanel(docuemntContentElement: HTMLElement) { 233 | if (!docuemntContentElement) { 234 | return; 235 | } 236 | let backlinkPanelBottomElement = docuemntContentElement.querySelector(".backlink-panel-document-bottom__area"); 237 | if (!backlinkPanelBottomElement) { 238 | return; 239 | } 240 | let panelId = backlinkPanelBottomElement.getAttribute("misuzu-backlink-panel-id"); 241 | if (!panelId) { 242 | return; 243 | } 244 | documentProtyleElementMap.delete(panelId); 245 | let pageSvelte = backlinkPanelPageSvelteMap.get(panelId); 246 | if (!pageSvelte) { 247 | return; 248 | } 249 | backlinkPanelPageSvelteMap.delete(panelId); 250 | pageSvelte.$destroy(); 251 | backlinkPanelBottomElement.remove(); 252 | 253 | } 254 | 255 | function destroyAllPanel() { 256 | 257 | let allDocumentContentElementArray = document.querySelectorAll("div.layout__center div.layout-tab-container div.protyle-content.protyle-content--transition"); 258 | 259 | if (!allDocumentContentElementArray) { 260 | return; 261 | } 262 | for (const docuemntContentElement of allDocumentContentElementArray) { 263 | destroyPanel(docuemntContentElement as HTMLElement); 264 | 265 | } 266 | 267 | } 268 | 269 | function hadnleClickEditorTitleIcon(e) { 270 | 271 | 272 | (e.detail.menu as Menu).addItem({ 273 | icon: "BacklinkPanelFilter", 274 | type: "submenu", 275 | label: "反链过滤面板", 276 | submenu: getDocumentBlockIconMenus(e) 277 | }); 278 | } 279 | 280 | function getDocumentBlockIconMenus(e) { 281 | let rootId = e.detail.data.rootID; 282 | if (!rootId) { 283 | return; 284 | } 285 | let submenus = []; 286 | submenus.push({ 287 | label: "恢复默认", 288 | click: async () => { 289 | await BacklinkFilterPanelAttributeService.ins.updateDocumentBottomShowPanel(rootId, null); 290 | let documentBottomDisplay = SettingService.ins.SettingConfig.documentBottomDisplay; 291 | if (documentBottomDisplay) { 292 | let docuemntContentElement = e.detail.protyle.contentElement as HTMLElement; 293 | await refreshBacklinkPanelToBottom(docuemntContentElement, rootId, null); 294 | } else { 295 | handleDestroyProtyle(e); 296 | } 297 | } 298 | }); 299 | submenus.push({ 300 | label: "始终显示该文档底部反链", 301 | click: async () => { 302 | await BacklinkFilterPanelAttributeService.ins.updateDocumentBottomShowPanel(rootId, 1); 303 | 304 | let docuemntContentElement = e.detail.protyle.contentElement as HTMLElement; 305 | await refreshBacklinkPanelToBottom(docuemntContentElement, rootId, null); 306 | } 307 | }); 308 | submenus.push({ 309 | label: "始终隐藏该文档底部反链", 310 | click: async () => { 311 | BacklinkFilterPanelAttributeService.ins.updateDocumentBottomShowPanel(rootId, -1); 312 | let docuemntContentElement = e.detail.protyle.contentElement as HTMLElement; 313 | destroyPanel(docuemntContentElement); 314 | } 315 | }); 316 | 317 | return submenus; 318 | } 319 | 320 | 321 | let intervalId; 322 | function intervalSetNodePaddingBottom() { 323 | // 后续看能不能优化成响应式的。。 324 | intervalId = setInterval(() => { 325 | if (documentProtyleElementMap.size <= 0) { 326 | return; 327 | } 328 | let paddingWidthSize = SettingService.ins.SettingConfig.documentBottomBacklinkPaddingWidth; 329 | 330 | let paddingBottomSize = "48px"; 331 | for (const key of documentProtyleElementMap.keys()) { 332 | let protyleElement = documentProtyleElementMap.get(key); 333 | 334 | if (parseFloat(protyleElement.style.paddingBottom) > 88) { 335 | protyleElement.style.paddingBottom = paddingBottomSize; 336 | } 337 | let panelElement = protyleElement.parentElement.querySelector(".backlink-panel-document-bottom__area") as HTMLElement; 338 | if (panelElement && protyleElement.style.paddingLeft != panelElement.style.paddingLeft) { 339 | let paddingWidthPx = paddingWidthSize + "px"; 340 | if (paddingWidthSize == undefined || paddingWidthSize == null) { 341 | // console.log("intervalSetNodePaddingBottom") 342 | paddingWidthPx = protyleElement.style.paddingLeft; 343 | } 344 | panelElement.style.paddingLeft = paddingWidthPx; 345 | panelElement.style.paddingRight = paddingWidthPx; 346 | } 347 | } 348 | }, 50); 349 | } 350 | 351 | 352 | function destoryIntervalSetNodePaddingBottom() { 353 | if (intervalId) { 354 | clearInterval(intervalId); 355 | } 356 | } -------------------------------------------------------------------------------- /src/service/backlink/backlink-sql.ts: -------------------------------------------------------------------------------- 1 | import { IBacklinkBlockQueryParams } from "@/models/backlink-model"; 2 | import { isArrayEmpty, isArrayNotEmpty } from "@/utils/array-util"; 3 | import { isStrBlank } from "@/utils/string-util"; 4 | 5 | 6 | 7 | /** 8 | * 查询指定块下面的所有定义块 9 | * @param queryParams 10 | * @returns 11 | */ 12 | export function generateGetDefBlockArraySql(paramObj: { 13 | rootId: string, focusBlockId?: string, queryCurDocDefBlockRange?: string 14 | } 15 | ): string { 16 | let rootId = paramObj.rootId; 17 | let focusBlockId = paramObj.focusBlockId; 18 | let queryCurDocDefBlockRange = paramObj.queryCurDocDefBlockRange; 19 | let sql = ""; 20 | if (focusBlockId && focusBlockId != rootId) { 21 | sql = ` 22 | WITH RECURSIVE cte AS ( 23 | SELECT * 24 | FROM blocks 25 | WHERE id = '${focusBlockId}' AND root_id = '${rootId}' 26 | UNION ALL 27 | SELECT t.* 28 | FROM blocks t 29 | INNER JOIN cte ON t.parent_id = cte.id 30 | WHERE t.root_id = '${rootId}' 31 | ) 32 | SELECT cte.*,rc.refCount AS refCount, rc.backlinkBlockIdConcat 33 | FROM cte 34 | INNER JOIN refs ON cte.id = refs.def_block_id 35 | AND refs.def_block_root_id = '${rootId}' 36 | LEFT JOIN ( 37 | SELECT def_block_id, COUNT(1) AS refCount, 38 | GROUP_CONCAT( refs.block_id ) AS backlinkBlockIdConcat 39 | FROM refs 40 | GROUP BY def_block_id 41 | ) rc 42 | WHERE cte.id = rc.def_block_id 43 | GROUP BY cte.id, rc.refCount 44 | LIMIT 999999999; 45 | ` 46 | /** 47 | * 其他简单写法。 48 | SELECT DISTINCT cte.*,(SELECT count(1) FROM refs rc WHERE cte.id = rc.def_block_id) 49 | FROM cte 50 | INNER JOIN refs ON cte.id = refs.def_block_id AND refs.def_block_root_id = '${rootId}' 51 | */ 52 | } else { 53 | let refBlockIdFieldSql = `,CASE WHEN root_id != '${rootId}' THEN ( SELECT block_id FROM refs WHERE root_id = '${rootId}' AND def_block_id = blocks.id ) END AS refBlockId`; 54 | let refWhereSql = `def_block_root_id = '${rootId}'`; 55 | if (queryCurDocDefBlockRange == "curDocRefDefBlock") { 56 | refWhereSql = ` root_id = '${rootId}'`; 57 | } else if (queryCurDocDefBlockRange == "all") { 58 | refWhereSql = `def_block_root_id = '${rootId}' OR root_id = '${rootId}'`; 59 | } 60 | sql = ` 61 | SELECT * , 62 | (SELECT count(refs.def_block_id) FROM refs WHERE refs.def_block_id = blocks.id 63 | ) AS refCount, 64 | ( SELECT GROUP_CONCAT( refs.block_id ) FROM refs WHERE refs.def_block_id = blocks.id 65 | ) AS backlinkBlockIdConcat 66 | ${queryCurDocDefBlockRange == "curDocDefBlock" ? "" : refBlockIdFieldSql} 67 | FROM blocks 68 | WHERE id in ( 69 | SELECT DISTINCT def_block_id 70 | FROM refs 71 | WHERE ${refWhereSql} 72 | ) 73 | LIMIT 999999999; 74 | ` 75 | } 76 | 77 | return cleanSpaceText(sql); 78 | } 79 | 80 | 81 | export function generateGetParentDefBlockArraySql( 82 | queryParams: IBacklinkBlockQueryParams, 83 | ): string { 84 | let defBlockIds = queryParams.defBlockIds; 85 | let backlinkBlockIds = queryParams.backlinkBlockIds; 86 | 87 | let backlinkIdInSql = ""; 88 | if (isArrayNotEmpty(backlinkBlockIds)) { 89 | backlinkIdInSql = generateAndInConditions("id", backlinkBlockIds); 90 | } else if (isArrayNotEmpty(defBlockIds)) { 91 | let defBlockIdInSql = generateAndInConditions("def_block_id", defBlockIds); 92 | backlinkIdInSql = `AND id IN ( SELECT block_id FROM refs WHERE 1 = 1 ${defBlockIdInSql} ) ` 93 | } 94 | if (isStrBlank(backlinkIdInSql)) { 95 | return ""; 96 | } 97 | 98 | 99 | let sql = ` 100 | WITH RECURSIVE parent_block AS ( 101 | SELECT id, parent_id, name || alias || memo AS inAttrConcat, markdown, type, CAST (id AS TEXT) AS childIdPath 102 | FROM blocks 103 | WHERE 1 = 1 ${backlinkIdInSql} 104 | UNION ALL 105 | SELECT t.id, t.parent_id, t.name || t.alias || t.memo AS inAttrConcat, t.markdown, t.type, (p.childIdPath || '->' || t.id) AS childIdPath 106 | FROM blocks t 107 | INNER JOIN parent_block p ON t.id = p.parent_id 108 | WHERE t.type NOT IN ( 'd', 'c', 'm', 't', 'p', 'tb', 'html', 'video', 'audio', 'widget', 'iframe', 'query_embed' ) 109 | ) 110 | SELECT id, parent_id, type, childIdPath, inAttrConcat, CASE WHEN type = 'i' THEN '' ELSE markdown END AS markdown 111 | FROM parent_block 112 | WHERE 1 == 1 113 | AND type IN ( 'i', 'h', 'b', 's' ) 114 | LIMIT 999999999; 115 | ` 116 | 117 | /** 118 | * 2025-01-19 跟进 v3.1.20 中,反链面板会展示父级是超级块、引述块 119 | * AND( ( type = 'i' ) OR ( type = 'h' ) ) 改为 AND type IN ( 'i', 'h', 'b', 's' ) 120 | */ 121 | 122 | 123 | return cleanSpaceText(sql); 124 | } 125 | 126 | 127 | export function generateGetParenListItemtDefBlockArraySql( 128 | queryParams: IBacklinkBlockQueryParams, 129 | ): string { 130 | 131 | let backlinkParentBlockIds = queryParams.backlinkAllParentBlockIds; 132 | let idInSql = generateAndInConditions("sb.parent_id", backlinkParentBlockIds); 133 | 134 | /** 135 | * 为了能够匹配所有父级列表项关键字,去除条件 AND markdown LIKE '%((%))%' 136 | */ 137 | let sql = ` 138 | SELECT 139 | sb.parent_id, 140 | GROUP_CONCAT( sb.name || sb.alias || sb.memo || p.name || p.alias || p.memo ) AS inAttrConcat, 141 | GROUP_CONCAT( sb.markdown ) AS subMarkdown 142 | FROM blocks sb LEFT JOIN blocks p on p.id = sb.parent_id 143 | WHERE 1 = 1 144 | ${idInSql} 145 | AND sb.type NOT IN ('l', 'i') 146 | 147 | GROUP BY sb.parent_id 148 | LIMIT 999999999; 149 | ` 150 | 151 | return cleanSpaceText(sql); 152 | } 153 | 154 | export function generateGetListItemtSubMarkdownArraySql( 155 | listItemIdArray: string[], 156 | ): string { 157 | if (isArrayEmpty(listItemIdArray)) { 158 | return ""; 159 | } 160 | let idInSql = generateAndInConditions("sb.parent_id", listItemIdArray); 161 | 162 | /** 163 | * subInAttrConcat 指的是叶子块中的内部属性 164 | * parentInAttrConcat 指的是叶子块父级的列表项块的内部属性 165 | */ 166 | let sql = ` 167 | SELECT sb.parent_id, GROUP_CONCAT( sb.markdown) AS subMarkdown , 168 | GROUP_CONCAT( sb.name || sb.alias ||sb.memo ) AS subInAttrConcat, 169 | p.name || p.alias || p.memo AS parentInAttrConcat 170 | FROM blocks sb LEFT JOIN blocks p on p.id = sb.parent_id 171 | WHERE 1 = 1 172 | ${idInSql} 173 | AND sb.type NOT IN ( 'l', 'i' ) 174 | GROUP BY 175 | sb.parent_id 176 | LIMIT 9999999999; 177 | ` 178 | 179 | return cleanSpaceText(sql); 180 | } 181 | 182 | 183 | 184 | export function generateGetBacklinkBlockArraySql( 185 | queryParams: IBacklinkBlockQueryParams, 186 | ): string { 187 | let defBlockIds = queryParams.defBlockIds; 188 | let idInSql = generateAndInConditions("def_block_id", defBlockIds); 189 | 190 | let sql = ` 191 | SELECT b.* 192 | FROM blocks b 193 | WHERE 1 = 1 194 | AND b.id IN ( 195 | SELECT block_id 196 | FROM refs 197 | WHERE 1 = 1 ${idInSql} 198 | ) 199 | LIMIT 9999999999; 200 | ` 201 | return cleanSpaceText(sql); 202 | } 203 | 204 | 205 | export function generateGetBacklinkListItemBlockArraySql( 206 | queryParams: IBacklinkBlockQueryParams, 207 | ): string { 208 | let defBlockIds = queryParams.defBlockIds; 209 | let idInSql = generateAndInConditions("def_block_id", defBlockIds); 210 | 211 | let sql = ` 212 | SELECT b.*, 213 | p1.type AS parentBlockType 214 | 215 | FROM blocks b 216 | LEFT JOIN blocks p1 ON b.parent_id = p1.id 217 | 218 | WHERE 1 = 1 219 | AND b.id IN ( 220 | SELECT block_id 221 | FROM refs 222 | WHERE 1 = 1 ${idInSql} 223 | ) 224 | LIMIT 999999999; 225 | ` 226 | /** 227 | , 228 | CASE WHEN p1.type = 'i' 229 | THEN p1.markdown 230 | ELSE NULL 231 | END AS parentListItemMarkdown 232 | */ 233 | return cleanSpaceText(sql); 234 | } 235 | 236 | 237 | export function generateGetHeadlineChildDefBlockArraySql( 238 | queryParams: IBacklinkBlockQueryParams, 239 | ): string { 240 | let defBlockIds = queryParams.defBlockIds; 241 | let backlinkBlockIds = queryParams.backlinkBlockIds; 242 | 243 | let backlinkIdInSql = ""; 244 | if (isArrayNotEmpty(backlinkBlockIds)) { 245 | backlinkIdInSql = generateAndInConditions("id", backlinkBlockIds); 246 | } else if (isArrayNotEmpty(defBlockIds)) { 247 | let defBlockIdInSql = generateAndInConditions("def_block_id", defBlockIds); 248 | backlinkIdInSql = `AND id IN ( SELECT block_id FROM refs WHERE 1 = 1 ${defBlockIdInSql} ) ` 249 | } 250 | if (isStrBlank(backlinkIdInSql)) { 251 | return ""; 252 | } 253 | 254 | let whereSql = ` AND type IN ( 'h', 'c', 'm', 't', 'p', 'html', 'av', 'video', 'audio', 'l', 's' ) `; 255 | // if (!queryParams.queryAllContentUnderHeadline) { 256 | // whereSql = ` 257 | // AND type IN ( 'h', 't', 'p' ) 258 | // AND markdown LIKE '%((%))%' 259 | // `; 260 | // } 261 | 262 | 263 | 264 | let sql = ` 265 | WITH RECURSIVE child_block AS ( 266 | SELECT id, parent_id, (name || alias || memo) AS subInAttrConcat, markdown, type, CAST ( id AS TEXT ) AS parentIdPath 267 | FROM blocks 268 | WHERE 1 = 1 269 | AND type = 'h' 270 | ${backlinkIdInSql} 271 | UNION ALL 272 | SELECT t.id, t.parent_id, (t.name || t.alias || t.memo) AS subInAttrConcat, t.markdown, t.type, ( c.parentIdPath || '->' || t.id ) AS parentIdPath 273 | FROM blocks t 274 | INNER JOIN child_block c ON c.id = t.parent_id 275 | WHERE t.type NOT IN ( 'd', 'i', 'tb', 'audio', 'widget', 'iframe', 'query_embed' ) 276 | ) 277 | SELECT * 278 | FROM child_block 279 | WHERE 1 == 1 ${whereSql} 280 | LIMIT 999999999; 281 | ` 282 | return cleanSpaceText(sql); 283 | } 284 | 285 | 286 | export function generateGetListItemChildBlockArraySql( 287 | queryParams: IBacklinkBlockQueryParams, 288 | ): string { 289 | let defBlockIds = queryParams.defBlockIds; 290 | let backlinkBlockIds = queryParams.backlinkBlockIds; 291 | let parentBlockIds = queryParams.backlinkParentListItemBlockIds; 292 | 293 | let idInSql = ""; 294 | if (isArrayNotEmpty(parentBlockIds)) { 295 | idInSql = generateAndInConditions("id", parentBlockIds); 296 | } else if (isArrayNotEmpty(backlinkBlockIds)) { 297 | let backlinkIdInSql = generateAndInConditions("id", backlinkBlockIds); 298 | idInSql = `AND id IN ( SELECT parent_id FROM blocks WHERE 1 = 1 ${backlinkIdInSql} ) ` 299 | } else if (isArrayNotEmpty(defBlockIds)) { 300 | let defBlockIdInSql = generateAndInConditions("def_block_id", defBlockIds); 301 | idInSql = `AND id IN ( SELECT parent_id FROM blocks WHERE 1 =1 AND id IN ( SELECT block_id FROM refs WHERE 1 = 1 ${defBlockIdInSql} ) )` 302 | } 303 | if (isStrBlank(idInSql)) { 304 | return ""; 305 | } 306 | 307 | let sql = ` 308 | WITH RECURSIVE child_block AS ( 309 | SELECT id,parent_id,type,CAST ( id AS TEXT ) AS parentIdPath 310 | FROM blocks 311 | WHERE 1 = 1 312 | ${idInSql} 313 | AND type = 'i' 314 | UNION ALL 315 | SELECT t.id,t.parent_id,t.type,( c.parentIdPath || '->' || t.id ) AS parentIdPath 316 | FROM blocks t INNER JOIN child_block c ON c.id = t.parent_id 317 | ) 318 | SELECT * 319 | FROM child_block 320 | WHERE 1 == 1 AND type IN ( 'i' ) 321 | LIMIT 999999999; 322 | ` 323 | /** 324 | * todo 先这样,等后续过两个版本后,都有父级索引后再优化。 325 | , 326 | (SELECT GROUP_CONCAT( markdown ) FROM blocks sb 327 | WHERE 1 = 1 AND sb.parent_id = child_block.id AND sb.type NOT IN ( 'l', 'i' ) GROUP BY sb.parent_id 328 | ) AS subMarkdown 329 | */ 330 | return cleanSpaceText(sql); 331 | } 332 | 333 | export function generateGetBlockArraySql( 334 | blockIds: string[], 335 | ): string { 336 | let idInSql = generateAndInConditions("id", blockIds); 337 | 338 | let sql = ` 339 | SELECT b.* 340 | FROM blocks b 341 | WHERE 1 = 1 342 | ${idInSql} 343 | LIMIT 999999999; 344 | ` 345 | return cleanSpaceText(sql); 346 | } 347 | 348 | export function getParentIdIdxInfoSql() { 349 | let sql = ` 350 | PRAGMA index_info(idx_blocks_parent_id_backlink_panel_plugin); 351 | ` 352 | return cleanSpaceText(sql); 353 | } 354 | 355 | export function getCreateBlocksParentIdIdxSql() { 356 | let sql = ` 357 | CREATE INDEX idx_blocks_parent_id_backlink_panel_plugin ON blocks(parent_id); 358 | ` 359 | return cleanSpaceText(sql); 360 | } 361 | 362 | 363 | 364 | export function generateGetChildBlockArraySql( 365 | rootId: string, 366 | focusBlockId: string, 367 | 368 | ): string { 369 | let sql = ` 370 | WITH RECURSIVE cte AS ( 371 | SELECT * 372 | FROM blocks 373 | WHERE id = '${focusBlockId}' AND root_id = '${rootId}' 374 | UNION ALL 375 | SELECT t.* 376 | FROM blocks t 377 | INNER JOIN cte ON t.parent_id = cte.id 378 | WHERE t.root_id = '${rootId}' 379 | AND t.type NOT IN ( 'd', 'i', 'tb', 'audio', 'widget', 'iframe', 'query_embed' ) 380 | ) 381 | SELECT cte.* 382 | FROM cte 383 | LIMIT 999999999; 384 | ` 385 | return cleanSpaceText(sql); 386 | } 387 | 388 | 389 | 390 | function cleanSpaceText(inputText: string): string { 391 | // 去除换行 392 | let cleanedText = inputText.replace(/[\r\n]+/g, ' '); 393 | 394 | // 将多个空格转为一个空格 395 | cleanedText = cleanedText.replace(/\s+/g, ' '); 396 | 397 | // 去除首尾空格 398 | cleanedText = cleanedText.trim(); 399 | 400 | return cleanedText; 401 | } 402 | 403 | function generatMarkdownOrLikeDefBlockIdConditions( 404 | fieldName: string, 405 | params: string[], 406 | ): string { 407 | if (params.length === 0) { 408 | return " "; 409 | } 410 | 411 | const conditions = params.map( 412 | (param) => `${fieldName} LIKE '%((${param} %))%'`, 413 | ); 414 | const result = conditions.join(" OR "); 415 | 416 | return result; 417 | } 418 | 419 | 420 | 421 | function generateAndInConditions( 422 | fieldName: string, 423 | params: string[], 424 | ): string { 425 | if (!params || params.length === 0) { 426 | return " "; 427 | } 428 | let result = ` AND ${fieldName} IN (` 429 | const conditions = params.map( 430 | (param) => ` '${param}' `, 431 | ); 432 | result = result + conditions.join(" , ") + " ) "; 433 | 434 | return result; 435 | } 436 | 437 | function generateAndNotInConditions( 438 | fieldName: string, 439 | params: string[], 440 | ): string { 441 | if (!params || params.length === 0) { 442 | return " "; 443 | } 444 | let result = ` AND ${fieldName} NOT IN (` 445 | const conditions = params.map( 446 | (param) => ` '${param}' `, 447 | ); 448 | result = result + conditions.join(" , ") + " ) "; 449 | 450 | return result; 451 | } 452 | 453 | function generateInConditions( 454 | params: string[], 455 | ): string { 456 | if (!params || params.length === 0) { 457 | return " "; 458 | } 459 | let result = ` ( ` 460 | const conditions = params.map( 461 | (param) => ` '${param}' `, 462 | ); 463 | result = result + conditions.join(" , ") + " ) "; 464 | 465 | return result; 466 | } 467 | 468 | -------------------------------------------------------------------------------- /src/utils/api.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2023 frostime. All rights reserved. 3 | * https://github.com/frostime/sy-plugin-template-vite 4 | * 5 | * See API Document in [API.md](https://github.com/siyuan-note/siyuan/blob/master/API.md) 6 | * API 文档见 [API_zh_CN.md](https://github.com/siyuan-note/siyuan/blob/master/API_zh_CN.md) 7 | */ 8 | 9 | import { fetchSyncPost, IWebSocketData } from "siyuan"; 10 | import { isBoolean } from "./object-util"; 11 | 12 | 13 | 14 | async function request(url: string, data: any) { 15 | let response: IWebSocketData = await fetchSyncPost(url, data); 16 | let res = response.code === 0 ? response.data : null; 17 | if (response.code != 0) { 18 | console.log(`反链面板插件接口异常 url : ${url} , msg : ${response.msg}`) 19 | } 20 | return res; 21 | } 22 | 23 | 24 | // **************************************** Noteboook **************************************** 25 | 26 | 27 | export async function lsNotebooks(): Promise { 28 | let url = '/api/notebook/lsNotebooks'; 29 | return request(url, ''); 30 | } 31 | 32 | 33 | export async function openNotebook(notebook: NotebookId) { 34 | let url = '/api/notebook/openNotebook'; 35 | return request(url, { notebook: notebook }); 36 | } 37 | 38 | 39 | export async function closeNotebook(notebook: NotebookId) { 40 | let url = '/api/notebook/closeNotebook'; 41 | return request(url, { notebook: notebook }); 42 | } 43 | 44 | 45 | export async function renameNotebook(notebook: NotebookId, name: string) { 46 | let url = '/api/notebook/renameNotebook'; 47 | return request(url, { notebook: notebook, name: name }); 48 | } 49 | 50 | 51 | export async function createNotebook(name: string): Promise { 52 | let url = '/api/notebook/createNotebook'; 53 | return request(url, { name: name }); 54 | } 55 | 56 | 57 | export async function removeNotebook(notebook: NotebookId) { 58 | let url = '/api/notebook/removeNotebook'; 59 | return request(url, { notebook: notebook }); 60 | } 61 | 62 | 63 | export async function getNotebookConf(notebook: NotebookId): Promise { 64 | let data = { notebook: notebook }; 65 | let url = '/api/notebook/getNotebookConf'; 66 | return request(url, data); 67 | } 68 | 69 | 70 | export async function setNotebookConf(notebook: NotebookId, conf: NotebookConf): Promise { 71 | let data = { notebook: notebook, conf: conf }; 72 | let url = '/api/notebook/setNotebookConf'; 73 | return request(url, data); 74 | } 75 | 76 | 77 | // **************************************** File Tree **************************************** 78 | export async function createDocWithMd(notebook: NotebookId, path: string, markdown: string): Promise { 79 | let data = { 80 | notebook: notebook, 81 | path: path, 82 | markdown: markdown, 83 | }; 84 | let url = '/api/filetree/createDocWithMd'; 85 | return request(url, data); 86 | } 87 | 88 | 89 | export async function renameDoc(notebook: NotebookId, path: string, title: string): Promise { 90 | let data = { 91 | doc: notebook, 92 | path: path, 93 | title: title 94 | }; 95 | let url = '/api/filetree/renameDoc'; 96 | return request(url, data); 97 | } 98 | 99 | 100 | export async function removeDoc(notebook: NotebookId, path: string) { 101 | let data = { 102 | notebook: notebook, 103 | path: path, 104 | }; 105 | let url = '/api/filetree/removeDoc'; 106 | return request(url, data); 107 | } 108 | 109 | 110 | export async function moveDocs(fromPaths: string[], toNotebook: NotebookId, toPath: string) { 111 | let data = { 112 | fromPaths: fromPaths, 113 | toNotebook: toNotebook, 114 | toPath: toPath 115 | }; 116 | let url = '/api/filetree/moveDocs'; 117 | return request(url, data); 118 | } 119 | 120 | 121 | export async function getHPathByPath(notebook: NotebookId, path: string): Promise { 122 | let data = { 123 | notebook: notebook, 124 | path: path 125 | }; 126 | let url = '/api/filetree/getHPathByPath'; 127 | return request(url, data); 128 | } 129 | 130 | 131 | export async function getHPathByID(id: BlockId): Promise { 132 | let data = { 133 | id: id 134 | }; 135 | let url = '/api/filetree/getHPathByID'; 136 | return request(url, data); 137 | } 138 | 139 | 140 | export async function getIDsByHPath(notebook: NotebookId, path: string): Promise { 141 | let data = { 142 | notebook: notebook, 143 | path: path 144 | }; 145 | let url = '/api/filetree/getIDsByHPath'; 146 | return request(url, data); 147 | } 148 | 149 | // **************************************** Asset Files **************************************** 150 | 151 | export async function upload(assetsDirPath: string, files: any[]): Promise { 152 | let form = new FormData(); 153 | form.append('assetsDirPath', assetsDirPath); 154 | for (let file of files) { 155 | form.append('file[]', file); 156 | } 157 | let url = '/api/asset/upload'; 158 | return request(url, form); 159 | } 160 | 161 | // **************************************** Block **************************************** 162 | type DataType = "markdown" | "dom"; 163 | export async function insertBlock( 164 | dataType: DataType, data: string, 165 | nextID?: BlockId, previousID?: BlockId, parentID?: BlockId 166 | ): Promise { 167 | let payload = { 168 | dataType: dataType, 169 | data: data, 170 | nextID: nextID, 171 | previousID: previousID, 172 | parentID: parentID 173 | } 174 | let url = '/api/block/insertBlock'; 175 | return request(url, payload); 176 | } 177 | 178 | 179 | export async function prependBlock(dataType: DataType, data: string, parentID: BlockId | DocumentId): Promise { 180 | let payload = { 181 | dataType: dataType, 182 | data: data, 183 | parentID: parentID 184 | } 185 | let url = '/api/block/prependBlock'; 186 | return request(url, payload); 187 | } 188 | 189 | 190 | export async function appendBlock(dataType: DataType, data: string, parentID: BlockId | DocumentId): Promise { 191 | let payload = { 192 | dataType: dataType, 193 | data: data, 194 | parentID: parentID 195 | } 196 | let url = '/api/block/appendBlock'; 197 | return request(url, payload); 198 | } 199 | 200 | 201 | export async function updateBlock(dataType: DataType, data: string, id: BlockId): Promise { 202 | let payload = { 203 | dataType: dataType, 204 | data: data, 205 | id: id 206 | } 207 | let url = '/api/block/updateBlock'; 208 | return request(url, payload); 209 | } 210 | 211 | 212 | export async function deleteBlock(id: BlockId): Promise { 213 | let data = { 214 | id: id 215 | } 216 | let url = '/api/block/deleteBlock'; 217 | return request(url, data); 218 | } 219 | 220 | 221 | export async function moveBlock(id: BlockId, previousID?: PreviousID, parentID?: ParentID): Promise { 222 | let data = { 223 | id: id, 224 | previousID: previousID, 225 | parentID: parentID 226 | } 227 | let url = '/api/block/moveBlock'; 228 | return request(url, data); 229 | } 230 | 231 | 232 | export async function getBlockKramdown(id: BlockId): Promise { 233 | let data = { 234 | id: id 235 | } 236 | let url = '/api/block/getBlockKramdown'; 237 | return request(url, data); 238 | } 239 | 240 | 241 | export async function getChildBlocks(id: BlockId): Promise { 242 | let data = { 243 | id: id 244 | } 245 | let url = '/api/block/getChildBlocks'; 246 | return request(url, data); 247 | } 248 | 249 | export async function transferBlockRef(fromID: BlockId, toID: BlockId, refIDs: BlockId[]) { 250 | let data = { 251 | fromID: fromID, 252 | toID: toID, 253 | refIDs: refIDs 254 | } 255 | let url = '/api/block/transferBlockRef'; 256 | return request(url, data); 257 | } 258 | 259 | export async function getBlockIndex(id: BlockId): Promise { 260 | let data = { 261 | id: id 262 | } 263 | let url = '/api/block/getBlockIndex'; 264 | 265 | return request(url, data); 266 | } 267 | 268 | export async function getBlocksIndexes(ids: BlockId[]): Promise { 269 | let data = { 270 | ids: ids 271 | } 272 | let url = '/api/block/getBlocksIndexes'; 273 | 274 | return request(url, data); 275 | } 276 | 277 | export async function getBlockIsFolded(id: string): Promise { 278 | 279 | let response = await checkBlockFold(id); 280 | let result: boolean; 281 | if (isBoolean(response)) { 282 | result = response as boolean; 283 | } else { 284 | result = response.isFolded; 285 | } 286 | // console.log(`getBlockIsFolded response : ${JSON.stringify(response)}, result : ${result} `) 287 | return result; 288 | }; 289 | 290 | export async function checkBlockFold(id: string): Promise { 291 | if (!id) { 292 | // 参数校验失败,返回拒绝 293 | return Promise.reject(new Error('参数错误')); 294 | } 295 | let data = { 296 | id: id 297 | } 298 | let url = '/api/block/checkBlockFold'; 299 | 300 | return request(url, data); 301 | }; 302 | 303 | 304 | export async function getBatchBlockIdIndex(ids: string[]): Promise> { 305 | let idMap: Map = new Map(); 306 | let getSuccess = true; 307 | try { 308 | let idObject = await getBlocksIndexes(ids); 309 | // 遍历对象的键值对,并将它们添加到 Map 中 310 | for (const key in idObject) { 311 | if (Object.prototype.hasOwnProperty.call(idObject, key)) { 312 | const value = idObject[key]; 313 | idMap.set(key, value); 314 | } 315 | } 316 | } catch (err) { 317 | getSuccess = false; 318 | console.error("批量获取块索引报错,可能是旧版本不支持批量接口 : ", err) 319 | } 320 | 321 | if (!getSuccess) { 322 | for (const id of ids) { 323 | let index = 0 324 | try { 325 | index = await getBlockIndex(id); 326 | } catch (err) { 327 | console.error("获取块索引报错 : ", err) 328 | } 329 | idMap.set(id, index) 330 | } 331 | } 332 | 333 | return idMap; 334 | } 335 | 336 | // **************************************** Attributes **************************************** 337 | export async function setBlockAttrs(id: BlockId, attrs: { [key: string]: string }) { 338 | let data = { 339 | id: id, 340 | attrs: attrs 341 | } 342 | let url = '/api/attr/setBlockAttrs'; 343 | return request(url, data); 344 | } 345 | 346 | 347 | export async function getBlockAttrs(id: BlockId): Promise<{ [key: string]: string }> { 348 | let data = { 349 | id: id 350 | } 351 | let url = '/api/attr/getBlockAttrs'; 352 | return request(url, data); 353 | } 354 | 355 | // **************************************** SQL **************************************** 356 | 357 | export async function sql(sql: string): Promise { 358 | let sqldata = { 359 | stmt: sql, 360 | }; 361 | let url = '/api/query/sql'; 362 | return request(url, sqldata); 363 | } 364 | 365 | export async function getBlockByID(blockId: string): Promise { 366 | let sqlScript = `select * from blocks where id ='${blockId}'`; 367 | let data = await sql(sqlScript); 368 | return data[0]; 369 | } 370 | 371 | // **************************************** Template **************************************** 372 | 373 | export async function render(id: DocumentId, path: string): Promise { 374 | let data = { 375 | id: id, 376 | path: path 377 | } 378 | let url = '/api/template/render'; 379 | return request(url, data); 380 | } 381 | 382 | 383 | export async function renderSprig(template: string): Promise { 384 | let url = '/api/template/renderSprig'; 385 | return request(url, { template: template }); 386 | } 387 | 388 | // **************************************** File **************************************** 389 | 390 | export async function getFile(path: string): Promise { 391 | let data = { 392 | path: path 393 | } 394 | let url = '/api/file/getFile'; 395 | try { 396 | let file = await fetchSyncPost(url, data); 397 | return file; 398 | } catch (error_msg) { 399 | return null; 400 | } 401 | } 402 | 403 | export async function putFile(path: string, isDir: boolean, file: any) { 404 | let form = new FormData(); 405 | form.append('path', path); 406 | form.append('isDir', isDir.toString()); 407 | // Copyright (c) 2023, terwer. 408 | // https://github.com/terwer/siyuan-plugin-importer/blob/v1.4.1/src/api/kernel-api.ts 409 | form.append('modTime', Math.floor(Date.now() / 1000).toString()); 410 | form.append('file', file); 411 | let url = '/api/file/putFile'; 412 | return request(url, form); 413 | } 414 | 415 | export async function removeFile(path: string) { 416 | let data = { 417 | path: path 418 | } 419 | let url = '/api/file/removeFile'; 420 | return request(url, data); 421 | } 422 | 423 | 424 | 425 | export async function readDir(path: string): Promise { 426 | let data = { 427 | path: path 428 | } 429 | let url = '/api/file/readDir'; 430 | return request(url, data); 431 | } 432 | 433 | 434 | // **************************************** Export **************************************** 435 | 436 | export async function exportMdContent(id: DocumentId): Promise { 437 | let data = { 438 | id: id 439 | } 440 | let url = '/api/export/exportMdContent'; 441 | return request(url, data); 442 | } 443 | 444 | export async function exportResources(paths: string[], name: string): Promise { 445 | let data = { 446 | paths: paths, 447 | name: name 448 | } 449 | let url = '/api/export/exportResources'; 450 | return request(url, data); 451 | } 452 | 453 | // **************************************** Convert **************************************** 454 | 455 | export type PandocArgs = string; 456 | export async function pandoc(args: PandocArgs[]) { 457 | let data = { 458 | args: args 459 | } 460 | let url = '/api/convert/pandoc'; 461 | return request(url, data); 462 | } 463 | 464 | // **************************************** Notification **************************************** 465 | 466 | // /api/notification/pushMsg 467 | // { 468 | // "msg": "test", 469 | // "timeout": 7000 470 | // } 471 | export async function pushMsg(msg: string, timeout: number = 7000) { 472 | let payload = { 473 | msg: msg, 474 | timeout: timeout 475 | }; 476 | let url = "/api/notification/pushMsg"; 477 | return request(url, payload); 478 | } 479 | 480 | export async function pushErrMsg(msg: string, timeout: number = 7000) { 481 | let payload = { 482 | msg: msg, 483 | timeout: timeout 484 | }; 485 | let url = "/api/notification/pushErrMsg"; 486 | return request(url, payload); 487 | } 488 | 489 | // **************************************** Network **************************************** 490 | export async function forwardProxy( 491 | url: string, method: string = 'GET', payload: any = {}, 492 | headers: any[] = [], timeout: number = 7000, contentType: string = "text/html" 493 | ): Promise { 494 | let data = { 495 | url: url, 496 | method: method, 497 | timeout: timeout, 498 | contentType: contentType, 499 | headers: headers, 500 | payload: payload 501 | } 502 | let url1 = '/api/network/forwardProxy'; 503 | return request(url1, data); 504 | } 505 | 506 | 507 | // **************************************** System **************************************** 508 | 509 | export async function bootProgress(): Promise { 510 | return request('/api/system/bootProgress', {}); 511 | } 512 | 513 | 514 | export async function version(): Promise { 515 | return request('/api/system/version', {}); 516 | } 517 | 518 | 519 | export async function currentTime(): Promise { 520 | return request('/api/system/currentTime', {}); 521 | } 522 | 523 | 524 | 525 | export async function getBacklinkDoc(defID: string, refTreeID: string, keyword: string, containChildren: boolean): Promise<{ backlinks: IBacklinkData[] }> { 526 | let data = { 527 | defID: defID, 528 | refTreeID: refTreeID, 529 | keyword: keyword, 530 | containChildren: containChildren, 531 | } 532 | let url = '/api/ref/getBacklinkDoc'; 533 | 534 | return request(url, data); 535 | } 536 | 537 | /** 538 | * 539 | { 540 | "sort": "3", 541 | "mSort": "3", 542 | "k": "", 543 | "mk": "", 544 | "id": "20240808122601-yuhti2c" 545 | } 546 | * @param id 文档ID,聚焦就是聚焦后的ID 547 | * @param k 反链关键字 548 | * @param mk 提及关键字 549 | * @param sort 反链排序 550 | * @param msort 提及排序 551 | * @returns 552 | */ 553 | export async function getBacklink2(id: string, k: string, mk: string, sort: string, msort: string): Promise { 554 | let data = { 555 | id: id, 556 | k: k, 557 | mk: mk, 558 | sort: sort, 559 | msort: msort, 560 | } 561 | let url = '/api/ref/getBacklink2'; 562 | 563 | return request(url, data); 564 | } --------------------------------------------------------------------------------