├── .github └── workflows │ └── branch-tag-release.yml ├── .gitignore ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── BACKLOG.md ├── CHANGELOG.md ├── README.md ├── docs ├── alternative-installations.md ├── docker-compose-recipes │ ├── basic.yml │ ├── local-repo.yml │ ├── non-root-user.yml │ └── ssh-private-key.yml ├── editor-reference.md ├── haiku-language-reference.md ├── hosting-setup-guide.md ├── internal-ci-cd-guide.md ├── launch-shortcut.md ├── media │ └── osmosnote-square-badge.svg ├── storage-setup-guide.md └── wsl-setup-guide.md ├── package-lock.json ├── package.json └── packages ├── scripts ├── install.sh └── release.js ├── server ├── .env.example ├── .gitignore ├── Dockerfile ├── index.d.ts ├── main.js ├── package.json ├── public │ ├── .gitignore │ ├── favicon.ico │ ├── index.html │ ├── settings.html │ ├── src │ │ ├── components │ │ │ ├── command-bar │ │ │ │ ├── command-bar.component.ts │ │ │ │ ├── command-bar.css │ │ │ │ ├── command-tree.ts │ │ │ │ ├── commands │ │ │ │ │ ├── handle-file-delete.ts │ │ │ │ │ ├── handle-file-format.ts │ │ │ │ │ ├── handle-file-save-and-sync-all.ts │ │ │ │ │ ├── handle-file-save.ts │ │ │ │ │ ├── handle-file-sync-all.ts │ │ │ │ │ ├── handle-insert-note.ts │ │ │ │ │ ├── handle-insert-tags.ts │ │ │ │ │ ├── handle-link-to-note.ts │ │ │ │ │ ├── handle-open-note.ts │ │ │ │ │ ├── handle-open-settings.ts │ │ │ │ │ ├── handle-shutdown.ts │ │ │ │ │ ├── handle-toggle-spellcheck.ts │ │ │ │ │ └── parse-query.ts │ │ │ │ └── menu │ │ │ │ │ ├── menu-row.component.ts │ │ │ │ │ ├── menu-row.css │ │ │ │ │ └── render-menu.ts │ │ │ ├── reference-panel │ │ │ │ ├── reference-panel.component.ts │ │ │ │ └── reference-panel.css │ │ │ ├── settings-form │ │ │ │ ├── settings-form.component.ts │ │ │ │ └── settings-form.css │ │ │ ├── status-bar │ │ │ │ ├── status-bar.component.ts │ │ │ │ └── status-bar.css │ │ │ ├── tag-list │ │ │ │ └── tag-list.css │ │ │ └── text-editor │ │ │ │ ├── caret.service.ts │ │ │ │ ├── compiler │ │ │ │ ├── blank.ts │ │ │ │ ├── compile.service.ts │ │ │ │ ├── generic.ts │ │ │ │ ├── haiku-sample.osmos │ │ │ │ ├── haiku-spec.md │ │ │ │ ├── heading.ts │ │ │ │ ├── list.ts │ │ │ │ ├── meta.ts │ │ │ │ └── parse-inline-paragraph.ts │ │ │ │ ├── edit.service.ts │ │ │ │ ├── helpers │ │ │ │ ├── dom.ts │ │ │ │ ├── source-to-lines.ts │ │ │ │ ├── string.ts │ │ │ │ └── template.ts │ │ │ │ ├── history │ │ │ │ ├── history-stack.ts │ │ │ │ └── history.service.ts │ │ │ │ ├── input.service.ts │ │ │ │ ├── line-query.service.ts │ │ │ │ ├── measure.service.ts │ │ │ │ ├── sync.service.ts │ │ │ │ ├── text-editor.component.ts │ │ │ │ ├── text-editor.css │ │ │ │ └── track-change.service.ts │ │ ├── pages │ │ │ ├── home │ │ │ │ ├── home.css │ │ │ │ ├── index.css │ │ │ │ └── index.ts │ │ │ └── settings │ │ │ │ ├── index.css │ │ │ │ ├── index.ts │ │ │ │ └── settings.css │ │ ├── services │ │ │ ├── api │ │ │ │ └── api.service.ts │ │ │ ├── component-reference │ │ │ │ └── component-ref.service.ts │ │ │ ├── diagnostics │ │ │ │ └── diagnostics-service.ts │ │ │ ├── document-reference │ │ │ │ └── document.service.ts │ │ │ ├── notification │ │ │ │ └── notification.service.ts │ │ │ ├── preferences │ │ │ │ └── preferences.service.ts │ │ │ ├── query │ │ │ │ └── query.service.ts │ │ │ ├── remote │ │ │ │ ├── remote-client.service.ts │ │ │ │ └── remote-host.service.ts │ │ │ ├── route │ │ │ │ └── route.service.ts │ │ │ └── window-reference │ │ │ │ └── window.service.ts │ │ ├── styles │ │ │ ├── 01-settings.css │ │ │ ├── 02-tools.css │ │ │ ├── 03-generic.css │ │ │ ├── 04-elements.css │ │ │ └── 06-utils.css │ │ └── utils │ │ │ ├── clipboard.ts │ │ │ ├── dependency-injector.ts │ │ │ ├── ensure-note-title.ts │ │ │ ├── events.ts │ │ │ ├── get-overflow.ts │ │ │ ├── global-state-factory.ts │ │ │ ├── hyper.ts │ │ │ ├── keyboard.ts │ │ │ ├── sanitize-html.ts │ │ │ ├── scroll-into-view.ts │ │ │ ├── special-characters.ts │ │ │ ├── time.ts │ │ │ └── url.ts │ └── tsconfig.json ├── scripts │ ├── build-client.js │ └── build-server.js ├── src │ ├── lib │ │ ├── create-handler.ts │ │ ├── diagnostics.ts │ │ ├── exec-async.ts │ │ ├── filename-to-id.ts │ │ ├── get-env.ts │ │ ├── get-search-rank-score.ts │ │ ├── get-timestamp-id.ts │ │ ├── git.ts │ │ ├── id-to-filename.ts │ │ ├── note-file-io.ts │ │ ├── parse-note.ts │ │ ├── parse-page.ts │ │ ├── print.ts │ │ ├── repo-config.ts │ │ ├── repo-metadata.ts │ │ ├── run-shell.ts │ │ ├── tag.ts │ │ └── unique.ts │ ├── main.ts │ └── routes │ │ ├── create-note.ts │ │ ├── delete-note.ts │ │ ├── force-push.ts │ │ ├── get-content-from-url.ts │ │ ├── get-incoming-links.ts │ │ ├── get-note.ts │ │ ├── get-recent-notes.ts │ │ ├── get-recent-tags.ts │ │ ├── get-settings.ts │ │ ├── get-system-information.ts │ │ ├── get-version-status.ts │ │ ├── lookup-tags.ts │ │ ├── reset-local-version.ts │ │ ├── search-note.ts │ │ ├── set-git-remote.ts │ │ ├── shutdown.ts │ │ ├── sync-versions.ts │ │ ├── test-git-remote.ts │ │ └── update-note.ts └── tsconfig.json └── tools ├── bookmarklets ├── README.md └── capture-current-page.js ├── linux-launcher ├── README.md ├── app │ ├── osmosnote-favicon.png │ └── osmosnote.sh └── desktop │ ├── osmosnote-favicon.png │ └── osmosnote.desktop ├── markdown-converter ├── .gitignore ├── haiku-to-md.js ├── jsconfig.json ├── lib.js ├── lib.test.js ├── package-lock.json ├── package.json └── sync.sh └── wsl-launcher ├── s2-node.sh └── s2.bat /.github/workflows/branch-tag-release.yml: -------------------------------------------------------------------------------- 1 | name: Branch and tag based release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - "v*" 9 | 10 | jobs: 11 | build-and-publish: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Setup Node.js environment 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: 18.x 20 | registry-url: https://registry.npmjs.org/ 21 | - run: npm ci 22 | - run: npm run build 23 | - name: Publish to npm 24 | if: startsWith(github.ref, 'refs/tags/v') 25 | run: npm publish --access=public 26 | working-directory: packages/server 27 | env: 28 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 29 | - name: Login to Dockerhub 30 | if: startsWith(github.ref, 'refs/tags/v') 31 | uses: docker/login-action@v1 32 | with: 33 | username: ${{ secrets.DOCKERHUB_USERNAME }} 34 | password: ${{ secrets.DOCKERHUB_TOKEN }} 35 | - name: Build and push to Dockerhub 36 | if: startsWith(github.ref, 'refs/tags/v') 37 | id: docker_build 38 | uses: docker/build-push-action@v2 39 | with: 40 | context: packages/server 41 | push: true 42 | tags: osmoscraft/osmosnote:latest 43 | - name: Create Release 44 | if: startsWith(github.ref, 'refs/tags/v') 45 | id: create_release 46 | uses: actions/create-release@v1 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token 49 | with: 50 | tag_name: ${{ github.ref }} 51 | release_name: Release ${{ github.ref }} 52 | draft: true 53 | prerelease: false 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bin 3 | .env -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "tobermory.es6-string-html" 4 | ] 5 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug with sandbox", 6 | "request": "launch", 7 | "runtimeArgs": ["run-script", "dev"], 8 | "runtimeVersion": "18", 9 | "runtimeExecutable": "npm", 10 | "skipFiles": ["/**"], 11 | "type": "node" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } -------------------------------------------------------------------------------- /BACKLOG.md: -------------------------------------------------------------------------------- 1 | # Roadmap 2 | 3 | ## Bug backlog 4 | 5 | - Bug: When moving lines with Ctrl + up/down arrow, the window won't scroll up/down. 6 | - Bug: List movement is broken in 202104122000878 7 | - Bug: CJK input doesn't work on blank lines: consider using `
` for empty line 8 | - Bug: emoji (and other unicode characters) got split in caret movement. Consider using default arrow key handling. 9 | - Bug: Chinese character causes line height to jump. 10 | - Bug: Ctrl + c can't access clipboard in http mode. (legacy copy command causes focus lost) 11 | - Bug: scrollbar has no hover effect and a wrong cursor style 12 | - Bug: when spellcheck auto-fix, event type `insertReplacementText` won't trigger dirty state 13 | 14 | ## Code health 15 | 16 | - Parser testing 17 | - Test (need real dom: playwright/cypress) 18 | - How to mock dom measure? 19 | - Refactor command menu directives: 20 | - Port testing helpers to browser 21 | - Open url and insert on save should be refactored into two directives: 22 | - data-url (string) and data-action ("open"|"insert-on-save") 23 | - How does command bar and keyboard shortcut share code? 24 | - Command bar should own its own keyboard shortcut service. 25 | - Input service should focus on handling text editing inputs, not command. 26 | - Refactor git utilities. They are a mess 27 | - Redesign and refactor core API to improve scalability 28 | 29 | ## Workflow 30 | 31 | - Node package: ship a bin file and support `npx @osmoscraft/osmosnote`. 32 | - Workflow: Use single console logger with color, symbol, and verbosity control 33 | - Workflow: Fully preserver selection state after formatting 34 | - List auto indent fixing 35 | - UI managed metadata entry 36 | - Design interaction pattern 37 | - Push git step status to client from server 38 | - Search ranking algorithm is way off. Need to improve accuracy for literal match. 39 | - IDE: a deleted note should be marked as "Deleted" in status 40 | - Consider using orange border to indicate dirty document 41 | - A fraction of delay after entering any command that waits for server. 42 | - URL search needs debouncer. Invalid url blocks UI 43 | - Strong need to curate a list based on tags 44 | - Consider support shortcut to insert current time or date 45 | - Use History Service to track every keypress and use debouncer to improve performance 46 | - Customizable home page with blocks of queries 47 | - Note refactoring system: rename title, delete 48 | - Display per line dirty status in gutter 49 | - Add demo repo content to template repo 50 | - Backtick inline code snippet 51 | - Tag suggestion based on content 52 | - Link suggestion based on tags 53 | - After navigation back, scroll position is lost 54 | - Validate metadata on-save 55 | - alt + shift + arrow to increase/decrease selection scope: word, line, section, all 56 | - Unicode: emoji takes two arrow presses to skip over 57 | - Unicode: backspace on emoji splits the unicode 58 | - Unicode: when line is empty, typing with IME breaks line ending 59 | 60 | ## Compiler 61 | 62 | - Core: Use real anchor to represent links for improved a11y. Need to disable focus. 63 | - Control + left seems to greedy when to prev. line 64 | - Ctrl + delete is too agressive when handling white spaces 65 | - Heading and list item themselves cannot wrap with multi-row indent. 66 | - Move # into the padding so heading lines can wrap without breaking indentation 67 | - Simplied internal API 68 | - Efficiently convert DOM layer node and offset into plaintext layer offset 69 | - Incrementally read more lines while in plaintext layer 70 | - Efficiently convert plaintext layer offset into DOM layer node and offset 71 | - Embedded virtual blocks 72 | - Query block 73 | - Read-only query-driven notes 74 | - CJK compatibility mode: avoid visual travel 75 | - codify block quote symbol ">" 76 | - Compiler: Require space after link 77 | - When line ends with link, insert a new line at the end causes link to open 78 | - When document ends with link, you can't add a new line without adding a space after the last link 79 | - Core: Experiment: zero width space as line-end in UI while keeping `\n` in source. (Failed due to select indiciator becoming invisible on zero width space). 80 | - Use mutation observer to handle line update AFTER user enters the data. Only intercept events that could cause the line dom to change. Ref: https://medium.engineering/why-contenteditable-is-terrible-122d8a40e480 81 | 82 | ## Overarching issues 83 | 84 | - Difficult to decide between A link to B or B link to A. 85 | 86 | ## Project North star 87 | 88 | - CJK support (need pixel based column calc or unicode char visual length detection) 89 | - Display local menu next to caret 90 | - Live compiling for loading typescript extensions 91 | - Visualized graph traveling (node <-> link <-> node) 92 | - Eliminate manual linking via proximity detection and NLP pattern detection 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![image](./docs/media/osmosnote-square-badge.svg)](#get-started) 2 | 3 | # osmos::note 4 | 5 | A web-based text editor for networked note-taking, self-hostable on any Git repository. 6 | 7 | - Retrieve knowledge as fast as you can type with zero-latency full-text search. 8 | - Make serendipitous discovery via backlink traversal. 9 | - Durable knowledge preservation with plaintext and Git backend. 10 | - Keyboard-centeric design for max efficiency. 11 | - Easy theming and customziation with JavaScript and CSS (coming soon). 12 | 13 | Want a host-free alternative? Check out the sister project: [Tundra](https://github.com/osmoscraft/Tundra). 14 | 15 | ## Screenshot 16 | 17 | ![image](https://user-images.githubusercontent.com/1895289/116659117-ed0fb800-a945-11eb-9e97-c28eeaf29ab0.png) 18 | 19 | ## Get started 20 | 21 | ### With NPM (Linux, WSL, MacOS) 22 | 23 | Install dependencies 24 | - [Node.js](https://nodejs.org) (v18 or newer) 25 | - [Git](https://git-scm.com/downloads) 26 | - [ripgrep](https://github.com/BurntSushi/ripgrep#installation) 27 | 28 | Then run in terminal 29 | ``` 30 | npx @osmoscraft/osmosnote@latest 31 | ``` 32 | 33 | ### With Docker (All platforms) 34 | 35 | [Get Docker for your operating system](https://docs.docker.com/get-docker) 36 | 37 | ### Run the app 38 | 39 | ```sh 40 | docker run -p 6683:6683 osmoscraft/osmosnote 41 | ``` 42 | 43 | You can open the app in your browser, at [http://localhost:6683](http://localhost:6683). 44 | To exit, press Ctrl + Space, then press q. 45 | 46 |
47 | Having trouble exit? 48 |
docker ps # Find your container_id in the output
 49 | docker kill container_id # Manually stop the container
 50 | 
51 |
52 | 53 | ### Next steps 54 | 55 | When running from the container, you won't be able to persist any content after the container exits. Follow the [Storage setup guide](docs/storage-setup-guide.md) to persist your notes. 56 | 57 | ## Guides and references 58 | 59 | 1. [Storage setup guide](docs/storage-setup-guide.md) 60 | 2. [Git hosting setup guide](docs/hosting-setup-guide.md). 61 | 3. [Editor reference](docs/editor-reference.md). 62 | 4. [Haiku language reference](docs/haiku-language-reference.md). 63 | 5. [Knowledge capture guide](docs/knowledge-capture-guide.md). 64 | 65 | ## Supported browser 66 | 67 | - Chrome, Firefox are primary support targets. 68 | - Safari should work in theory. There is no guarantee. 69 | 70 | ## Roadmap 71 | 72 | This project is still in its early stage. Expect breaking changes and feature overhauls. Some ideas on top of my head: 73 | 74 | 1. **Theming**. Since we have web technology, supporting CSS based theming is a no-brainer. 75 | 2. **Customizable Text Editor**. I wrote my own text editor in order to optimize the UX for link insertion and indentation control. As a trade-off, the editor is not as customizable as other off-the-shelf solutions such as `CodeMirror` and `Monaco`. I will continue assess this trade-off and adopt open-source editor library as needed. Currently, a vim-like keybinding is supported with caveats. See [notes for vim users](https://github.com/osmoscraft/osmosnote/blob/master/docs/editor-reference.md#vim-users). 76 | 77 | ## Contributions 78 | 79 | My top priority is to modularize the system so I can tackle customization and theming without building technical debt. Until then, I have limited bandwidth for new features. Ideas and bug reports are welcome. I'll get to them as soon as I free up. Thank you for being patient with this project. 80 | 81 | ## Credits 82 | 83 | This project is inspired by all of the great text editors and note taking apps out there. You should check them out and see if they are better solutions for you specific needs: 84 | 85 | - [Notion](https://www.notion.so) 86 | - [Zettlelkasten](https://zettelkasten.de) 87 | - [Roam](https://roamresearch.com), [Foam](https://foambubble.github.io) 88 | - [Semilattice](https://www.semilattice.xyz) 89 | - [Emacs Org Mode](https://orgmode.org), [Org roam](https://github.com/org-roam/org-roam) 90 | - [Project Xanadu](https://www.xanadu.net) 91 | 92 | ## Ecosystem 93 | 94 | ## Ecosystem 95 | 96 | Browse other projects from the [OsmosCraft](https://osmoscraft.org/) ecosystem. 97 | 98 | - Read the web with [Fjord](https://github.com/osmoscraft/fjord) 99 | - Manage bookmarks with [Memo](https://github.com/osmoscraft/osmosmemo) 100 | - Take notes with [Tundra](https://github.com/osmoscraft/tundra) 101 | -------------------------------------------------------------------------------- /docs/alternative-installations.md: -------------------------------------------------------------------------------- 1 | # Alternative installations 2 | 3 | ## Node.js based 4 | 5 | ### Install dependencies 6 | 7 | 1. Choose your operating system: 8 | - Linux: primary support 9 | - Windows: support via [Windows Subsystem for Linux (WLS)](https://docs.microsoft.com/en-us/windows/wsl/) 10 | - MacOS: should work. Not tested. 11 | 2. Install depedendencies 12 | - [ripgrep](https://github.com/BurntSushi/ripgrep): used for full-text search. 13 | ```sh 14 | sudo sh -c "$(curl -fsSL https://raw.github.com/osmoscraft/osmosnote/master/packages/scripts/install.sh)" 15 | ``` 16 | (Don't trust any shell script from the Internet. [Audit the source](https://github.com/osmoscraft/osmosnote/blob/master/packages/scripts/install.sh) before you run.) 17 | - [xargs](https://man7.org/linux/man-pages/man1/xargs.1.html): used for search result parsing. Most linux distro comes with it. 18 | - [git](https://git-scm.com/): 2.28.0 minimum. used for storage and version control. Most linux distro comes with it. 19 | 3. Create an empty Git repository for storing notes. For easy start, clone from the template. 20 | 21 | ### Run 22 | 23 | ```sh 24 | npx @osmoscraft/osmosnote 25 | ``` 26 | 27 | 28 | -------------------------------------------------------------------------------- /docs/docker-compose-recipes/basic.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | osmosnote: 5 | environment: 6 | OSMOSNOTE_REPO_DIR: "/data" 7 | image: osmoscraft/osmosnote:latest 8 | volumes: 9 | - my-osmosnote-repo:/data 10 | ports: 11 | - 6683:6683 # : 12 | 13 | volumes: 14 | my-osmosnote-repo: 15 | external: false # `false` creates the volume if none exists. 16 | -------------------------------------------------------------------------------- /docs/docker-compose-recipes/local-repo.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | osmosnote: 5 | environment: 6 | OSMOSNOTE_REPO_DIR: "/data" 7 | image: osmoscraft/osmosnote:latest 8 | volumes: 9 | - :/data 10 | ports: 11 | - 6683:6683 # : 12 | -------------------------------------------------------------------------------- /docs/docker-compose-recipes/non-root-user.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | osmosnote: 5 | environment: 6 | OSMOSNOTE_REPO_DIR: "/data" 7 | image: osmoscraft/osmosnote:latest 8 | volumes: 9 | - my-osmosnote-repo:/data 10 | - /etc/passwd:/etc/passwd:ro 11 | - /etc/group:/etc/group:ro 12 | ports: 13 | - 6683:6683 # : 14 | user: 1000:1000 15 | 16 | volumes: 17 | my-osmosnote-repo: 18 | external: false # `false` creates the volume if none exists. 19 | -------------------------------------------------------------------------------- /docs/docker-compose-recipes/ssh-private-key.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | osmosnote: 5 | environment: 6 | OSMOSNOTE_REPO_DIR: "/data" 7 | image: osmoscraft/osmosnote:latest 8 | volumes: 9 | - my-osmosnote-repo:/data 10 | - /home//.ssh:/home//.ssh 11 | - /etc/passwd:/etc/passwd:ro 12 | - /etc/group:/etc/group:ro 13 | ports: 14 | - 6683:6683 # : 15 | user: 1000:1000 16 | 17 | volumes: 18 | my-osmosnote-repo: 19 | external: false # `false` creates the volume if none exists. 20 | -------------------------------------------------------------------------------- /docs/editor-reference.md: -------------------------------------------------------------------------------- 1 | # Navigation 2 | 3 | - Use keyboard arrow keys to move around. 4 | - Use Ctrl+ArrowLeft and Ctrl+ArrowRight to move by words. 5 | - Use Home and End to move to line start and line end. 6 | - Use PageUp/PageDown to jump by blocks. 7 | - When opening a different note, you can use browser Back/Forward button to navigate. By default, Alt+ArrowLeft goes back. Alt+ArrowRight goes forward. It may differ across browsers. 8 | - When you place cursor inside the `()` portion of a link, use Enter to open the link. 9 | 10 | # Using command bar 11 | 12 | - Use Ctrl+Space to open command bar. You can type the character in bracket to select/active an item. 13 | 14 | # Editing 15 | 16 | ## Text manipulation 17 | 18 | - Use Alt+ArrowUp and Alt+ArrowDown to move a line. 19 | - Use Alt+Shift+ArrowUp and Alt+Shift+ArrowDown to duplicate a line. 20 | - Use Alt+, and Alt+. to decrease/increase indentation. 21 | - All manipulations also support vim keys: H/J/K/L 22 | 23 | # Knowledge capture 24 | 25 | ## Create a new note 26 | 27 | ### Create by title 28 | 29 | 1. Use Ctrl+o (a shortcut for Ctrl+Space, o) to open command input. 30 | 2. Type in the name of note. 31 | 3. Press Enter to confirm. 32 | 33 | ### Create by url capture 34 | 35 | 1. Use Ctrl+o to open command input. 36 | 2. Paste the url you want to capture. 37 | 3. Press Enter to confirm. 38 | 39 | ## Search existing notes 40 | 41 | ### Full text search 42 | 43 | 1. Use Ctrl+o to open command input. 44 | 2. Type in search query. 45 | 3. Use ArrowUp and ArrowDown to select result. 46 | 4. Press Enter to confirm. If you don't select any result, a new note will be created with your query as the title. 47 | 48 | ### Tag search 49 | 50 | 1. Use Ctrl+o to open command input. 51 | 2. Type in your text query, followed by `-t`, then tags. The combined query looks like: ` -t , , `. 52 | 3. Use ArrowUp and ArrowDown to select result. 53 | 4. Press Enter to confirm. If you don't select any result, a new note will be created with your query as the title. 54 | 55 | ## Link to a note 56 | 57 | ### Add link to selected text 58 | 59 | 1. Select text (Shift+Arrow keys) 60 | 2. Use Ctrl+i (Shortcut for Ctrl+Space, k) to open command input. 61 | 3. Type in your query to search notes. 62 | 4. Use ArrowUp and ArrowDown to select result. 63 | 5. Press Enter to confirm. 64 | 6. If you don't select any result and press Enter, you will to taken to create a new note. 65 | 1. Edit the new note. Press Enter to save. You may close the newly saved note. 66 | 2. The previous note will automatically link to the newly created note. Remember to save it as well. 67 | 68 | ### Replace selected text with the title of a note and link to it 69 | 70 | 1. Select text. 71 | 2. Use Ctrl+Space, i to open command input. 72 | 3. Same steps as above. 73 | 74 | ## Add tags 75 | 76 | 1. Move the caret to the metadata line that starts with #+tags: 77 | 2. Place the caret as the end of line 78 | 3. Use Ctrl+Space, t to open command input. 79 | 4. Type in tag name. 80 | 5. Select a search result or add a new tag. 81 | 6. After adding one tag, the command input will re-open so you can quickly add more tags. 82 | 7. When you finished add all tags, press Escape to exit command input. 83 | 84 | # Chores 85 | 86 | ## Version management 87 | 88 | ### Save file locally 89 | 90 | - Use Ctrl+s (shortcut for Ctrl+Space, fs) to save the file you are editing. 91 | 92 | ### Sync with remote 93 | 94 | - Note this won't work until you configured Git hosting. 95 | - Use Ctrl+Shift+s (shortcut for Ctrl+Space, fa) to save the file and sync all files. 96 | 97 | ## Undo, redo 98 | 99 | - Use Ctrl+z to undo. 100 | - Use Ctrl+Shift+z to redo. 101 | 102 | ## Find on page 103 | 104 | - Use the browser find on page (Ctrl+F) feature. 105 | - Use Escape key to exit find on page mode. Your caret should select the matching phrase. 106 | 107 | # Vim users 108 | 109 | 1. In the short term, a vim-like keybinding is very limited: 110 | - Command bar movement with Ctrl + J and Ctrl + K 111 | - Editor block movement with Ctrl + [/] for block travel. Combine with Shift for selection 112 | 2. In the long term: 113 | - Allow custom key binding. 114 | - Support modal vim. 115 | -------------------------------------------------------------------------------- /docs/haiku-language-reference.md: -------------------------------------------------------------------------------- 1 | # Haiku language reference 2 | 3 | A simple language for knowledge capture. Inspired by Markdown and Emacs Org Mode. 4 | 5 | ## Syntax reference 6 | 7 | ### Heading 8 | 9 | An editor should support at least 6 levels of heading. 10 | 11 | ```haiku 12 | # Heading level 1 13 | ## Heading level 2 14 | ### Heading level 3 15 | #### Heading level 4 16 | ##### Heading level 5 17 | ###### Heading level 6 18 | ``` 19 | 20 | ### List 21 | 22 | ```haiku 23 | Ordered list: 24 | 1. Level 1 item 1 25 | -1. Level 2 item 1 26 | --1. Level 3 item 1 27 | --2. Level 3 item 2 28 | -2. Level 2 item 2 29 | 2. Level 1 item 2 30 | 31 | Unordered list: 32 | - Level 1 item 1 33 | -- Level 2 item 1 34 | --- Level 3 item 1 35 | --- Level 3 item 2 36 | -- Level 2 item 2 37 | - Level 1 item 2 38 | ``` 39 | 40 | ### Metadata 41 | 42 | ```haiku 43 | #+key1: value1 44 | #+key2: https://exampledomain.org/path/to/page 45 | #+key3: item1, item2, item3 46 | ``` 47 | 48 | ### Link 49 | 50 | ```haiku 51 | This is [an internal link](YYYYMMDDHHMMSSS). 52 | This is [an external link](https://exampledomain.org/path/to/page). 53 | 54 | This is a bare link https://exampledomain.org/path/to/page in the middle of a sentence. 55 | ``` 56 | -------------------------------------------------------------------------------- /docs/hosting-setup-guide.md: -------------------------------------------------------------------------------- 1 | # Hosting setup tutorial 2 | 3 | ## GitHub 4 | 5 | In this tutorial, you will create a new GitHub repository to host your notes. 6 | 7 | ### Create a repo 8 | 9 | 1. Navigate to [github.com/new](https://github.com/new). 10 | 2. Given the repo a name. 11 | 3. Choose "Private". (Choose "Public" if you want to open-source all of your notes). 12 | 4. Leave other options as default. Click "Create repository". 13 | 14 | ### Create personal access token 15 | 16 | 1. Navigate to [github.com/settings/tokens/new](https://github.com/settings/tokens/new). 17 | 2. Give the token a name. 18 | 3. Check the `repo` checkbox. Its children should be auto selected. 19 | 4. Leave otgher options as default. Click "Generate token". 20 | 5. Copy and save the token somewhere safe. You will not see it again once you leave the page. 21 | 22 | ### Initialize the repo for knowledge capture 23 | 24 | 1. Open a GitHub a 25 | 2. Use Ctrl+Space to open command input. Type ss to open settings page. 26 | 3. Fill in `Owner` field with your GitHub username. 27 | 4. Fill in `Repo` field with the name of the repo you just created. 28 | 5. Select `HTTPS` as your Network protocol. 29 | 6. Fill in the personal access token you just created. 30 | 7. Click "Test" to make sure your credentials are correct. 31 | 8. Click "Save connection" to connect and initialize the repo. 32 | 33 | ## Other hosting options 34 | 35 | 1. If you want to use SSH protocol, you need to make sure the SSH private keys are available on the container. See [Storage setup guide](./storage-setup-guide#mount-a-directory-with-ssh-private-keys) 36 | -------------------------------------------------------------------------------- /docs/internal-ci-cd-guide.md: -------------------------------------------------------------------------------- 1 | # To publish 2 | 3 | ## Update version 4 | ```sh 5 | # In root package 6 | npm version -w packages/server 7 | 8 | # Git commit and push 9 | ``` 10 | 11 | ## Tag and share to remote 12 | ```sh 13 | # Auto pick up version from package.json 14 | # In root package 15 | npm run release 16 | 17 | # OR, manually tag the version 18 | # In root package 19 | git tag v..-. 20 | git push origin vX.Y.Z 21 | ``` -------------------------------------------------------------------------------- /docs/launch-shortcut.md: -------------------------------------------------------------------------------- 1 | # Linux 2 | 3 | ## gnome desktop launcher 4 | 5 | Create the file `s2.desktop` in `~/.local/share/applications` with the content below 6 | 7 | ```desktop 8 | #!/usr/bin/env xdg-open 9 | [Desktop Entry] 10 | Version=1.0 11 | Name=osmosnote 12 | Comment=Run osmosnote as docker app 13 | Exec= 14 | Terminal=true 15 | Type=Application 16 | Categories=Application; 17 | ``` 18 | 19 | ## docker launch script 20 | ```sh 21 | #!/bin/sh 22 | docker run -p 6683:6683 \ 23 | -e "OSMOSNOTE_REPO_DIR=/data" \ 24 | -v /home//.ssh:/home//.ssh \ 25 | -v :/data \ 26 | -v /etc/passwd:/etc/passwd:ro \ 27 | -v /etc/group:/etc/group:ro \ 28 | -u 1000:1000 \ 29 | osmoscraft/osmosnote 30 | ``` 31 | 32 | ## node.js launch script 33 | ```sh 34 | #!/bin/sh 35 | export OSMOSNOTE_REPO_DIR= 36 | export OSMOSNOTE_SERVER_PORT=6683 37 | npx @osmoscraft/osmosnote 38 | ``` 39 | -------------------------------------------------------------------------------- /docs/media/osmosnote-square-badge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/storage-setup-guide.md: -------------------------------------------------------------------------------- 1 | # Storage setup guide 2 | 3 | ## Basic setup 4 | 5 | By default the app expects you to mount a repository to the `//.osmosnote/repo` path inside the container. 6 | And the default user is `root`. 7 | 8 | ```sh 9 | docker run -p 6683:6683 -v :/root/.osmosnote/repo osmoscraft/osmosnote 10 | ``` 11 | 12 | ## Mount a directory with non-root user 13 | 14 | If you want to use a non-root user to create and update the notes: 15 | 16 | - You need to mount to a directory where the non-root user has permission to modify. e.g. `/data` 17 | - You need to pass user information into the container, including `/etc/passwd` and `/etc/group` 18 | 19 | ```sh 20 | docker run -p 6683:6683 \ 21 | -e "OSMOSNOTE_REPO_DIR=/data" \ 22 | -v :/data \ 23 | -v /etc/passwd:/etc/passwd:ro \ 24 | -v /etc/group:/etc/group:ro \ 25 | -u 1000:1000 \ 26 | osmoscraft/osmosnote 27 | ``` 28 | 29 | ## Mount a directory with SSH private keys 30 | 31 | If you want to connect to Git host using SSH instead of HTTPS protocol: 32 | 33 | - You need to mount your the host machine's SSH private keys into the container, which is usually located in `/home//.ssh`. 34 | 35 | ```sh 36 | docker run -p 6683:6683 \ 37 | -e "OSMOSNOTE_REPO_DIR=/data" \ 38 | -v /home//.ssh:/home//.ssh \ 39 | -v :/data \ 40 | -v /etc/passwd:/etc/passwd:ro \ 41 | -v /etc/group:/etc/group:ro \ 42 | -u 1000:1000 \ 43 | osmoscraft/osmosnote 44 | ``` 45 | 46 | ## Next steps 47 | 48 | - Learn how to back up your notes from [Hosting setup guide](./hosting-setup-guide.md). 49 | -------------------------------------------------------------------------------- /docs/wsl-setup-guide.md: -------------------------------------------------------------------------------- 1 | # WSL setup 2 | 3 | - Make sure the repo has proper git permissions. If your org has 2FA, make sure you can access the repo from WSL, not from Windows. This might mean you have you clone it from the SSH remote url, instead of HTTPS. 4 | 5 | - Known network performance issue 6 | 7 | ## WSL setup 8 | 9 | - In a Windows directory, create a vbs script that launches the server in WSL (See wsl launcher pacakge) 10 | - Create a shortcut to wscript.exe, in the target field, use `C:\Windows\System32\wscript.exe "PATH_TO_THE_VBS_SCRIPT"`. 11 | 12 | ### WLS network issue 13 | 14 | - Device manager > Network adapters > Hyber-V Virtual Ethernet Adapter > Large Send Offload Version 2 > Set it to "Disabled" 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "", 6 | "devDependencies": { 7 | "concurrently": "^7.5.0", 8 | "tsx": "^3.6.0" 9 | }, 10 | "scripts": { 11 | "dev": "npm run dev --prefix packages/server", 12 | "serve": "npm run serve --prefix packages/server", 13 | "clean": "npm run clean --prefix packages/server", 14 | "build": "npm run build --prefix packages/server", 15 | "release": "node packages/scripts/release.js" 16 | }, 17 | "workspaces": [ 18 | "packages/*" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /packages/scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | wget -O ripgrep.deb https://github.com/BurntSushi/ripgrep/releases/download/13.0.0/ripgrep_13.0.0_amd64.deb 4 | dpkg -i ripgrep.deb 5 | -------------------------------------------------------------------------------- /packages/scripts/release.js: -------------------------------------------------------------------------------- 1 | // Create a tag using the current server package version and push the tag to origin 2 | const { version } = require("../../packages/server/package.json"); 3 | const { exec } = require("child_process"); 4 | const { exit } = require("process"); 5 | 6 | release(); 7 | 8 | async function release() { 9 | console.log(`[tag] clean up v${version}`); 10 | const { error: cleanError, stderr: cleanStderr } = await runShell(`git tag -d v${version}`, { dir: __dirname }); 11 | if (cleanError || cleanStderr) { 12 | console.log(`[tag] nothing to clean up, continue`); 13 | } 14 | 15 | console.log(`[tag] add v${version}`); 16 | const { error: addError, stderr: addStderr } = await runShell(`git tag v${version}`, { dir: __dirname }); 17 | if (addError || addStderr) { 18 | console.error("[tag] add failed"); 19 | console.error("[tag] error code", addError?.code); 20 | console.error("[tag] error msg", addStderr); 21 | exit(1); 22 | } 23 | 24 | console.log(`[tag] push v${version}`); 25 | const { 26 | stdout: pushStdout, 27 | error: pushError, 28 | stderr: pushStderr, 29 | } = await runShell(`git push origin v${version}`, { dir: __dirname }); 30 | if (pushError) { 31 | // Git writes to stderr even when successful 32 | console.error("[tag] push failed"); 33 | console.error("[tag] error code", pushError?.code); 34 | console.error("[tag] error msg", pushStderr); 35 | exit(1); 36 | } 37 | 38 | console.log(pushStderr ?? pushStdout ?? "No output. Something might be wrong."); 39 | } 40 | 41 | async function runShell(command, options) { 42 | return new Promise((resolve) => { 43 | exec(command, options, (error, stdout, stderr) => 44 | resolve({ 45 | error, 46 | stdout, 47 | stderr, 48 | }) 49 | ); 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /packages/server/.env.example: -------------------------------------------------------------------------------- 1 | OSMOSNOTE_REPO_DIR=/absolute/path/to/repo/dir -------------------------------------------------------------------------------- /packages/server/.gitignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /packages/server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18-alpine 2 | RUN apk add --no-cache findutils 3 | RUN apk add --no-cache git 4 | RUN apk add --no-cache openssh-client 5 | RUN apk add --no-cache ripgrep 6 | ADD ./main.js /usr/local/bin/osmosnote/ 7 | ADD ./dist /usr/local/bin/osmosnote/dist/ 8 | ADD ./public /usr/local/bin/osmosnote/public/ 9 | EXPOSE 6683 10 | CMD [ "node", "/usr/local/bin/osmosnote/main.js"] 11 | # ENTRYPOINT ["sh"] 12 | # CMD ["s2"] -------------------------------------------------------------------------------- /packages/server/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from "./src/lib/create-handler"; 2 | export * from "./src/routes/create-note"; 3 | export * from "./src/routes/delete-note"; 4 | export * from "./src/routes/force-push"; 5 | export * from "./src/routes/get-content-from-url"; 6 | export * from "./src/routes/get-incoming-links"; 7 | export * from "./src/routes/get-note"; 8 | export * from "./src/routes/get-recent-notes"; 9 | export * from "./src/routes/get-recent-tags"; 10 | export * from "./src/routes/get-settings"; 11 | export * from "./src/routes/get-system-information"; 12 | export * from "./src/routes/get-version-status"; 13 | export * from "./src/routes/lookup-tags"; 14 | export * from "./src/routes/reset-local-version"; 15 | export * from "./src/routes/search-note"; 16 | export * from "./src/routes/set-git-remote"; 17 | export * from "./src/routes/shutdown"; 18 | export * from "./src/routes/sync-versions"; 19 | export * from "./src/routes/test-git-remote"; 20 | export * from "./src/routes/update-note"; 21 | -------------------------------------------------------------------------------- /packages/server/main.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require("./dist/main"); -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@osmoscraft/osmosnote", 3 | "version": "1.0.0-alpha.25", 4 | "description": "", 5 | "main": "main.js", 6 | "types": "index.d.ts", 7 | "scripts": { 8 | "dev": "concurrently npm:dev:*", 9 | "dev:client": "node scripts/build-client.js --watch", 10 | "dev:server": "tsx watch --clear-screen=false src/main.ts", 11 | "serve": "node main.js", 12 | "build": "npm run clean && concurrently npm:build:* && npm run pack:docker", 13 | "build:server": "node scripts/build-server.js", 14 | "build:client": "node scripts/build-client.js", 15 | "pack:docker": "docker build -t osmoscraft/osmosnote:latest .", 16 | "clean": "concurrently npm:clean:*", 17 | "clean:server": "rm -rf dist && rm -rf bin", 18 | "clean:client": "rm -rf public/dist" 19 | }, 20 | "bin": { 21 | "osmosnote": "main.js" 22 | }, 23 | "engines": { 24 | "node": "^18" 25 | }, 26 | "files": [ 27 | "src/**/*", 28 | "dist/**/*", 29 | "public/**/*" 30 | ], 31 | "repository": { 32 | "type": "git", 33 | "url": "git+https://github.com/osmoscraft/osmosnote.git" 34 | }, 35 | "keywords": [], 36 | "author": "osmoscraft", 37 | "license": "MIT", 38 | "bugs": { 39 | "url": "https://github.com/osmoscraft/osmosnote/issues" 40 | }, 41 | "homepage": "https://github.com/osmoscraft/osmosnote#readme", 42 | "dependencies": { 43 | "@fastify/static": "^6.5.0", 44 | "cheerio": "^1.0.0-rc.12", 45 | "dotenv": "^16.0.3", 46 | "fastify": "^4.10.0" 47 | }, 48 | "devDependencies": { 49 | "@types/node": "^18.11.9", 50 | "esbuild": "^0.15.14", 51 | "prettier": "^2.7.1", 52 | "typescript": "^4.9.3" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/server/public/.gitignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /packages/server/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmoscraft/osmosnote/0fd06c6aecea60cf219da016f33f35c48541b792/packages/server/public/favicon.ico -------------------------------------------------------------------------------- /packages/server/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Note 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /packages/server/public/settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Settings 9 | 10 | 11 | 12 | 13 |
14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /packages/server/public/src/components/command-bar/command-bar.css: -------------------------------------------------------------------------------- 1 | @import url("./menu/menu-row.css"); 2 | 3 | s2-command-bar { 4 | --command-bar-height: 32px; 5 | 6 | height: var(--command-bar-height); 7 | opacity: 0; 8 | pointer-events: none; 9 | } 10 | 11 | s2-command-bar[data-active] { 12 | opacity: 1; 13 | pointer-events: auto; 14 | } 15 | 16 | .cmdbr-input { 17 | width: 100%; 18 | height: 32px; 19 | background-color: var(--base01); 20 | color: var(--base06); 21 | border: none; 22 | outline: none; 23 | font-family: monospace; 24 | padding: 0 16px; 25 | border-radius: 0px; 26 | } 27 | 28 | .cmdbr-input:focus { 29 | background-color: var(--theme-background-active); 30 | outline: 1px solid var(--theme-outline-color); 31 | outline-offset: -1px; 32 | } 33 | 34 | .cmdbr-dropdown { 35 | --theme-scrollbar-background: var(--theme-menu-option-background-rest); 36 | /* options never receive real focus. force it to adopt focus command bar style */ 37 | --theme-scrollbar-blur-thumb-rest: var(--theme-scrollbar-focus-thumb-rest); 38 | --theme-scrollbar-blur-thumb-hover: var(--theme-scrollbar-focus-thumb-hover); 39 | 40 | margin: 0; 41 | 42 | display: grid; 43 | max-height: calc(100vh - var(--command-bar-height)); 44 | overflow: auto; 45 | } 46 | 47 | .cmdbr-dropdown:empty { 48 | display: none; 49 | } 50 | 51 | .cmdbr-dropdown-row { 52 | background-color: var(--theme-menu-option-background-rest); 53 | font-family: monospace; 54 | height: 32px; 55 | padding: 0 16px; 56 | align-items: center; 57 | display: flex; 58 | } 59 | 60 | .cmdbr-dropdown-row--header { 61 | color: var(--base0A); 62 | font-weight: bold; 63 | } 64 | 65 | .cmdbr-dropdown-row--message { 66 | color: var(--theme-text-color-secondary); 67 | } 68 | 69 | .cmdbr-dropdown-row--btn { 70 | color: var(--theme-text-color); 71 | border: none; 72 | text-align: start; 73 | outline: none; 74 | border-radius: 0px; 75 | } 76 | 77 | .cmdbr-dropdown-row--btn[data-active] { 78 | font-weight: bold; 79 | color: var(--theme-text-color-knockout); 80 | background-color: var(--theme-menu-option-background-active); 81 | } 82 | -------------------------------------------------------------------------------- /packages/server/public/src/components/command-bar/command-tree.ts: -------------------------------------------------------------------------------- 1 | import type { RegisteredCommand } from "./command-bar.component.js"; 2 | import { handleFileDelete } from "./commands/handle-file-delete.js"; 3 | import { handleFileFormat } from "./commands/handle-file-format.js"; 4 | import { handleFileSaveAndSyncAll } from "./commands/handle-file-save-and-sync-all.js"; 5 | import { handleFileSave } from "./commands/handle-file-save.js"; 6 | import { handleFileSyncAll } from "./commands/handle-file-sync-all.js"; 7 | import { handleInsertNote } from "./commands/handle-insert-note.js"; 8 | import { handleInsertTags } from "./commands/handle-insert-tags.js"; 9 | import { handleLinkToNote } from "./commands/handle-link-to-note.js"; 10 | import { handleOpenOrCreateNote } from "./commands/handle-open-note.js"; 11 | import { handleOpenSettings } from "./commands/handle-open-settings.js"; 12 | import { handleShutdown } from "./commands/handle-shutdown.js"; 13 | import { handleToggleSpellcheck } from "./commands/handle-toggle-spellcheck.js"; 14 | 15 | export const commandTree: RegisteredCommand = { 16 | name: "All commands", 17 | key: "", 18 | commands: [ 19 | { 20 | name: "Open or new note", 21 | key: "o", 22 | handler: handleOpenOrCreateNote, 23 | }, 24 | { 25 | name: "Insert note", 26 | key: "i", 27 | handler: handleInsertNote, 28 | }, 29 | { 30 | name: "Add tag", 31 | key: "t", 32 | handler: handleInsertTags, 33 | }, 34 | { 35 | name: "Link to note", 36 | key: "k", 37 | handler: handleLinkToNote, 38 | }, 39 | { 40 | name: "File", 41 | key: "f", 42 | commands: [ 43 | { 44 | name: "Save", 45 | key: "s", 46 | handler: handleFileSave, 47 | }, 48 | { 49 | name: "Sync", 50 | key: "y", 51 | handler: handleFileSyncAll, 52 | }, 53 | { 54 | name: "Save and sync all", 55 | key: "a", 56 | handler: handleFileSaveAndSyncAll, 57 | }, 58 | { 59 | name: "Delete", 60 | key: "q", 61 | handler: handleFileDelete, 62 | }, 63 | { 64 | name: "Format", 65 | key: "f", 66 | handler: handleFileFormat, 67 | }, 68 | ], 69 | }, 70 | { 71 | name: "Settings", 72 | key: "s", 73 | commands: [ 74 | { 75 | name: "Open settings page", 76 | key: "s", 77 | handler: handleOpenSettings, 78 | }, 79 | { 80 | name: "Toggle spellcheck", 81 | key: "l", 82 | handler: handleToggleSpellcheck, 83 | }, 84 | ], 85 | }, 86 | { 87 | name: "Shutdown", 88 | key: "q", 89 | handler: handleShutdown, 90 | }, 91 | ], 92 | }; 93 | -------------------------------------------------------------------------------- /packages/server/public/src/components/command-bar/commands/handle-file-delete.ts: -------------------------------------------------------------------------------- 1 | import type { CommandHandler, CommandHandlerContext } from "../command-bar.component.js"; 2 | 3 | export const handleFileDelete: CommandHandler = async ({ context }) => ({ 4 | runOnMatch: async () => { 5 | const { id } = context.routeService.getNoteConfigFromUrl(); 6 | if (id) { 7 | try { 8 | const data = await context.apiService.getIncomingLinks(id); 9 | if (data.incomingLinks.length) { 10 | const isConfirmed = context.windowRef.window.confirm( 11 | `This note is referenced by other notes. Are you sure you want to delete it?\n\nNotes that are referencing this note:\n` + 12 | data.incomingLinks.map((item) => item.title).join("\n") 13 | ); 14 | 15 | if (!isConfirmed) { 16 | context.notificationService.displayMessage("Delete canceled", "info"); 17 | return; 18 | } 19 | } 20 | 21 | await context.apiService.deleteNote(id); 22 | await context.syncService.syncAllFileVersions(); 23 | context.notificationService.displayMessage("Note deleted", "warning"); 24 | } catch (error) { 25 | console.error(error); 26 | context.notificationService.displayMessage("Delete note failed.", "error"); 27 | } 28 | } 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /packages/server/public/src/components/command-bar/commands/handle-file-format.ts: -------------------------------------------------------------------------------- 1 | import type { CommandHandler } from "../command-bar.component.js"; 2 | 3 | export const handleFileFormat: CommandHandler = async ({ context }) => ({ 4 | runOnMatch: async () => { 5 | await context.formatService.compile(context.componentRefs.textEditor.host); 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /packages/server/public/src/components/command-bar/commands/handle-file-save-and-sync-all.ts: -------------------------------------------------------------------------------- 1 | import type { CommandHandler } from "../command-bar.component.js"; 2 | 3 | export const handleFileSaveAndSyncAll: CommandHandler = async ({ context }) => ({ 4 | runOnMatch: async () => { 5 | if (context.trackChangeService.isDirty() || context.trackChangeService.isNew()) { 6 | await context.syncService.saveFile(); 7 | } 8 | await context.syncService.syncAllFileVersions(); 9 | await context.syncService.checkAllFileVersions(); 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/server/public/src/components/command-bar/commands/handle-file-save.ts: -------------------------------------------------------------------------------- 1 | import type { CommandHandler, CommandHandlerContext } from "../command-bar.component.js"; 2 | 3 | export const handleFileSave: CommandHandler = async ({ context }) => ({ 4 | runOnMatch: async () => { 5 | if (context.trackChangeService.isDirty() || context.trackChangeService.isNew()) { 6 | await context.syncService.saveFile(); 7 | } 8 | await context.syncService.checkAllFileVersions(); 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /packages/server/public/src/components/command-bar/commands/handle-file-sync-all.ts: -------------------------------------------------------------------------------- 1 | import type { CommandHandler } from "../command-bar.component.js"; 2 | 3 | export const handleFileSyncAll: CommandHandler = async ({ context }) => ({ 4 | runOnMatch: async () => context.syncService.syncAllFileVersions(), 5 | }); 6 | -------------------------------------------------------------------------------- /packages/server/public/src/components/command-bar/commands/handle-insert-note.ts: -------------------------------------------------------------------------------- 1 | import { ensureNoteTitle } from "../../../utils/ensure-note-title.js"; 2 | import { findUrl, getUrlWithSearchParams } from "../../../utils/url.js"; 3 | import type { CommandHandler } from "../command-bar.component.js"; 4 | import { PayloadAction } from "../menu/menu-row.component.js"; 5 | import { 6 | renderCrawlResult, 7 | renderHeaderRow, 8 | renderMessageRow, 9 | renderNoteWithUrl, 10 | renderRecentNotes, 11 | renderSearchResultSection, 12 | } from "../menu/render-menu.js"; 13 | import { parseQuery } from "./parse-query.js"; 14 | 15 | export const handleInsertNote: CommandHandler = async ({ input, context }) => { 16 | const selectedText = context.componentRefs.textEditor.getSelectedText()?.trim(); 17 | 18 | const query = input.args?.trim() ?? ""; 19 | 20 | const { phrase, tags } = parseQuery(query); 21 | const targetUrl = findUrl(phrase); 22 | 23 | const newNoteTitle = ensureNoteTitle(phrase); 24 | 25 | const newNoteUrl = getUrlWithSearchParams("/", { 26 | url: targetUrl, 27 | title: newNoteTitle, 28 | }); 29 | 30 | return { 31 | updateDropdownOnInput: async () => { 32 | let optionsHtml = ""; 33 | 34 | if (!phrase?.length && !tags.length) { 35 | // Blank input 36 | 37 | optionsHtml += renderMessageRow("Type keywords or URL"); 38 | 39 | const recentNotesAsync = context.apiService.getRecentNotes(selectedText ? 5 : 10); 40 | const foundNotesAsync = selectedText ? context.apiService.searchNotes(selectedText) : null; 41 | 42 | // Show recent notes 43 | try { 44 | const recentNotes = await recentNotesAsync; 45 | optionsHtml += renderRecentNotes("Insert recent", recentNotes, PayloadAction.insertText); 46 | } catch (error) { 47 | optionsHtml += renderMessageRow("Error loading recent notes"); 48 | } 49 | 50 | // Search notes based on selection 51 | if (foundNotesAsync) { 52 | try { 53 | const foundNotes = await foundNotesAsync; 54 | optionsHtml += renderSearchResultSection("Insert search result", foundNotes, PayloadAction.insertText); 55 | } catch (error) { 56 | optionsHtml += renderMessageRow("Error searching notes"); 57 | } 58 | } 59 | } else { 60 | optionsHtml = renderHeaderRow("Insert new"); 61 | 62 | // Start search first for parallelism 63 | const notesAsync = context.apiService.searchNotes(phrase, tags); 64 | 65 | // URL crawl result 66 | if (targetUrl) { 67 | try { 68 | const urlContent = await context.apiService.getContentFromUrl(targetUrl); 69 | optionsHtml += renderCrawlResult(urlContent, PayloadAction.insertNewNoteByUrl); 70 | } catch (error) { 71 | console.error(error); 72 | optionsHtml += renderMessageRow("Error visiting URL"); 73 | } 74 | } 75 | 76 | // Raw new note 77 | optionsHtml += renderNoteWithUrl(newNoteUrl, newNoteTitle, PayloadAction.insertNewNoteByUrl); 78 | 79 | // Search result 80 | try { 81 | const notes = await notesAsync; 82 | optionsHtml += renderSearchResultSection("Insert search result", notes, PayloadAction.insertText); 83 | } catch (error) { 84 | optionsHtml += renderMessageRow("Error searching notes"); 85 | } 86 | } 87 | 88 | return optionsHtml; 89 | }, 90 | runOnCommit: () => { 91 | // treating input as title to create a new note 92 | context.componentRefs.textEditor.insertNoteLinkOnSave(newNoteUrl); 93 | }, 94 | }; 95 | }; 96 | -------------------------------------------------------------------------------- /packages/server/public/src/components/command-bar/commands/handle-insert-tags.ts: -------------------------------------------------------------------------------- 1 | import type { CommandHandler } from "../command-bar.component.js"; 2 | import { renderHeaderRow, renderMessageRow } from "../menu/render-menu.js"; 3 | 4 | export const handleInsertTags: CommandHandler = async ({ input, context }) => { 5 | const phrase = input.args?.trim() ?? ""; 6 | 7 | const searchParams = new URLSearchParams(); 8 | phrase && searchParams.set("title", phrase); 9 | 10 | return { 11 | updateDropdownOnInput: async () => { 12 | let optionsHtml = renderHeaderRow("Insert tags"); 13 | 14 | if (!phrase?.length) { 15 | try { 16 | const recentTagsOutput = await context.apiService.getRecentTags(); 17 | optionsHtml += recentTagsOutput.tags 18 | .map( 19 | (item) => 20 | /*html*/ `` 21 | ) 22 | .join(""); 23 | } catch (error) { 24 | optionsHtml += renderMessageRow("Error loading recent tags"); 25 | } 26 | } else { 27 | try { 28 | const lookupTagsOutput = await context.apiService.lookupTags(phrase); 29 | optionsHtml += lookupTagsOutput.tags 30 | .map( 31 | (item) => 32 | /*html*/ `` 33 | ) 34 | .join(""); 35 | } catch (error) { 36 | optionsHtml += renderMessageRow("Error searching notes"); 37 | } 38 | } 39 | 40 | return optionsHtml; 41 | }, 42 | repeatableRunOnCommit: () => { 43 | context.componentRefs.textEditor.insertAtCaretWithContext((context) => { 44 | if (context.textBeforeRaw.endsWith(":") || context.textBeforeRaw.endsWith(",")) { 45 | // "#+tags:" or "tag," 46 | return ` ${phrase}`; 47 | } else if (context.textBeforeRaw.endsWith(": ") || context.textBeforeRaw.endsWith(", ")) { 48 | // "#+tags: ", or "tag, " 49 | return phrase; 50 | } else if (!context.textBeforeRaw.trim().length) { 51 | // Empty line 52 | return `#+tags: ${phrase}`; 53 | } else { 54 | return `, ${phrase}`; 55 | } 56 | }); 57 | context.componentRefs.statusBar.setMessage(`inserted "${phrase}"`); 58 | }, 59 | }; 60 | }; 61 | -------------------------------------------------------------------------------- /packages/server/public/src/components/command-bar/commands/handle-link-to-note.ts: -------------------------------------------------------------------------------- 1 | import { ensureNoteTitle } from "../../../utils/ensure-note-title.js"; 2 | import { findUrl, getUrlWithSearchParams } from "../../../utils/url.js"; 3 | import type { CommandHandler } from "../command-bar.component.js"; 4 | import { PayloadAction } from "../menu/menu-row.component.js"; 5 | import { 6 | renderCrawlResult, 7 | renderHeaderRow, 8 | renderMessageRow, 9 | renderNoteWithUrl, 10 | renderRecentNotes, 11 | renderSearchResultSection, 12 | } from "../menu/render-menu.js"; 13 | import { handleInsertNote } from "./handle-insert-note.js"; 14 | import { parseQuery } from "./parse-query.js"; 15 | 16 | export const handleLinkToNote: CommandHandler = async ({ input, context }) => { 17 | const selectedText = context.componentRefs.textEditor.getSelectedText()?.trim(); 18 | 19 | // Same behavior as insert when there is no selection 20 | if (!selectedText) return handleInsertNote({ input, context }); 21 | 22 | const query = input.args?.trim() ?? ""; 23 | const { phrase, tags } = parseQuery(query); 24 | const targetUrl = findUrl(phrase); 25 | const newNoteTitle = ensureNoteTitle(phrase); 26 | 27 | return { 28 | updateDropdownOnInput: async () => { 29 | let optionsHtml = ""; 30 | 31 | if (!phrase?.length && !tags.length) { 32 | optionsHtml += renderMessageRow("Type keywords or URL"); 33 | 34 | // kick off network requests in parallel 35 | const recentNotesAsync = context.apiService.getRecentNotes(5); 36 | const foundNotesAsync = context.apiService.searchNotes(selectedText); 37 | 38 | // Blank input, show recent notes AND search using selected text 39 | try { 40 | const recentNotes = await recentNotesAsync; 41 | optionsHtml += renderRecentNotes("Link to recent", recentNotes, PayloadAction.linkToNoteById); 42 | } catch (error) { 43 | optionsHtml += renderMessageRow("Error loading recent notes"); 44 | } 45 | 46 | try { 47 | const foundNotes = await foundNotesAsync; 48 | optionsHtml += renderSearchResultSection("Link to search result", foundNotes, PayloadAction.linkToNoteById); 49 | } catch (error) { 50 | optionsHtml += renderMessageRow("Error searching notes"); 51 | } 52 | } else { 53 | optionsHtml += renderHeaderRow("Link to new"); 54 | 55 | // Start search first for parallelism 56 | const notesAsync = context.apiService.searchNotes(phrase, tags); 57 | 58 | // URL crawl result 59 | if (targetUrl) { 60 | try { 61 | const urlContent = await context.apiService.getContentFromUrl(targetUrl); 62 | optionsHtml += renderCrawlResult(urlContent, PayloadAction.linkToNewNoteByUrl); 63 | } catch (error) { 64 | console.error(error); 65 | optionsHtml += renderMessageRow("Error visiting URL"); 66 | } 67 | } 68 | 69 | // Raw new note 70 | const newNoteUrl = getUrlWithSearchParams(`/`, { 71 | url: targetUrl, 72 | title: newNoteTitle, 73 | }); 74 | 75 | optionsHtml += renderNoteWithUrl(newNoteUrl, newNoteTitle, PayloadAction.linkToNewNoteByUrl); 76 | 77 | // Search result 78 | try { 79 | const notes = await notesAsync; 80 | optionsHtml += renderSearchResultSection("Link to search result", notes, PayloadAction.linkToNoteById); 81 | } catch (error) { 82 | optionsHtml += renderMessageRow("Error searching notes"); 83 | } 84 | } 85 | 86 | return optionsHtml; 87 | }, 88 | runOnCommit: () => { 89 | const defaultNewNoteUrl = getUrlWithSearchParams(`/`, { 90 | url: targetUrl, 91 | title: selectedText, 92 | }); 93 | 94 | // treating input as title to create a new note 95 | context.componentRefs.textEditor.linkToNoteOnSave(defaultNewNoteUrl); 96 | }, 97 | }; 98 | }; 99 | -------------------------------------------------------------------------------- /packages/server/public/src/components/command-bar/commands/handle-open-note.ts: -------------------------------------------------------------------------------- 1 | import { ensureNoteTitle } from "../../../utils/ensure-note-title.js"; 2 | import { findUrl, getUrlWithSearchParams } from "../../../utils/url.js"; 3 | import type { CommandHandler } from "../command-bar.component.js"; 4 | import { PayloadAction } from "../menu/menu-row.component.js"; 5 | import { 6 | renderCrawlResult, 7 | renderHeaderRow, 8 | renderMessageRow, 9 | renderNoteWithUrl, 10 | renderRecentNotes, 11 | renderSearchResultSection, 12 | } from "../menu/render-menu.js"; 13 | import { parseQuery } from "./parse-query.js"; 14 | 15 | export const handleOpenOrCreateNote: CommandHandler = async ({ input, context }) => { 16 | const selectedText = context.componentRefs.textEditor.getSelectedText()?.trim(); 17 | 18 | const query = input.args?.trim() ?? ""; 19 | 20 | const { phrase, tags } = parseQuery(query); 21 | const targetUrl = findUrl(phrase); 22 | 23 | const newNoteTitle = ensureNoteTitle(phrase); 24 | 25 | const newNoteUrl = getUrlWithSearchParams("/", { 26 | url: targetUrl, 27 | title: newNoteTitle, 28 | }); 29 | 30 | return { 31 | updateDropdownOnInput: async () => { 32 | let optionsHtml = ""; 33 | 34 | if (!phrase?.length && !tags.length) { 35 | // Blank input 36 | optionsHtml += renderMessageRow("Type keywords or URL"); 37 | 38 | const recentNotesAsync = context.apiService.getRecentNotes(selectedText ? 5 : 10); 39 | const foundNotesAsync = selectedText ? context.apiService.searchNotes(selectedText) : null; 40 | 41 | // Show recent notes 42 | try { 43 | const recentNotes = await recentNotesAsync; 44 | optionsHtml += renderRecentNotes("Open recent", recentNotes, PayloadAction.openNoteById); 45 | } catch (error) { 46 | optionsHtml += renderMessageRow("Error loading recent notes"); 47 | } 48 | 49 | // Search notes based on selection 50 | if (foundNotesAsync) { 51 | try { 52 | const foundNotes = await foundNotesAsync; 53 | optionsHtml += renderSearchResultSection("Open search result", foundNotes, PayloadAction.openNoteById); 54 | } catch (error) { 55 | optionsHtml += renderMessageRow("Error searching notes"); 56 | } 57 | } 58 | } else { 59 | optionsHtml = renderHeaderRow("Open new"); 60 | 61 | // Start search first for parallelism 62 | const notesAsync = context.apiService.searchNotes(phrase, tags); 63 | 64 | // URL crawl result 65 | if (targetUrl) { 66 | try { 67 | const urlContent = await context.apiService.getContentFromUrl(targetUrl); 68 | optionsHtml += renderCrawlResult(urlContent, PayloadAction.openNoteByUrl); 69 | } catch (error) { 70 | console.error(error); 71 | optionsHtml += renderMessageRow("Error visiting URL"); 72 | } 73 | } 74 | 75 | // Raw new note 76 | optionsHtml += renderNoteWithUrl(newNoteUrl, newNoteTitle, PayloadAction.openNoteByUrl); 77 | 78 | // Search result 79 | try { 80 | const notes = await notesAsync; 81 | optionsHtml += renderSearchResultSection("Open search result", notes, PayloadAction.openNoteById); 82 | } catch (error) { 83 | optionsHtml += renderMessageRow("Error searching notes"); 84 | } 85 | } 86 | 87 | return optionsHtml; 88 | }, 89 | runOnCommit: () => { 90 | // treating input as title to create a new note 91 | context.windowRef.window.open(newNoteUrl, "_self"); 92 | }, 93 | }; 94 | }; 95 | -------------------------------------------------------------------------------- /packages/server/public/src/components/command-bar/commands/handle-open-settings.ts: -------------------------------------------------------------------------------- 1 | import type { CommandHandler, CommandHandlerContext } from "../command-bar.component.js"; 2 | 3 | export const handleOpenSettings: CommandHandler = async ({ context }) => ({ 4 | runOnMatch: async () => { 5 | window.open("/settings", "_self"); 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /packages/server/public/src/components/command-bar/commands/handle-shutdown.ts: -------------------------------------------------------------------------------- 1 | import type { CommandHandler } from "../command-bar.component.js"; 2 | 3 | export const handleShutdown: CommandHandler = async ({ context }) => ({ 4 | runOnMatch: async () => { 5 | if (await context.trackChangeService.isDirty()) { 6 | if (!confirm("You have unsaved changes, do you want to shutdown?")) { 7 | return; 8 | } 9 | } 10 | 11 | try { 12 | await context.apiService.shutdown(); 13 | window.close(); 14 | } catch (e) { 15 | console.error("[shudown] something went wrong during shutdown. Please inspect server logs", e); 16 | } 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /packages/server/public/src/components/command-bar/commands/handle-toggle-spellcheck.ts: -------------------------------------------------------------------------------- 1 | import type { CommandHandler } from "../command-bar.component.js"; 2 | 3 | export const handleToggleSpellcheck: CommandHandler = async ({ context }) => ({ 4 | runOnMatch: async () => { 5 | const result = context.componentRefs.textEditor.toggleSpellcheck(); 6 | context.notificationService.displayMessage(`Spellcheck is ${result ? "ON" : "OFF"}`); 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /packages/server/public/src/components/command-bar/commands/parse-query.ts: -------------------------------------------------------------------------------- 1 | export interface ParsedQuery { 2 | phrase: string; 3 | tags: string[]; 4 | } 5 | export function parseQuery(query: string): ParsedQuery { 6 | const raw = " " + query.trim(); // Front-pad with a space to make arg parsing easier 7 | let phraseResult = ""; 8 | let tagsResult: string[] = []; 9 | 10 | // naive parsing for now, since we only have one args type 11 | const tagsStartIndex = raw.indexOf(" -t"); 12 | 13 | if (tagsStartIndex < 0) { 14 | phraseResult = raw.trim(); 15 | tagsResult = []; 16 | } else { 17 | phraseResult = raw.slice(0, tagsStartIndex).trim(); 18 | const tagsRaw = raw.slice(tagsStartIndex + " -t".length).trim(); 19 | tagsResult = tagsRaw 20 | .split(",") 21 | .map((tagRaw) => tagRaw.trim()) 22 | .filter((tagTrimmed) => tagTrimmed.length > 0); 23 | } 24 | 25 | return { 26 | phrase: phraseResult, 27 | tags: tagsResult, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /packages/server/public/src/components/command-bar/menu/menu-row.component.ts: -------------------------------------------------------------------------------- 1 | export enum PayloadAction { 2 | openNoteByUrl = "open-url", 3 | openNoteById = "open-id", 4 | insertText = "insert-text", 5 | /** Open a new note with provided url. Insert a link to the new note from current note when the new note is saved. */ 6 | insertNewNoteByUrl = "insert-on-save-url", 7 | linkToNoteById = "link-to-id", 8 | /** Open a new note with provided url. Convert selection to a link to the new note when the new note is saved. */ 9 | linkToNewNoteByUrl = "link-on-save-url", 10 | } 11 | 12 | export class MenuRowComponent extends HTMLElement { 13 | readonly dataset!: { 14 | kind: "header" | "option" | "message"; 15 | label: string; 16 | /** When focused, replace args with the given value */ 17 | autoComplete?: string; 18 | /** Applies to any open action */ 19 | alwaysNewTab?: "true"; 20 | /** Data to commit on enter */ 21 | payload?: string; 22 | /** How to commit on enter */ 23 | payloadAction?: PayloadAction; 24 | /** Internal only, applies to options */ 25 | commandKey?: string; 26 | /** Internal only, applies to options */ 27 | active?: ""; 28 | /** Comma separate list of tags */ 29 | tags?: string; 30 | }; 31 | 32 | connectedCallback() { 33 | const tagsHTML = this.dataset?.tags?.length 34 | ? /*html*/ `
    35 | ${this.dataset.tags 36 | .split(",") 37 | .map((tag: string) => `
  • ${tag}
  • `) 38 | .join("")} 39 |
` 40 | : ""; 41 | 42 | this.innerHTML = /*html*/ ``; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/server/public/src/components/command-bar/menu/menu-row.css: -------------------------------------------------------------------------------- 1 | s2-menu-row { 2 | background-color: var(--theme-menu-option-background-rest); 3 | font-family: monospace; 4 | height: 32px; 5 | padding: 0 16px; 6 | } 7 | 8 | s2-menu-row[data-kind="header"] { 9 | color: var(--base0A); 10 | font-weight: bold; 11 | } 12 | 13 | s2-menu-row[data-kind="message"] { 14 | color: var(--theme-text-color-secondary); 15 | } 16 | 17 | s2-menu-row[data-kind="option"] { 18 | color: var(--theme-text-color); 19 | border: none; 20 | text-align: start; 21 | outline: none; 22 | border-radius: 0px; 23 | } 24 | 25 | s2-menu-row[data-kind="option"][data-active] { 26 | font-weight: bold; 27 | color: var(--theme-text-color-knockout); 28 | background-color: var(--theme-menu-option-background-active); 29 | } 30 | 31 | .menu-row-content { 32 | display: grid; 33 | align-items: center; 34 | gap: 8px; 35 | grid-auto-flow: column; 36 | justify-content: start; 37 | align-items: center; 38 | height: 100%; 39 | } 40 | -------------------------------------------------------------------------------- /packages/server/public/src/components/command-bar/menu/render-menu.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SearchNoteOutput, 3 | GetRecentNotesOutput, 4 | GetContentFromUrlOutput, 5 | RecentNoteItem, 6 | SearchResultItem, 7 | } from "@osmoscraft/osmosnote"; 8 | import type { RegisteredCommand } from "../command-bar.component.js"; 9 | import { PayloadAction } from "./menu-row.component.js"; 10 | 11 | export function renderChildCommands(childCommand: RegisteredCommand[]) { 12 | const html = childCommand 13 | .map( 14 | (command) => 15 | /*html*/ `` 16 | ) 17 | .join(""); 18 | return html; 19 | } 20 | 21 | export function renderSearchResultSection( 22 | title: string, 23 | searchReply: SearchNoteOutput, 24 | action: PayloadAction.openNoteById | PayloadAction.insertText | PayloadAction.linkToNoteById 25 | ): string { 26 | const isSearchError = !searchReply?.items; 27 | const isSearchEmpty = searchReply.items && !searchReply.items.length; 28 | 29 | const getPayload = (item: SearchResultItem) => { 30 | switch (action) { 31 | case PayloadAction.openNoteById: 32 | case PayloadAction.linkToNoteById: 33 | return item.id; 34 | case PayloadAction.insertText: 35 | return `[${item.title}](${item.id})`; 36 | } 37 | }; 38 | 39 | return /*html */ ` 40 | 41 | ${isSearchError ? /*html*/ `` : ""} 42 | ${isSearchEmpty ? /*html*/ `` : ""} 43 | ${searchReply.items 44 | .map( 45 | (item) => /*html*/ `` 51 | ) 52 | .join("")} 53 | `; 54 | } 55 | 56 | export function renderRecentNotes( 57 | title: string, 58 | getRecentNotesOutput: GetRecentNotesOutput, 59 | action: PayloadAction.openNoteById | PayloadAction.insertText | PayloadAction.linkToNoteById 60 | ): string { 61 | const isRecentError = !getRecentNotesOutput?.notes; 62 | const isRecentEmpty = getRecentNotesOutput.notes && !getRecentNotesOutput.notes.length; 63 | 64 | const getPayload = (item: RecentNoteItem) => { 65 | switch (action) { 66 | case PayloadAction.openNoteById: 67 | case PayloadAction.linkToNoteById: 68 | return item.id; 69 | case PayloadAction.insertText: 70 | return `[${item.title}](${item.id})`; 71 | } 72 | }; 73 | 74 | return /*html */ ` 75 | 76 | ${ 77 | isRecentError 78 | ? /*html*/ `` 79 | : "" 80 | } 81 | ${isRecentEmpty ? /*html*/ `` : ""} 82 | ${getRecentNotesOutput.notes 83 | .map( 84 | (item) => /*html*/ `` 90 | ) 91 | .join("")} 92 | `; 93 | } 94 | 95 | export function renderCrawlResult( 96 | content: GetContentFromUrlOutput, 97 | action: PayloadAction.insertNewNoteByUrl | PayloadAction.openNoteByUrl | PayloadAction.linkToNewNoteByUrl 98 | ): string { 99 | const searchParams = new URLSearchParams(); 100 | searchParams.set("url", content.canonicalUrl); 101 | searchParams.set("title", content.title); 102 | searchParams.set("content", content.description); 103 | const openUrl = `/?${searchParams}`; 104 | 105 | return /*html*/ ``; 106 | } 107 | 108 | export function renderHeaderRow(title: string) { 109 | return /*html*/ ``; 110 | } 111 | 112 | export function renderMessageRow(message: string) { 113 | return /*html*/ ``; 114 | } 115 | 116 | export function renderNoteWithUrl(url: string, title: string, action: PayloadAction) { 117 | return /*html*/ ``; 118 | } 119 | -------------------------------------------------------------------------------- /packages/server/public/src/components/reference-panel/reference-panel.component.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingLink } from "@osmoscraft/osmosnote"; 2 | import { ApiService } from "../../services/api/api.service.js"; 3 | import { RouteService } from "../../services/route/route.service.js"; 4 | import { di } from "../../utils/dependency-injector.js"; 5 | 6 | export class ReferencePanelComponent extends HTMLElement { 7 | private listDom!: HTMLUListElement; 8 | private apiService!: ApiService; 9 | private routeService!: RouteService; 10 | 11 | connectedCallback() { 12 | this.innerHTML = /*html*/ `
    `; 13 | 14 | this.listDom = this.querySelector("#refpnl-list") as HTMLUListElement; 15 | 16 | this.apiService = di.getSingleton(ApiService); 17 | this.routeService = di.getSingleton(RouteService); 18 | this.loadContent(); 19 | 20 | this.handleEvents(); 21 | } 22 | 23 | focusOnActiveLink() { 24 | const allLinks = [...this.querySelectorAll(`a[data-index]`)] as HTMLAnchorElement[]; 25 | const activeLink = allLinks.find((link) => link.tabIndex === 0); 26 | 27 | activeLink?.focus(); 28 | } 29 | 30 | private async loadContent() { 31 | const { id } = this.routeService.getNoteConfigFromUrl(); 32 | if (id) { 33 | const data = await this.apiService.getIncomingLinks(id); 34 | this.setIncomingLinks(data.incomingLinks); 35 | } 36 | } 37 | 38 | private setIncomingLinks(links: IncomingLink[]) { 39 | this.listDom.innerHTML = links 40 | .map( 41 | (note, index) => /*html*/ ` 42 |
  • 43 | 44 | ${note.title} 45 | ${ 46 | note.tags.length 47 | ? /*html*/ `
      ${note.tags 48 | .map((tag) => /*html*/ `
    • ${tag}
    • `) 49 | .join("")}
    ` 50 | : "" 51 | } 52 |
    53 |
  • 54 | ` 55 | ) 56 | .join(""); 57 | } 58 | 59 | private handleEvents() { 60 | this.addEventListener("focus", () => this.focusOnActiveLink()); 61 | 62 | this.addEventListener("keydown", (event) => { 63 | if (event.key === "ArrowDown" || event.key === "ArrowUp") { 64 | event.stopPropagation(); 65 | event.preventDefault(); 66 | 67 | const allLinks = [...this.querySelectorAll(`a[data-index]`)] as HTMLAnchorElement[]; 68 | const activeIndex = allLinks.findIndex((link) => link.tabIndex === 0); 69 | 70 | if (activeIndex !== -1) { 71 | const nextIndex = (activeIndex + (event.key === "ArrowDown" ? 1 : allLinks.length - 1)) % allLinks.length; 72 | allLinks.forEach((link) => (link.tabIndex = -1)); 73 | allLinks[nextIndex].tabIndex = 0; 74 | allLinks[nextIndex].focus(); 75 | } 76 | } 77 | }); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /packages/server/public/src/components/reference-panel/reference-panel.css: -------------------------------------------------------------------------------- 1 | s2-reference-panel { 2 | background-color: var(--theme-background-rest); 3 | overflow-y: scroll; 4 | font-family: monospace; 5 | } 6 | 7 | s2-reference-panel:focus-within { 8 | outline: var(--theme-divider-width) solid var(--theme-outline-color); 9 | background-color: var(--theme-background-active); 10 | } 11 | 12 | .refpnl-list { 13 | margin: 0; 14 | padding: 0; 15 | list-style: none; 16 | display: grid; 17 | } 18 | 19 | .refpnl-list:empty { 20 | display: none; 21 | } 22 | 23 | .refpnl-link { 24 | text-decoration: none; 25 | padding: 6px 16px; 26 | outline: none; 27 | color: var(--theme-text-color); 28 | 29 | display: grid; 30 | align-items: center; 31 | gap: 8px; 32 | grid-auto-flow: column; 33 | justify-content: start; 34 | } 35 | 36 | .refpnl-link:is(:focus, :hover) { 37 | font-weight: bold; 38 | color: var(--theme-text-color-knockout); 39 | background-color: var(--theme-menu-option-background-active); 40 | } 41 | -------------------------------------------------------------------------------- /packages/server/public/src/components/settings-form/settings-form.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: var(--theme-font-monospace); 3 | } 4 | 5 | form { 6 | display: grid; 7 | gap: 1rem; 8 | } 9 | 10 | fieldset { 11 | padding: 0.5rem 1rem 1rem 1rem; 12 | border: 1px solid var(--base03); 13 | border-radius: 2px; 14 | margin: 0; 15 | } 16 | fieldset:focus-within { 17 | border-color: var(--base0D); 18 | } 19 | 20 | legend { 21 | font-size: 20px; 22 | } 23 | 24 | .form-fields { 25 | display: grid; 26 | gap: 1rem; 27 | } 28 | 29 | .form-field { 30 | display: grid; 31 | grid-template: 32 | "label action" auto 33 | "gap gap" 0.5rem 34 | "control control" auto / 1fr auto; 35 | } 36 | 37 | .form-field__label { 38 | grid-area: label; 39 | font-size: 1rem; 40 | font-weight: bold; 41 | } 42 | 43 | .form-field__action { 44 | grid-area: action; 45 | font-weight: bold; 46 | } 47 | 48 | .form-field__control { 49 | grid-area: control; 50 | } 51 | 52 | .option-set { 53 | display: flex; 54 | gap: 1rem; 55 | } 56 | 57 | .option-label { 58 | font-size: 1rem; 59 | display: inline-flex; 60 | line-height: 1rem; 61 | } 62 | 63 | input:is([type="text"], [type="url"], [type="password"]) { 64 | padding: 0.5rem; 65 | font-size: 1rem; 66 | color: var(--base05); 67 | border: 1px solid transparent; 68 | font-family: var(--theme-font-monospace); 69 | background-color: var(--base00); 70 | } 71 | input:is([type="text"], [type="url"], [type="password"]):focus { 72 | background-color: var(--theme-background-active); 73 | outline: 1px solid var(--theme-outline-color); 74 | outline-offset: -1px; 75 | } 76 | 77 | input[type="radio"] { 78 | margin: 0 0.5rem 0 0; 79 | width: 1rem; 80 | height: 1rem; 81 | } 82 | 83 | input[type="radio"]:focus-visible { 84 | outline: 2px solid var(--theme-outline-color); 85 | } 86 | 87 | [data-if-hosting-provider]:not([data-active]), 88 | [data-if-network-protocol]:not([data-active]) { 89 | display: none; 90 | } 91 | 92 | .form-status-container:not([data-active]) { 93 | display: none; 94 | } 95 | 96 | .details__label { 97 | cursor: pointer; 98 | font-size: 20px; 99 | padding: 0.5rem 1rem; 100 | } 101 | .details__label:is(:hover, :focus-visible) { 102 | outline: none; 103 | color: var(--base08); 104 | background-color: var(--base00); 105 | } 106 | 107 | .danger-zone { 108 | margin-top: 1rem; 109 | border: 1px solid var(--base03); 110 | border-radius: 2px; 111 | } 112 | .danger-zone[open] { 113 | border-color: var(--base08); 114 | } 115 | 116 | .danger-title { 117 | font-size: 1rem; 118 | font-weight: bold; 119 | margin: 0; 120 | } 121 | 122 | .danger-sections { 123 | display: grid; 124 | gap: 1rem; 125 | } 126 | 127 | .danger-section { 128 | padding: 1rem; 129 | } 130 | -------------------------------------------------------------------------------- /packages/server/public/src/components/status-bar/status-bar.component.ts: -------------------------------------------------------------------------------- 1 | export type ChangeSatus = "new" | "clean" | "dirty"; 2 | 3 | export class StatusBarComponent extends HTMLElement { 4 | private messageOutputDom!: HTMLSpanElement; 5 | private changeStatusDom!: HTMLSpanElement; 6 | 7 | connectedCallback() { 8 | this.innerHTML = /*html*/ ` 9 | 10 | 11 | 12 | `; 13 | this.messageOutputDom = this.querySelector("#message-output") as HTMLOutputElement; 14 | this.changeStatusDom = this.querySelector("#change-status") as HTMLOutputElement; 15 | } 16 | 17 | setChangeStatus(status: ChangeSatus) { 18 | this.changeStatusDom.innerText = status; 19 | this.changeStatusDom.dataset.status = status; 20 | } 21 | 22 | setMessage(text: string, kind: "error" | "info" | "warning" = "info") { 23 | this.messageOutputDom.innerText = `${text} ${new Date().toLocaleTimeString()}`; 24 | this.messageOutputDom.dataset.kind = kind; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/server/public/src/components/status-bar/status-bar.css: -------------------------------------------------------------------------------- 1 | s2-status-bar { 2 | --status-bar-height: 24px; 3 | height: var(--status-bar-height); 4 | background-color: var(--base0D); 5 | color: var(--theme-text-color-knockout); 6 | font-weight: bold; 7 | font-size: 14px; 8 | min-width: 0; 9 | 10 | padding: 0; 11 | } 12 | 13 | .stsbar-output { 14 | display: flex; 15 | align-items: center; 16 | height: 100%; 17 | } 18 | 19 | .stsbar-chip { 20 | padding: 0 8px; 21 | height: 100%; 22 | line-height: var(--status-bar-height); 23 | } 24 | 25 | .stsbar-chip--message { 26 | overflow: hidden; 27 | white-space: nowrap; 28 | text-overflow: ellipsis; 29 | } 30 | 31 | .stsbar-chip--change { 32 | text-transform: capitalize; 33 | } 34 | .stsbar-chip--change[data-status="clean"] { 35 | background-color: var(--theme-success-background); 36 | } 37 | .stsbar-chip--change[data-status="dirty"] { 38 | background-color: var(--theme-warning-background); 39 | } 40 | .stsbar-chip--change[data-status="new"] { 41 | background-color: var(--theme-warning-background); 42 | } 43 | 44 | .stsbar-chip--message[data-kind="warning"] { 45 | background-color: var(--theme-warning-background); 46 | } 47 | .stsbar-chip--message[data-kind="error"] { 48 | background-color: var(--theme-error-background); 49 | } 50 | 51 | .stsbar-spacer { 52 | flex: 1 0 auto; 53 | } 54 | -------------------------------------------------------------------------------- /packages/server/public/src/components/tag-list/tag-list.css: -------------------------------------------------------------------------------- 1 | .tag-list { 2 | list-style: none; 3 | margin: 0; 4 | padding: 0; 5 | display: flex; 6 | gap: 4px; 7 | max-width: 50vw; 8 | overflow: hidden; 9 | } 10 | 11 | .tag-list__item { 12 | padding: 2px 4px; 13 | background-color: var(--base03); 14 | color: var(--base06); 15 | white-space: nowrap; 16 | text-overflow: ellipsis; 17 | overflow: hidden; 18 | font-weight: normal; 19 | } 20 | -------------------------------------------------------------------------------- /packages/server/public/src/components/text-editor/compiler/blank.ts: -------------------------------------------------------------------------------- 1 | import type { FormatContext, LineCompiler } from "./compile.service.js"; 2 | import type { LineElement } from "../helpers/source-to-lines.js"; 3 | import { removeLineEnding } from "../helpers/string.js"; 4 | import { UI_LINE_END } from "../../../utils/special-characters.js"; 5 | 6 | const BLANK_PATTERN = /^(\s*)$/; 7 | 8 | function match(rawText: string) { 9 | return rawText.match(BLANK_PATTERN); 10 | } 11 | 12 | function parse(line: LineElement, match: RegExpMatchArray) { 13 | const [raw, spaces] = match; 14 | 15 | line.dataset.line = "blank"; 16 | 17 | const inlineSpaces = removeLineEnding(spaces); 18 | 19 | line.innerHTML = `${inlineSpaces}${UI_LINE_END}`; 20 | } 21 | 22 | function format(line: LineElement, context: FormatContext) { 23 | const indentSize = context.indentFromHeading + context.indentFromList; 24 | const indent = line.querySelector(`[data-indent]`)!; 25 | 26 | const lengthChange = indentSize - indent.textContent!.length; 27 | if (lengthChange !== 0) { 28 | indent.textContent = ` `.repeat(indentSize); 29 | } 30 | 31 | return { 32 | lengthChange, 33 | }; 34 | } 35 | 36 | function updateContext(_line: LineElement, context: FormatContext) { 37 | context.indentFromList = 0; 38 | context.listIndentFromSetter = []; 39 | context.listOrderFromSetter = []; 40 | } 41 | 42 | export const blank: LineCompiler = { 43 | match, 44 | parse, 45 | format, 46 | updateContext, 47 | }; 48 | -------------------------------------------------------------------------------- /packages/server/public/src/components/text-editor/compiler/generic.ts: -------------------------------------------------------------------------------- 1 | import { UI_LINE_END } from "../../../utils/special-characters.js"; 2 | import type { LineElement } from "../helpers/source-to-lines.js"; 3 | import { removeLineEnding } from "../helpers/string.js"; 4 | import type { FormatContext, LineCompiler } from "./compile.service.js"; 5 | import { parseInlineParagraph } from "./parse-inline-paragraph.js"; 6 | 7 | function match(rawText: string): RegExpMatchArray { 8 | return [rawText]; // similate a regexp match 9 | } 10 | 11 | function parse(line: LineElement, match: RegExpMatchArray) { 12 | let remainingText = removeLineEnding(match[0]); 13 | let indent = remainingText.match(/^(\s+)/)?.[0] ?? ""; 14 | 15 | remainingText = remainingText.slice(indent.length); 16 | 17 | const paragraphHtml = parseInlineParagraph(remainingText); 18 | 19 | line.innerHTML = `${indent}${paragraphHtml}${UI_LINE_END}`; 20 | } 21 | 22 | function format(line: LineElement, context: FormatContext) { 23 | const indentSize = context.indentFromHeading + context.indentFromList; 24 | 25 | const indent = line.querySelector(`[data-indent]`)!; 26 | 27 | const lengthChange = indentSize - indent.textContent!.length; 28 | if (lengthChange !== 0) { 29 | indent.textContent = ` `.repeat(indentSize); 30 | } 31 | 32 | return { 33 | lengthChange, 34 | }; 35 | } 36 | 37 | export const generic: LineCompiler = { 38 | match, 39 | parse, 40 | format, 41 | }; 42 | -------------------------------------------------------------------------------- /packages/server/public/src/components/text-editor/compiler/haiku-sample.osmos: -------------------------------------------------------------------------------- 1 | #+title: On the design of a knowledge documentation language 2 | #+tags: programming language, learning theory 3 | 4 | # There used to be a kingdom 5 | 6 | Where horses roam, and birds soar. 7 | 8 | ## Until one day, the evil spirit emerges 9 | 10 | 1. The spirit enslaved the farmers 11 | 2. The farmers turned into ghosts 12 | 3. The ghosts haunt the village ever since 13 | -1. Nights are dangerous 14 | --1. Animals were killed 15 | --2. Kids were lost 16 | --3. Parents were confused 17 | ---1. Some of them contacted policy 18 | ---2. Others gave up 19 | 4. Everything turned bad after that night -------------------------------------------------------------------------------- /packages/server/public/src/components/text-editor/compiler/haiku-spec.md: -------------------------------------------------------------------------------- 1 | # Guarantees 2 | 3 | - Idempotent (text -> ast -> text will not alter the text) 4 | - effects only propagate forward (a change cannot affect lines above it) 5 | 6 | # Tokens 7 | 8 | - meta 9 | - fence `---` 10 | - key `\w: ` 11 | - value (rest) 12 | - heading 13 | - indentation `^ +` 14 | - prefix `#+ ` 15 | - text (rest) 16 | - list title 17 | - indentation `^ +` 18 | - prefix `- `, `\d+ ` 19 | - text (rest) 20 | - code block 21 | - indentation `^ +` 22 | - fence ``` 23 | - inline 24 | - link `[...](...)`, `https?://...` 25 | 26 | # Syntax 27 | 28 | ## Meta block 29 | 30 | #+title: Hello world! 31 | #+tags: programming, philosophy 32 | #+key: value 33 | 34 | ## Heading 35 | 36 | - Always prefixed with "# ". 37 | - No character other than " " before # 38 | - Always single-line 39 | - Level derived from indentation. 40 | - 2 spaces per indentation level 41 | - Layout effect: 42 | - Content below will be indended so it's flush with heading text, until the next heading 43 | 44 | ``` 45 | # Heading level one 46 | 47 | # Heading level two 48 | 49 | # Heading level three 50 | 51 | ... 52 | ``` 53 | 54 | ## Paragraph 55 | 56 | - Flush aligned with hearest heading above 57 | - If there is no heading above, flush aligned with left edge of the document 58 | 59 | ``` 60 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 61 | 62 | # Heading 63 | 64 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 65 | 66 | # Heading 67 | 68 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 69 | ``` 70 | 71 | ## List 72 | 73 | - List item title has the same layout effect as heading 74 | - Ordered list uses "d+." 75 | - Unordered list uses "-." 76 | - A list is either ordered or unordered, and cannot mix them on the same level 77 | - A child list can be either ordered or unordered, regardless of the parent. 78 | - List counter restarts when the line above is not a list item, or is a list item of different type 79 | 80 | ``` 81 | 82 | # Heading 83 | 84 | 1. List item title 85 | List item content 86 | - Child list item 87 | - Child list item 88 | 2. List item title 89 | 3. List item third 90 | ... 91 | 10. List item title 92 | List item content 93 | 11. List item title 94 | 1. Child list item 95 | 2. Child list item 96 | 97 | 1. Another list starts 98 | - Yet another list starts 99 | 1. Once again, another list starts 100 | ``` 101 | 102 | ## Links 103 | 104 | - internal link `[title](id)` 105 | - external link `[title](https://)` 106 | - literal link `https://...` 107 | - footnote ref (future) `[title][ref]` 108 | - footnote (future) `[ref]: id` or `[ref]: https://` 109 | 110 | ## Code 111 | 112 | - inline: ` 113 | - block: ``` 114 | 115 | (this example has escaped back tick) 116 | 117 | ```markdown 118 | This \`code\` is inline 119 | 120 | \`\`\`language 121 | This code is block 122 | \`\`\` 123 | ``` 124 | 125 | ## Block quote (future) 126 | 127 | - Single quote " 128 | - Last line can use -- for source 129 | 130 | ``` 131 | " 132 | This is a brilliant quote, 133 | especially when presented as a block quote. 134 | 135 | -- Internet trolls, The Ultimate Collection of Bad Quotes 136 | " 137 | ``` 138 | 139 | ## Inline Tags (future) 140 | 141 | - ::tag1, tag2, tag3, tag_with-symbol 142 | - Can only be prefixed with space 143 | - Separated by comma followed by a space 144 | - Only allow `a-zA-Z0-9`, `_`, and `-`. 145 | - (future) can extend to allow space and period 146 | - Is case insensitive, though UpperCamel is recommended for readability 147 | 148 | Top level tagging 149 | 150 | ``` 151 | ::book, learning theory, policy, idea 152 | 153 | # Heading 154 | ``` 155 | 156 | Section level tagging 157 | 158 | ``` 159 | # Heading 160 | 161 | ::resource, tooling, out-of-the-box 162 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 163 | 164 | ``` 165 | 166 | ## Italic (future) 167 | 168 | - single asterisk 169 | 170 | ## Bold (future) 171 | 172 | - double asterisk 173 | 174 | ## Bold and italic (future) 175 | 176 | - tripple asterisk 177 | 178 | ## Table (future) 179 | 180 | - Ref to org mode 181 | 182 | ## Special block (future) 183 | 184 | - TBD 185 | -------------------------------------------------------------------------------- /packages/server/public/src/components/text-editor/compiler/heading.ts: -------------------------------------------------------------------------------- 1 | import type { FormatContext, LineCompiler } from "./compile.service"; 2 | import type { LineElement } from "../helpers/source-to-lines"; 3 | import { UI_LINE_END } from "../../../utils/special-characters.js"; 4 | 5 | export const HEADING_CONTROL_CHAR = "#"; 6 | export const HEADING_MAX_LEVEL = 6; // not enforced yet 7 | const HEADING_PATTERN = /^(\s*)(#+) (.*)/; // `### Heading` 8 | 9 | function match(rawText: string) { 10 | return rawText.match(HEADING_PATTERN); 11 | } 12 | 13 | function parse(line: LineElement, match: RegExpMatchArray) { 14 | const [raw, spaces, hashes, text] = match; 15 | 16 | line.dataset.headingLevel = hashes.length.toString(); 17 | line.dataset.line = "heading"; 18 | 19 | const hiddenHashes = HEADING_CONTROL_CHAR.repeat(hashes.length - 1); 20 | 21 | line.innerHTML = `${spaces}${hiddenHashes}${HEADING_CONTROL_CHAR} ${text}${UI_LINE_END}`; 22 | } 23 | 24 | function format(line: LineElement) { 25 | const indentSize = parseInt(line.dataset.headingLevel!) - 1; 26 | const indent = line.querySelector(`[data-indent]`)!; 27 | const lengthChange = indentSize - indent.textContent!.length; 28 | if (lengthChange !== 0) { 29 | indent.textContent = ` `.repeat(indentSize); 30 | } 31 | 32 | return { 33 | lengthChange, 34 | }; 35 | } 36 | 37 | function updateContext(line: LineElement, context: FormatContext) { 38 | const headingLevel = parseInt(line.dataset.headingLevel!); 39 | context.indentFromHeading = headingLevel * 2; 40 | context.indentFromList = 0; // heading resets list indent 41 | context.listIndentFromSetter = []; 42 | context.listOrderFromSetter = []; 43 | } 44 | 45 | export const heading: LineCompiler = { 46 | match, 47 | parse, 48 | format, 49 | updateContext, 50 | }; 51 | -------------------------------------------------------------------------------- /packages/server/public/src/components/text-editor/compiler/list.ts: -------------------------------------------------------------------------------- 1 | import type { FormatContext, LineCompiler } from "./compile.service.js"; 2 | import type { LineElement } from "../helpers/source-to-lines.js"; 3 | import { parseInlineParagraph } from "./parse-inline-paragraph.js"; 4 | import { UI_LINE_END } from "../../../utils/special-characters.js"; 5 | 6 | const LIST_PATTERN = /^(\s*)(-*)(-|\d+\.) (.*)/; // `-- Item`, or `--1. Item` 7 | export const LIST_CONTROL_CHAR = "-"; 8 | 9 | function match(rawText: string) { 10 | return rawText.match(LIST_PATTERN); 11 | } 12 | 13 | function parse(line: LineElement, match: RegExpMatchArray) { 14 | const [raw, spaces, levelSetters, listMarker, text] = match; 15 | 16 | const listLevel = levelSetters.length + 1; 17 | 18 | line.dataset.line = "list"; 19 | line.dataset.listLevel = listLevel.toString(); 20 | line.dataset.list = listMarker === "-" ? "unordered" : "ordered"; 21 | line.dataset.listMarker = listMarker; 22 | if (!text.length) line.dataset.listEmpty = ""; 23 | 24 | const hiddenHyphens = LIST_CONTROL_CHAR.repeat(levelSetters.length); 25 | 26 | const paragraphHtml = parseInlineParagraph(text); 27 | 28 | line.innerHTML = `${spaces}${hiddenHyphens}${listMarker} ${paragraphHtml}${UI_LINE_END}`; 29 | } 30 | 31 | function format(line: LineElement, context: FormatContext) { 32 | const levelSettersLength = parseInt(line.dataset.listLevel!) - 1; 33 | 34 | const listSelfIndent = context.listIndentFromSetter[levelSettersLength] ?? 0; 35 | const indentSize = context.indentFromHeading + listSelfIndent; 36 | 37 | // Format ordering 38 | const previousOrder = context.listOrderFromSetter[levelSettersLength] ?? 0; 39 | if (line.dataset.list === "ordered") { 40 | line.querySelector(`[data-list-marker]`)!.textContent = `${previousOrder + 1}.`; 41 | line.dataset.listMarker = `${previousOrder + 1}.`; 42 | } 43 | 44 | // Format indent 45 | const indent = line.querySelector(`[data-indent]`)!; 46 | const lengthChange = indentSize - indent.textContent!.length; 47 | 48 | if (lengthChange !== 0) { 49 | indent.textContent = ` `.repeat(indentSize); 50 | } 51 | 52 | return { lengthChange }; 53 | } 54 | 55 | function updateContext(line: LineElement, context: FormatContext) { 56 | const levelSettersLength = parseInt(line.dataset.listLevel!) - 1; 57 | const listMarkerLength = line.dataset.listMarker!.length; 58 | 59 | // Update context for ordering 60 | context.listOrderFromSetter = context.listOrderFromSetter.slice(0, levelSettersLength + 1); // clear any deeper items 61 | const previousOrder = context.listOrderFromSetter[levelSettersLength] ?? 0; 62 | context.listOrderFromSetter[levelSettersLength] = previousOrder + 1; 63 | 64 | // Update context for indentation 65 | const currentIdentSize = context.listIndentFromSetter[levelSettersLength] ?? 0; 66 | context.listIndentFromSetter[levelSettersLength + 1] = currentIdentSize + listMarkerLength; 67 | context.indentFromList = currentIdentSize + levelSettersLength + listMarkerLength + 1; 68 | } 69 | 70 | export const list: LineCompiler = { 71 | match, 72 | parse, 73 | format, 74 | updateContext, 75 | }; 76 | -------------------------------------------------------------------------------- /packages/server/public/src/components/text-editor/compiler/meta.ts: -------------------------------------------------------------------------------- 1 | import type { LineCompiler } from "./compile.service"; 2 | import type { LineElement } from "../helpers/source-to-lines"; 3 | import { UI_LINE_END } from "../../../utils/special-characters.js"; 4 | 5 | const META_PATTERN = /^#\+(.+?): (.*)/; // `#+key: value` 6 | 7 | function match(rawText: string) { 8 | return rawText.match(META_PATTERN); 9 | } 10 | 11 | function parse(line: LineElement, match: RegExpMatchArray) { 12 | const [raw, metaKey, metaValue] = match; 13 | 14 | line.dataset.line = "meta"; 15 | 16 | switch (metaKey) { 17 | case "url": 18 | line.innerHTML = `#+${metaKey}: ${metaValue}${UI_LINE_END}`; 19 | line.spellcheck = false; 20 | break; 21 | case "title": 22 | line.innerHTML = `#+${metaKey}: ${metaValue}${UI_LINE_END}`; 23 | break; 24 | case "tags": 25 | line.innerHTML = `#+${metaKey}: ${metaValue}${UI_LINE_END}`; 26 | break; 27 | case "created": 28 | line.innerHTML = `#+${metaKey}: ${metaValue}${UI_LINE_END}`; 29 | line.spellcheck = false; 30 | break; 31 | default: 32 | line.innerHTML = `#+${metaKey}: ${metaValue}${UI_LINE_END}`; 33 | console.error(`Unsupported meta key ${metaKey}`); 34 | } 35 | } 36 | 37 | function format() { 38 | return { 39 | lengthChange: 0, 40 | }; 41 | } 42 | 43 | export const meta: LineCompiler = { 44 | match, 45 | parse, 46 | format, 47 | }; 48 | -------------------------------------------------------------------------------- /packages/server/public/src/components/text-editor/compiler/parse-inline-paragraph.ts: -------------------------------------------------------------------------------- 1 | import { URL_PATTERN_WITH_PREFIX } from "../../../utils/url.js"; 2 | 3 | const TITLED_LINK_PATTERN = /^(.*?)\[([^\[\]]+?)\]\((.+?)\)/; // `[title](target)` 4 | 5 | /** 6 | * Assumption: inlineText already has line ending character removed. 7 | */ 8 | export function parseInlineParagraph(inlineText: string) { 9 | let paragraphHtml = ""; 10 | let remainingText = inlineText; 11 | 12 | while (remainingText) { 13 | let match = remainingText.match(TITLED_LINK_PATTERN); // [title](target) 14 | if (match) { 15 | const [raw, plainText, linkTitle, linkTarget] = match; 16 | paragraphHtml += plainText; 17 | paragraphHtml += `[${linkTitle}](${linkTarget})`; 18 | 19 | remainingText = remainingText.slice(raw.length); 20 | continue; 21 | } 22 | 23 | match = remainingText.match(URL_PATTERN_WITH_PREFIX); // raw URL 24 | if (match) { 25 | const [raw, plainText, url] = match; 26 | paragraphHtml += plainText; 27 | paragraphHtml += `${url}`; 28 | 29 | remainingText = remainingText.slice(raw.length); 30 | continue; 31 | } 32 | 33 | paragraphHtml += remainingText; 34 | remainingText = ""; 35 | } 36 | 37 | return paragraphHtml; 38 | } 39 | -------------------------------------------------------------------------------- /packages/server/public/src/components/text-editor/helpers/source-to-lines.ts: -------------------------------------------------------------------------------- 1 | import { SRC_LINE_END, UI_LINE_END } from "../../../utils/special-characters.js"; 2 | import { removeLineEnding } from "./string.js"; 3 | 4 | export type LineType = "" | "heading" | "meta" | "list" | "blank"; 5 | 6 | export interface LineElement extends HTMLDivElement { 7 | dataset: { 8 | line: LineType; 9 | /** Exists on heading lines */ 10 | headingLevel?: string; 11 | /** Exists on list item lines */ 12 | list?: "ordered" | "unordered"; 13 | /** The "bullet" or the number prefix of a list item, without surrounding space */ 14 | listMarker?: string; 15 | listLevel?: string; 16 | /** For list item that contains only the marker */ 17 | listEmpty?: string; 18 | /** Exists on meta lines */ 19 | meta?: "title" | "tags"; 20 | /** Line state */ 21 | parsed?: ""; 22 | /** Exists on the line that has collapsed caret */ 23 | caretCollapsed?: ""; 24 | /** Exists on the lines that overlap with selection */ 25 | caretSelected?: ""; 26 | }; 27 | } 28 | 29 | export function sourceToLines(source: string) { 30 | const result = document.createDocumentFragment(); 31 | 32 | // This can handle both src and ui line endings 33 | const trimmedLines = removeLineEnding(source).replaceAll(SRC_LINE_END, UI_LINE_END); 34 | const lines = trimmedLines.split(UI_LINE_END); 35 | 36 | lines.forEach((line) => { 37 | const lineDom = document.createElement("div") as LineElement; 38 | lineDom.dataset.line = ""; 39 | lineDom.textContent = line; 40 | 41 | result.appendChild(lineDom); 42 | }); 43 | 44 | return result; 45 | } 46 | -------------------------------------------------------------------------------- /packages/server/public/src/components/text-editor/helpers/string.ts: -------------------------------------------------------------------------------- 1 | import { UI_LINE_END } from "../../../utils/special-characters.js"; 2 | 3 | export function removeLineEnding(text: string): string { 4 | if (text.length && text[text.length - 1] === UI_LINE_END) { 5 | return text.slice(0, -1); 6 | } else { 7 | return text; 8 | } 9 | } 10 | 11 | export function ensureLineEnding(text: string): string { 12 | if (text.length && text[text.length - 1] === UI_LINE_END) { 13 | return text; 14 | } else { 15 | return text + UI_LINE_END; 16 | } 17 | } 18 | 19 | export function splice(text: string, start: number, deleteCount = 0, insert = "") { 20 | return text.substring(0, start) + insert + text.substring(start + deleteCount); 21 | } 22 | 23 | export function reverse(text: string): string { 24 | return text.split("").reverse().join(""); 25 | } 26 | 27 | /** 28 | * Get the index of the first word end character from the beginning of the string 29 | * Assuming the input ends with a new line character 30 | */ 31 | export function getWordEndOffset(text: string): number { 32 | let wordEndMatch = text.match(/^(\s*?)(\w+|[^\w\s]+)(\w|\s|[^\w\s])/); 33 | if (wordEndMatch) { 34 | const [raw, spaces, chunk, suffix] = wordEndMatch; 35 | const moveDistance = spaces.length + chunk.length; 36 | return moveDistance; 37 | } 38 | 39 | // if there is no match, the line must be empty. Set to the character before line end 40 | return text.indexOf(UI_LINE_END); 41 | } 42 | -------------------------------------------------------------------------------- /packages/server/public/src/components/text-editor/helpers/template.ts: -------------------------------------------------------------------------------- 1 | import { ensureNoteTitle } from "../../../utils/ensure-note-title.js"; 2 | import { SRC_LINE_END } from "../../../utils/special-characters.js"; 3 | import { getLocalTimestamp } from "../../../utils/time.js"; 4 | 5 | export interface TemplateInput { 6 | title?: string; 7 | url?: string; 8 | content?: string; 9 | } 10 | 11 | export interface TemplateOutput { 12 | title: string; 13 | note: string; 14 | } 15 | 16 | export function getNoteFromTemplate(input: TemplateInput): TemplateOutput { 17 | const title = ensureNoteTitle(input.title); 18 | 19 | const lines = [ 20 | `#+title: ${title}${SRC_LINE_END}`, 21 | `#+created: ${getLocalTimestamp(new Date())}${SRC_LINE_END}`, 22 | ...(input.url ? [`#+url: ${input.url}${SRC_LINE_END}`] : []), 23 | `${SRC_LINE_END}`, 24 | ...(input.content ? [input.content] : [SRC_LINE_END]), 25 | ]; 26 | 27 | const note = lines.join(""); 28 | 29 | return { 30 | title, 31 | note, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /packages/server/public/src/components/text-editor/history/history-stack.ts: -------------------------------------------------------------------------------- 1 | export interface IsEqual { 2 | (before: T, after: T): boolean; 3 | } 4 | 5 | export class HistoryStack { 6 | private pastStack: T[] = []; 7 | private present: T | null = null; 8 | private futureStack: T[] = []; 9 | 10 | push(value: T) { 11 | if (this.present) { 12 | this.pastStack.push(this.present); 13 | } 14 | this.present = value; 15 | 16 | this.futureStack = []; 17 | } 18 | 19 | peek(): T | null { 20 | return this.present; 21 | } 22 | 23 | undo(): T | null { 24 | if (!this.present) return null; 25 | 26 | const past = this.pastStack.pop(); 27 | if (!past) return null; 28 | 29 | this.futureStack.push(this.present); 30 | this.present = past; 31 | 32 | return this.present; 33 | } 34 | 35 | redo(): T | null { 36 | if (!this.present) return null; 37 | 38 | const future = this.futureStack.pop(); 39 | if (!future) return null; 40 | 41 | this.pastStack.push(this.present); 42 | this.present = future; 43 | 44 | return this.present; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/server/public/src/components/text-editor/history/history.service.ts: -------------------------------------------------------------------------------- 1 | import type { CaretService } from "../caret.service.js"; 2 | import type { LineElement } from "../helpers/source-to-lines.js"; 3 | import type { LineQueryService } from "../line-query.service.js"; 4 | import type { TrackChangeService } from "../track-change.service.js"; 5 | import { HistoryStack } from "./history-stack.js"; 6 | 7 | export interface Snapshot { 8 | documentHtml: string; 9 | textContent: string; 10 | caretLineIndex: number; 11 | caretLineOffset: number; 12 | } 13 | 14 | const compareSnapshots = (a: Snapshot | null, b: Snapshot | null) => { 15 | return ( 16 | a?.textContent === b?.textContent && 17 | a?.caretLineIndex === b?.caretLineIndex && 18 | a?.caretLineOffset === b?.caretLineOffset 19 | ); 20 | }; 21 | 22 | export class HistoryService { 23 | private stack = new HistoryStack(); 24 | 25 | constructor( 26 | private caretService: CaretService, 27 | private lineQueryService: LineQueryService, 28 | private trackChangeService: TrackChangeService 29 | ) {} 30 | 31 | save(root: HTMLElement) { 32 | const snapshot = this.getSnapshot(root); 33 | const current = this.stack.peek(); 34 | 35 | if (compareSnapshots(current, snapshot)) { 36 | return; 37 | } 38 | 39 | this.stack.push(snapshot); 40 | } 41 | 42 | /** Create a snapshot in history, mutation content, create a new snapshot, and update dirty status */ 43 | async runAtomic(root: HTMLElement, action: () => any) { 44 | this.save(root); 45 | await action(); 46 | this.save(root); 47 | this.trackChangeService.trackByText(this.peek()?.textContent); 48 | } 49 | 50 | undo(root: HTMLElement) { 51 | // before undo, always save, in case this is unsaved changes 52 | this.save(root); 53 | 54 | const snapshot = this.stack.undo(); 55 | 56 | if (snapshot) { 57 | this.restoreSnapshot(snapshot, root); 58 | } 59 | } 60 | 61 | redo(root: HTMLElement) { 62 | const snapshot = this.stack.redo(); 63 | if (!snapshot) return; 64 | 65 | this.restoreSnapshot(snapshot, root); 66 | } 67 | 68 | peek() { 69 | return this.stack.peek(); 70 | } 71 | 72 | private restoreSnapshot(snapshot: Snapshot, root: HTMLElement) { 73 | // restore dom 74 | root.innerHTML = snapshot.documentHtml; 75 | 76 | // restore caret 77 | const lines = [...root.querySelectorAll("[data-line]")] as HTMLElement[]; 78 | const caretLine = lines[snapshot.caretLineIndex]; 79 | const caretPosition = this.lineQueryService.getPositionByOffset(caretLine, snapshot.caretLineOffset); 80 | 81 | return this.caretService.setCollapsedCaretToLinePosition({ 82 | line: caretLine, 83 | position: { 84 | ...caretPosition, 85 | }, 86 | root, 87 | rememberColumn: true, 88 | }); 89 | } 90 | 91 | private getSnapshot(root: HTMLElement): Snapshot { 92 | const lines = [...root.querySelectorAll("[data-line]")] as LineElement[]; 93 | 94 | const documentHtml = root.innerHTML; 95 | const textContent = root.textContent ?? ""; 96 | 97 | const caret = this.caretService.caret; 98 | if (caret) { 99 | const currentLine = this.lineQueryService.getLine(caret.focus.node)! as LineElement; 100 | const { offset: caretOffset } = this.caretService.getCaretLinePosition(caret.focus); 101 | 102 | return { 103 | documentHtml: documentHtml, 104 | textContent, 105 | caretLineIndex: lines.indexOf(currentLine), 106 | caretLineOffset: caretOffset, 107 | }; 108 | } else { 109 | return { 110 | documentHtml: documentHtml, 111 | textContent, 112 | caretLineIndex: 0, 113 | caretLineOffset: 0, 114 | }; 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /packages/server/public/src/components/text-editor/measure.service.ts: -------------------------------------------------------------------------------- 1 | import type { WindowRefService } from "../../services/window-reference/window.service"; 2 | 3 | export class MeasureService { 4 | private _measure: number = Infinity; 5 | 6 | constructor(private windowRef: WindowRefService) {} 7 | 8 | get measure(): number { 9 | return this._measure; 10 | } 11 | 12 | init(host: HTMLElement) { 13 | this.windowRef.window.addEventListener("resize", () => this.updateMeausre(host)); 14 | this.updateMeausre(host); 15 | } 16 | 17 | private updateMeausre(host: HTMLElement) { 18 | const measure = this.calculateMeasure(host); 19 | this._measure = measure; 20 | } 21 | 22 | private calculateMeasure(host: HTMLElement) { 23 | const [lower, upper] = this.getLineMeasureBounds(host); 24 | return this.getLineMeasureRecursive(host, lower, upper); 25 | } 26 | 27 | private getLineMeasureBounds(host: HTMLElement): [lower: number, upper: number] { 28 | let lowerBound = 1, 29 | upperBound: number; 30 | let currentLength = 1; 31 | while (this.getLineCount(host, currentLength) < 2) { 32 | lowerBound = currentLength; 33 | currentLength *= 2; 34 | } 35 | upperBound = currentLength; 36 | 37 | return [lowerBound, upperBound]; 38 | } 39 | 40 | private getLineMeasureRecursive(host: HTMLElement, lower: number, upper: number): number { 41 | const diff = upper - lower; 42 | if (diff === 1) { 43 | return lower; 44 | } 45 | 46 | const mid = lower + Math.round(diff / 2); 47 | if (this.getLineCount(host, mid) === 1) { 48 | return this.getLineMeasureRecursive(host, mid, upper); 49 | } else { 50 | return this.getLineMeasureRecursive(host, lower, mid); 51 | } 52 | } 53 | 54 | private getLineCount(host: HTMLElement, contentLength: number): number { 55 | const probeString = "m".repeat(contentLength); 56 | const probeContainer = document.createElement("div"); 57 | probeContainer.dataset.measurableLine = ""; // Must get same css as the real ine 58 | const probeElement = document.createElement("span"); 59 | probeElement.innerHTML = probeString; 60 | probeElement.style.wordBreak = "break-all"; 61 | probeContainer.appendChild(probeElement); 62 | 63 | host.appendChild(probeContainer); 64 | const lineCount = probeElement.getClientRects().length; 65 | probeContainer.remove(); 66 | return lineCount; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/server/public/src/components/text-editor/sync.service.ts: -------------------------------------------------------------------------------- 1 | import type { ApiService } from "../../services/api/api.service"; 2 | import type { ComponentRefService } from "../../services/component-reference/component-ref.service"; 3 | import type { DocumentRefService } from "../../services/document-reference/document.service"; 4 | import type { NotificationService } from "../../services/notification/notification.service"; 5 | import type { RemoteClientService } from "../../services/remote/remote-client.service"; 6 | import type { RouteService } from "../../services/route/route.service"; 7 | import type { WindowRefService } from "../../services/window-reference/window.service"; 8 | import type { CompileService } from "./compiler/compile.service"; 9 | import type { HistoryService } from "./history/history.service"; 10 | import type { TrackChangeService } from "./track-change.service"; 11 | 12 | export class SyncService { 13 | constructor( 14 | private apiService: ApiService, 15 | private historyService: HistoryService, 16 | private trackChangeService: TrackChangeService, 17 | private notificationService: NotificationService, 18 | private remoteClientService: RemoteClientService, 19 | private componentRefs: ComponentRefService, 20 | private formatService: CompileService, 21 | private routeService: RouteService, 22 | private windowRef: WindowRefService, 23 | private documentRef: DocumentRefService 24 | ) {} 25 | 26 | async saveFile() { 27 | const host = this.componentRefs.textEditor.host; 28 | this.formatService.compile(host); 29 | const lines = [...host.querySelectorAll("[data-line]")] as HTMLElement[]; 30 | const note = this.formatService.getPortableText(lines); 31 | // TODO ensure any required metadata fields, e.g. title and ctime 32 | 33 | const { id } = this.routeService.getNoteConfigFromUrl(); 34 | 35 | try { 36 | if (id) { 37 | this.trackChangeService.set(this.historyService.peek()!.textContent, false); 38 | this.historyService.save(host); 39 | const result = await this.apiService.updateNote(id, note); 40 | 41 | this.documentRef.document.title = result.title; 42 | } else { 43 | this.trackChangeService.set(this.historyService.peek()!.textContent, false, false); 44 | const result = await this.apiService.createNote(note); 45 | this.remoteClientService.notifyNoteCreated({ id: result.id, title: result.title }); 46 | this.windowRef.window.history.replaceState(null, result.title, `/?id=${result.id}`); 47 | 48 | this.documentRef.document.title = result.title; 49 | } 50 | 51 | this.notificationService.displayMessage("Saved"); 52 | } catch (error) { 53 | this.notificationService.displayMessage("Error saving note"); 54 | } 55 | } 56 | 57 | async checkAllFileVersions() { 58 | this.notificationService.displayMessage("Checking…"); 59 | 60 | try { 61 | const result = await this.apiService.getVersionStatus(); 62 | if (result.isUpToDate) { 63 | this.notificationService.displayMessage(result.message); 64 | } else { 65 | this.notificationService.displayMessage(result.message, "warning"); 66 | } 67 | } catch (error) { 68 | this.componentRefs.statusBar.setMessage("Error checking version", "error"); 69 | } 70 | } 71 | 72 | async syncAllFileVersions() { 73 | this.componentRefs.statusBar.setMessage("Sync…"); 74 | 75 | try { 76 | const result = await this.apiService.syncVersions(); 77 | this.componentRefs.statusBar.setMessage(result.message); 78 | } catch (error) { 79 | this.componentRefs.statusBar.setMessage("Error syncing versions", "error"); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /packages/server/public/src/components/text-editor/text-editor.css: -------------------------------------------------------------------------------- 1 | s2-text-editor:focus-within { 2 | outline: var(--theme-divider-width) solid var(--theme-outline-color); 3 | } 4 | 5 | #content-host { 6 | background-color: var(--theme-background-rest); 7 | white-space: break-spaces; 8 | word-break: break-all; 9 | line-break: anywhere; 10 | font-family: var(--theme-font-monospace); 11 | line-height: 1.4; 12 | outline: none; 13 | caret-color: var(--theme-text-color); 14 | padding: 16px 0; 15 | overflow-y: scroll; 16 | height: 100%; 17 | } 18 | 19 | #content-host * { 20 | caret-color: inherit; 21 | } 22 | 23 | [data-measurable-line] { 24 | padding: 0 16px; 25 | } 26 | [data-line] { 27 | padding: 0 16px; 28 | display: flex; 29 | } 30 | [data-line] [data-indent] { 31 | flex: 0 0 auto; 32 | } 33 | [data-line] [data-wrap] { 34 | flex: 1 1 auto; 35 | } 36 | 37 | [data-line][data-caret-selected], 38 | [data-line][data-caret-collapsed] { 39 | background-color: var(--theme-background-active); 40 | } 41 | 42 | [data-line="meta"] [data-meta-value="title"] { 43 | font-weight: 700; 44 | } 45 | [data-line="meta"] [data-meta-value="title"] { 46 | color: var(--theme-title-color); 47 | } 48 | 49 | [data-line="blank"] [data-empty-content] { 50 | /* since Chromium 89, zero-width elements can no longer display caret. */ 51 | min-width: 1px; 52 | } 53 | 54 | [data-line="heading"][data-heading-level="1"], 55 | [data-line="heading"][data-heading-level="4"] { 56 | color: var(--theme-h1-color); 57 | } 58 | 59 | [data-line="heading"][data-heading-level="2"], 60 | [data-line="heading"][data-heading-level="5"] { 61 | color: var(--theme-h2-color); 62 | } 63 | 64 | [data-line="heading"][data-heading-level="3"], 65 | [data-line="heading"][data-heading-level="6"] { 66 | color: var(--theme-h3-color); 67 | } 68 | 69 | [data-line="list"] [data-list-marker] { 70 | color: var(--theme-list-marker-color); 71 | font-weight: 700; 72 | } 73 | 74 | [data-link] .link__title { 75 | color: var(--theme-link-color); 76 | } 77 | 78 | [data-link] .link__target { 79 | color: var(--theme-text-color-secondary); 80 | } 81 | 82 | [data-link] :is(.link__target[data-caret-collapsed], .link__target:hover) { 83 | color: var(--theme-link-color); 84 | text-decoration: underline; 85 | cursor: pointer; 86 | } 87 | 88 | [data-url] { 89 | color: var(--theme-link-color); 90 | } 91 | 92 | [data-url][data-caret-collapsed], 93 | [data-url]:hover { 94 | text-decoration: underline; 95 | cursor: pointer; 96 | } 97 | -------------------------------------------------------------------------------- /packages/server/public/src/components/text-editor/track-change.service.ts: -------------------------------------------------------------------------------- 1 | import type { NotificationService } from "../../services/notification/notification.service"; 2 | import type { WindowRefService } from "../../services/window-reference/window.service"; 3 | 4 | export class TrackChangeService { 5 | private savedText!: string | null; 6 | private _isDirty = false; 7 | private _isNew = false; 8 | 9 | constructor(private notificationService: NotificationService, private windowRef: WindowRefService) { 10 | this.windowRef.window.addEventListener("beforeunload", (event) => { 11 | if (this._isDirty) { 12 | event.preventDefault(); 13 | event.returnValue = ""; 14 | } 15 | }); 16 | } 17 | 18 | init({ isNew = false, isDirty = false } = {}) { 19 | this._isNew = isNew; 20 | this._isDirty = isDirty; 21 | if (this._isNew) { 22 | this.notificationService.setChangeStatus("new"); 23 | } else { 24 | this.notificationService.setChangeStatus(isDirty ? "dirty" : "clean"); 25 | } 26 | } 27 | 28 | trackByText(text?: string) { 29 | if (text === undefined) return; 30 | const isDirty = this.savedText !== text; 31 | this.trackByState(isDirty); 32 | } 33 | 34 | trackByState(isDirty: boolean) { 35 | if (isDirty !== this._isDirty) { 36 | this._isDirty = isDirty; 37 | if (!this._isNew) { 38 | this.notificationService.setChangeStatus(isDirty ? "dirty" : "clean"); 39 | } 40 | } 41 | } 42 | 43 | /** 44 | * Set text to null indicates the state has no text 45 | */ 46 | set(text: string | null, isDirty: boolean, isNew: boolean = false) { 47 | this.savedText = text; 48 | 49 | if (isNew !== undefined) { 50 | this._isNew = isNew; 51 | } 52 | 53 | this._isDirty = isDirty; 54 | if (!this._isNew) { 55 | this.notificationService.setChangeStatus(isDirty ? "dirty" : "clean"); 56 | } 57 | } 58 | 59 | isNew() { 60 | return this._isNew; 61 | } 62 | 63 | isDirty() { 64 | return this._isDirty; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/server/public/src/pages/home/home.css: -------------------------------------------------------------------------------- 1 | body { 2 | display: grid; 3 | grid-template: 4 | "main" 1fr 5 | "bottom-panel" auto 6 | "footer" auto / 1fr; 7 | gap: var(--theme-divider-width); 8 | padding: var(--theme-divider-width); 9 | } 10 | 11 | s2-command-bar { 12 | position: absolute; 13 | top: 0px; 14 | left: 50%; 15 | width: 100%; 16 | max-width: 640px; 17 | transform: translateX(-50%); 18 | } 19 | 20 | s2-text-editor { 21 | grid-area: main; 22 | min-height: 0; /* allow it to shrink */ 23 | } 24 | 25 | s2-reference-panel { 26 | grid-area: bottom-panel; 27 | max-height: 25vh; 28 | } 29 | 30 | s2-status-bar { 31 | grid-area: footer; 32 | margin: calc(-1 * var(--theme-divider-width)); /* blend into gutter */ 33 | } 34 | -------------------------------------------------------------------------------- /packages/server/public/src/pages/home/index.css: -------------------------------------------------------------------------------- 1 | /* Settings – used with preprocessors and contain font, colors definitions, etc. */ 2 | @import url("../../styles/01-settings.css"); 3 | 4 | /* Tools – globally used mixins and functions. It’s important not to output any CSS in the first 2 layers. */ 5 | @import url("../../styles/02-tools.css"); 6 | 7 | /* Generic – reset and/or normalize styles, box-sizing definition, etc. This is the first layer which generates actual CSS. */ 8 | @import url("../../styles/03-generic.css"); 9 | 10 | /* Elements – styling for bare HTML elements (like H1, A, etc.). These come with default styling from the browser so we can redefine them here. */ 11 | @import url("../../styles/04-elements.css"); 12 | 13 | /* Components – specific UI components. This is where the majority of our work takes place and our UI components are often composed of Objects and Components */ 14 | @import url("./home.css"); 15 | @import url("../../components/command-bar/command-bar.css"); 16 | @import url("../../components/text-editor/text-editor.css"); 17 | @import url("../../components/status-bar/status-bar.css"); 18 | @import url("../../components/reference-panel/reference-panel.css"); 19 | @import url("../../components/tag-list/tag-list.css"); 20 | 21 | /* Utilities – utilities and helper classes with ability to override anything which goes before in the triangle, eg. hide helper class */ 22 | @import url("../../styles/06-utils.css"); 23 | -------------------------------------------------------------------------------- /packages/server/public/src/pages/home/index.ts: -------------------------------------------------------------------------------- 1 | import { CommandBarComponent } from "../../components/command-bar/command-bar.component.js"; 2 | import { ReferencePanelComponent } from "../../components/reference-panel/reference-panel.component.js"; 3 | import { StatusBarComponent } from "../../components/status-bar/status-bar.component.js"; 4 | import { CaretService } from "../../components/text-editor/caret.service.js"; 5 | import { CompileService } from "../../components/text-editor/compiler/compile.service.js"; 6 | import { EditService } from "../../components/text-editor/edit.service.js"; 7 | import { HistoryService } from "../../components/text-editor/history/history.service.js"; 8 | import { InputService } from "../../components/text-editor/input.service.js"; 9 | import { LineQueryService } from "../../components/text-editor/line-query.service.js"; 10 | import { MeasureService } from "../../components/text-editor/measure.service.js"; 11 | import { SyncService } from "../../components/text-editor/sync.service.js"; 12 | import { TextEditorComponent } from "../../components/text-editor/text-editor.component.js"; 13 | import { TrackChangeService } from "../../components/text-editor/track-change.service.js"; 14 | import { ApiService } from "../../services/api/api.service.js"; 15 | import { ComponentRefService } from "../../services/component-reference/component-ref.service.js"; 16 | import { DocumentRefService } from "../../services/document-reference/document.service.js"; 17 | import { NotificationService } from "../../services/notification/notification.service.js"; 18 | import { PreferencesService } from "../../services/preferences/preferences.service.js"; 19 | import { QueryService } from "../../services/query/query.service.js"; 20 | import { RemoteClientService } from "../../services/remote/remote-client.service.js"; 21 | import { RemoteHostService } from "../../services/remote/remote-host.service.js"; 22 | import { RouteService } from "../../services/route/route.service.js"; 23 | import { WindowRefService } from "../../services/window-reference/window.service.js"; 24 | import { di } from "../../utils/dependency-injector.js"; 25 | 26 | di.registerClass(ComponentRefService, []); 27 | di.registerClass(QueryService, []); 28 | di.registerClass(RouteService, []); 29 | di.registerClass(NotificationService, [ComponentRefService]); 30 | di.registerClass(ApiService, [QueryService]); 31 | di.registerClass(DocumentRefService, []); 32 | di.registerClass(WindowRefService, []); 33 | di.registerClass(PreferencesService, [WindowRefService]); 34 | di.registerClass(MeasureService, [WindowRefService]); 35 | di.registerClass(LineQueryService, [MeasureService]); 36 | di.registerClass(CaretService, [ComponentRefService, WindowRefService, LineQueryService]); 37 | di.registerClass(HistoryService, [CaretService, LineQueryService, TrackChangeService]); 38 | di.registerClass(CompileService, [CaretService, LineQueryService]); 39 | di.registerClass(EditService, [CaretService, CompileService, LineQueryService, CompileService]); 40 | di.registerClass(InputService, [ 41 | CaretService, 42 | EditService, 43 | LineQueryService, 44 | HistoryService, 45 | TrackChangeService, 46 | ComponentRefService, 47 | WindowRefService, 48 | ]); 49 | di.registerClass(RemoteHostService, [ComponentRefService]); 50 | di.registerClass(TrackChangeService, [NotificationService, WindowRefService]); 51 | di.registerClass(RemoteClientService, []); 52 | di.registerClass(SyncService, [ 53 | ApiService, 54 | HistoryService, 55 | TrackChangeService, 56 | NotificationService, 57 | RemoteClientService, 58 | ComponentRefService, 59 | CompileService, 60 | RouteService, 61 | WindowRefService, 62 | DocumentRefService, 63 | ]); 64 | 65 | customElements.define("s2-command-bar", CommandBarComponent); 66 | customElements.define("s2-status-bar", StatusBarComponent); 67 | customElements.define("s2-text-editor", TextEditorComponent); 68 | customElements.define("s2-reference-panel", ReferencePanelComponent); 69 | -------------------------------------------------------------------------------- /packages/server/public/src/pages/settings/index.css: -------------------------------------------------------------------------------- 1 | /* Settings – used with preprocessors and contain font, colors definitions, etc. */ 2 | @import url("../../styles/01-settings.css"); 3 | 4 | /* Tools – globally used mixins and functions. It’s important not to output any CSS in the first 2 layers. */ 5 | 6 | /* Generic – reset and/or normalize styles, box-sizing definition, etc. This is the first layer which generates actual CSS. */ 7 | 8 | /* Elements – styling for bare HTML elements (like H1, A, etc.). These come with default styling from the browser so we can redefine them here. */ 9 | @import url("../../styles/04-elements.css"); 10 | 11 | /* Components – specific UI components. This is where the majority of our work takes place and our UI components are often composed of Objects and Components */ 12 | @import url("./settings.css"); 13 | @import url("../../components/settings-form/settings-form.css"); 14 | 15 | /* Utilities – utilities and helper classes with ability to override anything which goes before in the triangle, eg. hide helper class */ 16 | -------------------------------------------------------------------------------- /packages/server/public/src/pages/settings/index.ts: -------------------------------------------------------------------------------- 1 | import { SettingsFormComponent } from "../../components/settings-form/settings-form.component.js"; 2 | import { ApiService } from "../../services/api/api.service.js"; 3 | import { DiagnosticsService } from "../../services/diagnostics/diagnostics-service.js"; 4 | import { QueryService } from "../../services/query/query.service.js"; 5 | import { di } from "../../utils/dependency-injector.js"; 6 | 7 | di.registerClass(QueryService, []); 8 | di.registerClass(ApiService, [QueryService]); 9 | di.registerClass(DiagnosticsService, [ApiService]); 10 | 11 | // Do this as early as possible 12 | const diagnostics = di.getSingleton(DiagnosticsService); 13 | (async () => { 14 | (await diagnostics.init()).printToConsole(); 15 | })(); 16 | 17 | customElements.define("s2-settings-form", SettingsFormComponent); 18 | -------------------------------------------------------------------------------- /packages/server/public/src/pages/settings/settings.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 1rem; 4 | background-color: var(--base01); 5 | color: var(--theme-text-color); 6 | font-family: var(--theme-font-propotional); 7 | } 8 | -------------------------------------------------------------------------------- /packages/server/public/src/services/api/api.service.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CreateNoteInput, 3 | CreateNoteOutput, 4 | DeleteNoteInput, 5 | DeleteNoteOutput, 6 | ForcePushInput, 7 | ForcePushOutput, 8 | GetContentFromUrlInput, 9 | GetContentFromUrlOutput, 10 | GetIncomingLinksInput, 11 | GetIncomingLinksOuput, 12 | GetNoteInput, 13 | GetNoteOutput, 14 | GetRecentNotesInput, 15 | GetRecentNotesOutput, 16 | GetRecentTagsInput, 17 | GetRecentTagsOutput, 18 | GetSettingsInput, 19 | GetSettingsOutput, 20 | GetSystemInformationInput, 21 | GetSystemInformationOutput, 22 | GetVersionStatusInput, 23 | GetVersionStatusOutput, 24 | LookupTagsInput, 25 | LookupTagsOutput, 26 | OutputSuccessOrError, 27 | ResetLocalVersionInput, 28 | ResetLocalVersionOutput, 29 | SearchNoteInput, 30 | SearchNoteOutput, 31 | SetGitRemoteInput, 32 | SetGitRemoteOutput, 33 | SyncVersionsInput, 34 | SyncVersionsOutput, 35 | TestGitRemoteInput, 36 | TestGitRemoteOutput, 37 | UpdateNoteInput, 38 | UpdateNoteOutput, 39 | } from "@osmoscraft/osmosnote"; 40 | import type { ShutdownInput, ShutdownOutput } from "../../../../src/routes/shutdown.js"; 41 | import type { QueryService } from "../query/query.service.js"; 42 | 43 | export class ApiService { 44 | constructor(private queryService: QueryService) {} 45 | 46 | loadNote = (id: string) => this.safeQuery(`/api/get-note`, { id }); 47 | 48 | forcePush = () => this.safeQuery(`/api/force-push`, {}); 49 | 50 | getRecentNotes = (limit?: number) => 51 | this.safeQuery(`/api/get-recent-notes`, { limit }); 52 | 53 | getRecentTags = () => this.safeQuery(`/api/get-recent-tags`, {}); 54 | 55 | lookupTags = (phrase: string) => 56 | this.safeQuery(`/api/lookup-tags`, { 57 | phrase, 58 | }); 59 | 60 | searchNotes = (phrase: string, tags?: string[], limit?: number) => 61 | this.safeQuery(`/api/search-notes`, { 62 | phrase, 63 | tags, 64 | limit, 65 | }); 66 | 67 | createNote = (note: string) => 68 | this.safeQuery("/api/create-note", { 69 | note, 70 | }); 71 | 72 | updateNote = (id: string, note: string) => 73 | this.safeQuery(`/api/update-note`, { 74 | id, 75 | note, 76 | }); 77 | 78 | deleteNote = (id: string) => 79 | this.safeQuery(`/api/delete-note`, { 80 | id, 81 | }); 82 | 83 | getIncomingLinks = (id: string) => 84 | this.safeQuery(`/api/get-incoming-links`, { 85 | id, 86 | }); 87 | 88 | getContentFromUrl = (url: string) => 89 | this.safeQuery(`/api/get-content-from-url`, { 90 | url, 91 | }); 92 | 93 | getSettings = () => this.safeQuery(`/api/get-settings`, {}); 94 | 95 | getSystemInformation = () => 96 | this.safeQuery(`/api/get-system-information`, {}); 97 | 98 | getVersionStatus = () => this.safeQuery(`/api/get-version-status`, {}); 99 | 100 | resetLocalVersion = () => 101 | this.safeQuery(`/api/reset-local-version`, {}); 102 | 103 | setGitRemote = (url: string) => this.safeQuery(`/api/set-git-remote`, { url }); 104 | 105 | shutdown = () => this.safeQuery(`/api/shutdown`, {}); 106 | 107 | syncVersions = () => this.safeQuery(`/api/sync-versions`, {}); 108 | 109 | testGitRemote = (remoteUrl: string) => 110 | this.safeQuery(`/api/test-git-remote`, { 111 | remoteUrl, 112 | }); 113 | 114 | private async safeQuery(path: string, input: InputType) { 115 | const output = await this.queryService.query(path, input); 116 | 117 | return this.getSuccessData(output); 118 | } 119 | 120 | private getSuccessData(output: OutputSuccessOrError): T { 121 | if (output.error) throw output.error; 122 | 123 | return output.data!; 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /packages/server/public/src/services/component-reference/component-ref.service.ts: -------------------------------------------------------------------------------- 1 | import type { CommandBarComponent } from "../../components/command-bar/command-bar.component.js"; 2 | import type { StatusBarComponent } from "../../components/status-bar/status-bar.component.js"; 3 | import type { TextEditorComponent } from "../../components/text-editor/text-editor.component.js"; 4 | 5 | export class ComponentRefService { 6 | get commandBar() { 7 | return document.querySelector("s2-command-bar") as CommandBarComponent; 8 | } 9 | get textEditor() { 10 | return document.querySelector("s2-text-editor") as TextEditorComponent; 11 | } 12 | get statusBar() { 13 | return document.querySelector("s2-status-bar") as StatusBarComponent; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/server/public/src/services/diagnostics/diagnostics-service.ts: -------------------------------------------------------------------------------- 1 | import type { GetSystemInformationOutput } from "@osmoscraft/osmosnote"; 2 | import type { ApiService } from "../api/api.service"; 3 | 4 | interface FrontendInformation { 5 | clipboardEnabled: boolean; 6 | customElementEnabled: boolean; 7 | localStorageEnabled: boolean; 8 | } 9 | 10 | export class DiagnosticsService { 11 | private backendInformation: GetSystemInformationOutput["systemInformation"] | null = null; 12 | private frontendInformation: FrontendInformation | null = null; 13 | 14 | constructor(private apiService: ApiService) {} 15 | 16 | async init() { 17 | this.backendInformation = (await this.apiService.getSystemInformation())?.systemInformation; 18 | this.frontendInformation = this.collectFrontendDiagnostics(); 19 | return this; 20 | } 21 | 22 | printToConsole() { 23 | console.log(`Frontend`, JSON.stringify(this.frontendInformation, null, 2)); 24 | console.log(`Backend`, JSON.stringify(this.backendInformation, null, 2)); 25 | return this; 26 | } 27 | 28 | private collectFrontendDiagnostics(): FrontendInformation { 29 | const clipboardEnabled = typeof window?.navigator?.clipboard?.writeText === "function"; 30 | const customElementEnabled = typeof window?.customElements?.define === "function"; 31 | const localStorageEnabled = typeof window?.localStorage?.setItem === "function"; 32 | 33 | return { 34 | clipboardEnabled, 35 | customElementEnabled, 36 | localStorageEnabled, 37 | }; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/server/public/src/services/document-reference/document.service.ts: -------------------------------------------------------------------------------- 1 | export class DocumentRefService { 2 | get document() { 3 | return document; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/server/public/src/services/notification/notification.service.ts: -------------------------------------------------------------------------------- 1 | import type { ChangeSatus } from "../../components/status-bar/status-bar.component"; 2 | import type { ComponentRefService } from "../component-reference/component-ref.service"; 3 | 4 | export class NotificationService { 5 | constructor(private componentRefs: ComponentRefService) {} 6 | 7 | displayMessage(text: string, kind: "error" | "info" | "warning" = "info") { 8 | this.componentRefs.statusBar.setMessage(text, kind); 9 | } 10 | 11 | setChangeStatus(status: ChangeSatus) { 12 | this.componentRefs.statusBar.setChangeStatus(status); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/server/public/src/services/preferences/preferences.service.ts: -------------------------------------------------------------------------------- 1 | import type { WindowRefService } from "../window-reference/window.service"; 2 | 3 | export interface Preferences { 4 | spellcheck: boolean; 5 | } 6 | 7 | const DEFAULT_PREFERENCES: Preferences = { 8 | spellcheck: false, 9 | }; 10 | 11 | const STORAGE_KEY = "s2-preferences"; 12 | 13 | export class PreferencesService { 14 | constructor(private windowRef: WindowRefService) {} 15 | 16 | getPreferences(): Preferences { 17 | let preferencesString = this.windowRef.window.localStorage.getItem(STORAGE_KEY); 18 | if (preferencesString === null) { 19 | this.resetPreferences(); 20 | preferencesString = this.windowRef.window.localStorage.getItem(STORAGE_KEY)!; 21 | } 22 | 23 | let preferences: Preferences; 24 | 25 | try { 26 | preferences = JSON.parse(preferencesString); 27 | } catch (error) { 28 | console.error(error); 29 | this.resetPreferences(); 30 | preferencesString = this.windowRef.window.localStorage.getItem(STORAGE_KEY)!; 31 | preferences = JSON.parse(preferencesString); 32 | } 33 | 34 | return preferences; 35 | } 36 | 37 | updatePreferences(update: Partial): Preferences { 38 | const existing = this.getPreferences(); 39 | const updated: Preferences = { ...existing, ...update }; 40 | 41 | this.windowRef.window.localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); 42 | return updated; 43 | } 44 | 45 | private resetPreferences() { 46 | this.windowRef.window.localStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_PREFERENCES)); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/server/public/src/services/query/query.service.ts: -------------------------------------------------------------------------------- 1 | import type { OutputSuccessOrError } from "@osmoscraft/osmosnote"; 2 | 3 | export class QueryService { 4 | /** 5 | * @param {string} url 6 | * @param {any} input 7 | */ 8 | async query(url: string, input: InputType): Promise> { 9 | try { 10 | const response = await fetch(url, { 11 | method: "POST", 12 | headers: { 13 | "Content-Type": "application/json", 14 | }, 15 | body: JSON.stringify(input), 16 | }); 17 | 18 | const result = await response.json(); 19 | return result; 20 | } catch (error: any) { 21 | return { 22 | error, 23 | }; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/server/public/src/services/remote/remote-client.service.ts: -------------------------------------------------------------------------------- 1 | import { emit } from "../../utils/events.js"; 2 | 3 | export interface NoteCreatedDetail { 4 | id: string; 5 | title: string; 6 | } 7 | 8 | export class RemoteClientService { 9 | notifyNoteCreated(detail: NoteCreatedDetail) { 10 | try { 11 | if (window.opener && (window.opener as Window)?.location?.host === location.host) { 12 | emit(window.opener, "remote-service:child-note-created", { 13 | detail, 14 | }); 15 | } 16 | } catch (error) { 17 | console.log("[window-bridge] opener is not the same host"); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/server/public/src/services/remote/remote-host.service.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentRefService } from "../component-reference/component-ref.service.js"; 2 | 3 | export interface NoteCreatedDetail { 4 | id: string; 5 | title: string; 6 | } 7 | 8 | declare global { 9 | interface GlobalEventHandlersEventMap { 10 | "remote-service:child-note-created": CustomEvent; 11 | } 12 | } 13 | 14 | export class RemoteHostService { 15 | private callbackWithCleanUp: null | ((ev: CustomEvent) => any) = null; 16 | 17 | constructor(private componentRefs: ComponentRefService) { 18 | this.handleCancel = this.handleCancel.bind(this); 19 | } 20 | 21 | runOnNewNote(openUrl: string, callback: (ev: CustomEvent) => any) { 22 | // we can handle one action at a time, so stop handling any previous action 23 | this.cancelInsertLinkOnSave(); 24 | 25 | this.callbackWithCleanUp = (ev: CustomEvent) => { 26 | callback(ev); 27 | this.cancelInsertLinkOnSave(); 28 | }; 29 | 30 | this.componentRefs.statusBar.setMessage("Waiting for remote window event, [PRESS ANY KEY] to cancel", "warning"); 31 | 32 | window.addEventListener("remote-service:child-note-created", this.callbackWithCleanUp); 33 | window.addEventListener("keydown", this.handleCancel, { capture: true }); 34 | 35 | window.open(openUrl); 36 | } 37 | 38 | private handleCancel(e: KeyboardEvent) { 39 | this.cancelInsertLinkOnSave(); 40 | this.componentRefs.statusBar.setMessage(`Cancelled`); 41 | console.log("[command-bar] cancelled handling child note created"); 42 | 43 | e.preventDefault(); 44 | e.stopPropagation(); 45 | } 46 | 47 | private cancelInsertLinkOnSave() { 48 | window.removeEventListener("keydown", this.handleCancel, { capture: true }); 49 | if (this.callbackWithCleanUp) { 50 | window.removeEventListener("remote-service:child-note-created", this.callbackWithCleanUp); 51 | this.callbackWithCleanUp = null; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/server/public/src/services/route/route.service.ts: -------------------------------------------------------------------------------- 1 | export interface UrlNoteConfig { 2 | id?: string; 3 | title?: string; 4 | /** 5 | * the initial content for the note, in plaintext, not markdown. 6 | */ 7 | content?: string; 8 | url?: string; 9 | } 10 | 11 | export class RouteService { 12 | getNoteConfigFromUrl(): UrlNoteConfig { 13 | const url = new URL(location.href); 14 | const searchParams = new URLSearchParams(url.search); 15 | 16 | const rawTitle = searchParams.get("title")?.trim(); 17 | const rawId = searchParams.get("id")?.trim(); 18 | const rawContent = searchParams.get("content")?.trim(); 19 | const rawUrl = searchParams.get("url")?.trim(); 20 | 21 | // a parameter must have length 22 | const title = rawTitle ? rawTitle : undefined; 23 | const id = rawId ? rawId : undefined; 24 | const content = rawContent ? rawContent : undefined; 25 | const metadataUrl = rawUrl ? rawUrl : undefined; 26 | 27 | return { 28 | title, 29 | id, 30 | content, 31 | url: metadataUrl, 32 | }; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/server/public/src/services/window-reference/window.service.ts: -------------------------------------------------------------------------------- 1 | export class WindowRefService { 2 | get window() { 3 | return window; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/server/public/src/styles/01-settings.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* scheme: "Gruvbox dark, medium" 3 | author: "Dawid Kurek (dawikur@gmail.com), morhetz (https://github.com/morhetz/gruvbox)" */ 4 | --base00: #282828; /* ---- */ 5 | --base01: #3c3836; /* --- */ 6 | --base02: #504945; /* -- */ 7 | --base03: #665c54; /* - */ 8 | --base04: #bdae93; /* + */ 9 | --base05: #d5c4a1; /* ++ */ 10 | --base06: #ebdbb2; /* +++ */ 11 | --base07: #fbf1c7; /* ++++ */ 12 | --base08: #fb4934; /* red */ 13 | --base09: #fe8019; /* orange */ 14 | --base0A: #fabd2f; /* yellow */ 15 | --base0B: #b8bb26; /* green */ 16 | --base0C: #8ec07c; /* aqua/cyan */ 17 | --base0D: #83a598; /* blue */ 18 | --base0E: #d3869b; /* purple */ 19 | --base0F: #d65d0e; /* brown */ 20 | 21 | /* derived */ 22 | --theme-background-rest: var(--base01); 23 | --theme-background-active: var(--base00); 24 | --theme-menu-option-background-rest: var(--base00); 25 | --theme-menu-option-background-alt: var(--base03); /* active but out of focus */ 26 | --theme-menu-option-background-active: var(--base04); 27 | --theme-text-color: var(--base06); 28 | --theme-text-selection-background: var(--base05); 29 | --theme-text-color-accent: var(--base09); /* non-interactive accents, e.g. tag prefix */ 30 | --theme-text-color-secondary: var(--base03); 31 | --theme-text-color-knockout: var(--base00); /* used on solid color background */ 32 | --theme-text-color-ghost: var(--base01); /* symbols, disabled content */ 33 | --theme-divider-color: var(--base03); 34 | --theme-divider-width: 1px; 35 | --theme-outline-color: var(--base0D); 36 | --theme-title-color: var(--base0A); 37 | --theme-link-color: var(--base0D); 38 | --theme-h1-color: var(--base0C); 39 | --theme-h2-color: var(--base0E); 40 | --theme-h3-color: var(--base0B); 41 | --theme-h4-color: var(--base0C); 42 | --theme-h5-color: var(--base0E); 43 | --theme-h6-color: var(--base0B); 44 | --theme-list-marker-color: var(--base0A); 45 | --theme-success-background: var(--base0C); 46 | --theme-warning-background: var(--base09); 47 | --theme-error-background: var(--base08); 48 | --theme-scrollbar-background: transparent; 49 | --theme-scrollbar-blur-thumb-rest: transparent; 50 | --theme-scrollbar-blur-thumb-hover: transparent; 51 | --theme-scrollbar-focus-thumb-rest: var(--base01); 52 | --theme-scrollbar-focus-thumb-hover: var(--base03); 53 | 54 | /* typograph */ 55 | --theme-font-monospace: monospace; 56 | --theme-font-propotional: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, 57 | "Helvetica Neue", sans-serif; 58 | } 59 | -------------------------------------------------------------------------------- /packages/server/public/src/styles/02-tools.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmoscraft/osmosnote/0fd06c6aecea60cf219da016f33f35c48541b792/packages/server/public/src/styles/02-tools.css -------------------------------------------------------------------------------- /packages/server/public/src/styles/03-generic.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | } 5 | 6 | body { 7 | margin: 0; 8 | background-color: var(--theme-divider-color); /* negative space becomes gap */ 9 | color: var(--theme-text-color); 10 | font-family: var(--theme-font-propotional); 11 | } 12 | 13 | * { 14 | box-sizing: border-box; 15 | } 16 | 17 | ::-webkit-scrollbar { 18 | width: 16px; 19 | height: 16px; 20 | background-color: var(--theme-scrollbar-background); 21 | } 22 | 23 | ::-webkit-scrollbar-thumb { 24 | border-radius: 8px; 25 | width: 8px; 26 | height: 8px; 27 | border: 4px solid var(--theme-scrollbar-background); 28 | background-clip: padding-box; 29 | 30 | background-color: var(--theme-scrollbar-blur-thumb-rest); 31 | } 32 | 33 | :focus-within::-webkit-scrollbar-thumb { 34 | --thumb-color-rest: var(--theme-scrollbar-focus-thumb-rest); 35 | } 36 | 37 | ::-webkit-scrollbar-thumb:hover { 38 | background-color: var(--theme-scrollbar-blur-thumb-hover); 39 | } 40 | :focus-within::-webkit-scrollbar-thumb { 41 | background-color: var(--theme-scrollbar-focus-thumb-hover); 42 | } 43 | 44 | ::-webkit-scrollbar-corner { 45 | background-color: var(--theme-scrollbar-background); 46 | } 47 | -------------------------------------------------------------------------------- /packages/server/public/src/styles/04-elements.css: -------------------------------------------------------------------------------- 1 | a { 2 | color: var(--base0D); 3 | text-decoration: none; 4 | } 5 | 6 | .btn--box { 7 | min-width: 5rem; 8 | padding: 0.5rem 1rem; 9 | font-size: 1rem; 10 | color: var(--base01); 11 | background-color: var(--base0D); 12 | font-weight: bold; 13 | border: 1px solid transparent; 14 | border-radius: 4px; 15 | display: inline-flex; 16 | align-items: center; 17 | justify-content: center; 18 | cursor: pointer; 19 | outline: none; 20 | } 21 | 22 | .btn--box:active { 23 | opacity: 0.75; 24 | } 25 | 26 | .btn--box:focus-visible { 27 | outline: none; 28 | } 29 | 30 | .btn--neutral { 31 | color: var(--base06); 32 | background-color: var(--base03); 33 | } 34 | .btn--neutral:is(:hover, :focus-visible) { 35 | color: var(--base00); 36 | background-color: var(--base0D); 37 | } 38 | 39 | .btn--danger { 40 | color: var(--base08); 41 | background-color: var(--base00); 42 | } 43 | .btn--danger:is(:hover, :focus-visible) { 44 | color: var(--base00); 45 | background-color: var(--base08); 46 | } 47 | -------------------------------------------------------------------------------- /packages/server/public/src/styles/06-utils.css: -------------------------------------------------------------------------------- 1 | .t--ghost { 2 | color: var(--theme-text-color-ghost); 3 | } 4 | 5 | .t--secondary { 6 | color: var(--theme-text-color-secondary); 7 | } 8 | 9 | .t--bold { 10 | font-weight: 700; 11 | } 12 | 13 | .t--single-line { 14 | overflow: hidden; 15 | white-space: nowrap; 16 | text-overflow: ellipsis; 17 | } 18 | -------------------------------------------------------------------------------- /packages/server/public/src/utils/clipboard.ts: -------------------------------------------------------------------------------- 1 | export async function readClipboardText() { 2 | return navigator.clipboard.readText(); 3 | } 4 | 5 | export function writeClipboardText(text: string) { 6 | return navigator.clipboard.writeText(text); 7 | } 8 | -------------------------------------------------------------------------------- /packages/server/public/src/utils/dependency-injector.ts: -------------------------------------------------------------------------------- 1 | type Class = new (...args: any[]) => T; 2 | 3 | type Classes = Tuple extends any[] 4 | ? { 5 | [Index in keyof Tuple]: Class; 6 | } 7 | : []; 8 | 9 | export class DependencyInjector { 10 | depMap = new Map, Class[]>(); 11 | instanceMap = new Map, any>(); 12 | 13 | registerClass>(klass: K, deps: Classes>) { 14 | this.depMap.set(klass, deps); 15 | } 16 | 17 | getSingleton(klass: { new (...args: any[]): T }): T { 18 | const existingInstance = this.instanceMap.get(klass); 19 | if (existingInstance) return existingInstance; 20 | 21 | const instance = this.createShallow(klass); 22 | this.instanceMap.set(klass, instance); 23 | 24 | return instance; 25 | } 26 | 27 | createShallow(klass: { new (...args: any[]): T }): T { 28 | const depKlasses = this.depMap.get(klass)!; 29 | const depInstances = depKlasses.map((depKlass) => this.getSingleton(depKlass)); 30 | 31 | const newInstance = new klass(...depInstances); 32 | 33 | return newInstance; 34 | } 35 | } 36 | 37 | export const di = new DependencyInjector(); 38 | -------------------------------------------------------------------------------- /packages/server/public/src/utils/ensure-note-title.ts: -------------------------------------------------------------------------------- 1 | export function ensureNoteTitle(title?: string | null): string { 2 | const trimmedTitle = title?.trim(); 3 | return trimmedTitle?.length ? trimmedTitle : `New note`; 4 | } 5 | -------------------------------------------------------------------------------- /packages/server/public/src/utils/events.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | // Use this snippet to inject typing to global event types 4 | 5 | declare global { 6 | interface GlobalEventHandlersEventMap { 7 | "my-componet:event-name-1": CustomEvent; 8 | "my-componet:event-name-2": CustomEvent; 9 | } 10 | } 11 | */ 12 | 13 | /** 14 | * Emit an event with typed payload 15 | */ 16 | export function emit< 17 | K extends keyof GlobalEventHandlersEventMap, 18 | T extends InitOfCustomEvent 19 | >(source: EventTarget, type: K, init?: T) { 20 | const event = new CustomEvent(type, init); 21 | source.dispatchEvent(event); 22 | } 23 | 24 | export type InitOfCustomEvent = T extends CustomEvent ? CustomEventInit : never; 25 | -------------------------------------------------------------------------------- /packages/server/public/src/utils/get-overflow.ts: -------------------------------------------------------------------------------- 1 | export type VerticalOverflow = "none" | "top" | "bottom" | "both"; 2 | 3 | export function getVerticalOverflow(target: HTMLElement, container: HTMLElement): VerticalOverflow { 4 | const { bottom, top } = target.getBoundingClientRect(); 5 | 6 | const bottomOverflow = bottom > container.clientHeight; 7 | const topOverflow = top < 0; 8 | 9 | if (bottomOverflow && topOverflow) return "both"; 10 | if (bottomOverflow && !topOverflow) return "bottom"; 11 | if (!bottomOverflow && topOverflow) return "top"; 12 | return "none"; 13 | } 14 | -------------------------------------------------------------------------------- /packages/server/public/src/utils/global-state-factory.ts: -------------------------------------------------------------------------------- 1 | export type ValueOrSetter = T | ((prev: T) => T); 2 | 3 | export function createState(initialValue: T) { 4 | let state = initialValue; 5 | 6 | const getState: () => T = () => state; 7 | const setState = (valueOrSetter: ValueOrSetter) => { 8 | if (isSetterFunction(valueOrSetter)) { 9 | valueOrSetter(state); 10 | } else { 11 | state = valueOrSetter; 12 | } 13 | }; 14 | 15 | return [getState, setState] as const; 16 | } 17 | 18 | function isSetterFunction(valueOrSetter: ValueOrSetter): valueOrSetter is (prev: T) => T { 19 | return typeof valueOrSetter === "function"; 20 | } 21 | -------------------------------------------------------------------------------- /packages/server/public/src/utils/hyper.ts: -------------------------------------------------------------------------------- 1 | export interface CreateElementProps { 2 | class?: string[]; 3 | dataset?: any; 4 | } 5 | 6 | export type CreateFragmentInput = [children?: (string | Node)[]]; 7 | 8 | export function frag(...args: CreateFragmentInput): DocumentFragment { 9 | const [children] = args; 10 | let currentNode = document.createDocumentFragment(); 11 | 12 | if (children) { 13 | children.forEach((child) => { 14 | currentNode.append(child); 15 | }); 16 | } 17 | 18 | return currentNode; 19 | } 20 | 21 | export type CreateElementInput = [name: string, props?: CreateElementProps | null, children?: (string | Node)[]]; 22 | 23 | /** 24 | * A lightweight version of createElement for real dom, instead of vDom. 25 | */ 26 | export function elem(...args: CreateElementInput): HTMLElement { 27 | const [name, props, children] = args; 28 | let currentNode = document.createElement(name); 29 | 30 | if (props?.class) { 31 | currentNode.classList.add(...props.class); 32 | } 33 | 34 | if (props?.dataset) { 35 | Object.assign(currentNode.dataset, props.dataset); 36 | } 37 | 38 | if (children) { 39 | children.forEach((child) => { 40 | currentNode.append(child); 41 | }); 42 | } 43 | 44 | return currentNode; 45 | } 46 | -------------------------------------------------------------------------------- /packages/server/public/src/utils/keyboard.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Format `[ctrl+][alt+][shift+]` 3 | * @example 4 | * // ctrl+k 5 | * // ctrl+shift+space 6 | * // alt+` 7 | */ 8 | export function getCombo(e: KeyboardEvent): string { 9 | return `${e.ctrlKey ? "ctrl+" : ""}${e.altKey ? "alt+" : ""}${e.shiftKey ? "shift+" : ""}${normalizeKey(e.key)}`; 10 | } 11 | 12 | function normalizeKey(key: string) { 13 | switch (key) { 14 | case " ": 15 | return "space"; 16 | default: 17 | return `${key.slice(0, 1).toLowerCase()}${key.slice(1)}`; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/server/public/src/utils/sanitize-html.ts: -------------------------------------------------------------------------------- 1 | const tagsToReplace: Record = { 2 | "&": "&", 3 | "<": "<", 4 | ">": ">", 5 | '"': """, 6 | }; 7 | 8 | function replacer(unsafeChar: string): string { 9 | return tagsToReplace[unsafeChar] || unsafeChar; 10 | } 11 | 12 | export function sanitizeHtml(unsafeString: string): string { 13 | return unsafeString.replace(/[&<>"]/g, replacer); 14 | } 15 | -------------------------------------------------------------------------------- /packages/server/public/src/utils/scroll-into-view.ts: -------------------------------------------------------------------------------- 1 | import { getVerticalOverflow } from "./get-overflow.js"; 2 | 3 | /** 4 | * Scroll into view an snap to top of bottom edge of the container based on which direction the element came from 5 | */ 6 | export function scrollIntoView(target: HTMLElement, container = target.parentElement) { 7 | if (!container) return; 8 | 9 | const overflow = getVerticalOverflow(target, container); 10 | // Consider alternative: 11 | // https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoViewIfNeeded 12 | 13 | if (overflow === "bottom") { 14 | target.scrollIntoView(false); 15 | } else if (overflow === "top") { 16 | target.scrollIntoView(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/server/public/src/utils/special-characters.ts: -------------------------------------------------------------------------------- 1 | export const UI_LINE_END = "\n"; 2 | export const SRC_LINE_END = "\n"; 3 | -------------------------------------------------------------------------------- /packages/server/public/src/utils/time.ts: -------------------------------------------------------------------------------- 1 | export function getLocalTimestamp(date: Date) { 2 | const tzo = -date.getTimezoneOffset(); 3 | const dif = tzo >= 0 ? "+" : "-"; 4 | 5 | return ( 6 | date.getFullYear() + 7 | "-" + 8 | toAbsTwoDigit(date.getMonth() + 1) + 9 | "-" + 10 | toAbsTwoDigit(date.getDate()) + 11 | "T" + 12 | toAbsTwoDigit(date.getHours()) + 13 | ":" + 14 | toAbsTwoDigit(date.getMinutes()) + 15 | ":" + 16 | toAbsTwoDigit(date.getSeconds()) + 17 | dif + 18 | toAbsTwoDigit(tzo / 60) + 19 | ":" + 20 | toAbsTwoDigit(tzo % 60) 21 | ); 22 | } 23 | 24 | function toAbsTwoDigit(num: number) { 25 | var norm = Math.floor(Math.abs(num)); 26 | return norm.toString().padStart(2, "0"); 27 | } 28 | -------------------------------------------------------------------------------- /packages/server/public/src/utils/url.ts: -------------------------------------------------------------------------------- 1 | // ref: https://mathiasbynens.be/demo/url-regex 2 | // @stephenhay 3 | export const URL_PATTERN = /^https?:\/\/[^\s/$.?#].[^\s]*$/; 4 | export const URL_PATTERN_WITH_PREFIX = /^(.*?)(https?:\/\/[^\s/$.?#].[^\s]*)/; 5 | 6 | export function isUrl(input: string): boolean { 7 | return input.match(URL_PATTERN) !== null; 8 | } 9 | 10 | /** 11 | * If the input is URL, return the URL. Otherwise, null will be returned 12 | */ 13 | export function findUrl(input: string): string | null { 14 | return isUrl(input) ? input : null; 15 | } 16 | 17 | export function getUrlWithSearchParams(path: string, parameters: Record): string { 18 | const searchParams = new URLSearchParams(); 19 | Object.entries(parameters).forEach(([key, value]) => { 20 | if (value !== null && value !== undefined) { 21 | searchParams.set(key, value); 22 | } 23 | }); 24 | 25 | const composedUrl = [...searchParams.keys()].length ? `${path}?${searchParams}` : path; 26 | 27 | return composedUrl; 28 | } 29 | -------------------------------------------------------------------------------- /packages/server/public/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "lib": ["DOM", "DOM.Iterable", "esnext"], 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "module": "esnext", 8 | "strict": true, 9 | "importsNotUsedAsValues": "error", 10 | "skipDefaultLibCheck": true, 11 | "allowJs": true, 12 | "declaration": true, 13 | "outDir": "dist" 14 | }, 15 | "include": [ 16 | "src/**/*.ts" 17 | ] 18 | } -------------------------------------------------------------------------------- /packages/server/scripts/build-client.js: -------------------------------------------------------------------------------- 1 | const isWatch = process.argv.includes("--watch"); 2 | 3 | require("esbuild") 4 | .build({ 5 | entryPoints: ["public/src/pages/home/index.ts", "public/src/pages/settings/index.ts"], 6 | bundle: true, 7 | outdir: "public/dist/pages", 8 | sourcemap: true, 9 | watch: isWatch 10 | ? { 11 | onRebuild(error, result) { 12 | if (error) console.error("rebuild failed:", error); 13 | else console.log(`[${new Date().toISOString()}] rebuild successful`); 14 | }, 15 | } 16 | : undefined, 17 | }) 18 | .then(() => isWatch && console.log("watching...")) 19 | .catch(() => process.exit(1)); 20 | -------------------------------------------------------------------------------- /packages/server/scripts/build-server.js: -------------------------------------------------------------------------------- 1 | require("esbuild") 2 | .build({ 3 | platform: "node", 4 | entryPoints: ["src/main.ts"], 5 | bundle: true, 6 | sourcemap: true, 7 | target: "node14", 8 | outfile: "dist/main.js", 9 | }) 10 | .catch(() => process.exit(1)); 11 | -------------------------------------------------------------------------------- /packages/server/src/lib/create-handler.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyRequest } from "fastify"; 2 | 3 | export function createHandler(sourceHandler: (input: InputType) => Promise) { 4 | const decoratedHandler = async (request: FastifyRequest) => { 5 | try { 6 | const body = request.body; 7 | const output = await sourceHandler(request.body as InputType); 8 | return { 9 | data: output, 10 | }; 11 | } catch (error) { 12 | console.error(`[handler] caught error`, error); 13 | return { 14 | error: { 15 | name: (error as Error).name, 16 | message: (error as Error).message, 17 | stack: (error as Error).stack, 18 | }, 19 | }; 20 | } 21 | }; 22 | 23 | return decoratedHandler; 24 | } 25 | 26 | export interface OutputSuccessOrError { 27 | data?: T; 28 | error?: { 29 | name?: string; 30 | message?: string; 31 | stack?: string; 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /packages/server/src/lib/diagnostics.ts: -------------------------------------------------------------------------------- 1 | import { bold, gray, red } from "./print"; 2 | import { getRunShellError, runShell } from "./run-shell"; 3 | 4 | export interface SystemInformation { 5 | version: string | null; 6 | rgPath: string | null; 7 | rgVersion: string | null; 8 | gitPath: string | null; 9 | gitVersion: string | null; 10 | xargsPath: string | null; 11 | xargsVersion: string | null; 12 | } 13 | 14 | export async function printDiagnosticsToConsole() { 15 | const systemInformation = await getSystemInformation(); 16 | let { version, ...rest } = systemInformation; 17 | 18 | console.log(bold(`Osmos Note ${version ?? bold(red("version unknown"))}`)); 19 | const dependencyNames = Object.keys(rest); 20 | dependencyNames.forEach((dependencyNames) => { 21 | const value = (rest as any)[dependencyNames]; 22 | console.log(gray(`${dependencyNames}: ${value ? value : bold(red(value))}`)); 23 | }); 24 | } 25 | 26 | export async function getSystemInformation() { 27 | const version = await getPackageVersion(); 28 | const nodeVersion = getNodeVersion(); 29 | const rgPath = await getBinPath("rg"); 30 | const rgVersion = await getBinVersion("rg", (stdout) => stdout.split("\n")?.[0]?.replace("ripgrep ", "")); 31 | const gitPath = await getBinPath("git"); 32 | const gitVersion = await getBinVersion("git"); 33 | const xargsPath = await getBinPath("xargs"); 34 | const xargsVersion = await getBinVersion("xargs"); 35 | 36 | return { 37 | version, 38 | nodeVersion, 39 | rgPath, 40 | rgVersion, 41 | gitPath, 42 | gitVersion, 43 | xargsPath, 44 | xargsVersion, 45 | }; 46 | } 47 | 48 | export async function getPackageVersion(): Promise { 49 | try { 50 | const packageJson = require("../../package.json"); 51 | return packageJson.version; 52 | } catch (error) { 53 | console.error("[diagnostics] error getting package version"); 54 | return null; 55 | } 56 | } 57 | 58 | export async function getBinPath(bin: string): Promise { 59 | try { 60 | const result = await runShell(`which ${bin}`); 61 | const error = getRunShellError(result, `Error executing \`which ${bin}\``); 62 | if (error) { 63 | throw error; 64 | } 65 | 66 | return result.stdout.trim(); 67 | } catch (error: any) { 68 | console.error(error?.message); 69 | return null; 70 | } 71 | } 72 | 73 | export async function getBinVersion( 74 | bin: string, 75 | getVerionsFromStdout: (stdout: string) => string = (stdout) => stdout.split("\n")[0]?.split(" ")?.pop() ?? "Unknown", 76 | getVersionFlag: string = "--version" 77 | ): Promise { 78 | try { 79 | const result = await runShell(`${bin} ${getVersionFlag}`); 80 | const error = getRunShellError(result, `Error executing \`${bin} ${getVersionFlag}\``); 81 | if (error) { 82 | throw error; 83 | } 84 | 85 | return getVerionsFromStdout(result.stdout); 86 | } catch (error: any) { 87 | console.error(error?.message); 88 | return null; 89 | } 90 | } 91 | 92 | export function getNodeVersion() { 93 | return process.version; 94 | } 95 | -------------------------------------------------------------------------------- /packages/server/src/lib/exec-async.ts: -------------------------------------------------------------------------------- 1 | import util from "util"; 2 | import { exec } from "child_process"; 3 | 4 | export const execAsync = util.promisify(exec); 5 | -------------------------------------------------------------------------------- /packages/server/src/lib/filename-to-id.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { STORAGE_FILE_EXTENSION } from "./id-to-filename"; 3 | 4 | export function filenameToId(filename: string) { 5 | return path.basename(filename, STORAGE_FILE_EXTENSION); 6 | } 7 | -------------------------------------------------------------------------------- /packages/server/src/lib/get-env.ts: -------------------------------------------------------------------------------- 1 | import { homedir } from "os"; 2 | import path from "path"; 3 | 4 | export interface AppEnv { 5 | OSMOSNOTE_REPO_DIR: string; 6 | OSMOSNOTE_SERVER_PORT: number; 7 | } 8 | 9 | export function getAppEnv() { 10 | const appEnv: AppEnv = { 11 | OSMOSNOTE_REPO_DIR: process.env.OSMOSNOTE_REPO_DIR ?? path.join(homedir(), ".osmosnote/repo"), 12 | OSMOSNOTE_SERVER_PORT: parseInt(process.env.OSMOSNOTE_SERVER_PORT ?? "6683"), 13 | }; 14 | 15 | return appEnv; 16 | } 17 | -------------------------------------------------------------------------------- /packages/server/src/lib/get-search-rank-score.ts: -------------------------------------------------------------------------------- 1 | const segmenter = new Intl.Segmenter(undefined, { granularity: "word" }); 2 | 3 | export function getSearchRankScore(needle: string, haystack: string) { 4 | const querySegments = [...segmenter.segment(needle)] 5 | .filter((seg) => seg.isWordLike) 6 | .map((segment) => ({ seg: segment.segment, lowerSeg: segment.segment.toLocaleLowerCase() })); 7 | const titleSegments = [...segmenter.segment(haystack)] 8 | .filter((seg) => seg.isWordLike) 9 | .map((segment) => ({ seg: segment.segment, lowerSeg: segment.segment.toLocaleLowerCase() })); 10 | 11 | const result = titleSegments.reduce( 12 | (total, titleSegment, titlePos) => 13 | total + 14 | querySegments.reduce((subTotal, querySegment) => { 15 | const posBoost = titlePos === 0 ? 5 : 1; 16 | switch (true) { 17 | case titleSegment.seg === querySegment.seg: 18 | return 8 * posBoost + subTotal; 19 | case titleSegment.lowerSeg === querySegment.lowerSeg: 20 | return 5 * posBoost + subTotal; 21 | case titleSegment.lowerSeg.startsWith(querySegment.lowerSeg): 22 | return 3 * posBoost + subTotal; 23 | case titleSegment.lowerSeg.endsWith(querySegment.lowerSeg): 24 | return 2 + subTotal; 25 | case titleSegment.lowerSeg.includes(querySegment.lowerSeg): 26 | return 1 + subTotal; 27 | default: 28 | return subTotal; 29 | } 30 | }, 0), 31 | 0 32 | ); 33 | 34 | return result; 35 | } 36 | -------------------------------------------------------------------------------- /packages/server/src/lib/get-timestamp-id.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get id in the `YYYYMMDDhhmmssll` format for current UTC time 3 | */ 4 | export function getTimestampId(): string { 5 | const now = new Date(); 6 | const milliseconds = now.getMilliseconds().toString().padStart(3, "0"); 7 | return now.toISOString().replace(/-|T|:/g, "").slice(0, 12) + milliseconds; 8 | } 9 | -------------------------------------------------------------------------------- /packages/server/src/lib/id-to-filename.ts: -------------------------------------------------------------------------------- 1 | export const STORAGE_FILE_EXTENSION = ".haiku"; 2 | 3 | export function idToFilename(id: string) { 4 | return `${id}${STORAGE_FILE_EXTENSION}`; 5 | } 6 | -------------------------------------------------------------------------------- /packages/server/src/lib/note-file-io.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs/promises"; 2 | import path from "path"; 3 | import { getRepoMetadata } from "./repo-metadata"; 4 | 5 | export const readNote = async (filename: string): Promise => { 6 | const { repoDir: notesDir } = await getRepoMetadata(); 7 | 8 | const rawMarkdown = await fs.readFile(path.join(notesDir, filename), "utf-8"); 9 | 10 | return rawMarkdown; 11 | }; 12 | 13 | export const writeNote = async (filename: string, data: string): Promise => { 14 | const { repoDir: notesDir } = await getRepoMetadata(); 15 | 16 | await fs.writeFile(path.join(notesDir, filename), data); 17 | }; 18 | 19 | export const deleteNote = async (filename: string): Promise => { 20 | const { repoDir: notesDir } = await getRepoMetadata(); 21 | 22 | const candidatePath = path.join(notesDir, filename); 23 | 24 | // extra safety check 25 | const stat = await fs.lstat(candidatePath); 26 | if (!stat.isFile()) { 27 | throw new Error("Delete note received a directory. Nothing is deleted. Likely a mistake."); 28 | } 29 | 30 | await fs.rm(path.join(notesDir, filename)); 31 | }; 32 | -------------------------------------------------------------------------------- /packages/server/src/lib/parse-note.ts: -------------------------------------------------------------------------------- 1 | export interface Note { 2 | metadata: NoteMetadata; 3 | raw: string; 4 | } 5 | 6 | export interface NoteMetadata { 7 | title: string; 8 | url?: string; 9 | tags: string[]; 10 | } 11 | 12 | export function parseNote(rawText: string): Note { 13 | const lines = rawText.split("\n"); 14 | const metaLineMatches = lines 15 | .map((line) => line.match(/^#\+(.+?): (.*)\n?/)) 16 | .filter((match) => match !== null) as RegExpMatchArray[]; 17 | 18 | const title = metaLineMatches.find(([raw, key, value]) => key === "title")?.[2] ?? ""; // extract "value" 19 | const tagsLine = metaLineMatches.find(([raw, key, value]) => key === "tags")?.[0]; // extract "raw" 20 | const tags = tagsLine ? parseTagsLine(tagsLine) : []; 21 | 22 | return { 23 | metadata: { 24 | title, 25 | tags, 26 | }, 27 | raw: rawText, 28 | }; 29 | } 30 | 31 | export function parseTagsLine(line: string): string[] { 32 | return line 33 | .slice("#+tags: ".length) 34 | .split(", ") 35 | .filter((t) => t.length > 0); 36 | } 37 | -------------------------------------------------------------------------------- /packages/server/src/lib/parse-page.ts: -------------------------------------------------------------------------------- 1 | import type { CheerioAPI } from "cheerio"; 2 | 3 | export function getPageUrl($: CheerioAPI, fallbackUrl?: string) { 4 | let url = $(`link[rel="canonical"]`).attr("href")?.trim(); 5 | 6 | if (!url) { 7 | url = fallbackUrl; 8 | } 9 | 10 | if (!url) { 11 | url = ""; 12 | } 13 | 14 | return url; 15 | } 16 | 17 | const titlePatern = /(.+)<\/title>/; 18 | 19 | export function getPageTitle($: CheerioAPI) { 20 | let title = $(`meta[property="og:title"]`).attr("content")?.trim(); 21 | 22 | if (!title) { 23 | title = $(`meta[name="twitter:title"]`).attr("content")?.trim(); 24 | } 25 | 26 | if (!title) { 27 | title = $("title").text()?.trim(); 28 | } 29 | 30 | if (!title) { 31 | title = $("h1").text()?.trim(); 32 | } 33 | 34 | if (!title) { 35 | // edge case: cheerio cannot parse YouTube title 36 | title = titlePatern.exec($.html())?.[1]; 37 | } 38 | 39 | if (!title) { 40 | title = "Untitled"; 41 | } 42 | 43 | return title; 44 | } 45 | 46 | export function getPageDescription($: CheerioAPI) { 47 | let content = $(`meta[name="description"]`).attr("content")?.trim(); 48 | 49 | if (!content) { 50 | content = $(`meta[property="og:description"]`).attr("content")?.trim(); 51 | } 52 | 53 | if (!content) { 54 | content = ""; 55 | } 56 | 57 | return content; 58 | } 59 | -------------------------------------------------------------------------------- /packages/server/src/lib/print.ts: -------------------------------------------------------------------------------- 1 | const RESET = `\x1b[0m\x1b[0m`; 2 | 3 | export function red(input: string) { 4 | return `\x1b[31m${input}\x1b[89m${RESET}`; 5 | } 6 | 7 | export function green(input: string) { 8 | return `\x1b[32m${input}\x1b[89m${RESET}`; 9 | } 10 | 11 | export function yellow(input: string) { 12 | return `\x1b[33m${input}\x1b[89m${RESET}`; 13 | } 14 | 15 | export function blue(input: string) { 16 | return `\x1b[34m${input}\x1b[89m${RESET}`; 17 | } 18 | 19 | export function gray(input: string) { 20 | return `\x1b[90m${input}\x1b[89m${RESET}`; 21 | } 22 | 23 | export function bold(input: string) { 24 | return `\x1b[1m${input}\x1b[22m${RESET}`; 25 | } 26 | -------------------------------------------------------------------------------- /packages/server/src/lib/repo-config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import { existsSync } from "fs"; 3 | import fs from "fs/promises"; 4 | import path from "path"; 5 | import { printDiagnosticsToConsole } from "./diagnostics"; 6 | import { execAsync } from "./exec-async"; 7 | import { getAppEnv } from "./get-env"; 8 | 9 | const DEFAULT_GIT_USER_NAME = "osmosnote bot"; 10 | const DEFAULT_GIT_USER_EMAIL = "osmosnote-bot@osmoscraft.org"; 11 | 12 | export async function ensureRepoConfig() { 13 | // Todo stop if there is no git 14 | await printDiagnosticsToConsole(); 15 | 16 | // Show user's name 17 | const whoAmIResult = await execAsync(`whoami`); 18 | console.log(`[config] Username: ${whoAmIResult.stdout.trim()}`); 19 | 20 | // load environment variables 21 | const debugEnvPath = path.join(process.cwd(), ".env"); 22 | if (debugEnvPath && existsSync(debugEnvPath)) { 23 | console.log(`[config] Using dotenv: ${debugEnvPath}`); 24 | const configResult = dotenv.config({ path: debugEnvPath }); 25 | if (configResult.error) { 26 | console.error(`[config] .env contains error`, configResult.error); 27 | process.exit(1); 28 | } 29 | } 30 | const env = getAppEnv(); 31 | Object.entries(env).forEach((entry) => console.log(`[config] env ${entry[0]}: ${entry[1]}`)); 32 | 33 | // ensure repo dir 34 | const repoDir = env.OSMOSNOTE_REPO_DIR; 35 | if (!existsSync(repoDir)) { 36 | console.log(`[config] Creating dir: ${repoDir}`); 37 | try { 38 | await fs.mkdir(repoDir, { recursive: true }); 39 | } catch (error) { 40 | console.error(`[config] Error creating repo directory: ${repoDir}`, error); 41 | process.exit(1); 42 | } 43 | } 44 | 45 | // ensure repo dir is managed by git 46 | const dotGit = path.join(env.OSMOSNOTE_REPO_DIR!, ".git"); 47 | if (!existsSync(dotGit)) { 48 | console.log(`[config] Initializing git: ${dotGit}`); 49 | let mainBranchCreated = false; 50 | try { 51 | const initResult = await execAsync("git init --q --initial-branch=main", { cwd: repoDir }); 52 | mainBranchCreated = true; 53 | } catch (error) { 54 | console.error(`[config] Error creating main branch. Fallback to master`); 55 | } 56 | 57 | if (!mainBranchCreated) { 58 | try { 59 | const initResult = await execAsync("git init -q", { cwd: repoDir }); 60 | if (initResult.stderr) { 61 | console.log(initResult.stderr); 62 | } 63 | } catch (error) { 64 | console.error(`[config] Error initialize git: ${repoDir}`, error); 65 | process.exit(1); 66 | } 67 | } 68 | } 69 | 70 | // ensure user.name 71 | try { 72 | const { stdout } = await execAsync(`git config --get user.name`, { cwd: repoDir }); 73 | console.log(`[config] Git username: ${stdout.trim()}`); 74 | } catch (error) { 75 | console.log(`[config] Initializing git username: ${DEFAULT_GIT_USER_NAME}`); 76 | await execAsync(`git config user.name "${DEFAULT_GIT_USER_NAME}"`, { cwd: repoDir }); 77 | } 78 | 79 | // ensure user.email 80 | try { 81 | const { stdout } = await execAsync(`git config --get user.email`, { cwd: repoDir }); 82 | console.log(`[config] Git email: ${stdout.trim()}`); 83 | } catch (error) { 84 | console.log(`[config] Initializing git user email: ${DEFAULT_GIT_USER_EMAIL}`); 85 | await execAsync(`git config user.email "${DEFAULT_GIT_USER_EMAIL}"`, { cwd: repoDir }); 86 | } 87 | 88 | console.log(`[config] System ready ${repoDir}`); 89 | } 90 | -------------------------------------------------------------------------------- /packages/server/src/lib/repo-metadata.ts: -------------------------------------------------------------------------------- 1 | import { getAppEnv } from "./get-env"; 2 | 3 | export interface RepoMetadata { 4 | repoDir: string; 5 | port: number; 6 | } 7 | 8 | export async function getRepoMetadata(): Promise<RepoMetadata> { 9 | try { 10 | const env = getAppEnv(); 11 | const port = env.OSMOSNOTE_SERVER_PORT; 12 | 13 | const appConfig = { 14 | repoDir: env.OSMOSNOTE_REPO_DIR!, 15 | port, 16 | }; 17 | 18 | return appConfig; 19 | } catch (error) { 20 | console.error(`[config] error getting app config`); 21 | throw error; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/server/src/lib/run-shell.ts: -------------------------------------------------------------------------------- 1 | import { exec, ExecException, ExecOptions } from "child_process"; 2 | 3 | export interface RunShellResult { 4 | stderr: string; 5 | stdout: string; 6 | error: ExecException | null; 7 | } 8 | 9 | /** 10 | * run shell command in a given directory 11 | * output will be string type 12 | */ 13 | export async function runShell(command: string, options?: ExecOptions): Promise<RunShellResult> { 14 | return new Promise((resolve) => { 15 | exec(command, options, (error, stdout, stderr) => 16 | resolve({ 17 | error, 18 | stdout: stdout as string, 19 | stderr: stderr as string, 20 | }) 21 | ); 22 | }); 23 | } 24 | 25 | export function getRunShellError(result: RunShellResult, message: string) { 26 | if (result.error) { 27 | console.error(message); 28 | result.error?.name.length && console.log("error name", result.error.name); 29 | result.error?.code && console.log("error code", result.error.code); 30 | result.error?.message.length && console.log("error message", result.error.message); 31 | result.stderr?.length && console.log("stderr", result.stderr); 32 | 33 | return { 34 | message, 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/server/src/lib/tag.ts: -------------------------------------------------------------------------------- 1 | export const TAG_SEPARATOR = "#"; 2 | -------------------------------------------------------------------------------- /packages/server/src/lib/unique.ts: -------------------------------------------------------------------------------- 1 | export function unique<T>(a: T[], keyFn?: (item: T) => any) { 2 | let seen = new Set(); 3 | return a.filter((item) => { 4 | let k = keyFn ? keyFn(item) : item; 5 | return seen.has(k) ? false : seen.add(k); 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /packages/server/src/main.ts: -------------------------------------------------------------------------------- 1 | import fastifyStatic from "@fastify/static"; 2 | import fastify from "fastify"; 3 | import path from "path"; 4 | import { bold, green } from "./lib/print"; 5 | import { ensureRepoConfig } from "./lib/repo-config"; 6 | import { getRepoMetadata } from "./lib/repo-metadata"; 7 | import { handleCreateNote } from "./routes/create-note"; 8 | import { handleDeleteNote } from "./routes/delete-note"; 9 | import { handleForcePush } from "./routes/force-push"; 10 | import { handleGetContentFromUrl } from "./routes/get-content-from-url"; 11 | import { handleGetIncomingLinks } from "./routes/get-incoming-links"; 12 | import { handleGetNote } from "./routes/get-note"; 13 | import { handleGetRecentNotes } from "./routes/get-recent-notes"; 14 | import { handleGetRecentTags } from "./routes/get-recent-tags"; 15 | import { handleGetSettings } from "./routes/get-settings"; 16 | import { handleGetSystemInformation } from "./routes/get-system-information"; 17 | import { handleGetVersionStatus } from "./routes/get-version-status"; 18 | import { handleLookupTags } from "./routes/lookup-tags"; 19 | import { handleResetLocalVersion } from "./routes/reset-local-version"; 20 | import { handleSearchNote } from "./routes/search-note"; 21 | import { handleSetGitRemote } from "./routes/set-git-remote"; 22 | import { handleShutdown } from "./routes/shutdown"; 23 | import { handleSyncVersions } from "./routes/sync-versions"; 24 | import { handleTestGitRemote } from "./routes/test-git-remote"; 25 | import { handleUpdateNote } from "./routes/update-note"; 26 | 27 | // check if repo has .git dir 28 | 29 | async function run() { 30 | /** 31 | * Initialization sequence 32 | * 33 | * 1. Check dependency 34 | * 1. xargs 35 | * 2. git 36 | * 2. ripgrep 37 | * 2. Check repo location from ENV 38 | * 3. If location doesn't contain osmosnote.json, finish with need-initialization status. 39 | * 4. Finish initialization sucess. Display config in console. 40 | */ 41 | await ensureRepoConfig(); 42 | const appConfig = await getRepoMetadata(); 43 | 44 | const server = fastify({ ignoreTrailingSlash: true }); 45 | 46 | const publicPath = path.join(__dirname, "../public"); 47 | server.register(fastifyStatic, { root: publicPath }); 48 | server.get("/settings", (_req, reply) => { 49 | reply.sendFile("settings.html"); 50 | }); 51 | 52 | server.post("/api/create-note", handleCreateNote); 53 | server.post("/api/delete-note", handleDeleteNote); 54 | server.post("/api/force-push", handleForcePush); 55 | server.post("/api/get-content-from-url", handleGetContentFromUrl); 56 | server.post("/api/get-incoming-links", handleGetIncomingLinks); 57 | server.post("/api/get-note", handleGetNote); 58 | server.post("/api/get-recent-notes", handleGetRecentNotes); 59 | server.post("/api/get-recent-tags", handleGetRecentTags); 60 | server.post("/api/get-settings", handleGetSettings); 61 | server.post("/api/get-system-information", handleGetSystemInformation); 62 | server.post("/api/get-version-status", handleGetVersionStatus); 63 | server.post("/api/lookup-tags", handleLookupTags); 64 | server.post("/api/reset-local-version", handleResetLocalVersion); 65 | server.post("/api/search-notes", handleSearchNote); 66 | server.post("/api/set-git-remote", handleSetGitRemote); 67 | server.post("/api/shutdown", handleShutdown); 68 | server.post("/api/sync-versions", handleSyncVersions); 69 | server.post("/api/test-git-remote", handleTestGitRemote); 70 | server.post("/api/update-note", handleUpdateNote); 71 | 72 | server.listen({ port: appConfig.port, host: "0.0.0.0" }, async (err, address) => { 73 | if (err) { 74 | console.error(err); 75 | process.exit(1); 76 | } 77 | 78 | console.log(`Server address: ${bold(green(`http://localhost:${appConfig.port}`))}`); 79 | console.log(`When running from Docker, port number is specified by host.`); 80 | }); 81 | } 82 | 83 | run(); 84 | -------------------------------------------------------------------------------- /packages/server/src/routes/create-note.ts: -------------------------------------------------------------------------------- 1 | import { getRepoMetadata } from "../lib/repo-metadata"; 2 | import { createHandler } from "../lib/create-handler"; 3 | import { getTimestampId } from "../lib/get-timestamp-id"; 4 | import { gitAdd } from "../lib/git"; 5 | import { idToFilename } from "../lib/id-to-filename"; 6 | import { writeNote } from "../lib/note-file-io"; 7 | import { parseNote } from "../lib/parse-note"; 8 | 9 | export interface CreateNoteInput { 10 | note: string; 11 | } 12 | 13 | export interface CreateNoteOutput { 14 | id: string; 15 | title: string; 16 | note: string; 17 | } 18 | 19 | export const handleCreateNote = createHandler<CreateNoteOutput, CreateNoteInput>(async (input) => { 20 | const note = input.note; 21 | const id = getTimestampId(); 22 | const filename = idToFilename(id); 23 | const config = await getRepoMetadata(); 24 | 25 | const { metadata } = parseNote(note); 26 | 27 | await writeNote(filename, note); 28 | await gitAdd(config.repoDir); 29 | 30 | return { 31 | id, 32 | note, 33 | title: metadata.title, 34 | }; 35 | }); 36 | -------------------------------------------------------------------------------- /packages/server/src/routes/delete-note.ts: -------------------------------------------------------------------------------- 1 | import { createHandler } from "../lib/create-handler"; 2 | import { idToFilename } from "../lib/id-to-filename"; 3 | import { deleteNote } from "../lib/note-file-io"; 4 | 5 | export interface DeleteNoteInput { 6 | id: string; 7 | } 8 | 9 | export interface DeleteNoteOutput {} 10 | 11 | export const handleDeleteNote = createHandler<DeleteNoteOutput, DeleteNoteInput>(async (input) => { 12 | const id = input.id; 13 | const filename = idToFilename(id); 14 | await deleteNote(filename); 15 | 16 | return {}; 17 | }); 18 | -------------------------------------------------------------------------------- /packages/server/src/routes/force-push.ts: -------------------------------------------------------------------------------- 1 | import { createHandler } from "../lib/create-handler"; 2 | import { gitFetchV2, gitForcePush, gitRemoteDefaultBranch } from "../lib/git"; 3 | import { getRepoMetadata } from "../lib/repo-metadata"; 4 | 5 | export interface ForcePushInput {} 6 | 7 | export interface ForcePushOutput { 8 | success: boolean; 9 | message?: string; 10 | } 11 | 12 | export const handleForcePush = createHandler<ForcePushOutput, ForcePushInput>(async (input) => { 13 | const config = await getRepoMetadata(); 14 | 15 | const forcePushResult = await gitForcePush(config.repoDir); 16 | if (!forcePushResult.success) { 17 | return { 18 | success: false, 19 | message: forcePushResult.message, 20 | }; 21 | } 22 | 23 | return { 24 | success: true, 25 | }; 26 | }); 27 | -------------------------------------------------------------------------------- /packages/server/src/routes/get-content-from-url.ts: -------------------------------------------------------------------------------- 1 | import { load } from "cheerio"; 2 | import { createHandler } from "../lib/create-handler"; 3 | import { getPageDescription, getPageTitle, getPageUrl } from "../lib/parse-page"; 4 | 5 | export interface GetContentFromUrlInput { 6 | url: string; 7 | } 8 | 9 | export interface GetContentFromUrlOutput { 10 | title: string; 11 | description: string; 12 | canonicalUrl: string; 13 | } 14 | 15 | export const handleGetContentFromUrl = createHandler<GetContentFromUrlOutput, GetContentFromUrlInput>(async (input) => { 16 | const url = input.url; 17 | 18 | if (!url) { 19 | throw new Error("Cannot crawl an empty URL"); 20 | } 21 | 22 | try { 23 | const response = await fetch(url); 24 | 25 | if (response.status !== 200) { 26 | throw new Error(`Fetch status (${response.status}) is not OK`); 27 | } 28 | 29 | const $ = load(await response.text()); 30 | 31 | return { 32 | title: getPageTitle($), 33 | description: getPageDescription($), 34 | canonicalUrl: getPageUrl($, url), 35 | }; 36 | } catch (error) { 37 | console.error(`Error parsing ${url}`); 38 | throw error; 39 | } 40 | }); 41 | -------------------------------------------------------------------------------- /packages/server/src/routes/get-incoming-links.ts: -------------------------------------------------------------------------------- 1 | import { getRepoMetadata } from "../lib/repo-metadata"; 2 | import { createHandler } from "../lib/create-handler"; 3 | import { filenameToId } from "../lib/filename-to-id"; 4 | import { readNote } from "../lib/note-file-io"; 5 | import { parseNote } from "../lib/parse-note"; 6 | import { runShell } from "../lib/run-shell"; 7 | 8 | export interface GetIncomingLinksInput { 9 | id: string; 10 | } 11 | 12 | export interface GetIncomingLinksOuput { 13 | incomingLinks: IncomingLink[]; 14 | } 15 | 16 | export interface IncomingLink { 17 | id: string; 18 | title: string; 19 | score: number; 20 | tags: string[]; 21 | } 22 | 23 | export const handleGetIncomingLinks = createHandler<GetIncomingLinksOuput, GetIncomingLinksInput>(async (input) => { 24 | const id = input.id; 25 | const links = await getIncomingLinks(id); 26 | 27 | return { 28 | incomingLinks: links, 29 | }; 30 | }); 31 | 32 | async function getIncomingLinks(id: string): Promise<IncomingLink[]> { 33 | const config = await getRepoMetadata(); 34 | 35 | const { error, stdout, stderr } = await runShell(`rg "\(${id}\)" --count-matches ./`, { cwd: config.repoDir }); 36 | 37 | if (error) { 38 | if (error.code === 1) { 39 | return []; 40 | } else { 41 | throw stderr; 42 | } 43 | } else if (!stdout.length) { 44 | return []; 45 | } else { 46 | const lines = stdout.trim().split("\n"); 47 | const searchResultItems = lines 48 | .map((line) => line.split(":") as [filename: string, count: string]) 49 | .map((line) => ({ filename: line[0], score: parseInt(line[1]) })); 50 | 51 | // open each note to parse its title 52 | const notesAsync = searchResultItems.map(async (item) => { 53 | const markdown = await readNote(item.filename); 54 | const parseResult = parseNote(markdown); 55 | 56 | return { 57 | id: filenameToId(item.filename), 58 | title: parseResult.metadata.title, 59 | score: item.score, 60 | tags: parseResult.metadata.tags, 61 | }; 62 | }); 63 | 64 | const notes: IncomingLink[] = await Promise.all(notesAsync); 65 | const sortedNotes = notes 66 | .sort((a, b) => a.title.localeCompare(b.title)) // sort title first to result can remain the same 67 | .sort((a, b) => b.score - a.score); 68 | 69 | return sortedNotes; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/server/src/routes/get-note.ts: -------------------------------------------------------------------------------- 1 | import { createHandler } from "../lib/create-handler"; 2 | import { idToFilename } from "../lib/id-to-filename"; 3 | import { readNote } from "../lib/note-file-io"; 4 | import { parseNote } from "../lib/parse-note"; 5 | 6 | export interface GetNoteInput { 7 | id: string; 8 | } 9 | 10 | export interface GetNoteOutput { 11 | title: string; 12 | note: string; 13 | } 14 | 15 | export const handleGetNote = createHandler<GetNoteOutput, GetNoteInput>(async (input) => { 16 | const id = input.id; 17 | const filename = idToFilename(id); 18 | const note = await readNote(filename); 19 | const parseResult = parseNote(note); 20 | 21 | return { 22 | title: parseResult.metadata.title, 23 | note, 24 | }; 25 | }); 26 | -------------------------------------------------------------------------------- /packages/server/src/routes/get-recent-notes.ts: -------------------------------------------------------------------------------- 1 | import { getRepoMetadata } from "../lib/repo-metadata"; 2 | import { createHandler } from "../lib/create-handler"; 3 | import { filenameToId } from "../lib/filename-to-id"; 4 | import { readNote } from "../lib/note-file-io"; 5 | import { parseNote } from "../lib/parse-note"; 6 | import { runShell } from "../lib/run-shell"; 7 | import { STORAGE_FILE_EXTENSION } from "../lib/id-to-filename"; 8 | 9 | const DEFAULT_LIMIT = 10; 10 | 11 | export interface GetRecentNotesOutput { 12 | notes: RecentNoteItem[]; 13 | } 14 | 15 | export interface GetRecentNotesInput { 16 | limit?: number; 17 | } 18 | 19 | export interface RecentNoteItem { 20 | id: string; 21 | title: string; 22 | tags: string[]; 23 | raw: string; 24 | } 25 | 26 | export const handleGetRecentNotes = createHandler<GetRecentNotesOutput, GetRecentNotesInput>(async (input) => { 27 | const config = await getRepoMetadata(); 28 | 29 | const notesDir = config.repoDir; 30 | const { limit = DEFAULT_LIMIT } = input; 31 | 32 | const { stdout, stderr, error } = await runShell(`ls -1t *${STORAGE_FILE_EXTENSION} | head -n ${limit}`, { 33 | cwd: notesDir, 34 | }); 35 | 36 | if (error || stderr.length) { 37 | if (stderr) console.log("[note-list] cannot list", stderr); 38 | if (error) console.log("[note-list] cannot list", error.name); 39 | 40 | return { 41 | notes: [], 42 | }; 43 | } 44 | 45 | const filenames = stdout.trim().split("\n"); 46 | 47 | const notesAsync = filenames.map(async (filename) => { 48 | const markdown = await readNote(filename); 49 | const parseResult = parseNote(markdown); 50 | 51 | return { 52 | id: filenameToId(filename), 53 | title: parseResult.metadata.title, 54 | tags: parseResult.metadata.tags, 55 | raw: parseResult.raw, 56 | }; 57 | }); 58 | 59 | const notes: RecentNoteItem[] = await Promise.all(notesAsync); 60 | 61 | return { 62 | notes, 63 | }; 64 | }); 65 | -------------------------------------------------------------------------------- /packages/server/src/routes/get-recent-tags.ts: -------------------------------------------------------------------------------- 1 | import { getRepoMetadata } from "../lib/repo-metadata"; 2 | import { createHandler } from "../lib/create-handler"; 3 | import { parseTagsLine } from "../lib/parse-note"; 4 | import { runShell } from "../lib/run-shell"; 5 | import { TAG_SEPARATOR } from "../lib/tag"; 6 | import { STORAGE_FILE_EXTENSION } from "../lib/id-to-filename"; 7 | 8 | export interface GetRecentTagsInput { 9 | limit?: number; 10 | } 11 | 12 | export interface GetRecentTagsOutput { 13 | tags: string[]; 14 | } 15 | 16 | export interface SuggestedTag { 17 | text: string; 18 | score: number; 19 | } 20 | 21 | const RECENT_TAG_LIMIT = 10; 22 | 23 | export const handleGetRecentTags = createHandler<GetRecentTagsOutput, GetRecentTagsInput>(async (input) => { 24 | const config = await getRepoMetadata(); 25 | 26 | const notesDir = config.repoDir; 27 | 28 | const limit = input.limit ?? RECENT_TAG_LIMIT; 29 | 30 | const _ = TAG_SEPARATOR; 31 | 32 | /** 33 | * List last modified and extract tags from them. 34 | * The extraction logic is similar to tag look up except it doesn't use any user input 35 | * -I hides filename 36 | * -N hides line number 37 | * --no-heading removes blank line between files 38 | */ 39 | const { stdout, stderr, error } = await runShell( 40 | String.raw`ls -1t *${STORAGE_FILE_EXTENSION} | head -n ${limit} | xargs -d "\n" rg "^#\+tags: " --no-heading --ignore-case -IN ./`, 41 | { 42 | cwd: notesDir, 43 | } 44 | ); 45 | 46 | if (error) { 47 | if (error.code === 1) { 48 | return { 49 | tags: [], 50 | }; 51 | } else { 52 | console.log(stderr); 53 | throw stderr; 54 | } 55 | } else if (!stdout.length) { 56 | return { 57 | tags: [], 58 | }; 59 | } else { 60 | const allMatchedLines = stdout.trim().split("\n"); 61 | const allTags = allMatchedLines.flatMap(parseTagsLine); 62 | 63 | const tagsByFrequency = new Map<string, number>(); 64 | allTags.forEach((tag) => { 65 | const currentCount = tagsByFrequency.get(tag); 66 | tagsByFrequency.set(tag, currentCount ? currentCount + 1 : 1); 67 | }); 68 | 69 | const sortedTags: SuggestedTag[] = [...tagsByFrequency.entries()] 70 | .map(([tag, count]) => ({ text: tag, score: count })) 71 | .sort((a, b) => b.score - a.score); 72 | 73 | const plainTextTags = sortedTags.map((tag) => tag.text); 74 | 75 | return { 76 | tags: plainTextTags, 77 | }; 78 | } 79 | }); 80 | -------------------------------------------------------------------------------- /packages/server/src/routes/get-settings.ts: -------------------------------------------------------------------------------- 1 | import { createHandler } from "../lib/create-handler"; 2 | import { gitGetRemoteUrl } from "../lib/git"; 3 | import { getRepoMetadata } from "../lib/repo-metadata"; 4 | 5 | export interface GetSettingsInput {} 6 | 7 | export interface GetSettingsOutput { 8 | remoteUrl: string | null; 9 | } 10 | 11 | export const handleGetSettings = createHandler<GetSettingsOutput, GetSettingsInput>(async (input) => { 12 | const config = await getRepoMetadata(); 13 | const remoteUrl = await gitGetRemoteUrl(config.repoDir); 14 | 15 | return { 16 | remoteUrl, 17 | }; 18 | }); 19 | -------------------------------------------------------------------------------- /packages/server/src/routes/get-system-information.ts: -------------------------------------------------------------------------------- 1 | import { createHandler } from "../lib/create-handler"; 2 | import { getSystemInformation, SystemInformation } from "../lib/diagnostics"; 3 | 4 | export interface GetSystemInformationInput {} 5 | 6 | export interface GetSystemInformationOutput { 7 | systemInformation: SystemInformation; 8 | } 9 | 10 | export const handleGetSystemInformation = createHandler<GetSystemInformationOutput, GetSystemInformationInput>( 11 | async (input) => { 12 | const systemInformation = await getSystemInformation(); 13 | 14 | return { 15 | systemInformation, 16 | }; 17 | } 18 | ); 19 | -------------------------------------------------------------------------------- /packages/server/src/routes/get-version-status.ts: -------------------------------------------------------------------------------- 1 | import { getRepoMetadata } from "../lib/repo-metadata"; 2 | import { createHandler } from "../lib/create-handler"; 3 | import { gitDiffStaged, gitDiffUnstaged, gitFetch, gitStatus } from "../lib/git"; 4 | 5 | export interface GetVersionStatusInput {} 6 | 7 | export interface GetVersionStatusOutput { 8 | message: string; 9 | isUpToDate: boolean | null; 10 | } 11 | 12 | export const handleGetVersionStatus = createHandler<GetVersionStatusOutput, GetVersionStatusInput>(async (input) => { 13 | const config = await getRepoMetadata(); 14 | const notesDir = config.repoDir; 15 | 16 | let { error: fetchError } = await gitFetch(notesDir); 17 | if (fetchError !== null) { 18 | return { 19 | message: fetchError, 20 | isUpToDate: null, 21 | }; 22 | } 23 | 24 | let { message: diffMessage, error: diffError, isDifferent } = await gitDiffUnstaged(notesDir); 25 | if (diffError !== null) { 26 | return { 27 | message: diffError, 28 | isUpToDate: null, 29 | }; 30 | } 31 | 32 | if (isDifferent) { 33 | return { 34 | message: diffMessage ?? "Unknown git unstaged diff result", 35 | isUpToDate: false, 36 | }; 37 | } 38 | 39 | ({ message: diffMessage, error: diffError, isDifferent } = await gitDiffStaged(notesDir)); 40 | if (diffError !== null) { 41 | return { 42 | message: diffError, 43 | isUpToDate: null, 44 | }; 45 | } 46 | 47 | if (isDifferent) { 48 | return { 49 | message: diffMessage ?? "Unknown git staged diff result", 50 | isUpToDate: false, 51 | }; 52 | } 53 | 54 | let { message: statusMessage, error: statusError, isUpToDate } = await gitStatus(notesDir); 55 | if (statusError !== null) { 56 | return { 57 | message: statusError, 58 | isUpToDate: null, 59 | }; 60 | } 61 | 62 | if (isUpToDate === null) { 63 | return { 64 | message: "Unknown git status", 65 | isUpToDate: null, 66 | }; 67 | } 68 | 69 | return { 70 | message: statusMessage ?? "Unknown status message", 71 | isUpToDate: isUpToDate, 72 | }; 73 | }); 74 | -------------------------------------------------------------------------------- /packages/server/src/routes/lookup-tags.ts: -------------------------------------------------------------------------------- 1 | import { getRepoMetadata } from "../lib/repo-metadata"; 2 | import { createHandler } from "../lib/create-handler"; 3 | import { parseTagsLine } from "../lib/parse-note"; 4 | import { runShell } from "../lib/run-shell"; 5 | import { TAG_SEPARATOR } from "../lib/tag"; 6 | 7 | export interface LookupTagsInput { 8 | phrase: string; 9 | } 10 | 11 | export interface LookupTagsOutput { 12 | tags: string[]; 13 | } 14 | 15 | export const handleLookupTags = createHandler<LookupTagsOutput, LookupTagsInput>(async (input) => { 16 | const phrase = input.phrase.trim(); 17 | 18 | const config = await getRepoMetadata(); 19 | const dir = config.repoDir; 20 | 21 | if (!phrase) { 22 | return { 23 | tags: [], 24 | }; 25 | } 26 | 27 | const _ = TAG_SEPARATOR; 28 | 29 | // Consider performance optimization: performance full text search and filter to results on the line that starts with "#+tags:" 30 | 31 | /** 32 | * -I hides filename 33 | * -N hides line number 34 | * --no-heading removes blank line between files 35 | */ 36 | const findAllMatchingTagLinesCommand = String.raw`rg "^#\+tags:.*?\b${phrase}" --no-heading --ignore-case -IN ./`; 37 | const { error, stdout, stderr } = await runShell(findAllMatchingTagLinesCommand, { cwd: dir }); 38 | 39 | if (error) { 40 | if (error.code === 1) { 41 | return { 42 | tags: [], 43 | }; 44 | } else { 45 | console.log(stderr); 46 | throw stderr; 47 | } 48 | } else if (!stdout.length) { 49 | return { 50 | tags: [], 51 | }; 52 | } else { 53 | const allMatchedLines = stdout.trim().split("\n"); 54 | const tagRegex = new RegExp(String.raw`\b${phrase}`, "i"); 55 | const allMatchedTags = allMatchedLines.flatMap(parseTagsLine).filter((tag) => tag.match(tagRegex)); 56 | 57 | const sortedTags = [...new Set(allMatchedTags.sort())]; 58 | 59 | return { 60 | tags: sortedTags, 61 | }; 62 | } 63 | }); 64 | -------------------------------------------------------------------------------- /packages/server/src/routes/reset-local-version.ts: -------------------------------------------------------------------------------- 1 | import { createHandler } from "../lib/create-handler"; 2 | import { gitFetchV2, gitRemoteDefaultBranch, gitRenameBranch, gitReset, gitTrackBranch } from "../lib/git"; 3 | import { getRepoMetadata } from "../lib/repo-metadata"; 4 | 5 | export interface ResetLocalVersionInput {} 6 | 7 | export interface ResetLocalVersionOutput { 8 | success: boolean; 9 | message?: string; 10 | } 11 | 12 | export const handleResetLocalVersion = createHandler<ResetLocalVersionOutput, ResetLocalVersionInput>(async (input) => { 13 | const config = await getRepoMetadata(); 14 | 15 | const remoteBranchResult = await gitRemoteDefaultBranch(config.repoDir); 16 | if (!remoteBranchResult.success) { 17 | return { 18 | success: false, 19 | message: remoteBranchResult.message, 20 | }; 21 | } 22 | 23 | const fetchResult = await gitFetchV2(config.repoDir, remoteBranchResult.branch!); 24 | if (!fetchResult.success) { 25 | return { 26 | success: false, 27 | message: fetchResult.message, 28 | }; 29 | } 30 | 31 | const resetResult = await gitReset(config.repoDir, remoteBranchResult.branch!); 32 | if (!resetResult.success) { 33 | return { 34 | success: false, 35 | message: resetResult.message, 36 | }; 37 | } 38 | 39 | const trackBranchResult = await gitTrackBranch(config.repoDir, remoteBranchResult.branch!); 40 | if (!trackBranchResult.success) { 41 | return { 42 | success: false, 43 | message: trackBranchResult.message, 44 | }; 45 | } 46 | 47 | const renameBranchResult = await gitRenameBranch(config.repoDir, remoteBranchResult.branch!); 48 | if (!renameBranchResult.success) { 49 | return { 50 | success: false, 51 | message: renameBranchResult.message, 52 | }; 53 | } 54 | 55 | return { 56 | success: true, 57 | }; 58 | }); 59 | -------------------------------------------------------------------------------- /packages/server/src/routes/set-git-remote.ts: -------------------------------------------------------------------------------- 1 | import { createHandler } from "../lib/create-handler"; 2 | import { gitSetRemoteUrl } from "../lib/git"; 3 | import { getRepoMetadata } from "../lib/repo-metadata"; 4 | 5 | export interface SetGitRemoteInput { 6 | url: string; 7 | } 8 | 9 | export interface SetGitRemoteOutput { 10 | success: boolean; 11 | message?: string; 12 | } 13 | 14 | export const handleSetGitRemote = createHandler<SetGitRemoteOutput, SetGitRemoteInput>(async (input) => { 15 | // Assumption: 16 | // Local is already a git repo 17 | // Local might already have a remote url 18 | 19 | // clean up existing remote 20 | const config = await getRepoMetadata(); 21 | const result = await gitSetRemoteUrl(config.repoDir, input.url); 22 | 23 | return { 24 | success: result.success, 25 | message: result.message, 26 | }; 27 | }); 28 | -------------------------------------------------------------------------------- /packages/server/src/routes/shutdown.ts: -------------------------------------------------------------------------------- 1 | import { createHandler } from "../lib/create-handler"; 2 | 3 | export interface ShutdownInput {} 4 | export interface ShutdownOutput {} 5 | 6 | export const handleShutdown = createHandler<ShutdownInput, ShutdownOutput>(async () => { 7 | setTimeout(() => { 8 | console.log(`[shutdown] shutdown success`); 9 | process.exit(0); 10 | }, 1000); 11 | console.log(`[shutdown] shutting down...`); 12 | return {}; 13 | }); 14 | -------------------------------------------------------------------------------- /packages/server/src/routes/sync-versions.ts: -------------------------------------------------------------------------------- 1 | import { createHandler } from "../lib/create-handler"; 2 | import { gitAdd, gitCommit, gitDiffStaged, gitLsRemoteExists, gitPull, gitPush, gitStatus } from "../lib/git"; 3 | import { getRepoMetadata } from "../lib/repo-metadata"; 4 | 5 | export interface SyncVersionsInput {} 6 | 7 | export interface SyncVersionsOutput { 8 | message: string; 9 | } 10 | 11 | export const handleSyncVersions = createHandler<SyncVersionsOutput, SyncVersionsInput>(async (input) => { 12 | const config = await getRepoMetadata(); 13 | const notesDir = config.repoDir; 14 | let error: string | null; 15 | let message: string | null; 16 | let isDifferent: boolean | null; 17 | let isUpToDate: boolean | null; 18 | 19 | ({ error } = await gitPull(notesDir)); 20 | if (error !== null) { 21 | const remoteExists = await gitLsRemoteExists(notesDir); 22 | console.log(`[sync-version] Remote does not exist. Pull skipped.`); 23 | if (remoteExists) { 24 | return { 25 | message: error, 26 | }; 27 | } 28 | } 29 | 30 | ({ error } = await gitAdd(notesDir)); 31 | if (error !== null) { 32 | return { 33 | message: error, 34 | }; 35 | } 36 | 37 | // Commit if there are any new staged changes 38 | ({ message, error, isDifferent } = await gitDiffStaged(notesDir)); 39 | if (error !== null) { 40 | return { 41 | message: error, 42 | }; 43 | } 44 | if (isDifferent) { 45 | // commit only if there is something to commit 46 | ({ error } = await gitCommit(notesDir)); 47 | if (error !== null) { 48 | return { 49 | message: error, 50 | }; 51 | } 52 | } 53 | 54 | ({ message, error, isUpToDate } = await gitStatus(notesDir)); 55 | if (error !== null) { 56 | return { 57 | message: error, 58 | isUpToDate: null, 59 | }; 60 | } 61 | 62 | if (isUpToDate) { 63 | return { 64 | message: message ?? "Already up to date.", 65 | }; 66 | } 67 | 68 | ({ message, error } = await gitPush(notesDir)); 69 | if (error !== null) { 70 | return { 71 | message: error, 72 | }; 73 | } 74 | 75 | return { 76 | message: message ?? "Unknown git push result", 77 | }; 78 | }); 79 | -------------------------------------------------------------------------------- /packages/server/src/routes/test-git-remote.ts: -------------------------------------------------------------------------------- 1 | import { createHandler } from "../lib/create-handler"; 2 | import { gitLsRemoteUrl } from "../lib/git"; 3 | import { getRepoMetadata } from "../lib/repo-metadata"; 4 | 5 | export interface TestGitRemoteInput { 6 | remoteUrl: string; 7 | } 8 | 9 | export interface TestGitRemoteOutput { 10 | success: boolean; 11 | message?: string; 12 | } 13 | 14 | export const handleTestGitRemote = createHandler<TestGitRemoteOutput, TestGitRemoteInput>(async (input) => { 15 | const config = await getRepoMetadata(); 16 | return gitLsRemoteUrl(config.repoDir, input.remoteUrl); 17 | }); 18 | -------------------------------------------------------------------------------- /packages/server/src/routes/update-note.ts: -------------------------------------------------------------------------------- 1 | import { getRepoMetadata } from "../lib/repo-metadata"; 2 | import { createHandler } from "../lib/create-handler"; 3 | import { gitAdd } from "../lib/git"; 4 | import { idToFilename } from "../lib/id-to-filename"; 5 | import { writeNote } from "../lib/note-file-io"; 6 | import { parseNote } from "../lib/parse-note"; 7 | 8 | export interface UpdateNoteInput { 9 | id: string; 10 | note: string; 11 | } 12 | 13 | export interface UpdateNoteOutput { 14 | note: string; 15 | title: string; 16 | } 17 | 18 | export const handleUpdateNote = createHandler<UpdateNoteOutput, UpdateNoteInput>(async (input) => { 19 | const id = input.id; 20 | const filename = idToFilename(id); 21 | const note = input.note; 22 | const config = await getRepoMetadata(); 23 | 24 | const parseResult = parseNote(note); 25 | 26 | await writeNote(filename, note); 27 | await gitAdd(config.repoDir); 28 | 29 | return { 30 | note, 31 | title: parseResult.metadata.title, 32 | }; 33 | }); 34 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "moduleResolution": "node", 5 | "esModuleInterop": true, 6 | "module": "commonjs", 7 | "strict": true, 8 | "importsNotUsedAsValues": "error", 9 | "skipDefaultLibCheck": true, 10 | "outDir": "dist" 11 | }, 12 | "include": ["src/**/*.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/tools/bookmarklets/README.md: -------------------------------------------------------------------------------- 1 | Use https://babeljs.io/repl to compile to es6 2 | Use https://chriszarate.github.io/bookmarkleter/ to generate bookmarklet from JavaScript source files 3 | -------------------------------------------------------------------------------- /packages/tools/bookmarklets/capture-current-page.js: -------------------------------------------------------------------------------- 1 | const searchParams = new URLSearchParams(); 2 | searchParams.set("url", getUrl()); 3 | searchParams.set("title", getTitle()); 4 | searchParams.set("content", getContent()); 5 | const captureUIrl = new URL("http://localhost:2077?" + searchParams.toString()); 6 | 7 | window.open(captureUIrl); 8 | 9 | function getUrl() { 10 | let url = document.querySelector(`link[rel="canonical"]`)?.href.trim(); 11 | 12 | if (!url) { 13 | url = location.href; 14 | } 15 | 16 | if (!url) { 17 | url = "https://"; 18 | } 19 | 20 | return url; 21 | } 22 | 23 | function getTitle() { 24 | let title = document.querySelector(`meta[property="og:title"]`)?.content.trim(); 25 | 26 | if (!title) { 27 | title = document.querySelector(`meta[name="twitter:title"]`)?.content.trim(); 28 | } 29 | 30 | if (!title) { 31 | title = document.title.trim(); 32 | } 33 | 34 | if (!title) { 35 | title = document.querySelector("h1")?.innerText.trim(); 36 | } 37 | 38 | if (!title) { 39 | title = getUrl(); 40 | } 41 | 42 | if (!title) { 43 | title = ""; 44 | } 45 | 46 | return title; 47 | } 48 | 49 | function getContent() { 50 | let content = window.getSelection()?.trim?.().toString(); 51 | 52 | if (!content) { 53 | content = document.querySelector(`meta[name="description"]`)?.content.trim(); 54 | } 55 | 56 | if (!content) { 57 | content = document.querySelector(`meta[property="og:description"]`)?.content.trim(); 58 | } 59 | 60 | if (!content) { 61 | content = ""; 62 | } 63 | 64 | return content; 65 | } 66 | -------------------------------------------------------------------------------- /packages/tools/linux-launcher/README.md: -------------------------------------------------------------------------------- 1 | 1. Make sure nvm is properly exposing `npx` in login shell 2 | 1. If launching via window manager's launcher, `.profile` should have nvm setup script. 3 | 1. Put content of the `app` folder somewhere in the system. Make sure the script is executable 4 | 1. Put the content of the `desktop` folder on the desktop 5 | 1. Adjust the `Exec` and `Icon` path in `osmosnote.desktop` to point to the content of the `app` 6 | 1. In Gnome, right click the `.desktop` and choose `Allow Launching`. Note, this is NOT the same as `chmod +x`. 7 | -------------------------------------------------------------------------------- /packages/tools/linux-launcher/app/osmosnote-favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmoscraft/osmosnote/0fd06c6aecea60cf219da016f33f35c48541b792/packages/tools/linux-launcher/app/osmosnote-favicon.png -------------------------------------------------------------------------------- /packages/tools/linux-launcher/app/osmosnote.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | source ~/.nvm/nvm.sh 4 | 5 | xdg-open http://localhost:6683 6 | npx @osmoscraft/osmosnote 7 | 8 | -------------------------------------------------------------------------------- /packages/tools/linux-launcher/desktop/osmosnote-favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmoscraft/osmosnote/0fd06c6aecea60cf219da016f33f35c48541b792/packages/tools/linux-launcher/desktop/osmosnote-favicon.png -------------------------------------------------------------------------------- /packages/tools/linux-launcher/desktop/osmosnote.desktop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env xdg-open 2 | [Desktop Entry] 3 | Version=1.0 4 | Name=osmosnote 5 | Comment=Networked note taking 6 | Exec=/home/sora/Desktop/portable/osmosnote/osmosnote.sh 7 | Icon=/home/sora/Desktop/portable/osmosnote/osmosnote-favicon.png 8 | Terminal=true 9 | Type=Application 10 | Categories=Application; 11 | -------------------------------------------------------------------------------- /packages/tools/markdown-converter/.gitignore: -------------------------------------------------------------------------------- 1 | input 2 | output -------------------------------------------------------------------------------- /packages/tools/markdown-converter/haiku-to-md.js: -------------------------------------------------------------------------------- 1 | import fs from "fs/promises"; 2 | import assert from "node:assert"; 3 | import path from "path"; 4 | import { fileToLines, handleBodyLines, handleHeaderLines } from "./lib.js"; 5 | const __dirname = path.resolve(); 6 | const inputDir = path.resolve(__dirname, "./input"); 7 | 8 | // console.warn = () => {}; 9 | 10 | const filenameMap = new Map(); 11 | 12 | async function main(inputDir) { 13 | try { 14 | // clear output 15 | await fs.rm(path.resolve(__dirname, "./output"), { force: true, recursive: true }); 16 | } catch {} 17 | 18 | fs.mkdir(path.resolve(__dirname, "./output"), { recursive: true }); 19 | const haikuFiles = await fs.readdir(inputDir); 20 | console.log("file count: ", haikuFiles.length); 21 | 22 | // first pass, analyze all metadata 23 | for (const haikuFile of haikuFiles) { 24 | await fs.readFile(path.resolve(inputDir, haikuFile), "utf-8").then(async (data) => { 25 | const { headerLines } = fileToLines(data); 26 | 27 | // convert headerLines to yaml 28 | const { timeId } = handleHeaderLines(haikuFile, headerLines); 29 | const sourceFilename = haikuFile.replace(".haiku", ""); 30 | filenameMap.set(sourceFilename, timeId); 31 | }); 32 | } 33 | 34 | assert(filenameMap.size === haikuFiles.length, "filenameMap size should match haikuFiles length"); 35 | 36 | for (const haikuFile of haikuFiles) { 37 | await fs.readFile(path.resolve(inputDir, haikuFile), "utf-8").then(async (data) => { 38 | const { headerLines, bodyLines } = fileToLines(data); 39 | 40 | // convert headerLines to yaml 41 | const { frontmatter, timeId } = handleHeaderLines(haikuFile, headerLines); 42 | 43 | const body = handleBodyLines(haikuFile, bodyLines, filenameMap); 44 | 45 | // TODO pass through markdown and yaml parser 46 | 47 | // `wx` flag: fail if file exists 48 | await fs.writeFile(path.join(__dirname, `./output/${timeId}.md`), `${frontmatter}\n\n${body}\n`, { flag: "wx" }); 49 | }); 50 | } 51 | } 52 | 53 | main(inputDir); 54 | -------------------------------------------------------------------------------- /packages/tools/markdown-converter/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/tools/markdown-converter/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markdown-converter", 3 | "version": "1.0.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "markdown-converter", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "devDependencies": { 12 | "yaml": "^2.3.1" 13 | } 14 | }, 15 | "node_modules/yaml": { 16 | "version": "2.3.1", 17 | "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", 18 | "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==", 19 | "dev": true, 20 | "engines": { 21 | "node": ">= 14" 22 | } 23 | } 24 | }, 25 | "dependencies": { 26 | "yaml": { 27 | "version": "2.3.1", 28 | "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", 29 | "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==", 30 | "dev": true 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/tools/markdown-converter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markdown-converter", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "haiku-to-md.js", 6 | "type": "module", 7 | "scripts": { 8 | "start": "node ./haiku-to-md.js", 9 | "test": "node --test" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "yaml": "^2.3.1" 16 | } 17 | } -------------------------------------------------------------------------------- /packages/tools/markdown-converter/sync.sh: -------------------------------------------------------------------------------- 1 | #/bin/sh 2 | 3 | HAIKU_REPO_PATH=~/.osmosnote/repo 4 | MD_REPO_NOTES_PATH=~/repos/s2-notes-md/data/notes 5 | 6 | rm -rf ./input 7 | mkdir ./input 8 | rm -rf ./output 9 | mkdir ./output 10 | 11 | # load inputs 12 | git -C $HAIKU_REPO_PATH pull 13 | cp $HAIKU_REPO_PATH/* ./input 14 | 15 | # convert 16 | node ./haiku-to-md.js 17 | 18 | # update target 19 | git -C $MD_REPO_NOTES_PATH fetch 20 | git -C $MD_REPO_NOTES_PATH reset --hard origin/master 21 | rm -rf $MD_REPO_NOTES_PATH/* 22 | cp ./output/* $MD_REPO_NOTES_PATH/ 23 | 24 | # let user finish git push 25 | git -C $MD_REPO_NOTES_PATH add -A 26 | git -C $MD_REPO_NOTES_PATH status 27 | git -C $MD_REPO_NOTES_PATH commit -m "sync" 28 | git -C $MD_REPO_NOTES_PATH push 29 | 30 | -------------------------------------------------------------------------------- /packages/tools/wsl-launcher/s2-node.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | xdg-open http://localhost:6683 4 | npx @osmoscraft/osmosnote 5 | -------------------------------------------------------------------------------- /packages/tools/wsl-launcher/s2.bat: -------------------------------------------------------------------------------- 1 | start "" http://localhost:6683 2 | wsl bash -ic 'npx @osmoscraft/osmosnote@latest' 3 | --------------------------------------------------------------------------------