├── .eslintrc.json ├── .github └── workflows │ ├── main.yml │ └── publish.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── assets └── logo.svg ├── index.html ├── logo.svg ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── readme.md ├── release.config.js ├── renovate.json ├── src ├── App.tsx ├── BarApp.tsx ├── EditorApp.tsx ├── IncrementalBlock.ts ├── MainApp.tsx ├── ModalApp.tsx ├── PopoverApp.tsx ├── algorithm │ ├── beta.ts │ ├── priority.ts │ └── scheduling.ts ├── anki │ ├── anki.ts │ └── ankiSlice.ts ├── db.ts ├── docx │ ├── DocxWindow.tsx │ ├── docx.ts │ ├── docxSlice.ts │ └── selection.ts ├── globals.ts ├── hooks │ ├── useCalculateHeight.tsx │ └── useMainSizeAndPos.tsx ├── ib │ ├── actions.ts │ ├── create.ts │ └── read.ts ├── import │ ├── DioUpload.tsx │ ├── ExtractPanel.tsx │ ├── HTMLUpload.tsx │ ├── Import.tsx │ ├── MedxImport.tsx │ ├── YtUpload.tsx │ ├── importSlice.ts │ └── types.ts ├── index.css ├── learn │ ├── ActionsPopover.tsx │ ├── LearnBar.tsx │ ├── LearnWindow.tsx │ ├── PriorityComponent.tsx │ ├── PriorityPopover.tsx │ ├── QueuePopover.tsx │ ├── ScheduleComponent.tsx │ ├── SchedulePopover.tsx │ ├── SettingsComponent.tsx │ └── learnSlice.ts ├── logseq │ ├── block-ui.ts │ ├── blockrender.ts │ ├── command.ts │ ├── events.ts │ ├── macro.ts │ ├── nav.ts │ ├── query.ts │ ├── settings.ts │ └── theme.ts ├── main.tsx ├── main │ ├── DueDateView.tsx │ ├── IbsView.tsx │ ├── IntervalView.tsx │ ├── MainWindow.tsx │ ├── Postpone.tsx │ ├── RefsView.tsx │ └── mainSlice.ts ├── medx │ ├── ExtractionView.tsx │ ├── MedxWindow.tsx │ ├── PlayerView.tsx │ ├── RangeSelector.tsx │ ├── command.ts │ ├── macro.tsx │ ├── media.tsx │ └── medxSlice.ts ├── medx_old │ ├── ExtractionView.tsx │ ├── MedxWindow.tsx │ ├── PlayerView.tsx │ ├── RangeSelector.tsx │ ├── command.ts │ ├── jump │ │ ├── ExtractsView.tsx │ │ ├── JumpView.tsx │ │ └── SubsView.tsx │ ├── macro.tsx │ ├── media.tsx │ └── medxSlice.ts ├── modules.d.ts ├── state │ ├── appSlice.ts │ ├── hooks.ts │ ├── listenerMiddleware.ts │ ├── store.ts │ └── viewSlice.ts ├── types.ts ├── utils │ ├── datetime.ts │ ├── logseq.ts │ ├── theme.ts │ └── utils.ts └── widgets │ ├── BetaGraph.tsx │ ├── Buttons.tsx │ ├── IbItem.tsx │ ├── Popover.tsx │ ├── PrioritySlider.tsx │ ├── RefButton.tsx │ ├── ScheduleView.tsx │ └── Select.tsx ├── tailwind.config.js ├── tests └── ib.test.js ├── tsconfig.json ├── vite.config.ts └── vitest.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:react/recommended", 5 | "plugin:@typescript-eslint/eslint-recommended", 6 | "plugin:@typescript-eslint/recommended" 7 | ], 8 | "plugins": ["@typescript-eslint", "react-hooks"], 9 | "parser": "@typescript-eslint/parser", 10 | "rules": { 11 | "react-hooks/rules-of-hooks": "error", 12 | "react-hooks/exhaustive-deps": "warn", 13 | "import/prefer-default-export": "off", 14 | "@typescript-eslint/ban-ts-comment": "off", 15 | "@typescript-eslint/no-non-null-assertion": "off", 16 | "@typescript-eslint/explicit-module-boundary-types": "off" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Releases 4 | 5 | env: 6 | PLUGIN_NAME: logseq-plugin-template-react 7 | 8 | # Controls when the action will run. 9 | on: 10 | # push: 11 | # branches: 12 | # - "master" 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | release: 19 | # The type of runner that the job will run on 20 | runs-on: ubuntu-latest 21 | 22 | # Steps represent a sequence of tasks that will be executed as part of the job 23 | steps: 24 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 25 | - uses: actions/checkout@v3 26 | - uses: actions/setup-node@v3 27 | with: 28 | node-version: "16" 29 | - uses: pnpm/action-setup@v2.4.0 30 | with: 31 | version: 6.0.2 32 | - run: pnpm install 33 | - run: pnpm build 34 | - name: Install zip 35 | uses: montudor/action-zip@v1 36 | - name: Release 37 | run: npx semantic-release 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # From: benjypng / logseq-zoterolocal-plugin 2 | name: Build Logseq Plugin 3 | 4 | on: 5 | push: 6 | branches: 7 | - "main" 8 | paths-ignore: 9 | - 'README.md' 10 | workflow_dispatch: 11 | 12 | env: 13 | PLUGIN_NAME: ${{ github.event.repository.name }} 14 | 15 | jobs: 16 | build: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v3 21 | 22 | - name: Use Node.js 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: "20.x" # You might need to adjust this value to your own version 26 | 27 | - uses: pnpm/action-setup@v4 28 | with: 29 | version: 9.4.0 30 | 31 | - name: Build 32 | id: build 33 | run: | 34 | tsc && vite build 35 | mkdir ${{ env.PLUGIN_NAME }} 36 | cp README.md package.json icon.svg ${{ env.PLUGIN_NAME }} 37 | mv dist ${{ env.PLUGIN_NAME }} 38 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 39 | ls 40 | echo "tag_name=git tag --sort version:refname | tail -n 1" >> $GITHUB_OUTPUT 41 | 42 | - name: Release 43 | run: npx semantic-release 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | .vscode 7 | incremental_blocks.zip 8 | 9 | *~ 10 | \#*\# 11 | .#* 12 | 13 | .aider* 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.1.1](https://github.com/pengx17/logseq-plugin-template-react/compare/v2.1.0...v2.1.1) (2022-03-24) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * revert bot pr ([59527a7](https://github.com/pengx17/logseq-plugin-template-react/commit/59527a7044bec0ddd17a79de54844730e8a591a4)) 7 | 8 | # [2.1.0](https://github.com/pengx17/logseq-plugin-template-react/compare/v2.0.1...v2.1.0) (2022-03-24) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * remove unused line ([0d69a50](https://github.com/pengx17/logseq-plugin-template-react/commit/0d69a504e4847b4859377ada65766b887920ae38)) 14 | * update logseq-dev-plugin ([36a69f7](https://github.com/pengx17/logseq-plugin-template-react/commit/36a69f7f13789cd86156273dbf8c01fad793b3e1)) 15 | 16 | 17 | ### Features 18 | 19 | * use vite-plugin-logseq ([54aa154](https://github.com/pengx17/logseq-plugin-template-react/commit/54aa154615eafa9af8727d0fc1f3031c5e610aa7)) 20 | 21 | ## [2.0.1](https://github.com/pengx17/logseq-plugin-template-react/compare/v2.0.0...v2.0.1) (2022-03-21) 22 | 23 | 24 | ### Bug Fixes 25 | 26 | * add missing base for production build ([738ac09](https://github.com/pengx17/logseq-plugin-template-react/commit/738ac09dab9785ccc3564117bc4026cfb4464e9a)) 27 | 28 | # [2.0.0](https://github.com/pengx17/logseq-plugin-template-react/compare/v1.0.0...v2.0.0) (2022-03-17) 29 | 30 | # 1.0.0 (2021-09-03) 31 | 32 | 33 | ### Bug Fixes 34 | 35 | * build ([fd35d6c](https://github.com/pengx17/logseq-plugin-template-react/commit/fd35d6c098e030920da26a65c734940a27b604df)) 36 | * deps ([7ad5f35](https://github.com/pengx17/logseq-plugin-template-react/commit/7ad5f351a645029823c3ab4cc04db2476948943a)) 37 | * useAppVisible hook ([0f3ad46](https://github.com/pengx17/logseq-plugin-template-react/commit/0f3ad46e2fe8f9326e796fb50f8f32d5c66d9bf8)) 38 | 39 | 40 | ### Features 41 | 42 | * enable HMR ([7ff7100](https://github.com/pengx17/logseq-plugin-template-react/commit/7ff7100552180c6d14f3df37a449b704da29270d)) 43 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Logseq Plugin 8 | 9 | 10 |
11 |
12 |
13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "logseq-incremental-blocks", 3 | "version": "0.0.1", 4 | "main": "dist/index.html", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "tsc && vite build", 8 | "preinstall": "npx only-allow pnpm", 9 | "test": "vitest", 10 | "tsnode": "ts-node -O \"{\\\"module\\\":\\\"commonjs\\\", \\\"isolatedModules\\\":false}\"" 11 | }, 12 | "license": "AGPL-3", 13 | "dependencies": { 14 | "@kitaitimakoto/media-fragment": "^0.0.1", 15 | "@logseq/libs": "^0.0.17", 16 | "@mozilla/readability": "^0.5.0", 17 | "@nivo/core": "^0.88.0", 18 | "@nivo/line": "^0.88.0", 19 | "@reduxjs/toolkit": "^2.2.7", 20 | "jstat": "^1.9.6", 21 | "react": "^18.2.0", 22 | "react-datepicker": "^7.3.0", 23 | "react-dom": "^18.2.0", 24 | "react-player": "^2.16.0", 25 | "react-range": "file:../react-range", 26 | "react-redux": "^9.1.2", 27 | "react-virtuoso": "^4.7.13", 28 | "redux": "^5.0.1", 29 | "remove-markdown": "^0.5.0", 30 | "seedrandom": "^3.0.5", 31 | "string-comparison": "^1.3.0", 32 | "youtube-caption-extractor": "^1.4.3" 33 | }, 34 | "devDependencies": { 35 | "@eslint/js": "^9.10.0", 36 | "@semantic-release/changelog": "6.0.3", 37 | "@semantic-release/exec": "6.0.3", 38 | "@semantic-release/git": "10.0.1", 39 | "@semantic-release/npm": "10.0.6", 40 | "@types/node": "18.19.31", 41 | "@types/react": "18.3.1", 42 | "@types/react-dom": "18.3.0", 43 | "@types/seedrandom": "3.0.8", 44 | "@typescript-eslint/eslint-plugin": "5.62.0", 45 | "@typescript-eslint/parser": "5.62.0", 46 | "@vitejs/plugin-react": "3.1.0", 47 | "autoprefixer": "10.4.19", 48 | "conventional-changelog-conventionalcommits": "5.0.0", 49 | "eslint": "8.57.0", 50 | "eslint-plugin-react": "7.34.1", 51 | "eslint-plugin-react-hooks": "4.6.2", 52 | "globals": "^15.9.0", 53 | "postcss": "8.4.38", 54 | "semantic-release": "21.1.2", 55 | "tailwindcss": "3.4.3", 56 | "typescript": "4.9.5", 57 | "typescript-eslint": "^8.6.0", 58 | "typescript-eslint-language-service": "^5.0.5", 59 | "vite": "4.5.3", 60 | "vite-plugin-logseq": "1.1.2", 61 | "vite-plugin-svgr": "^4.2.0", 62 | "vitest": "^2.0.5" 63 | }, 64 | "logseq": { 65 | "id": "logseq-incremental-blocks", 66 | "icon": "./logo.svg" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | plugins: { 4 | tailwindcss: {}, 5 | autoprefixer: {}, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branches: ["master"], 3 | plugins: [ 4 | [ 5 | "@semantic-release/commit-analyzer", 6 | { 7 | preset: "conventionalcommits", 8 | }, 9 | ], 10 | "@semantic-release/release-notes-generator", 11 | "@semantic-release/changelog", 12 | [ 13 | "@semantic-release/npm", 14 | { 15 | npmPublish: false, 16 | }, 17 | ], 18 | "@semantic-release/git", 19 | [ 20 | "@semantic-release/exec", 21 | { 22 | prepareCmd: 23 | "zip -qq -r logseq-plugin-template-react-${nextRelease.version}.zip dist readme.md logo.svg LICENSE package.json", 24 | }, 25 | ], 26 | [ 27 | "@semantic-release/github", 28 | { 29 | assets: "logseq-plugin-template-react-*.zip", 30 | }, 31 | ], 32 | ], 33 | }; 34 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "packageRules": [ 4 | { 5 | "matchUpdateTypes": ["minor", "patch"], 6 | "automerge": true, 7 | "requiredStatusChecks": null 8 | }, 9 | { 10 | "matchPackageNames": ["@logseq/libs"], 11 | "ignoreUnstable": false, 12 | "automerge": false, 13 | "requiredStatusChecks": null 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import * as ReactDOM from "react-dom/client"; 3 | import { useAppDispatch, useAppSelector } from "./state/hooks"; 4 | import { EditorView, MainView, setEditorView, toggleMainView } from "./state/viewSlice"; 5 | import { selectFragmentBlock, selectMedia } from "./medx/medxSlice"; 6 | import { finishRep } from "./learn/learnSlice"; 7 | import { renderMediaEmbed } from "./medx/macro"; 8 | import { refreshCollections } from "./main/mainSlice"; 9 | import { updateThemeStyle } from "./logseq/theme"; 10 | import { isDark } from "./utils/logseq"; 11 | import { handleSettingsChanged, themeModeChanged } from "./state/appSlice"; 12 | import { Provider, useStore } from "react-redux"; 13 | import EditorApp from "./EditorApp"; 14 | import { addListener } from "@reduxjs/toolkit"; 15 | import { parseFragment } from "./medx/media"; 16 | import { openDocFromUUID } from "./docx/docxSlice"; 17 | 18 | export default function App() { 19 | const view = useAppSelector(state => state.view); 20 | const learning = useAppSelector(state => state.learn.learning); 21 | const currentIbData = useAppSelector(state => state.learn.current); 22 | const dispatch = useAppDispatch(); 23 | const store = useStore(); 24 | 25 | const state = useAppSelector(state => state); 26 | console.log(state); 27 | 28 | useEffect(() => { 29 | logseq.provideModel({ 30 | toggleMain() { 31 | if (view.main?.view != MainView.main) { 32 | dispatch(toggleMainView({ view: MainView.main })); 33 | } 34 | }, 35 | editBlock(e: any) { 36 | logseq.Editor.editBlock(e.dataset.blockUuid); 37 | }, 38 | async openDoc(e: any) { 39 | const uuid = e.dataset.blockUuid; 40 | await dispatch(openDocFromUUID(uuid)); 41 | dispatch(setEditorView({ view: EditorView.doc })); 42 | }, 43 | async nextRep() { 44 | if (!learning) return; 45 | await dispatch(finishRep()); 46 | const openIb = logseq.settings?.learnAutoOpen as boolean ?? true; 47 | if (currentIbData && openIb) { 48 | logseq.App.pushState('page', { name: currentIbData.ib.uuid }); 49 | } 50 | }, 51 | async selectFragment(e: any) { 52 | const block = await logseq.Editor.getBlock(e.dataset.blockUuid); 53 | if (!block) { 54 | logseq.UI.showMsg('Block not found', 'error'); 55 | return; 56 | } 57 | const isSource = block.page.id == block.parent.id; 58 | if (isSource) { 59 | dispatch(selectMedia(block.page.id)); 60 | } else { 61 | dispatch(selectFragmentBlock(e.dataset)); 62 | } 63 | if (view.editor == null || view.editor.view != EditorView.medx) { 64 | dispatch(setEditorView({ view: EditorView.medx })); 65 | } 66 | }, 67 | playRange(e: any) { 68 | const { slotId, mediaUrl, macroArgs } = e.dataset; 69 | const playerDiv = top?.document.getElementById(`medx-player-${mediaUrl}`); 70 | if (!playerDiv) { 71 | logseq.UI.showMsg('Media not found in page'); 72 | return; 73 | } 74 | const args = parseFragment(macroArgs.split(',')); 75 | if (!args) { 76 | logseq.UI.showMsg('Invalid media args'); 77 | return; 78 | } 79 | renderMediaEmbed({ playerDiv, args, play: true }); 80 | }, 81 | async refToMedia(e: any) { 82 | const { blockUuid } = e.dataset; 83 | const block = await logseq.Editor.getBlock(blockUuid); 84 | if (!block) return; 85 | const newContent = block.content.replace('{{renderer :medx_ref', '{{renderer :medx'); 86 | await logseq.Editor.updateBlock(blockUuid, newContent); 87 | } 88 | }); 89 | 90 | logseq.App.onCurrentGraphChanged((e) => dispatch(refreshCollections())); 91 | dispatch(refreshCollections()); 92 | 93 | updateThemeStyle(); 94 | isDark().then((dark) => dispatch(themeModeChanged(dark ? 'dark' : 'light'))); 95 | logseq.App.onThemeModeChanged(({ mode }) => { 96 | dispatch(themeModeChanged(mode)); 97 | updateThemeStyle(); 98 | }); 99 | 100 | logseq.onSettingsChanged((a, b) => 101 | dispatch(handleSettingsChanged({ old: b, new: a }))); 102 | 103 | const unsub = dispatch(addListener({ 104 | actionCreator: setEditorView, 105 | effect: (action, listenerApi) => { 106 | console.log(action.payload); 107 | if (action.payload) { 108 | openEditorView(); 109 | } 110 | } 111 | })); 112 | return unsub; 113 | }, []); 114 | 115 | function openEditorView() { 116 | const el = parent!.document.getElementById('app-single-container'); 117 | if (!el) { 118 | logseq.UI.showMsg('Cannot find element', 'error'); 119 | return; 120 | } 121 | 122 | parent!.document.body.classList.add('is-pdf-active') 123 | const root = ReactDOM.createRoot(el); 124 | function unmount() { 125 | root.unmount(); 126 | parent!.document.body.classList.remove('is-pdf-active'); 127 | } 128 | root.render( 129 | 130 | 131 | 132 | 133 | 134 | ); 135 | } 136 | 137 | return (<>); 138 | } 139 | -------------------------------------------------------------------------------- /src/BarApp.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { useAppVisible } from "./logseq/events"; 3 | import { useAppDispatch, useAppSelector } from "./state/hooks"; 4 | import { PARENT_MAIN_CONTAINER_ID } from "./globals"; 5 | import LearnBar from "./learn/LearnBar"; 6 | 7 | export default function BarApp() { 8 | const learning = useAppSelector(state => state.learn.learning); 9 | const [sizeAndPos, setSizeAndPos] = useState({ width: innerWidth*.5, height: 50, left: 0, top: innerHeight-50 }); 10 | 11 | useEffect(() => { 12 | const mainContainer = parent.document.getElementById(PARENT_MAIN_CONTAINER_ID); 13 | 14 | const updateSizeAndPosition = () => { 15 | if (mainContainer) { 16 | const rect = mainContainer.getBoundingClientRect(); 17 | setSizeAndPos({ 18 | // Don't know why the width overestimates by ~15px 19 | width: rect.width-15, 20 | height: sizeAndPos.height, 21 | left: rect.x, 22 | top: 300,// innerHeight - sizeAndPos.height, 23 | }); 24 | } 25 | }; 26 | 27 | const resizeObserver = new ResizeObserver(updateSizeAndPosition); 28 | if (mainContainer) resizeObserver.observe(mainContainer); 29 | updateSizeAndPosition(); 30 | 31 | return () => { 32 | resizeObserver.disconnect(); 33 | }; 34 | }, []) 35 | 36 | if (!learning) return <>; 37 | 38 | return ( 39 |
49 | 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /src/EditorApp.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from "react"; 2 | import { useAppSelector } from "./state/hooks"; 3 | import { EditorView } from "./state/viewSlice"; 4 | import MedxWindow from "./medx/MedxWindow"; 5 | import DocxWindow from "./docx/DocxWindow"; 6 | 7 | export default function EditorApp({ unmount }: { unmount: Function }) { 8 | const ref = useRef(null); 9 | const view = useAppSelector(state => state.view.editor); 10 | 11 | if (view == null) return <>; 12 | 13 | let content = Is that leather?; 14 | if (view.view == EditorView.medx) { 15 | content = ; 16 | } else if (view.view == EditorView.doc) { 17 | content = ; 18 | } 19 | 20 | return ( 21 |
22 |
23 |
24 |
25 | 26 | {content} 27 |
28 |
29 |
30 | 33 | 34 |
35 | ); 36 | } 37 | 38 | function Resizer({ container }: { container: React.RefObject }) { 39 | const docEl = parent!.document.documentElement; 40 | const ref = useRef(null); 41 | 42 | const adjustMainSize = debounce(200, (width: number) => { 43 | docEl.style.setProperty('--ph-view-container-width', width + 'vw'); 44 | }); 45 | 46 | function onDrag(e: DragEvent) { 47 | if (!container.current) return; 48 | const width = docEl.clientWidth; 49 | const offset = e.clientX; 50 | // Last drag event always 0 for some reason 51 | if (offset == 0) return; 52 | const elRatio = offset / width; 53 | const adjustedWidth = Math.min(Math.max(elRatio * 100, 20), 80); 54 | container.current.style.width = adjustedWidth + 'vw'; 55 | adjustMainSize(adjustedWidth); 56 | } 57 | 58 | function onDragStart() { 59 | parent!.document.documentElement.classList.add("is-resizing-buf"); 60 | } 61 | 62 | function onDragEnd() { 63 | parent!.document.documentElement.classList.remove("is-resizing-buf"); 64 | } 65 | 66 | return ( 67 | 75 | ); 76 | } 77 | 78 | function debounce(delay: number, callback: Function) : Function { 79 | let timeout: NodeJS.Timeout; 80 | return (...args: any) => { 81 | clearTimeout(timeout); 82 | timeout = setTimeout(() => { 83 | callback(...args); 84 | }, delay); 85 | }; 86 | } 87 | -------------------------------------------------------------------------------- /src/IncrementalBlock.ts: -------------------------------------------------------------------------------- 1 | import { BlockEntity, PageEntity } from "@logseq/libs/dist/LSPlugin.user"; 2 | import Beta from "./algorithm/beta"; 3 | import { toDashCase } from "./utils/utils"; 4 | import { todayMidnight, dateDiffInDays, toEndOfDay } from "./utils/datetime"; 5 | import { RENDERER_MACRO_NAME as MACRO_NAME } from "./globals"; 6 | import { removeIbPropsFromContent, removePropsFromContent } from "./utils/logseq"; 7 | 8 | class IncrementalBlock { 9 | readonly uuid: string; 10 | readonly properties: Record; 11 | readonly block: BlockEntity | null; 12 | readonly beta: Beta | null; 13 | readonly dueDate: Date | null; 14 | readonly sample: number | null; 15 | readonly multiplier: number; 16 | readonly interval: number | null; 17 | readonly reps: number | null; 18 | 19 | constructor(uuid: string, props: Record, block?: BlockEntity) { 20 | this.uuid = uuid; 21 | this.properties = props; 22 | this.block = block ?? null; 23 | this.beta = Beta.fromProps(props); 24 | 25 | const due = new Date(parseFloat(props['ibDue'])); 26 | if (due instanceof Date && !isNaN(due.getTime())) { 27 | // Set the time to midnight. 28 | // Should already be the case, but sometimes users are 29 | // naughty naughty so we need to fix things. 30 | due.setHours(0, 0, 0, 0); 31 | this.dueDate = due; 32 | } else { 33 | this.dueDate = null; 34 | } 35 | 36 | const sample = parseFloat(props['ibSample']); 37 | if (Beta.isValidSample(sample)) { 38 | this.sample = sample; 39 | } else if (this.beta) { 40 | // Should be the sample of today 41 | // this.sample = this.sampleAt(this.dueDate ?? todayMidnight()); 42 | this.sample = this.sampleAt(todayMidnight()); 43 | } else { 44 | this.sample = null; 45 | } 46 | 47 | const multiplier = parseFloat(props['ibMultiplier']); 48 | if (typeof multiplier === 'number' && multiplier >= 1) { 49 | this.multiplier = multiplier; 50 | } else { 51 | this.multiplier = logseq.settings?.defaultMultiplier as number ?? 2.; 52 | } 53 | 54 | const reps = parseFloat(props['ibReps']); 55 | if (typeof reps === 'number' && reps >= 0 && Number.isInteger(reps)) { 56 | this.reps = reps; 57 | } else { 58 | this.reps = null; 59 | } 60 | 61 | const interval = parseFloat(props['ibInterval']); 62 | if (typeof interval === 'number' && interval >= 0) { 63 | this.interval = interval; 64 | } else { 65 | this.interval = null; 66 | } 67 | } 68 | 69 | public sampleAt(date: Date) : number | null { 70 | if (!this.beta) return null; 71 | // Add uuid prefix as different ibs can have same beta. 72 | return this.beta.sample({ prefix: this.uuid, seedDate: date }); 73 | } 74 | 75 | static async fromUuid(uuid: string, opts : { propsOnly?: boolean } = {}) { 76 | if (opts.propsOnly ?? true) { 77 | const props = await logseq.Editor.getBlockProperties(uuid); 78 | return new this(uuid, props); 79 | } else { 80 | const block = await logseq.Editor.getBlock(uuid); 81 | return this.fromBlock(block!); 82 | } 83 | } 84 | 85 | static fromBlock(block: BlockEntity) { 86 | return new this(block.uuid, block.properties ?? {}, block); 87 | } 88 | 89 | static fromPage(page: PageEntity) { 90 | return new this(page.uuid, page.properties ?? {}); 91 | } 92 | 93 | public copy() : IncrementalBlock { 94 | if (this.block) return IncrementalBlock.fromBlock(this.block); 95 | return new IncrementalBlock(this.uuid, this.properties); 96 | } 97 | 98 | public dueDays(): number | null { 99 | if (!this.dueDate) return null; 100 | const today = todayMidnight(); 101 | const diff = dateDiffInDays(today, this.dueDate); 102 | return diff; 103 | } 104 | 105 | public dueToday() : boolean { 106 | const dueDays = this.dueDays(); 107 | if (dueDays == null) return false; 108 | return dueDays <= 0; 109 | } 110 | 111 | public async done() { 112 | if (!this.block) return; 113 | // Remove properties by content and using removeBlockProperty, since former only 114 | // works when props are visible and latter when props are hidden. 115 | const content = removeIbPropsFromContent(this.block.content).replace(MACRO_NAME, ''); 116 | for (let prop of Object.keys(this.properties)) { 117 | if (prop.startsWith('ib')) { 118 | logseq.Editor.removeBlockProperty(this.uuid, toDashCase(prop)); 119 | } 120 | } 121 | await logseq.Editor.updateBlock(this.uuid, content); 122 | } 123 | 124 | public get priorityOnly() : boolean { 125 | return this.dueDate == null || this.interval == null; 126 | } 127 | } 128 | 129 | export default IncrementalBlock; 130 | -------------------------------------------------------------------------------- /src/MainApp.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useAppSelector } from "./state/hooks"; 3 | import { MainView } from "./state/viewSlice"; 4 | import MainWindow from "./main/MainWindow"; 5 | import useMainSizeAndPosition from "./hooks/useMainSizeAndPos"; 6 | 7 | export default function MainApp() { 8 | const view = useAppSelector(state => state.view); 9 | const sizeAndPos = useMainSizeAndPosition(); 10 | 11 | if (view.main == null) return <>; 12 | 13 | let viewComponent: JSX.Element = <>; 14 | switch (view.main?.view) { 15 | case MainView.main: 16 | viewComponent = ; 17 | break; 18 | } 19 | 20 | return ( 21 |
33 | {viewComponent} 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/ModalApp.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { useAppDispatch, useAppSelector } from "./state/hooks"; 3 | import { ModalView, setModalView } from "./state/viewSlice"; 4 | import Postpone from "./main/Postpone"; 5 | import Import from "./import/Import"; 6 | 7 | export default function ModalApp() { 8 | const dispatch = useAppDispatch(); 9 | const view = useAppSelector(state => state.view.modal); 10 | 11 | function tryHide(e: any) { 12 | if (parent!.document.getElementById('ib-modal-content')?.contains(e.target)) { 13 | return; 14 | } 15 | dispatch(setModalView(null)); 16 | } 17 | 18 | if (view == null) return <>; 19 | 20 | let content = Is that leather?; 21 | if (view.view == ModalView.ibActions) { 22 | content = 23 | } else if (view.view == ModalView.import) { 24 | content = 25 | } 26 | 27 | return ( 28 | <> 29 |
32 |
33 |
34 |
35 |
40 |
45 | {content} 46 |
47 |
48 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/PopoverApp.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import IbPopover from "./widgets/Popover"; 3 | import { useAppVisible } from "./logseq/events"; 4 | import { useAppDispatch, useAppSelector } from "./state/hooks"; 5 | import { IbViewData, PopoverView, setPopoverView, togglePopoverView } from "./state/viewSlice"; 6 | import sanitize from "sanitize-filename"; 7 | 8 | export default function PopoverApp() { 9 | const visible = useAppVisible(); 10 | const dispatch = useAppDispatch(); 11 | const view = useAppSelector(state => state.view); 12 | const themeMode = useAppSelector(state => state.app.themeMode); 13 | 14 | useEffect(() => { 15 | logseq.provideModel({ 16 | sanitize(s: string) { 17 | logseq.UI.showMsg(sanitize(s)); 18 | }, 19 | toggleIbPopover(e: any) { 20 | dispatch(togglePopoverView({ 21 | view: PopoverView.ib, 22 | blockUuid: e.dataset.blockUuid 23 | })); 24 | }, 25 | }); 26 | }, []); 27 | 28 | function tryHide(e: any) { 29 | if (document.getElementById('ib-learn')?.contains(e.target) || 30 | document.getElementById('ib-popover')?.contains(e.target) || 31 | document.getElementById('ib-insert')?.contains(e.target)) { 32 | return; 33 | } 34 | dispatch(setPopoverView(null)); 35 | if (view.main == null) window.logseq.hideMainUI(); 36 | } 37 | 38 | if (!visible || view.popover == null) return null; 39 | 40 | let viewComponent: JSX.Element = <>; 41 | let classesIfCentered = ''; 42 | switch (view.popover?.view) { 43 | // case PopoverView.learn: 44 | // viewComponent = ; 45 | // break; 46 | case PopoverView.ib: 47 | const ibData = view.popover.data! as IbViewData; 48 | viewComponent = ; 49 | break; 50 | } 51 | 52 | return ( 53 |
57 | {viewComponent} 58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/algorithm/beta.ts: -------------------------------------------------------------------------------- 1 | import { jStat } from "jstat"; 2 | import seedrandom from "seedrandom"; 3 | import { todayMidnight } from "../utils/datetime"; 4 | import { BETA_BOUNDS } from "../globals"; 5 | import { PriorityUpdate } from "./priority"; 6 | import { BetaParams } from "../types"; 7 | 8 | function toBetaParams(mean: number, variance: number): {a: number, b: number} { 9 | let a = ((1 - mean) / variance - 1 / mean) * mean * mean; 10 | let b = a * (1 / mean - 1); 11 | // Ensure the parameters are non-negative 12 | a = Math.max(1e-10, a); 13 | b = Math.max(1e-10, b); 14 | return { a, b }; 15 | } 16 | 17 | function toBetaMoments(a: number, b: number) : { mean: number, variance: number } { 18 | const mean = jStat.beta.mean(a, b); 19 | const variance = jStat.beta.variance(a, b); 20 | return { mean, variance }; 21 | } 22 | 23 | function boundParam(param: number) { 24 | return Math.min(Math.max(param, BETA_BOUNDS.paramLower), BETA_BOUNDS.paramUpper); 25 | } 26 | 27 | class Beta { 28 | private _a: number; 29 | private _b: number; 30 | private _mean!: number; 31 | private _variance!: number; 32 | 33 | constructor(a: number, b: number) { 34 | this._a = a; 35 | this._b = b; 36 | this.setMoments(); 37 | } 38 | 39 | static fromProps(props: Record) : Beta | null { 40 | // TODO validate a and b are within bounds 41 | const a = parseFloat(props['ibA']); 42 | if (!Beta.isValidParam(a)) return null; 43 | const b = parseFloat(props['ibB']); 44 | if (!Beta.isValidParam(b)) return null; 45 | return new this(a, b); 46 | } 47 | 48 | static fromParams(params: BetaParams) { 49 | return new this(params.a, params.b); 50 | } 51 | 52 | static fromMoments(mean: number, variance: number) { 53 | const {a, b} = toBetaParams(mean, variance); 54 | return new this(a, b); 55 | } 56 | 57 | static fromMeanA(mean: number, a: number) { 58 | const b = a * (1 / mean - 1); 59 | return new this(a, b); 60 | } 61 | 62 | static fromMeanB(mean: number, b: number) { 63 | const a = mean * b / (1 - mean); 64 | return new this(a, b); 65 | } 66 | 67 | private setMoments() { 68 | const {mean, variance} = toBetaMoments(this._a, this._b); 69 | this._mean = mean; 70 | this._variance = variance; 71 | } 72 | 73 | private setParams() { 74 | const {a, b} = toBetaParams(this._mean, this._variance); 75 | this._a = a; 76 | this._b = b; 77 | } 78 | 79 | static isValidParam(p: number) { 80 | return typeof p === 'number' && p > 0; 81 | } 82 | 83 | public pdf(x: number): number { 84 | return jStat.beta.pdf(x, this._a, this._b); 85 | } 86 | 87 | public sample({ prefix='', seedToday=false, seedDate }: { prefix?: string, seedToday?: boolean, seedDate?: Date }): number { 88 | if (seedDate) { 89 | jStat.setRandom(seedrandom(prefix + seedDate.getTime().toString())); 90 | } else if (seedToday) { 91 | jStat.setRandom(seedrandom(prefix + todayMidnight().getTime().toString())); 92 | } 93 | return jStat.beta.sample(this._a, this._b); 94 | } 95 | 96 | static isValidSample(x: any): boolean { 97 | return typeof x === 'number' && x >= 0 && x <= 1; 98 | } 99 | 100 | public mode(): number { 101 | // TODO: only exists if a > 1 && b > 1 102 | return jStat.beta.mode(this._a, this._b); 103 | } 104 | 105 | public std(): number { 106 | return Math.sqrt(this._variance); 107 | } 108 | 109 | public varianceUpperBound(): number { 110 | return this._mean * (1 - this._mean); 111 | } 112 | 113 | public correctForBounds() { 114 | if (this._a < BETA_BOUNDS.paramLower || this._b < BETA_BOUNDS.paramLower) { 115 | const min = Math.min(this._a, this._b); 116 | const off = BETA_BOUNDS.paramLower - min; 117 | if (this._a == min) { 118 | this._a += off; 119 | this._b = Beta.fromMeanA(this._mean, this._a).b; 120 | } else { 121 | this._b += off; 122 | this._a = Beta.fromMeanB(this._mean, this._b).a; 123 | } 124 | this.setMoments(); 125 | } else if (this._a > BETA_BOUNDS.paramUpper || this._b > BETA_BOUNDS.paramUpper) { 126 | const max = Math.max(this._a, this._b); 127 | const off = max - BETA_BOUNDS.paramUpper; 128 | if (this._a == max) { 129 | this._a -= off; 130 | this._b = Beta.fromMeanA(this._mean, this._a).b; 131 | } else { 132 | this._b -= off; 133 | this._a = Beta.fromMeanB(this._mean, this._b).a; 134 | } 135 | this.setMoments(); 136 | } 137 | } 138 | 139 | public get a() { 140 | return this._a; 141 | }; 142 | 143 | public set a(a) { 144 | this._a = a; 145 | this.setMoments(); 146 | } 147 | 148 | public get b() { 149 | return this._b; 150 | } 151 | 152 | public set b(b) { 153 | this._b = b; 154 | this.setMoments(); 155 | } 156 | 157 | public get mean() { 158 | return this._mean; 159 | } 160 | 161 | public set mean(mean) { 162 | this._mean = mean; 163 | // Can change variance bound leading to potentially invalid variance. 164 | const varUpperBound = this.varianceUpperBound(); 165 | if (this._variance >= varUpperBound) { 166 | this._variance = varUpperBound - 0.00001; 167 | } 168 | this.setParams(); 169 | this.correctForBounds(); 170 | } 171 | 172 | public get variance() { 173 | return this._variance; 174 | } 175 | 176 | public set variance(variance) { 177 | this._variance = variance; 178 | this.setParams(); 179 | this.correctForBounds(); 180 | } 181 | 182 | public copy() : Beta { 183 | return new Beta(this._a, this._b); 184 | } 185 | 186 | public get params() : BetaParams { 187 | return {a: this._a, b: this._b}; 188 | } 189 | 190 | public applyPriorityUpdate(priorityUpdate: PriorityUpdate) { 191 | this._a = this._a + priorityUpdate.a; 192 | this._b = this._b + priorityUpdate.b; 193 | this.correctForBounds(); 194 | } 195 | 196 | public static boundParam(param: number) : number { 197 | return boundParam(param); 198 | } 199 | } 200 | 201 | export default Beta; 202 | -------------------------------------------------------------------------------- /src/algorithm/priority.ts: -------------------------------------------------------------------------------- 1 | import { BETA_BOUNDS } from "../globals"; 2 | import { CurrentIBData } from "../learn/learnSlice"; 3 | import { unixTimestampToDate } from "../utils/datetime"; 4 | import Beta from "./beta"; 5 | import stringComparison from "string-comparison"; 6 | 7 | // Update priority manually 8 | export function betaFromMean(mean: number, opts: { currentBeta?: Beta | null } = {}) : Beta { 9 | mean = Math.min(Math.max(mean, BETA_BOUNDS.meanLower), BETA_BOUNDS.meanUpper); 10 | 11 | let beta : Beta; 12 | if (opts.currentBeta) { 13 | beta = opts.currentBeta.copy(); 14 | beta.mean = mean; 15 | } else { 16 | let a: number, b: number; 17 | if (mean == 0.5) { 18 | a = 1; 19 | b = 1; 20 | } else if (mean > 0.5) { 21 | b = BETA_BOUNDS.paramLower; 22 | a = (mean * b) / (1 - mean); 23 | } else { 24 | a = BETA_BOUNDS.paramLower; 25 | b = a * (1 - mean) / mean; 26 | } 27 | beta = new Beta(a, b); 28 | } 29 | 30 | return beta; 31 | } 32 | 33 | // Update priority from session data 34 | function logistic(max: number, mid: number, rate: number, offset: number): (x: number) => number { 35 | const l = (x: number) => ((max + offset) / (1 + Math.exp(-rate * (x - mid)))) - offset; 36 | return l; 37 | } 38 | 39 | export interface PriorityUpdate { 40 | scoreContent: number, 41 | aContent: number, 42 | a: number, 43 | scoreTime: number, 44 | bTime: number, 45 | b: number, 46 | } 47 | 48 | export function getPriorityUpdate(data: CurrentIBData) : PriorityUpdate { 49 | // Time component 50 | const now = new Date(); 51 | const start = new Date(data.start); 52 | const durationSeconds = (now.getTime() - start.getTime()) / 1000; 53 | const timeBeta = logistic(1., 30, -.1, .2)(durationSeconds); 54 | 55 | // Content component 56 | const uuids = new Set([...Object.keys(data.contents), ...Object.keys(data.newContents)]); 57 | let totalContentDistance = 0; 58 | for (let uuid of uuids) { 59 | const distance = stringComparison.levenshtein.distance( 60 | data.contents[uuid]??'', data.newContents[uuid]??''); 61 | totalContentDistance += distance; 62 | } 63 | const contentAlpha = logistic(1, 50, .1, 0)(totalContentDistance); 64 | 65 | return { 66 | scoreTime: durationSeconds, 67 | bTime: timeBeta, 68 | b: timeBeta, 69 | scoreContent: totalContentDistance, 70 | aContent: contentAlpha, 71 | a: contentAlpha 72 | }; 73 | } 74 | -------------------------------------------------------------------------------- /src/algorithm/scheduling.ts: -------------------------------------------------------------------------------- 1 | import { jStat } from "jstat"; 2 | import { IncrementalBlock } from "../types"; 3 | 4 | export function initialIntervalFromMean(mean: number) : number { 5 | const rate = (1 - mean) * 25; 6 | const interval = jStat.poisson.sample(rate) + 1; 7 | return interval; 8 | } 9 | 10 | export function nextInterval(ib: IncrementalBlock) : number { 11 | return Math.ceil(ib.scheduling!.interval * ib.scheduling!.multiplier); 12 | } 13 | -------------------------------------------------------------------------------- /src/anki/anki.ts: -------------------------------------------------------------------------------- 1 | const ANKI_PORT = 8765; 2 | 3 | export async function invoke(action: string, params = {}): Promise { 4 | return new Promise((resolve, reject) => { 5 | const xhr = new XMLHttpRequest(); 6 | xhr.addEventListener("error", () => reject("Failed to issue request. Is Anki open?")); 7 | xhr.addEventListener("load", () => { 8 | try { 9 | const response = JSON.parse(xhr.responseText); 10 | if (Object.getOwnPropertyNames(response).length != 2) { 11 | throw "Response has an unexpected number of fields"; 12 | } 13 | if (!response.hasOwnProperty("error")) { 14 | throw "Response is missing required error field"; 15 | } 16 | if (!response.hasOwnProperty("result")) { 17 | throw "Response is missing required result field"; 18 | } 19 | if (response.error) { 20 | throw response.error; 21 | } 22 | resolve(response.result); 23 | } catch (e) { 24 | reject(e); 25 | } 26 | }); 27 | 28 | xhr.open("POST", "http://127.0.0.1:" + ANKI_PORT.toString()); 29 | xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); 30 | xhr.send(JSON.stringify({action, version: 6, params})); 31 | }); 32 | } 33 | 34 | export async function getAnkiModelName() : Promise { 35 | const graph = await logseq.App.getCurrentGraph(); 36 | if (graph == null) return null; 37 | return `${graph.name}Model`; 38 | } 39 | 40 | export async function getLogseqCards({ due=false, deck }: { due?: boolean, deck?: string }) : Promise[]> { 41 | const modelName = await getAnkiModelName(); 42 | if (modelName == null) return []; 43 | let query = `note:${modelName}`; 44 | if (due) query = `(is:due or is:new) ${query}`; 45 | if (deck) query = `${query} deck:${deck}`; 46 | const cardIds = await invoke('findCards', { query }); 47 | const cardsData = await invoke('cardsInfo', { cards: cardIds }); 48 | console.log('cards data', cardsData); 49 | return cardsData; 50 | } 51 | 52 | export async function extractSourcesFromCard(content: string) : Promise> { 53 | const regex = /src="([^"]*)"/g; 54 | const sources = [...content.matchAll(regex)].map((r) => r[1]).filter((s) => !s.startsWith('_')); 55 | const contents = new Map(); 56 | await Promise.all( 57 | sources.map(async (s) => contents.set(s, atob(await invoke('retrieveMediaFile', { filename: s })))) 58 | ); 59 | return contents; 60 | } 61 | 62 | export interface CardData { 63 | question: string, 64 | answer: string, 65 | deckName: string, 66 | modelName: string, 67 | fieldOrder: number, 68 | fields: any, 69 | css: string, 70 | cardId: number, 71 | interval: number, 72 | note: number, 73 | ord: number, 74 | type: number, 75 | queue: number, 76 | due: number, 77 | reps: number, 78 | lapses: number, 79 | left: number, 80 | mod: number 81 | } 82 | 83 | export async function getCardData(cardIds: number[]) : Promise { 84 | const data = await invoke('cardsInfo', { cards: cardIds }); 85 | return data as CardData[]; 86 | } 87 | 88 | export interface DeckReview { 89 | reviewTime: number, 90 | cardID: number, 91 | usn: number, 92 | buttonPressed: number, 93 | newInterval: number, 94 | previousInterval: number, 95 | newFactor: number, 96 | reviewDuration: number, 97 | reviewType: number 98 | } 99 | 100 | export async function getDeckReviews(deck: string, sinceUnix: number) : Promise { 101 | const reviews = await invoke('cardReviews', { deck, startID: sinceUnix }); 102 | return reviews.map((r: any) => { 103 | return { 104 | reviewTime: r[0], 105 | cardID: r[1], 106 | usn: r[2], 107 | buttonPressed: r[3], 108 | newInterval: r[4], 109 | previousInterval: r[5], 110 | newFactor: r[6], 111 | reviewDuration: r[7], 112 | reviewType: r[8] 113 | }; 114 | }); 115 | } 116 | 117 | // https://github.com/ankidroid/Anki-Android/wiki/Database-Structure#review-log 118 | export interface CardReview { 119 | // epoch-milliseconds timestamp of when you did the review 120 | id: number, 121 | // update sequence number: for finding diffs when syncing 122 | usn: number, 123 | // which button you pushed to score your recall. 124 | // review: 1(wrong), 2(hard), 3(ok), 4(easy) 125 | // learn/relearn: 1(wrong), 2(ok), 3(easy) 126 | ease: number, 127 | // interval (i.e. as in the card table) 128 | ivl: number, 129 | // last interval, i.e. the last value of ivl. Note that this value is not 130 | // necessarily equal to the actual interval between this review and the 131 | // preceding review. 132 | lastIvl: number, 133 | // factor 134 | factor: number, 135 | // how many milliseconds your review took, up to 60000 (60s) 136 | time: number, 137 | // 0=learn, 1=review, 2=relearn, 3=filtered, 4=manual 138 | type: number, 139 | } 140 | 141 | interface CardReviews { 142 | [key: string]: CardReview[] 143 | } 144 | 145 | export async function getCardReviews(cardIds: number[]) : Promise { 146 | const reviews = await invoke('getReviewsOfCards', { cards: cardIds }); 147 | return reviews as CardReviews; 148 | } 149 | -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | import Dexie, { type EntityTable } from 'dexie'; 2 | import { IncrementalBlock } from './types'; 3 | 4 | export const db = new Dexie('ibdb') as Dexie & { 5 | ibs: EntityTable; 6 | }; 7 | 8 | db.version(1).stores({ 9 | ibs: '&uuid, scheduling.dueDate' 10 | }); 11 | -------------------------------------------------------------------------------- /src/docx/DocxWindow.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from "react"; 2 | import { useAppDispatch, useAppSelector } from "../state/hooks"; 3 | import { extractSelection } from "./docxSlice"; 4 | import { parseHtml } from "../utils/utils"; 5 | 6 | export default function DocxWindow() { 7 | const ref = useRef(null); 8 | const data = useAppSelector(state => state.docx.data); 9 | const dispatch = useAppDispatch(); 10 | 11 | useEffect(() => { 12 | const el = parseHtml(data.content).documentElement; 13 | el.getElementsByTagName('body').item(0).setAttribute('contentEditable', 'true'); 14 | ref.current.srcdoc = el.innerHTML; 15 | }, [data]); 16 | 17 | async function save() { 18 | } 19 | 20 | async function extract() { 21 | await dispatch(extractSelection(ref.current.contentDocument)); 22 | } 23 | 24 | return ( 25 |
26 |
27 | 32 | 37 |
38 | 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/docx/docx.ts: -------------------------------------------------------------------------------- 1 | import { BlockEntity, BlockUUID, PageEntity } from "@logseq/libs/dist/LSPlugin"; 2 | import { generateNewIbProps } from "../ib/create"; 3 | import { parseHtml } from "../utils/utils"; 4 | 5 | export async function generateNewDocIbProps(uuid?: string) : Promise> { 6 | const props = await generateNewIbProps(uuid); 7 | props['ib-readpoint'] = 0; 8 | return props; 9 | } 10 | 11 | interface ICreateDoc { 12 | html: string, 13 | title: string, 14 | uuid?: BlockUUID, 15 | parent?: BlockUUID 16 | } 17 | 18 | export async function createDoc({ title, html, uuid, parent }: ICreateDoc) : Promise { 19 | if (!uuid) uuid = await logseq.Editor.newBlockUUID(); 20 | const storage = logseq.Assets.makeSandboxStorage(); 21 | const filename = `${uuid}.html`; 22 | 23 | if (await storage.hasItem(filename)) { 24 | logseq.UI.showMsg('Asset already exists with given title', 'error'); 25 | return null; 26 | } 27 | 28 | const properties = await generateNewDocIbProps(uuid); 29 | let blockOrPage: PageEntity | BlockEntity; 30 | if (parent) { 31 | blockOrPage = await logseq.Editor.insertBlock(parent, title, { properties }); 32 | } else { 33 | blockOrPage = await logseq.Editor.createPage(title, properties, { redirect: true }); 34 | const document = parseHtml(html); 35 | const styleEl = document.createElement('style'); 36 | styleEl.innerHTML = ` 37 | .extract { 38 | background: pink; 39 | cursor: pointer; 40 | }`; 41 | document.head.appendChild(styleEl); 42 | html = document.documentElement.outerHTML; 43 | } 44 | 45 | if (blockOrPage == null) { 46 | logseq.UI.showMsg('Failed to create block/page', 'error'); 47 | return null; 48 | } 49 | 50 | // Store file 51 | await storage.setItem(filename, html); 52 | 53 | return blockOrPage; 54 | } 55 | -------------------------------------------------------------------------------- /src/docx/docxSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit" 2 | import { AppDispatch, RootState } from "../state/store"; 3 | import { IncrementalBlock } from "../types"; 4 | import { ibFromProperties, ibFromUuid } from "../ib/read"; 5 | import { highlightSelection } from "./selection"; 6 | import { BlockEntity, PageEntity } from "@logseq/libs/dist/LSPlugin"; 7 | import { createDoc, generateNewDocIbProps } from "./docx"; 8 | import { parseHtml } from "../utils/utils"; 9 | 10 | interface ActiveDocData { 11 | block: BlockEntity, 12 | ib: IncrementalBlock, 13 | page: PageEntity, 14 | content: string 15 | } 16 | 17 | interface DocxState { 18 | busy: boolean, 19 | data?: ActiveDocData 20 | } 21 | 22 | const initialState: DocxState = { 23 | busy: false 24 | } 25 | 26 | const docxSlice = createSlice({ 27 | name: 'docx', 28 | initialState, 29 | reducers: { 30 | gotBusy(state, action: PayloadAction) { 31 | state.busy = action.payload; 32 | }, 33 | docLoaded(state, action: PayloadAction) { 34 | state.data = action.payload; 35 | } 36 | } 37 | }); 38 | 39 | export const { } = docxSlice.actions; 40 | 41 | export const openDocFromUUID = (uuid: string) => { 42 | return async (dispatch: AppDispatch, getState: () => RootState) : Promise => { 43 | const state = getState(); 44 | if (state.docx.busy) return; 45 | dispatch(docxSlice.actions.gotBusy(true)); 46 | 47 | // 48 | const block = await logseq.Editor.getBlock(uuid); 49 | const page = await logseq.Editor.getPage(block.parent.id); 50 | const ib = ibFromProperties(uuid, block.properties ?? {}); 51 | if (ib == null) { 52 | logseq.UI.showMsg('Ib not found', 'error'); 53 | dispatch(docxSlice.actions.gotBusy(false)); 54 | return; 55 | } 56 | 57 | // Load content 58 | const storage = logseq.Assets.makeSandboxStorage(); 59 | let content = await storage.getItem(`${uuid}.html`); 60 | if (!content) { 61 | logseq.UI.showMsg('Document not found', 'error'); 62 | dispatch(docxSlice.actions.gotBusy(false)); 63 | return; 64 | } 65 | content = content.replace('', ` 66 | 72 | `); 73 | 74 | dispatch(docxSlice.actions.docLoaded({ ib, content, page, block })); 75 | dispatch(docxSlice.actions.gotBusy(false)); 76 | } 77 | } 78 | 79 | export const extractSelection = (document: Document) => { 80 | return async (dispatch: AppDispatch, getState: () => RootState) : Promise => { 81 | const state = getState(); 82 | if (state.docx.busy) return; 83 | const data = state.docx.data; 84 | if (!data) return; 85 | 86 | const selection = document.getSelection(); 87 | if (!selection.rangeCount) { 88 | logseq.UI.showMsg('Nothing selected to extract', 'warning'); 89 | return; 90 | } 91 | 92 | dispatch(docxSlice.actions.gotBusy(true)); 93 | 94 | const uuid = await logseq.Editor.newBlockUUID(); 95 | highlightSelection(selection, document, ['extract', `extract-${uuid}`]); 96 | 97 | const parent = data.block.left.id === data.block.page.id ? data.page.uuid : data.block.uuid; 98 | const container = document.createElement('div'); 99 | container.appendChild(selection.getRangeAt(0).cloneContents()); 100 | const html = container.innerHTML; 101 | const extractDoc = parseHtml(data.content); 102 | extractDoc.body.innerHTML = html; 103 | // const title = selection.toString().split('\n')[0].slice(0, 100); 104 | const title = selection.toString().slice(0, 100); 105 | await createDoc({ title, html, parent, uuid }); 106 | 107 | dispatch(docxSlice.actions.gotBusy(false)); 108 | } 109 | } 110 | 111 | export default docxSlice.reducer; 112 | -------------------------------------------------------------------------------- /src/docx/selection.ts: -------------------------------------------------------------------------------- 1 | 2 | export function highlightSelection(selection: Selection, document: Document, classes: Array) { 3 | let range = selection.getRangeAt(0); 4 | let startNode = range.startContainer; 5 | let endNode = range.endContainer; 6 | let startOffset = range.startOffset; 7 | let endOffset = range.endOffset; 8 | 9 | console.log(selection, range); 10 | 11 | // Handle selection within a single text node 12 | if (startNode === endNode) { 13 | wrapSelectedNode(startNode, startOffset, endOffset, classes); 14 | return; 15 | } 16 | 17 | // Handle multi-node selection 18 | let commonAncestor = range.commonAncestorContainer; 19 | let walker = document.createTreeWalker(commonAncestor, NodeFilter.SHOW_TEXT, null); 20 | let isHighlighting = false; 21 | 22 | const nodesToWrap = Array(); 23 | while (walker.nextNode()) { 24 | let currentNode = walker.currentNode; 25 | 26 | if (currentNode === startNode) { 27 | isHighlighting = true; 28 | nodesToWrap.push(currentNode); 29 | } else if (isHighlighting) { 30 | nodesToWrap.push(currentNode); 31 | if (currentNode === endNode) break; 32 | } 33 | } 34 | 35 | for (const node of nodesToWrap) { 36 | if (node === startNode) { 37 | wrapSelectedNode(node, startOffset, null, classes); 38 | } else if (node === endNode) { 39 | wrapSelectedNode(node, 0, endOffset, classes); 40 | } else { 41 | wrapSelectedNode(node, 0, null, classes); 42 | } 43 | } 44 | } 45 | 46 | function wrapSelectedNode(node: Node, startOffset: number, endOffset: number | null, classes: Array) : Node { 47 | if (node.nodeType === Node.TEXT_NODE) { 48 | return wrapSelectedText(node, startOffset, endOffset, classes); 49 | } 50 | } 51 | 52 | function wrapSelectedText(textNode: Node, startOffset: number, endOffset: number | null, classes: Array) : Node { 53 | let text = textNode.nodeValue; 54 | let parent = textNode.parentNode; 55 | 56 | let beforeText = text.substring(0, startOffset); 57 | let highlightText = text.substring(startOffset, endOffset !== null ? endOffset : text.length); 58 | let afterText = endOffset !== null ? text.substring(endOffset) : ""; 59 | 60 | let span = document.createElement('span'); 61 | classes.forEach(c => span.classList.add(c)); 62 | span.textContent = highlightText; 63 | 64 | if (beforeText) parent.insertBefore(document.createTextNode(beforeText), textNode); 65 | parent.insertBefore(span, textNode); 66 | if (afterText) parent.insertBefore(document.createTextNode(afterText), textNode); 67 | 68 | parent.removeChild(textNode); 69 | return span; 70 | } 71 | 72 | -------------------------------------------------------------------------------- /src/globals.ts: -------------------------------------------------------------------------------- 1 | export const PRIORITY_PALETTE = [ 2 | // "#ff595e66", "#ffca3a66", "#88bb6466", "#1982c466", "#6a4c9366", 3 | '#ffa5a5', '#ffe39f', '#feffb6', '#a7ffcc', '#b0cdff' 4 | ]; 5 | 6 | export const BETA_BOUNDS = { 7 | // Limits of a and b params. 8 | // - Mode only defined for a,b > 1 9 | // - when a,b < 1, bimodal. Doesnt make sense to have here. 10 | // - With upper=100, we can reach mean precision of 0.01-0.99. 11 | paramLower: 1., 12 | paramUpper: 100., 13 | // Mean = a / (a+b) 14 | meanLower: 0.01, // 1/(1+paramUpper) = 0.01 15 | meanUpper: 0.99, // 100/(100+paramLower) = 0.99 16 | } 17 | 18 | export const RENDERER_MACRO_NAME = '{{renderer :ib}}'; 19 | export const PROP_REGEX = /[a-zA-Z0-9-_]+:: [^:]+/; 20 | export const PLUGIN_ROUTE = '/page/incremental-blocks'; 21 | // From: logseq-plugin-files-manager 22 | export const PARENT_MAIN_CONTAINER_ID = 'main-content-container'; 23 | export const PARENT_HEAD_ID = 'head'; 24 | -------------------------------------------------------------------------------- /src/hooks/useCalculateHeight.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export default function useCalculateHeight(el: HTMLElement | null, min: number = 100) : number { 4 | const [height, setHeight] = useState(min); 5 | 6 | useEffect(() => { 7 | calculateHeight(); 8 | window.addEventListener('resize', calculateHeight); 9 | 10 | return () => window.removeEventListener('resize', calculateHeight); 11 | }, [el]); 12 | 13 | function calculateHeight() { 14 | let height = 100; 15 | if (el) { 16 | //const rect = el.getBoundingClientRect(); 17 | //height = rect.height; 18 | 19 | // From: 20 | // https://github.com/petyosi/react-virtuoso/issues/37#issuecomment-2241281767 21 | let topOffset = 0; 22 | let currentElement = el as any; 23 | 24 | // Calculate the total offset from the top of the document 25 | while (currentElement) { 26 | topOffset += currentElement.offsetTop || 0; 27 | currentElement = currentElement.offsetParent as HTMLElement; 28 | } 29 | 30 | const totalHeight = window.innerHeight; 31 | const calculatedHeight = totalHeight - topOffset; 32 | height = calculatedHeight; 33 | } 34 | setHeight(height); 35 | } 36 | 37 | return height; 38 | } 39 | -------------------------------------------------------------------------------- /src/hooks/useMainSizeAndPos.tsx: -------------------------------------------------------------------------------- 1 | // From: logseq-plugin-file-manager 2 | import { useState, useEffect } from 'react'; 3 | import { PARENT_MAIN_CONTAINER_ID } from '../globals'; 4 | 5 | const useMainSizeAndPosition = () => { 6 | const [sizeAndPosition, setSizeAndPosition] = useState({ width: innerWidth, height: innerHeight, left: 0, top: 0 }); 7 | 8 | useEffect(() => { 9 | const mainContainer = parent.document.getElementById(PARENT_MAIN_CONTAINER_ID); 10 | 11 | const updateSizeAndPosition = () => { 12 | if (mainContainer) { 13 | const rect = mainContainer.getBoundingClientRect(); 14 | setSizeAndPosition({ 15 | width: rect.width, 16 | height: rect.height, 17 | left: rect.x, 18 | top: rect.top, 19 | }); 20 | 21 | } 22 | }; 23 | 24 | const resizeObserver = new ResizeObserver(updateSizeAndPosition); 25 | if (mainContainer) resizeObserver.observe(mainContainer); 26 | updateSizeAndPosition(); 27 | 28 | return () => { 29 | resizeObserver.disconnect(); 30 | }; 31 | 32 | }, []); 33 | 34 | return sizeAndPosition; 35 | }; 36 | 37 | export default useMainSizeAndPosition; 38 | -------------------------------------------------------------------------------- /src/ib/actions.ts: -------------------------------------------------------------------------------- 1 | import { RENDERER_MACRO_NAME } from "../globals"; 2 | import { IncrementalBlock } from "../types"; 3 | import { removeIbPropsFromContent } from "../utils/logseq"; 4 | import { toDashCase } from "../utils/utils"; 5 | 6 | export async function doneIb(ib: IncrementalBlock) { 7 | const block = await logseq.Editor.getBlock(ib.uuid, {includeChildren: false}); 8 | if (!block) return; 9 | // Remove properties by content and using removeBlockProperty, since former only 10 | // works when props are visible and latter when props are hidden. 11 | const content = removeIbPropsFromContent(block.content).replace(RENDERER_MACRO_NAME, ''); 12 | for (let prop of Object.keys(block.properties ?? {})) { 13 | if (prop.startsWith('ib')) { 14 | logseq.Editor.removeBlockProperty(ib.uuid, toDashCase(prop)); 15 | } 16 | } 17 | await logseq.Editor.updateBlock(ib.uuid, content); 18 | } 19 | 20 | -------------------------------------------------------------------------------- /src/ib/create.ts: -------------------------------------------------------------------------------- 1 | import IncrementalBlock from "../IncrementalBlock"; 2 | import { average } from "../utils/utils"; 3 | import { addContentAndProps } from "../utils/logseq"; 4 | import { BlockEntity } from "@logseq/libs/dist/LSPlugin"; 5 | import Beta from "../algorithm/beta"; 6 | import { queryBlockRefs } from "../logseq/query"; 7 | import { initialIntervalFromMean } from "../algorithm/scheduling"; 8 | import { renderAndObserveUuid } from "../logseq/blockrender"; 9 | import { ibFromProperties } from "./read"; 10 | import { addDays, todayMidnight } from "../utils/datetime"; 11 | 12 | function blockRefsToBeta(refIbs: IncrementalBlock[]) : Beta | null { 13 | const as: number[] = []; 14 | const bs: number[] = []; 15 | for (const refIb of refIbs) { 16 | if (refIb.beta) { 17 | as.push(refIb.beta.a); 18 | bs.push(refIb.beta.b); 19 | } 20 | } 21 | if (as.length > 0) { 22 | return new Beta(average(as), average(bs)); 23 | } 24 | return null; 25 | } 26 | 27 | export async function generateNewIbProps(uuid?: string) : Promise> { 28 | if (!uuid) uuid = await logseq.Editor.newBlockUUID(); 29 | const multiplier = logseq.settings?.defaultMultiplier as number ?? 2.; 30 | const interval = initialIntervalFromMean(.5); 31 | const due = addDays(todayMidnight(), interval); 32 | return { 33 | id: uuid, 34 | 'ib-a': 1, 'ib-b': 1, 35 | 'ib-reps': 0, 'ib-multiplier': multiplier, 36 | 'ib-interval': interval, 'ib-due': due.getTime() 37 | }; 38 | } 39 | 40 | /* 41 | Generate ib properties from block. If some of them already exist, keep them. 42 | */ 43 | type PriorityOnly = boolean | 'inherit'; 44 | 45 | interface IGenProps { 46 | uuid: string, 47 | priorityOnly?: PriorityOnly, 48 | block?: BlockEntity | null 49 | } 50 | 51 | export async function generateIbPropsFromBlock({ uuid, priorityOnly=false, block } : IGenProps) : Promise | null> { 52 | if (!block) block = await logseq.Editor.getBlock(uuid); 53 | if (!block) return null; 54 | 55 | // Parse the existing ib related properties 56 | const ib = IncrementalBlock.fromBlock(block); 57 | 58 | // If priorityOnly flag is boolean, then respect it. Otherwise, determine 59 | // from existing ib properties (if they exist) if it has scheduling. 60 | if (priorityOnly == 'inherit') { 61 | priorityOnly = !(ib.dueDate && ib.interval); 62 | } 63 | 64 | const props: Record = { id: uuid }; 65 | if (priorityOnly == false) { 66 | props['ib-multiplier'] = ib.multiplier; 67 | props['ib-reps'] = ib.reps ?? 0; 68 | } 69 | let beta = ib.beta; 70 | if (!beta) { 71 | // Try to calculate initial beta by inheriting from refs 72 | const refs = await queryBlockRefs({ uuid }); 73 | if (refs && refs.refs.length > 0) { 74 | const refPages = await Promise.all( 75 | refs.refs.map(r => logseq.Editor.getPage(r.uuid))); 76 | const refIbs = refPages 77 | .filter(p => p?.properties) 78 | .map(p => IncrementalBlock.fromPage(p!)); 79 | if (refIbs.length > 0) { 80 | beta = blockRefsToBeta(refIbs); 81 | } 82 | } 83 | // If none, use default priority 84 | if (!beta) { 85 | beta = new Beta(1, 1); 86 | beta.mean = logseq.settings?.defaultPriority as number ?? 0.5; 87 | } 88 | } 89 | props['ib-a'] = beta.a; 90 | props['ib-b'] = beta.b; 91 | 92 | if (priorityOnly == false) { 93 | const interval = initialIntervalFromMean(beta.mean); 94 | const due = new Date(); 95 | due.setDate(due.getDate() + interval); 96 | props['ib-due'] = due.getTime(); 97 | props['ib-interval'] = interval; 98 | } 99 | return props; 100 | } 101 | 102 | /* 103 | */ 104 | interface BlockToIb { 105 | uuid: string, 106 | priorityOnly?: PriorityOnly, 107 | block?: BlockEntity | null, 108 | backToEditing?: boolean 109 | } 110 | 111 | export async function convertBlockToIb({ uuid, block, priorityOnly=false, backToEditing=false }: BlockToIb) { 112 | let content: string = ''; 113 | let isEditing = false; 114 | const cursorPos = await logseq.Editor.getEditingCursorPosition(); 115 | 116 | if (!block) { 117 | // If editing, get content 118 | content = await logseq.Editor.getEditingBlockContent(); 119 | if (content) isEditing = true; 120 | block = await logseq.Editor.getBlock(uuid); 121 | } 122 | 123 | if (!block) { 124 | logseq.UI.showMsg('Block not found', 'error'); 125 | return; 126 | } 127 | 128 | if (!content) content = block.content; 129 | 130 | const props = await generateIbPropsFromBlock({ uuid, priorityOnly, block }); 131 | if (!props) { 132 | logseq.UI.showMsg('Failed to generate ib properties', 'error'); 133 | return; 134 | } 135 | 136 | // await logseq.Editor.updateBlock(uuid, content, { properties: props }); 137 | 138 | // TODO: This doesn't update existing params 139 | let addition = ''; 140 | // if (!content.includes(RENDERER_MACRO_NAME)) addition = RENDERER_MACRO_NAME; 141 | const newContent = addContentAndProps(content, { addition, props }); 142 | await logseq.Editor.updateBlock(uuid, newContent); 143 | await logseq.Editor.exitEditingMode(); 144 | 145 | renderAndObserveUuid(uuid); 146 | 147 | if (backToEditing) { 148 | setTimeout(() => { 149 | logseq.Editor.editBlock(uuid, { pos: cursorPos?.pos ?? 0 }); 150 | }, 100); 151 | } 152 | } 153 | 154 | -------------------------------------------------------------------------------- /src/ib/read.ts: -------------------------------------------------------------------------------- 1 | import Beta from "../algorithm/beta"; 2 | import { ExtractData, IncrementalBlock, Scheduling } from "../types"; 3 | 4 | /* 5 | * Block recognized as ib if at least a and b properties. 6 | */ 7 | export function ibFromProperties(uuid: string, props: Record): IncrementalBlock | null { 8 | const beta = Beta.fromProps(props); 9 | if (!beta) return null; 10 | 11 | let scheduling: Scheduling | undefined; 12 | let due = parseFloat(props['ibDue']); 13 | if (!isNaN(due)) { 14 | // Set the time to midnight. 15 | // Should already be the case, but sometimes users are 16 | // naughty naughty so we need to fix things. 17 | const date = new Date(due); 18 | date.setHours(0, 0, 0, 0); 19 | due = date.getTime(); 20 | 21 | let multiplier = parseFloat(props['ibMultiplier']); 22 | if (!(typeof multiplier === 'number' && multiplier >= 1)) { 23 | multiplier = logseq.settings?.defaultMultiplier as number ?? 2.; 24 | } 25 | 26 | let interval = parseFloat(props['ibInterval']); 27 | if (!(typeof interval === 'number' && interval >= 0)) { 28 | interval = 1; 29 | } 30 | 31 | let reps = parseFloat(props['ibReps']); 32 | if (!(typeof reps === 'number' && reps >= 0 && Number.isInteger(reps))) { 33 | reps = 0; 34 | } 35 | 36 | scheduling = { multiplier, interval, reps, dueDate: due }; 37 | } 38 | 39 | let extractData: ExtractData | undefined; 40 | const readpoint = props['ibReadpoint']; 41 | if (readpoint != undefined) { 42 | extractData = { readpoint }; 43 | } 44 | // TODO medx 45 | // TODO pdf 46 | 47 | return { 48 | uuid, 49 | betaParams: beta.params ?? {a:1, b:1}, 50 | scheduling, 51 | extractData, 52 | } 53 | } 54 | 55 | export async function ibFromUuid(uuid: string): Promise { 56 | const props = await logseq.Editor.getBlockProperties(uuid); 57 | if (!props) return null; 58 | return ibFromProperties(uuid, props); 59 | } 60 | -------------------------------------------------------------------------------- /src/import/DioUpload.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { basename } from "path"; 3 | import { useAppDispatch, useAppSelector } from "../state/hooks"; 4 | import { formatSelected } from "./importSlice"; 5 | 6 | export default function DioUpload() { 7 | const busy = useAppSelector(state => state.import.busy); 8 | const format = useAppSelector(state => state.import.format); 9 | const [path, setPath] = React.useState(''); 10 | const [title, setTitle] = React.useState(''); 11 | const keep = React.useRef(false); 12 | const dispatch = useAppDispatch(); 13 | 14 | useEffect(() => { 15 | if (keep.current) { 16 | keep.current = false; 17 | } else { 18 | setPath(''); 19 | setTitle(''); 20 | } 21 | }, [format]); 22 | 23 | function onDrop(e: any) { 24 | e.preventDefault(); 25 | if (!e.dataTransfer.files) return; 26 | const file = e.dataTransfer.files[0]; 27 | const parsedFormat = file.type.split('/')[0]; 28 | if (!['audio', 'video'].includes(parsedFormat)) { 29 | logseq.UI.showMsg('Not audio or video format', 'error'); 30 | return; 31 | } 32 | if (!file.path) { 33 | logseq.UI.showMsg('Unable to retrieve file path', 'error'); 34 | return; 35 | } 36 | setPath(file.path); 37 | console.log(file); 38 | const parsedTitle = (file.name !== '' ? file.name : file.path.replace(/^.*(\\|\/|\:)/, '')).split('.')[0]; 39 | setTitle(parsedTitle); 40 | if (parsedFormat != format) { 41 | keep.current = true; 42 | dispatch(formatSelected(parsedFormat)); 43 | } 44 | } 45 | 46 | let preview = <>; 47 | if (path) { 48 | preview = React.createElement(format, 49 | { controls: true, style: { width: 600, marginTop: '.5rem' }, src: path }); 50 | } 51 | 52 | return ( 53 |
54 |
55 |
59 | Drop file here 60 |
61 | setTitle(e.target.value)} 67 | /> 68 |
69 | {preview} 70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/import/ExtractPanel.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useAppDispatch, useAppSelector } from "../state/hooks"; 3 | import { intervalChanged, priorityChanged } from "./importSlice"; 4 | import PrioritySlider from "../widgets/PrioritySlider"; 5 | import Beta from "../algorithm/beta"; 6 | 7 | export default function ExtractPanel({ importThing }: { importThing: Function }) { 8 | const busy = useAppSelector(state => state.import.busy); 9 | const interval = useAppSelector(state => state.import.interval); 10 | const betaParams = useAppSelector(state => state.import.betaParams); 11 | const dispatch = useAppDispatch(); 12 | 13 | function update(priority: number) { 14 | dispatch(priorityChanged(priority)); 15 | } 16 | 17 | return ( 18 |
19 |

Priority

20 | 21 |
22 | 27 |
28 | 29 |

Interval

30 | dispatch(intervalChanged(parseFloat(e.target.value)))} 35 | min="1" 36 | step="1" 37 | > 38 | 39 |
40 | 41 | 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/import/HTMLUpload.tsx: -------------------------------------------------------------------------------- 1 | import { Readability } from "@mozilla/readability"; 2 | import React, { useMemo } from "react"; 3 | import { Article } from "./types"; 4 | import ExtractPanel from "./ExtractPanel"; 5 | import { importHtml } from "./importSlice"; 6 | import { useAppDispatch } from "../state/hooks"; 7 | import { setModalView } from "../state/viewSlice"; 8 | 9 | export default function HtmlUpload() { 10 | const [title, setTitle] = React.useState(''); 11 | const [article, setArticle] = React.useState
(null); 12 | const [readable, setReadable] = React.useState(false); 13 | const dispatch = useAppDispatch(); 14 | 15 | const html = useMemo(() => { 16 | if (!article) return; 17 | const content = readable ? article.readableContent : article.content; 18 | return content; 19 | }, [article, readable]); 20 | 21 | async function upload() { 22 | //@ts-ignore 23 | const [fileHandle] = await showOpenFilePicker(); 24 | if (!fileHandle || fileHandle.kind !== 'file') return; 25 | const file = await fileHandle.getFile() as File; 26 | if (file.type !== 'text/html') return; 27 | if (file.name) setTitle(file.name); 28 | const content = await file.text(); 29 | const parser = new DOMParser(); 30 | const doc = parser.parseFromString(content, 'text/html'); 31 | const article = new Readability(doc).parse(); 32 | setTitle(article.title); 33 | setArticle({ content, readableContent: article.content }); 34 | } 35 | 36 | async function importThing() { 37 | if (!html) return; 38 | const success = await dispatch(importHtml(title, html)); 39 | if (success) { 40 | dispatch(setModalView(null)); 41 | } 42 | } 43 | 44 | return ( 45 | <> 46 |
47 |
48 | 54 | setTitle(e.target.value)} 60 | /> 61 |
62 | {html && ( 63 |
64 |
65 | 73 | 74 |
75 | )} 76 |
77 | 78 |
79 | 80 | 81 | 82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /src/import/Import.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { capitalize } from "../utils/utils"; 3 | import YtUpload from "./YtUpload"; 4 | import HtmlUpload from "./HTMLUpload"; 5 | import DioUpload from "./DioUpload"; 6 | import { useAppDispatch, useAppSelector } from "../state/hooks"; 7 | import { formatSelected } from "./importSlice"; 8 | import { importFormats } from "./types"; 9 | 10 | export default function Import() { 11 | const busy = useAppSelector(state => state.import.busy); 12 | const format = useAppSelector(state => state.import.format); 13 | const dispatch = useAppDispatch(); 14 | 15 | function selectFormat(e: any) { 16 | dispatch(formatSelected(e.target.value)); 17 | } 18 | 19 | let uploadEl = <>; 20 | if (format == 'youtube') { 21 | uploadEl = ; 22 | } else if (format == 'html') { 23 | uploadEl = ; 24 | } else { 25 | uploadEl = ; 26 | } 27 | 28 | return ( 29 |
30 |

Import

31 | 32 |
33 |
34 | {importFormats.map(f => { 35 | return ( 36 | 46 | ); 47 | })} 48 |
49 | 50 | {uploadEl} 51 |
52 |
53 | ); 54 | } 55 | 56 | -------------------------------------------------------------------------------- /src/import/MedxImport.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from "react"; 2 | import { useDebounce } from "../utils/utils"; 3 | import { useAppDispatch } from "../state/hooks"; 4 | import { togglePopoverView } from "../state/viewSlice"; 5 | import { MediaFragment, renderFragment } from "../medx/MediaFragment"; 6 | 7 | // https://stackoverflow.com/a/27728417/2374668 8 | function parseYoutubeId(url: string) : string | null { 9 | const rx = /^.*(?:(?:youtu\.be\/|v\/|vi\/|u\/\w\/|embed\/|shorts\/)|(?:(?:watch)?\?v(?:i)?=|\&v(?:i)?=))([^#\&\?]*).*/; 10 | const match = url.match(rx); 11 | if (match && match?.length >= 2) return match[1]; 12 | return null; 13 | } 14 | 15 | export default function MedxImport() { 16 | const ref = React.useRef(null); 17 | const dispatch = useAppDispatch(); 18 | const [format, setFormat] = React.useState('audio'); 19 | const [url, setUrl] = React.useState(''); 20 | const debouncedUrl = useDebounce(url, 300); 21 | 22 | const mediaElement = useMemo(() => { 23 | if (!debouncedUrl) return <>; 24 | if (format == 'audio') { 25 | return ; 26 | } 27 | if (format == 'video') { 28 | return ; 29 | } 30 | if (format == 'youtube') { 31 | const id = parseYoutubeId(url); 32 | if (!id)

Unable to recognize youtube URL.

; 33 | return ; 36 | } 37 | }, [debouncedUrl, format]); 38 | 39 | function formatSelected(e: any) { 40 | setFormat(e.target.value); 41 | } 42 | 43 | function urlChanged(value: string) { 44 | setUrl(value); 45 | } 46 | 47 | function onDrop(e: any) { 48 | e.preventDefault(); 49 | 50 | const files = e.dataTransfer.files; 51 | if (files.length > 0) { 52 | // file.name, file.path 53 | setUrl(files[0].path); 54 | } 55 | } 56 | 57 | async function insert() { 58 | const url_ = format == 'youtube' ? (parseYoutubeId(url) ?? url) : url; 59 | console.log(url_, format); 60 | const args: MediaFragment = { 61 | url: url_, 62 | //@ts-ignore 63 | format, 64 | volume: 1, 65 | rate: 1, 66 | loop: false 67 | }; 68 | await logseq.Editor.insertAtEditingCursor(renderFragment(args)); 69 | dispatch(togglePopoverView({ view: null })); 70 | } 71 | 72 | return ( 73 |
78 | 88 | 89 |
90 |

91 | 95 | 99 | 103 |

104 | 105 | 111 |
112 | 113 |
114 | {mediaElement} 115 |
116 |
117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /src/import/YtUpload.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { getVideoDetails } from "youtube-caption-extractor"; 3 | 4 | // https://stackoverflow.com/a/27728417/2374668 5 | function parseYoutubeId(url: string) : string | null { 6 | const rx = /^.*(?:(?:youtu\.be\/|v\/|vi\/|u\/\w\/|embed\/|shorts\/)|(?:(?:watch)?\?v(?:i)?=|\&v(?:i)?=))([^#\&\?]*).*/; 7 | const match = url.match(rx); 8 | if (match && match?.length >= 2) return match[1]; 9 | return null; 10 | } 11 | 12 | export default function YtUpload() { 13 | const [url, setUrl] = React.useState(''); 14 | const [title, setTitle] = React.useState(''); 15 | const [id, setId] = React.useState(''); 16 | 17 | async function urlChanged(url: string) { 18 | setUrl(url); 19 | const id = parseYoutubeId(url); 20 | if (id) { 21 | setId(id); 22 | try { 23 | // TODO: maybe too slow as it fetches also subs 24 | const details = await getVideoDetails({ videoID: id }); 25 | if (!details.title) throw new Error(); 26 | setTitle(details.title); 27 | } catch (e) { 28 | logseq.UI.showMsg('Could not fetch video title', 'warning'); 29 | } 30 | } 31 | } 32 | 33 | let preview = <>; 34 | if (id) { 35 | preview = ( 36 | ; 69 | } 70 | return <>; 71 | } 72 | 73 | function parsePotentialFloat(value: any) : number | undefined { 74 | const float = parseFloat(value); 75 | if (Number.isNaN(float)) return undefined; 76 | return float; 77 | } 78 | 79 | export function parseFragmentProperties(properties: Record) : MediaFragment | null { 80 | const td = new TemporalDimension(`${properties['start']},${properties['end']}`); 81 | const start = parseFloat(td.s.toString()); 82 | const end = parseFloat(td.e.toString()); 83 | if (Number.isNaN(start) || Number.isNaN(end)) return null; 84 | const volume = parsePotentialFloat(properties['volume']); 85 | const rate = parsePotentialFloat(properties['rate']); 86 | let loop = properties['loop']; 87 | if (loop) { 88 | // Not sure if always read as boolean type or string 89 | loop = loop.toString() == 'true' 90 | ? true : (loop.toString() == 'false' ? false : undefined) 91 | } 92 | return { start, end, volume, rate, loop }; 93 | } 94 | 95 | export function parseSourceProperties(properties: Record) : MediaSource | null { 96 | if (!properties) return null; 97 | const url = properties['url']; 98 | const type = properties['media']; 99 | if (!(url && mediaTypes.includes(type))) return null; 100 | const fragment = parseFragmentProperties(properties); 101 | return { url, type, ...fragment }; 102 | } 103 | 104 | export function getTimedUrl(fragment: MediaSource) : string { 105 | let urlTimed = ''; 106 | if (fragment.type == 'youtube') { 107 | urlTimed = `https://www.youtube.com/embed/${fragment.url}?autoplay=0`; 108 | if (fragment.start) { 109 | urlTimed = `${urlTimed}&start=${fragment.start}`; 110 | } 111 | if (fragment.end) { 112 | urlTimed = `${urlTimed}&end=${fragment.end}`; 113 | } 114 | } else { 115 | const td = new TemporalDimension(); 116 | if (fragment.start) td.s = fragment.start; 117 | if (fragment.end) td.e = fragment.end; 118 | urlTimed = `${fragment.url}#t=${td.toString().replace('npt:', '')}`; 119 | } 120 | return urlTimed; 121 | } 122 | -------------------------------------------------------------------------------- /src/medx_old/ExtractionView.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useAppDispatch, useAppSelector } from "../state/hooks"; 3 | import { extract, intervalChanged, mediaAttrsChanged, noteChanged, priorityChanged } from "./medxSlice"; 4 | import Beta from "../algorithm/beta"; 5 | import PrioritySlider from "../widgets/PrioritySlider"; 6 | 7 | export default function ExtractionView() { 8 | return ( 9 |
10 |
11 | 12 | 13 |
14 | 15 |
16 | ); 17 | } 18 | 19 | function MediaAttributesPanel() { 20 | const dispatch = useAppDispatch(); 21 | const mediaAttrs = useAppSelector(state => state.medx.mediaAttrs); 22 | 23 | return ( 24 |
25 | 33 | 45 | 57 |
58 | ); 59 | } 60 | 61 | function NotePanel() { 62 | const ref = React.useRef(null); 63 | const dispatch = useAppDispatch(); 64 | const note = useAppSelector(state => state.medx.note); 65 | 66 | React.useEffect(() => { 67 | if (ref.current) ref.current.value = note; 68 | }, [note]); 69 | 70 | async function updateNote(text: string) { 71 | await new Promise(resolve => setTimeout(resolve, 500)); 72 | if (ref.current?.value == text) { 73 | dispatch(noteChanged(text)); 74 | } 75 | } 76 | 77 | return ( 78 |