Merge pull request #21 from stevedylandev/feat/jotts-tui
2035f712
feat/jotts tui
20 file(s) · +2390 −88
feat/jotts tui
| 1 | + | name: Docker Test |
|
| 2 | + | ||
| 3 | + | on: |
|
| 4 | + | pull_request: |
|
| 5 | + | branches: |
|
| 6 | + | - main |
|
| 7 | + | ||
| 8 | + | jobs: |
|
| 9 | + | changes: |
|
| 10 | + | name: Detect changes |
|
| 11 | + | runs-on: ubuntu-latest |
|
| 12 | + | outputs: |
|
| 13 | + | apps: ${{ steps.filter.outputs.apps }} |
|
| 14 | + | steps: |
|
| 15 | + | - uses: actions/checkout@v4 |
|
| 16 | + | with: |
|
| 17 | + | fetch-depth: 0 |
|
| 18 | + | ||
| 19 | + | - name: Determine which apps to build |
|
| 20 | + | id: filter |
|
| 21 | + | run: | |
|
| 22 | + | ALL='["cellar","sipp","feeds","parcels","jotts","og","shrink","backup","posts"]' |
|
| 23 | + | ||
| 24 | + | changed=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) |
|
| 25 | + | ||
| 26 | + | if echo "$changed" | grep -qE '^(Cargo\.(toml|lock)|crates/|\.github/workflows/docker(-test)?\.yml)'; then |
|
| 27 | + | echo "apps=${ALL}" >> "$GITHUB_OUTPUT" |
|
| 28 | + | exit 0 |
|
| 29 | + | fi |
|
| 30 | + | ||
| 31 | + | apps=() |
|
| 32 | + | for app in cellar sipp feeds parcels jotts og shrink backup posts; do |
|
| 33 | + | if echo "$changed" | grep -q "^apps/${app}/"; then |
|
| 34 | + | apps+=("\"${app}\"") |
|
| 35 | + | fi |
|
| 36 | + | done |
|
| 37 | + | ||
| 38 | + | if [ ${#apps[@]} -eq 0 ]; then |
|
| 39 | + | echo 'apps=[]' >> "$GITHUB_OUTPUT" |
|
| 40 | + | else |
|
| 41 | + | echo "apps=[$(IFS=,; echo "${apps[*]}")]" >> "$GITHUB_OUTPUT" |
|
| 42 | + | fi |
|
| 43 | + | ||
| 44 | + | build: |
|
| 45 | + | name: build (${{ matrix.app }}) |
|
| 46 | + | needs: changes |
|
| 47 | + | if: needs.changes.outputs.apps != '[]' |
|
| 48 | + | runs-on: ubuntu-latest |
|
| 49 | + | strategy: |
|
| 50 | + | fail-fast: false |
|
| 51 | + | matrix: |
|
| 52 | + | app: ${{ fromJson(needs.changes.outputs.apps) }} |
|
| 53 | + | steps: |
|
| 54 | + | - uses: actions/checkout@v4 |
|
| 55 | + | ||
| 56 | + | - name: Set up Docker Buildx |
|
| 57 | + | uses: docker/setup-buildx-action@v3 |
|
| 58 | + | ||
| 59 | + | - name: Build |
|
| 60 | + | uses: docker/build-push-action@v6 |
|
| 61 | + | with: |
|
| 62 | + | context: . |
|
| 63 | + | file: apps/${{ matrix.app }}/Dockerfile |
|
| 64 | + | push: false |
|
| 65 | + | load: true |
|
| 66 | + | tags: ${{ matrix.app }}:test |
|
| 67 | + | cache-from: type=gha,scope=${{ matrix.app }} |
|
| 68 | + | cache-to: type=gha,mode=max,scope=${{ matrix.app }} |
| 2194 | 2194 | version = "0.1.2" |
|
| 2195 | 2195 | dependencies = [ |
|
| 2196 | 2196 | "andromeda-auth", |
|
| 2197 | + | "arboard", |
|
| 2197 | 2198 | "askama 0.15.6", |
|
| 2198 | 2199 | "askama_web", |
|
| 2199 | 2200 | "axum", |
|
| 2201 | + | "clap", |
|
| 2202 | + | "crossterm", |
|
| 2200 | 2203 | "dotenvy", |
|
| 2201 | 2204 | "nanoid", |
|
| 2205 | + | "open", |
|
| 2202 | 2206 | "pulldown-cmark", |
|
| 2203 | 2207 | "rand 0.8.5", |
|
| 2208 | + | "ratatui", |
|
| 2209 | + | "reqwest 0.13.2", |
|
| 2210 | + | "rpassword", |
|
| 2204 | 2211 | "rusqlite", |
|
| 2205 | 2212 | "rust-embed", |
|
| 2206 | 2213 | "serde", |
|
| 2207 | 2214 | "serde_json", |
|
| 2208 | 2215 | "subtle", |
|
| 2216 | + | "syntect", |
|
| 2209 | 2217 | "tokio", |
|
| 2218 | + | "toml", |
|
| 2210 | 2219 | "tracing", |
|
| 2211 | 2220 | "tracing-subscriber", |
|
| 2212 | 2221 | ] |
| 3 | 3 | COOKIE_SECURE=false |
|
| 4 | 4 | HOST=127.0.0.1 |
|
| 5 | 5 | PORT=3000 |
|
| 6 | + | # Optional. When set, enables the JSON API at /api/notes gated by x-api-key header. |
|
| 7 | + | # Leave unset to disable the API (returns 403). |
|
| 8 | + | JOTTS_API_KEY= |
| 24 | 24 | askama = "0.15" |
|
| 25 | 25 | askama_web = { version = "0.15", features = ["axum-0.8"] } |
|
| 26 | 26 | pulldown-cmark = "0.12" |
|
| 27 | + | ratatui = "0.30" |
|
| 28 | + | crossterm = "0.29" |
|
| 29 | + | arboard = "3" |
|
| 30 | + | syntect = "5" |
|
| 31 | + | reqwest = { version = "0.13", features = ["json", "blocking"] } |
|
| 32 | + | clap = { version = "4", features = ["derive", "env"] } |
|
| 33 | + | toml = "1.0" |
|
| 34 | + | rpassword = "7" |
|
| 35 | + | open = "5.3.3" |
|
| 36 | + | ||
| 37 | + | [[bin]] |
|
| 38 | + | name = "jotts" |
|
| 39 | + | path = "src/main.rs" |
| 35 | 35 | COPY --from=builder /app/target/release/jotts /usr/local/bin/jotts |
|
| 36 | 36 | WORKDIR /data |
|
| 37 | 37 | EXPOSE 3000 |
|
| 38 | - | ENV HOST=0.0.0.0 |
|
| 39 | - | ENV PORT=3000 |
|
| 40 | - | CMD ["jotts"] |
|
| 38 | + | CMD ["jotts", "server", "--port", "3000", "--host", "0.0.0.0"] |
| 24 | 24 | | `HOST` | Server bind address | `127.0.0.1` | |
|
| 25 | 25 | | `PORT` | Server port | `3000` | |
|
| 26 | 26 | | `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` | |
|
| 27 | + | | `JOTTS_API_KEY` | API key for `/api/notes` JSON endpoints (unset = API disabled) | _(unset)_ | |
|
| 27 | 28 | ||
| 28 | 29 | ## Overview |
|
| 29 | 30 | ||
| 56 | 57 | ├── Dockerfile # Multi-stage build (Rust + Debian slim) |
|
| 57 | 58 | └── docker-compose.yml |
|
| 58 | 59 | ``` |
|
| 60 | + | ||
| 61 | + | ## CLI / TUI |
|
| 62 | + | ||
| 63 | + | The `jotts` binary also ships an interactive terminal UI and a minimal CLI. |
|
| 64 | + | ||
| 65 | + | ```bash |
|
| 66 | + | jotts # Launch TUI (local SQLite by default, remote if configured) |
|
| 67 | + | jotts server # Run the web server |
|
| 68 | + | jotts tui # Launch TUI explicitly |
|
| 69 | + | jotts auth # Prompt for remote URL + API key, save to ~/.config/jotts/config.toml |
|
| 70 | + | ``` |
|
| 71 | + | ||
| 72 | + | Flags: `--remote <URL>` (env `JOTTS_REMOTE_URL`), `--api-key <KEY>` (env `JOTTS_API_KEY`). |
|
| 73 | + | ||
| 74 | + | Against a remote server, set `JOTTS_API_KEY` on the server and pass the same key from the CLI. Requests go to `/api/notes` with the `x-api-key` header. The TUI renders note content with syntect Markdown syntax highlighting. |
|
| 59 | 75 | ||
| 60 | 76 | ## Deployment |
|
| 61 | 77 | ||
| 4 | 4 | context: ../.. |
|
| 5 | 5 | dockerfile: apps/jotts/Dockerfile |
|
| 6 | 6 | ports: |
|
| 7 | - | - "${PORT:-3000}:${PORT:-3000}" |
|
| 7 | + | - "3000:3000" |
|
| 8 | 8 | environment: |
|
| 9 | 9 | - JOTTS_PASSWORD=${JOTTS_PASSWORD:-changeme} |
|
| 10 | 10 | - JOTTS_DB_PATH=/data/jotts.sqlite |
|
| 11 | 11 | - COOKIE_SECURE=false |
|
| 12 | - | - HOST=0.0.0.0 |
|
| 13 | - | - PORT=${PORT:-3000} |
|
| 14 | 12 | volumes: |
|
| 15 | 13 | - jotts-data:/data |
|
| 16 | 14 | restart: unless-stopped |
| 1 | + | <?xml version="1.0" encoding="UTF-8"?> |
|
| 2 | + | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
|
| 3 | + | <plist version="1.0"> |
|
| 4 | + | <dict> |
|
| 5 | + | <!-- |
|
| 6 | + | The colors in this theme are encoded as #RRGGBBAA where: |
|
| 7 | + | * If AA is 00, then RR is an ANSI palette number from 00 to 07. |
|
| 8 | + | * If AA is 01, the terminal's default fg/bg color is used. |
|
| 9 | + | --> |
|
| 10 | + | <key>author</key> |
|
| 11 | + | <string>Template: Chris Kempson, Scheme: Mitchell Kember</string> |
|
| 12 | + | <key>name</key> |
|
| 13 | + | <string>ANSI</string> |
|
| 14 | + | <key>colorSpaceName</key> |
|
| 15 | + | <string>sRGB</string> |
|
| 16 | + | <key>settings</key> |
|
| 17 | + | <array> |
|
| 18 | + | <dict> |
|
| 19 | + | <key>settings</key> |
|
| 20 | + | <dict> |
|
| 21 | + | <key>background</key> |
|
| 22 | + | <string>#00000001</string> |
|
| 23 | + | <key>foreground</key> |
|
| 24 | + | <string>#00000001</string> |
|
| 25 | + | <!-- |
|
| 26 | + | Explicitly set the gutter color since bat falls back to a |
|
| 27 | + | hardcoded DEFAULT_GUTTER_COLOR otherwise. |
|
| 28 | + | --> |
|
| 29 | + | <key>gutter</key> |
|
| 30 | + | <string>#00000001</string> |
|
| 31 | + | <key>gutterForeground</key> |
|
| 32 | + | <string>#00000001</string> |
|
| 33 | + | </dict> |
|
| 34 | + | </dict> |
|
| 35 | + | <dict> |
|
| 36 | + | <key>name</key> |
|
| 37 | + | <string>Comments</string> |
|
| 38 | + | <key>scope</key> |
|
| 39 | + | <string>comment, punctuation.definition.comment</string> |
|
| 40 | + | <key>settings</key> |
|
| 41 | + | <dict> |
|
| 42 | + | <key>foreground</key> |
|
| 43 | + | <string>#02000000</string> |
|
| 44 | + | </dict> |
|
| 45 | + | </dict> |
|
| 46 | + | <dict> |
|
| 47 | + | <key>name</key> |
|
| 48 | + | <string>Keywords</string> |
|
| 49 | + | <key>scope</key> |
|
| 50 | + | <string>keyword</string> |
|
| 51 | + | <key>settings</key> |
|
| 52 | + | <dict> |
|
| 53 | + | <key>foreground</key> |
|
| 54 | + | <string>#05000000</string> |
|
| 55 | + | </dict> |
|
| 56 | + | </dict> |
|
| 57 | + | <dict> |
|
| 58 | + | <key>name</key> |
|
| 59 | + | <string>Functions</string> |
|
| 60 | + | <key>scope</key> |
|
| 61 | + | <string>entity.name.function, meta.require, support.function.any-method</string> |
|
| 62 | + | <key>settings</key> |
|
| 63 | + | <dict> |
|
| 64 | + | <key>foreground</key> |
|
| 65 | + | <string>#04000000</string> |
|
| 66 | + | </dict> |
|
| 67 | + | </dict> |
|
| 68 | + | <dict> |
|
| 69 | + | <key>name</key> |
|
| 70 | + | <string>Labels</string> |
|
| 71 | + | <key>scope</key> |
|
| 72 | + | <string>entity.name.label, variable.parameter</string> |
|
| 73 | + | <key>settings</key> |
|
| 74 | + | <dict> |
|
| 75 | + | <key>foreground</key> |
|
| 76 | + | <string>#06000000</string> |
|
| 77 | + | </dict> |
|
| 78 | + | </dict> |
|
| 79 | + | <dict> |
|
| 80 | + | <key>name</key> |
|
| 81 | + | <string>Classes</string> |
|
| 82 | + | <key>scope</key> |
|
| 83 | + | <string>support.class, entity.name.class, entity.name.type.class, entity.name</string> |
|
| 84 | + | <key>settings</key> |
|
| 85 | + | <dict> |
|
| 86 | + | <key>foreground</key> |
|
| 87 | + | <string>#03000000</string> |
|
| 88 | + | </dict> |
|
| 89 | + | </dict> |
|
| 90 | + | <dict> |
|
| 91 | + | <key>name</key> |
|
| 92 | + | <string>Methods</string> |
|
| 93 | + | <key>scope</key> |
|
| 94 | + | <string>keyword.other.special-method</string> |
|
| 95 | + | <key>settings</key> |
|
| 96 | + | <dict> |
|
| 97 | + | <key>foreground</key> |
|
| 98 | + | <string>#04000000</string> |
|
| 99 | + | </dict> |
|
| 100 | + | </dict> |
|
| 101 | + | <dict> |
|
| 102 | + | <key>name</key> |
|
| 103 | + | <string>Storage</string> |
|
| 104 | + | <key>scope</key> |
|
| 105 | + | <string>storage</string> |
|
| 106 | + | <key>settings</key> |
|
| 107 | + | <dict> |
|
| 108 | + | <key>foreground</key> |
|
| 109 | + | <string>#05000000</string> |
|
| 110 | + | </dict> |
|
| 111 | + | </dict> |
|
| 112 | + | <dict> |
|
| 113 | + | <key>name</key> |
|
| 114 | + | <string>Support</string> |
|
| 115 | + | <key>scope</key> |
|
| 116 | + | <string>support.function</string> |
|
| 117 | + | <key>settings</key> |
|
| 118 | + | <dict> |
|
| 119 | + | <key>foreground</key> |
|
| 120 | + | <string>#06000000</string> |
|
| 121 | + | </dict> |
|
| 122 | + | </dict> |
|
| 123 | + | <dict> |
|
| 124 | + | <key>name</key> |
|
| 125 | + | <string>Strings, Inherited Class</string> |
|
| 126 | + | <key>scope</key> |
|
| 127 | + | <string>string, constant.other.symbol, entity.other.inherited-class</string> |
|
| 128 | + | <key>settings</key> |
|
| 129 | + | <dict> |
|
| 130 | + | <key>foreground</key> |
|
| 131 | + | <string>#02000000</string> |
|
| 132 | + | </dict> |
|
| 133 | + | </dict> |
|
| 134 | + | <dict> |
|
| 135 | + | <key>name</key> |
|
| 136 | + | <string>Integers</string> |
|
| 137 | + | <key>scope</key> |
|
| 138 | + | <string>constant.numeric</string> |
|
| 139 | + | <key>settings</key> |
|
| 140 | + | <dict> |
|
| 141 | + | <key>foreground</key> |
|
| 142 | + | <string>#03000000</string> |
|
| 143 | + | </dict> |
|
| 144 | + | </dict> |
|
| 145 | + | <dict> |
|
| 146 | + | <key>name</key> |
|
| 147 | + | <string>Floats</string> |
|
| 148 | + | <key>scope</key> |
|
| 149 | + | <string>none</string> |
|
| 150 | + | <key>settings</key> |
|
| 151 | + | <dict> |
|
| 152 | + | <key>foreground</key> |
|
| 153 | + | <string>#03000000</string> |
|
| 154 | + | </dict> |
|
| 155 | + | </dict> |
|
| 156 | + | <dict> |
|
| 157 | + | <key>name</key> |
|
| 158 | + | <string>Boolean</string> |
|
| 159 | + | <key>scope</key> |
|
| 160 | + | <string>none</string> |
|
| 161 | + | <key>settings</key> |
|
| 162 | + | <dict> |
|
| 163 | + | <key>foreground</key> |
|
| 164 | + | <string>#03000000</string> |
|
| 165 | + | </dict> |
|
| 166 | + | </dict> |
|
| 167 | + | <dict> |
|
| 168 | + | <key>name</key> |
|
| 169 | + | <string>Constants</string> |
|
| 170 | + | <key>scope</key> |
|
| 171 | + | <string>constant</string> |
|
| 172 | + | <key>settings</key> |
|
| 173 | + | <dict> |
|
| 174 | + | <key>foreground</key> |
|
| 175 | + | <string>#03000000</string> |
|
| 176 | + | </dict> |
|
| 177 | + | </dict> |
|
| 178 | + | <dict> |
|
| 179 | + | <key>name</key> |
|
| 180 | + | <string>Tags</string> |
|
| 181 | + | <key>scope</key> |
|
| 182 | + | <string>entity.name.tag</string> |
|
| 183 | + | <key>settings</key> |
|
| 184 | + | <dict> |
|
| 185 | + | <key>foreground</key> |
|
| 186 | + | <string>#01000000</string> |
|
| 187 | + | </dict> |
|
| 188 | + | </dict> |
|
| 189 | + | <dict> |
|
| 190 | + | <key>name</key> |
|
| 191 | + | <string>Attributes</string> |
|
| 192 | + | <key>scope</key> |
|
| 193 | + | <string>entity.other.attribute-name</string> |
|
| 194 | + | <key>settings</key> |
|
| 195 | + | <dict> |
|
| 196 | + | <key>foreground</key> |
|
| 197 | + | <string>#03000000</string> |
|
| 198 | + | </dict> |
|
| 199 | + | </dict> |
|
| 200 | + | <dict> |
|
| 201 | + | <key>name</key> |
|
| 202 | + | <string>Attribute IDs</string> |
|
| 203 | + | <key>scope</key> |
|
| 204 | + | <string>entity.other.attribute-name.id, punctuation.definition.entity</string> |
|
| 205 | + | <key>settings</key> |
|
| 206 | + | <dict> |
|
| 207 | + | <key>foreground</key> |
|
| 208 | + | <string>#04000000</string> |
|
| 209 | + | </dict> |
|
| 210 | + | </dict> |
|
| 211 | + | <dict> |
|
| 212 | + | <key>name</key> |
|
| 213 | + | <string>Selector</string> |
|
| 214 | + | <key>scope</key> |
|
| 215 | + | <string>meta.selector</string> |
|
| 216 | + | <key>settings</key> |
|
| 217 | + | <dict> |
|
| 218 | + | <key>foreground</key> |
|
| 219 | + | <string>#05000000</string> |
|
| 220 | + | </dict> |
|
| 221 | + | </dict> |
|
| 222 | + | <dict> |
|
| 223 | + | <key>name</key> |
|
| 224 | + | <string>Values</string> |
|
| 225 | + | <key>scope</key> |
|
| 226 | + | <string>none</string> |
|
| 227 | + | <key>settings</key> |
|
| 228 | + | <dict> |
|
| 229 | + | <key>foreground</key> |
|
| 230 | + | <string>#03000000</string> |
|
| 231 | + | </dict> |
|
| 232 | + | </dict> |
|
| 233 | + | <dict> |
|
| 234 | + | <key>name</key> |
|
| 235 | + | <string>Headings</string> |
|
| 236 | + | <key>scope</key> |
|
| 237 | + | <string>markup.heading punctuation.definition.heading, entity.name.section, markup.heading - text.html.markdown, meta.mapping.key string.quoted.double</string> |
|
| 238 | + | <key>settings</key> |
|
| 239 | + | <dict> |
|
| 240 | + | <key>fontStyle</key> |
|
| 241 | + | <string></string> |
|
| 242 | + | <key>foreground</key> |
|
| 243 | + | <string>#04000000</string> |
|
| 244 | + | </dict> |
|
| 245 | + | </dict> |
|
| 246 | + | <dict> |
|
| 247 | + | <key>name</key> |
|
| 248 | + | <string>Units</string> |
|
| 249 | + | <key>scope</key> |
|
| 250 | + | <string>keyword.other.unit</string> |
|
| 251 | + | <key>settings</key> |
|
| 252 | + | <dict> |
|
| 253 | + | <key>foreground</key> |
|
| 254 | + | <string>#03000000</string> |
|
| 255 | + | </dict> |
|
| 256 | + | </dict> |
|
| 257 | + | <dict> |
|
| 258 | + | <key>name</key> |
|
| 259 | + | <string>Bold</string> |
|
| 260 | + | <key>scope</key> |
|
| 261 | + | <string>markup.bold, punctuation.definition.bold</string> |
|
| 262 | + | <key>settings</key> |
|
| 263 | + | <dict> |
|
| 264 | + | <key>fontStyle</key> |
|
| 265 | + | <string>bold</string> |
|
| 266 | + | <key>foreground</key> |
|
| 267 | + | <string>#03000000</string> |
|
| 268 | + | </dict> |
|
| 269 | + | </dict> |
|
| 270 | + | <dict> |
|
| 271 | + | <key>name</key> |
|
| 272 | + | <string>Italic</string> |
|
| 273 | + | <key>scope</key> |
|
| 274 | + | <string>markup.italic, punctuation.definition.italic</string> |
|
| 275 | + | <key>settings</key> |
|
| 276 | + | <dict> |
|
| 277 | + | <key>fontStyle</key> |
|
| 278 | + | <string>italic</string> |
|
| 279 | + | <key>foreground</key> |
|
| 280 | + | <string>#05000000</string> |
|
| 281 | + | </dict> |
|
| 282 | + | </dict> |
|
| 283 | + | <dict> |
|
| 284 | + | <key>name</key> |
|
| 285 | + | <string>Code</string> |
|
| 286 | + | <key>scope</key> |
|
| 287 | + | <string>markup.raw.inline</string> |
|
| 288 | + | <key>settings</key> |
|
| 289 | + | <dict> |
|
| 290 | + | <key>foreground</key> |
|
| 291 | + | <string>#02000000</string> |
|
| 292 | + | </dict> |
|
| 293 | + | </dict> |
|
| 294 | + | <dict> |
|
| 295 | + | <key>name</key> |
|
| 296 | + | <string>Link Text</string> |
|
| 297 | + | <key>scope</key> |
|
| 298 | + | <string>string.other.link, punctuation.definition.string.end.markdown, punctuation.definition.string.begin.markdown</string> |
|
| 299 | + | <key>settings</key> |
|
| 300 | + | <dict> |
|
| 301 | + | <key>foreground</key> |
|
| 302 | + | <string>#01000000</string> |
|
| 303 | + | </dict> |
|
| 304 | + | </dict> |
|
| 305 | + | <dict> |
|
| 306 | + | <key>name</key> |
|
| 307 | + | <string>Link Url</string> |
|
| 308 | + | <key>scope</key> |
|
| 309 | + | <string>meta.link</string> |
|
| 310 | + | <key>settings</key> |
|
| 311 | + | <dict> |
|
| 312 | + | <key>foreground</key> |
|
| 313 | + | <string>#03000000</string> |
|
| 314 | + | </dict> |
|
| 315 | + | </dict> |
|
| 316 | + | <dict> |
|
| 317 | + | <key>name</key> |
|
| 318 | + | <string>Quotes</string> |
|
| 319 | + | <key>scope</key> |
|
| 320 | + | <string>markup.quote</string> |
|
| 321 | + | <key>settings</key> |
|
| 322 | + | <dict> |
|
| 323 | + | <key>foreground</key> |
|
| 324 | + | <string>#03000000</string> |
|
| 325 | + | </dict> |
|
| 326 | + | </dict> |
|
| 327 | + | <dict> |
|
| 328 | + | <key>name</key> |
|
| 329 | + | <string>Inserted</string> |
|
| 330 | + | <key>scope</key> |
|
| 331 | + | <string>markup.inserted</string> |
|
| 332 | + | <key>settings</key> |
|
| 333 | + | <dict> |
|
| 334 | + | <key>foreground</key> |
|
| 335 | + | <string>#02000000</string> |
|
| 336 | + | </dict> |
|
| 337 | + | </dict> |
|
| 338 | + | <dict> |
|
| 339 | + | <key>name</key> |
|
| 340 | + | <string>Deleted</string> |
|
| 341 | + | <key>scope</key> |
|
| 342 | + | <string>markup.deleted</string> |
|
| 343 | + | <key>settings</key> |
|
| 344 | + | <dict> |
|
| 345 | + | <key>foreground</key> |
|
| 346 | + | <string>#01000000</string> |
|
| 347 | + | </dict> |
|
| 348 | + | </dict> |
|
| 349 | + | <dict> |
|
| 350 | + | <key>name</key> |
|
| 351 | + | <string>Changed</string> |
|
| 352 | + | <key>scope</key> |
|
| 353 | + | <string>markup.changed</string> |
|
| 354 | + | <key>settings</key> |
|
| 355 | + | <dict> |
|
| 356 | + | <key>foreground</key> |
|
| 357 | + | <string>#05000000</string> |
|
| 358 | + | </dict> |
|
| 359 | + | </dict> |
|
| 360 | + | <dict> |
|
| 361 | + | <key>name</key> |
|
| 362 | + | <string>Colors</string> |
|
| 363 | + | <key>scope</key> |
|
| 364 | + | <string>constant.other.color</string> |
|
| 365 | + | <key>settings</key> |
|
| 366 | + | <dict> |
|
| 367 | + | <key>foreground</key> |
|
| 368 | + | <string>#06000000</string> |
|
| 369 | + | </dict> |
|
| 370 | + | </dict> |
|
| 371 | + | <dict> |
|
| 372 | + | <key>name</key> |
|
| 373 | + | <string>Regular Expressions</string> |
|
| 374 | + | <key>scope</key> |
|
| 375 | + | <string>string.regexp</string> |
|
| 376 | + | <key>settings</key> |
|
| 377 | + | <dict> |
|
| 378 | + | <key>foreground</key> |
|
| 379 | + | <string>#06000000</string> |
|
| 380 | + | </dict> |
|
| 381 | + | </dict> |
|
| 382 | + | <dict> |
|
| 383 | + | <key>name</key> |
|
| 384 | + | <string>Escape Characters</string> |
|
| 385 | + | <key>scope</key> |
|
| 386 | + | <string>constant.character.escape</string> |
|
| 387 | + | <key>settings</key> |
|
| 388 | + | <dict> |
|
| 389 | + | <key>foreground</key> |
|
| 390 | + | <string>#06000000</string> |
|
| 391 | + | </dict> |
|
| 392 | + | </dict> |
|
| 393 | + | <dict> |
|
| 394 | + | <key>name</key> |
|
| 395 | + | <string>Embedded</string> |
|
| 396 | + | <key>scope</key> |
|
| 397 | + | <string>punctuation.section.embedded, variable.interpolation</string> |
|
| 398 | + | <key>settings</key> |
|
| 399 | + | <dict> |
|
| 400 | + | <key>foreground</key> |
|
| 401 | + | <string>#05000000</string> |
|
| 402 | + | </dict> |
|
| 403 | + | </dict> |
|
| 404 | + | <dict> |
|
| 405 | + | <key>name</key> |
|
| 406 | + | <string>Illegal</string> |
|
| 407 | + | <key>scope</key> |
|
| 408 | + | <string>invalid.illegal</string> |
|
| 409 | + | <key>settings</key> |
|
| 410 | + | <dict> |
|
| 411 | + | <key>background</key> |
|
| 412 | + | <string>#01000000</string> |
|
| 413 | + | </dict> |
|
| 414 | + | </dict> |
|
| 415 | + | <dict> |
|
| 416 | + | <key>name</key> |
|
| 417 | + | <string>Broken</string> |
|
| 418 | + | <key>scope</key> |
|
| 419 | + | <string>invalid.broken</string> |
|
| 420 | + | <key>settings</key> |
|
| 421 | + | <dict> |
|
| 422 | + | <key>background</key> |
|
| 423 | + | <string>#03000000</string> |
|
| 424 | + | </dict> |
|
| 425 | + | </dict> |
|
| 426 | + | </array> |
|
| 427 | + | <key>uuid</key> |
|
| 428 | + | <string>uuid</string> |
|
| 429 | + | </dict> |
|
| 430 | + | </plist> |
| 1 | + | use crate::db::{self, Db, Note}; |
|
| 2 | + | use std::fmt; |
|
| 3 | + | ||
| 4 | + | #[derive(Debug)] |
|
| 5 | + | pub enum BackendError { |
|
| 6 | + | #[allow(dead_code)] |
|
| 7 | + | NotFound, |
|
| 8 | + | Unauthorized(String), |
|
| 9 | + | Network(String), |
|
| 10 | + | Database(String), |
|
| 11 | + | } |
|
| 12 | + | ||
| 13 | + | impl fmt::Display for BackendError { |
|
| 14 | + | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
|
| 15 | + | match self { |
|
| 16 | + | BackendError::NotFound => write!(f, "Not found"), |
|
| 17 | + | BackendError::Unauthorized(msg) => write!(f, "Unauthorized: {}", msg), |
|
| 18 | + | BackendError::Network(msg) => write!(f, "Network error: {}", msg), |
|
| 19 | + | BackendError::Database(msg) => write!(f, "Database error: {}", msg), |
|
| 20 | + | } |
|
| 21 | + | } |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | impl std::error::Error for BackendError {} |
|
| 25 | + | ||
| 26 | + | impl From<db::DbError> for BackendError { |
|
| 27 | + | fn from(e: db::DbError) -> Self { |
|
| 28 | + | BackendError::Database(e.to_string()) |
|
| 29 | + | } |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | pub enum Backend { |
|
| 33 | + | Local { |
|
| 34 | + | db: Db, |
|
| 35 | + | }, |
|
| 36 | + | Remote { |
|
| 37 | + | base_url: String, |
|
| 38 | + | api_key: Option<String>, |
|
| 39 | + | client: reqwest::blocking::Client, |
|
| 40 | + | }, |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | impl Backend { |
|
| 44 | + | pub fn local() -> Self { |
|
| 45 | + | Backend::Local { db: db::init_db() } |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | pub fn remote(base_url: String, api_key: Option<String>) -> Self { |
|
| 49 | + | Backend::Remote { |
|
| 50 | + | base_url, |
|
| 51 | + | api_key, |
|
| 52 | + | client: reqwest::blocking::Client::new(), |
|
| 53 | + | } |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | pub fn list_notes(&self) -> Result<Vec<Note>, BackendError> { |
|
| 57 | + | match self { |
|
| 58 | + | Backend::Local { db } => Ok(db::get_all_notes(db)?), |
|
| 59 | + | Backend::Remote { |
|
| 60 | + | base_url, |
|
| 61 | + | api_key, |
|
| 62 | + | client, |
|
| 63 | + | } => { |
|
| 64 | + | let mut req = client.get(format!("{}/api/notes", base_url)); |
|
| 65 | + | if let Some(key) = api_key { |
|
| 66 | + | req = req.header("x-api-key", key); |
|
| 67 | + | } |
|
| 68 | + | let resp = req |
|
| 69 | + | .send() |
|
| 70 | + | .map_err(|e| BackendError::Network(e.to_string()))?; |
|
| 71 | + | match resp.status().as_u16() { |
|
| 72 | + | 200 => resp |
|
| 73 | + | .json::<Vec<Note>>() |
|
| 74 | + | .map_err(|e| BackendError::Network(e.to_string())), |
|
| 75 | + | 401 => Err(BackendError::Unauthorized("Invalid API key".into())), |
|
| 76 | + | 403 => Err(BackendError::Unauthorized( |
|
| 77 | + | "No API key configured on server".into(), |
|
| 78 | + | )), |
|
| 79 | + | _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))), |
|
| 80 | + | } |
|
| 81 | + | } |
|
| 82 | + | } |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | pub fn create_note(&self, title: &str, content: &str) -> Result<Note, BackendError> { |
|
| 86 | + | match self { |
|
| 87 | + | Backend::Local { db } => Ok(db::create_note(db, title, content)?), |
|
| 88 | + | Backend::Remote { |
|
| 89 | + | base_url, |
|
| 90 | + | api_key, |
|
| 91 | + | client, |
|
| 92 | + | } => { |
|
| 93 | + | let mut req = client |
|
| 94 | + | .post(format!("{}/api/notes", base_url)) |
|
| 95 | + | .json(&serde_json::json!({"title": title, "content": content})); |
|
| 96 | + | if let Some(key) = api_key { |
|
| 97 | + | req = req.header("x-api-key", key); |
|
| 98 | + | } |
|
| 99 | + | let resp = req |
|
| 100 | + | .send() |
|
| 101 | + | .map_err(|e| BackendError::Network(e.to_string()))?; |
|
| 102 | + | match resp.status().as_u16() { |
|
| 103 | + | 201 => resp |
|
| 104 | + | .json::<Note>() |
|
| 105 | + | .map_err(|e| BackendError::Network(e.to_string())), |
|
| 106 | + | 401 => Err(BackendError::Unauthorized("Invalid API key".into())), |
|
| 107 | + | 403 => Err(BackendError::Unauthorized( |
|
| 108 | + | "No API key configured on server".into(), |
|
| 109 | + | )), |
|
| 110 | + | _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))), |
|
| 111 | + | } |
|
| 112 | + | } |
|
| 113 | + | } |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | pub fn update_note( |
|
| 117 | + | &self, |
|
| 118 | + | short_id: &str, |
|
| 119 | + | title: &str, |
|
| 120 | + | content: &str, |
|
| 121 | + | ) -> Result<Option<Note>, BackendError> { |
|
| 122 | + | match self { |
|
| 123 | + | Backend::Local { db } => Ok(db::update_note_by_short_id(db, short_id, title, content)?), |
|
| 124 | + | Backend::Remote { |
|
| 125 | + | base_url, |
|
| 126 | + | api_key, |
|
| 127 | + | client, |
|
| 128 | + | } => { |
|
| 129 | + | let mut req = client |
|
| 130 | + | .put(format!("{}/api/notes/{}", base_url, short_id)) |
|
| 131 | + | .json(&serde_json::json!({"title": title, "content": content})); |
|
| 132 | + | if let Some(key) = api_key { |
|
| 133 | + | req = req.header("x-api-key", key); |
|
| 134 | + | } |
|
| 135 | + | let resp = req |
|
| 136 | + | .send() |
|
| 137 | + | .map_err(|e| BackendError::Network(e.to_string()))?; |
|
| 138 | + | match resp.status().as_u16() { |
|
| 139 | + | 200 => resp |
|
| 140 | + | .json::<Note>() |
|
| 141 | + | .map(Some) |
|
| 142 | + | .map_err(|e| BackendError::Network(e.to_string())), |
|
| 143 | + | 401 => Err(BackendError::Unauthorized("Invalid API key".into())), |
|
| 144 | + | 403 => Err(BackendError::Unauthorized( |
|
| 145 | + | "No API key configured on server".into(), |
|
| 146 | + | )), |
|
| 147 | + | 404 => Ok(None), |
|
| 148 | + | _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))), |
|
| 149 | + | } |
|
| 150 | + | } |
|
| 151 | + | } |
|
| 152 | + | } |
|
| 153 | + | ||
| 154 | + | pub fn delete_note(&self, short_id: &str) -> Result<bool, BackendError> { |
|
| 155 | + | match self { |
|
| 156 | + | Backend::Local { db } => Ok(db::delete_note_by_short_id(db, short_id)?), |
|
| 157 | + | Backend::Remote { |
|
| 158 | + | base_url, |
|
| 159 | + | api_key, |
|
| 160 | + | client, |
|
| 161 | + | } => { |
|
| 162 | + | let mut req = client.delete(format!("{}/api/notes/{}", base_url, short_id)); |
|
| 163 | + | if let Some(key) = api_key { |
|
| 164 | + | req = req.header("x-api-key", key); |
|
| 165 | + | } |
|
| 166 | + | let resp = req |
|
| 167 | + | .send() |
|
| 168 | + | .map_err(|e| BackendError::Network(e.to_string()))?; |
|
| 169 | + | match resp.status().as_u16() { |
|
| 170 | + | 200 | 204 => Ok(true), |
|
| 171 | + | 401 => Err(BackendError::Unauthorized("Invalid API key".into())), |
|
| 172 | + | 403 => Err(BackendError::Unauthorized( |
|
| 173 | + | "No API key configured on server".into(), |
|
| 174 | + | )), |
|
| 175 | + | 404 => Ok(false), |
|
| 176 | + | _ => Err(BackendError::Network(format!("HTTP {}", resp.status()))), |
|
| 177 | + | } |
|
| 178 | + | } |
|
| 179 | + | } |
|
| 180 | + | } |
|
| 181 | + | } |
|
| 182 | + |
| 1 | + | use serde::{Deserialize, Serialize}; |
|
| 2 | + | use std::path::PathBuf; |
|
| 3 | + | ||
| 4 | + | #[derive(Debug, Default, Serialize, Deserialize)] |
|
| 5 | + | pub struct Config { |
|
| 6 | + | pub remote_url: Option<String>, |
|
| 7 | + | pub api_key: Option<String>, |
|
| 8 | + | } |
|
| 9 | + | ||
| 10 | + | pub fn config_path() -> PathBuf { |
|
| 11 | + | let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); |
|
| 12 | + | PathBuf::from(home).join(".config/jotts/config.toml") |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | pub fn load_config() -> Config { |
|
| 16 | + | let path = config_path(); |
|
| 17 | + | match std::fs::read_to_string(&path) { |
|
| 18 | + | Ok(contents) => toml::from_str(&contents).unwrap_or_default(), |
|
| 19 | + | Err(_) => Config::default(), |
|
| 20 | + | } |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | pub fn save_config(config: &Config) -> Result<(), Box<dyn std::error::Error>> { |
|
| 24 | + | let path = config_path(); |
|
| 25 | + | if let Some(parent) = path.parent() { |
|
| 26 | + | std::fs::create_dir_all(parent)?; |
|
| 27 | + | } |
|
| 28 | + | let contents = toml::to_string_pretty(config)?; |
|
| 29 | + | std::fs::write(&path, contents)?; |
|
| 30 | + | Ok(()) |
|
| 31 | + | } |
| 1 | + | use ratatui::style::{Color, Style}; |
|
| 2 | + | use ratatui::text::{Line, Span, Text}; |
|
| 3 | + | use std::io::Cursor; |
|
| 4 | + | use syntect::easy::HighlightLines; |
|
| 5 | + | use syntect::highlighting::Theme; |
|
| 6 | + | use syntect::parsing::SyntaxSet; |
|
| 7 | + | use syntect::util::LinesWithEndings; |
|
| 8 | + | ||
| 9 | + | pub struct Highlighter { |
|
| 10 | + | syntax_set: SyntaxSet, |
|
| 11 | + | theme: Theme, |
|
| 12 | + | } |
|
| 13 | + | ||
| 14 | + | impl Highlighter { |
|
| 15 | + | pub fn new() -> Self { |
|
| 16 | + | let theme_data = include_bytes!("ansi.tmTheme"); |
|
| 17 | + | let theme = |
|
| 18 | + | syntect::highlighting::ThemeSet::load_from_reader(&mut Cursor::new(&theme_data[..])) |
|
| 19 | + | .expect("failed to load ansi theme"); |
|
| 20 | + | Self { |
|
| 21 | + | syntax_set: SyntaxSet::load_defaults_newlines(), |
|
| 22 | + | theme, |
|
| 23 | + | } |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | pub fn highlight_markdown(&self, content: &str) -> Text<'static> { |
|
| 27 | + | let syntax = self |
|
| 28 | + | .syntax_set |
|
| 29 | + | .find_syntax_by_extension("md") |
|
| 30 | + | .or_else(|| self.syntax_set.find_syntax_by_name("Markdown")) |
|
| 31 | + | .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text()); |
|
| 32 | + | let mut h = HighlightLines::new(syntax, &self.theme); |
|
| 33 | + | ||
| 34 | + | let lines: Vec<Line<'static>> = LinesWithEndings::from(content) |
|
| 35 | + | .map(|line| { |
|
| 36 | + | let ranges = h |
|
| 37 | + | .highlight_line(line, &self.syntax_set) |
|
| 38 | + | .unwrap_or_default(); |
|
| 39 | + | let spans: Vec<Span<'static>> = ranges |
|
| 40 | + | .into_iter() |
|
| 41 | + | .map(|(style, text)| { |
|
| 42 | + | let color = to_ratatui_color(style.foreground); |
|
| 43 | + | Span::styled(text.to_owned(), Style::default().fg(color)) |
|
| 44 | + | }) |
|
| 45 | + | .collect(); |
|
| 46 | + | Line::from(spans) |
|
| 47 | + | }) |
|
| 48 | + | .collect(); |
|
| 49 | + | ||
| 50 | + | Text::from(lines) |
|
| 51 | + | } |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | fn to_ratatui_color(color: syntect::highlighting::Color) -> Color { |
|
| 55 | + | if color.a == 0 { |
|
| 56 | + | Color::Indexed(color.r) |
|
| 57 | + | } else { |
|
| 58 | + | Color::Reset |
|
| 59 | + | } |
|
| 60 | + | } |
| 1 | + | pub mod auth; |
|
| 2 | + | pub mod backend; |
|
| 3 | + | pub mod config; |
|
| 4 | + | pub mod db; |
|
| 5 | + | pub mod highlight; |
|
| 6 | + | pub mod server; |
|
| 7 | + | pub mod tui; |
| 1 | - | mod auth; |
|
| 2 | - | mod db; |
|
| 3 | - | mod server; |
|
| 1 | + | use clap::{Parser, Subcommand}; |
|
| 2 | + | use std::path::PathBuf; |
|
| 3 | + | ||
| 4 | + | #[derive(Parser)] |
|
| 5 | + | #[command(name = "jotts", about = "Markdown notes — TUI, server, and CLI")] |
|
| 6 | + | struct Cli { |
|
| 7 | + | /// Remote server URL (e.g. http://localhost:3000) |
|
| 8 | + | #[arg(short, long, env = "JOTTS_REMOTE_URL")] |
|
| 9 | + | remote: Option<String>, |
|
| 10 | + | ||
| 11 | + | /// API key for authenticated operations |
|
| 12 | + | #[arg(short = 'k', long, env = "JOTTS_API_KEY")] |
|
| 13 | + | api_key: Option<String>, |
|
| 14 | + | ||
| 15 | + | /// File path to create a note from |
|
| 16 | + | #[arg(value_name = "FILE")] |
|
| 17 | + | file: Option<PathBuf>, |
|
| 18 | + | ||
| 19 | + | #[command(subcommand)] |
|
| 20 | + | command: Option<Commands>, |
|
| 21 | + | } |
|
| 4 | 22 | ||
| 5 | - | #[tokio::main] |
|
| 6 | - | async fn main() { |
|
| 23 | + | #[derive(Subcommand)] |
|
| 24 | + | enum Commands { |
|
| 25 | + | /// Start the web server |
|
| 26 | + | Server { |
|
| 27 | + | /// Port to listen on |
|
| 28 | + | #[arg(short, long, default_value_t = 3000)] |
|
| 29 | + | port: u16, |
|
| 30 | + | ||
| 31 | + | /// Host to bind to |
|
| 32 | + | #[arg(long, default_value = "127.0.0.1")] |
|
| 33 | + | host: String, |
|
| 34 | + | }, |
|
| 35 | + | /// Launch the interactive TUI |
|
| 36 | + | Tui { |
|
| 37 | + | #[arg(short, long, env = "JOTTS_REMOTE_URL")] |
|
| 38 | + | remote: Option<String>, |
|
| 39 | + | ||
| 40 | + | #[arg(short = 'k', long, env = "JOTTS_API_KEY")] |
|
| 41 | + | api_key: Option<String>, |
|
| 42 | + | }, |
|
| 43 | + | /// Save remote URL and API key to config file |
|
| 44 | + | Auth, |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | fn main() -> Result<(), Box<dyn std::error::Error>> { |
|
| 48 | + | dotenvy::dotenv().ok(); |
|
| 7 | 49 | tracing_subscriber::fmt::init(); |
|
| 8 | - | let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()); |
|
| 9 | - | let port: u16 = std::env::var("PORT") |
|
| 10 | - | .ok() |
|
| 11 | - | .and_then(|v| v.parse().ok()) |
|
| 12 | - | .unwrap_or(3000); |
|
| 13 | - | server::run(host, port).await; |
|
| 50 | + | ||
| 51 | + | let cli = Cli::parse(); |
|
| 52 | + | ||
| 53 | + | match cli.command { |
|
| 54 | + | Some(Commands::Server { port, host }) => { |
|
| 55 | + | let rt = tokio::runtime::Runtime::new()?; |
|
| 56 | + | rt.block_on(jotts::server::run(host, port)); |
|
| 57 | + | } |
|
| 58 | + | Some(Commands::Tui { remote, api_key }) => { |
|
| 59 | + | jotts::tui::run_interactive(remote, api_key)?; |
|
| 60 | + | } |
|
| 61 | + | Some(Commands::Auth) => { |
|
| 62 | + | jotts::tui::run_auth()?; |
|
| 63 | + | } |
|
| 64 | + | None => { |
|
| 65 | + | if let Some(file) = cli.file { |
|
| 66 | + | jotts::tui::run_file_upload(cli.remote, cli.api_key, file)?; |
|
| 67 | + | } else { |
|
| 68 | + | jotts::tui::run_interactive(cli.remote, cli.api_key)?; |
|
| 69 | + | } |
|
| 70 | + | } |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | Ok(()) |
|
| 14 | 74 | } |
| 1 | 1 | use askama::Template; |
|
| 2 | 2 | use askama_web::WebTemplate; |
|
| 3 | 3 | use axum::{ |
|
| 4 | - | extract::{Form, Path, Query, State}, |
|
| 4 | + | extract::{Form, Path, Query, Request, State}, |
|
| 5 | 5 | http::{HeaderValue, StatusCode}, |
|
| 6 | + | middleware::{self, Next}, |
|
| 6 | 7 | response::{Html, IntoResponse, Redirect, Response}, |
|
| 7 | 8 | routing::{get, post}, |
|
| 8 | - | Router, |
|
| 9 | + | Json, Router, |
|
| 9 | 10 | }; |
|
| 10 | 11 | use pulldown_cmark::{Options, Parser, html}; use rust_embed::Embed; |
|
| 11 | 12 | use std::sync::Arc; |
|
| 17 | 18 | pub struct AppState { |
|
| 18 | 19 | pub db: Db, |
|
| 19 | 20 | pub app_password: String, |
|
| 21 | + | pub api_key: Option<String>, |
|
| 20 | 22 | pub cookie_secure: bool, |
|
| 21 | 23 | } |
|
| 22 | 24 | ||
| 78 | 80 | struct NoteForm { |
|
| 79 | 81 | title: String, |
|
| 80 | 82 | content: String, |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | #[derive(serde::Deserialize)] |
|
| 86 | + | struct NoteJson { |
|
| 87 | + | title: String, |
|
| 88 | + | content: String, |
|
| 89 | + | } |
|
| 90 | + | ||
| 91 | + | // --- API key middleware --- |
|
| 92 | + | ||
| 93 | + | async fn api_key_guard( |
|
| 94 | + | State(state): State<Arc<AppState>>, |
|
| 95 | + | req: Request, |
|
| 96 | + | next: Next, |
|
| 97 | + | ) -> Response { |
|
| 98 | + | let expected = match &state.api_key { |
|
| 99 | + | Some(k) if !k.is_empty() => k.clone(), |
|
| 100 | + | _ => { |
|
| 101 | + | return (StatusCode::FORBIDDEN, "API key not configured on server").into_response(); |
|
| 102 | + | } |
|
| 103 | + | }; |
|
| 104 | + | ||
| 105 | + | let provided = req |
|
| 106 | + | .headers() |
|
| 107 | + | .get("x-api-key") |
|
| 108 | + | .and_then(|v| v.to_str().ok()) |
|
| 109 | + | .unwrap_or(""); |
|
| 110 | + | ||
| 111 | + | if !andromeda_auth::verify_api_key(provided, &expected) { |
|
| 112 | + | return (StatusCode::UNAUTHORIZED, "Invalid API key").into_response(); |
|
| 113 | + | } |
|
| 114 | + | ||
| 115 | + | next.run(req).await |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | // --- JSON API handlers --- |
|
| 119 | + | ||
| 120 | + | async fn api_list_notes(State(state): State<Arc<AppState>>) -> Response { |
|
| 121 | + | match db::get_all_notes(&state.db) { |
|
| 122 | + | Ok(notes) => Json(notes).into_response(), |
|
| 123 | + | Err(e) => { |
|
| 124 | + | tracing::error!("Failed to list notes: {}", e); |
|
| 125 | + | (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response() |
|
| 126 | + | } |
|
| 127 | + | } |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | async fn api_get_note( |
|
| 131 | + | State(state): State<Arc<AppState>>, |
|
| 132 | + | Path(short_id): Path<String>, |
|
| 133 | + | ) -> Response { |
|
| 134 | + | match db::get_note_by_short_id(&state.db, &short_id) { |
|
| 135 | + | Ok(Some(note)) => Json(note).into_response(), |
|
| 136 | + | Ok(None) => StatusCode::NOT_FOUND.into_response(), |
|
| 137 | + | Err(e) => { |
|
| 138 | + | tracing::error!("Failed to get note: {}", e); |
|
| 139 | + | (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response() |
|
| 140 | + | } |
|
| 141 | + | } |
|
| 142 | + | } |
|
| 143 | + | ||
| 144 | + | async fn api_create_note( |
|
| 145 | + | State(state): State<Arc<AppState>>, |
|
| 146 | + | Json(body): Json<NoteJson>, |
|
| 147 | + | ) -> Response { |
|
| 148 | + | let title = body.title.trim(); |
|
| 149 | + | if title.is_empty() { |
|
| 150 | + | return (StatusCode::BAD_REQUEST, "title required").into_response(); |
|
| 151 | + | } |
|
| 152 | + | match db::create_note(&state.db, title, &body.content) { |
|
| 153 | + | Ok(note) => (StatusCode::CREATED, Json(note)).into_response(), |
|
| 154 | + | Err(e) => { |
|
| 155 | + | tracing::error!("Failed to create note: {}", e); |
|
| 156 | + | (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response() |
|
| 157 | + | } |
|
| 158 | + | } |
|
| 159 | + | } |
|
| 160 | + | ||
| 161 | + | async fn api_update_note( |
|
| 162 | + | State(state): State<Arc<AppState>>, |
|
| 163 | + | Path(short_id): Path<String>, |
|
| 164 | + | Json(body): Json<NoteJson>, |
|
| 165 | + | ) -> Response { |
|
| 166 | + | let title = body.title.trim(); |
|
| 167 | + | if title.is_empty() { |
|
| 168 | + | return (StatusCode::BAD_REQUEST, "title required").into_response(); |
|
| 169 | + | } |
|
| 170 | + | match db::update_note_by_short_id(&state.db, &short_id, title, &body.content) { |
|
| 171 | + | Ok(Some(note)) => Json(note).into_response(), |
|
| 172 | + | Ok(None) => StatusCode::NOT_FOUND.into_response(), |
|
| 173 | + | Err(e) => { |
|
| 174 | + | tracing::error!("Failed to update note: {}", e); |
|
| 175 | + | (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response() |
|
| 176 | + | } |
|
| 177 | + | } |
|
| 178 | + | } |
|
| 179 | + | ||
| 180 | + | async fn api_delete_note( |
|
| 181 | + | State(state): State<Arc<AppState>>, |
|
| 182 | + | Path(short_id): Path<String>, |
|
| 183 | + | ) -> Response { |
|
| 184 | + | match db::delete_note_by_short_id(&state.db, &short_id) { |
|
| 185 | + | Ok(true) => StatusCode::NO_CONTENT.into_response(), |
|
| 186 | + | Ok(false) => StatusCode::NOT_FOUND.into_response(), |
|
| 187 | + | Err(e) => { |
|
| 188 | + | tracing::error!("Failed to delete note: {}", e); |
|
| 189 | + | (StatusCode::INTERNAL_SERVER_ERROR, "Server error").into_response() |
|
| 190 | + | } |
|
| 191 | + | } |
|
| 81 | 192 | } |
|
| 82 | 193 | ||
| 83 | 194 | // --- Static file handlers --- |
|
| 359 | 470 | .map(|v| v == "true") |
|
| 360 | 471 | .unwrap_or(false); |
|
| 361 | 472 | ||
| 473 | + | let api_key = std::env::var("JOTTS_API_KEY") |
|
| 474 | + | .ok() |
|
| 475 | + | .filter(|k| !k.is_empty()); |
|
| 476 | + | if api_key.is_none() { |
|
| 477 | + | tracing::info!("JOTTS_API_KEY not set, /api/* will return 403"); |
|
| 478 | + | } |
|
| 479 | + | ||
| 362 | 480 | let state = Arc::new(AppState { |
|
| 363 | 481 | db, |
|
| 364 | 482 | app_password, |
|
| 483 | + | api_key, |
|
| 365 | 484 | cookie_secure, |
|
| 366 | 485 | }); |
|
| 367 | 486 | ||
| 487 | + | let api_router = Router::new() |
|
| 488 | + | .route("/api/notes", get(api_list_notes).post(api_create_note)) |
|
| 489 | + | .route( |
|
| 490 | + | "/api/notes/{short_id}", |
|
| 491 | + | get(api_get_note) |
|
| 492 | + | .put(api_update_note) |
|
| 493 | + | .delete(api_delete_note), |
|
| 494 | + | ) |
|
| 495 | + | .route_layer(middleware::from_fn_with_state( |
|
| 496 | + | state.clone(), |
|
| 497 | + | api_key_guard, |
|
| 498 | + | )); |
|
| 499 | + | ||
| 368 | 500 | let app = Router::new() |
|
| 369 | 501 | // Public routes |
|
| 370 | 502 | .route("/login", get(get_login).post(post_login)) |
|
| 379 | 511 | .route("/notes/{short_id}/delete", post(post_delete_note)) |
|
| 380 | 512 | // Static assets |
|
| 381 | 513 | .route("/static/{*path}", get(serve_static)) |
|
| 514 | + | .merge(api_router) |
|
| 382 | 515 | .with_state(state); |
|
| 383 | 516 | ||
| 384 | 517 | let addr = format!("{}:{}", host, port); |
|
| 1 | + | use crate::backend::Backend; |
|
| 2 | + | use crate::config; |
|
| 3 | + | use crate::db::Note; |
|
| 4 | + | use crate::highlight::Highlighter; |
|
| 5 | + | use arboard::Clipboard; |
|
| 6 | + | use crossterm::event::{self, Event, KeyCode, KeyModifiers}; |
|
| 7 | + | use ratatui::{ |
|
| 8 | + | DefaultTerminal, |
|
| 9 | + | layout::{Alignment, Constraint, Layout}, |
|
| 10 | + | style::{Color, Modifier, Style}, |
|
| 11 | + | text::{Line, Span, Text}, |
|
| 12 | + | widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Widget, Wrap}, |
|
| 13 | + | }; |
|
| 14 | + | use std::path::PathBuf; |
|
| 15 | + | use std::time::{Duration, Instant}; |
|
| 16 | + | ||
| 17 | + | fn edit_in_external_editor( |
|
| 18 | + | terminal: &mut DefaultTerminal, |
|
| 19 | + | app: &mut App, |
|
| 20 | + | backend: &Backend, |
|
| 21 | + | ) -> Result<(), Box<dyn std::error::Error>> { |
|
| 22 | + | let (short_id, title, content) = match app.selected_note() { |
|
| 23 | + | Some(n) => (n.short_id.clone(), n.title.clone(), n.content.clone()), |
|
| 24 | + | None => return Ok(()), |
|
| 25 | + | }; |
|
| 26 | + | ||
| 27 | + | let editor = match std::env::var("EDITOR") { |
|
| 28 | + | Ok(e) if !e.trim().is_empty() => e, |
|
| 29 | + | _ => { |
|
| 30 | + | app.status_message = Some(("EDITOR env not set".to_string(), Instant::now())); |
|
| 31 | + | return Ok(()); |
|
| 32 | + | } |
|
| 33 | + | }; |
|
| 34 | + | ||
| 35 | + | let mut path = std::env::temp_dir(); |
|
| 36 | + | path.push(format!("jotts-{}.md", short_id)); |
|
| 37 | + | std::fs::write(&path, &content)?; |
|
| 38 | + | ||
| 39 | + | ratatui::restore(); |
|
| 40 | + | ||
| 41 | + | let status = std::process::Command::new(&editor).arg(&path).status(); |
|
| 42 | + | ||
| 43 | + | *terminal = ratatui::init(); |
|
| 44 | + | terminal.clear()?; |
|
| 45 | + | ||
| 46 | + | match status { |
|
| 47 | + | Ok(s) if s.success() => { |
|
| 48 | + | let new_content = std::fs::read_to_string(&path)?; |
|
| 49 | + | let _ = std::fs::remove_file(&path); |
|
| 50 | + | if new_content == content { |
|
| 51 | + | app.status_message = Some(("No changes".to_string(), Instant::now())); |
|
| 52 | + | return Ok(()); |
|
| 53 | + | } |
|
| 54 | + | match backend.update_note(&short_id, &title, &new_content) { |
|
| 55 | + | Ok(Some(updated)) => { |
|
| 56 | + | if let Some(pos) = app.notes.iter().position(|n| n.short_id == short_id) { |
|
| 57 | + | app.notes[pos] = updated; |
|
| 58 | + | } |
|
| 59 | + | app.status_message = Some(("Updated!".to_string(), Instant::now())); |
|
| 60 | + | } |
|
| 61 | + | Ok(None) => { |
|
| 62 | + | app.status_message = Some(("Note not found".to_string(), Instant::now())); |
|
| 63 | + | } |
|
| 64 | + | Err(e) => { |
|
| 65 | + | app.status_message = Some((e.to_string(), Instant::now())); |
|
| 66 | + | } |
|
| 67 | + | } |
|
| 68 | + | } |
|
| 69 | + | Ok(_) => { |
|
| 70 | + | let _ = std::fs::remove_file(&path); |
|
| 71 | + | app.status_message = Some(("Editor exited non-zero".to_string(), Instant::now())); |
|
| 72 | + | } |
|
| 73 | + | Err(e) => { |
|
| 74 | + | let _ = std::fs::remove_file(&path); |
|
| 75 | + | app.status_message = |
|
| 76 | + | Some((format!("Failed to launch editor: {}", e), Instant::now())); |
|
| 77 | + | } |
|
| 78 | + | } |
|
| 79 | + | Ok(()) |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | enum Focus { |
|
| 83 | + | List, |
|
| 84 | + | Content, |
|
| 85 | + | CreateTitle, |
|
| 86 | + | CreateContent, |
|
| 87 | + | EditTitle, |
|
| 88 | + | EditContent, |
|
| 89 | + | Search, |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | struct App { |
|
| 93 | + | notes: Vec<Note>, |
|
| 94 | + | list_state: ListState, |
|
| 95 | + | should_quit: bool, |
|
| 96 | + | status_message: Option<(String, Instant)>, |
|
| 97 | + | focus: Focus, |
|
| 98 | + | content_scroll: u16, |
|
| 99 | + | show_help: bool, |
|
| 100 | + | confirm_delete: bool, |
|
| 101 | + | highlighter: Highlighter, |
|
| 102 | + | edit_title: String, |
|
| 103 | + | edit_content: String, |
|
| 104 | + | edit_short_id: Option<String>, |
|
| 105 | + | search_query: String, |
|
| 106 | + | filtered_indices: Option<Vec<usize>>, |
|
| 107 | + | is_remote: bool, |
|
| 108 | + | remote_url: Option<String>, |
|
| 109 | + | wrap_content: bool, |
|
| 110 | + | edit_scroll: u16, |
|
| 111 | + | } |
|
| 112 | + | ||
| 113 | + | impl App { |
|
| 114 | + | fn new(notes: Vec<Note>, is_remote: bool, remote_url: Option<String>) -> Self { |
|
| 115 | + | let mut list_state = ListState::default(); |
|
| 116 | + | if !notes.is_empty() { |
|
| 117 | + | list_state.select(Some(0)); |
|
| 118 | + | } |
|
| 119 | + | Self { |
|
| 120 | + | notes, |
|
| 121 | + | list_state, |
|
| 122 | + | should_quit: false, |
|
| 123 | + | status_message: None, |
|
| 124 | + | focus: Focus::List, |
|
| 125 | + | content_scroll: 0, |
|
| 126 | + | show_help: false, |
|
| 127 | + | confirm_delete: false, |
|
| 128 | + | highlighter: Highlighter::new(), |
|
| 129 | + | edit_title: String::new(), |
|
| 130 | + | edit_content: String::new(), |
|
| 131 | + | edit_short_id: None, |
|
| 132 | + | search_query: String::new(), |
|
| 133 | + | filtered_indices: None, |
|
| 134 | + | is_remote, |
|
| 135 | + | remote_url, |
|
| 136 | + | wrap_content: true, |
|
| 137 | + | edit_scroll: 0, |
|
| 138 | + | } |
|
| 139 | + | } |
|
| 140 | + | ||
| 141 | + | fn selected_note(&self) -> Option<&Note> { |
|
| 142 | + | self.list_state.selected().and_then(|i| { |
|
| 143 | + | if let Some(indices) = &self.filtered_indices { |
|
| 144 | + | indices.get(i).and_then(|&real| self.notes.get(real)) |
|
| 145 | + | } else { |
|
| 146 | + | self.notes.get(i) |
|
| 147 | + | } |
|
| 148 | + | }) |
|
| 149 | + | } |
|
| 150 | + | ||
| 151 | + | fn visible_count(&self) -> usize { |
|
| 152 | + | match &self.filtered_indices { |
|
| 153 | + | Some(indices) => indices.len(), |
|
| 154 | + | None => self.notes.len(), |
|
| 155 | + | } |
|
| 156 | + | } |
|
| 157 | + | ||
| 158 | + | fn move_up(&mut self) { |
|
| 159 | + | let count = self.visible_count(); |
|
| 160 | + | if count == 0 { |
|
| 161 | + | return; |
|
| 162 | + | } |
|
| 163 | + | let i = match self.list_state.selected() { |
|
| 164 | + | Some(i) if i > 0 => i - 1, |
|
| 165 | + | Some(_) => count - 1, |
|
| 166 | + | None => 0, |
|
| 167 | + | }; |
|
| 168 | + | self.list_state.select(Some(i)); |
|
| 169 | + | self.content_scroll = 0; |
|
| 170 | + | } |
|
| 171 | + | ||
| 172 | + | fn move_down(&mut self) { |
|
| 173 | + | let count = self.visible_count(); |
|
| 174 | + | if count == 0 { |
|
| 175 | + | return; |
|
| 176 | + | } |
|
| 177 | + | let i = match self.list_state.selected() { |
|
| 178 | + | Some(i) if i < count - 1 => i + 1, |
|
| 179 | + | Some(_) => 0, |
|
| 180 | + | None => 0, |
|
| 181 | + | }; |
|
| 182 | + | self.list_state.select(Some(i)); |
|
| 183 | + | self.content_scroll = 0; |
|
| 184 | + | } |
|
| 185 | + | ||
| 186 | + | fn scroll_up(&mut self) { |
|
| 187 | + | self.content_scroll = self.content_scroll.saturating_sub(1); |
|
| 188 | + | } |
|
| 189 | + | ||
| 190 | + | fn scroll_down(&mut self, max_lines: u16) { |
|
| 191 | + | if self.content_scroll < max_lines { |
|
| 192 | + | self.content_scroll += 1; |
|
| 193 | + | } |
|
| 194 | + | } |
|
| 195 | + | ||
| 196 | + | fn copy_selected(&mut self) { |
|
| 197 | + | if let Some(note) = self.selected_note() { |
|
| 198 | + | if let Ok(mut clipboard) = Clipboard::new() { |
|
| 199 | + | let _ = clipboard.set_text(¬e.content); |
|
| 200 | + | self.status_message = Some(("Copied!".to_string(), Instant::now())); |
|
| 201 | + | } |
|
| 202 | + | } |
|
| 203 | + | } |
|
| 204 | + | ||
| 205 | + | fn copy_link(&mut self) { |
|
| 206 | + | match &self.remote_url { |
|
| 207 | + | Some(url) => { |
|
| 208 | + | if let Some(note) = self.selected_note() { |
|
| 209 | + | let link = format!("{}/notes/{}", url.trim_end_matches('/'), note.short_id); |
|
| 210 | + | if let Ok(mut clipboard) = Clipboard::new() { |
|
| 211 | + | let _ = clipboard.set_text(&link); |
|
| 212 | + | self.status_message = |
|
| 213 | + | Some(("Link copied!".to_string(), Instant::now())); |
|
| 214 | + | } |
|
| 215 | + | } |
|
| 216 | + | } |
|
| 217 | + | None => { |
|
| 218 | + | self.status_message = |
|
| 219 | + | Some(("No remote URL configured".to_string(), Instant::now())); |
|
| 220 | + | } |
|
| 221 | + | } |
|
| 222 | + | } |
|
| 223 | + | ||
| 224 | + | fn open_in_browser(&mut self) { |
|
| 225 | + | match &self.remote_url { |
|
| 226 | + | Some(url) => { |
|
| 227 | + | if let Some(note) = self.selected_note() { |
|
| 228 | + | let link = format!("{}/notes/{}", url.trim_end_matches('/'), note.short_id); |
|
| 229 | + | if let Err(e) = open::that(&link) { |
|
| 230 | + | self.status_message = |
|
| 231 | + | Some((format!("Failed to open browser: {}", e), Instant::now())); |
|
| 232 | + | } else { |
|
| 233 | + | self.status_message = |
|
| 234 | + | Some(("Opened in browser!".to_string(), Instant::now())); |
|
| 235 | + | } |
|
| 236 | + | } |
|
| 237 | + | } |
|
| 238 | + | None => { |
|
| 239 | + | self.status_message = |
|
| 240 | + | Some(("No remote URL configured".to_string(), Instant::now())); |
|
| 241 | + | } |
|
| 242 | + | } |
|
| 243 | + | } |
|
| 244 | + | ||
| 245 | + | fn delete_selected(&mut self, backend: &Backend) { |
|
| 246 | + | if let Some(selected_index) = self.list_state.selected() { |
|
| 247 | + | let real_index = if let Some(indices) = &self.filtered_indices { |
|
| 248 | + | match indices.get(selected_index) { |
|
| 249 | + | Some(&ri) => ri, |
|
| 250 | + | None => return, |
|
| 251 | + | } |
|
| 252 | + | } else { |
|
| 253 | + | selected_index |
|
| 254 | + | }; |
|
| 255 | + | if let Some(note) = self.notes.get(real_index) { |
|
| 256 | + | let short_id = note.short_id.clone(); |
|
| 257 | + | match backend.delete_note(&short_id) { |
|
| 258 | + | Ok(true) => { |
|
| 259 | + | self.notes.remove(real_index); |
|
| 260 | + | if self.filtered_indices.is_some() { |
|
| 261 | + | self.update_search_filter(); |
|
| 262 | + | } |
|
| 263 | + | let count = self.visible_count(); |
|
| 264 | + | if count == 0 { |
|
| 265 | + | self.list_state.select(None); |
|
| 266 | + | } else if selected_index >= count { |
|
| 267 | + | self.list_state.select(Some(count - 1)); |
|
| 268 | + | } else { |
|
| 269 | + | self.list_state.select(Some(selected_index)); |
|
| 270 | + | } |
|
| 271 | + | self.status_message = Some(("Deleted!".to_string(), Instant::now())); |
|
| 272 | + | } |
|
| 273 | + | Ok(false) => { |
|
| 274 | + | self.status_message = |
|
| 275 | + | Some(("Note not found".to_string(), Instant::now())); |
|
| 276 | + | } |
|
| 277 | + | Err(e) => { |
|
| 278 | + | self.status_message = Some((e.to_string(), Instant::now())); |
|
| 279 | + | } |
|
| 280 | + | } |
|
| 281 | + | } |
|
| 282 | + | } |
|
| 283 | + | } |
|
| 284 | + | ||
| 285 | + | fn refresh(&mut self, backend: &Backend) { |
|
| 286 | + | match backend.list_notes() { |
|
| 287 | + | Ok(notes) => { |
|
| 288 | + | self.notes = notes; |
|
| 289 | + | self.filtered_indices = None; |
|
| 290 | + | self.search_query.clear(); |
|
| 291 | + | if self.notes.is_empty() { |
|
| 292 | + | self.list_state.select(None); |
|
| 293 | + | } else { |
|
| 294 | + | let idx = self.list_state.selected().unwrap_or(0); |
|
| 295 | + | if idx >= self.notes.len() { |
|
| 296 | + | self.list_state.select(Some(self.notes.len() - 1)); |
|
| 297 | + | } |
|
| 298 | + | } |
|
| 299 | + | self.status_message = Some(("Refreshed!".to_string(), Instant::now())); |
|
| 300 | + | } |
|
| 301 | + | Err(e) => { |
|
| 302 | + | self.status_message = Some((e.to_string(), Instant::now())); |
|
| 303 | + | } |
|
| 304 | + | } |
|
| 305 | + | } |
|
| 306 | + | ||
| 307 | + | fn cursor_position_wrapped(&self, width: u16) -> (u16, u16) { |
|
| 308 | + | let w = width as usize; |
|
| 309 | + | if w == 0 { |
|
| 310 | + | return (0, 0); |
|
| 311 | + | } |
|
| 312 | + | let text = &self.edit_content; |
|
| 313 | + | let mut visual_row: usize = 0; |
|
| 314 | + | let lines: Vec<&str> = if text.is_empty() { |
|
| 315 | + | vec![""] |
|
| 316 | + | } else { |
|
| 317 | + | text.split('\n').collect() |
|
| 318 | + | }; |
|
| 319 | + | let last_idx = lines.len() - 1; |
|
| 320 | + | for (i, line) in lines.iter().enumerate() { |
|
| 321 | + | let line_len = line.len(); |
|
| 322 | + | let wrapped_lines = if line_len == 0 { |
|
| 323 | + | 1 |
|
| 324 | + | } else { |
|
| 325 | + | (line_len + w - 1) / w |
|
| 326 | + | }; |
|
| 327 | + | if i < last_idx { |
|
| 328 | + | visual_row += wrapped_lines; |
|
| 329 | + | } else { |
|
| 330 | + | let cursor_col = if text.ends_with('\n') { 0 } else { line_len }; |
|
| 331 | + | let extra_rows = cursor_col / w; |
|
| 332 | + | let col = cursor_col % w; |
|
| 333 | + | visual_row += extra_rows; |
|
| 334 | + | return (col as u16, visual_row as u16); |
|
| 335 | + | } |
|
| 336 | + | } |
|
| 337 | + | (0, visual_row as u16) |
|
| 338 | + | } |
|
| 339 | + | ||
| 340 | + | fn auto_scroll_edit(&mut self, cursor_visual_row: u16, visible_height: u16) { |
|
| 341 | + | if visible_height == 0 { |
|
| 342 | + | return; |
|
| 343 | + | } |
|
| 344 | + | if cursor_visual_row < self.edit_scroll { |
|
| 345 | + | self.edit_scroll = cursor_visual_row; |
|
| 346 | + | } else if cursor_visual_row >= self.edit_scroll + visible_height { |
|
| 347 | + | self.edit_scroll = cursor_visual_row - visible_height + 1; |
|
| 348 | + | } |
|
| 349 | + | } |
|
| 350 | + | ||
| 351 | + | fn start_create(&mut self) { |
|
| 352 | + | self.edit_title.clear(); |
|
| 353 | + | self.edit_content.clear(); |
|
| 354 | + | self.edit_scroll = 0; |
|
| 355 | + | self.focus = Focus::CreateTitle; |
|
| 356 | + | } |
|
| 357 | + | ||
| 358 | + | fn save_create(&mut self, backend: &Backend) { |
|
| 359 | + | if self.edit_title.trim().is_empty() { |
|
| 360 | + | self.status_message = Some(("Title cannot be empty".to_string(), Instant::now())); |
|
| 361 | + | return; |
|
| 362 | + | } |
|
| 363 | + | match backend.create_note(&self.edit_title, &self.edit_content) { |
|
| 364 | + | Ok(note) => { |
|
| 365 | + | self.notes.insert(0, note); |
|
| 366 | + | self.list_state.select(Some(0)); |
|
| 367 | + | self.filtered_indices = None; |
|
| 368 | + | self.search_query.clear(); |
|
| 369 | + | self.status_message = Some(("Created!".to_string(), Instant::now())); |
|
| 370 | + | self.focus = Focus::List; |
|
| 371 | + | self.edit_title.clear(); |
|
| 372 | + | self.edit_content.clear(); |
|
| 373 | + | } |
|
| 374 | + | Err(e) => { |
|
| 375 | + | self.status_message = Some((e.to_string(), Instant::now())); |
|
| 376 | + | } |
|
| 377 | + | } |
|
| 378 | + | } |
|
| 379 | + | ||
| 380 | + | fn cancel_create(&mut self) { |
|
| 381 | + | self.edit_title.clear(); |
|
| 382 | + | self.edit_content.clear(); |
|
| 383 | + | self.focus = Focus::List; |
|
| 384 | + | } |
|
| 385 | + | ||
| 386 | + | fn start_edit(&mut self) { |
|
| 387 | + | let data = self |
|
| 388 | + | .selected_note() |
|
| 389 | + | .map(|n| (n.title.clone(), n.content.clone(), n.short_id.clone())); |
|
| 390 | + | if let Some((title, content, short_id)) = data { |
|
| 391 | + | self.edit_title = title; |
|
| 392 | + | self.edit_content = content; |
|
| 393 | + | self.edit_short_id = Some(short_id); |
|
| 394 | + | self.edit_scroll = 0; |
|
| 395 | + | self.focus = Focus::EditTitle; |
|
| 396 | + | } |
|
| 397 | + | } |
|
| 398 | + | ||
| 399 | + | fn save_edit(&mut self, backend: &Backend) { |
|
| 400 | + | if self.edit_title.trim().is_empty() { |
|
| 401 | + | self.status_message = Some(("Title cannot be empty".to_string(), Instant::now())); |
|
| 402 | + | return; |
|
| 403 | + | } |
|
| 404 | + | let short_id = match &self.edit_short_id { |
|
| 405 | + | Some(id) => id.clone(), |
|
| 406 | + | None => return, |
|
| 407 | + | }; |
|
| 408 | + | match backend.update_note(&short_id, &self.edit_title, &self.edit_content) { |
|
| 409 | + | Ok(Some(updated)) => { |
|
| 410 | + | if let Some(pos) = self.notes.iter().position(|n| n.short_id == short_id) { |
|
| 411 | + | self.notes[pos] = updated; |
|
| 412 | + | } |
|
| 413 | + | self.status_message = Some(("Updated!".to_string(), Instant::now())); |
|
| 414 | + | self.focus = Focus::List; |
|
| 415 | + | self.edit_title.clear(); |
|
| 416 | + | self.edit_content.clear(); |
|
| 417 | + | self.edit_short_id = None; |
|
| 418 | + | } |
|
| 419 | + | Ok(None) => { |
|
| 420 | + | self.status_message = Some(("Note not found".to_string(), Instant::now())); |
|
| 421 | + | } |
|
| 422 | + | Err(e) => { |
|
| 423 | + | self.status_message = Some((e.to_string(), Instant::now())); |
|
| 424 | + | } |
|
| 425 | + | } |
|
| 426 | + | } |
|
| 427 | + | ||
| 428 | + | fn cancel_edit(&mut self) { |
|
| 429 | + | self.edit_title.clear(); |
|
| 430 | + | self.edit_content.clear(); |
|
| 431 | + | self.edit_short_id = None; |
|
| 432 | + | self.focus = Focus::List; |
|
| 433 | + | } |
|
| 434 | + | ||
| 435 | + | fn start_search(&mut self) { |
|
| 436 | + | self.search_query.clear(); |
|
| 437 | + | self.filtered_indices = Some((0..self.notes.len()).collect()); |
|
| 438 | + | self.focus = Focus::Search; |
|
| 439 | + | self.list_state |
|
| 440 | + | .select(if self.notes.is_empty() { None } else { Some(0) }); |
|
| 441 | + | } |
|
| 442 | + | ||
| 443 | + | fn update_search_filter(&mut self) { |
|
| 444 | + | let query = self.search_query.to_lowercase(); |
|
| 445 | + | let indices: Vec<usize> = self |
|
| 446 | + | .notes |
|
| 447 | + | .iter() |
|
| 448 | + | .enumerate() |
|
| 449 | + | .filter(|(_, n)| n.title.to_lowercase().contains(&query)) |
|
| 450 | + | .map(|(i, _)| i) |
|
| 451 | + | .collect(); |
|
| 452 | + | self.filtered_indices = Some(indices); |
|
| 453 | + | if self.visible_count() == 0 { |
|
| 454 | + | self.list_state.select(None); |
|
| 455 | + | } else { |
|
| 456 | + | self.list_state.select(Some(0)); |
|
| 457 | + | } |
|
| 458 | + | } |
|
| 459 | + | ||
| 460 | + | fn cancel_search(&mut self) { |
|
| 461 | + | self.filtered_indices = None; |
|
| 462 | + | self.search_query.clear(); |
|
| 463 | + | self.focus = Focus::List; |
|
| 464 | + | } |
|
| 465 | + | ||
| 466 | + | fn confirm_search(&mut self) { |
|
| 467 | + | let real_index = self.list_state.selected().and_then(|i| { |
|
| 468 | + | self.filtered_indices |
|
| 469 | + | .as_ref() |
|
| 470 | + | .and_then(|indices| indices.get(i).copied()) |
|
| 471 | + | }); |
|
| 472 | + | self.filtered_indices = None; |
|
| 473 | + | self.search_query.clear(); |
|
| 474 | + | self.focus = Focus::List; |
|
| 475 | + | if let Some(ri) = real_index { |
|
| 476 | + | self.list_state.select(Some(ri)); |
|
| 477 | + | } |
|
| 478 | + | } |
|
| 479 | + | ||
| 480 | + | fn clear_expired_status(&mut self) { |
|
| 481 | + | if let Some((_, time)) = &self.status_message { |
|
| 482 | + | if time.elapsed() > Duration::from_secs(2) { |
|
| 483 | + | self.status_message = None; |
|
| 484 | + | } |
|
| 485 | + | } |
|
| 486 | + | } |
|
| 487 | + | } |
|
| 488 | + | ||
| 489 | + | fn db_path() -> String { |
|
| 490 | + | std::env::var("JOTTS_DB_PATH").unwrap_or_else(|_| "jotts.sqlite".to_string()) |
|
| 491 | + | } |
|
| 492 | + | ||
| 493 | + | fn resolve_backend( |
|
| 494 | + | remote: Option<String>, |
|
| 495 | + | api_key: Option<String>, |
|
| 496 | + | ) -> Result<(Backend, bool, Option<String>), Box<dyn std::error::Error>> { |
|
| 497 | + | if let Some(url) = remote { |
|
| 498 | + | return Ok((Backend::remote(url.clone(), api_key), true, Some(url))); |
|
| 499 | + | } |
|
| 500 | + | ||
| 501 | + | if !std::path::Path::new(&db_path()).exists() { |
|
| 502 | + | let cfg = config::load_config(); |
|
| 503 | + | let url = cfg |
|
| 504 | + | .remote_url |
|
| 505 | + | .unwrap_or_else(|| "http://localhost:3000".to_string()); |
|
| 506 | + | let api_key = api_key.or(cfg.api_key); |
|
| 507 | + | return Ok((Backend::remote(url.clone(), api_key), true, Some(url))); |
|
| 508 | + | } |
|
| 509 | + | ||
| 510 | + | Ok(( |
|
| 511 | + | Backend::local(), |
|
| 512 | + | false, |
|
| 513 | + | Some("http://localhost:3000".to_string()), |
|
| 514 | + | )) |
|
| 515 | + | } |
|
| 516 | + | ||
| 517 | + | pub fn run_file_upload( |
|
| 518 | + | remote: Option<String>, |
|
| 519 | + | api_key: Option<String>, |
|
| 520 | + | file: PathBuf, |
|
| 521 | + | ) -> Result<(), Box<dyn std::error::Error>> { |
|
| 522 | + | let (backend, _, remote_url) = resolve_backend(remote, api_key)?; |
|
| 523 | + | ||
| 524 | + | let title = file |
|
| 525 | + | .file_stem() |
|
| 526 | + | .ok_or("Invalid file path")? |
|
| 527 | + | .to_string_lossy() |
|
| 528 | + | .to_string(); |
|
| 529 | + | let content = std::fs::read_to_string(&file) |
|
| 530 | + | .map_err(|e| format!("Failed to read file: {}", e))?; |
|
| 531 | + | let note = backend |
|
| 532 | + | .create_note(&title, &content) |
|
| 533 | + | .map_err(|e| format!("{}", e))?; |
|
| 534 | + | let link = match &remote_url { |
|
| 535 | + | Some(url) => format!("{}/notes/{}", url.trim_end_matches('/'), note.short_id), |
|
| 536 | + | None => note.short_id.clone(), |
|
| 537 | + | }; |
|
| 538 | + | println!("{}", link); |
|
| 539 | + | if let Ok(mut clipboard) = Clipboard::new() { |
|
| 540 | + | let _ = clipboard.set_text(&link); |
|
| 541 | + | println!("\u{2714} Copied to clipboard!"); |
|
| 542 | + | } |
|
| 543 | + | Ok(()) |
|
| 544 | + | } |
|
| 545 | + | ||
| 546 | + | pub fn run_auth() -> Result<(), Box<dyn std::error::Error>> { |
|
| 547 | + | use std::io::{self, Write}; |
|
| 548 | + | ||
| 549 | + | print!("Remote URL: "); |
|
| 550 | + | io::stdout().flush()?; |
|
| 551 | + | let mut remote_url = String::new(); |
|
| 552 | + | io::stdin().read_line(&mut remote_url)?; |
|
| 553 | + | let remote_url = remote_url.trim().to_string(); |
|
| 554 | + | ||
| 555 | + | print!("API Key: "); |
|
| 556 | + | io::stdout().flush()?; |
|
| 557 | + | let api_key = rpassword::read_password()?; |
|
| 558 | + | let api_key = api_key.trim().to_string(); |
|
| 559 | + | ||
| 560 | + | let cfg = config::Config { |
|
| 561 | + | remote_url: if remote_url.is_empty() { |
|
| 562 | + | None |
|
| 563 | + | } else { |
|
| 564 | + | Some(remote_url) |
|
| 565 | + | }, |
|
| 566 | + | api_key: if api_key.is_empty() { |
|
| 567 | + | None |
|
| 568 | + | } else { |
|
| 569 | + | Some(api_key) |
|
| 570 | + | }, |
|
| 571 | + | }; |
|
| 572 | + | ||
| 573 | + | config::save_config(&cfg)?; |
|
| 574 | + | println!("Config saved to {}", config::config_path().display()); |
|
| 575 | + | Ok(()) |
|
| 576 | + | } |
|
| 577 | + | ||
| 578 | + | pub fn run_interactive( |
|
| 579 | + | remote: Option<String>, |
|
| 580 | + | api_key: Option<String>, |
|
| 581 | + | ) -> Result<(), Box<dyn std::error::Error>> { |
|
| 582 | + | let (backend, is_remote, remote_url) = resolve_backend(remote, api_key)?; |
|
| 583 | + | ||
| 584 | + | let notes = match backend.list_notes() { |
|
| 585 | + | Ok(n) => n, |
|
| 586 | + | Err(e) => { |
|
| 587 | + | eprintln!("Failed to load notes: {}", e); |
|
| 588 | + | Vec::new() |
|
| 589 | + | } |
|
| 590 | + | }; |
|
| 591 | + | ||
| 592 | + | ratatui::run(|terminal| run_app(terminal, App::new(notes, is_remote, remote_url), &backend)) |
|
| 593 | + | } |
|
| 594 | + | ||
| 595 | + | fn run_app( |
|
| 596 | + | terminal: &mut DefaultTerminal, |
|
| 597 | + | mut app: App, |
|
| 598 | + | backend: &Backend, |
|
| 599 | + | ) -> Result<(), Box<dyn std::error::Error>> { |
|
| 600 | + | while !app.should_quit { |
|
| 601 | + | app.clear_expired_status(); |
|
| 602 | + | ||
| 603 | + | let content_line_count = app |
|
| 604 | + | .selected_note() |
|
| 605 | + | .map(|n| n.content.lines().count() as u16) |
|
| 606 | + | .unwrap_or(0); |
|
| 607 | + | ||
| 608 | + | terminal.draw(|frame| { |
|
| 609 | + | let outer = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]) |
|
| 610 | + | .split(frame.area()); |
|
| 611 | + | ||
| 612 | + | let chunks = Layout::horizontal([ |
|
| 613 | + | Constraint::Percentage(30), |
|
| 614 | + | Constraint::Percentage(70), |
|
| 615 | + | ]) |
|
| 616 | + | .split(outer[0]); |
|
| 617 | + | ||
| 618 | + | let items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices { |
|
| 619 | + | indices |
|
| 620 | + | .iter() |
|
| 621 | + | .filter_map(|&i| app.notes.get(i)) |
|
| 622 | + | .map(|n| ListItem::new(n.title.as_str())) |
|
| 623 | + | .collect() |
|
| 624 | + | } else { |
|
| 625 | + | app.notes |
|
| 626 | + | .iter() |
|
| 627 | + | .map(|n| ListItem::new(n.title.as_str())) |
|
| 628 | + | .collect() |
|
| 629 | + | }; |
|
| 630 | + | ||
| 631 | + | let list_border_style = match app.focus { |
|
| 632 | + | Focus::List | Focus::Search => Style::default().fg(Color::Yellow), |
|
| 633 | + | _ => Style::default().fg(Color::DarkGray), |
|
| 634 | + | }; |
|
| 635 | + | let content_border_style = match app.focus { |
|
| 636 | + | Focus::Content => Style::default().fg(Color::Yellow), |
|
| 637 | + | _ => Style::default().fg(Color::DarkGray), |
|
| 638 | + | }; |
|
| 639 | + | ||
| 640 | + | let list = List::new(items) |
|
| 641 | + | .block( |
|
| 642 | + | Block::default() |
|
| 643 | + | .title(" Notes ") |
|
| 644 | + | .borders(Borders::ALL) |
|
| 645 | + | .border_style(list_border_style), |
|
| 646 | + | ) |
|
| 647 | + | .highlight_style( |
|
| 648 | + | Style::default() |
|
| 649 | + | .fg(Color::Yellow) |
|
| 650 | + | .add_modifier(Modifier::BOLD), |
|
| 651 | + | ) |
|
| 652 | + | .highlight_symbol("▶ "); |
|
| 653 | + | ||
| 654 | + | if matches!(app.focus, Focus::Search) { |
|
| 655 | + | let search_split = |
|
| 656 | + | Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(chunks[0]); |
|
| 657 | + | ||
| 658 | + | let search_items: Vec<ListItem> = if let Some(indices) = &app.filtered_indices { |
|
| 659 | + | indices |
|
| 660 | + | .iter() |
|
| 661 | + | .filter_map(|&i| app.notes.get(i)) |
|
| 662 | + | .map(|n| ListItem::new(n.title.as_str())) |
|
| 663 | + | .collect() |
|
| 664 | + | } else { |
|
| 665 | + | app.notes |
|
| 666 | + | .iter() |
|
| 667 | + | .map(|n| ListItem::new(n.title.as_str())) |
|
| 668 | + | .collect() |
|
| 669 | + | }; |
|
| 670 | + | let search_list = List::new(search_items) |
|
| 671 | + | .block( |
|
| 672 | + | Block::default() |
|
| 673 | + | .title(" Notes ") |
|
| 674 | + | .borders(Borders::ALL) |
|
| 675 | + | .border_style(list_border_style), |
|
| 676 | + | ) |
|
| 677 | + | .highlight_style( |
|
| 678 | + | Style::default() |
|
| 679 | + | .fg(Color::Yellow) |
|
| 680 | + | .add_modifier(Modifier::BOLD), |
|
| 681 | + | ) |
|
| 682 | + | .highlight_symbol("▶ "); |
|
| 683 | + | frame.render_stateful_widget(search_list, search_split[0], &mut app.list_state); |
|
| 684 | + | ||
| 685 | + | let search_input = Paragraph::new(app.search_query.as_str()).block( |
|
| 686 | + | Block::default() |
|
| 687 | + | .title(" Search ") |
|
| 688 | + | .borders(Borders::ALL) |
|
| 689 | + | .border_style(Style::default().fg(Color::Yellow)), |
|
| 690 | + | ); |
|
| 691 | + | frame.render_widget(search_input, search_split[1]); |
|
| 692 | + | ||
| 693 | + | let x = search_split[1].x + 1 + app.search_query.len() as u16; |
|
| 694 | + | let y = search_split[1].y + 1; |
|
| 695 | + | frame.set_cursor_position((x, y)); |
|
| 696 | + | } else { |
|
| 697 | + | frame.render_stateful_widget(list, chunks[0], &mut app.list_state); |
|
| 698 | + | } |
|
| 699 | + | ||
| 700 | + | match app.focus { |
|
| 701 | + | Focus::CreateTitle |
|
| 702 | + | | Focus::CreateContent |
|
| 703 | + | | Focus::EditTitle |
|
| 704 | + | | Focus::EditContent => { |
|
| 705 | + | let form_title = match app.focus { |
|
| 706 | + | Focus::EditTitle | Focus::EditContent => " Edit Note ", |
|
| 707 | + | _ => " New Note ", |
|
| 708 | + | }; |
|
| 709 | + | let create_block = Block::default() |
|
| 710 | + | .title(form_title) |
|
| 711 | + | .borders(Borders::ALL) |
|
| 712 | + | .border_style(Style::default().fg(Color::Yellow)); |
|
| 713 | + | ||
| 714 | + | let inner = create_block.inner(chunks[1]); |
|
| 715 | + | frame.render_widget(create_block, chunks[1]); |
|
| 716 | + | ||
| 717 | + | let form_layout = |
|
| 718 | + | Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).split(inner); |
|
| 719 | + | ||
| 720 | + | let title_style = match app.focus { |
|
| 721 | + | Focus::CreateTitle | Focus::EditTitle => Style::default().fg(Color::Yellow), |
|
| 722 | + | _ => Style::default().fg(Color::DarkGray), |
|
| 723 | + | }; |
|
| 724 | + | let title_input = Paragraph::new(app.edit_title.as_str()).block( |
|
| 725 | + | Block::default() |
|
| 726 | + | .title(" Title ") |
|
| 727 | + | .borders(Borders::ALL) |
|
| 728 | + | .border_style(title_style), |
|
| 729 | + | ); |
|
| 730 | + | frame.render_widget(title_input, form_layout[0]); |
|
| 731 | + | ||
| 732 | + | let content_style = match app.focus { |
|
| 733 | + | Focus::CreateContent | Focus::EditContent => { |
|
| 734 | + | Style::default().fg(Color::Yellow) |
|
| 735 | + | } |
|
| 736 | + | _ => Style::default().fg(Color::DarkGray), |
|
| 737 | + | }; |
|
| 738 | + | let mut content_input = Paragraph::new(app.edit_content.as_str()).block( |
|
| 739 | + | Block::default() |
|
| 740 | + | .title(" Content ") |
|
| 741 | + | .borders(Borders::ALL) |
|
| 742 | + | .border_style(content_style), |
|
| 743 | + | ); |
|
| 744 | + | if app.wrap_content { |
|
| 745 | + | content_input = content_input.wrap(Wrap { trim: false }); |
|
| 746 | + | } |
|
| 747 | + | content_input = content_input.scroll((app.edit_scroll, 0)); |
|
| 748 | + | frame.render_widget(content_input, form_layout[1]); |
|
| 749 | + | ||
| 750 | + | let content_inner = |
|
| 751 | + | Block::default().borders(Borders::ALL).inner(form_layout[1]); |
|
| 752 | + | let inner_width = content_inner.width; |
|
| 753 | + | let inner_height = content_inner.height; |
|
| 754 | + | ||
| 755 | + | match app.focus { |
|
| 756 | + | Focus::CreateTitle | Focus::EditTitle => { |
|
| 757 | + | let x = form_layout[0].x + 1 + app.edit_title.len() as u16; |
|
| 758 | + | let y = form_layout[0].y + 1; |
|
| 759 | + | frame.set_cursor_position((x, y)); |
|
| 760 | + | } |
|
| 761 | + | Focus::CreateContent | Focus::EditContent => { |
|
| 762 | + | let (cx, cy) = if app.wrap_content { |
|
| 763 | + | app.cursor_position_wrapped(inner_width) |
|
| 764 | + | } else { |
|
| 765 | + | let last_line = app.edit_content.lines().last().unwrap_or(""); |
|
| 766 | + | let line_count = app.edit_content.lines().count() |
|
| 767 | + | + if app.edit_content.ends_with('\n') { 1 } else { 0 }; |
|
| 768 | + | let y_offset = if line_count == 0 { 0 } else { line_count - 1 }; |
|
| 769 | + | let col = if app.edit_content.ends_with('\n') { |
|
| 770 | + | 0 |
|
| 771 | + | } else { |
|
| 772 | + | last_line.len() as u16 |
|
| 773 | + | }; |
|
| 774 | + | (col, y_offset as u16) |
|
| 775 | + | }; |
|
| 776 | + | app.auto_scroll_edit(cy, inner_height); |
|
| 777 | + | let screen_y = cy.saturating_sub(app.edit_scroll); |
|
| 778 | + | let x = content_inner.x + cx; |
|
| 779 | + | let y = content_inner.y + screen_y; |
|
| 780 | + | frame.set_cursor_position((x, y)); |
|
| 781 | + | } |
|
| 782 | + | _ => {} |
|
| 783 | + | } |
|
| 784 | + | } |
|
| 785 | + | _ => { |
|
| 786 | + | let highlighted = match app.selected_note() { |
|
| 787 | + | Some(n) => app.highlighter.highlight_markdown(&n.content), |
|
| 788 | + | None => Text::raw(""), |
|
| 789 | + | }; |
|
| 790 | + | ||
| 791 | + | let paragraph = Paragraph::new(highlighted) |
|
| 792 | + | .block( |
|
| 793 | + | Block::default() |
|
| 794 | + | .title(" Content ") |
|
| 795 | + | .borders(Borders::ALL) |
|
| 796 | + | .border_style(content_border_style), |
|
| 797 | + | ) |
|
| 798 | + | .scroll((app.content_scroll, 0)); |
|
| 799 | + | ||
| 800 | + | frame.render_widget(paragraph, chunks[1]); |
|
| 801 | + | } |
|
| 802 | + | } |
|
| 803 | + | ||
| 804 | + | let hints = match app.focus { |
|
| 805 | + | Focus::List => Line::from(vec![ |
|
| 806 | + | Span::styled("j/k", Style::default().fg(Color::Yellow)), |
|
| 807 | + | Span::raw(": Navigate "), |
|
| 808 | + | Span::styled("Enter", Style::default().fg(Color::Yellow)), |
|
| 809 | + | Span::raw(": View "), |
|
| 810 | + | Span::styled("y", Style::default().fg(Color::Yellow)), |
|
| 811 | + | Span::raw(": Copy "), |
|
| 812 | + | Span::styled("e", Style::default().fg(Color::Yellow)), |
|
| 813 | + | Span::raw(": Edit "), |
|
| 814 | + | Span::styled("d", Style::default().fg(Color::Yellow)), |
|
| 815 | + | Span::raw(": Delete "), |
|
| 816 | + | Span::styled("c", Style::default().fg(Color::Yellow)), |
|
| 817 | + | Span::raw(": Create "), |
|
| 818 | + | Span::styled("/", Style::default().fg(Color::Yellow)), |
|
| 819 | + | Span::raw(": Search "), |
|
| 820 | + | Span::styled("?", Style::default().fg(Color::Yellow)), |
|
| 821 | + | Span::raw(": Help "), |
|
| 822 | + | Span::styled("q", Style::default().fg(Color::Yellow)), |
|
| 823 | + | Span::raw(": Quit"), |
|
| 824 | + | ]), |
|
| 825 | + | Focus::Content => Line::from(vec![ |
|
| 826 | + | Span::styled("j/k", Style::default().fg(Color::Yellow)), |
|
| 827 | + | Span::raw(": Scroll "), |
|
| 828 | + | Span::styled("y", Style::default().fg(Color::Yellow)), |
|
| 829 | + | Span::raw(": Copy "), |
|
| 830 | + | Span::styled("e", Style::default().fg(Color::Yellow)), |
|
| 831 | + | Span::raw(": Edit "), |
|
| 832 | + | Span::styled("Esc", Style::default().fg(Color::Yellow)), |
|
| 833 | + | Span::raw(": Back "), |
|
| 834 | + | Span::styled("?", Style::default().fg(Color::Yellow)), |
|
| 835 | + | Span::raw(": Help"), |
|
| 836 | + | ]), |
|
| 837 | + | Focus::CreateTitle |
|
| 838 | + | | Focus::CreateContent |
|
| 839 | + | | Focus::EditTitle |
|
| 840 | + | | Focus::EditContent => Line::from(vec![ |
|
| 841 | + | Span::styled("Tab", Style::default().fg(Color::Yellow)), |
|
| 842 | + | Span::raw(": Switch field "), |
|
| 843 | + | Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)), |
|
| 844 | + | Span::raw(": Save "), |
|
| 845 | + | Span::styled("Ctrl+W", Style::default().fg(Color::Yellow)), |
|
| 846 | + | Span::raw(": Wrap "), |
|
| 847 | + | Span::styled("Esc", Style::default().fg(Color::Yellow)), |
|
| 848 | + | Span::raw(": Cancel"), |
|
| 849 | + | ]), |
|
| 850 | + | Focus::Search => Line::from(vec![ |
|
| 851 | + | Span::styled("Type", Style::default().fg(Color::Yellow)), |
|
| 852 | + | Span::raw(": Filter "), |
|
| 853 | + | Span::styled("Enter", Style::default().fg(Color::Yellow)), |
|
| 854 | + | Span::raw(": Select "), |
|
| 855 | + | Span::styled("Esc", Style::default().fg(Color::Yellow)), |
|
| 856 | + | Span::raw(": Cancel"), |
|
| 857 | + | ]), |
|
| 858 | + | }; |
|
| 859 | + | frame.render_widget(Paragraph::new(hints), outer[1]); |
|
| 860 | + | ||
| 861 | + | if let Some((msg, _)) = &app.status_message { |
|
| 862 | + | let area = frame.area(); |
|
| 863 | + | let msg_width = (msg.len() as u16 + 4).max(20).min(area.width.saturating_sub(4)); |
|
| 864 | + | let popup_area = ratatui::layout::Rect { |
|
| 865 | + | x: (area.width.saturating_sub(msg_width)) / 2, |
|
| 866 | + | y: (area.height.saturating_sub(3)) / 2, |
|
| 867 | + | width: msg_width, |
|
| 868 | + | height: 3, |
|
| 869 | + | }; |
|
| 870 | + | Clear.render(popup_area, frame.buffer_mut()); |
|
| 871 | + | let status_popup = Paragraph::new(Line::from(msg.as_str())) |
|
| 872 | + | .style(Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)) |
|
| 873 | + | .alignment(Alignment::Center) |
|
| 874 | + | .block( |
|
| 875 | + | Block::default() |
|
| 876 | + | .borders(Borders::ALL) |
|
| 877 | + | .border_style(Style::default().fg(Color::Green)), |
|
| 878 | + | ); |
|
| 879 | + | frame.render_widget(status_popup, popup_area); |
|
| 880 | + | } |
|
| 881 | + | ||
| 882 | + | if app.confirm_delete { |
|
| 883 | + | let delete_msg = match app.selected_note() { |
|
| 884 | + | Some(n) => format!("Delete {}? (y/n)", n.title), |
|
| 885 | + | None => "Delete note? (y/n)".to_string(), |
|
| 886 | + | }; |
|
| 887 | + | let area = frame.area(); |
|
| 888 | + | let msg_width = (delete_msg.len() as u16 + 4).max(24).min(area.width.saturating_sub(4)); |
|
| 889 | + | let popup_area = ratatui::layout::Rect { |
|
| 890 | + | x: (area.width.saturating_sub(msg_width)) / 2, |
|
| 891 | + | y: (area.height.saturating_sub(3)) / 2, |
|
| 892 | + | width: msg_width, |
|
| 893 | + | height: 3, |
|
| 894 | + | }; |
|
| 895 | + | Clear.render(popup_area, frame.buffer_mut()); |
|
| 896 | + | let confirm_popup = Paragraph::new(Line::from(delete_msg)) |
|
| 897 | + | .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)) |
|
| 898 | + | .alignment(Alignment::Center) |
|
| 899 | + | .block( |
|
| 900 | + | Block::default() |
|
| 901 | + | .borders(Borders::ALL) |
|
| 902 | + | .border_style(Style::default().fg(Color::Red)), |
|
| 903 | + | ); |
|
| 904 | + | frame.render_widget(confirm_popup, popup_area); |
|
| 905 | + | } |
|
| 906 | + | ||
| 907 | + | if app.show_help { |
|
| 908 | + | let area = frame.area(); |
|
| 909 | + | let popup_width = 34u16.min(area.width.saturating_sub(4)); |
|
| 910 | + | let popup_height = 21u16.min(area.height.saturating_sub(4)); |
|
| 911 | + | let popup_area = ratatui::layout::Rect { |
|
| 912 | + | x: (area.width.saturating_sub(popup_width)) / 2, |
|
| 913 | + | y: (area.height.saturating_sub(popup_height)) / 2, |
|
| 914 | + | width: popup_width, |
|
| 915 | + | height: popup_height, |
|
| 916 | + | }; |
|
| 917 | + | ||
| 918 | + | let mut help_lines = vec![ |
|
| 919 | + | Line::from(""), |
|
| 920 | + | Line::from(vec![ |
|
| 921 | + | Span::styled(" j/↓ ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 922 | + | Span::raw("Move down / Scroll down"), |
|
| 923 | + | ]), |
|
| 924 | + | Line::from(vec![ |
|
| 925 | + | Span::styled(" k/↑ ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 926 | + | Span::raw("Move up / Scroll up"), |
|
| 927 | + | ]), |
|
| 928 | + | Line::from(vec![ |
|
| 929 | + | Span::styled(" Enter", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 930 | + | Span::raw(" Focus content pane"), |
|
| 931 | + | ]), |
|
| 932 | + | Line::from(vec![ |
|
| 933 | + | Span::styled(" Esc ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 934 | + | Span::raw("Back / Quit"), |
|
| 935 | + | ]), |
|
| 936 | + | Line::from(vec![ |
|
| 937 | + | Span::styled(" y ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 938 | + | Span::raw("Copy note"), |
|
| 939 | + | ]), |
|
| 940 | + | Line::from(vec![ |
|
| 941 | + | Span::styled(" Y ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 942 | + | Span::raw("Copy link"), |
|
| 943 | + | ]), |
|
| 944 | + | Line::from(vec![ |
|
| 945 | + | Span::styled(" o ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 946 | + | Span::raw("Open in browser"), |
|
| 947 | + | ]), |
|
| 948 | + | Line::from(vec![ |
|
| 949 | + | Span::styled(" d ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 950 | + | Span::raw("Delete note"), |
|
| 951 | + | ]), |
|
| 952 | + | Line::from(vec![ |
|
| 953 | + | Span::styled(" c ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 954 | + | Span::raw("Create note"), |
|
| 955 | + | ]), |
|
| 956 | + | Line::from(vec![ |
|
| 957 | + | Span::styled(" e ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 958 | + | Span::raw("Edit note"), |
|
| 959 | + | ]), |
|
| 960 | + | Line::from(vec![ |
|
| 961 | + | Span::styled(" E ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 962 | + | Span::raw("Edit in $EDITOR"), |
|
| 963 | + | ]), |
|
| 964 | + | Line::from(vec![ |
|
| 965 | + | Span::styled(" / ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 966 | + | Span::raw("Search notes"), |
|
| 967 | + | ]), |
|
| 968 | + | Line::from(vec![ |
|
| 969 | + | Span::styled(" ^W ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 970 | + | Span::raw("Toggle word wrap (edit)"), |
|
| 971 | + | ]), |
|
| 972 | + | ]; |
|
| 973 | + | ||
| 974 | + | if app.is_remote { |
|
| 975 | + | help_lines.push(Line::from(vec![ |
|
| 976 | + | Span::styled(" r ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 977 | + | Span::raw("Refresh notes"), |
|
| 978 | + | ])); |
|
| 979 | + | } |
|
| 980 | + | ||
| 981 | + | help_lines.extend([ |
|
| 982 | + | Line::from(vec![ |
|
| 983 | + | Span::styled(" q ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 984 | + | Span::raw("Quit"), |
|
| 985 | + | ]), |
|
| 986 | + | Line::from(vec![ |
|
| 987 | + | Span::styled(" ? ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), |
|
| 988 | + | Span::raw("Toggle this help"), |
|
| 989 | + | ]), |
|
| 990 | + | Line::from(""), |
|
| 991 | + | Line::from(Span::styled( |
|
| 992 | + | " Press any key to close", |
|
| 993 | + | Style::default().fg(Color::DarkGray), |
|
| 994 | + | )), |
|
| 995 | + | ]); |
|
| 996 | + | ||
| 997 | + | let help_text = Text::from(help_lines); |
|
| 998 | + | ||
| 999 | + | Clear.render(popup_area, frame.buffer_mut()); |
|
| 1000 | + | let help = Paragraph::new(help_text).block( |
|
| 1001 | + | Block::default() |
|
| 1002 | + | .title(" Keybindings ") |
|
| 1003 | + | .borders(Borders::ALL) |
|
| 1004 | + | .border_style(Style::default().fg(Color::Yellow)), |
|
| 1005 | + | ); |
|
| 1006 | + | frame.render_widget(help, popup_area); |
|
| 1007 | + | } |
|
| 1008 | + | })?; |
|
| 1009 | + | ||
| 1010 | + | if event::poll(Duration::from_millis(100))? { |
|
| 1011 | + | if let Event::Key(key) = event::read()? { |
|
| 1012 | + | if app.show_help { |
|
| 1013 | + | app.show_help = false; |
|
| 1014 | + | } else if app.status_message.is_some() { |
|
| 1015 | + | app.status_message = None; |
|
| 1016 | + | } else if app.confirm_delete { |
|
| 1017 | + | if key.code == KeyCode::Char('y') { |
|
| 1018 | + | app.delete_selected(backend); |
|
| 1019 | + | } |
|
| 1020 | + | app.confirm_delete = false; |
|
| 1021 | + | } else { |
|
| 1022 | + | match app.focus { |
|
| 1023 | + | Focus::List => match key.code { |
|
| 1024 | + | KeyCode::Char('q') | KeyCode::Esc => app.should_quit = true, |
|
| 1025 | + | KeyCode::Char('j') | KeyCode::Down => app.move_down(), |
|
| 1026 | + | KeyCode::Char('k') | KeyCode::Up => app.move_up(), |
|
| 1027 | + | KeyCode::Char('y') => app.copy_selected(), |
|
| 1028 | + | KeyCode::Char('Y') => app.copy_link(), |
|
| 1029 | + | KeyCode::Char('d') => app.confirm_delete = true, |
|
| 1030 | + | KeyCode::Char('c') => app.start_create(), |
|
| 1031 | + | KeyCode::Char('e') => app.start_edit(), |
|
| 1032 | + | KeyCode::Char('E') => { |
|
| 1033 | + | edit_in_external_editor(terminal, &mut app, backend)? |
|
| 1034 | + | } |
|
| 1035 | + | KeyCode::Char('/') => app.start_search(), |
|
| 1036 | + | KeyCode::Char('o') => app.open_in_browser(), |
|
| 1037 | + | KeyCode::Char('r') if app.is_remote => app.refresh(backend), |
|
| 1038 | + | KeyCode::Char('?') => app.show_help = true, |
|
| 1039 | + | KeyCode::Enter | KeyCode::Char('l') => { |
|
| 1040 | + | if app.selected_note().is_some() { |
|
| 1041 | + | app.focus = Focus::Content; |
|
| 1042 | + | } |
|
| 1043 | + | } |
|
| 1044 | + | _ => {} |
|
| 1045 | + | }, |
|
| 1046 | + | Focus::Content => match key.code { |
|
| 1047 | + | KeyCode::Char(' ') |
|
| 1048 | + | | KeyCode::Esc |
|
| 1049 | + | | KeyCode::Char('q') |
|
| 1050 | + | | KeyCode::Char('h') => { |
|
| 1051 | + | app.focus = Focus::List; |
|
| 1052 | + | } |
|
| 1053 | + | KeyCode::Char('j') | KeyCode::Down => { |
|
| 1054 | + | app.scroll_down(content_line_count); |
|
| 1055 | + | } |
|
| 1056 | + | KeyCode::Char('k') | KeyCode::Up => app.scroll_up(), |
|
| 1057 | + | KeyCode::Char('y') => app.copy_selected(), |
|
| 1058 | + | KeyCode::Char('Y') => app.copy_link(), |
|
| 1059 | + | KeyCode::Char('e') => app.start_edit(), |
|
| 1060 | + | KeyCode::Char('E') => { |
|
| 1061 | + | edit_in_external_editor(terminal, &mut app, backend)? |
|
| 1062 | + | } |
|
| 1063 | + | KeyCode::Char('o') => app.open_in_browser(), |
|
| 1064 | + | KeyCode::Char('?') => app.show_help = true, |
|
| 1065 | + | _ => {} |
|
| 1066 | + | }, |
|
| 1067 | + | Focus::CreateTitle => { |
|
| 1068 | + | if key.modifiers.contains(KeyModifiers::CONTROL) |
|
| 1069 | + | && key.code == KeyCode::Char('s') |
|
| 1070 | + | { |
|
| 1071 | + | app.save_create(backend); |
|
| 1072 | + | } else { |
|
| 1073 | + | match key.code { |
|
| 1074 | + | KeyCode::Esc => app.cancel_create(), |
|
| 1075 | + | KeyCode::Enter | KeyCode::Tab => { |
|
| 1076 | + | app.focus = Focus::CreateContent |
|
| 1077 | + | } |
|
| 1078 | + | KeyCode::Backspace => { |
|
| 1079 | + | app.edit_title.pop(); |
|
| 1080 | + | } |
|
| 1081 | + | KeyCode::Char(c) => app.edit_title.push(c), |
|
| 1082 | + | _ => {} |
|
| 1083 | + | } |
|
| 1084 | + | } |
|
| 1085 | + | } |
|
| 1086 | + | Focus::CreateContent => { |
|
| 1087 | + | if key.modifiers.contains(KeyModifiers::CONTROL) { |
|
| 1088 | + | match key.code { |
|
| 1089 | + | KeyCode::Char('s') => app.save_create(backend), |
|
| 1090 | + | KeyCode::Char('w') => { |
|
| 1091 | + | app.wrap_content = !app.wrap_content; |
|
| 1092 | + | app.edit_scroll = 0; |
|
| 1093 | + | } |
|
| 1094 | + | _ => {} |
|
| 1095 | + | } |
|
| 1096 | + | } else { |
|
| 1097 | + | match key.code { |
|
| 1098 | + | KeyCode::Esc => app.cancel_create(), |
|
| 1099 | + | KeyCode::Tab => app.focus = Focus::CreateTitle, |
|
| 1100 | + | KeyCode::Enter => app.edit_content.push('\n'), |
|
| 1101 | + | KeyCode::Backspace => { |
|
| 1102 | + | app.edit_content.pop(); |
|
| 1103 | + | } |
|
| 1104 | + | KeyCode::Char(c) => app.edit_content.push(c), |
|
| 1105 | + | _ => {} |
|
| 1106 | + | } |
|
| 1107 | + | } |
|
| 1108 | + | } |
|
| 1109 | + | Focus::EditTitle => { |
|
| 1110 | + | if key.modifiers.contains(KeyModifiers::CONTROL) |
|
| 1111 | + | && key.code == KeyCode::Char('s') |
|
| 1112 | + | { |
|
| 1113 | + | app.save_edit(backend); |
|
| 1114 | + | } else { |
|
| 1115 | + | match key.code { |
|
| 1116 | + | KeyCode::Esc => app.cancel_edit(), |
|
| 1117 | + | KeyCode::Enter | KeyCode::Tab => { |
|
| 1118 | + | app.focus = Focus::EditContent |
|
| 1119 | + | } |
|
| 1120 | + | KeyCode::Backspace => { |
|
| 1121 | + | app.edit_title.pop(); |
|
| 1122 | + | } |
|
| 1123 | + | KeyCode::Char(c) => app.edit_title.push(c), |
|
| 1124 | + | _ => {} |
|
| 1125 | + | } |
|
| 1126 | + | } |
|
| 1127 | + | } |
|
| 1128 | + | Focus::EditContent => { |
|
| 1129 | + | if key.modifiers.contains(KeyModifiers::CONTROL) { |
|
| 1130 | + | match key.code { |
|
| 1131 | + | KeyCode::Char('s') => app.save_edit(backend), |
|
| 1132 | + | KeyCode::Char('w') => { |
|
| 1133 | + | app.wrap_content = !app.wrap_content; |
|
| 1134 | + | app.edit_scroll = 0; |
|
| 1135 | + | } |
|
| 1136 | + | _ => {} |
|
| 1137 | + | } |
|
| 1138 | + | } else { |
|
| 1139 | + | match key.code { |
|
| 1140 | + | KeyCode::Esc => app.cancel_edit(), |
|
| 1141 | + | KeyCode::Tab => app.focus = Focus::EditTitle, |
|
| 1142 | + | KeyCode::Enter => app.edit_content.push('\n'), |
|
| 1143 | + | KeyCode::Backspace => { |
|
| 1144 | + | app.edit_content.pop(); |
|
| 1145 | + | } |
|
| 1146 | + | KeyCode::Char(c) => app.edit_content.push(c), |
|
| 1147 | + | _ => {} |
|
| 1148 | + | } |
|
| 1149 | + | } |
|
| 1150 | + | } |
|
| 1151 | + | Focus::Search => match key.code { |
|
| 1152 | + | KeyCode::Esc => app.cancel_search(), |
|
| 1153 | + | KeyCode::Enter => app.confirm_search(), |
|
| 1154 | + | KeyCode::Backspace => { |
|
| 1155 | + | app.search_query.pop(); |
|
| 1156 | + | app.update_search_filter(); |
|
| 1157 | + | } |
|
| 1158 | + | KeyCode::Char(c) => { |
|
| 1159 | + | app.search_query.push(c); |
|
| 1160 | + | app.update_search_filter(); |
|
| 1161 | + | } |
|
| 1162 | + | _ => {} |
|
| 1163 | + | }, |
|
| 1164 | + | } |
|
| 1165 | + | } |
|
| 1166 | + | } |
|
| 1167 | + | } |
|
| 1168 | + | } |
|
| 1169 | + | ||
| 1170 | + | Ok(()) |
|
| 1171 | + | } |
| 1 | 1 | use serde::{Deserialize, Serialize}; |
|
| 2 | - | use std::path::PathBuf; |
|
| 2 | + | use std::path::{Path, PathBuf}; |
|
| 3 | 3 | ||
| 4 | 4 | #[derive(Debug, Default, Serialize, Deserialize)] |
|
| 5 | 5 | pub struct Config { |
|
| 9 | 9 | ||
| 10 | 10 | pub fn config_path() -> PathBuf { |
|
| 11 | 11 | let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); |
|
| 12 | - | PathBuf::from(home).join(".config/sipp/config.toml") |
|
| 12 | + | config_path_from(Path::new(&home)) |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | pub fn config_path_from(home: &Path) -> PathBuf { |
|
| 16 | + | home.join(".config/sipp/config.toml") |
|
| 13 | 17 | } |
|
| 14 | 18 | ||
| 15 | 19 | pub fn load_config() -> Config { |
|
| 16 | - | let path = config_path(); |
|
| 17 | - | match std::fs::read_to_string(&path) { |
|
| 20 | + | load_config_from(&config_path()) |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | pub fn load_config_from(path: &Path) -> Config { |
|
| 24 | + | match std::fs::read_to_string(path) { |
|
| 18 | 25 | Ok(contents) => toml::from_str(&contents).unwrap_or_default(), |
|
| 19 | 26 | Err(_) => Config::default(), |
|
| 20 | 27 | } |
|
| 21 | 28 | } |
|
| 22 | 29 | ||
| 23 | 30 | pub fn save_config(config: &Config) -> Result<(), Box<dyn std::error::Error>> { |
|
| 24 | - | let path = config_path(); |
|
| 31 | + | save_config_to(&config_path(), config) |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | pub fn save_config_to(path: &Path, config: &Config) -> Result<(), Box<dyn std::error::Error>> { |
|
| 25 | 35 | if let Some(parent) = path.parent() { |
|
| 26 | 36 | std::fs::create_dir_all(parent)?; |
|
| 27 | 37 | } |
|
| 28 | 38 | let contents = toml::to_string_pretty(config)?; |
|
| 29 | - | std::fs::write(&path, contents)?; |
|
| 39 | + | std::fs::write(path, contents)?; |
|
| 30 | 40 | Ok(()) |
|
| 31 | 41 | } |
|
| 32 | 42 | ||
| 68 | 78 | #[test] |
|
| 69 | 79 | fn load_config_missing_file_returns_default() { |
|
| 70 | 80 | let tmp = tempfile::tempdir().unwrap(); |
|
| 71 | - | // SAFETY: test-only, single-threaded test runner for this test |
|
| 72 | - | unsafe { std::env::set_var("HOME", tmp.path()); } |
|
| 73 | - | let config = load_config(); |
|
| 81 | + | let path = config_path_from(tmp.path()); |
|
| 82 | + | let config = load_config_from(&path); |
|
| 74 | 83 | assert!(config.remote_url.is_none()); |
|
| 75 | 84 | assert!(config.api_key.is_none()); |
|
| 76 | 85 | } |
|
| 78 | 87 | #[test] |
|
| 79 | 88 | fn save_and_load_config_roundtrip() { |
|
| 80 | 89 | let tmp = tempfile::tempdir().unwrap(); |
|
| 81 | - | // SAFETY: test-only, single-threaded test runner for this test |
|
| 82 | - | unsafe { std::env::set_var("HOME", tmp.path()); } |
|
| 90 | + | let path = config_path_from(tmp.path()); |
|
| 83 | 91 | ||
| 84 | 92 | let config = Config { |
|
| 85 | 93 | remote_url: Some("https://sipp.example.com".to_string()), |
|
| 86 | 94 | api_key: Some("key123".to_string()), |
|
| 87 | 95 | }; |
|
| 88 | - | save_config(&config).unwrap(); |
|
| 96 | + | save_config_to(&path, &config).unwrap(); |
|
| 89 | 97 | ||
| 90 | - | let loaded = load_config(); |
|
| 98 | + | let loaded = load_config_from(&path); |
|
| 91 | 99 | assert_eq!(loaded.remote_url, config.remote_url); |
|
| 92 | 100 | assert_eq!(loaded.api_key, config.api_key); |
|
| 93 | 101 | } |
|
| 23 | 23 | bytes.iter().map(|b| format!("{:02x}", b)).collect() |
|
| 24 | 24 | } |
|
| 25 | 25 | ||
| 26 | + | /// Constant-time API key comparison. Same shape as `verify_password`. |
|
| 27 | + | pub fn verify_api_key(input: &str, expected: &str) -> bool { |
|
| 28 | + | const LEN: usize = 256; |
|
| 29 | + | let mut a = [0u8; LEN]; |
|
| 30 | + | let mut b = [0u8; LEN]; |
|
| 31 | + | let ib = input.as_bytes(); |
|
| 32 | + | let eb = expected.as_bytes(); |
|
| 33 | + | a[..ib.len().min(LEN)].copy_from_slice(&ib[..ib.len().min(LEN)]); |
|
| 34 | + | b[..eb.len().min(LEN)].copy_from_slice(&eb[..eb.len().min(LEN)]); |
|
| 35 | + | let lengths_match = subtle::Choice::from((ib.len() == eb.len()) as u8); |
|
| 36 | + | (lengths_match & a.ct_eq(&b)).into() |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | /// Generate a 32-byte cryptographically random hex API key. |
|
| 40 | + | pub fn generate_api_key() -> String { |
|
| 41 | + | let mut bytes = [0u8; 32]; |
|
| 42 | + | rand::rngs::OsRng.fill_bytes(&mut bytes); |
|
| 43 | + | bytes.iter().map(|b| format!("{:02x}", b)).collect() |
|
| 44 | + | } |
|
| 45 | + | ||
| 26 | 46 | /// Build a session cookie with HttpOnly, SameSite=Strict, 7-day Max-Age. |
|
| 27 | 47 | pub fn build_session_cookie(token: &str, secure: bool) -> String { |
|
| 28 | 48 | let mut cookie = format!( |
|
| 127 | 147 | let a = generate_session_token(); |
|
| 128 | 148 | let b = generate_session_token(); |
|
| 129 | 149 | assert_ne!(a, b); |
|
| 150 | + | } |
|
| 151 | + | ||
| 152 | + | // ── verify_api_key ───────────────────────────────────────────────── |
|
| 153 | + | ||
| 154 | + | #[test] |
|
| 155 | + | fn verify_api_key_correct() { |
|
| 156 | + | assert!(verify_api_key("abc123", "abc123")); |
|
| 157 | + | } |
|
| 158 | + | ||
| 159 | + | #[test] |
|
| 160 | + | fn verify_api_key_wrong() { |
|
| 161 | + | assert!(!verify_api_key("abc123", "abc124")); |
|
| 162 | + | } |
|
| 163 | + | ||
| 164 | + | #[test] |
|
| 165 | + | fn verify_api_key_length_mismatch() { |
|
| 166 | + | assert!(!verify_api_key("short", "longer_key")); |
|
| 167 | + | } |
|
| 168 | + | ||
| 169 | + | // ── generate_api_key ─────────────────────────────────────────────── |
|
| 170 | + | ||
| 171 | + | #[test] |
|
| 172 | + | fn api_key_is_64_hex_chars() { |
|
| 173 | + | let key = generate_api_key(); |
|
| 174 | + | assert_eq!(key.len(), 64); |
|
| 175 | + | assert!(key.chars().all(|c| c.is_ascii_hexdigit())); |
|
| 176 | + | } |
|
| 177 | + | ||
| 178 | + | #[test] |
|
| 179 | + | fn api_key_unique_across_calls() { |
|
| 180 | + | assert_ne!(generate_api_key(), generate_api_key()); |
|
| 130 | 181 | } |
|
| 131 | 182 | ||
| 132 | 183 | // ── build_session_cookie ─────────────────────────────────────────── |
|
| 3 | 3 | export function Landing() { |
|
| 4 | 4 | return ( |
|
| 5 | 5 | <main |
|
| 6 | - | className="landing" |
|
| 7 | - | style={{ |
|
| 8 | - | position: "relative", |
|
| 9 | - | width: "100%", |
|
| 10 | - | background: "#121113 url('/bg.webp') center center / cover no-repeat", |
|
| 11 | - | }} |
|
| 6 | + | className="landing relative w-full bg-[#121113] bg-[url('/bg.webp')] bg-cover bg-center bg-no-repeat" |
|
| 12 | 7 | > |
|
| 13 | - | <div |
|
| 14 | - | style={{ |
|
| 15 | - | display: "flex", |
|
| 16 | - | width: "100%", |
|
| 17 | - | minHeight: "100vh", |
|
| 18 | - | flexDirection: "column", |
|
| 19 | - | alignItems: "center", |
|
| 20 | - | justifyContent: "center", |
|
| 21 | - | gap: "3rem", |
|
| 22 | - | padding: "1rem", |
|
| 23 | - | }} |
|
| 24 | - | > |
|
| 25 | - | <div |
|
| 26 | - | style={{ |
|
| 27 | - | display: "flex", |
|
| 28 | - | flexDirection: "column", |
|
| 29 | - | alignItems: "center", |
|
| 30 | - | gap: "1.5rem", |
|
| 31 | - | }} |
|
| 32 | - | > |
|
| 33 | - | <h1 |
|
| 34 | - | style={{ |
|
| 35 | - | textAlign: "center", |
|
| 36 | - | fontSize: "92px", |
|
| 37 | - | fontWeight: 400, |
|
| 38 | - | fontFamily: '"Commit Mono", monospace, sans-serif', |
|
| 39 | - | color: "#ffffff", |
|
| 40 | - | }} |
|
| 41 | - | > |
|
| 8 | + | <div className="flex w-full min-h-screen flex-col items-center justify-center gap-12 p-4"> |
|
| 9 | + | <div className="flex flex-col items-center gap-6"> |
|
| 10 | + | <h1 className="text-center sm:text-[92px] text-[64px] font-normal font-['Commit_Mono',monospace] text-white"> |
|
| 42 | 11 | Andromeda |
|
| 43 | 12 | </h1> |
|
| 44 | - | <h3 |
|
| 45 | - | style={{ |
|
| 46 | - | textAlign: "center", |
|
| 47 | - | fontSize: "16px", |
|
| 48 | - | fontWeight: 400, |
|
| 49 | - | fontFamily: '"Commit Mono", monospace, sans-serif', |
|
| 50 | - | color: "#ffffff", |
|
| 51 | - | }} |
|
| 52 | - | > |
|
| 53 | - | Minimal, self-hosted personal software in Rust |
|
| 13 | + | <h3 className="text-center text-lg font-normal font-['Commit_Mono',monospace] text-white"> |
|
| 14 | + | Minimal, self-hosted, personal software in Rust |
|
| 54 | 15 | </h3> |
|
| 55 | 16 | </div> |
|
| 56 | - | <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}> |
|
| 17 | + | <div className="flex items-center gap-4"> |
|
| 57 | 18 | <Button type="link" href="/quickstart"> |
|
| 58 | 19 | Get Started |
|
| 59 | 20 | </Button> |
| 1 | + | @import "tailwindcss"; |
|
| 2 | + | ||
| 1 | 3 | .vocs_DocsLayout_content:has(.landing) { |
|
| 2 | 4 | background: #121113 !important; |
|
| 3 | 5 | padding-top: 0 !important; |
| 8 | 8 | - Password authentication with session cookies |
|
| 9 | 9 | - Create, edit, and delete markdown notes |
|
| 10 | 10 | - Markdown rendering with strikethrough, tables, and task lists |
|
| 11 | + | - Interactive TUI with authenticated access for note management |
|
| 11 | 12 | - Dark themed UI with Commit Mono font |
|
| 12 | 13 | - SQLite for persistent storage |
|
| 13 | 14 | ||
| 15 | + | ## Install |
|
| 16 | + | ||
| 17 | + | Jotts can be installed several ways: |
|
| 18 | + | ||
| 19 | + | ### Homebrew |
|
| 20 | + | ||
| 21 | + | ```bash |
|
| 22 | + | brew install stevedylandev/tap/jotts |
|
| 23 | + | ``` |
|
| 24 | + | ||
| 25 | + | ### Releases |
|
| 26 | + | ||
| 27 | + | Visit the [releases](https://github.com/stevedylandev/andromeda/releases?q=jotts&expanded=true) page for binaries and install scripts. |
|
| 28 | + | ||
| 14 | 29 | ## Configure |
|
| 15 | 30 | ||
| 16 | 31 | ### Environment Variables |
|
| 17 | 32 | ||
| 18 | 33 | | Variable | Description | Default | |
|
| 19 | 34 | |---|---|---| |
|
| 20 | - | | `JOTTS_PASSWORD` | Password for login authentication | `changeme` | |
|
| 35 | + | | `JOTTS_PASSWORD` | Password for web login authentication | `changeme` | |
|
| 36 | + | | `JOTTS_API_KEY` | API key for protecting `/api/*` endpoints | — | |
|
| 21 | 37 | | `JOTTS_DB_PATH` | SQLite database file path | `jotts.sqlite` | |
|
| 22 | 38 | | `HOST` | Server bind address | `127.0.0.1` | |
|
| 23 | 39 | | `PORT` | Server port | `3000` | |
|
| 24 | 40 | | `COOKIE_SECURE` | Enable HTTPS-only cookies | `false` | |
|
| 25 | 41 | ||
| 42 | + | If `JOTTS_API_KEY` is not set, `/api/*` routes return `403`. |
|
| 43 | + | ||
| 26 | 44 | ## Deploy |
|
| 27 | 45 | ||
| 28 | 46 | ### Railway |
|
| 29 | 47 | ||
| 30 | - | The easiest way to deploy Jotts is with the one-click Railway template. See the [Deploying with Railway](/deploy-railway) guide for a walkthrough of the process. Jotts requires `JOTTS_PASSWORD` to be set during the configure step. |
|
| 48 | + | The easiest way to deploy Jotts is with the one-click Railway template. See the [Deploying with Railway](/deploy-railway) guide for a walkthrough of the process. Jotts requires `JOTTS_PASSWORD` to be set during the configure step. Set `JOTTS_API_KEY` too if you want to use the TUI against your remote instance. |
|
| 31 | 49 | ||
| 32 | 50 | [](https://railway.com/deploy/DLhUhH?referralCode=JGcIp6) |
|
| 33 | 51 | ||
| 44 | 62 | ||
| 45 | 63 | ### Binary |
|
| 46 | 64 | ||
| 47 | - | Install with Homebrew: |
|
| 65 | + | ```bash |
|
| 66 | + | cargo build --release -p jotts |
|
| 67 | + | ``` |
|
| 68 | + | ||
| 69 | + | The resulting binary is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly. |
|
| 70 | + | ||
| 71 | + | ## Use |
|
| 72 | + | ||
| 73 | + | ### CLI |
|
| 74 | + | ||
| 75 | + | ``` |
|
| 76 | + | jotts [OPTIONS] [FILE] [COMMAND] |
|
| 77 | + | ``` |
|
| 78 | + | ||
| 79 | + | #### Commands |
|
| 80 | + | ||
| 81 | + | | Command | Description | |
|
| 82 | + | |---|---| |
|
| 83 | + | | `server` | Start the web server | |
|
| 84 | + | | `tui` | Launch the interactive TUI | |
|
| 85 | + | | `auth` | Save remote URL and API key to config file | |
|
| 86 | + | ||
| 87 | + | #### Arguments |
|
| 88 | + | ||
| 89 | + | | Argument | Description | |
|
| 90 | + | |---|---| |
|
| 91 | + | | `[FILE]` | File path to create a note from | |
|
| 92 | + | ||
| 93 | + | #### Options |
|
| 94 | + | ||
| 95 | + | | Option | Description | |
|
| 96 | + | |---|---| |
|
| 97 | + | | `-r, --remote <URL>` | Remote server URL (env: `JOTTS_REMOTE_URL`) | |
|
| 98 | + | | `-k, --api-key <KEY>` | API key for authenticated operations (env: `JOTTS_API_KEY`) | |
|
| 99 | + | ||
| 100 | + | ### Server |
|
| 101 | + | ||
| 102 | + | Start the web server with: |
|
| 48 | 103 | ||
| 49 | 104 | ```bash |
|
| 50 | - | brew install stevedylandev/tap/jotts |
|
| 105 | + | jotts server --port 3000 --host localhost |
|
| 51 | 106 | ``` |
|
| 52 | 107 | ||
| 53 | - | Or grab a prebuilt binary from the [releases page](https://github.com/stevedylandev/andromeda/releases?q=jotts&expanded=true). |
|
| 108 | + | Log in at `/login` with your configured password. From there you can create, edit, and delete markdown notes. Notes support GitHub-flavored markdown including strikethrough, tables, and task lists. |
|
| 54 | 109 | ||
| 55 | - | You can also build from source: |
|
| 110 | + | #### API Endpoints |
|
| 111 | + | ||
| 112 | + | | Method | Endpoint | Description | |
|
| 113 | + | |---|---|---| |
|
| 114 | + | | `GET` | `/api/notes` | List all notes | |
|
| 115 | + | | `POST` | `/api/notes` | Create a note (`{"title": "...", "content": "..."}`) | |
|
| 116 | + | | `GET` | `/api/notes/{short_id}` | Get a note by ID | |
|
| 117 | + | | `PUT` | `/api/notes/{short_id}` | Update a note | |
|
| 118 | + | | `DELETE` | `/api/notes/{short_id}` | Delete a note by ID | |
|
| 119 | + | ||
| 120 | + | All `/api/*` endpoints require an `x-api-key` header matching `JOTTS_API_KEY`. |
|
| 121 | + | ||
| 122 | + | ### TUI |
|
| 123 | + | ||
| 124 | + | The Jotts TUI makes it easy to create, edit, and manage your notes either locally or remotely. |
|
| 56 | 125 | ||
| 57 | 126 | ```bash |
|
| 58 | - | cargo build --release -p jotts |
|
| 127 | + | # Launch TUI (default behavior when no file argument is given) |
|
| 128 | + | jotts |
|
| 129 | + | ||
| 130 | + | # Or explicitly |
|
| 131 | + | jotts tui |
|
| 132 | + | ||
| 133 | + | # With remote options |
|
| 134 | + | jotts -r https://your-jotts-instance.com -k your-api-key |
|
| 59 | 135 | ``` |
|
| 60 | 136 | ||
| 61 | - | The resulting binary is self-contained with all assets embedded. Copy it to your server with a configured `.env` file and run it directly. |
|
| 137 | + | #### Local Access |
|
| 62 | 138 | ||
| 63 | - | ## Use |
|
| 139 | + | If you are running `jotts` in the same directory as the `jotts.sqlite` file created by the server instance, the TUI will automatically access the database locally. |
|
| 140 | + | ||
| 141 | + | #### Remote Access |
|
| 142 | + | ||
| 143 | + | To access a remote instance: |
|
| 144 | + | - Set `JOTTS_API_KEY` on your server instance |
|
| 145 | + | - Run `jotts auth` to enter your server URL and API key, stored under `$HOME/.config/jotts` |
|
| 64 | 146 | ||
| 65 | - | Log in at `/login` with your configured password. From there you can create, edit, and delete markdown notes. Notes support GitHub-flavored markdown including strikethrough, tables, and task lists. |
|
| 147 | + | #### Keybindings |
|
| 148 | + | ||
| 149 | + | | Key | Action | |
|
| 150 | + | |---|---| |
|
| 151 | + | | `j`/`Down` | Move down / Scroll down | |
|
| 152 | + | | `k`/`Up` | Move up / Scroll up | |
|
| 153 | + | | `Enter` | Focus content pane | |
|
| 154 | + | | `Esc` | Back / Quit | |
|
| 155 | + | | `y` | Copy note content | |
|
| 156 | + | | `Y` | Copy note link | |
|
| 157 | + | | `o` | Open in browser | |
|
| 158 | + | | `e` | Edit note | |
|
| 159 | + | | `E` | Edit in `$EDITOR` | |
|
| 160 | + | | `d` | Delete note | |
|
| 161 | + | | `c` | Create note | |
|
| 162 | + | | `/` | Search notes | |
|
| 163 | + | | `^W` | Toggle word wrap (edit) | |
|
| 164 | + | | `r` | Refresh notes (remote only) | |
|
| 165 | + | | `q` | Quit | |
|
| 166 | + | | `?` | Toggle help | |
|