├── src ├── vite-env.d.ts ├── logger.ts ├── i18n │ ├── ja.json │ ├── en.json │ └── configs.ts ├── db.ts ├── main.css ├── App.css ├── main.tsx └── App.tsx ├── icon.png ├── images ├── pagetag.png ├── screen-main.png ├── cardbox_small.png └── screen-launch.png ├── tsconfig.node.json ├── .gitignore ├── vite.config.ts ├── index.html ├── .eslintrc.cjs ├── tsconfig.json ├── package.json ├── .github └── workflows │ └── publish.yml ├── README.md └── LICENSE /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sosuisen/logseq-cardbox/HEAD/icon.png -------------------------------------------------------------------------------- /images/pagetag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sosuisen/logseq-cardbox/HEAD/images/pagetag.png -------------------------------------------------------------------------------- /images/screen-main.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sosuisen/logseq-cardbox/HEAD/images/screen-main.png -------------------------------------------------------------------------------- /images/cardbox_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sosuisen/logseq-cardbox/HEAD/images/cardbox_small.png -------------------------------------------------------------------------------- /images/screen-launch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sosuisen/logseq-cardbox/HEAD/images/screen-launch.png -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from "tslog"; 2 | 3 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 4 | // @ts-ignore 5 | const isDebug = __DEBUG__; 6 | console.log("DEBUG:", isDebug); 7 | // Default severities are: 0: silly, 1: trace, 2: debug, 3: info, 4: warn, 5: error, 6: fatal 8 | export const logger = new Logger({ 9 | minLevel: isDebug ? 0 : 3, 10 | }); 11 | -------------------------------------------------------------------------------- /src/i18n/ja.json: -------------------------------------------------------------------------------- 1 | { 2 | "rebuild": "再構築", 3 | "open-pages-btn": "pagesフォルダを選択", 4 | "open-pages-btn-label": "Logseqのグラフ保存先フォルダにあるpagesフォルダを選択してください。", 5 | "footer": "本文のないページは表示されません。クリックまたはカーソル移動+Enterキーで開きます。", 6 | "loading": "構築中...", 7 | "please-select-pages": "pagesという名前のフォルダを選択してください。", 8 | "filter-by-page-tag": "ページタグで絞り込み", 9 | "cancel": "キャンセル" 10 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Release files 27 | release -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react-swc' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig(({ mode }) => { 6 | const isDebug = mode === 'development'; 7 | return { 8 | define: { 9 | '__DEBUG__': isDebug, 10 | }, 11 | base: './', 12 | plugins: [ 13 | react(), 14 | ], 15 | }; 16 | }); 17 | 18 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | CardBox 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "rebuild": "Rebuild", 3 | "open-pages-btn": "Select pages folder", 4 | "open-pages-btn-label": "Please select the pages folder in the Logseq graph storage folder." , 5 | "footer": "Empty pages are not displayed. Click or move the cursor and press Enter to open the page.", 6 | "loading" : "Building...", 7 | "please-select-pages": "Select the folder named 'pages'", 8 | "filter-by-page-tag": "Filter by Page Tag", 9 | "cancel": "Cancel" 10 | } -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /src/i18n/configs.ts: -------------------------------------------------------------------------------- 1 | import i18n from "i18next"; 2 | import { initReactI18next } from "react-i18next"; 3 | 4 | // 言語jsonファイルのimport 5 | import translation_en from "./en.json"; 6 | import translation_ja from "./ja.json"; 7 | 8 | const resources = { 9 | ja: { 10 | translation: translation_ja 11 | }, 12 | en: { 13 | translation: translation_en 14 | } 15 | }; 16 | 17 | i18n 18 | .use(initReactI18next) 19 | .init({ 20 | resources, 21 | fallbackLng: "en", 22 | interpolation: { 23 | escapeValue: false, 24 | } 25 | }); 26 | 27 | export default i18n; -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | import Dexie, { Table } from 'dexie'; 2 | 3 | export interface Box { 4 | graph: string; // graph name in Logseq db 5 | name: string; // originalName in Logseq db 6 | uuid: string; // uuid in Logseq db 7 | time: number; // Unix time 8 | summary: string[]; 9 | image: string; 10 | } 11 | 12 | export class CardBoxDexie extends Dexie { 13 | box!: Table; 14 | 15 | constructor(dbName: string) { 16 | super(dbName); 17 | this.version(1).stores({ 18 | box: '[graph+name], graph, time' // [graph+name] is the compound primary key, and time is an indexed property. 19 | }); 20 | } 21 | } 22 | 23 | export const db = new CardBoxDexie('logseq-cardbox-plugin'); 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | "types": [ "@types/wicg-file-system-access"] 24 | 25 | }, 26 | "include": ["src"], 27 | "references": [{ "path": "./tsconfig.node.json" }] 28 | } 29 | -------------------------------------------------------------------------------- /src/main.css: -------------------------------------------------------------------------------- 1 | /* :root is pseudo class */ 2 | :root { 3 | font-family: 'Roboto', 'Noto Sans JP', sans-serif; 4 | line-height: 1.5; 5 | font-weight: 400; 6 | 7 | font-synthesis: none; 8 | text-rendering: optimizeLegibility; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | -webkit-text-size-adjust: 100%; 12 | user-select: none; 13 | } 14 | 15 | a { 16 | font-weight: 500; 17 | color: #646cff; 18 | text-decoration: inherit; 19 | } 20 | a:hover { 21 | color: #747bff; 22 | } 23 | 24 | body { 25 | margin: 0; 26 | place-items: center; 27 | min-width: 320px; 28 | min-height: 100vh; 29 | width: 100%; 30 | height: 100%; 31 | overflow: hidden; 32 | background-color: transparent; 33 | } 34 | 35 | h1 { 36 | font-size: 3.2em; 37 | line-height: 1.1; 38 | } 39 | 40 | button { 41 | border-radius: 4px; 42 | border: 1px solid transparent; 43 | padding: 0.3em 0.6em; 44 | font-size: 1em; 45 | font-weight: 500; 46 | font-family: inherit; 47 | background-color: #f9f9f9; 48 | cursor: pointer; 49 | transition: border-color 0.25s; 50 | } 51 | button:hover { 52 | border-color: #646cff; 53 | } 54 | button:focus, 55 | button:focus-visible { 56 | outline: 4px auto -webkit-focus-ring-color; 57 | } 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "logseq-cardbox", 3 | "description": "Plugin to add thumbnail cards to Logseq that are displayed in order of update.", 4 | "version": "0.2.0", 5 | "type": "module", 6 | "main": "dist/index.html", 7 | "license": "MPL-2.0", 8 | "repository": "sosuisen/logseq-cardbox", 9 | "scripts": { 10 | "dev": "vite", 11 | "build:dev": "tsc && vite build --mode development", 12 | "build": "tsc && vite build", 13 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 14 | "preview": "vite preview", 15 | "release": "npm run build && rm -r release && mkdir release && cp -r dist images release && cp icon.png LICENSE package.json README.md release" 16 | }, 17 | "dependencies": { 18 | "@emotion/react": "^11.11.3", 19 | "@emotion/styled": "^11.11.0", 20 | "@fontsource/noto-sans-jp": "^5.0.17", 21 | "@fontsource/roboto": "^5.0.8", 22 | "@logseq/libs": "^0.0.15", 23 | "@mui/icons-material": "^5.15.2", 24 | "@mui/material": "^5.15.2", 25 | "date-fns": "^2.30.0", 26 | "dexie": "^3.2.4", 27 | "dexie-react-hooks": "^1.1.7", 28 | "i18next": "^23.6.0", 29 | "react": "^18.2.0", 30 | "react-dom": "^18.2.0", 31 | "react-i18next": "^13.3.1", 32 | "react-remove-scroll": "^2.5.7", 33 | "tslog": "^4.9.2" 34 | }, 35 | "devDependencies": { 36 | "@types/node": "^20.9.0", 37 | "@types/react": "^18.2.15", 38 | "@types/react-dom": "^18.2.7", 39 | "@types/wicg-file-system-access": "^2023.10.3", 40 | "@typescript-eslint/eslint-plugin": "^6.0.0", 41 | "@typescript-eslint/parser": "^6.0.0", 42 | "@vitejs/plugin-react-swc": "^3.3.2", 43 | "eslint": "^8.45.0", 44 | "eslint-plugin-react-hooks": "^4.6.0", 45 | "eslint-plugin-react-refresh": "^0.4.3", 46 | "typescript": "^5.0.2", 47 | "vite": "^4.4.5", 48 | "vite-plugin-logseq": "^1.1.2" 49 | }, 50 | "logseq": { 51 | "id": "logseq-cardbox_3sulqr0v8", 52 | "title": "Logseq CardBox", 53 | "icon": "./icon.png" 54 | }, 55 | "author": "Hidekazu Kubota" 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build plugin 2 | 3 | on: 4 | push: 5 | # Sequence of patterns matched against refs/tags 6 | tags: 7 | - "*" # Push events to matching any tag format, i.e. 1.0, 20.15.10 8 | 9 | env: 10 | PLUGIN_NAME: logseq-cardbox 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Use Node.js 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: "18.x" # You might need to adjust this value to your own version 22 | - name: Build 23 | id: build 24 | run: | 25 | npm install 26 | npm run build 27 | mkdir ${{ env.PLUGIN_NAME }} 28 | cp icon.png LICENSE package.json README.md ${{ env.PLUGIN_NAME }} 29 | mv dist images ${{ env.PLUGIN_NAME }} 30 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 31 | ls 32 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 33 | 34 | - name: Create Release 35 | uses: ncipollo/release-action@v1 36 | id: create_release 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | VERSION: ${{ github.ref }} 40 | with: 41 | allowUpdates: true 42 | draft: false 43 | prerelease: false 44 | 45 | - name: Upload zip file 46 | id: upload_zip 47 | uses: actions/upload-release-asset@v1 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | with: 51 | upload_url: ${{ steps.create_release.outputs.upload_url }} 52 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 53 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 54 | asset_content_type: application/zip 55 | 56 | - name: Upload package.json 57 | id: upload_metadata 58 | uses: actions/upload-release-asset@v1 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | with: 62 | upload_url: ${{ steps.create_release.outputs.upload_url }} 63 | asset_path: ./package.json 64 | asset_name: package.json 65 | asset_content_type: application/json -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #app { 2 | margin: 0 auto; 3 | text-align: center; 4 | width: 96%; 5 | height: 92%; 6 | margin-top: 2%; 7 | margin-left: 2%; 8 | position: absolute; 9 | background-color: #dcdde0; 10 | box-shadow: 5px 5px 0 0 #c0c0c0; 11 | border-radius: 3px; 12 | display: grid; 13 | grid-template-rows: 60px auto 20px; 14 | } 15 | 16 | .control { 17 | margin-top: 7px; 18 | margin-bottom: 5px; 19 | display: grid; 20 | grid-template-columns: 1fr 60px; 21 | } 22 | 23 | .loading { 24 | color: rgba(144, 0, 0, 0.7); 25 | text-align: left; 26 | margin-left: 24px; 27 | margin-top: 7px; 28 | float: left; 29 | font-size: 24px; 30 | } 31 | 32 | .card-number { 33 | color: rgba(0, 0, 0, 0.7); 34 | text-align: left; 35 | margin-left: 24px; 36 | margin-top: 7px; 37 | float: left; 38 | font-size: 24px; 39 | } 40 | 41 | #tile { 42 | padding-left: 24px; 43 | padding-right: 24px; 44 | display: grid; 45 | grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); 46 | overflow-y: auto; 47 | } 48 | 49 | .box { 50 | cursor: pointer; 51 | border-radius: 0px 0px 3px 3px; 52 | margin: 10px auto 10px 10px; 53 | width: 140px; 54 | height: 140px; 55 | box-shadow: 2px 3px 0 0px #d0d0d0; 56 | background-color: #ffffff; 57 | border-top: 5px solid #90c0ff; 58 | overflow-y: hidden; 59 | box-sizing: border-box; 60 | display: flex; 61 | flex-direction: column; 62 | } 63 | 64 | .selectedBox { 65 | background-color: #f0f0ff; 66 | } 67 | 68 | .box:hover { 69 | background-color: #e0e0e0; 70 | } 71 | 72 | .box-title { 73 | width: 100%; 74 | font-weight: 700; 75 | font-size: 14px; 76 | overflow: hidden; 77 | border-bottom: 1px solid #90c0ff; 78 | flex-shrink: 0; 79 | } 80 | 81 | .box-summary { 82 | font-size: 12px; 83 | color: #606060; 84 | overflow: hidden; 85 | text-align: left; 86 | padding: 7px; 87 | white-space: pre-wrap; 88 | flex-grow: 1; 89 | } 90 | 91 | .box-image { 92 | overflow: hidden; 93 | padding-top: 7px; 94 | display: none; 95 | flex-grow: 1; 96 | } 97 | 98 | .box-date { 99 | font-size: 10px; 100 | } 101 | 102 | .footer { 103 | color: rgba(0, 0, 0, 0.7); 104 | font-size: 14px; 105 | width: 100%; 106 | background-color: #dcdde0; 107 | } 108 | 109 | .tag-label { 110 | float: left; 111 | margin-top: 7px; 112 | margin-left: 24px; 113 | } 114 | 115 | .tag-input { 116 | float: left; 117 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Logseq CardBox Plugin [](https://www.buymeacoffee.com/hidekaz) 2 | 3 | English / [日本語](https://scrapbox.io/logseq-ja/Logseq_Cardbox_Plugin%2F%E4%BD%BF%E3%81%84%E6%96%B9) 4 | 5 | Plugin to add thumbnail cards to [Logseq](https://github.com/logseq/logseq) that are displayed in order of update. 6 | 7 | ![main](./images/screen-main.png) 8 | 9 | ## How to launch 10 | Any of the following: 11 | - "CardBox" button on the left sidebar 12 | - Click the CardBox icon in the top right corner of the page 13 | - Enter "Open CardBox" in the command palette 14 | - Shortcut: 15 | - (Windows) Ctrl + Shift + Enter 16 | - (macOS) Cmd + Shift + Enter 17 | 18 | ![launch](./images/screen-launch.png) 19 | 20 | - Immediately after installation, CardBox will automatically build the database. During this time, the message "Building..." will appear in the upper left corner of the CardBox. Do not exit Logseq until this message disappears. 21 | 22 | ## Selecting a card 23 | - The thumbnail cards of the pages are ordered by the date of the last update, starting from the top left. 24 | - Only pages are displayed. The journal and whiteboard are not displayed. 25 | - Click on a page or use the cursor keys to move the selection and press Enter to open it. 26 | - If you hold down Shift while performing the open operation, the page will open in the sidebar. 27 | 28 | ## Filtering using page tags 29 | - Type a character key to filter cards by a page tag 30 | - What is a page tag? See below: 31 | 32 | ![page tag](./images/pagetag.png) 33 | 34 | ## Closing the CardBox 35 | - Close CardBox by pressing the X button in the top right-hand corner or pressing the Esc key. 36 | 37 | ## Key bindings 38 | - Any character keys: Input a page tag 39 | - Up, Down, Left, Right: Move the cursor to select a card 40 | - Enter: Open the selected card 41 | - Shift+Enter: Open the selected card in the sidebar 42 | - Esc: Close the CardBox 43 | 44 | ## Languages supported 45 | - English 46 | - Japanese 47 | 48 | You need to restart Logseq for the language change to take effect. 49 | 50 | ## Limitations 51 | - CardBox will not display pages without body text. 52 | - Logseq does not create a .md file for a page with only a title without body text. This plug-in reads the modification time of the .md file directly, so it cannot display pages with no file. 53 | - Since Logseq is currently in beta, it cannot correctly manage the modification time of pages. If the modification time is incorrect, please press the "Rebuild" button (it will get the modification time directly from the specified pages folder). 54 | - See https://github.com/logseq/logseq/issues/8556 55 | - Changes made directly to the .md file while Logseq is not running will not be reflected in the CardBox. 56 | - To reflect them, press the 'Rebuild' button. 57 | - After executing the "Re-index" in Logseq, press the "Rebuild" button in the CardBox. 58 | - If you do not rebuild, pages may not be displayed in the correct time order. 59 | - If you do not rebuild, you cannot open the page in the sidebar from CardBox. 60 | - Pages with a slash at the end of the title (e.g. MyPage/ ) will not display correctly. 61 | 62 | # Support 63 | 64 | If you like it, please donate to me to continue the development. 65 | 66 | [![donate](https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png)](https://www.buymeacoffee.com/hidekaz) 67 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import "@fontsource/roboto" 5 | import "@fontsource/roboto/700.css" 6 | import '@fontsource/noto-sans-jp' 7 | import '@fontsource/noto-sans-jp/700.css' 8 | import './main.css' 9 | import '@logseq/libs' 10 | import './i18n/configs'; 11 | import { SimpleCommandKeybinding } from '@logseq/libs/dist/LSPlugin' 12 | 13 | const openCardBox = () => { 14 | logseq.showMainUI(); 15 | } 16 | 17 | function main() { 18 | // Ctrl+Shift+Enter or Command+Shift+Enter 19 | /* 20 | logseq.App.registerCommandShortcut( 21 | { binding: 'mod+shift+enter' }, 22 | () => logseq.showMainUI(), 23 | ); 24 | */ 25 | // It might be more in line with the Logseq way to register it in the command palette. 26 | // In this case, it's also possible to assign a name to the shortcut." 27 | const command: { 28 | key: string; 29 | keybinding: SimpleCommandKeybinding 30 | label: string; 31 | } = { 32 | key: 'cardbox:open', 33 | keybinding: { 34 | binding: 'mod+shift+enter', 35 | mode: 'global', 36 | }, 37 | label: 'Open CardBox', 38 | }; 39 | logseq.App.registerCommandPalette(command, openCardBox); 40 | 41 | logseq.provideStyle(` 42 | @import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0"); 43 | `) 44 | 45 | logseq.setMainUIInlineStyle({ 46 | position: 'fixed', 47 | zIndex: 20, 48 | }) 49 | 50 | logseq.App.registerUIItem('pagebar', { 51 | key: 'cardbox', 52 | template: ` 53 | 54 | 55 | grid_view 56 | 57 | 58 | `, 59 | }); 60 | 61 | const cardboxDiv = document.createElement('div'); 62 | cardboxDiv.innerHTML = ` 63 | 64 | 65 | 66 | 67 | CardBox 68 | 69 | `; 70 | cardboxDiv.className = `cardbox-nav`; 71 | cardboxDiv.addEventListener('click', openCardBox); 72 | 73 | const navHeader = window.parent.document.querySelector('.nav-header'); 74 | const cardboxNav = navHeader!.querySelector(`.cardbox-nav`); 75 | if (cardboxNav) { 76 | navHeader!.removeChild(cardboxNav); 77 | } 78 | navHeader!.insertBefore(cardboxDiv, navHeader!.lastChild); 79 | 80 | document.body.addEventListener('click', (e) => { 81 | if ((e.target as HTMLElement).classList.length === 0) { 82 | // stopPropagation on is ignored because click event on body is fired first. 83 | // So, check if the click event is fired on or not. 84 | logseq.hideMainUI({ restoreEditingCursor: true }); 85 | } 86 | }); 87 | 88 | document.getElementById('app')!.addEventListener('click', e => { 89 | e.stopPropagation(); 90 | }); 91 | 92 | ReactDOM.createRoot(document.getElementById('app')!).render( 93 | 94 | 95 | , 96 | ); 97 | } 98 | 99 | // bootstrap 100 | logseq.ready({ 101 | openCardBox 102 | }, main).catch(console.error) 103 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at https://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { logger } from './logger'; // logger.tsからロガーをインポート 2 | import { useCallback, useEffect, useRef, useState } from 'react' 3 | import { format } from 'date-fns'; 4 | import { BlockEntity, BlockUUIDTuple, IDatom } from '@logseq/libs/dist/LSPlugin.user'; 5 | import { useTranslation } from "react-i18next"; 6 | import i18n from "i18next"; 7 | import { db, Box } from './db'; 8 | import './App.css' 9 | import { useLiveQuery } from 'dexie-react-hooks'; 10 | import { Button, IconButton, InputAdornment, TextField, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; 11 | import { Clear } from '@mui/icons-material'; 12 | 13 | type Operation = 'create' | 'modified' | 'delete' | ''; 14 | 15 | type MarkdownOrOrg = 'markdown' | 'org'; 16 | 17 | type ParentBlocks = 18 | { 19 | blocks: (BlockEntity | BlockUUIDTuple)[]; 20 | index: number; 21 | }; 22 | 23 | type SearchResultPage = string[]; 24 | 25 | type PrimaryKey = [string, string]; 26 | 27 | type FileChanges = { 28 | blocks: BlockEntity[]; 29 | txData: IDatom[]; 30 | txMeta?: { 31 | outlinerOp: string; 32 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 33 | [key: string]: any; 34 | }; 35 | } 36 | 37 | function sleep(ms: number): Promise { 38 | return new Promise(resolve => setTimeout(resolve, ms)); 39 | } 40 | 41 | const encodeLogseqFileName = (name: string) => { 42 | // Encode characters that are not allowed in file name. 43 | if (!name) return ''; 44 | return name 45 | .replace(/\/$/, '') // Remove trailing slash 46 | .replace(/^(CON|PRN|AUX|NUL|COM1|COM2|COM3|COM4|COM5|COM6|COM7|COM8|COM9|LPT1|LPT2|LPT3|LPT4|LPT5|LPT6|LPT7|LPT8|LPT9)$/, '$1___') 47 | .replace(/\.$/, '.___') 48 | .replace(/_\/_/g, '%5F___%5F') 49 | .replace(//g, '%3E') 51 | .replace(/:/g, '%3A') 52 | .replace(/"/g, '%22') 53 | .replace(/\//g, '___') 54 | .replace(/\\/g, '%5C') 55 | .replace(/\|/g, '%7C') 56 | .replace(/\?/g, '%3F') 57 | .replace(/\*/g, '%2A') 58 | .replace(/#/g, '%23') 59 | .replace(/^\./, '%2E'); 60 | }; 61 | 62 | const decodeLogseqFileName = (name: string) => { 63 | if (!name) return ''; 64 | 65 | // Cannot restore trailing slash because it is not saved in local file. 66 | return name 67 | .replace(/^(CON|PRN|AUX|NUL|COM1|COM2|COM3|COM4|COM5|COM6|COM7|COM8|COM9|LPT1|LPT2|LPT3|LPT4|LPT5|LPT6|LPT7|LPT8|LPT9)___$/, '$1') 68 | .replace(/\.___$/, '.') 69 | .replace(/%5F___%5F/g, '_/_') 70 | .replace(/%3C/g, '<') 71 | .replace(/%3E/g, '>') 72 | .replace(/%3A/g, ':') 73 | .replace(/%22/g, '"') 74 | .replace(/___/g, '/') 75 | .replace(/%5C/g, '\\') 76 | .replace(/%7C/g, '|') 77 | .replace(/%3F/g, '?') 78 | .replace(/%2A/g, '*') 79 | .replace(/%23/g, '#') 80 | .replace(/%2E/g, '.'); 81 | }; 82 | 83 | const getLastUpdatedTime = async (fileName: string, handle: FileSystemDirectoryHandle, preferredFormat: MarkdownOrOrg): Promise => { 84 | // Cannot get from subdirectory. 85 | // const path = `pages/${fileName}.md`; 86 | let path = fileName + (preferredFormat === 'markdown' ? '.md' : '.org'); 87 | 88 | let fileHandle = await handle.getFileHandle(path).catch(() => { 89 | // Logseq does not save an empty page as a local file. 90 | logger.debug(`Failed to get file handle: ${path}`); 91 | return null; 92 | }); 93 | if (!fileHandle) { 94 | path = fileName + (preferredFormat === 'markdown' ? '.org' : '.md'); 95 | logger.debug(`Retry: ${path}`); 96 | fileHandle = await handle.getFileHandle(path).catch(() => { 97 | // Logseq does not save an empty page as a local file. 98 | logger.debug(`Failed to get file handle: ${path}`); 99 | return null; 100 | }); 101 | } 102 | 103 | if (!fileHandle) return 0; 104 | 105 | const file = await fileHandle.getFile(); 106 | const date = new Date(file.lastModified); 107 | 108 | return date.getTime(); 109 | }; 110 | 111 | const getSummary = (blocks: BlockEntity[]): [string[], string] => { 112 | const max = 100; 113 | let total = 0; 114 | const summary = []; 115 | let image = ''; 116 | const parentStack: ParentBlocks[] = []; 117 | 118 | if (blocks && blocks.length > 0) { 119 | parentStack.push({ 120 | blocks, 121 | index: 0, 122 | }); 123 | 124 | while (total < max) { 125 | let currentParent: ParentBlocks = parentStack[parentStack.length - 1]; 126 | while (currentParent.index >= currentParent.blocks.length) { 127 | parentStack.pop(); 128 | if (parentStack.length === 0) break; 129 | currentParent = parentStack[parentStack.length - 1]; 130 | } 131 | if (parentStack.length === 0) break; 132 | 133 | const block = currentParent.blocks[currentParent.index++]; 134 | 135 | if (Object.prototype.hasOwnProperty.call(block, 'id')) { 136 | let content = (block as BlockEntity).content.substring(0, max); 137 | // skip property 138 | if (!content.match(/^\w+?:: ./) && !content.match(/^---\n/)) { 139 | if (parentStack.length > 1) { 140 | content = ' '.repeat(parentStack.length - 1) + '* ' + content; 141 | } 142 | total += content.length; 143 | summary.push(content); 144 | } 145 | if ((block as BlockEntity).children && (block as BlockEntity).children!.length > 0) { 146 | parentStack.push({ 147 | blocks: (block as BlockEntity).children!, 148 | index: 0, 149 | }); 150 | } 151 | } 152 | } 153 | 154 | // Search embedded image 155 | parentStack.splice(0, parentStack.length); 156 | parentStack.push({ 157 | blocks, 158 | index: 0, 159 | }); 160 | 161 | while (parentStack.length > 0) { 162 | let currentParent: ParentBlocks = parentStack[parentStack.length - 1]; 163 | while (currentParent.index >= currentParent.blocks.length) { 164 | parentStack.pop(); 165 | if (parentStack.length === 0) break; 166 | currentParent = parentStack[parentStack.length - 1]; 167 | } 168 | if (parentStack.length === 0) break; 169 | 170 | const block = currentParent.blocks[currentParent.index++]; 171 | 172 | if (Object.prototype.hasOwnProperty.call(block, 'id')) { 173 | // Markdown ![xxx](../assets/xxx.png) 174 | // Org mode [[../assets/xxx.png]] 175 | const ma = (block as BlockEntity).content.match(/[[(]..\/assets\/(.+\.(png|jpg|jpeg))[\])]/i); 176 | if (ma) { 177 | image = ma[1]; 178 | // logger.debug("asset: " + ma[1]); 179 | break; 180 | } 181 | // summary.push(content); 182 | 183 | if ((block as BlockEntity).children && (block as BlockEntity).children!.length > 0) { 184 | parentStack.push({ 185 | blocks: (block as BlockEntity).children!, 186 | index: 0, 187 | }); 188 | } 189 | } 190 | } 191 | } 192 | return [summary, image]; 193 | }; 194 | 195 | const parseOperation = (changes: FileChanges): [Operation, string] => { 196 | let operation: Operation = ''; 197 | let originalName = ''; 198 | // logger.debug(changes); 199 | for (const block of changes.blocks) { 200 | if (Object.prototype.hasOwnProperty.call(block, 'path')) { 201 | if (changes.txData.length === 0) continue; 202 | if (changes.txData[0][1] === 'file/last-modified-at') { 203 | const path = block.path; 204 | const ma = path.match(/pages\/(.*)\.(md|org)/); 205 | if (ma) { 206 | const fileName = ma[1]; 207 | 208 | originalName = decodeLogseqFileName(fileName); 209 | 210 | operation = 'modified'; 211 | 212 | // logger.debug("File modified: " + originalName); 213 | 214 | return [operation, originalName]; 215 | } 216 | } 217 | } 218 | } 219 | 220 | for (const data of changes.txData) { 221 | if (data.length === 5 && data[1] === 'block/original-name') { 222 | originalName = data[2]; 223 | let createOrDelete: Operation = 'create'; 224 | if (data[4] === false) { 225 | createOrDelete = 'delete'; 226 | } 227 | else { 228 | logger.debug(`created, ${originalName}`); 229 | } 230 | operation = createOrDelete; 231 | 232 | return [operation, originalName]; 233 | } 234 | } 235 | 236 | return [operation, originalName]; 237 | }; 238 | 239 | const dirHandles: { [graphName: string]: FileSystemDirectoryHandle } = {}; 240 | 241 | const tileGridHeight = 160; // height of a grid row 242 | 243 | function App() { 244 | const [currentDirHandle, setCurrentDirHandle] = useState(); 245 | const [currentGraph, setCurrentGraph] = useState(''); 246 | const [preferredDateFormat, setPreferredDateFormat] = useState(''); 247 | const [preferredFormat, setPreferredFormat] = useState('markdown'); 248 | const [loading, setLoading] = useState(true); 249 | const [selectedBox, setSelectedBox] = useState(0); 250 | const [open, setOpen] = useState(false); 251 | const [filteredPages, setFilteredPages] = useState([]); 252 | const [tag, setTag] = useState(''); 253 | const tileRef = useRef(null); 254 | const tagInputFieldRef = useRef(null); 255 | // const appRef = useRef(null); 256 | const [tileColumnSize, setTileColumnSize] = useState(0); 257 | const [tileRowSize, setTileRowSize] = useState(0); 258 | const [maxBoxNumber, setMaxBoxNumber] = useState(0); 259 | const [totalCardNumber, setTotalCardNumber] = useState(0); 260 | 261 | const { t } = useTranslation(); 262 | 263 | const cardboxes = useLiveQuery( 264 | () => { 265 | if (filteredPages.length === 0) { 266 | return db.box 267 | .orderBy('time') 268 | .filter(box => box.graph === currentGraph) 269 | .reverse() 270 | .limit(maxBoxNumber) 271 | .toArray() 272 | } 273 | else { 274 | return db.box 275 | .where(':id') 276 | .anyOf(filteredPages) 277 | .reverse() 278 | .sortBy('time') 279 | } 280 | } 281 | , [currentGraph, filteredPages, maxBoxNumber]); 282 | 283 | 284 | useEffect(() => { 285 | const handleScroll = () => { 286 | // logger.debug('Scrolled to: ' + Math.floor(tileRef.current!.scrollTop / pagenationScrollHeight)); 287 | 288 | const loadScreensAhead = 3; 289 | const loadRowsAhead = loadScreensAhead * tileRowSize; 290 | const loadRowsByScroll = (Math.floor(Math.floor(tileRef.current!.scrollTop / tileGridHeight) / loadRowsAhead) + 1) * loadRowsAhead; 291 | const limit = tileColumnSize * (tileRowSize + loadRowsByScroll); 292 | 293 | setMaxBoxNumber(current => current < limit ? limit : current); 294 | }; 295 | 296 | const tileElement = tileRef.current; 297 | if (tileElement) { 298 | tileElement.addEventListener('scroll', handleScroll); 299 | } 300 | 301 | // コンポーネントのアンマウント時にイベントリスナーを削除 302 | return () => { 303 | if (tileElement) { 304 | tileElement.removeEventListener('scroll', handleScroll); 305 | } 306 | }; 307 | }, [tileRowSize, tileColumnSize]); 308 | 309 | useEffect(() => { 310 | const handleKeyDown = (e: { key: string; }) => { 311 | switch (e.key) { 312 | case "Escape": 313 | logseq.hideMainUI({ restoreEditingCursor: true }); 314 | break; 315 | default: 316 | return; 317 | } 318 | }; 319 | window.addEventListener("keydown", handleKeyDown); 320 | 321 | return () => { 322 | window.removeEventListener("keydown", handleKeyDown); 323 | }; 324 | }, [filteredPages]); 325 | 326 | useEffect(() => { 327 | tileRef.current!.style.gridAutoRows = `${tileGridHeight}px`; 328 | 329 | const handleResize = () => { 330 | const gridStyles = window.getComputedStyle(tileRef.current!); 331 | const columnSize = gridStyles.gridTemplateColumns.split(' ').length; 332 | setTileColumnSize(columnSize); 333 | 334 | // const rowSize = gridStyles.gridTemplateRows.split(' ').length; // This gets all rows in tile grid 335 | const rowsInAScreen = Math.ceil(tileRef.current!.offsetHeight / tileGridHeight); 336 | 337 | setTileRowSize(rowsInAScreen); 338 | 339 | // logger.debug(columnSize, rowsInAScreen); 340 | 341 | const scrollRow = Math.floor(tileRef.current!.scrollTop / tileGridHeight) + 1; 342 | const limit = columnSize * (rowsInAScreen + scrollRow); 343 | 344 | setMaxBoxNumber(current => current < limit ? limit : current); 345 | }; 346 | handleResize(); // call once after render tile 347 | window.addEventListener('resize', handleResize); 348 | 349 | return () => { 350 | window.removeEventListener('resize', handleResize); 351 | }; 352 | }, []); 353 | 354 | 355 | 356 | useEffect(() => { 357 | // pageEntries should be reloaded when totalCardNumber is changed. 358 | const filter = async (tag: string) => { 359 | 360 | setSelectedBox(0); 361 | 362 | if (tag === '') { 363 | setFilteredPages([]); 364 | return; 365 | } 366 | 367 | const pageEntries: SearchResultPage[] = await logseq.DB.datascriptQuery(` 368 | [:find ?name 369 | :where 370 | [?t :block/name ?namePattern] 371 | [(clojure.string/starts-with? ?namePattern "${tag}")] 372 | [?p :block/tags ?t] 373 | [?p :block/original-name ?name]] 374 | `); 375 | if (pageEntries.length === 0) { 376 | setFilteredPages([["", ""]]); 377 | return; 378 | } 379 | setFilteredPages(pageEntries.map(entry => [currentGraph, entry[0]])); 380 | }; 381 | filter(tag.toLowerCase()); 382 | }, [tag, currentGraph, totalCardNumber]); 383 | 384 | useEffect(() => { 385 | const getUserConfigs = async () => { 386 | const { currentGraph, preferredDateFormat, preferredLanguage, preferredFormat } = await logseq.App.getUserConfigs(); 387 | setCurrentGraph(currentGraph); 388 | setPreferredDateFormat(preferredDateFormat); 389 | setPreferredFormat(preferredFormat); 390 | i18n.changeLanguage(preferredLanguage); 391 | }; 392 | getUserConfigs(); 393 | 394 | return logseq.App.onCurrentGraphChanged(async () => { 395 | const { currentGraph } = await logseq.App.getUserConfigs(); 396 | 397 | setCurrentDirHandle(dirHandles[currentGraph]); // undefined or FileSystemDirectoryHandle 398 | 399 | setCurrentGraph(currentGraph); 400 | }); 401 | }, []); 402 | 403 | const rebuildDB = useCallback(() => { 404 | if (!currentGraph) return; 405 | 406 | db.box.where('graph').equals(currentGraph).count().then(async count => { 407 | if (count > 0) { 408 | setLoading(false); 409 | setTotalCardNumber(count); 410 | } 411 | else { 412 | setLoading(true); 413 | 414 | // This currentGraph is not the same as the one in state. 415 | const { currentGraph } = await logseq.App.getUserConfigs(); 416 | 417 | const pages = await logseq.Editor.getAllPages(); 418 | if (!pages) return []; 419 | 420 | const promises = []; 421 | while (pages.length > 0) { 422 | const page = pages.shift(); 423 | if (page) { 424 | if (page['journal?']) continue; 425 | 426 | const promise = (async () => { 427 | let updatedTime: number | undefined = 0; 428 | if (currentDirHandle) { 429 | updatedTime = await getLastUpdatedTime(encodeLogseqFileName(page.originalName), currentDirHandle!, preferredFormat); 430 | } 431 | else { 432 | // Skip Contents because page.updatedAt of Contents is always wrong. 433 | if (page.originalName === 'Contents') return; 434 | updatedTime = page.updatedAt; 435 | } 436 | if (!updatedTime) return; 437 | // Load summary asynchronously 438 | const blocks = await logseq.Editor.getPageBlocksTree(page.uuid).catch(err => { 439 | console.error(`Failed to get blocks: ${page.originalName}`); 440 | console.error(err); 441 | return null; 442 | }); 443 | // Quick check for empty page 444 | if (!blocks || blocks.length === 0) { 445 | return; 446 | } 447 | const [summary, image] = getSummary(blocks); 448 | // Logseq has many meta pages that has no content. Skip them. 449 | // Detailed check for empty page 450 | if (summary.length > 0 && !(summary.length === 1 && summary[0] === '')) { 451 | await db.box.put({ 452 | graph: currentGraph, 453 | name: page.originalName, 454 | uuid: page.uuid, 455 | time: updatedTime, 456 | summary, 457 | image, 458 | }); 459 | } 460 | })(); 461 | promises.push(promise); 462 | } 463 | const loadingCardNumber = promises.length; 464 | if (pages.length === 0 || loadingCardNumber >= 100) { 465 | await Promise.all(promises).catch(err => { 466 | console.error(err); 467 | }); 468 | promises.splice(0, loadingCardNumber); 469 | setTotalCardNumber(await db.box.where('graph').equals(currentGraph).count()); 470 | // LiveQuery needs some time to update. 471 | await sleep(500); 472 | } 473 | 474 | } 475 | 476 | setLoading(false); 477 | } 478 | }); 479 | }, [currentDirHandle, currentGraph, preferredFormat]); 480 | 481 | useEffect(() => rebuildDB(), [rebuildDB]); 482 | 483 | useEffect(() => { 484 | const onFileChanged = async (changes: FileChanges) => { 485 | const [operation, originalName] = parseOperation(changes); 486 | 487 | // Ignore create event because the file is not created yet. 488 | if (operation == 'modified' || operation == 'delete') { 489 | const updatedTime = new Date().getTime(); 490 | logger.debug(`${operation}, ${originalName}, ${updatedTime}`); 491 | 492 | // A trailing slash in the title cannot be recovered from the file name. 493 | // This is because they are removed during encoding. 494 | if (operation === 'modified') { 495 | const blocks = await logseq.Editor.getPageBlocksTree(originalName).catch(err => { 496 | console.error(`Failed to get blocks: ${originalName}`); 497 | console.error(err); 498 | return null; 499 | }); 500 | if (!blocks) return; 501 | 502 | const [summary, image] = getSummary(blocks); 503 | 504 | if (summary.length > 0 && !(summary.length === 1 && summary[0] === '')) { 505 | const box = await db.box.get([currentGraph, originalName]); 506 | if (box) { 507 | db.box.update([currentGraph, originalName], { 508 | time: updatedTime, 509 | summary, 510 | image, 511 | }); 512 | } 513 | else { 514 | // create 515 | const page = await logseq.Editor.getPage(originalName); 516 | if (page) { 517 | db.box.put({ 518 | graph: currentGraph, 519 | name: originalName, 520 | uuid: page.uuid, 521 | time: updatedTime, 522 | summary, 523 | image, 524 | }).then(() => { 525 | setTotalCardNumber(num => num + 1); 526 | }); 527 | } 528 | } 529 | } 530 | else { 531 | // Remove empty page 532 | logger.debug(`Empty page: ${originalName}`); 533 | db.box.delete([currentGraph, originalName]).then(() => { 534 | setTotalCardNumber(num => num > 0 ? num - 1 : 0); 535 | }); 536 | } 537 | } 538 | else if (operation === 'delete') { 539 | db.box.delete([currentGraph, originalName]).then(() => { 540 | setTotalCardNumber(num => num > 0 ? num - 1 : 0); 541 | }); 542 | } 543 | else { 544 | logger.debug('Unknown operation: ' + operation); 545 | } 546 | 547 | } 548 | }; 549 | 550 | // onChanged returns a function to unsubscribe. 551 | // Use 'return unsubscribe_function' to call unsubscribe_function 552 | // when component is unmounted, otherwise a lot of listeners will be left. 553 | const removeOnChanged = logseq.DB.onChanged(onFileChanged); 554 | return () => { 555 | removeOnChanged(); 556 | } 557 | }, [currentGraph]); 558 | 559 | useEffect(() => { 560 | const handleKeyDown = (e: { key: string; shiftKey: boolean; }) => { 561 | if (loading) return; 562 | const tile = document.getElementById('tile'); 563 | if (!tile?.hasChildNodes()) { 564 | return; 565 | } 566 | const tileWidth = tile!.clientWidth - 24 * 2; // padding is 24px. clientWidth does not include scrollbar width. 567 | const tileHeight = tile!.offsetHeight; 568 | const tileTop = tile!.offsetTop; 569 | // margin-right is auto 570 | // margin-left must not be auto to avoid the layout becoming too dense 571 | const boxMarginRight = parseInt(window.getComputedStyle((tile!.children[0] as HTMLElement)).getPropertyValue('margin-right')); 572 | const boxWidth = (tile!.children[0] as HTMLElement).offsetWidth + 10 + boxMarginRight; // margin-left is 10px 573 | const boxHeight = (tile!.children[0] as HTMLElement).offsetHeight + 10 * 2; // margin is 10px 574 | 575 | const cols = Math.floor(tileWidth / boxWidth); 576 | const rows = Math.floor(tileHeight / boxHeight); 577 | if (e.key === 'ArrowUp') { 578 | tileRef.current?.focus(); // To un-focus tag input field. 579 | setSelectedBox(selectedBox => { 580 | const newIndex = selectedBox - cols; 581 | if (newIndex < 0) { 582 | return selectedBox; 583 | } 584 | 585 | const boxTop = (tile!.children[selectedBox] as HTMLElement).offsetTop - tileTop - 10 - tile.scrollTop; // margin is 10px; 586 | if (Math.floor(boxTop / boxHeight) <= 1) { 587 | tile.scrollBy(0, -boxHeight); 588 | } 589 | return newIndex; 590 | }); 591 | } 592 | else if (e.key === 'ArrowDown') { 593 | tileRef.current?.focus(); // To un-focus tag input field. 594 | setSelectedBox(selectedBox => { 595 | const newIndex = selectedBox + cols; 596 | if (newIndex >= tile!.childElementCount) { 597 | return selectedBox; 598 | } 599 | const boxTop = (tile!.children[selectedBox] as HTMLElement).offsetTop - tileTop - 10 - tile.scrollTop; // margin is 10px; 600 | if (Math.floor(boxTop / boxHeight) >= rows - 1) { 601 | tile.scrollBy(0, boxHeight); 602 | } 603 | 604 | return newIndex; 605 | }); 606 | } 607 | else if (e.key === 'ArrowRight') { 608 | tileRef.current?.focus(); // To un-focus tag input field. 609 | setSelectedBox(selectedBox => { 610 | const newIndex = selectedBox + 1; 611 | if (newIndex >= tile!.childElementCount) { 612 | return selectedBox; 613 | } 614 | if (Math.floor(selectedBox / cols) !== Math.floor(newIndex / cols)) { 615 | const boxTop = (tile!.children[selectedBox] as HTMLElement).offsetTop - tileTop - 10 - tile.scrollTop; // margin is 10px; 616 | if (Math.floor(boxTop / boxHeight) >= rows - 1) { 617 | tile.scrollBy(0, boxHeight); 618 | } 619 | } 620 | return newIndex; 621 | }); 622 | } 623 | else if (e.key === 'ArrowLeft') { 624 | tileRef.current?.focus(); // To un-focus tag input field. 625 | setSelectedBox(selectedBox => { 626 | const newIndex = selectedBox - 1; 627 | if (newIndex < 0) { 628 | return selectedBox; 629 | } 630 | if (Math.floor(selectedBox / cols) !== Math.floor(newIndex / cols)) { 631 | const boxTop = (tile!.children[selectedBox] as HTMLElement).offsetTop - tileTop - 10 - tile.scrollTop; // margin is 10px; 632 | if (Math.floor(boxTop / boxHeight) <= 1) { 633 | tile.scrollBy(0, -boxHeight); 634 | } 635 | } 636 | return newIndex; 637 | }); 638 | } 639 | else if (e.key === 'Enter') { 640 | const box = (document.getElementsByClassName('selectedBox')[0] as HTMLElement); 641 | if (e.shiftKey) { 642 | logseq.Editor.openInRightSidebar(box.id); 643 | } 644 | else { 645 | logseq.App.pushState('page', { 646 | name: box.getElementsByClassName('box-title')[0].innerHTML, 647 | }); 648 | } 649 | logseq.hideMainUI({ restoreEditingCursor: true }); 650 | } 651 | else { 652 | switch (e.key) { 653 | case "Shift": 654 | case "Control": 655 | case "Alt": 656 | case "Meta": 657 | case "Tab": 658 | return; 659 | } 660 | tagInputFieldRef.current?.focus(); 661 | } 662 | 663 | }; 664 | window.addEventListener('keydown', handleKeyDown); 665 | 666 | return () => { 667 | window.removeEventListener('keydown', handleKeyDown); 668 | }; 669 | }, [loading]); 670 | 671 | const openDirectoryPicker = useCallback(async () => { 672 | const handle = await window.showDirectoryPicker(); 673 | // Cannot get full path of the selected directory because of security reason. 674 | // Check only the directory name 675 | if (handle.name === 'pages') { 676 | dirHandles[currentGraph] = handle; 677 | await db.box.where('graph').equals(currentGraph).delete(); 678 | setCurrentDirHandle(handle); 679 | setOpen(false); 680 | // rebuildDB() is called when currentDirHandle is changed. 681 | } 682 | else { 683 | logseq.UI.showMsg(t('please-select-pages')); 684 | } 685 | }, [currentGraph, t]); 686 | 687 | const boxOnClick = async (box: Box, e: React.MouseEvent) => { 688 | if (e.nativeEvent.shiftKey) { 689 | logseq.Editor.openInRightSidebar(box.uuid); 690 | } 691 | else { 692 | logseq.App.pushState('page', { 693 | name: box.name, 694 | }); 695 | } 696 | logseq.hideMainUI({ restoreEditingCursor: true }); 697 | }; 698 | 699 | const getTimeString = (unixTime: number) => { 700 | const date = new Date(unixTime); 701 | return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}`; 702 | }; 703 | 704 | const boxElements = cardboxes?.map((box: Box, index) => ( 705 | // Do not use uuid because pagebar is not shown properly. 706 | // 707 | // Calling deep link is very slow. Use pushState() instead. 708 | // 709 | 710 |
boxOnClick(box, e)} id={box.uuid}> 711 |
712 | {box.name} 713 |
714 |
715 | {box.summary.map(item => (<>{item}
))} 716 |
717 |
718 | (image) 719 |
720 |
721 | {format(box.time, preferredDateFormat)} {getTimeString(box.time)} 722 |
723 | 724 |
725 | )); 726 | 727 | return ( 728 | <> 729 |
730 |
731 | 740 | setOpen(false)}> 741 | {t("rebuild")} 742 | 743 | {t("open-pages-btn-label")} ({currentGraph.replace('logseq_local_', '')}/pages) 744 | 745 | 746 | 747 | 748 | 749 | 750 |
751 | {t("loading")} 752 |
753 |
754 | {filteredPages.length === 0 ? totalCardNumber : cardboxes?.length} cards 755 |
756 | setTag(e.target.value)} 759 | inputRef={tagInputFieldRef} 760 | InputProps={{ 761 | endAdornment: ( 762 | 763 | setTag('')} 765 | edge="end" 766 | > 767 | 768 | 769 | 770 | ), 771 | inputProps: { 772 | tabIndex: 1, 773 | }, 774 | }} 775 | /> 776 |
777 |
778 | logseq.hideMainUI({ restoreEditingCursor: true })} 779 | style={{ 780 | cursor: "pointer", 781 | float: "right", 782 | marginTop: "10px", 783 | marginRight: "24px", 784 | }} 785 | /> 786 |
787 |
788 |
789 | {boxElements} 790 |
791 |
792 | {t("footer")} 793 |
794 | 795 | ) 796 | } 797 | 798 | export default App 799 | --------------------------------------------------------------------------------