├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md ├── justfile ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── raycast ├── assets │ └── extension-icon.png ├── package.json ├── raycast-env.d.ts ├── src │ ├── atom.ts │ ├── create.tsx │ ├── form.tsx │ ├── list.tsx │ └── stop-clocking.ts └── tsconfig.json ├── src ├── backend.rs ├── cli │ ├── api_server.rs │ ├── environment.rs │ ├── fmt.rs │ ├── lsp_server.rs │ ├── mod.rs │ └── src_block.rs ├── command │ ├── clocking │ │ ├── mod.rs │ │ ├── start.rs │ │ ├── status.rs │ │ └── stop.rs │ ├── formatting │ │ ├── blank_lines.rs │ │ ├── list.rs │ │ ├── mod.rs │ │ └── rule.rs │ ├── headline │ │ ├── create.rs │ │ ├── duplicate.rs │ │ ├── generate_toc.rs │ │ ├── mod.rs │ │ ├── remove.rs │ │ ├── reorder.rs │ │ ├── search.rs │ │ └── update.rs │ ├── mod.rs │ └── src_block │ │ ├── detangle.rs │ │ ├── execute.rs │ │ ├── mod.rs │ │ └── tangle.rs ├── lib.rs ├── lsp │ ├── code_lens.rs │ ├── completion.rs │ ├── document_link.rs │ ├── document_symbol.rs │ ├── execute_command.rs │ ├── folding_range.rs │ ├── formatting.rs │ ├── initialize.rs │ ├── mod.rs │ ├── references.rs │ └── semantic_token.rs ├── main.rs ├── test.rs ├── utils │ ├── clocking.rs │ ├── headline.rs │ ├── mod.rs │ ├── src_block.rs │ ├── text_size.rs │ └── timestamp.rs └── wasm │ ├── backend.rs │ ├── lsp_backend.rs │ └── mod.rs ├── vscode ├── .vscodeignore ├── README.md ├── build.mjs ├── images │ ├── extension-icon.png │ ├── language-dark-icon.png │ ├── language-light-icon.png │ └── overview.png ├── media │ └── org-mode.css ├── org.configuration.json ├── package.json ├── src │ ├── extension.ts │ ├── lsp-server.ts │ ├── lsp-worker.ts │ ├── preview-html.ts │ ├── syntax-tree.ts │ └── web-panel.ts ├── syntaxes │ └── org.tmLanguage.json └── tsconfig.json └── web ├── components.json ├── index.html ├── package.json ├── postcss.config.mjs ├── src-tauri ├── .gitignore ├── Cargo.toml ├── build.rs ├── icons │ ├── 128x128.png │ ├── 128x128@2x.png │ ├── 32x32.png │ ├── Square107x107Logo.png │ ├── Square142x142Logo.png │ ├── Square150x150Logo.png │ ├── Square284x284Logo.png │ ├── Square30x30Logo.png │ ├── Square310x310Logo.png │ ├── Square44x44Logo.png │ ├── Square71x71Logo.png │ ├── Square89x89Logo.png │ ├── StoreLogo.png │ ├── icon.icns │ ├── icon.ico │ └── icon.png ├── src │ ├── lib.rs │ └── main.rs └── tauri.conf.json ├── src ├── App.tsx ├── CalendarDay.tsx ├── Tasks.tsx ├── atom.ts ├── command.ts ├── components │ ├── HeadlineDialog.tsx │ ├── clocking.tsx │ └── ui │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── checkbox.tsx │ │ ├── context-menu.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ └── table.tsx ├── index.css ├── lib │ └── utils.ts └── main.tsx ├── tailwind.config.mjs ├── tsconfig.json ├── tsconfig.node.json └── vite.config.mts /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | node_modules 3 | dist 4 | *.org 5 | *.wasm 6 | *.vsix -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "orgwise" 3 | version = "0.0.0" 4 | authors = ["PoiScript "] 5 | repository = "https://github.com/PoiScript/orgwise" 6 | edition = "2021" 7 | license = "MIT" 8 | description = "Org-mode toolkit" 9 | exclude = ["editors", "pkg"] 10 | 11 | [workspace] 12 | members = [".", "./web/src-tauri"] 13 | 14 | [dependencies] 15 | orgize = { git = "https://github.com/PoiScript/orgize", branch = "v0.10", default-features = false, features = [ 16 | "chrono", 17 | ] } 18 | serde_json = "1.0" 19 | serde = { version = "1.0", features = ["derive"] } 20 | anyhow = "1.0" 21 | resolve-path = "0.1" 22 | memchr = "2.6" 23 | lsp-types = { version = "0.94.1", features = ["proposed"] } 24 | jetscii = "0.5.3" 25 | nom = "7.1.3" 26 | chrono = { version = "0.4.34", features = ["serde"] } 27 | 28 | wasm-bindgen = { version = "0.2.89", features = ["std"], optional = true } 29 | serde-wasm-bindgen = { version = "0.6.3", optional = true } 30 | wasm-bindgen-futures = { version = "0.4.39", optional = true } 31 | console_error_panic_hook = { version = "0.1.7", optional = true } 32 | web-sys = { version = "0.3.68", features = ["console"], optional = true } 33 | 34 | dashmap = { version = "5.1", features = ["raw-api"], optional = true } 35 | tokio = { version = "1.35.1", features = ["fs", "full"], optional = true } 36 | tower-lsp = { version = "0.20.0", features = ["proposed"], optional = true } 37 | tempfile = { version = "3.8.1", optional = true } 38 | dirs = { version = "5.0.1", optional = true } 39 | clap = { version = "4.4.11", features = ["derive"], optional = true } 40 | clap-verbosity-flag = { version = "2.1.0", optional = true } 41 | axum = { version = "0.6", optional = true } 42 | tower-http = { version = "0.4", features = ["cors"], optional = true } 43 | log = { version = "0.4.21", optional = true, features = ["std"] } 44 | notify = { version = "6.1.1", optional = true, default-features = false, features = [ 45 | "macos_fsevent", 46 | ] } 47 | 48 | [features] 49 | default = ["wasm", "tower"] 50 | wasm = [ 51 | "wasm-bindgen", 52 | "serde-wasm-bindgen", 53 | "wasm-bindgen-futures", 54 | "console_error_panic_hook", 55 | "web-sys", 56 | ] 57 | tower = [ 58 | "tokio", 59 | "tower-lsp", 60 | "tempfile", 61 | "dirs", 62 | "axum", 63 | "tower-http", 64 | "clap", 65 | "clap-verbosity-flag", 66 | "log", 67 | "notify", 68 | "dashmap", 69 | ] 70 | 71 | [lib] 72 | required-features = ["wasm"] 73 | crate-type = ["cdylib", "rlib"] 74 | path = "src/lib.rs" 75 | 76 | [[bin]] 77 | name = "orgwise" 78 | path = "src/main.rs" 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `Orgwise` 2 | 3 | ![Overview of orgwise](./vscode/images/overview.png) 4 | 5 | Orgwise is an org-mode toolkit builtin upon [`orgize`]. 6 | 7 | Currently, it includes: 8 | 9 | - Language server 10 | - VSCode extension 11 | - Command line utility 12 | - API Server 13 | - Web Interface 14 | - [Raycast] extension 15 | - [Tauri] application 16 | 17 | [`orgize`]: https://crates.io/crates/orgize 18 | [raycast]: https://www.raycast.com/ 19 | [Tauri]: https://tauri.app/ 20 | 21 | ## Development 22 | 23 | Requires `Rust 1.26+` for async trait feature. 24 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | build: build-wasm build-web build-raycast build-extension 2 | 3 | build-wasm: 4 | rm -rf ./pkg 5 | wasm-pack build -t web --no-default-features --features wasm 6 | 7 | build-web: 8 | rm -rf ./web/dist 9 | pnpm run -C web build 10 | 11 | build-raycast: 12 | rm -rf ./raycast/dist 13 | cp ./pkg/orgwise_bg.wasm ./raycast/assets/orgwise_bg.wasm 14 | pnpm run -C raycast build 15 | 16 | build-extension: 17 | rm -rf ./vscode/dist 18 | mkdir -p ./vscode/dist 19 | cp -r ./web/dist ./vscode/dist/web 20 | cp ./pkg/orgwise_bg.wasm ./vscode/dist/orgwise_bg.wasm 21 | pnpm run -C vscode build 22 | pnpm run -C vscode package --no-dependencies 23 | 24 | install-extension: 25 | code --install-extension ./vscode/orgwise.vsix --force 26 | 27 | install-raycast: 28 | pnpm run -C raycast install-local 29 | 30 | dev-raycast: 31 | pnpm run -C raycast dev 32 | 33 | dev-web: 34 | pnpm run -C web dev 35 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "raycast" 3 | - "vscode" 4 | - "web" 5 | -------------------------------------------------------------------------------- /raycast/assets/extension-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/raycast/assets/extension-icon.png -------------------------------------------------------------------------------- /raycast/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://www.raycast.com/schemas/extension.json", 3 | "name": "orgwise", 4 | "title": "Orgwise", 5 | "description": "Manage your org-mode files", 6 | "icon": "extension-icon.png", 7 | "author": "PoiScript", 8 | "license": "MIT", 9 | "commands": [ 10 | { 11 | "name": "list", 12 | "title": "List TODO items", 13 | "description": "List TODO items", 14 | "mode": "view" 15 | }, 16 | { 17 | "name": "create", 18 | "title": "Create TODO item", 19 | "description": "Create TODO item", 20 | "mode": "view", 21 | "arguments": [ 22 | { 23 | "name": "title", 24 | "type": "text", 25 | "required": true, 26 | "placeholder": "your title" 27 | } 28 | ] 29 | }, 30 | { 31 | "name": "stop-clocking", 32 | "title": "Stop clocking TODO item", 33 | "description": "Stop clocking TODO item", 34 | "mode": "no-view" 35 | } 36 | ], 37 | "preferences": [ 38 | { 39 | "name": "orgTodoFile", 40 | "title": "File", 41 | "description": "Path to your org todo file", 42 | "type": "file", 43 | "required": true 44 | }, 45 | { 46 | "name": "orgTodoKeywords", 47 | "title": "TODO keywords", 48 | "description": "separated by comma", 49 | "type": "textfield", 50 | "required": false, 51 | "default": "TODO,TASK" 52 | }, 53 | { 54 | "name": "orgDoneKeywords", 55 | "title": "DONE keywords", 56 | "description": "separated by comma", 57 | "type": "textfield", 58 | "required": false, 59 | "default": "DONE,DROP,CANCEL" 60 | }, 61 | { 62 | "name": "orgTags", 63 | "title": "Tags", 64 | "description": "separated by comma", 65 | "type": "textfield", 66 | "required": false, 67 | "default": "financial,fun,personal,shopping,study,work" 68 | }, 69 | { 70 | "name": "orgPriorities", 71 | "title": "Priorities", 72 | "description": "separated by comma", 73 | "type": "textfield", 74 | "required": false, 75 | "default": "A,B,C,D,E" 76 | }, 77 | { 78 | "name": "orgIncludePreviousClock", 79 | "title": "Priorities", 80 | "description": "separated by comma", 81 | "type": "textfield", 82 | "required": false, 83 | "default": "A,B,C,D,E" 84 | }, 85 | { 86 | "name": "orgConfirmBeforeRemove", 87 | "description": "Shows a confirmation alert when removing", 88 | "label": "Shows a confirmation alert when removing", 89 | "type": "checkbox", 90 | "required": false, 91 | "default": true 92 | }, 93 | { 94 | "name": "orgConfirmBeforeDuplicate", 95 | "description": "Shows a confirmation alert when duplicating", 96 | "label": "Shows a confirmation alert when duplicating", 97 | "type": "checkbox", 98 | "required": false, 99 | "default": true 100 | } 101 | ], 102 | "scripts": { 103 | "build": "ray build -e dist -o dist", 104 | "install-local": "ray build -e dist", 105 | "dev": "ray develop" 106 | }, 107 | "dependencies": { 108 | "@raycast/api": "^1.71.3", 109 | "typescript": "^5.3.3", 110 | "jotai": "^2.7.0", 111 | "vscode-uri": "^3.0.8" 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /raycast/raycast-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | /* 🚧 🚧 🚧 4 | * This file is auto-generated from the extension's manifest. 5 | * Do not modify manually. Instead, update the `package.json` file. 6 | * 🚧 🚧 🚧 */ 7 | 8 | /* eslint-disable @typescript-eslint/ban-types */ 9 | 10 | type ExtensionPreferences = { 11 | /** File - Path to your org todo file */ 12 | "orgTodoFile": string, 13 | /** TODO keywords - separated by comma */ 14 | "orgTodoKeywords": string, 15 | /** DONE keywords - separated by comma */ 16 | "orgDoneKeywords": string, 17 | /** Tags - separated by comma */ 18 | "orgTags": string, 19 | /** Priorities - separated by comma */ 20 | "orgPriorities": string, 21 | /** Priorities - separated by comma */ 22 | "orgIncludePreviousClock": string, 23 | /** - Shows a confirmation alert when removing */ 24 | "orgConfirmBeforeRemove": boolean, 25 | /** - Shows a confirmation alert when duplicating */ 26 | "orgConfirmBeforeDuplicate": boolean 27 | } 28 | 29 | /** Preferences accessible in all the extension's commands */ 30 | declare type Preferences = ExtensionPreferences 31 | 32 | declare namespace Preferences { 33 | /** Preferences accessible in the `list` command */ 34 | export type List = ExtensionPreferences & {} 35 | /** Preferences accessible in the `create` command */ 36 | export type Create = ExtensionPreferences & {} 37 | /** Preferences accessible in the `stop-clocking` command */ 38 | export type StopClocking = ExtensionPreferences & {} 39 | } 40 | 41 | declare namespace Arguments { 42 | /** Arguments passed to the `list` command */ 43 | export type List = {} 44 | /** Arguments passed to the `create` command */ 45 | export type Create = { 46 | /** your title */ 47 | "title": string 48 | } 49 | /** Arguments passed to the `stop-clocking` command */ 50 | export type StopClocking = {} 51 | } 52 | 53 | -------------------------------------------------------------------------------- /raycast/src/atom.ts: -------------------------------------------------------------------------------- 1 | import { environment, getPreferenceValues } from "@raycast/api"; 2 | import { atom } from "jotai"; 3 | import { existsSync, readFileSync } from "node:fs"; 4 | import { readFile, writeFile } from "node:fs/promises"; 5 | import { homedir } from "node:os"; 6 | import { URI } from "vscode-uri"; 7 | 8 | import { Backend, initSync } from "../../pkg/orgwise"; 9 | 10 | const preferencesAtom = atom(getPreferenceValues); 11 | 12 | export const orgFileAtom = atom((get) => 13 | URI.file(get(preferencesAtom).orgTodoFile) 14 | ); 15 | 16 | export const orgTodoKeywordsAtom = atom((get) => 17 | get(preferencesAtom) 18 | .orgTodoKeywords.split(",") 19 | .map((x) => x.trim().toUpperCase()) 20 | ); 21 | 22 | export const orgDoneKeywordsAtom = atom((get) => 23 | get(preferencesAtom) 24 | .orgDoneKeywords.split(",") 25 | .map((x) => x.trim().toUpperCase()) 26 | ); 27 | 28 | export const orgTagsAtom = atom((get) => 29 | get(preferencesAtom) 30 | .orgTags.split(",") 31 | .map((x) => x.trim()) 32 | ); 33 | 34 | export const orgPrioritiesAtom = atom((get) => 35 | get(preferencesAtom) 36 | .orgPriorities.split(",") 37 | .map((x) => x.trim().slice(0, 1).toUpperCase()) 38 | ); 39 | 40 | export const backendAtom = atom((get) => { 41 | const buffer = readFileSync(`${environment.assetsPath}/orgwise_bg.wasm`); 42 | 43 | initSync(buffer); 44 | 45 | const backend = new Backend({ 46 | homeDir: () => URI.file(homedir()).toString() + "/", 47 | 48 | readToString: async (url: string) => { 49 | const path = URI.parse(url).fsPath; 50 | if (existsSync(path)) { 51 | return readFile(path, { encoding: "utf-8" }); 52 | } else { 53 | return ""; 54 | } 55 | }, 56 | 57 | write: (url: string, content: string) => 58 | writeFile(URI.parse(url).fsPath, content), 59 | }); 60 | 61 | backend.setOptions({ 62 | todoKeywords: get(orgTodoKeywordsAtom), 63 | doneKeywords: get(orgDoneKeywordsAtom), 64 | }); 65 | 66 | const url = get(orgFileAtom); 67 | 68 | backend.addOrgFile(url.toString(), readFileSync(url.fsPath, "utf-8")); 69 | 70 | return backend; 71 | }); 72 | -------------------------------------------------------------------------------- /raycast/src/create.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | LaunchProps, 3 | LaunchType, 4 | launchCommand, 5 | showToast, 6 | } from "@raycast/api"; 7 | import { useAtomValue } from "jotai"; 8 | import { mutate } from "swr"; 9 | 10 | import { backendAtom, orgFileAtom } from "./atom"; 11 | import { TaskForm } from "./form"; 12 | 13 | type CreateResult = { 14 | line: number; 15 | url: string; 16 | }; 17 | 18 | export default function Command( 19 | props: LaunchProps<{ arguments: Arguments.Create }> 20 | ) { 21 | const orgTodoFile = useAtomValue(orgFileAtom); 22 | const backend = useAtomValue(backendAtom); 23 | 24 | return ( 25 | { 28 | backend 29 | .executeCommand("headline-create", { 30 | url: orgTodoFile.toString(), 31 | ...values, 32 | }) 33 | .then((result: CreateResult) => { 34 | mutate("headline-search"); 35 | showToast({ title: "TODO item created" }); 36 | launchCommand({ 37 | name: "list", 38 | type: LaunchType.UserInitiated, 39 | context: { selectedItemId: result.url + "#" + result.line }, 40 | }); 41 | }); 42 | }} 43 | /> 44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /raycast/src/form.tsx: -------------------------------------------------------------------------------- 1 | import { Action, ActionPanel, Form } from "@raycast/api"; 2 | import { parse, lightFormat } from "date-fns"; 3 | import { useState } from "react"; 4 | import { useAtomValue } from "jotai"; 5 | import { 6 | orgDoneKeywordsAtom, 7 | orgPrioritiesAtom, 8 | orgTagsAtom, 9 | orgTodoKeywordsAtom, 10 | } from "./atom"; 11 | 12 | import type { SearchResult } from "../../web/src/atom"; 13 | 14 | const formatStr = "yyyy-MM-dd'T'HH:mm:ss"; 15 | 16 | export const TaskForm: React.FC<{ 17 | defaultValue: Partial; 18 | onSubmit: (values: any) => void; 19 | }> = ({ defaultValue, onSubmit }) => { 20 | const [titleError, setTitleError] = useState(); 21 | 22 | const todoKeywords = useAtomValue(orgTodoKeywordsAtom); 23 | const doneKeywords = useAtomValue(orgDoneKeywordsAtom); 24 | const tags = useAtomValue(orgTagsAtom); 25 | const priorities = useAtomValue(orgPrioritiesAtom); 26 | 27 | function dropTitleErrorIfNeeded() { 28 | if (titleError && titleError.length > 0) { 29 | setTitleError(undefined); 30 | } 31 | } 32 | 33 | return ( 34 |
37 | { 40 | onSubmit({ 41 | ...values, 42 | 43 | scheduled: values.scheduled 44 | ? lightFormat(values.scheduled, formatStr) 45 | : null, 46 | 47 | deadline: values.deadline 48 | ? lightFormat(values.deadline, formatStr) 49 | : null, 50 | }); 51 | }} 52 | /> 53 | 54 | } 55 | > 56 | { 64 | if (event.target.value?.length == 0) { 65 | setTitleError("The field should't be empty!"); 66 | } else { 67 | dropTitleErrorIfNeeded(); 68 | } 69 | }} 70 | /> 71 | 72 | 77 | 78 | {todoKeywords.map((t) => ( 79 | 80 | ))} 81 | 82 | 83 | {doneKeywords.map((t) => ( 84 | 85 | ))} 86 | 87 | 88 | 89 | 95 | {priorities.map((p) => ( 96 | 97 | ))} 98 | 99 | 100 | 106 | {tags.map((t) => ( 107 | 108 | ))} 109 | 110 | 111 | 117 | 118 | 127 | 128 | 137 | 138 | ); 139 | }; 140 | -------------------------------------------------------------------------------- /raycast/src/stop-clocking.ts: -------------------------------------------------------------------------------- 1 | import { showHUD } from "@raycast/api"; 2 | import { getDefaultStore } from "jotai"; 3 | 4 | import { backendAtom } from "./atom"; 5 | 6 | type ClockStatus = { 7 | start: string; 8 | title: string; 9 | url: string; 10 | line: number; 11 | }; 12 | 13 | export default async function Command() { 14 | const backend = getDefaultStore().get(backendAtom); 15 | 16 | const status: { running?: ClockStatus } = await backend.executeCommand( 17 | "clocking-status", 18 | {} 19 | ); 20 | 21 | if (!status.running) { 22 | return await showHUD("No running clock"); 23 | } 24 | 25 | await backend.executeCommand("clocking-stop", { 26 | url: status.running.url, 27 | line: status.running.line, 28 | }); 29 | 30 | await showHUD(`Stopped clocking on ${JSON.stringify(status.running.title)}`); 31 | } 32 | -------------------------------------------------------------------------------- /raycast/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Node 16", 4 | "include": ["src/**/*"], 5 | "compilerOptions": { 6 | "lib": ["es2020", "WebWorker"], 7 | "module": "commonjs", 8 | "target": "es2020", 9 | "strict": true, 10 | "isolatedModules": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "jsx": "react-jsx", 15 | "resolveJsonModule": true, 16 | "types": ["./raycast-env.d.ts"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/cli/api_server.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::State, 3 | http::{Method, StatusCode}, 4 | response::{IntoResponse, Response}, 5 | routing::post, 6 | Json, Router, 7 | }; 8 | use clap::Args; 9 | use std::{ 10 | net::{Ipv4Addr, SocketAddr, SocketAddrV4}, 11 | path::PathBuf, 12 | sync::Arc, 13 | }; 14 | use tower_http::cors::{Any, CorsLayer}; 15 | 16 | use crate::command::OrgwiseCommand; 17 | use crate::{backend::Backend, cli::environment::CliBackend}; 18 | 19 | #[derive(Debug, Args)] 20 | pub struct Command { 21 | #[arg(short, long)] 22 | port: Option, 23 | path: Vec, 24 | } 25 | 26 | type AppState = Arc; 27 | 28 | impl Command { 29 | pub async fn run(self) -> anyhow::Result<()> { 30 | let addr = SocketAddr::V4(SocketAddrV4::new( 31 | Ipv4Addr::new(0, 0, 0, 0), 32 | self.port.unwrap_or(3000), 33 | )); 34 | 35 | log::info!("Listening at {addr:?}"); 36 | 37 | let backend = CliBackend::new(false); 38 | 39 | for path in &self.path { 40 | backend.load_org_file(path); 41 | } 42 | 43 | log::info!("Loaded {} org file(s)", backend.documents().len()); 44 | 45 | let state = AppState::new(backend); 46 | 47 | let cors = CorsLayer::new() 48 | .allow_methods([Method::GET, Method::POST]) 49 | .allow_headers(Any) 50 | .allow_origin(Any); 51 | 52 | let app = Router::new() 53 | .route("/api/command", post(execute_command)) 54 | .with_state(state) 55 | .layer(cors); 56 | 57 | axum::Server::bind(&addr) 58 | .serve(app.into_make_service()) 59 | .await?; 60 | 61 | Ok(()) 62 | } 63 | } 64 | 65 | async fn execute_command( 66 | State(state): State, 67 | Json(command): Json, 68 | ) -> Response { 69 | command 70 | .execute_response(state.as_ref()) 71 | .await 72 | .unwrap_or_else(|err| { 73 | log::error!("{err:?}"); 74 | ( 75 | StatusCode::INTERNAL_SERVER_ERROR, 76 | format!("Something went wrong: {err}"), 77 | ) 78 | .into_response() 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /src/cli/environment.rs: -------------------------------------------------------------------------------- 1 | use clap::builder::styling::{AnsiColor, Color, Style}; 2 | use lsp_types::{MessageType, Url}; 3 | use orgize::rowan::TextRange; 4 | use std::{collections::HashMap, fs, path::Path}; 5 | 6 | use crate::backend::{Backend, Documents}; 7 | 8 | pub struct CliBackend { 9 | dry_run: bool, 10 | documents: Documents, 11 | } 12 | 13 | impl CliBackend { 14 | pub fn new(dry_run: bool) -> Self { 15 | CliBackend { 16 | documents: Documents::default(), 17 | dry_run, 18 | } 19 | } 20 | 21 | pub fn load_org_file(&self, path: &Path) -> Option { 22 | if !path.exists() { 23 | log::error!("{} is not existed", path.display()); 24 | return None; 25 | } 26 | 27 | let path = match fs::canonicalize(path) { 28 | Ok(path) => path, 29 | Err(err) => { 30 | log::error!("failed to resolve {}: {err:?}", path.display()); 31 | return None; 32 | } 33 | }; 34 | 35 | let Ok(url) = Url::from_file_path(&path) else { 36 | log::error!("failed to parse {}", path.display()); 37 | return None; 38 | }; 39 | 40 | let content = match fs::read_to_string(&path) { 41 | Ok(content) => content, 42 | Err(err) => { 43 | log::error!("failed to read {}: {err:?}", path.display()); 44 | return None; 45 | } 46 | }; 47 | 48 | self.documents.insert(url.clone(), &content); 49 | 50 | Some(url) 51 | } 52 | } 53 | 54 | impl Backend for CliBackend { 55 | fn home_dir(&self) -> Option { 56 | dirs::home_dir().and_then(|d| Url::from_file_path(d).ok()) 57 | } 58 | 59 | async fn log_message(&self, typ: MessageType, message: String) { 60 | self.show_message(typ, message).await 61 | } 62 | 63 | async fn show_message(&self, typ: MessageType, message: String) { 64 | match typ { 65 | MessageType::ERROR => log::error!("{}", message), 66 | MessageType::WARNING => log::warn!("{}", message), 67 | MessageType::INFO => log::info!("{}", message), 68 | MessageType::LOG => log::debug!("{}", message), 69 | _ => {} 70 | } 71 | } 72 | 73 | async fn apply_edits( 74 | &self, 75 | items: impl Iterator, 76 | ) -> anyhow::Result<()> { 77 | let mut changes: HashMap> = HashMap::new(); 78 | 79 | for (url, new_text, text_range) in items { 80 | if let Some(edits) = changes.get_mut(&url) { 81 | edits.push((text_range, new_text)) 82 | } else { 83 | changes.insert(url.clone(), vec![(text_range, new_text)]); 84 | } 85 | } 86 | 87 | for (url, edits) in changes.iter_mut() { 88 | let Ok(path) = url.to_file_path() else { 89 | anyhow::bail!("Cannot convert Url to PathBuf") 90 | }; 91 | 92 | edits.sort_by_key(|edit| (edit.0.start(), edit.0.end())); 93 | 94 | let input = tokio::fs::read_to_string(&path).await?; 95 | let mut output = String::with_capacity(input.len()); 96 | let mut off = 0; 97 | 98 | for (range, content) in edits { 99 | let start = range.start().into(); 100 | let end = range.end().into(); 101 | 102 | if self.dry_run { 103 | print!("{}", &input[off..start]); 104 | 105 | if &input[start..end] != content { 106 | let style = Style::new().fg_color(Color::Ansi(AnsiColor::Cyan).into()); 107 | print!("{}{}{}", style.render(), &content, style.render_reset()); 108 | } else { 109 | print!("{}", &content); 110 | } 111 | } else { 112 | output += &input[off..start]; 113 | output += &content; 114 | } 115 | 116 | off = end; 117 | } 118 | 119 | if self.dry_run { 120 | print!("{}", &input[off..]); 121 | } else { 122 | output += &input[off..]; 123 | tokio::fs::write(&path, &output).await?; 124 | self.documents.update(url.clone(), None, &output); 125 | } 126 | } 127 | 128 | Ok(()) 129 | } 130 | 131 | async fn write(&self, url: &Url, content: &str) -> anyhow::Result<()> { 132 | if let Ok(path) = url.to_file_path() { 133 | tokio::fs::write(path, content).await?; 134 | Ok(()) 135 | } else { 136 | anyhow::bail!("Cannot convert Url to PathBuf") 137 | } 138 | } 139 | 140 | async fn read_to_string(&self, url: &Url) -> anyhow::Result { 141 | if let Ok(path) = url.to_file_path() { 142 | Ok(tokio::fs::read_to_string(path).await?) 143 | } else { 144 | anyhow::bail!("Cannot convert Url to PathBuf") 145 | } 146 | } 147 | 148 | async fn execute(&self, executable: &str, content: &str) -> anyhow::Result { 149 | let dir = tempfile::tempdir()?; 150 | 151 | let path = dir.path().join(".orgize"); 152 | 153 | tokio::fs::write(&path, content).await?; 154 | 155 | let output = tokio::process::Command::new(executable) 156 | .arg(&path) 157 | .output() 158 | .await?; 159 | 160 | let output = String::from_utf8_lossy(&output.stdout); 161 | 162 | Ok(output.to_string()) 163 | } 164 | 165 | fn documents(&self) -> &Documents { 166 | &self.documents 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/cli/fmt.rs: -------------------------------------------------------------------------------- 1 | use clap::Args; 2 | use std::path::PathBuf; 3 | 4 | use super::environment::CliBackend; 5 | use crate::backend::Backend; 6 | use crate::command::formatting; 7 | 8 | #[derive(Debug, Args)] 9 | pub struct Command { 10 | path: Vec, 11 | 12 | #[arg(short, long)] 13 | dry_run: bool, 14 | } 15 | 16 | impl Command { 17 | pub async fn run(self) -> anyhow::Result<()> { 18 | let backend = CliBackend::new(self.dry_run); 19 | 20 | for path in self.path { 21 | if let Some(url) = backend.load_org_file(&path) { 22 | if let Some(edits) = backend 23 | .documents() 24 | .get_map(&url, |doc| formatting::formatting(&doc.org)) 25 | { 26 | backend 27 | .apply_edits( 28 | edits 29 | .into_iter() 30 | .map(|(range, content)| (url.clone(), content, range)), 31 | ) 32 | .await?; 33 | } 34 | } 35 | } 36 | 37 | Ok(()) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod api_server; 2 | pub mod environment; 3 | pub mod fmt; 4 | pub mod lsp_server; 5 | pub mod src_block; 6 | -------------------------------------------------------------------------------- /src/cli/src_block.rs: -------------------------------------------------------------------------------- 1 | use clap::Args; 2 | use std::path::PathBuf; 3 | 4 | use crate::command::{Executable, SrcBlockDetangleAll, SrcBlockExecuteAll, SrcBlockTangleAll}; 5 | 6 | use super::environment::CliBackend; 7 | 8 | #[derive(Debug, Args)] 9 | pub struct DetangleCommand { 10 | path: Vec, 11 | 12 | #[arg(short, long)] 13 | dry_run: bool, 14 | } 15 | 16 | impl DetangleCommand { 17 | pub async fn run(self) -> anyhow::Result<()> { 18 | let backend = CliBackend::new(self.dry_run); 19 | 20 | for path in self.path { 21 | if let Some(url) = backend.load_org_file(&path) { 22 | SrcBlockDetangleAll { url }.execute(&backend).await?; 23 | } 24 | } 25 | 26 | Ok(()) 27 | } 28 | } 29 | 30 | #[derive(Debug, Args)] 31 | pub struct ExecuteCommand { 32 | path: Vec, 33 | 34 | #[arg(short, long)] 35 | dry_run: bool, 36 | } 37 | 38 | impl ExecuteCommand { 39 | pub async fn run(self) -> anyhow::Result<()> { 40 | let backend = CliBackend::new(self.dry_run); 41 | for path in self.path { 42 | if let Some(url) = backend.load_org_file(&path) { 43 | SrcBlockExecuteAll { url }.execute(&backend).await?; 44 | } 45 | } 46 | Ok(()) 47 | } 48 | } 49 | 50 | #[derive(Debug, Args)] 51 | pub struct TangleCommand { 52 | path: Vec, 53 | 54 | #[arg(short, long)] 55 | dry_run: bool, 56 | } 57 | 58 | impl TangleCommand { 59 | pub async fn run(self) -> anyhow::Result<()> { 60 | let backend = CliBackend::new(self.dry_run); 61 | for path in self.path { 62 | if let Some(url) = backend.load_org_file(&path) { 63 | SrcBlockTangleAll { url }.execute(&backend).await?; 64 | } 65 | } 66 | Ok(()) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/command/clocking/mod.rs: -------------------------------------------------------------------------------- 1 | mod start; 2 | mod status; 3 | mod stop; 4 | 5 | pub use start::*; 6 | pub use status::*; 7 | pub use stop::*; 8 | -------------------------------------------------------------------------------- /src/command/clocking/start.rs: -------------------------------------------------------------------------------- 1 | use chrono::Local; 2 | use lsp_types::{MessageType, Url}; 3 | use orgize::{ 4 | rowan::{ast::AstNode, TextRange}, 5 | SyntaxKind, 6 | }; 7 | use serde::{Deserialize, Serialize}; 8 | 9 | use crate::{ 10 | backend::Backend, 11 | command::Executable, 12 | utils::{clocking::find_logbook, headline::find_headline, timestamp::FormatInactiveTimestamp}, 13 | }; 14 | 15 | #[derive(Deserialize, Serialize)] 16 | pub struct ClockingStart { 17 | pub url: Url, 18 | pub line: u32, 19 | } 20 | 21 | impl Executable for ClockingStart { 22 | const NAME: &'static str = "clocking-start"; 23 | 24 | const TITLE: Option<&'static str> = Some("Start clocking"); 25 | 26 | type Result = bool; 27 | 28 | async fn execute(self, backend: &B) -> anyhow::Result { 29 | let Some(Some(headline)) = backend 30 | .documents() 31 | .get_map(&self.url, |doc| find_headline(&doc, self.line)) 32 | else { 33 | backend 34 | .log_message( 35 | MessageType::WARNING, 36 | format!("cannot find document with url {}", self.url), 37 | ) 38 | .await; 39 | 40 | return Ok(false); 41 | }; 42 | 43 | let now = Local::now().naive_local(); 44 | 45 | let (new_text, text_range) = (move || { 46 | if let Some(logbook) = find_logbook(&headline) { 47 | let node = logbook.syntax(); 48 | let s = node 49 | .children() 50 | .find(|x| x.kind() == SyntaxKind::DRAWER_END) 51 | .map(|x| x.text_range().start()) 52 | .unwrap_or_else(|| node.text_range().start()); 53 | ( 54 | format!("CLOCK: {}\n", FormatInactiveTimestamp(now)), 55 | TextRange::empty(s), 56 | ) 57 | } else { 58 | ( 59 | format!( 60 | "\n:LOGBOOK:\nCLOCK: {}\n:END:\n", 61 | FormatInactiveTimestamp(now) 62 | ), 63 | TextRange::empty(headline.end()), 64 | ) 65 | } 66 | })(); 67 | 68 | backend.apply_edit(self.url, new_text, text_range).await?; 69 | 70 | Ok(true) 71 | } 72 | } 73 | 74 | #[cfg(test)] 75 | #[tokio::test] 76 | async fn test() { 77 | use std::time::Duration; 78 | 79 | use chrono::TimeDelta; 80 | 81 | use crate::test::TestBackend; 82 | 83 | let backend = TestBackend::default(); 84 | let url = Url::parse("test://test.org").unwrap(); 85 | 86 | backend.documents().insert(url.clone(), r#"* a"#); 87 | 88 | let now = Local::now().naive_local(); 89 | let _1h_ago = now - TimeDelta::from_std(Duration::from_secs(60 * 60)).unwrap(); 90 | 91 | ClockingStart { 92 | url: url.clone(), 93 | line: 1, 94 | } 95 | .execute(&backend) 96 | .await 97 | .unwrap(); 98 | 99 | ClockingStart { 100 | url: url.clone(), 101 | line: 1, 102 | } 103 | .execute(&backend) 104 | .await 105 | .unwrap(); 106 | 107 | assert_eq!( 108 | backend.get(&url), 109 | format!( 110 | r#"* a 111 | :LOGBOOK: 112 | CLOCK: {} 113 | CLOCK: {} 114 | :END: 115 | "#, 116 | FormatInactiveTimestamp(now), 117 | FormatInactiveTimestamp(now), 118 | ) 119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /src/command/clocking/status.rs: -------------------------------------------------------------------------------- 1 | use chrono::NaiveDateTime; 2 | use lsp_types::Url; 3 | use orgize::{ 4 | ast::Timestamp, 5 | export::{from_fn_with_ctx, Container, Event}, 6 | rowan::ast::AstNode, 7 | }; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | use crate::backend::Backend; 11 | use crate::command::Executable; 12 | 13 | #[derive(Deserialize, Serialize)] 14 | pub struct ClockingStatus {} 15 | 16 | #[derive(Serialize, PartialEq, Debug)] 17 | pub struct Result { 18 | running: Option, 19 | } 20 | 21 | #[derive(Serialize, PartialEq, Debug)] 22 | struct ClockingStatusResult { 23 | url: Url, 24 | line: u32, 25 | start: NaiveDateTime, 26 | title: String, 27 | } 28 | 29 | impl Executable for ClockingStatus { 30 | const NAME: &'static str = "clocking-status"; 31 | 32 | type Result = Result; 33 | 34 | async fn execute(self, backend: &B) -> anyhow::Result { 35 | let mut running: Option = None; 36 | 37 | backend.documents().for_each(|url, doc| { 38 | doc.traverse(&mut from_fn_with_ctx(|event, ctx| match event { 39 | Event::Enter(Container::Headline(hdl)) => { 40 | for clock in hdl.clocks() { 41 | let Some(start) = clock 42 | .syntax() 43 | .children() 44 | .find_map(Timestamp::cast) 45 | .and_then(|ts| ts.start_to_chrono()) 46 | else { 47 | continue; 48 | }; 49 | 50 | if clock.is_running() && !matches!(&running, Some(r) if r.start >= start) { 51 | running = Some(ClockingStatusResult { 52 | url: url.clone(), 53 | line: doc.line_of(hdl.start().into()) + 1, 54 | start, 55 | title: hdl.title_raw(), 56 | }); 57 | } 58 | } 59 | } 60 | 61 | Event::Enter(Container::Section(_)) => ctx.skip(), 62 | 63 | _ => {} 64 | })); 65 | }); 66 | 67 | Ok(Result { running }) 68 | } 69 | } 70 | 71 | #[cfg(test)] 72 | #[tokio::test] 73 | async fn test() { 74 | use chrono::NaiveDate; 75 | 76 | use crate::test::TestBackend; 77 | 78 | let backend = TestBackend::default(); 79 | let url = Url::parse("test://test.org").unwrap(); 80 | 81 | backend.documents().insert( 82 | url.clone(), 83 | &format!( 84 | r#" 85 | * a 86 | :LOGBOOK: 87 | CLOCK: [2000-01-01 Web 00:00]--[2000-01-01 Web 01:00] => 01:00 88 | CLOCK: [2000-01-02 Web 00:00]--[2000-01-03 Web 01:00] => 01:00 89 | CLOCK: [2000-01-03 Web 00:00]--[2000-01-04 Web 01:00] => 01:00 90 | CLOCK: [2000-01-04 Web 00:00]--[2000-01-05 Web 01:00] => 01:00 91 | CLOCK: [2000-01-06 Web 00:00] 92 | :END: 93 | * b 94 | :LOGBOOK: 95 | CLOCK: [2000-01-05 Web 00:00]--[2000-01-06 Web 01:00] => 01:00 96 | CLOCK: [2000-01-03 Web 00:00]--[2000-01-04 Web 01:00] => 01:00 97 | CLOCK: [2000-01-07 Web 00:00] 98 | :END: 99 | "#, 100 | ), 101 | ); 102 | 103 | let r = |day: u32, title: &str, line: u32| ClockingStatusResult { 104 | line, 105 | start: NaiveDate::from_ymd_opt(2000, 1, day) 106 | .unwrap() 107 | .and_hms_opt(0, 0, 0) 108 | .unwrap(), 109 | title: title.into(), 110 | url: url.clone(), 111 | }; 112 | 113 | assert_eq!( 114 | ClockingStatus {}.execute(&backend).await.unwrap(), 115 | Result { 116 | running: Some(r(7, "b", 10)), 117 | } 118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /src/command/clocking/stop.rs: -------------------------------------------------------------------------------- 1 | use chrono::NaiveDateTime; 2 | use lsp_types::{MessageType, Url}; 3 | use orgize::{ast::Timestamp, rowan::ast::AstNode}; 4 | use serde::{Deserialize, Serialize}; 5 | use serde_json::Value; 6 | 7 | use crate::{ 8 | backend::Backend, 9 | command::Executable, 10 | utils::{headline::find_headline, timestamp::FormatInactiveTimestamp}, 11 | }; 12 | 13 | #[derive(Deserialize, Serialize)] 14 | pub struct ClockingStop { 15 | pub url: Url, 16 | pub line: u32, 17 | } 18 | 19 | impl Executable for ClockingStop { 20 | const NAME: &'static str = "clocking-stop"; 21 | 22 | const TITLE: Option<&'static str> = Some("Stop clocking"); 23 | 24 | type Result = Value; 25 | 26 | async fn execute(self, backend: &B) -> anyhow::Result { 27 | let Some(headline) = backend 28 | .documents() 29 | .get_and_then(&self.url, |doc| find_headline(&doc, self.line)) 30 | else { 31 | backend 32 | .log_message( 33 | MessageType::WARNING, 34 | format!("cannot find document with url {}", self.url), 35 | ) 36 | .await; 37 | 38 | return Ok(Value::Null); 39 | }; 40 | 41 | let now = now(); 42 | 43 | let edits: Vec<_> = (move || { 44 | headline 45 | .clocks() 46 | .filter_map(|clock| { 47 | if clock.is_closed() { 48 | return None; 49 | } 50 | 51 | let start = clock 52 | .syntax() 53 | .children() 54 | .find_map(Timestamp::cast)? 55 | .start_to_chrono()?; 56 | 57 | let duration = now - start; 58 | 59 | Some(( 60 | self.url.clone(), 61 | format!( 62 | "CLOCK: {}--{} => {:0>2}:{:0>2}\n", 63 | FormatInactiveTimestamp(start), 64 | FormatInactiveTimestamp(now), 65 | duration.num_hours(), 66 | duration.num_minutes() % 60, 67 | ), 68 | clock.text_range(), 69 | )) 70 | }) 71 | .collect() 72 | })(); 73 | 74 | backend.apply_edits(edits.into_iter()).await?; 75 | 76 | Ok(Value::Bool(true)) 77 | } 78 | } 79 | 80 | #[cfg(not(test))] 81 | #[inline] 82 | fn now() -> NaiveDateTime { 83 | chrono::Local::now().naive_local() 84 | } 85 | 86 | #[cfg(test)] 87 | #[inline] 88 | fn now() -> NaiveDateTime { 89 | chrono::NaiveDate::from_ymd_opt(2000, 1, 1) 90 | .unwrap() 91 | .and_hms_opt(0, 0, 0) 92 | .unwrap() 93 | } 94 | 95 | #[cfg(test)] 96 | #[tokio::test] 97 | async fn test() { 98 | use std::time::Duration; 99 | 100 | use chrono::TimeDelta; 101 | 102 | use crate::test::TestBackend; 103 | 104 | let backend = TestBackend::default(); 105 | let url = Url::parse("test://test.org").unwrap(); 106 | 107 | let now = now(); 108 | let _1h_ago = now - TimeDelta::from_std(Duration::from_secs(60 * 60)).unwrap(); 109 | 110 | backend.documents().insert( 111 | url.clone(), 112 | format!( 113 | r#" 114 | * a 115 | :LOGBOOK: 116 | CLOCK: {} 117 | CLOCK: {} 118 | :END: 119 | "#, 120 | FormatInactiveTimestamp(now), 121 | FormatInactiveTimestamp(_1h_ago) 122 | ), 123 | ); 124 | 125 | ClockingStop { 126 | url: url.clone(), 127 | line: 2, 128 | } 129 | .execute(&backend) 130 | .await 131 | .unwrap(); 132 | 133 | assert_eq!( 134 | backend.get(&url), 135 | format!( 136 | r#" 137 | * a 138 | :LOGBOOK: 139 | CLOCK: {}--{} => 00:00 140 | CLOCK: {}--{} => 01:00 141 | :END: 142 | "#, 143 | FormatInactiveTimestamp(now), 144 | FormatInactiveTimestamp(now), 145 | FormatInactiveTimestamp(_1h_ago), 146 | FormatInactiveTimestamp(now), 147 | ) 148 | ); 149 | } 150 | -------------------------------------------------------------------------------- /src/command/formatting/blank_lines.rs: -------------------------------------------------------------------------------- 1 | use orgize::{rowan::TextRange, SyntaxKind, SyntaxNode}; 2 | 3 | pub fn format(node: &SyntaxNode, edits: &mut Vec<(TextRange, String)>) { 4 | let mut blank_lines = node 5 | .children_with_tokens() 6 | .filter_map(|e| e.into_token()) 7 | .filter(|n| n.kind() == SyntaxKind::BLANK_LINE); 8 | 9 | let Some(first_line) = blank_lines.next() else { 10 | return; 11 | }; 12 | 13 | if first_line.text() != "\n" { 14 | edits.push((first_line.text_range(), "\n".into())); 15 | } 16 | 17 | match (blank_lines.next(), blank_lines.last()) { 18 | (Some(first), Some(last)) => { 19 | edits.push(( 20 | TextRange::new(first.text_range().start(), last.text_range().end()), 21 | "".into(), 22 | )); 23 | } 24 | (Some(first), None) => { 25 | edits.push((first.text_range(), "".into())); 26 | } 27 | _ => {} 28 | } 29 | } 30 | 31 | #[test] 32 | fn test() { 33 | use crate::test_case; 34 | use orgize::ast::SourceBlock; 35 | 36 | test_case!( 37 | SourceBlock, 38 | "#+begin_src\n#+end_src\n\r\n\n\r", 39 | format, 40 | "#+begin_src\n#+end_src\n\n" 41 | ); 42 | 43 | test_case!( 44 | SourceBlock, 45 | "#+begin_src\n#+end_src\n", 46 | format, 47 | "#+begin_src\n#+end_src\n" 48 | ); 49 | 50 | test_case!( 51 | SourceBlock, 52 | "#+begin_src\n#+end_src", 53 | format, 54 | "#+begin_src\n#+end_src" 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/command/formatting/list.rs: -------------------------------------------------------------------------------- 1 | use std::iter::once; 2 | 3 | use orgize::{ 4 | ast::ListItem, 5 | rowan::{ast::AstNode, TextRange, TextSize}, 6 | SyntaxNode, 7 | }; 8 | 9 | pub fn format(node: &SyntaxNode, indent_level: usize, edits: &mut Vec<(TextRange, String)>) { 10 | let mut items = node.children().filter_map(ListItem::cast); 11 | 12 | let Some(first_item) = items.next() else { 13 | return; 14 | }; 15 | 16 | match first_item.bullet().trim_end() { 17 | expected_bullet @ ("-" | "+" | "*") => { 18 | if first_item.indent() != 3 * indent_level { 19 | edits.push(( 20 | TextRange::at( 21 | first_item.start(), 22 | TextSize::new(first_item.indent() as u32), 23 | ), 24 | " ".repeat(3 * indent_level), 25 | )); 26 | } 27 | 28 | for item in items { 29 | if item.indent() != 3 * indent_level { 30 | edits.push(( 31 | TextRange::at(item.start(), TextSize::new(item.indent() as u32)), 32 | " ".repeat(3 * indent_level), 33 | )); 34 | } 35 | 36 | let bullet = item.bullet(); 37 | let s = bullet.trim_end(); 38 | if s != expected_bullet { 39 | edits.push(( 40 | TextRange::at(bullet.start(), TextSize::new(s.len() as u32)), 41 | expected_bullet.to_string(), 42 | )); 43 | } 44 | } 45 | } 46 | b => { 47 | let c = if b.ends_with(')') { ')' } else { '.' }; 48 | 49 | for (index, item) in once(first_item).chain(items).enumerate() { 50 | if item.indent() != 3 * indent_level { 51 | edits.push(( 52 | TextRange::at(item.start(), TextSize::new(item.indent() as u32)), 53 | " ".repeat(3 * indent_level), 54 | )); 55 | } 56 | 57 | let expected_bullet = format!("{}{c}", index + 1); 58 | let bullet = item.bullet(); 59 | let s = bullet.trim_end(); 60 | if s != expected_bullet { 61 | edits.push(( 62 | TextRange::at(bullet.start(), TextSize::new(s.len() as u32)), 63 | expected_bullet, 64 | )); 65 | } 66 | } 67 | } 68 | } 69 | } 70 | 71 | #[test] 72 | fn test() { 73 | use crate::test_case; 74 | use orgize::ast::List; 75 | 76 | let format0 = |node: &SyntaxNode, edits: &mut Vec<(TextRange, String)>| format(node, 0, edits); 77 | 78 | let format2 = |node: &SyntaxNode, edits: &mut Vec<(TextRange, String)>| format(node, 2, edits); 79 | 80 | test_case!(List, "1. item", format0, "1. item"); 81 | 82 | test_case!( 83 | List, 84 | "0. item\n- item\n+ item", 85 | format0, 86 | "1. item\n2. item\n3. item" 87 | ); 88 | 89 | test_case!( 90 | List, 91 | " + item\n - item\n 1. item", 92 | format0, 93 | "+ item\n+ item\n+ item" 94 | ); 95 | 96 | test_case!( 97 | List, 98 | " + item\n - item\n 1. item", 99 | format2, 100 | " + item\n + item\n + item" 101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /src/command/formatting/mod.rs: -------------------------------------------------------------------------------- 1 | use orgize::{ 2 | export::{from_fn, Container, Event}, 3 | rowan::{ast::AstNode, TextRange}, 4 | Org, 5 | }; 6 | 7 | mod blank_lines; 8 | mod list; 9 | mod rule; 10 | 11 | pub fn formatting(org: &Org) -> Vec<(TextRange, String)> { 12 | let mut indent_level = 0; 13 | let mut edits: Vec<(TextRange, String)> = vec![]; 14 | 15 | org.traverse(&mut from_fn(|event| match event { 16 | Event::Rule(rule) => { 17 | rule::format(rule.syntax(), &mut edits); 18 | blank_lines::format(rule.syntax(), &mut edits); 19 | } 20 | Event::Clock(clock) => { 21 | blank_lines::format(clock.syntax(), &mut edits); 22 | } 23 | 24 | Event::Enter(Container::Document(document)) => { 25 | blank_lines::format(document.syntax(), &mut edits); 26 | } 27 | Event::Enter(Container::Paragraph(paragraph)) => { 28 | blank_lines::format(paragraph.syntax(), &mut edits); 29 | } 30 | Event::Enter(Container::List(list)) => { 31 | list::format(list.syntax(), indent_level, &mut edits); 32 | blank_lines::format(list.syntax(), &mut edits); 33 | indent_level += 1; 34 | } 35 | Event::Leave(Container::List(_)) => { 36 | indent_level -= 1; 37 | } 38 | Event::Enter(Container::OrgTable(table)) => { 39 | blank_lines::format(table.syntax(), &mut edits); 40 | } 41 | Event::Enter(Container::SpecialBlock(block)) => { 42 | blank_lines::format(block.syntax(), &mut edits); 43 | } 44 | Event::Enter(Container::QuoteBlock(block)) => { 45 | blank_lines::format(block.syntax(), &mut edits); 46 | } 47 | Event::Enter(Container::CenterBlock(block)) => { 48 | blank_lines::format(block.syntax(), &mut edits); 49 | } 50 | Event::Enter(Container::VerseBlock(block)) => { 51 | blank_lines::format(block.syntax(), &mut edits); 52 | } 53 | Event::Enter(Container::CommentBlock(block)) => { 54 | blank_lines::format(block.syntax(), &mut edits); 55 | } 56 | Event::Enter(Container::ExampleBlock(block)) => { 57 | blank_lines::format(block.syntax(), &mut edits); 58 | } 59 | Event::Enter(Container::ExportBlock(block)) => { 60 | blank_lines::format(block.syntax(), &mut edits); 61 | } 62 | Event::Enter(Container::SourceBlock(block)) => { 63 | blank_lines::format(block.syntax(), &mut edits); 64 | } 65 | _ => {} 66 | })); 67 | 68 | edits 69 | } 70 | 71 | #[cfg(test)] 72 | #[macro_export] 73 | macro_rules! test_case { 74 | ( 75 | $n:tt, 76 | $input:expr, 77 | $fn:expr, 78 | $expected:expr 79 | ) => {{ 80 | use orgize::rowan::ast::AstNode; 81 | 82 | let org = orgize::Org::parse($input); 83 | let node = org.first_node::<$n>().unwrap(); 84 | let node = node.syntax(); 85 | 86 | let mut patches = vec![]; 87 | 88 | $fn(&node, &mut patches); 89 | 90 | let input = node.to_string(); 91 | 92 | patches.sort_by(|a, b| a.0.start().cmp(&b.0.start())); 93 | 94 | let mut i = 0; 95 | let mut output = String::new(); 96 | for (range, text) in patches { 97 | let start = range.start().into(); 98 | let end = range.end().into(); 99 | output.push_str(&input[i..start]); 100 | output.push_str(&text); 101 | i = end; 102 | } 103 | output.push_str(&input[i..]); 104 | 105 | assert_eq!(output, $expected); 106 | }}; 107 | } 108 | -------------------------------------------------------------------------------- /src/command/formatting/rule.rs: -------------------------------------------------------------------------------- 1 | use orgize::{rowan::TextRange, SyntaxKind, SyntaxNode}; 2 | 3 | pub fn format(node: &SyntaxNode, edits: &mut Vec<(TextRange, String)>) { 4 | for token in node.children_with_tokens().filter_map(|e| e.into_token()) { 5 | if token.kind() == SyntaxKind::WHITESPACE && !token.text().is_empty() { 6 | edits.push((token.text_range(), "".into())); 7 | } 8 | 9 | if token.kind() == SyntaxKind::TEXT && token.text().len() != 5 { 10 | edits.push((token.text_range(), "-----".into())); 11 | } 12 | 13 | if token.kind() == SyntaxKind::NEW_LINE && token.text() != "\n" { 14 | edits.push((token.text_range(), "\n".into())); 15 | } 16 | } 17 | } 18 | 19 | #[test] 20 | fn test() { 21 | use crate::test_case; 22 | use orgize::ast::Rule; 23 | 24 | test_case!(Rule, " ------------\r\n", format, "-----\n"); 25 | } 26 | -------------------------------------------------------------------------------- /src/command/headline/create.rs: -------------------------------------------------------------------------------- 1 | use chrono::NaiveDateTime; 2 | use lsp_types::{MessageType, Url}; 3 | use orgize::rowan::TextRange; 4 | use serde::{Deserialize, Serialize}; 5 | use std::fmt::Write; 6 | 7 | use crate::backend::Backend; 8 | use crate::command::Executable; 9 | use crate::utils::timestamp::FormatActiveTimestamp; 10 | 11 | #[derive(Deserialize, Serialize, Debug)] 12 | pub struct HeadlineCreate { 13 | pub url: Url, 14 | pub priority: Option, 15 | pub keyword: Option, 16 | pub title: Option, 17 | pub tags: Option>, 18 | pub section: Option, 19 | pub scheduled: Option, 20 | pub deadline: Option, 21 | } 22 | 23 | #[derive(Serialize)] 24 | pub struct Result { 25 | pub url: Url, 26 | pub line: usize, 27 | } 28 | 29 | impl Executable for HeadlineCreate { 30 | const NAME: &'static str = "headline-create"; 31 | 32 | type Result = Option; 33 | 34 | async fn execute(self, backend: &B) -> anyhow::Result> { 35 | let Some((end, line_numbers)) = backend.documents().get_map(&self.url, |doc| { 36 | (doc.org.document().end(), doc.line_numbers()) 37 | }) else { 38 | backend 39 | .log_message( 40 | MessageType::WARNING, 41 | format!("cannot find document with url {}", self.url), 42 | ) 43 | .await; 44 | 45 | return Ok(None); 46 | }; 47 | 48 | let mut s = "\n*".to_string(); 49 | 50 | if let Some(keyword) = self.keyword.filter(|t| !t.is_empty()) { 51 | s.push(' '); 52 | s.push_str(&keyword); 53 | } 54 | 55 | if let Some(priority) = self.priority.filter(|t| !t.is_empty()) { 56 | s.push_str(" [#"); 57 | s.push_str(&priority); 58 | s.push(']'); 59 | } 60 | 61 | s.push(' '); 62 | if let Some(title) = self.title { 63 | s.push_str(&title); 64 | } 65 | 66 | if let Some(tags) = self.tags.filter(|t| !t.is_empty()) { 67 | s.push_str(" :"); 68 | for tag in tags { 69 | s.push_str(&tag); 70 | s.push(':'); 71 | } 72 | } 73 | 74 | s.push('\n'); 75 | 76 | match (self.scheduled, self.deadline) { 77 | (Some(scheduled), Some(deadline)) => { 78 | let _ = writeln!( 79 | &mut s, 80 | "SCHEDULED: {} DEADLINE: {}", 81 | FormatActiveTimestamp(scheduled), 82 | FormatActiveTimestamp(deadline) 83 | ); 84 | } 85 | 86 | (Some(scheduled), None) => { 87 | let _ = writeln!(&mut s, "SCHEDULED: {}", FormatActiveTimestamp(scheduled)); 88 | } 89 | 90 | (None, Some(deadline)) => { 91 | let _ = writeln!(&mut s, "DEADLINE: {}", FormatActiveTimestamp(deadline)); 92 | } 93 | 94 | _ => {} 95 | }; 96 | 97 | if let Some(section) = self.section.filter(|t| !t.is_empty()) { 98 | s.push_str(§ion); 99 | s.push('\n'); 100 | } 101 | 102 | backend 103 | .apply_edit(self.url.clone(), s, TextRange::empty(end)) 104 | .await?; 105 | 106 | Ok(Some(Result { 107 | line: line_numbers + 1, 108 | url: self.url, 109 | })) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/command/headline/duplicate.rs: -------------------------------------------------------------------------------- 1 | use lsp_types::{MessageType, Url}; 2 | use orgize::rowan::TextRange; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use crate::backend::Backend; 6 | 7 | use crate::command::Executable; 8 | use crate::utils::headline::find_headline; 9 | 10 | #[derive(Deserialize, Serialize)] 11 | pub struct HeadlineDuplicate { 12 | pub url: Url, 13 | pub line: u32, 14 | } 15 | 16 | impl Executable for HeadlineDuplicate { 17 | const NAME: &'static str = "headline-duplicate"; 18 | 19 | type Result = bool; 20 | 21 | async fn execute(self, backend: &B) -> anyhow::Result { 22 | let Some(Some(headline)) = backend 23 | .documents() 24 | .get_map(&self.url, |doc| find_headline(&doc, self.line)) 25 | else { 26 | backend 27 | .log_message( 28 | MessageType::WARNING, 29 | format!("cannot find document with url {}", self.url), 30 | ) 31 | .await; 32 | 33 | return Ok(false); 34 | }; 35 | 36 | let (new_text, range) = (move || (headline.raw(), TextRange::empty(headline.end())))(); 37 | 38 | backend.apply_edit(self.url, new_text, range).await?; 39 | 40 | Ok(true) 41 | } 42 | } 43 | 44 | #[cfg(test)] 45 | #[tokio::test] 46 | async fn test() { 47 | use crate::test::TestBackend; 48 | 49 | let backend = TestBackend::default(); 50 | let url = Url::parse("test://test.org").unwrap(); 51 | backend.documents().insert(url.clone(), "* a\n* b\n * c"); 52 | 53 | HeadlineDuplicate { 54 | line: 1, 55 | url: url.clone(), 56 | } 57 | .execute(&backend) 58 | .await 59 | .unwrap(); 60 | assert_eq!(backend.get(&url), "* a\n* a\n* b\n * c"); 61 | 62 | HeadlineDuplicate { 63 | line: 2, 64 | url: url.clone(), 65 | } 66 | .execute(&backend) 67 | .await 68 | .unwrap(); 69 | assert_eq!(backend.get(&url), "* a\n* a\n* a\n* b\n * c"); 70 | } 71 | -------------------------------------------------------------------------------- /src/command/headline/generate_toc.rs: -------------------------------------------------------------------------------- 1 | use lsp_types::*; 2 | use orgize::{ 3 | export::{from_fn_with_ctx, Container, Event}, 4 | rowan::{ast::AstNode, TextRange, TextSize}, 5 | SyntaxKind, 6 | }; 7 | use serde::{Deserialize, Serialize}; 8 | use std::fmt::Write; 9 | 10 | use crate::backend::Backend; 11 | 12 | use crate::command::Executable; 13 | use crate::utils::headline::headline_slug; 14 | 15 | #[derive(Deserialize, Serialize)] 16 | pub struct HeadlineGenerateToc { 17 | pub url: Url, 18 | #[serde(with = "crate::utils::text_size")] 19 | pub headline_offset: TextSize, 20 | } 21 | 22 | impl Executable for HeadlineGenerateToc { 23 | const NAME: &'static str = "headline-toc"; 24 | 25 | const TITLE: Option<&'static str> = Some("Generate TOC"); 26 | 27 | type Result = bool; 28 | 29 | async fn execute(self, backend: &B) -> anyhow::Result { 30 | let mut edit_range: Option = None; 31 | let mut output = String::new(); 32 | let mut indent = 0; 33 | 34 | let Some(_) = backend.documents().get_map(&self.url, |doc| { 35 | doc.traverse(&mut from_fn_with_ctx(|event, ctx| match event { 36 | Event::Enter(Container::Headline(headline)) => { 37 | if headline.start() == self.headline_offset { 38 | let start = headline 39 | .syntax() 40 | .children_with_tokens() 41 | .find(|n| n.kind() == SyntaxKind::NEW_LINE) 42 | .map(|n| n.text_range().end()); 43 | 44 | let end = headline.end(); 45 | 46 | edit_range = Some(TextRange::new(start.unwrap_or(end), end)); 47 | } else { 48 | let title = headline.title_raw(); 49 | 50 | let slug = headline_slug(&headline); 51 | 52 | let _ = writeln!(&mut output, "{: >indent$}- [[#{slug}][{title}]]", "",); 53 | } 54 | 55 | indent += 2; 56 | } 57 | Event::Leave(Container::Headline(_)) => indent -= 2, 58 | Event::Enter(Container::Section(_)) => ctx.skip(), 59 | Event::Enter(Container::Document(_)) => output += "#+begin_quote\n", 60 | Event::Leave(Container::Document(_)) => output += "#+end_quote\n\n", 61 | _ => {} 62 | })); 63 | }) else { 64 | return Ok(false); 65 | }; 66 | 67 | if let Some(text_range) = edit_range { 68 | backend.apply_edit(self.url, output, text_range).await?; 69 | } 70 | 71 | Ok(true) 72 | } 73 | } 74 | 75 | #[cfg(test)] 76 | #[tokio::test] 77 | async fn test() { 78 | use crate::test::TestBackend; 79 | 80 | let backend = TestBackend::default(); 81 | let url = Url::parse("test://test.org").unwrap(); 82 | backend.documents().insert( 83 | url.clone(), 84 | r#"* toc 85 | * a 86 | **** g 87 | * b 88 | * c 89 | *** d 90 | ** e 91 | *** f"#, 92 | ); 93 | 94 | HeadlineGenerateToc { 95 | headline_offset: 0.into(), 96 | url: url.clone(), 97 | } 98 | .execute(&backend) 99 | .await 100 | .unwrap(); 101 | assert_eq!( 102 | backend.get(&url), 103 | r#"* toc 104 | #+begin_quote 105 | - [[#a][a]] 106 | - [[#g][g]] 107 | - [[#b][b]] 108 | - [[#c][c]] 109 | - [[#d][d]] 110 | - [[#e][e]] 111 | - [[#f][f]] 112 | #+end_quote 113 | 114 | * a 115 | **** g 116 | * b 117 | * c 118 | *** d 119 | ** e 120 | *** f"# 121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /src/command/headline/mod.rs: -------------------------------------------------------------------------------- 1 | mod create; 2 | mod duplicate; 3 | mod generate_toc; 4 | mod remove; 5 | mod search; 6 | mod update; 7 | 8 | pub use create::HeadlineCreate; 9 | pub use duplicate::*; 10 | pub use generate_toc::*; 11 | pub use remove::*; 12 | pub use search::*; 13 | pub use update::*; 14 | -------------------------------------------------------------------------------- /src/command/headline/remove.rs: -------------------------------------------------------------------------------- 1 | use lsp_types::{MessageType, Url}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::backend::Backend; 5 | 6 | use crate::command::Executable; 7 | use crate::utils::headline::find_headline; 8 | 9 | #[derive(Deserialize, Serialize, Debug)] 10 | pub struct HeadlineRemove { 11 | pub url: Url, 12 | pub line: u32, 13 | } 14 | 15 | impl Executable for HeadlineRemove { 16 | const NAME: &'static str = "headline-remove"; 17 | 18 | type Result = bool; 19 | 20 | async fn execute(self, backend: &B) -> anyhow::Result { 21 | let Some(headline) = backend 22 | .documents() 23 | .get_and_then(&self.url, |doc| find_headline(&doc, self.line)) 24 | else { 25 | backend 26 | .log_message( 27 | MessageType::WARNING, 28 | format!("cannot find document with url {}", self.url), 29 | ) 30 | .await; 31 | 32 | return Ok(false); 33 | }; 34 | 35 | let text_range = (move || headline.text_range())(); 36 | 37 | backend 38 | .apply_edit(self.url, String::new(), text_range) 39 | .await?; 40 | 41 | Ok(true) 42 | } 43 | } 44 | 45 | #[cfg(test)] 46 | #[tokio::test] 47 | async fn test() { 48 | use crate::test::TestBackend; 49 | 50 | let backend = TestBackend::default(); 51 | let url = Url::parse("test://test.org").unwrap(); 52 | backend.documents().insert(url.clone(), "** \n* "); 53 | 54 | HeadlineRemove { 55 | line: 1, 56 | url: url.clone(), 57 | } 58 | .execute(&backend) 59 | .await 60 | .unwrap(); 61 | assert_eq!(backend.get(&url), "* "); 62 | 63 | HeadlineRemove { 64 | line: 1, 65 | url: url.clone(), 66 | } 67 | .execute(&backend) 68 | .await 69 | .unwrap(); 70 | assert_eq!(backend.get(&url), ""); 71 | } 72 | -------------------------------------------------------------------------------- /src/command/headline/reorder.rs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/src/command/headline/reorder.rs -------------------------------------------------------------------------------- /src/command/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod clocking; 2 | pub mod formatting; 3 | pub mod headline; 4 | pub mod src_block; 5 | 6 | use lsp_types::*; 7 | use orgize::rowan::ast::AstNode; 8 | use serde::{de::DeserializeOwned, Deserialize, Serialize}; 9 | use serde_json::Value; 10 | 11 | use crate::backend::Backend; 12 | 13 | pub trait Executable: DeserializeOwned { 14 | const NAME: &'static str; 15 | 16 | const TITLE: Option<&'static str> = None; 17 | 18 | type Result: Serialize; 19 | 20 | async fn execute(self, backend: &B) -> anyhow::Result; 21 | } 22 | 23 | macro_rules! command { 24 | ($( $i:ident, )*) => { 25 | #[derive(Deserialize)] 26 | #[serde(tag = "command", content = "argument", rename_all = "kebab-case")] 27 | pub enum OrgwiseCommand { 28 | $( $i($i) ),* 29 | } 30 | 31 | impl OrgwiseCommand { 32 | pub async fn execute(self, backend: &B) -> anyhow::Result { 33 | match self { 34 | $( 35 | OrgwiseCommand::$i(i) => Ok(serde_json::to_value(i.execute(backend).await?)?) 36 | ),* 37 | } 38 | } 39 | 40 | #[cfg(feature="tower")] 41 | pub async fn execute_response(self, backend: &B) -> anyhow::Result { 42 | use axum::{response::IntoResponse, Json}; 43 | match self { 44 | $( 45 | OrgwiseCommand::$i(i) => Ok(Json(i.execute(backend).await?).into_response()) 46 | ),* 47 | } 48 | } 49 | 50 | pub fn all() -> Vec { 51 | vec![ 52 | $( 53 | format!("orgwise.{}", $i::NAME) 54 | ),* 55 | ] 56 | } 57 | 58 | pub fn from_value(name: &str, argument: Value) -> Option { 59 | match name { 60 | $( 61 | $i::NAME => { 62 | Some(OrgwiseCommand::$i( 63 | serde_json::from_value(argument).ok()? 64 | )) 65 | } 66 | ),* 67 | _ => None 68 | } 69 | } 70 | } 71 | 72 | $( 73 | impl Into for $i { 74 | fn into(self) -> Command { 75 | Command { 76 | title: $i::TITLE.unwrap_or($i::NAME).to_string(), 77 | command: format!("orgwise.{}", $i::NAME), 78 | arguments: Some(vec![serde_json::to_value(self).unwrap()]), 79 | } 80 | } 81 | } 82 | )* 83 | }; 84 | } 85 | 86 | #[derive(Deserialize, Serialize)] 87 | pub struct SyntaxTree(Url); 88 | 89 | impl Executable for SyntaxTree { 90 | const NAME: &'static str = "syntax-tree"; 91 | 92 | type Result = Option; 93 | 94 | async fn execute(self, backend: &B) -> anyhow::Result> { 95 | Ok(backend 96 | .documents() 97 | .get_map(&self.0, |doc| format!("{:#?}", doc.org.document().syntax()))) 98 | } 99 | } 100 | 101 | #[derive(Deserialize, Serialize)] 102 | pub struct PreviewHtml(Url); 103 | 104 | impl Executable for PreviewHtml { 105 | const NAME: &'static str = "preview-html"; 106 | 107 | type Result = Option; 108 | 109 | async fn execute(self, backend: &B) -> anyhow::Result> { 110 | Ok(backend 111 | .documents() 112 | .get_map(&self.0, |doc| doc.org.to_html())) 113 | } 114 | } 115 | 116 | pub use clocking::{ClockingStart, ClockingStatus, ClockingStop}; 117 | pub use headline::{ 118 | HeadlineCreate, HeadlineDuplicate, HeadlineGenerateToc, HeadlineRemove, HeadlineSearch, 119 | HeadlineUpdate, 120 | }; 121 | pub use src_block::{ 122 | SrcBlockDetangle, SrcBlockDetangleAll, SrcBlockExecute, SrcBlockExecuteAll, SrcBlockTangle, 123 | SrcBlockTangleAll, 124 | }; 125 | 126 | command!( 127 | PreviewHtml, 128 | SyntaxTree, 129 | ClockingStart, 130 | ClockingStatus, 131 | ClockingStop, 132 | HeadlineCreate, 133 | HeadlineDuplicate, 134 | HeadlineGenerateToc, 135 | HeadlineRemove, 136 | HeadlineSearch, 137 | HeadlineUpdate, 138 | SrcBlockDetangle, 139 | SrcBlockDetangleAll, 140 | SrcBlockExecute, 141 | SrcBlockExecuteAll, 142 | SrcBlockTangle, 143 | SrcBlockTangleAll, 144 | ); 145 | -------------------------------------------------------------------------------- /src/command/src_block/detangle.rs: -------------------------------------------------------------------------------- 1 | use lsp_types::*; 2 | use orgize::rowan::TextSize; 3 | use orgize::{ast::Headline, rowan::TextRange, SyntaxKind}; 4 | use orgize::{ast::SourceBlock, rowan::ast::AstNode}; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use crate::backend::Backend; 8 | 9 | use crate::command::Executable; 10 | use crate::utils::src_block::{ 11 | collect_src_blocks, header_argument, language_comments, property_drawer, property_keyword, 12 | }; 13 | 14 | #[derive(Deserialize, Serialize)] 15 | pub struct SrcBlockDetangle { 16 | pub url: Url, 17 | #[serde(with = "crate::utils::text_size")] 18 | pub block_offset: TextSize, 19 | } 20 | 21 | impl Executable for SrcBlockDetangle { 22 | const NAME: &'static str = "src-block-detangle"; 23 | 24 | const TITLE: Option<&'static str> = Some("Detangle"); 25 | 26 | type Result = bool; 27 | 28 | async fn execute(self, backend: &B) -> anyhow::Result { 29 | let Some(block) = backend 30 | .documents() 31 | .get_and_then(&self.url, |doc| doc.org.node_at_offset(self.block_offset)) 32 | else { 33 | return Ok(false); 34 | }; 35 | 36 | let Some(option) = DetangleOptions::new(block, &self.url, backend) else { 37 | backend 38 | .log_message( 39 | MessageType::WARNING, 40 | "Code block can't be detangled.".into(), 41 | ) 42 | .await; 43 | 44 | return Ok(false); 45 | }; 46 | 47 | let (text_range, new_text) = option.run(backend).await?; 48 | 49 | backend.apply_edit(self.url, new_text, text_range).await?; 50 | 51 | Ok(true) 52 | } 53 | } 54 | 55 | #[derive(Deserialize, Serialize)] 56 | pub struct SrcBlockDetangleAll { 57 | pub url: Url, 58 | } 59 | 60 | impl Executable for SrcBlockDetangleAll { 61 | const NAME: &'static str = "src-block-detangle-all"; 62 | 63 | const TITLE: Option<&'static str> = Some("Detangle all source blocks"); 64 | 65 | type Result = bool; 66 | 67 | async fn execute(self, backend: &B) -> anyhow::Result { 68 | let Some(blocks) = backend 69 | .documents() 70 | .get_map(&self.url, |doc| collect_src_blocks(&doc.org)) 71 | else { 72 | return Ok(false); 73 | }; 74 | 75 | let options: Vec<_> = blocks 76 | .into_iter() 77 | .filter_map(|block| DetangleOptions::new(block, &self.url, backend)) 78 | .collect(); 79 | 80 | let mut edits = Vec::with_capacity(options.len()); 81 | 82 | for option in options { 83 | let (range, content) = option.run(backend).await?; 84 | edits.push((self.url.clone(), content, range)); 85 | } 86 | 87 | backend.apply_edits(edits.into_iter()).await?; 88 | 89 | Ok(true) 90 | } 91 | } 92 | 93 | pub struct DetangleOptions { 94 | destination: Url, 95 | comment_link: Option<(String, String)>, 96 | text_range: TextRange, 97 | } 98 | 99 | impl DetangleOptions { 100 | pub fn new(block: SourceBlock, base: &Url, backend: &B) -> Option { 101 | let arg1 = block.parameters().unwrap_or_default(); 102 | let arg2 = property_drawer(block.syntax()).unwrap_or_default(); 103 | let arg3 = property_keyword(block.syntax()).unwrap_or_default(); 104 | let language = block.language().unwrap_or_default(); 105 | 106 | let tangle = header_argument(&arg1, &arg2, &arg3, ":tangle", "no"); 107 | 108 | if tangle == "no" { 109 | return None; 110 | } 111 | 112 | let text_range = block 113 | .syntax() 114 | .children() 115 | .find(|n| n.kind() == SyntaxKind::BLOCK_CONTENT) 116 | .unwrap() 117 | .text_range(); 118 | 119 | let comments = header_argument(&arg1, &arg2, &arg3, ":comments", "no"); 120 | 121 | let destination = backend.resolve_in(tangle, base).ok()?; 122 | 123 | let mut comment_link = None; 124 | if comments == "yes" || comments == "link" || comments == "noweb" || comments == "both" { 125 | let parent = block 126 | .syntax() 127 | .ancestors() 128 | .find(|n| n.kind() == SyntaxKind::HEADLINE || n.kind() == SyntaxKind::DOCUMENT); 129 | 130 | let nth = parent 131 | .as_ref() 132 | .and_then(|n| n.children().position(|c| &c == block.syntax())) 133 | .unwrap_or(1); 134 | 135 | let title = parent 136 | .and_then(Headline::cast) 137 | .map(|headline| headline.title_raw()) 138 | .unwrap_or_else(|| "No heading".to_string()); 139 | 140 | if let Some((l, r)) = language_comments(&language) { 141 | comment_link = Some(( 142 | format!("{l} [[{}::*{title}][{title}:{nth}]] {r}", destination), 143 | format!("{l} {title}:{nth} ends here {r}"), 144 | )); 145 | } 146 | } 147 | 148 | Some(DetangleOptions { 149 | destination, 150 | comment_link, 151 | text_range, 152 | }) 153 | } 154 | 155 | pub async fn run(self, backend: &B) -> anyhow::Result<(TextRange, String)> { 156 | let content = backend.read_to_string(&self.destination).await?; 157 | 158 | if let Some((begin, end)) = &self.comment_link { 159 | let mut block_content = String::new(); 160 | 161 | for line in content 162 | .lines() 163 | .skip_while(|line| line.trim() != begin) 164 | .skip(1) 165 | { 166 | if line.trim() == end { 167 | break; 168 | } else { 169 | block_content += line; 170 | block_content += "\n"; 171 | } 172 | } 173 | 174 | Ok((self.text_range, block_content)) 175 | } else { 176 | Ok((self.text_range, content)) 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/command/src_block/mod.rs: -------------------------------------------------------------------------------- 1 | mod detangle; 2 | mod execute; 3 | mod tangle; 4 | 5 | pub use detangle::*; 6 | pub use execute::*; 7 | pub use tangle::*; 8 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(async_fn_in_trait)] 2 | #![allow(dead_code)] 3 | 4 | pub mod backend; 5 | #[cfg(feature = "tower")] 6 | pub mod cli; 7 | pub mod command; 8 | pub mod lsp; 9 | #[cfg(test)] 10 | pub mod test; 11 | pub mod utils; 12 | #[cfg(feature = "wasm")] 13 | pub mod wasm; 14 | -------------------------------------------------------------------------------- /src/lsp/code_lens.rs: -------------------------------------------------------------------------------- 1 | use lsp_types::*; 2 | use orgize::{ 3 | export::{Container, Event, TraversalContext, Traverser}, 4 | rowan::ast::AstNode, 5 | }; 6 | 7 | use crate::command::{ 8 | ClockingStop, HeadlineGenerateToc, SrcBlockDetangle, SrcBlockExecute, SrcBlockTangle, 9 | }; 10 | use crate::utils::src_block::{header_argument, property_drawer, property_keyword}; 11 | use crate::{backend::Backend, command::ClockingStart}; 12 | use crate::{backend::OrgDocument, utils::clocking::find_logbook}; 13 | 14 | pub fn code_lens(backend: &B, params: CodeLensParams) -> Option> { 15 | backend 16 | .documents() 17 | .get_map(¶ms.text_document.uri.clone(), |doc| { 18 | let mut traverser = CodeLensTraverser { 19 | url: params.text_document.uri, 20 | lens: vec![], 21 | doc, 22 | }; 23 | 24 | doc.traverse(&mut traverser); 25 | 26 | traverser.lens 27 | }) 28 | } 29 | 30 | pub fn code_lens_resolve(_: &B, params: CodeLens) -> CodeLens { 31 | params 32 | } 33 | 34 | struct CodeLensTraverser<'a> { 35 | url: Url, 36 | doc: &'a OrgDocument, 37 | lens: Vec, 38 | } 39 | 40 | impl<'a> Traverser for CodeLensTraverser<'a> { 41 | fn event(&mut self, event: Event, ctx: &mut TraversalContext) { 42 | match event { 43 | Event::Enter(Container::SourceBlock(block)) => { 44 | let start = block.start(); 45 | 46 | let arg1 = block.parameters().unwrap_or_default(); 47 | let arg2 = property_drawer(block.syntax()).unwrap_or_default(); 48 | let arg3 = property_keyword(block.syntax()).unwrap_or_default(); 49 | 50 | let range = self.doc.range_of2(start, start); 51 | 52 | let tangle = header_argument(&arg1, &arg2, &arg3, ":tangle", "no"); 53 | 54 | if header_argument(&arg1, &arg2, &arg3, ":results", "no") != "no" { 55 | self.lens.push(CodeLens { 56 | range, 57 | command: Some( 58 | SrcBlockExecute { 59 | block_offset: start, 60 | url: self.url.clone(), 61 | } 62 | .into(), 63 | ), 64 | data: None, 65 | }); 66 | } 67 | 68 | if tangle != "no" { 69 | self.lens.push(CodeLens { 70 | range, 71 | command: Some( 72 | SrcBlockTangle { 73 | block_offset: start, 74 | url: self.url.clone(), 75 | } 76 | .into(), 77 | ), 78 | data: None, 79 | }); 80 | 81 | self.lens.push(CodeLens { 82 | range, 83 | command: Some( 84 | SrcBlockDetangle { 85 | block_offset: start, 86 | url: self.url.clone(), 87 | } 88 | .into(), 89 | ), 90 | data: None, 91 | }); 92 | } 93 | 94 | ctx.skip(); 95 | } 96 | Event::Enter(Container::Headline(headline)) => { 97 | let start = headline.start(); 98 | 99 | if headline.tags().any(|t| t.eq_ignore_ascii_case("TOC")) { 100 | self.lens.push(CodeLens { 101 | range: self.doc.range_of2(start, start), 102 | command: Some( 103 | HeadlineGenerateToc { 104 | headline_offset: start, 105 | url: self.url.clone(), 106 | } 107 | .into(), 108 | ), 109 | data: None, 110 | }); 111 | } 112 | 113 | if find_logbook(&headline).is_some() { 114 | self.lens.push(CodeLens { 115 | range: self.doc.range_of2(start, start), 116 | command: Some(if headline.clocks().any(|c| c.is_running()) { 117 | ClockingStop { 118 | url: self.url.clone(), 119 | line: self.doc.line_of(start.into()) + 1, 120 | } 121 | .into() 122 | } else { 123 | ClockingStart { 124 | url: self.url.clone(), 125 | line: self.doc.line_of(start.into()) + 1, 126 | } 127 | .into() 128 | }), 129 | data: None, 130 | }); 131 | } 132 | } 133 | _ => {} 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/lsp/completion.rs: -------------------------------------------------------------------------------- 1 | use crate::backend::Backend; 2 | use lsp_types::*; 3 | 4 | pub fn completion(backend: &B, params: CompletionParams) -> Option { 5 | let filter_text = backend.documents().get_and_then( 6 | ¶ms.text_document_position.text_document.uri, 7 | |doc| { 8 | let offset = doc.offset_of(params.text_document_position.position) as usize; 9 | if offset < 2 { 10 | return None; 11 | } 12 | Some(doc.text.get((offset - 2)..offset)?.to_string()) 13 | }, 14 | )?; 15 | 16 | let (label, new_text) = match &*filter_text { 17 | " ( 18 | "ASCI export block", 19 | "#+BEGIN_EXPORT ascii\n${0}\n#+END_EXPORT\n", 20 | ), 21 | " ("Center block", "#+BEGIN_CENTER\n${0}\n#+END_CENTER\n"), 22 | " ("Comment block", "#+BEGIN_COMMENT\n${0}\n#+END_COMMENT\n"), 23 | " ("Example block", "#+BEGIN_EXAMPLE\n${0}\n#+END_EXAMPLE\n"), 24 | " ("Export block", "#+BEGIN_EXPORT\n${0}\n#+END_EXPORT\n"), 25 | " ( 26 | "HTML export block", 27 | "#+BEGIN_EXPORT html\n${0}\n#+END_EXPORT\n", 28 | ), 29 | " ( 30 | "LaTeX export block", 31 | "#+BEGIN_EXPORT latex\n${0}\n#+END_EXPORT\n", 32 | ), 33 | " ("Quote block", "#+BEGIN_QUOTE\n${0}\n#+END_QUOTE\n"), 34 | " ("Source block", "#+BEGIN_SRC ${1}\n${0}\n#+END_SRC\n"), 35 | " ("Verse block", "#+BEGIN_VERSE\n${0}\n#+END_VERSE\n"), 36 | _ => return None, 37 | }; 38 | 39 | let end = params.text_document_position.position; 40 | 41 | Some(CompletionResponse::Array(vec![CompletionItem { 42 | label: label.into(), 43 | kind: Some(CompletionItemKind::SNIPPET), 44 | insert_text: Some(new_text.into()), 45 | insert_text_format: Some(InsertTextFormat::SNIPPET), 46 | filter_text: Some(filter_text), 47 | text_edit: Some(CompletionTextEdit::Edit(TextEdit { 48 | new_text: new_text.into(), 49 | range: Range { 50 | start: Position::new(end.line, end.character - 2), 51 | end, 52 | }, 53 | })), 54 | ..Default::default() 55 | }])) 56 | } 57 | 58 | pub fn completion_resolve(_: &B, params: CompletionItem) -> CompletionItem { 59 | params 60 | } 61 | 62 | pub fn trigger_characters() -> Vec { 63 | vec![ 64 | "( 15 | backend: &B, 16 | params: DocumentLinkParams, 17 | ) -> Option> { 18 | backend 19 | .documents() 20 | .get_map(¶ms.text_document.uri.clone(), |doc| { 21 | let mut traverser = DocumentLinkTraverser { 22 | doc: &doc, 23 | links: vec![], 24 | base: params.text_document.uri, 25 | }; 26 | 27 | doc.traverse(&mut traverser); 28 | 29 | traverser.links 30 | }) 31 | } 32 | 33 | pub fn document_link_resolve(backend: &B, mut params: DocumentLink) -> DocumentLink { 34 | if params.target.is_some() { 35 | return params; 36 | } 37 | 38 | if let Some(data) = params.data.take() { 39 | params.target = resolve(backend, data); 40 | } 41 | 42 | params 43 | } 44 | 45 | fn resolve(backend: &B, data: Value) -> Option { 46 | let (typ, url, id): (String, Url, String) = serde_json::from_value(data).ok()?; 47 | 48 | match (typ.as_str(), url, id) { 49 | ("headline-id", mut url, id) => { 50 | backend.documents().get_map(&url.clone(), |doc| { 51 | let mut h = HeadlineIdTraverser { 52 | id: id.to_string(), 53 | offset: None, 54 | }; 55 | 56 | doc.traverse(&mut h); 57 | 58 | if let Some(offset) = h.offset.take() { 59 | let line = doc.line_of(offset); 60 | // results is zero-based 61 | url.set_fragment(Some(&(line + 1).to_string())); 62 | } 63 | 64 | url 65 | }) 66 | } 67 | ("resolve", base, path) => backend.resolve_in(&path, &base).ok(), 68 | _ => None, 69 | } 70 | } 71 | 72 | struct DocumentLinkTraverser<'a> { 73 | doc: &'a OrgDocument, 74 | links: Vec, 75 | base: Url, 76 | } 77 | 78 | impl<'a> Traverser for DocumentLinkTraverser<'a> { 79 | fn event(&mut self, event: Event, ctx: &mut TraversalContext) { 80 | match event { 81 | Event::Enter(Container::Link(link)) => { 82 | if let Some(link) = self.link_path(link) { 83 | self.links.push(link); 84 | } 85 | ctx.skip(); 86 | } 87 | Event::Enter(Container::SourceBlock(block)) => { 88 | if let Some(link) = self.block_tangle(block) { 89 | self.links.push(link); 90 | } 91 | ctx.skip(); 92 | } 93 | 94 | _ => {} 95 | } 96 | } 97 | } 98 | 99 | impl<'a> DocumentLinkTraverser<'a> { 100 | fn link_path(&self, link: Link) -> Option { 101 | let path = support::token(link.syntax(), SyntaxKind::LINK_PATH) 102 | .or_else(|| support::token(link.syntax(), SyntaxKind::TEXT))?; 103 | 104 | let path_str = path.text(); 105 | 106 | let (target, data) = if let Some(file) = path_str.strip_prefix("file:") { 107 | ( 108 | None, 109 | serde_json::to_value(("resolve", &self.base, file)).ok(), 110 | ) 111 | } else if path_str.starts_with('/') 112 | || path_str.starts_with("./") 113 | || path_str.starts_with("~/") 114 | { 115 | ( 116 | None, 117 | serde_json::to_value(("resolve", &self.base, path_str)).ok(), 118 | ) 119 | } else if path_str.starts_with("http://") || path_str.starts_with("https://") { 120 | (Some(Url::parse(path_str).ok()?), None) 121 | } else if let Some(id) = path_str.strip_prefix('#') { 122 | ( 123 | None, 124 | serde_json::to_value(("headline-id", &self.base, id)).ok(), 125 | ) 126 | } else { 127 | return None; 128 | }; 129 | 130 | Some(DocumentLink { 131 | range: self.doc.range_of(path.text_range()), 132 | tooltip: Some("Jump to link".into()), 133 | target, 134 | data, 135 | }) 136 | } 137 | 138 | fn block_tangle(&self, block: SourceBlock) -> Option { 139 | let parameters = block 140 | .syntax() 141 | .children() 142 | .find(|e| e.kind() == SyntaxKind::BLOCK_BEGIN) 143 | .into_iter() 144 | .flat_map(|n| n.children_with_tokens()) 145 | .filter_map(|n| n.into_token()) 146 | .find(|n| n.kind() == SyntaxKind::SRC_BLOCK_PARAMETERS)?; 147 | 148 | let tangle = header_argument(parameters.text(), "", "", ":tangle", "no"); 149 | 150 | if tangle == "no" { 151 | return None; 152 | } 153 | 154 | let start: u32 = parameters.text_range().start().into(); 155 | 156 | let index = parameters.text().find(tangle).unwrap_or_default() as u32; 157 | 158 | let len = tangle.len() as u32; 159 | 160 | Some(DocumentLink { 161 | range: self.doc.range_of2(start + index, start + index + len), 162 | tooltip: Some("Jump to tangle destination".into()), 163 | target: None, 164 | data: serde_json::to_value(("resolve", &self.base, tangle)).ok(), 165 | }) 166 | } 167 | } 168 | 169 | struct HeadlineIdTraverser { 170 | id: String, 171 | offset: Option, 172 | } 173 | 174 | impl Traverser for HeadlineIdTraverser { 175 | fn event(&mut self, event: Event, ctx: &mut TraversalContext) { 176 | match event { 177 | Event::Enter(Container::Headline(headline)) if headline_slug(&headline) == self.id => { 178 | self.offset = Some(headline.start().into()); 179 | ctx.stop() 180 | } 181 | Event::Enter(Container::Section(_)) => ctx.skip(), 182 | _ => {} 183 | } 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/lsp/document_symbol.rs: -------------------------------------------------------------------------------- 1 | #![allow(deprecated)] 2 | 3 | use lsp_types::*; 4 | use orgize::{ 5 | export::{from_fn_with_ctx, Container, Event}, 6 | rowan::ast::AstNode, 7 | SyntaxKind, 8 | }; 9 | 10 | use crate::backend::Backend; 11 | 12 | pub fn document_symbol( 13 | backend: &B, 14 | params: DocumentSymbolParams, 15 | ) -> Option { 16 | backend 17 | .documents() 18 | .get_map(¶ms.text_document.uri, |doc| { 19 | let mut stack: Vec = vec![]; 20 | let mut symbols: Vec = vec![]; 21 | 22 | let mut handler = from_fn_with_ctx(|event, ctx| match event { 23 | Event::Enter(Container::Headline(headline)) => { 24 | let mut s = &mut symbols; 25 | for &i in &stack { 26 | s = s[i].children.get_or_insert(vec![]); 27 | } 28 | 29 | let name = headline 30 | .syntax() 31 | .children_with_tokens() 32 | .filter(|n| { 33 | n.kind() != SyntaxKind::HEADLINE_KEYWORD_DONE 34 | && n.kind() != SyntaxKind::HEADLINE_KEYWORD_TODO 35 | && n.kind() != SyntaxKind::HEADLINE_PRIORITY 36 | && n.kind() != SyntaxKind::HEADLINE_TAGS 37 | }) 38 | .take_while(|n| n.kind() != SyntaxKind::NEW_LINE) 39 | .map(|n| n.to_string()) 40 | .collect::(); 41 | 42 | let start = headline.start(); 43 | let end = headline.end(); 44 | 45 | stack.push(s.len()); 46 | s.push(DocumentSymbol { 47 | children: None, 48 | name, 49 | detail: None, 50 | kind: SymbolKind::STRING, 51 | tags: Some(vec![]), 52 | range: doc.range_of2(start, end), 53 | selection_range: doc.range_of2(start, end), 54 | deprecated: None, 55 | }); 56 | } 57 | Event::Leave(Container::Headline(_)) => { 58 | stack.pop(); 59 | } 60 | Event::Enter(Container::Section(_)) => ctx.skip(), 61 | _ => {} 62 | }); 63 | doc.traverse(&mut handler); 64 | 65 | DocumentSymbolResponse::Nested(symbols) 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /src/lsp/execute_command.rs: -------------------------------------------------------------------------------- 1 | use lsp_types::{ExecuteCommandParams, MessageType}; 2 | use serde_json::Value; 3 | 4 | use crate::{backend::Backend, command::OrgwiseCommand}; 5 | 6 | pub async fn execute_command( 7 | backend: &B, 8 | mut params: ExecuteCommandParams, 9 | ) -> Option { 10 | let name = params.command.as_str().strip_prefix("orgwise.")?; 11 | let argument = params.arguments.pop()?; 12 | 13 | let Some(cmd) = OrgwiseCommand::from_value(name, argument) else { 14 | backend 15 | .show_message(MessageType::WARNING, format!("Unknown command {name:?}")) 16 | .await; 17 | return None; 18 | }; 19 | 20 | match cmd.execute(backend).await { 21 | Ok(value) => Some(value), 22 | Err(err) => { 23 | backend 24 | .show_message( 25 | MessageType::ERROR, 26 | format!("Failed to execute {name:?}: {err}"), 27 | ) 28 | .await; 29 | 30 | None 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/lsp/folding_range.rs: -------------------------------------------------------------------------------- 1 | use lsp_types::*; 2 | use orgize::{ 3 | export::{from_fn, Container, Event}, 4 | rowan::ast::AstNode, 5 | SyntaxKind, SyntaxNode, 6 | }; 7 | 8 | use crate::backend::Backend; 9 | 10 | pub fn folding_range( 11 | backend: &B, 12 | params: FoldingRangeParams, 13 | ) -> Option> { 14 | backend 15 | .documents() 16 | .get_map(¶ms.text_document.uri, |doc| { 17 | let mut ranges: Vec = vec![]; 18 | 19 | doc.traverse(&mut from_fn(|event| { 20 | let syntax = match &event { 21 | Event::Enter(Container::Headline(i)) => i.syntax(), 22 | Event::Enter(Container::OrgTable(i)) => i.syntax(), 23 | Event::Enter(Container::TableEl(i)) => i.syntax(), 24 | Event::Enter(Container::List(i)) => i.syntax(), 25 | Event::Enter(Container::Drawer(i)) => i.syntax(), 26 | Event::Enter(Container::DynBlock(i)) => i.syntax(), 27 | Event::Enter(Container::SpecialBlock(i)) => i.syntax(), 28 | Event::Enter(Container::QuoteBlock(i)) => i.syntax(), 29 | Event::Enter(Container::CenterBlock(i)) => i.syntax(), 30 | Event::Enter(Container::VerseBlock(i)) => i.syntax(), 31 | Event::Enter(Container::CommentBlock(i)) => i.syntax(), 32 | Event::Enter(Container::ExampleBlock(i)) => i.syntax(), 33 | Event::Enter(Container::ExportBlock(i)) => i.syntax(), 34 | Event::Enter(Container::SourceBlock(i)) => i.syntax(), 35 | _ => return, 36 | }; 37 | 38 | let (start, end) = if syntax.kind() == SyntaxKind::HEADLINE { 39 | let range = syntax.text_range(); 40 | (range.start().into(), range.end().into()) 41 | } else { 42 | get_block_folding_range(syntax) 43 | }; 44 | 45 | let start_line = doc.line_of(start); 46 | let end_line = doc.line_of(end - 1); 47 | 48 | if start_line != end_line { 49 | ranges.push(FoldingRange { 50 | start_line, 51 | end_line, 52 | kind: Some(FoldingRangeKind::Region), 53 | ..Default::default() 54 | }); 55 | } 56 | })); 57 | 58 | ranges 59 | }) 60 | } 61 | 62 | fn get_block_folding_range(syntax: &SyntaxNode) -> (u32, u32) { 63 | let start: u32 = syntax.text_range().start().into(); 64 | 65 | // don't include blank lines in folding range 66 | let end = syntax 67 | .children() 68 | .take_while(|n| n.kind() != SyntaxKind::BLANK_LINE) 69 | .last(); 70 | 71 | let end: u32 = end.map(|n| n.text_range().end().into()).unwrap_or(start); 72 | 73 | (start, end) 74 | } 75 | 76 | #[test] 77 | fn test() { 78 | use crate::test::TestBackend; 79 | 80 | let backend = TestBackend::default(); 81 | let url = Url::parse("test://test.org").unwrap(); 82 | backend.documents().insert(url.clone(), "\n* a\n\n* b\n\n"); 83 | 84 | let ranges = folding_range( 85 | &backend, 86 | FoldingRangeParams { 87 | text_document: TextDocumentIdentifier { uri: url.clone() }, 88 | partial_result_params: PartialResultParams::default(), 89 | work_done_progress_params: WorkDoneProgressParams::default(), 90 | }, 91 | ) 92 | .unwrap(); 93 | assert_eq!(ranges[0].start_line, 1); 94 | assert_eq!(ranges[0].end_line, 2); 95 | assert_eq!(ranges[1].start_line, 3); 96 | assert_eq!(ranges[1].end_line, 4); 97 | 98 | backend 99 | .documents() 100 | .insert(url.clone(), "\n\r\n#+begin_src\n#+end_src\n\r\r"); 101 | let ranges = folding_range( 102 | &backend, 103 | FoldingRangeParams { 104 | text_document: TextDocumentIdentifier { uri: url.clone() }, 105 | partial_result_params: PartialResultParams::default(), 106 | work_done_progress_params: WorkDoneProgressParams::default(), 107 | }, 108 | ) 109 | .unwrap(); 110 | assert_eq!(ranges[0].start_line, 2); 111 | assert_eq!(ranges[0].end_line, 3); 112 | } 113 | -------------------------------------------------------------------------------- /src/lsp/formatting.rs: -------------------------------------------------------------------------------- 1 | use crate::backend::Backend; 2 | use lsp_types::*; 3 | 4 | pub fn formatting( 5 | backend: &B, 6 | params: DocumentFormattingParams, 7 | ) -> Option> { 8 | backend 9 | .documents() 10 | .get_map(¶ms.text_document.uri, |doc| { 11 | crate::command::formatting::formatting(&doc.org) 12 | .into_iter() 13 | .map(|(range, content)| TextEdit { 14 | range: doc.range_of(range), 15 | new_text: content, 16 | }) 17 | .collect::>() 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /src/lsp/initialize.rs: -------------------------------------------------------------------------------- 1 | use lsp_types::*; 2 | use orgize::ParseConfig; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | use super::semantic_token; 6 | use crate::backend::Backend; 7 | use crate::command::OrgwiseCommand; 8 | 9 | #[derive(Serialize, Deserialize, Debug)] 10 | #[serde(rename_all = "camelCase")] 11 | pub struct InitializationOptions { 12 | #[serde(default)] 13 | pub todo_keywords: Vec, 14 | #[serde(default)] 15 | pub done_keywords: Vec, 16 | } 17 | 18 | pub async fn initialize(backend: &B, params: InitializeParams) -> InitializeResult { 19 | if let Some(initialization_options) = params 20 | .initialization_options 21 | .and_then(|o| serde_json::from_value::(o).ok()) 22 | { 23 | backend 24 | .log_message( 25 | MessageType::INFO, 26 | format!( 27 | "Initialization options: {}", 28 | serde_json::to_string(&initialization_options).unwrap_or_default() 29 | ), 30 | ) 31 | .await; 32 | 33 | backend.documents().set_default_parse_config(ParseConfig { 34 | todo_keywords: ( 35 | initialization_options.todo_keywords, 36 | initialization_options.done_keywords, 37 | ), 38 | ..Default::default() 39 | }); 40 | } 41 | 42 | InitializeResult { 43 | server_info: None, 44 | offset_encoding: None, 45 | capabilities: ServerCapabilities { 46 | text_document_sync: Some(TextDocumentSyncCapability::Kind( 47 | TextDocumentSyncKind::INCREMENTAL, 48 | )), 49 | execute_command_provider: Some(ExecuteCommandOptions { 50 | commands: OrgwiseCommand::all(), 51 | work_done_progress_options: Default::default(), 52 | }), 53 | workspace: Some(WorkspaceServerCapabilities { 54 | workspace_folders: Some(WorkspaceFoldersServerCapabilities { 55 | supported: Some(true), 56 | change_notifications: Some(OneOf::Left(true)), 57 | }), 58 | file_operations: None, 59 | }), 60 | references_provider: Some(OneOf::Right(ReferencesOptions { 61 | work_done_progress_options: WorkDoneProgressOptions::default(), 62 | })), 63 | semantic_tokens_provider: Some( 64 | SemanticTokensServerCapabilities::SemanticTokensRegistrationOptions( 65 | SemanticTokensRegistrationOptions { 66 | text_document_registration_options: { 67 | TextDocumentRegistrationOptions { 68 | document_selector: Some(vec![DocumentFilter { 69 | language: Some("org".to_string()), 70 | scheme: Some("file".to_string()), 71 | pattern: None, 72 | }]), 73 | } 74 | }, 75 | semantic_tokens_options: SemanticTokensOptions { 76 | work_done_progress_options: WorkDoneProgressOptions::default(), 77 | legend: SemanticTokensLegend { 78 | token_types: semantic_token::TYPES.into(), 79 | token_modifiers: semantic_token::MODIFIERS.into(), 80 | }, 81 | range: Some(true), 82 | full: Some(SemanticTokensFullOptions::Bool(true)), 83 | }, 84 | static_registration_options: StaticRegistrationOptions::default(), 85 | }, 86 | ), 87 | ), 88 | code_lens_provider: Some(CodeLensOptions { 89 | resolve_provider: Some(true), 90 | }), 91 | folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)), 92 | document_link_provider: Some(DocumentLinkOptions { 93 | resolve_provider: Some(true), 94 | work_done_progress_options: WorkDoneProgressOptions::default(), 95 | }), 96 | document_formatting_provider: Some(OneOf::Left(true)), 97 | document_symbol_provider: Some(OneOf::Left(true)), 98 | completion_provider: Some(CompletionOptions { 99 | resolve_provider: Some(false), 100 | trigger_characters: Some(super::completion::trigger_characters()), 101 | ..Default::default() 102 | }), 103 | ..ServerCapabilities::default() 104 | }, 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/lsp/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod code_lens; 2 | pub mod completion; 3 | pub mod document_link; 4 | pub mod document_symbol; 5 | pub mod execute_command; 6 | pub mod folding_range; 7 | pub mod formatting; 8 | pub mod initialize; 9 | pub mod references; 10 | pub mod semantic_token; 11 | 12 | pub use code_lens::*; 13 | pub use completion::*; 14 | pub use document_link::*; 15 | pub use document_symbol::*; 16 | pub use execute_command::*; 17 | pub use folding_range::*; 18 | pub use formatting::*; 19 | pub use initialize::*; 20 | pub use references::*; 21 | pub use semantic_token::*; 22 | 23 | use crate::backend::Backend; 24 | use lsp_types::*; 25 | 26 | pub async fn initialized(backend: &B) { 27 | backend 28 | .log_message(MessageType::WARNING, "Initialized".into()) 29 | .await; 30 | } 31 | 32 | pub fn did_change_configuration(_: &B, _: DidChangeConfigurationParams) {} 33 | 34 | pub fn did_open(backend: &B, params: DidOpenTextDocumentParams) { 35 | backend 36 | .documents() 37 | .insert(params.text_document.uri, params.text_document.text); 38 | } 39 | 40 | pub fn did_change(backend: &B, params: DidChangeTextDocumentParams) { 41 | for change in params.content_changes { 42 | backend 43 | .documents() 44 | .update(params.text_document.uri.clone(), change.range, change.text); 45 | } 46 | } 47 | 48 | pub fn code_action(_: &B, _: CodeActionParams) -> Option { 49 | None 50 | } 51 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod backend; 2 | mod cli; 3 | mod command; 4 | mod lsp; 5 | mod utils; 6 | 7 | #[cfg(test)] 8 | mod test; 9 | 10 | use clap::{ 11 | builder::styling::{AnsiColor, Color, Style}, 12 | Parser, Subcommand, 13 | }; 14 | use clap_verbosity_flag::{InfoLevel, Verbosity}; 15 | use log::{Level, LevelFilter, Log, Metadata, Record}; 16 | 17 | /// Command line utility for org-mode files 18 | #[derive(Debug, Parser)] 19 | #[clap(name = "orgwise", version)] 20 | pub struct App { 21 | #[clap(subcommand)] 22 | command: Command, 23 | 24 | #[command(flatten)] 25 | verbose: Verbosity, 26 | } 27 | 28 | #[derive(Debug, Subcommand)] 29 | enum Command { 30 | /// Tangle source block contents to destination files 31 | #[clap(name = "tangle")] 32 | Tangle(cli::src_block::TangleCommand), 33 | 34 | /// Insert tangled file contents back to source files 35 | #[clap(name = "detangle")] 36 | Detangle(cli::src_block::DetangleCommand), 37 | 38 | /// Execute source block 39 | #[clap(name = "execute-src-block")] 40 | ExecuteSrcBlock(cli::src_block::ExecuteCommand), 41 | 42 | /// Format org-mode files 43 | #[clap(name = "fmt")] 44 | Format(cli::fmt::Command), 45 | 46 | /// Start api server 47 | #[clap(name = "api")] 48 | ApiServer(cli::api_server::Command), 49 | 50 | /// Start language server 51 | #[clap(name = "lsp")] 52 | LanguageServer, 53 | } 54 | 55 | #[tokio::main] 56 | async fn main() -> anyhow::Result<()> { 57 | let parsed = App::parse(); 58 | 59 | let level = parsed.verbose.log_level_filter(); 60 | log::set_boxed_logger(Box::new(Logger { level })).unwrap(); 61 | log::set_max_level(level); 62 | 63 | match parsed.command { 64 | Command::Tangle(cmd) => cmd.run().await, 65 | Command::Detangle(cmd) => cmd.run().await, 66 | Command::ExecuteSrcBlock(cmd) => cmd.run().await, 67 | Command::Format(cmd) => cmd.run().await, 68 | Command::ApiServer(cmd) => cmd.run().await, 69 | Command::LanguageServer => cli::lsp_server::start().await, 70 | } 71 | } 72 | 73 | struct Logger { 74 | level: LevelFilter, 75 | } 76 | 77 | impl Log for Logger { 78 | fn enabled(&self, metadata: &Metadata) -> bool { 79 | metadata.level().to_level_filter() <= self.level 80 | } 81 | 82 | fn log(&self, record: &Record) { 83 | if self.enabled(record.metadata()) { 84 | let color = match record.level() { 85 | Level::Error => AnsiColor::Red, 86 | Level::Warn => AnsiColor::Yellow, 87 | Level::Info => AnsiColor::Cyan, 88 | Level::Debug => AnsiColor::Green, 89 | Level::Trace => AnsiColor::White, 90 | }; 91 | 92 | let style = Style::new().fg_color(Color::Ansi(color).into()); 93 | 94 | println!( 95 | "{}{:<5}{} {}", 96 | style.render(), 97 | &record.level(), 98 | style.render_reset(), 99 | record.args() 100 | ) 101 | } 102 | } 103 | 104 | fn flush(&self) {} 105 | } 106 | -------------------------------------------------------------------------------- /src/test.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use lsp_types::Url; 4 | use orgize::rowan::TextRange; 5 | 6 | use crate::backend::{Backend, Documents}; 7 | 8 | #[derive(Default)] 9 | pub struct TestBackend { 10 | documents: Documents, 11 | } 12 | 13 | impl TestBackend { 14 | pub fn get(&self, url: &Url) -> String { 15 | self.documents.get_map(url, |d| d.org.to_org()).unwrap() 16 | } 17 | } 18 | 19 | impl Backend for TestBackend { 20 | fn documents(&self) -> &Documents { 21 | &self.documents 22 | } 23 | 24 | async fn apply_edits( 25 | &self, 26 | items: impl Iterator, 27 | ) -> anyhow::Result<()> { 28 | let mut changes: HashMap> = HashMap::new(); 29 | 30 | for (url, new_text, text_range) in items { 31 | if let Some(edits) = changes.get_mut(&url) { 32 | edits.push((text_range, new_text)) 33 | } else { 34 | changes.insert(url.clone(), vec![(text_range, new_text)]); 35 | } 36 | } 37 | 38 | for (url, edits) in changes.iter_mut() { 39 | edits.sort_by_key(|edit| (edit.0.start(), edit.0.end())); 40 | 41 | let input = self 42 | .documents() 43 | .get_map(url, |d| d.org.to_org()) 44 | .unwrap_or_default(); 45 | let mut output = String::with_capacity(input.len()); 46 | let mut off = 0; 47 | 48 | for (range, content) in edits { 49 | let start = range.start().into(); 50 | let end = range.end().into(); 51 | output += &input[off..start]; 52 | output += &content; 53 | off = end; 54 | } 55 | 56 | output += &input[off..]; 57 | 58 | self.documents.insert(url.clone(), &output) 59 | } 60 | 61 | Ok(()) 62 | } 63 | 64 | async fn read_to_string(&self, url: &Url) -> anyhow::Result { 65 | Ok(self 66 | .documents() 67 | .get_map(url, |d| d.org.to_org()) 68 | .unwrap_or_default()) 69 | } 70 | 71 | async fn write(&self, url: &Url, content: &str) -> anyhow::Result<()> { 72 | self.documents.insert(url.clone(), content); 73 | Ok(()) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/utils/clocking.rs: -------------------------------------------------------------------------------- 1 | use orgize::{ 2 | ast::{Drawer, Headline, Section}, 3 | rowan::ast::AstNode, 4 | }; 5 | 6 | pub fn find_logbook(headline: &Headline) -> Option { 7 | headline 8 | .syntax() 9 | .children() 10 | .flat_map(Section::cast) 11 | .flat_map(|x| x.syntax().children().filter_map(Drawer::cast)) 12 | .find(|d| d.name().eq_ignore_ascii_case("LOGBOOK")) 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/headline.rs: -------------------------------------------------------------------------------- 1 | use lsp_types::Position; 2 | use orgize::ast::Headline; 3 | use orgize::rowan::ast::AstNode; 4 | 5 | use crate::backend::OrgDocument; 6 | 7 | pub fn find_headline(doc: &OrgDocument, line: u32) -> Option { 8 | let offset = doc.offset_of(Position { 9 | line: line - 1, 10 | character: 0, 11 | }); 12 | 13 | let mut node = doc.org.document().syntax().clone(); 14 | 15 | 'l: loop { 16 | for hdl in node.children().filter_map(Headline::cast) { 17 | if hdl.start() == offset.into() { 18 | return Some(hdl); 19 | } else if hdl.start() < offset.into() && hdl.end() > offset.into() { 20 | node = hdl.syntax().clone(); 21 | continue 'l; 22 | } 23 | } 24 | return None; 25 | } 26 | } 27 | 28 | pub fn headline_slug(headline: &Headline) -> String { 29 | headline.title().fold(String::new(), |mut acc, elem| { 30 | for ch in elem.to_string().chars().filter(|c| c.is_ascii_graphic()) { 31 | acc.push(ch); 32 | } 33 | acc 34 | }) 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod clocking; 2 | pub mod headline; 3 | pub mod src_block; 4 | pub mod text_size; 5 | pub mod timestamp; 6 | -------------------------------------------------------------------------------- /src/utils/src_block.rs: -------------------------------------------------------------------------------- 1 | use jetscii::Substring; 2 | use nom::{ 3 | bytes::complete::take_while1, 4 | character::complete::{space0, space1}, 5 | InputTake, 6 | }; 7 | use orgize::{ 8 | ast::{Headline, Keyword, SourceBlock, Token}, 9 | export::{from_fn_with_ctx, Container, Event}, 10 | rowan::ast::AstNode, 11 | Org, SyntaxKind, SyntaxNode, 12 | }; 13 | 14 | pub fn collect_src_blocks(org: &Org) -> Vec { 15 | let mut blocks = Vec::::new(); 16 | 17 | org.traverse(&mut from_fn_with_ctx(|event, ctx| { 18 | match event { 19 | Event::Enter(Container::SourceBlock(block)) => { 20 | blocks.push(block); 21 | ctx.skip(); 22 | } 23 | 24 | // skip some containers for performance 25 | Event::Enter(Container::List(_)) 26 | | Event::Enter(Container::OrgTable(_)) 27 | | Event::Enter(Container::SpecialBlock(_)) 28 | | Event::Enter(Container::QuoteBlock(_)) 29 | | Event::Enter(Container::CenterBlock(_)) 30 | | Event::Enter(Container::VerseBlock(_)) 31 | | Event::Enter(Container::CommentBlock(_)) 32 | | Event::Enter(Container::ExampleBlock(_)) 33 | | Event::Enter(Container::ExportBlock(_)) => ctx.skip(), 34 | _ => {} 35 | } 36 | })); 37 | 38 | blocks 39 | } 40 | 41 | pub fn language_comments(language: &str) -> Option<(&str, &str)> { 42 | match language { 43 | "c" | "cpp" | "c++" | "go" | "js" | "javascript" | "ts" | "typescript" | "rust" 44 | | "vera" | "jsonc" => Some(("//", "")), 45 | "toml" | "tml" | "yaml" | "yml" | "conf" | "gitconfig" | "conf-toml" | "sh" | "shell" 46 | | "bash" | "zsh" | "fish" => Some(("#", "")), 47 | "lua" | "sql" => Some(("--", "")), 48 | "lisp" | "emacs-lisp" | "elisp" => Some((";;", "")), 49 | "xml" | "html" | "svg" => Some(("")), 50 | _ => None, 51 | } 52 | } 53 | 54 | pub fn language_execute_command(language: &str) -> Option<&str> { 55 | match language { 56 | "js" | "javascript" => Some("node"), 57 | "sh" | "bash" => Some("bash"), 58 | "py" | "python" => Some("python"), 59 | "fish" => Some("fish"), 60 | _ => None, 61 | } 62 | } 63 | 64 | pub fn header_argument<'a>( 65 | arg1: &'a str, 66 | arg2: &'a str, 67 | arg3: &'a str, 68 | key: &str, 69 | default: &'static str, 70 | ) -> &'a str { 71 | extract_header_args(arg1, key) 72 | .or_else(|_| extract_header_args(arg2, key)) 73 | .or_else(|_| extract_header_args(arg3, key)) 74 | .unwrap_or(default) 75 | } 76 | 77 | pub fn property_keyword(node: &SyntaxNode) -> Option { 78 | node.ancestors() 79 | .find(|n| n.kind() == SyntaxKind::DOCUMENT) 80 | .and_then(|n| n.first_child()) 81 | .filter(|n| n.kind() == SyntaxKind::SECTION) 82 | .and_then(|n| { 83 | n.children() 84 | .filter_map(Keyword::cast) 85 | .filter(|kw| kw.key().eq_ignore_ascii_case("PROPERTY")) 86 | .map(|kw| kw.value()) 87 | .find(|value| value.trim_start().starts_with("header-args ")) 88 | }) 89 | } 90 | 91 | pub fn property_drawer(node: &SyntaxNode) -> Option { 92 | node.ancestors() 93 | .find_map(Headline::cast) 94 | .and_then(|hdl| hdl.properties()) 95 | .and_then(|drawer| drawer.get("header-args")) 96 | } 97 | 98 | pub fn extract_header_args<'a>(input: &'a str, key: &str) -> Result<&'a str, nom::Err<()>> { 99 | let mut i = input; 100 | 101 | while !i.is_empty() { 102 | let (input, _) = space0(i)?; 103 | let (input, name) = take_while1(|c| c != ' ' && c != '\t')(input)?; 104 | 105 | if !name.eq_ignore_ascii_case(key) { 106 | debug_assert!(input.len() < i.len(), "{} < {}", input.len(), i.len()); 107 | i = input; 108 | continue; 109 | } 110 | 111 | let (input, _) = space1(input)?; 112 | 113 | if let Some(idx) = Substring::new(" :") 114 | .find(input) 115 | .or_else(|| Substring::new("\t:").find(input)) 116 | { 117 | let idx = input[0..idx] 118 | .rfind(|c| c != ' ' && c != '\t') 119 | .map(|i| i + 1) 120 | .unwrap_or(idx); 121 | 122 | let (_, value) = input.take_split(idx); 123 | 124 | return Ok(value.trim()); 125 | } else { 126 | return Ok(input.trim()); 127 | } 128 | } 129 | 130 | Err(nom::Err::Error(())) 131 | } 132 | 133 | #[test] 134 | fn parse_header_args() { 135 | assert!(extract_header_args("", ":tangle").is_err()); 136 | assert!(extract_header_args(" :noweb yes", ":tangle1").is_err()); 137 | assert!(extract_header_args(":tangle", ":tangle").is_err()); 138 | 139 | assert_eq!(extract_header_args(":tangle ", ":tangle").unwrap(), ""); 140 | 141 | assert_eq!( 142 | extract_header_args(":tangle emacs.d/init.el", ":tangle").unwrap(), 143 | "emacs.d/init.el" 144 | ); 145 | assert_eq!( 146 | extract_header_args(" :tangle emacs.d/init.el", ":tangle").unwrap(), 147 | "emacs.d/init.el" 148 | ); 149 | assert_eq!( 150 | extract_header_args(" :tangle emacs.d/init.el :noweb yes", ":tangle").unwrap(), 151 | "emacs.d/init.el" 152 | ); 153 | assert_eq!( 154 | extract_header_args(" :noweb yes :tangle emacs.d/init.el", ":tangle").unwrap(), 155 | "emacs.d/init.el" 156 | ); 157 | 158 | assert_eq!( 159 | extract_header_args(":results output code", ":results").unwrap(), 160 | "output code" 161 | ); 162 | } 163 | -------------------------------------------------------------------------------- /src/utils/text_size.rs: -------------------------------------------------------------------------------- 1 | use orgize::rowan::TextSize; 2 | use serde::de::Deserializer; 3 | use serde::ser::Serializer; 4 | use serde::Deserialize; 5 | 6 | pub fn deserialize<'de, D>(d: D) -> Result 7 | where 8 | D: Deserializer<'de>, 9 | { 10 | ::deserialize(d).map(TextSize::new) 11 | } 12 | 13 | pub fn serialize(t: &TextSize, s: S) -> Result 14 | where 15 | S: Serializer, 16 | { 17 | s.serialize_u32((*t).into()) 18 | } 19 | -------------------------------------------------------------------------------- /src/utils/timestamp.rs: -------------------------------------------------------------------------------- 1 | use chrono::{Datelike, NaiveDateTime, Timelike}; 2 | use std::fmt; 3 | 4 | pub struct FormatActiveTimestamp(pub NaiveDateTime); 5 | 6 | impl fmt::Display for FormatActiveTimestamp { 7 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 8 | write!( 9 | f, 10 | "<{:0>4}-{:0>2}-{:0>2} {} {:0>2}:{:0>2}>", 11 | self.0.year(), 12 | self.0.month(), 13 | self.0.day(), 14 | self.0.weekday(), 15 | self.0.hour(), 16 | self.0.minute() 17 | ) 18 | } 19 | } 20 | 21 | pub struct FormatInactiveTimestamp(pub NaiveDateTime); 22 | 23 | impl fmt::Display for FormatInactiveTimestamp { 24 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 25 | write!( 26 | f, 27 | "[{:0>4}-{:0>2}-{:0>2} {} {:0>2}:{:0>2}]", 28 | self.0.year(), 29 | self.0.month(), 30 | self.0.day(), 31 | self.0.weekday(), 32 | self.0.hour(), 33 | self.0.minute() 34 | ) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/wasm/backend.rs: -------------------------------------------------------------------------------- 1 | use lsp_types::{MessageType, Url}; 2 | use orgize::rowan::TextRange; 3 | use orgize::ParseConfig; 4 | use serde::Serialize; 5 | use serde_json::Value; 6 | use std::collections::HashMap; 7 | use wasm_bindgen::prelude::*; 8 | 9 | use super::SERIALIZER; 10 | use crate::backend::{Backend, Documents}; 11 | use crate::command::OrgwiseCommand; 12 | use crate::lsp; 13 | 14 | #[wasm_bindgen] 15 | extern "C" { 16 | pub type WasmMethods; 17 | 18 | #[wasm_bindgen(method, js_name = "homeDir")] 19 | pub fn home_dir(this: &WasmMethods) -> JsValue; 20 | 21 | #[wasm_bindgen(method, js_name = "readToString", catch)] 22 | pub async fn read_to_string(this: &WasmMethods, path: &str) -> Result; 23 | 24 | #[wasm_bindgen(method, js_name = "write", catch)] 25 | pub async fn write(this: &WasmMethods, path: &str, content: &str) -> Result; 26 | } 27 | 28 | #[wasm_bindgen(js_name = "Backend")] 29 | pub struct WasmBackend { 30 | methods: WasmMethods, 31 | documents: Documents, 32 | } 33 | 34 | impl Backend for WasmBackend { 35 | fn home_dir(&self) -> Option { 36 | self.methods 37 | .home_dir() 38 | .as_string() 39 | .and_then(|s| Url::parse(&s).ok()) 40 | } 41 | 42 | async fn write(&self, path: &Url, content: &str) -> anyhow::Result<()> { 43 | self.methods 44 | .write(path.as_ref(), content) 45 | .await 46 | .map_err(|err| anyhow::anyhow!("JS Error: {err:?}"))?; 47 | Ok(()) 48 | } 49 | 50 | async fn read_to_string(&self, path: &Url) -> anyhow::Result { 51 | let value = self 52 | .methods 53 | .read_to_string(path.as_ref()) 54 | .await 55 | .map_err(|err| anyhow::anyhow!("JS Error: {err:?}"))?; 56 | 57 | Ok(value.as_string().unwrap_or_default()) 58 | } 59 | 60 | async fn log_message(&self, typ: MessageType, message: String) { 61 | match typ { 62 | MessageType::ERROR => web_sys::console::error_1(&JsValue::from_str(&message)), 63 | MessageType::WARNING => web_sys::console::warn_1(&JsValue::from_str(&message)), 64 | MessageType::INFO => web_sys::console::info_1(&JsValue::from_str(&message)), 65 | MessageType::LOG | _ => web_sys::console::log_1(&JsValue::from_str(&message)), 66 | }; 67 | } 68 | 69 | async fn show_message(&self, typ: MessageType, message: String) { 70 | self.log_message(typ, message).await 71 | } 72 | 73 | async fn apply_edits( 74 | &self, 75 | items: impl Iterator, 76 | ) -> anyhow::Result<()> { 77 | let mut changes: HashMap> = HashMap::new(); 78 | 79 | for (url, new_text, text_range) in items { 80 | if let Some(edits) = changes.get_mut(&url) { 81 | edits.push((text_range, new_text)) 82 | } else { 83 | changes.insert(url.clone(), vec![(text_range, new_text)]); 84 | } 85 | } 86 | 87 | for (url, edits) in changes.iter_mut() { 88 | edits.sort_by_key(|edit| (edit.0.start(), edit.0.end())); 89 | 90 | let input = self 91 | .methods 92 | .read_to_string(&url.to_string()) 93 | .await 94 | .unwrap() 95 | .as_string() 96 | .unwrap(); 97 | 98 | let mut output = String::with_capacity(input.len()); 99 | let mut off = 0; 100 | 101 | for (range, content) in edits { 102 | let start = range.start().into(); 103 | let end = range.end().into(); 104 | 105 | output += &input[off..start]; 106 | output += &content; 107 | 108 | off = end; 109 | } 110 | 111 | output += &input[off..]; 112 | 113 | self.write(&url, &output).await?; 114 | self.documents.update(url.clone(), None, &output); 115 | } 116 | 117 | Ok(()) 118 | } 119 | 120 | fn documents(&self) -> &Documents { 121 | &self.documents 122 | } 123 | } 124 | 125 | #[wasm_bindgen(js_class = "Backend")] 126 | impl WasmBackend { 127 | #[wasm_bindgen(constructor)] 128 | pub fn new(methods: WasmMethods) -> WasmBackend { 129 | console_error_panic_hook::set_once(); 130 | 131 | WasmBackend { 132 | methods, 133 | documents: Documents::default(), 134 | } 135 | } 136 | 137 | #[wasm_bindgen(js_name = "setOptions")] 138 | pub fn set_options(&mut self, options: JsValue) { 139 | let options: lsp::InitializationOptions = serde_wasm_bindgen::from_value(options).unwrap(); 140 | self.documents().set_default_parse_config(ParseConfig { 141 | todo_keywords: (options.todo_keywords, options.done_keywords), 142 | ..Default::default() 143 | }); 144 | } 145 | 146 | #[wasm_bindgen(js_name = "addOrgFile")] 147 | pub fn add_org_file(&mut self, url: String, text: &str) { 148 | self.documents.insert(Url::parse(&url).unwrap(), text); 149 | } 150 | 151 | #[wasm_bindgen(js_name = "updateOrgFile")] 152 | pub fn update_org_file(&mut self, url: String, text: &str) { 153 | self.documents.update(Url::parse(&url).unwrap(), None, text); 154 | } 155 | 156 | #[wasm_bindgen(js_name = "executeCommand")] 157 | pub async fn execute_command(&mut self, name: &str, argument: JsValue) -> JsValue { 158 | let argument: Value = serde_wasm_bindgen::from_value(argument).unwrap(); 159 | 160 | let Some(cmd) = OrgwiseCommand::from_value(name, argument) else { 161 | self.log_message(MessageType::WARNING, format!("Unknown command {name:?}")) 162 | .await; 163 | 164 | return JsValue::NULL; 165 | }; 166 | 167 | match cmd.execute(self).await { 168 | Ok(value) => value.serialize(&SERIALIZER).unwrap(), 169 | Err(err) => { 170 | self.log_message( 171 | MessageType::ERROR, 172 | format!("Failed to execute {name:?}: {err}"), 173 | ) 174 | .await; 175 | 176 | JsValue::NULL 177 | } 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/wasm/mod.rs: -------------------------------------------------------------------------------- 1 | mod backend; 2 | mod lsp_backend; 3 | 4 | use serde_wasm_bindgen::Serializer; 5 | 6 | pub const SERIALIZER: Serializer = Serializer::new().serialize_maps_as_objects(true); 7 | -------------------------------------------------------------------------------- /vscode/.vscodeignore: -------------------------------------------------------------------------------- 1 | ** 2 | !dist/ 3 | !syntaxes/ 4 | !media/ 5 | !images/ 6 | !package.json 7 | !org.configuration.json 8 | !README.md -------------------------------------------------------------------------------- /vscode/README.md: -------------------------------------------------------------------------------- 1 | # Orgwise 2 | 3 | This extension provides language support for [Org-mode](https://orgmode.org/) files. 4 | -------------------------------------------------------------------------------- /vscode/build.mjs: -------------------------------------------------------------------------------- 1 | import * as esbuild from "esbuild"; 2 | import { execSync } from "node:child_process"; 3 | 4 | const GIT_COMMIT = execSync("git rev-parse --short HEAD", { 5 | encoding: "utf8", 6 | }).trim(); 7 | const BUILD_TIME = new Date().toISOString(); 8 | 9 | await esbuild.build({ 10 | bundle: true, 11 | entryPoints: ["./src/extension.ts"], 12 | external: ["vscode"], 13 | outfile: "./dist/node.js", 14 | format: "cjs", 15 | platform: "node", 16 | treeShaking: true, 17 | minify: true, 18 | define: { 19 | WEB_EXTENSION: "false", 20 | GIT_COMMIT: `"${GIT_COMMIT}"`, 21 | BUILD_TIME: `"${BUILD_TIME}"`, 22 | }, 23 | }); 24 | 25 | await esbuild.build({ 26 | bundle: true, 27 | entryPoints: ["./src/extension.ts"], 28 | external: ["vscode"], 29 | outfile: "./dist/browser.js", 30 | format: "cjs", 31 | platform: "browser", 32 | treeShaking: true, 33 | minify: true, 34 | define: { 35 | WEB_EXTENSION: "true", 36 | GIT_COMMIT: `"${GIT_COMMIT}"`, 37 | BUILD_TIME: `"${BUILD_TIME}"`, 38 | }, 39 | }); 40 | 41 | await esbuild.build({ 42 | bundle: true, 43 | entryPoints: ["./src/lsp-server.ts"], 44 | external: ["node:*"], 45 | outfile: "./dist/lsp-server.js", 46 | format: "cjs", 47 | platform: "node", 48 | treeShaking: true, 49 | minify: true, 50 | }); 51 | 52 | await esbuild.build({ 53 | bundle: true, 54 | entryPoints: ["./src/lsp-worker.ts"], 55 | outfile: "./dist/lsp-worker.js", 56 | format: "cjs", 57 | platform: "browser", 58 | treeShaking: true, 59 | minify: true, 60 | }); 61 | -------------------------------------------------------------------------------- /vscode/images/extension-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/vscode/images/extension-icon.png -------------------------------------------------------------------------------- /vscode/images/language-dark-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/vscode/images/language-dark-icon.png -------------------------------------------------------------------------------- /vscode/images/language-light-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/vscode/images/language-light-icon.png -------------------------------------------------------------------------------- /vscode/images/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/vscode/images/overview.png -------------------------------------------------------------------------------- /vscode/media/org-mode.css: -------------------------------------------------------------------------------- 1 | /* https://github.com/microsoft/vscode/blob/01fc3110beb3f6be198f641b19e3c2e83125d2e3/extensions/markdown-language-features/media/markdown.css */ 2 | 3 | html, 4 | body { 5 | font-family: var( 6 | --markdown-font-family, 7 | -apple-system, 8 | BlinkMacSystemFont, 9 | "Segoe WPC", 10 | "Segoe UI", 11 | system-ui, 12 | "Ubuntu", 13 | "Droid Sans", 14 | sans-serif 15 | ); 16 | font-size: var(--markdown-font-size, 14px); 17 | padding: 0 26px; 18 | line-height: var(--markdown-line-height, 22px); 19 | word-wrap: break-word; 20 | } 21 | 22 | body { 23 | padding-top: 1em; 24 | } 25 | 26 | /* Reset margin top for elements */ 27 | h1, 28 | h2, 29 | h3, 30 | h4, 31 | h5, 32 | h6, 33 | p, 34 | ol, 35 | ul, 36 | pre { 37 | margin-top: 0; 38 | } 39 | 40 | h1, 41 | h2, 42 | h3, 43 | h4, 44 | h5, 45 | h6 { 46 | font-weight: 600; 47 | margin-top: 24px; 48 | margin-bottom: 16px; 49 | line-height: 1.25; 50 | } 51 | 52 | #code-csp-warning { 53 | position: fixed; 54 | top: 0; 55 | right: 0; 56 | color: white; 57 | margin: 16px; 58 | text-align: center; 59 | font-size: 12px; 60 | font-family: sans-serif; 61 | background-color: #444444; 62 | cursor: pointer; 63 | padding: 6px; 64 | box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.25); 65 | } 66 | 67 | #code-csp-warning:hover { 68 | text-decoration: none; 69 | background-color: #007acc; 70 | box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.25); 71 | } 72 | 73 | body.scrollBeyondLastLine { 74 | margin-bottom: calc(100vh - 22px); 75 | } 76 | 77 | body.showEditorSelection .code-line { 78 | position: relative; 79 | } 80 | 81 | body.showEditorSelection :not(tr, ul, ol).code-active-line:before, 82 | body.showEditorSelection :not(tr, ul, ol).code-line:hover:before { 83 | content: ""; 84 | display: block; 85 | position: absolute; 86 | top: 0; 87 | left: -12px; 88 | height: 100%; 89 | } 90 | 91 | .vscode-high-contrast.showEditorSelection 92 | :not(tr, ul, ol).code-line 93 | .code-line:hover:before { 94 | border-left: none; 95 | } 96 | 97 | body.showEditorSelection li.code-active-line:before, 98 | body.showEditorSelection li.code-line:hover:before { 99 | left: -30px; 100 | } 101 | 102 | .vscode-light.showEditorSelection .code-active-line:before { 103 | border-left: 3px solid rgba(0, 0, 0, 0.15); 104 | } 105 | 106 | .vscode-light.showEditorSelection .code-line:hover:before { 107 | border-left: 3px solid rgba(0, 0, 0, 0.4); 108 | } 109 | 110 | .vscode-dark.showEditorSelection .code-active-line:before { 111 | border-left: 3px solid rgba(255, 255, 255, 0.4); 112 | } 113 | 114 | .vscode-dark.showEditorSelection .code-line:hover:before { 115 | border-left: 3px solid rgba(255, 255, 255, 0.6); 116 | } 117 | 118 | .vscode-high-contrast.showEditorSelection .code-active-line:before { 119 | border-left: 3px solid rgba(255, 160, 0, 0.7); 120 | } 121 | 122 | .vscode-high-contrast.showEditorSelection .code-line:hover:before { 123 | border-left: 3px solid rgba(255, 160, 0, 1); 124 | } 125 | 126 | /* Prevent `sub` and `sup` elements from affecting line height */ 127 | sub, 128 | sup { 129 | line-height: 0; 130 | } 131 | 132 | ul ul:first-child, 133 | ul ol:first-child, 134 | ol ul:first-child, 135 | ol ol:first-child { 136 | margin-bottom: 0; 137 | } 138 | 139 | img, 140 | video { 141 | max-width: 100%; 142 | max-height: 100%; 143 | } 144 | 145 | a { 146 | text-decoration: none; 147 | } 148 | 149 | a:hover { 150 | text-decoration: underline; 151 | } 152 | 153 | a:focus, 154 | input:focus, 155 | select:focus, 156 | textarea:focus { 157 | outline: 1px solid -webkit-focus-ring-color; 158 | outline-offset: -1px; 159 | } 160 | 161 | p { 162 | margin-bottom: 16px; 163 | } 164 | 165 | li p { 166 | margin-bottom: 0.7em; 167 | } 168 | 169 | ul, 170 | ol { 171 | margin-bottom: 0.7em; 172 | } 173 | 174 | hr { 175 | border: 0; 176 | height: 1px; 177 | border-bottom: 1px solid; 178 | } 179 | 180 | h1 { 181 | font-size: 2em; 182 | margin-top: 0; 183 | padding-bottom: 0.3em; 184 | border-bottom-width: 1px; 185 | border-bottom-style: solid; 186 | } 187 | 188 | h2 { 189 | font-size: 1.5em; 190 | padding-bottom: 0.3em; 191 | border-bottom-width: 1px; 192 | border-bottom-style: solid; 193 | } 194 | 195 | h3 { 196 | font-size: 1.25em; 197 | } 198 | 199 | h4 { 200 | font-size: 1em; 201 | } 202 | 203 | h5 { 204 | font-size: 0.875em; 205 | } 206 | 207 | h6 { 208 | font-size: 0.85em; 209 | } 210 | 211 | table { 212 | border-collapse: collapse; 213 | margin-bottom: 0.7em; 214 | } 215 | 216 | th { 217 | text-align: left; 218 | border-bottom: 1px solid; 219 | } 220 | 221 | th, 222 | td { 223 | padding: 5px 10px; 224 | } 225 | 226 | table > tbody > tr + tr > td { 227 | border-top: 1px solid; 228 | } 229 | 230 | blockquote { 231 | margin: 0; 232 | padding: 2px 16px 0 10px; 233 | border-left-width: 5px; 234 | border-left-style: solid; 235 | border-radius: 2px; 236 | } 237 | 238 | code { 239 | font-family: var( 240 | --vscode-editor-font-family, 241 | "SF Mono", 242 | Monaco, 243 | Menlo, 244 | Consolas, 245 | "Ubuntu Mono", 246 | "Liberation Mono", 247 | "DejaVu Sans Mono", 248 | "Courier New", 249 | monospace 250 | ); 251 | font-size: 1em; 252 | line-height: 1.357em; 253 | } 254 | 255 | body.wordWrap pre { 256 | white-space: pre-wrap; 257 | } 258 | 259 | pre:not(.hljs), 260 | pre.hljs code > div { 261 | padding: 16px; 262 | border-radius: 3px; 263 | overflow: auto; 264 | } 265 | 266 | pre code { 267 | display: inline-block; 268 | color: var(--vscode-editor-foreground); 269 | tab-size: 4; 270 | background: none; 271 | } 272 | 273 | /** Theming */ 274 | 275 | pre { 276 | background-color: var(--vscode-textCodeBlock-background); 277 | border: 1px solid var(--vscode-widget-border); 278 | } 279 | 280 | .vscode-high-contrast h1 { 281 | border-color: rgb(0, 0, 0); 282 | } 283 | 284 | .vscode-light th { 285 | border-color: rgba(0, 0, 0, 0.69); 286 | } 287 | 288 | .vscode-dark th { 289 | border-color: rgba(255, 255, 255, 0.69); 290 | } 291 | 292 | .vscode-light h1, 293 | .vscode-light h2, 294 | .vscode-light hr, 295 | .vscode-light td { 296 | border-color: rgba(0, 0, 0, 0.18); 297 | } 298 | 299 | .vscode-dark h1, 300 | .vscode-dark h2, 301 | .vscode-dark hr, 302 | .vscode-dark td { 303 | border-color: rgba(255, 255, 255, 0.18); 304 | } 305 | -------------------------------------------------------------------------------- /vscode/org.configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "comments": { 3 | "lineComment": "#", 4 | "blockComment": ["#+BEGIN_COMMENT\n", "\n#+END_COMMENT"] 5 | }, 6 | "brackets": [ 7 | ["{", "}"], 8 | ["[", "]"], 9 | ["(", ")"] 10 | ], 11 | "autoClosingPairs": [ 12 | ["{", "}"], 13 | ["[", "]"], 14 | ["(", ")"], 15 | ["\"", "\""] 16 | ], 17 | "surroundingPairs": [ 18 | ["{", "}"], 19 | ["[", "]"], 20 | ["(", ")"], 21 | ["\"", "\""], 22 | ["'", "'"], 23 | ["*", "*"], 24 | ["/", "/"], 25 | ["_", "_"], 26 | ["=", "="], 27 | ["~", "~"] 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /vscode/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "orgwise", 3 | "private": true, 4 | "version": "0.0.3", 5 | "engines": { 6 | "vscode": "^1.75.0" 7 | }, 8 | "displayName": "Orgwise", 9 | "description": "Language server for org-mode files, builtin with orgize.", 10 | "icon": "./images/extension-icon.png", 11 | "publisher": "poiscript", 12 | "preview": true, 13 | "categories": [ 14 | "Programming Languages", 15 | "Formatters" 16 | ], 17 | "keywords": [ 18 | "org", 19 | "org-mode" 20 | ], 21 | "license": "MIT", 22 | "repository": { 23 | "type": "git", 24 | "url": "https://github.com/PoiScript/orgwise" 25 | }, 26 | "scripts": { 27 | "build": "node build.mjs", 28 | "package": "vsce package -o orgwise.vsix --skip-license --no-dependencies" 29 | }, 30 | "dependencies": { 31 | "@types/vscode": "~1.75.1", 32 | "@vscode/vsce": "^2.23.0", 33 | "esbuild": "^0.19.12", 34 | "vscode-languageclient": "^9.0.1", 35 | "vscode-languageserver-protocol": "^3.17.5", 36 | "vscode-uri": "^3.0.8" 37 | }, 38 | "main": "./dist/node.js", 39 | "browser": "./dist/browser.js", 40 | "contributes": { 41 | "commands": [ 42 | { 43 | "command": "orgwise.syntax-tree-ui", 44 | "title": "Orgwise (debug): Show Org Syntax Tree" 45 | }, 46 | { 47 | "command": "orgwise.preview-html-ui", 48 | "title": "Orgwise: Preview In HTML" 49 | }, 50 | { 51 | "command": "orgwise.web-panel-ui", 52 | "title": "Orgwise: Show Web Panel" 53 | }, 54 | { 55 | "command": "orgwise.show-info-ui", 56 | "title": "Orgwise: Show Info" 57 | } 58 | ], 59 | "languages": [ 60 | { 61 | "id": "org", 62 | "aliases": [ 63 | "Org", 64 | "Org Markup", 65 | "Org Mode" 66 | ], 67 | "extensions": [ 68 | ".org" 69 | ], 70 | "configuration": "./org.configuration.json", 71 | "icon": { 72 | "light": "./images/language-light-icon.png", 73 | "dark": "./images/language-dark-icon.png" 74 | } 75 | } 76 | ], 77 | "semanticTokenScopes": [ 78 | { 79 | "language": "org", 80 | "scopes": { 81 | "headlineTodoKeyword": [ 82 | "invalid.illegal.org" 83 | ], 84 | "headlineDoneKeyword": [ 85 | "string.org" 86 | ], 87 | "headlineTags": [ 88 | "variable.other.org" 89 | ], 90 | "headlinePriority": [ 91 | "keyword.control.org" 92 | ], 93 | "timestamp": [ 94 | "variable.org" 95 | ] 96 | } 97 | } 98 | ], 99 | "grammars": [ 100 | { 101 | "language": "org", 102 | "scopeName": "source.org", 103 | "path": "./syntaxes/org.tmLanguage.json", 104 | "embeddedLanguages": { 105 | "meta.embedded.block.html": "html", 106 | "source.js": "javascript", 107 | "source.css": "css", 108 | "meta.embedded.block.frontmatter": "yaml", 109 | "meta.embedded.block.css": "css", 110 | "meta.embedded.block.ini": "ini", 111 | "meta.embedded.block.java": "java", 112 | "meta.embedded.block.lua": "lua", 113 | "meta.embedded.block.makefile": "makefile", 114 | "meta.embedded.block.perl": "perl", 115 | "meta.embedded.block.r": "r", 116 | "meta.embedded.block.ruby": "ruby", 117 | "meta.embedded.block.php": "php", 118 | "meta.embedded.block.sql": "sql", 119 | "meta.embedded.block.vs_net": "vs_net", 120 | "meta.embedded.block.xml": "xml", 121 | "meta.embedded.block.xsl": "xsl", 122 | "meta.embedded.block.yaml": "yaml", 123 | "meta.embedded.block.dosbatch": "dosbatch", 124 | "meta.embedded.block.clojure": "clojure", 125 | "meta.embedded.block.coffee": "coffee", 126 | "meta.embedded.block.c": "c", 127 | "meta.embedded.block.cpp": "cpp", 128 | "meta.embedded.block.diff": "diff", 129 | "meta.embedded.block.dockerfile": "dockerfile", 130 | "meta.embedded.block.go": "go", 131 | "meta.embedded.block.groovy": "groovy", 132 | "meta.embedded.block.pug": "jade", 133 | "meta.embedded.block.javascript": "javascript", 134 | "meta.embedded.block.json": "json", 135 | "meta.embedded.block.jsonc": "jsonc", 136 | "meta.embedded.block.latex": "latex", 137 | "meta.embedded.block.less": "less", 138 | "meta.embedded.block.objc": "objc", 139 | "meta.embedded.block.scss": "scss", 140 | "meta.embedded.block.perl6": "perl6", 141 | "meta.embedded.block.powershell": "powershell", 142 | "meta.embedded.block.python": "python", 143 | "meta.embedded.block.rust": "rust", 144 | "meta.embedded.block.scala": "scala", 145 | "meta.embedded.block.shellscript": "shellscript", 146 | "meta.embedded.block.typescript": "typescript", 147 | "meta.embedded.block.typescriptreact": "typescriptreact", 148 | "meta.embedded.block.csharp": "csharp", 149 | "meta.embedded.block.fsharp": "fsharp" 150 | } 151 | } 152 | ], 153 | "configuration": { 154 | "title": "Orgwise", 155 | "properties": { 156 | "orgwise.useCli": { 157 | "type": "boolean", 158 | "default": false, 159 | "description": "Run language server from Orgwise Cli." 160 | }, 161 | "orgwise.todoKeywords": { 162 | "type": "array", 163 | "default": [ 164 | "TODO" 165 | ], 166 | "description": "Headline todo keywords." 167 | }, 168 | "orgwise.doneKeywords": { 169 | "type": "array", 170 | "default": [ 171 | "DONE" 172 | ], 173 | "description": "Headline done keywords." 174 | } 175 | } 176 | }, 177 | "configurationDefaults": { 178 | "[org]": { 179 | "editor.semanticHighlighting.enabled": true 180 | } 181 | } 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /vscode/src/extension.ts: -------------------------------------------------------------------------------- 1 | import * as vscode from "vscode"; 2 | 3 | import { LanguageClient as BrowserLanguageClient } from "vscode-languageclient/browser"; 4 | import { 5 | BaseLanguageClient, 6 | Executable, 7 | LanguageClientOptions, 8 | LanguageClient as NodeLanguageClient, 9 | NodeModule, 10 | ServerOptions, 11 | TransportKind, 12 | } from "vscode-languageclient/node"; 13 | 14 | import PreviewHtml from "./preview-html"; 15 | import SyntaxTree from "./syntax-tree"; 16 | import WebPanel from "./web-panel"; 17 | 18 | declare const WEB_EXTENSION: boolean; 19 | declare const GIT_COMMIT: string; 20 | declare const BUILD_TIME: string; 21 | 22 | export let client: BaseLanguageClient; 23 | 24 | export function activate(context: vscode.ExtensionContext) { 25 | const configuration = vscode.workspace.getConfiguration(); 26 | 27 | // Options to control the language client 28 | const clientOptions: LanguageClientOptions = { 29 | // Register the server for plain text documents 30 | documentSelector: [{ scheme: "file", language: "org" }], 31 | initializationOptions: { 32 | todoKeywords: configuration.get("orgwise.todoKeywords"), 33 | doneKeywords: configuration.get("orgwise.doneKeywords"), 34 | wasmUrl: vscode.Uri.joinPath( 35 | context.extensionUri, 36 | "./dist/orgwise_bg.wasm" 37 | ).toString(), 38 | }, 39 | }; 40 | 41 | if (WEB_EXTENSION) { 42 | const workerUrl = vscode.Uri.joinPath( 43 | context.extensionUri, 44 | "./dist/lsp-worker.js" 45 | ); 46 | 47 | client = new BrowserLanguageClient( 48 | "orgwise", 49 | "Orgwise", 50 | clientOptions, 51 | new Worker(workerUrl.toString()) 52 | ); 53 | } else if (configuration.get("orgwise.useCli")) { 54 | const run: Executable = { 55 | command: "/Users/poi/.cargo/bin/orgwise", 56 | args: ["lsp"], 57 | }; 58 | 59 | const serverOptions: ServerOptions = { 60 | run, 61 | debug: run, 62 | }; 63 | 64 | client = new NodeLanguageClient( 65 | "orgwise", 66 | "Orgwise", 67 | serverOptions, 68 | clientOptions 69 | ); 70 | } else { 71 | const serverUrl = vscode.Uri.joinPath( 72 | context.extensionUri, 73 | "./dist/lsp-server.js" 74 | ); 75 | 76 | const run: NodeModule = { 77 | module: serverUrl.fsPath, 78 | transport: TransportKind.ipc, 79 | }; 80 | 81 | const serverOptions: ServerOptions = { 82 | run, 83 | debug: run, 84 | }; 85 | 86 | client = new NodeLanguageClient( 87 | "orgwise", 88 | "Orgwise", 89 | serverOptions, 90 | clientOptions 91 | ); 92 | } 93 | 94 | // Start the client. This will also launch the server 95 | client.start(); 96 | 97 | context.subscriptions.push(SyntaxTree.register()); 98 | context.subscriptions.push(PreviewHtml.register(context)); 99 | context.subscriptions.push(WebPanel.register(context)); 100 | 101 | vscode.commands.registerCommand("orgwise.show-info-ui", () => { 102 | vscode.window.showInformationMessage(`orgwise info:`, { 103 | modal: true, 104 | detail: 105 | `Web Extension: ${WEB_EXTENSION}\n` + 106 | `Use CLI: ${configuration.get("orgwise.useCli")}\n` + 107 | `Git Commit: ${GIT_COMMIT}\n` + 108 | `Build Time: ${BUILD_TIME}`, 109 | }); 110 | }); 111 | } 112 | 113 | export function deactivate(): Thenable | undefined { 114 | if (!client) { 115 | return undefined; 116 | } 117 | return client.stop(); 118 | } 119 | -------------------------------------------------------------------------------- /vscode/src/lsp-server.ts: -------------------------------------------------------------------------------- 1 | // Node.js implementation for orgwise lsp server 2 | 3 | import { exec } from "node:child_process"; 4 | import { existsSync } from "node:fs"; 5 | import { readFile, writeFile } from "node:fs/promises"; 6 | import { homedir, tmpdir } from "node:os"; 7 | import { join } from "node:path"; 8 | import { promisify } from "node:util"; 9 | import { 10 | IPCMessageReader, 11 | IPCMessageWriter, 12 | createMessageConnection, 13 | } from "vscode-languageserver-protocol/node"; 14 | import { URI } from "vscode-uri"; 15 | 16 | const execAsync = promisify(exec); 17 | 18 | import { LspBackend, initSync } from "../../pkg/orgwise"; 19 | 20 | const writer = new IPCMessageWriter(process); 21 | const reader = new IPCMessageReader(process); 22 | 23 | const connection = createMessageConnection(reader, writer); 24 | 25 | let backend: LspBackend; 26 | 27 | connection.onRequest("initialize", async (params) => { 28 | if (!backend) { 29 | const path = URI.parse((params).initializationOptions.wasmUrl).fsPath; 30 | const buffer = await readFile(path); 31 | initSync(buffer); 32 | 33 | backend = new LspBackend({ 34 | sendNotification: (method: string, params: any) => 35 | connection.sendNotification(method, params), 36 | 37 | sendRequest: (method: string, params: any) => 38 | connection.sendRequest(method, params), 39 | 40 | homeDir: () => URI.file(homedir()).toString() + "/", 41 | 42 | readToString: async (url: string) => { 43 | const path = URI.parse(url).fsPath; 44 | if (existsSync(path)) { 45 | return readFile(path, { encoding: "utf-8" }); 46 | } else { 47 | return ""; 48 | } 49 | }, 50 | 51 | write: (url: string, content: string) => 52 | writeFile(URI.parse(url).fsPath, content), 53 | 54 | execute: async (executable: string, content: string) => { 55 | const file = join(tmpdir(), ".orgwise"); 56 | await writeFile(file, content); 57 | const output = await execAsync(`${executable} ${file}`); 58 | return output.stdout; 59 | }, 60 | }); 61 | } 62 | 63 | return backend.onRequest("initialize", params); 64 | }); 65 | 66 | connection.onRequest((method, params) => { 67 | return backend.onRequest(method, params); 68 | }); 69 | 70 | connection.onNotification((method, params) => { 71 | return backend.onNotification(method, params); 72 | }); 73 | 74 | connection.listen(); 75 | -------------------------------------------------------------------------------- /vscode/src/lsp-worker.ts: -------------------------------------------------------------------------------- 1 | // web worker implementation for orgwise lsp server 2 | 3 | import { 4 | BrowserMessageReader, 5 | BrowserMessageWriter, 6 | createMessageConnection, 7 | } from "vscode-languageserver-protocol/browser"; 8 | 9 | import init, { LspBackend } from "../../pkg/orgwise"; 10 | 11 | declare var self: DedicatedWorkerGlobalScope; 12 | 13 | const writer = new BrowserMessageWriter(self); 14 | const reader = new BrowserMessageReader(self); 15 | 16 | const connection = createMessageConnection(reader, writer); 17 | 18 | let backend: LspBackend; 19 | 20 | connection.onRequest("initialize", async (params) => { 21 | if (!backend) { 22 | await init((params).initializationOptions.wasmUrl); 23 | 24 | backend = new LspBackend({ 25 | sendNotification: (method: string, params: any) => { 26 | return connection.sendNotification(method, params); 27 | }, 28 | sendRequest: (method: string, params: any) => { 29 | return connection.sendRequest(method, params); 30 | }, 31 | 32 | execute: () => { 33 | throw new Error("`execute` is not support in web extension"); 34 | }, 35 | 36 | readToString: () => { 37 | throw new Error("`readToString` is not support in web extension"); 38 | }, 39 | 40 | write: () => { 41 | throw new Error("`write` is not support in web extension"); 42 | }, 43 | }); 44 | } 45 | 46 | return backend.onRequest("initialize", params); 47 | }); 48 | 49 | connection.onRequest((method, params) => { 50 | return backend.onRequest(method, params); 51 | }); 52 | 53 | connection.onNotification((method, params) => { 54 | return backend.onNotification(method, params); 55 | }); 56 | 57 | connection.listen(); 58 | -------------------------------------------------------------------------------- /vscode/src/preview-html.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Disposable, 3 | ExtensionContext, 4 | Uri, 5 | ViewColumn, 6 | WebviewPanel, 7 | commands, 8 | window, 9 | workspace, 10 | } from "vscode"; 11 | import { Utils } from "vscode-uri"; 12 | 13 | import { client } from "./extension"; 14 | 15 | export default class PreviewHtml { 16 | public static currentPanel: PreviewHtml | undefined; 17 | 18 | public static readonly viewType = "orgwise-preview-html"; 19 | 20 | private readonly _panel: WebviewPanel; 21 | private _orgUri: Uri; 22 | private _extensionUri: Uri; 23 | 24 | private _disposables: Disposable[] = []; 25 | 26 | static register(context: ExtensionContext): Disposable { 27 | return commands.registerTextEditorCommand( 28 | "orgwise.preview-html-ui", 29 | (editor) => 30 | PreviewHtml.createOrShow(context.extensionUri, editor.document.uri) 31 | ); 32 | } 33 | 34 | private static createOrShow(extensionUri: Uri, orgUri: Uri) { 35 | const column = window.activeTextEditor.viewColumn! + 1; 36 | 37 | // If we already have a panel, show it. 38 | if (PreviewHtml.currentPanel) { 39 | PreviewHtml.currentPanel._panel.reveal(column); 40 | PreviewHtml.currentPanel._orgUri = orgUri; 41 | PreviewHtml.currentPanel.refresh(); 42 | // PreviewHtmlPanel.currentPanel._panel.webview.pos 43 | return; 44 | } 45 | 46 | // Otherwise, create a new panel. 47 | const panel = window.createWebviewPanel( 48 | PreviewHtml.viewType, 49 | "Preview of " + Utils.basename(orgUri), 50 | column || ViewColumn.One, 51 | { 52 | // Enable javascript in the webview 53 | enableScripts: true, 54 | localResourceRoots: [ 55 | Uri.joinPath(extensionUri, "media"), 56 | ...workspace.workspaceFolders.map((folder) => folder.uri), 57 | ], 58 | } 59 | ); 60 | 61 | PreviewHtml.currentPanel = new PreviewHtml(panel, orgUri, extensionUri); 62 | } 63 | 64 | private constructor(panel: WebviewPanel, orgUri: Uri, extensionUri: Uri) { 65 | this._panel = panel; 66 | this._orgUri = orgUri; 67 | this._extensionUri = extensionUri; 68 | 69 | // Set the webview's initial html content 70 | this._update(); 71 | 72 | // Listen for when the panel is disposed 73 | // This happens when the user closes the panel or when the panel is closed programmatically 74 | this._panel.onDidDispose( 75 | () => { 76 | this.dispose(); 77 | }, 78 | null, 79 | this._disposables 80 | ); 81 | 82 | workspace.onDidChangeTextDocument((event) => { 83 | if (event.document.uri.fsPath === this._orgUri.fsPath) { 84 | this.refresh(); 85 | } 86 | }, this._disposables); 87 | 88 | workspace.onDidOpenTextDocument((document) => { 89 | if (document.uri.fsPath === this._orgUri.fsPath) { 90 | this.refresh(); 91 | } 92 | }, this._disposables); 93 | 94 | // Update the content based on view changes 95 | this._panel.onDidChangeViewState( 96 | (e) => { 97 | if (this._panel.visible) { 98 | this.refresh(); 99 | } 100 | }, 101 | null, 102 | this._disposables 103 | ); 104 | } 105 | 106 | private readonly _delay = 300; 107 | private _throttleTimer: any; 108 | private _firstUpdate = true; 109 | 110 | public refresh() { 111 | // Schedule update if none is pending 112 | if (!this._throttleTimer) { 113 | if (this._firstUpdate) { 114 | this._update(); 115 | } else { 116 | this._throttleTimer = setTimeout(() => this._update(), this._delay); 117 | } 118 | } 119 | 120 | this._firstUpdate = false; 121 | } 122 | 123 | private async _update() { 124 | clearTimeout(this._throttleTimer); 125 | this._throttleTimer = undefined; 126 | 127 | if (!client) { 128 | return; 129 | } 130 | 131 | try { 132 | const content: string = await client.sendRequest( 133 | "workspace/executeCommand", 134 | { 135 | command: "orgwise.preview-html", 136 | arguments: [this._orgUri.with({ scheme: "file" }).toString()], 137 | } 138 | ); 139 | 140 | this._panel.webview.html = ` 141 | 142 | 143 | 144 | 145 | 149 | 150 | 151 | 152 | 158 | 159 | 160 |
${content}
161 | 162 | `; 163 | } catch {} 164 | } 165 | 166 | public dispose() { 167 | PreviewHtml.currentPanel = undefined; 168 | 169 | // Clean up our resources 170 | this._panel.dispose(); 171 | 172 | while (this._disposables.length) { 173 | const x = this._disposables.pop(); 174 | if (x) { 175 | x.dispose(); 176 | } 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /vscode/src/syntax-tree.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Disposable, 3 | TextDocumentContentProvider, 4 | Uri, 5 | commands, 6 | window, 7 | workspace, 8 | } from "vscode"; 9 | import { client } from "./extension"; 10 | 11 | export default class SyntaxTreeProvider implements TextDocumentContentProvider { 12 | static readonly scheme = "orgwise-syntax-tree"; 13 | 14 | static register(): Disposable { 15 | const provider = new SyntaxTreeProvider(); 16 | 17 | // register content provider for scheme `references` 18 | // register document link provider for scheme `references` 19 | const providerRegistrations = workspace.registerTextDocumentContentProvider( 20 | SyntaxTreeProvider.scheme, 21 | provider 22 | ); 23 | 24 | // register command that crafts an uri with the `references` scheme, 25 | // open the dynamic document, and shows it in the next editor 26 | const commandRegistration = commands.registerTextEditorCommand( 27 | "orgwise.syntax-tree-ui", 28 | (editor) => { 29 | return workspace 30 | .openTextDocument(encode(editor.document.uri)) 31 | .then((doc) => window.showTextDocument(doc, editor.viewColumn! + 1)); 32 | } 33 | ); 34 | 35 | return Disposable.from( 36 | provider, 37 | commandRegistration, 38 | providerRegistrations 39 | ); 40 | } 41 | 42 | dispose() { 43 | // this._subscriptions.dispose(); 44 | // this._documents.clear(); 45 | // this._editorDecoration.dispose(); 46 | // this._onDidChange.dispose(); 47 | } 48 | 49 | async provideTextDocumentContent(uri: Uri): Promise { 50 | if (!client) { 51 | return "LSP server is not ready..."; 52 | } 53 | 54 | const result = await client.sendRequest("workspace/executeCommand", { 55 | command: "orgwise.syntax-tree", 56 | arguments: [decode(uri).toString()], 57 | }); 58 | 59 | if (typeof result === "string") { 60 | return result; 61 | } 62 | 63 | return ""; 64 | } 65 | } 66 | 67 | const encode = (uri: Uri): Uri => { 68 | return uri.with({ 69 | scheme: SyntaxTreeProvider.scheme, 70 | query: uri.path, 71 | path: "tree.syntax", 72 | }); 73 | }; 74 | 75 | const decode = (uri: Uri): Uri => { 76 | return uri.with({ scheme: "file", path: uri.query, query: "" }); 77 | }; 78 | -------------------------------------------------------------------------------- /vscode/src/web-panel.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Disposable, 3 | ExtensionContext, 4 | Uri, 5 | ViewColumn, 6 | WebviewPanel, 7 | commands, 8 | window, 9 | workspace, 10 | } from "vscode"; 11 | 12 | import { client } from "./extension"; 13 | 14 | import manifest from "../dist/web/.vite.manifest.json"; 15 | 16 | export default class WebPanel { 17 | public static currentPanel: WebPanel | undefined; 18 | 19 | public static readonly viewType = "orgwise-web-panel"; 20 | 21 | private readonly _panel: WebviewPanel; 22 | 23 | private _disposables: Disposable[] = []; 24 | 25 | public static register(context: ExtensionContext): Disposable { 26 | return commands.registerTextEditorCommand( 27 | "orgwise.web-panel-ui", 28 | (editor) => 29 | WebPanel.createOrShow(context.extensionUri, editor.document.uri) 30 | ); 31 | } 32 | 33 | static createOrShow(extensionUri: Uri, orgUri: Uri) { 34 | const column = window.activeTextEditor.viewColumn! + 1; 35 | 36 | // If we already have a panel, show it. 37 | if (WebPanel.currentPanel) { 38 | WebPanel.currentPanel._panel.reveal(column); 39 | return; 40 | } 41 | 42 | // Otherwise, create a new panel. 43 | const panel = window.createWebviewPanel( 44 | WebPanel.viewType, 45 | "Orgwise Web", 46 | column || ViewColumn.One, 47 | { 48 | // Enable javascript in the webview 49 | enableScripts: true, 50 | // And restrict the webview to only loading content from our extension's `media` directory. 51 | localResourceRoots: [ 52 | Uri.joinPath(extensionUri, "dist"), 53 | ...workspace.workspaceFolders.map((folder) => folder.uri), 54 | ], 55 | } 56 | ); 57 | 58 | panel.webview.html = ` 59 | 60 | 61 | 62 | 63 | 67 | 68 | 69 | 70 | 71 |
72 | 73 | 79 | 80 | ${(manifest["src/main.tsx"].css || []) 81 | .map( 82 | (css) => 83 | `` 89 | ) 90 | .join("")} 91 | 92 | `; 93 | 94 | WebPanel.currentPanel = new WebPanel(panel); 95 | } 96 | 97 | private constructor(panel: WebviewPanel) { 98 | this._panel = panel; 99 | 100 | // Listen for when the panel is disposed 101 | // This happens when the user closes the panel or when the panel is closed programmatically 102 | this._panel.onDidDispose( 103 | () => { 104 | this.dispose(); 105 | }, 106 | null, 107 | this._disposables 108 | ); 109 | 110 | this._panel.webview.onDidReceiveMessage( 111 | async (message) => { 112 | const result = await client.sendRequest("workspace/executeCommand", { 113 | command: message.command, 114 | arguments: message.arguments, 115 | }); 116 | await this._panel.webview.postMessage({ 117 | id: message.id, 118 | result: result, 119 | }); 120 | }, 121 | undefined, 122 | this._disposables 123 | ); 124 | } 125 | 126 | public dispose() { 127 | WebPanel.currentPanel = undefined; 128 | 129 | // Clean up our resources 130 | this._panel.dispose(); 131 | 132 | while (this._disposables.length) { 133 | const x = this._disposables.pop(); 134 | if (x) { 135 | x.dispose(); 136 | } 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /vscode/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2019", 5 | "lib": ["ES2019", "WebWorker", "WebWorker.ImportScripts"], 6 | "outDir": "../dist", 7 | "sourceMap": true, 8 | "resolveJsonModule": true, 9 | "esModuleInterop": true 10 | }, 11 | "include": ["src"], 12 | "exclude": ["node_modules", ".vscode-test"] 13 | } 14 | -------------------------------------------------------------------------------- /web/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Orgwise 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "orgwise", 3 | "private": true, 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "tsc && vite build", 7 | "preview": "vite preview", 8 | "tauri": "tauri" 9 | }, 10 | "dependencies": { 11 | "@radix-ui/react-checkbox": "^1.0.4", 12 | "@radix-ui/react-context-menu": "^2.1.5", 13 | "@radix-ui/react-dialog": "^1.0.5", 14 | "@radix-ui/react-dropdown-menu": "^2.0.6", 15 | "@radix-ui/react-label": "^2.0.2", 16 | "@radix-ui/react-select": "^2.0.0", 17 | "@radix-ui/react-separator": "^1.0.3", 18 | "@radix-ui/react-slot": "^1.0.2", 19 | "@radix-ui/react-toast": "^1.1.5", 20 | "@tanstack/react-table": "^8.13.2", 21 | "@tauri-apps/api": ">=2.0.0-beta.0", 22 | "@tauri-apps/plugin-shell": ">=2.0.0-beta.0", 23 | "@types/node": "~16.11.68", 24 | "@types/react": "^18.2.61", 25 | "@types/react-dom": "^18.2.19", 26 | "@vitejs/plugin-react": "^4.2.1", 27 | "autoprefixer": "^10.4.17", 28 | "class-variance-authority": "^0.7.0", 29 | "clsx": "^2.1.0", 30 | "date-fns": "^3.6.0", 31 | "jotai": "^2.7.0", 32 | "lucide-react": "^0.344.0", 33 | "postcss": "^8.4.35", 34 | "prettier": "^3.2.5", 35 | "react": "^18.2.0", 36 | "react-dom": "^18.2.0", 37 | "react-hook-form": "^7.51.3", 38 | "swr": "^2.2.5", 39 | "tailwind-merge": "^2.2.1", 40 | "tailwindcss": "^3.4.1", 41 | "tailwindcss-animate": "^1.0.7", 42 | "tslib": "^2.6.2" 43 | }, 44 | "devDependencies": { 45 | "internal-ip": "^7.0.0", 46 | "@tauri-apps/cli": ">=2.0.0-beta.0", 47 | "typescript": "^5.3.3", 48 | "vite": "^5.1.1" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /web/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /web/src-tauri/.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Generated by Tauri 6 | # will have schema files for capabilities auto-completion 7 | /gen/schemas 8 | -------------------------------------------------------------------------------- /web/src-tauri/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "orgwise-tauri" 3 | version = "0.0.0" 4 | edition = "2021" 5 | 6 | [lib] 7 | name = "orgwise_lib" 8 | crate-type = ["lib", "cdylib", "staticlib"] 9 | 10 | [dependencies] 11 | tauri = { version = "2.0.0-beta", features = [] } 12 | tauri-plugin-shell = "2.0.0-beta" 13 | serde = { version = "1", features = ["derive"] } 14 | serde_json = "1" 15 | orgwise = { path = "../.." } 16 | 17 | [build-dependencies] 18 | tauri-build = { version = "2.0.0-beta", features = [] } 19 | -------------------------------------------------------------------------------- /web/src-tauri/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | tauri_build::build() 3 | } 4 | -------------------------------------------------------------------------------- /web/src-tauri/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/web/src-tauri/icons/128x128.png -------------------------------------------------------------------------------- /web/src-tauri/icons/128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/web/src-tauri/icons/128x128@2x.png -------------------------------------------------------------------------------- /web/src-tauri/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/web/src-tauri/icons/32x32.png -------------------------------------------------------------------------------- /web/src-tauri/icons/Square107x107Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/web/src-tauri/icons/Square107x107Logo.png -------------------------------------------------------------------------------- /web/src-tauri/icons/Square142x142Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/web/src-tauri/icons/Square142x142Logo.png -------------------------------------------------------------------------------- /web/src-tauri/icons/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/web/src-tauri/icons/Square150x150Logo.png -------------------------------------------------------------------------------- /web/src-tauri/icons/Square284x284Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/web/src-tauri/icons/Square284x284Logo.png -------------------------------------------------------------------------------- /web/src-tauri/icons/Square30x30Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/web/src-tauri/icons/Square30x30Logo.png -------------------------------------------------------------------------------- /web/src-tauri/icons/Square310x310Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/web/src-tauri/icons/Square310x310Logo.png -------------------------------------------------------------------------------- /web/src-tauri/icons/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/web/src-tauri/icons/Square44x44Logo.png -------------------------------------------------------------------------------- /web/src-tauri/icons/Square71x71Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/web/src-tauri/icons/Square71x71Logo.png -------------------------------------------------------------------------------- /web/src-tauri/icons/Square89x89Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/web/src-tauri/icons/Square89x89Logo.png -------------------------------------------------------------------------------- /web/src-tauri/icons/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/web/src-tauri/icons/StoreLogo.png -------------------------------------------------------------------------------- /web/src-tauri/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/web/src-tauri/icons/icon.icns -------------------------------------------------------------------------------- /web/src-tauri/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/web/src-tauri/icons/icon.ico -------------------------------------------------------------------------------- /web/src-tauri/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PoiScript/orgwise/36db088557d15eb4ebf391aafc37f7f37d7f056c/web/src-tauri/icons/icon.png -------------------------------------------------------------------------------- /web/src-tauri/src/lib.rs: -------------------------------------------------------------------------------- 1 | use orgwise::cli::environment::CliBackend; 2 | use orgwise::command::OrgwiseCommand; 3 | use serde_json::Value; 4 | use std::path::PathBuf; 5 | 6 | #[tauri::command] 7 | async fn execute_command(command: OrgwiseCommand) -> Result { 8 | let backend = CliBackend::new(false); 9 | 10 | backend.load_org_file(&PathBuf::from("/Users/poi/org/todo.org")); 11 | 12 | command 13 | .execute(&backend) 14 | .await 15 | .map_err(|err| err.to_string()) 16 | } 17 | 18 | #[cfg_attr(mobile, tauri::mobile_entry_point)] 19 | pub fn run() { 20 | tauri::Builder::default() 21 | .plugin(tauri_plugin_shell::init()) 22 | .invoke_handler(tauri::generate_handler![execute_command]) 23 | .run(tauri::generate_context!()) 24 | .expect("error while running tauri application"); 25 | } 26 | -------------------------------------------------------------------------------- /web/src-tauri/src/main.rs: -------------------------------------------------------------------------------- 1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!! 2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] 3 | 4 | fn main() { 5 | orgwise_lib::run() 6 | } 7 | -------------------------------------------------------------------------------- /web/src-tauri/tauri.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "productName": "orgwise", 3 | "version": "0.0.0", 4 | "identifier": "com.tauri.dev", 5 | "build": { 6 | "beforeDevCommand": "pnpm dev", 7 | "devUrl": "http://localhost:1420", 8 | "beforeBuildCommand": "pnpm build", 9 | "frontendDist": "../dist" 10 | }, 11 | "app": { 12 | "windows": [ 13 | { 14 | "title": "orgwise", 15 | "width": 800, 16 | "height": 600 17 | } 18 | ], 19 | "security": { 20 | "csp": null 21 | } 22 | }, 23 | "bundle": { 24 | "active": true, 25 | "targets": "all", 26 | "icon": [ 27 | "icons/32x32.png", 28 | "icons/128x128.png", 29 | "icons/128x128@2x.png", 30 | "icons/icon.icns", 31 | "icons/icon.ico" 32 | ] 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useAtomValue, useSetAtom } from "jotai/react"; 2 | import React, { useEffect } from "react"; 3 | import useSWR, { SWRConfig } from "swr"; 4 | import executeCommand from "./command"; 5 | 6 | import Clocking from "@/components/Clocking"; 7 | import { Button } from "@/components/ui/button"; 8 | import { Input } from "@/components/ui/input"; 9 | 10 | import { CalendarDay } from "./CalendarDay"; 11 | import { Tasks } from "./Tasks"; 12 | import { ViewMode, filtersAtom, viewModeAtom } from "./atom"; 13 | 14 | const App: React.FC<{}> = () => { 15 | const viewMode = useAtomValue(viewModeAtom); 16 | 17 | return ( 18 | 24 |
25 | 26 | 27 | 28 |
29 | 30 | 31 | 32 | {viewMode === ViewMode.Tasks && } 33 | 34 | {viewMode === ViewMode.CalendarDay && } 35 |
36 | ); 37 | }; 38 | 39 | export const SearchInput: React.FC = () => { 40 | const setFilter = useSetAtom(filtersAtom); 41 | 42 | useEffect(() => { 43 | setFilter({}); 44 | }, []); 45 | 46 | return ( 47 | 51 | // table.getColumn("title")?.setFilterValue(event.target.value) 52 | // } 53 | className="max-w-sm" 54 | /> 55 | ); 56 | }; 57 | 58 | export const LoadingButton: React.FC = () => { 59 | const { isLoading, mutate } = useSWR("headline-search"); 60 | 61 | return ( 62 | 65 | ); 66 | }; 67 | 68 | export default App; 69 | -------------------------------------------------------------------------------- /web/src/Tasks.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ContextMenu, 3 | ContextMenuContent, 4 | ContextMenuItem, 5 | ContextMenuTrigger, 6 | } from "@/components/ui/context-menu"; 7 | import clsx from "clsx"; 8 | import { parse } from "date-fns"; 9 | import { useAtom, useSetAtom } from "jotai/react"; 10 | import { 11 | CheckCircle2, 12 | Circle, 13 | CircleDashed, 14 | Clock, 15 | Copy, 16 | Play, 17 | Tag, 18 | Trash2, 19 | } from "lucide-react"; 20 | import React from "react"; 21 | import useSWR, { SWRConfiguration, mutate, useSWRConfig } from "swr"; 22 | import { SearchResult, selectedAtom } from "@/atom"; 23 | import { CountDown } from "@/components/Clocking"; 24 | import HeadlineDialog from "@/components/HeadlineDialog"; 25 | import { Badge } from "@/components/ui/badge"; 26 | import { Dialog, DialogContent } from "@/components/ui/dialog"; 27 | import { Separator } from "@/components/ui/separator"; 28 | import { formatMinutes } from "@/lib/utils"; 29 | 30 | export const Tasks: React.FC = () => { 31 | const { 32 | data = [], 33 | isLoading, 34 | error, 35 | } = useSWR("headline-search"); 36 | 37 | const [selected, setSelected] = useAtom(selectedAtom); 38 | 39 | if (isLoading || error) return; 40 | 41 | return ( 42 |
43 | setSelected(null)}> 44 | {data.map((item) => ( 45 |
46 | 47 | 48 |
49 | ))} 50 | 51 | 54 | setSelected(null)} /> 55 | 56 |
57 |
58 | ); 59 | }; 60 | 61 | const ListItem: React.FC<{ item: SearchResult }> = ({ item }) => { 62 | const select = useSetAtom(selectedAtom); 63 | 64 | const { fetcher: executeCommand }: SWRConfiguration = 65 | useSWRConfig(); 66 | 67 | return ( 68 | 69 | 70 |
select(item)} 74 | > 75 | {item.keyword ? ( 76 | item.keyword.type === "TODO" ? ( 77 | 78 | ) : ( 79 | 80 | ) 81 | ) : ( 82 | 83 | )} 84 | 85 | {item.priority && ( 86 | #{item.priority} 87 | )} 88 | 89 |
{item.title}
90 | 91 | {Array.isArray(item.tags) && ( 92 | <> 93 | {item.tags.map((tag) => ( 94 |
95 | 96 | {tag} 97 |
98 | ))} 99 | 100 | )} 101 | 102 | {item.clocking.total_minutes > 0 && ( 103 |
104 | 105 | {formatMinutes(item.clocking.total_minutes)} 106 |
107 | )} 108 | 109 | {item.clocking.start && ( 110 |
111 | 112 | 119 |
120 | )} 121 |
122 |
123 | 124 | 125 | 127 | executeCommand("headline-duplicate", { 128 | url: item.url, 129 | line: item.line, 130 | }).then(() => { 131 | mutate("headline-search"); 132 | }) 133 | } 134 | > 135 | 136 | Duplicate 137 | 138 | 139 | {item.clocking.start ? ( 140 | 142 | executeCommand("clocking-stop", { 143 | url: item.url, 144 | line: item.line, 145 | }).then(() => { 146 | mutate("headline-search"); 147 | mutate("clocking-status"); 148 | }) 149 | } 150 | > 151 | 152 | Clock Out 153 | 154 | ) : ( 155 | 157 | executeCommand("clocking-start", { 158 | url: item.url, 159 | line: item.line, 160 | }).then(() => { 161 | mutate("headline-search"); 162 | mutate("clocking-status"); 163 | }) 164 | } 165 | > 166 | 167 | Clock In 168 | 169 | )} 170 | 171 | 174 | executeCommand("headline-remove", { 175 | url: item.url, 176 | line: item.line, 177 | }).then(() => { 178 | mutate("headline-search"); 179 | }) 180 | } 181 | > 182 | 183 | Remove 184 | 185 | 186 |
187 | ); 188 | }; 189 | -------------------------------------------------------------------------------- /web/src/atom.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | 3 | export type SearchResult = { 4 | title: string; 5 | section?: string; 6 | section_html?: string; 7 | section_markdown?: string; 8 | url: string; 9 | line: number; 10 | level: number; 11 | priority?: string; 12 | tags: string[]; 13 | keyword?: { value: string; type: "DONE" | "TODO" }; 14 | planning: { deadline?: string; scheduled?: string; closed?: string }; 15 | clocking: { total_minutes: number; start?: string }; 16 | }; 17 | 18 | export const enum ViewMode { 19 | Tasks, 20 | CalendarDay, 21 | } 22 | 23 | export const viewModeAtom = atom(ViewMode.Tasks); 24 | 25 | export const filtersAtom = atom({}); 26 | 27 | export const selectedAtom = atom(null as SearchResult | null); 28 | -------------------------------------------------------------------------------- /web/src/command.ts: -------------------------------------------------------------------------------- 1 | declare const acquireVsCodeApi: () => any | undefined; 2 | declare const PLATFORM: string; 3 | 4 | import { invoke } from "@tauri-apps/api/core"; 5 | 6 | const createExecuteCommand = () => { 7 | if (PLATFORM !== "web") { 8 | return function executeCommand( 9 | command: string, 10 | argument?: A 11 | ): Promise { 12 | return invoke("execute_command", { 13 | command: { command, argument: argument || {} }, 14 | }); 15 | }; 16 | } else if (typeof acquireVsCodeApi != "undefined") { 17 | const vscode = acquireVsCodeApi(); 18 | let reqId = 0; 19 | 20 | return function executeCommand( 21 | command: string, 22 | argument?: A 23 | ): Promise { 24 | return new Promise((resolve) => { 25 | const id = ++reqId; 26 | 27 | window.addEventListener("message", (ev) => { 28 | if (ev.data.id == id) { 29 | resolve(ev.data.result); 30 | } 31 | }); 32 | 33 | vscode.postMessage({ 34 | id, 35 | command: `orgwise.${command}`, 36 | arguments: [argument || {}], 37 | }); 38 | }); 39 | }; 40 | } else { 41 | return function executeCommand( 42 | command: string, 43 | argument?: A 44 | ): Promise { 45 | return window 46 | .fetch("http://127.0.0.1:4100/api/command", { 47 | method: "POST", 48 | headers: { "content-type": "application/json" }, 49 | body: JSON.stringify({ 50 | command, 51 | argument: argument || {}, 52 | }), 53 | }) 54 | .then((res) => res.json()); 55 | }; 56 | } 57 | }; 58 | 59 | export default createExecuteCommand(); 60 | -------------------------------------------------------------------------------- /web/src/components/clocking.tsx: -------------------------------------------------------------------------------- 1 | import * as Toast from "@radix-ui/react-toast"; 2 | import { differenceInMinutes, isValid, parse } from "date-fns"; 3 | import { Clock, PauseCircle } from "lucide-react"; 4 | import React, { useEffect, useState } from "react"; 5 | import useSWR, { SWRConfiguration, mutate, useSWRConfig } from "swr"; 6 | 7 | import { formatMinutes } from "@/lib/utils"; 8 | 9 | type ClockStatus = { 10 | start: string; 11 | title: string; 12 | url: string; 13 | line: number; 14 | }; 15 | 16 | const Clocking: React.FC = () => { 17 | const { 18 | data: status, 19 | isLoading, 20 | error, 21 | } = useSWR<{ running: ClockStatus }>("clocking-status"); 22 | 23 | const { fetcher: executeCommand }: SWRConfiguration = 24 | useSWRConfig(); 25 | 26 | if (isLoading || error) return; 27 | 28 | return ( 29 | 30 | 39 | 40 | 41 | {status?.running && ( 42 |
43 | 50 |
51 | )} 52 | 53 | 55 | executeCommand("clocking-stop", { 56 | url: status!.running.url, 57 | line: status!.running.line, 58 | }).then(() => { 59 | mutate("clocking-status"); 60 | mutate("headline-search"); 61 | }) 62 | } 63 | size={20} 64 | /> 65 |
66 | 67 | 68 |
69 | ); 70 | }; 71 | 72 | export const CountDown: React.FC<{ start: Date }> = ({ start }) => { 73 | const [minutes, setMinutes] = useState(0); 74 | 75 | useEffect(() => { 76 | if (!isValid(start)) { 77 | return; 78 | } 79 | 80 | const fn = () => setMinutes(differenceInMinutes(new Date(), start)); 81 | 82 | fn(); 83 | const timer = setInterval(fn, 60); 84 | 85 | return () => { 86 | clearInterval(timer); 87 | }; 88 | }, [start]); 89 | 90 | return <>{formatMinutes(minutes)}; 91 | }; 92 | 93 | export default Clocking; 94 | -------------------------------------------------------------------------------- /web/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | }, 24 | ); 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ); 34 | } 35 | 36 | export { Badge, badgeVariants }; 37 | -------------------------------------------------------------------------------- /web/src/components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { ChevronRight, MoreHorizontal } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Breadcrumb = React.forwardRef< 8 | HTMLElement, 9 | React.ComponentPropsWithoutRef<"nav"> & { 10 | separator?: React.ReactNode 11 | } 12 | >(({ ...props }, ref) =>