From b0dd19d8fcc5b0329bb66383135291e6c7f474a4 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Thu, 19 Mar 2026 22:15:53 -0500 Subject: [PATCH] refactor: split Tauri commands document GitHub Gist integration fix the release workflow race condition --- .github/workflows/release.yml | 28 +++- docs/integration/gh.md | 252 ++++++++++++++++++++++++++++++ docs/roadmap.md | 28 ++++ src-tauri/src/commands.rs | 248 ++--------------------------- src-tauri/src/commands/atproto.rs | 35 +++++ src-tauri/src/commands/md.rs | 177 +++++++++++++++++++++ src-tauri/src/commands/strings.rs | 32 ++++ 7 files changed, 558 insertions(+), 242 deletions(-) create mode 100644 docs/integration/gh.md create mode 100644 src-tauri/src/commands/atproto.rs create mode 100644 src-tauri/src/commands/md.rs create mode 100644 src-tauri/src/commands/strings.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3609be..a5a163e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,27 @@ on: workflow_dispatch: jobs: - release: + create-release: + permissions: + contents: write + runs-on: ubuntu-latest + outputs: + release-id: ${{ steps.create.outputs.id }} + steps: + - uses: actions/checkout@v4 + + - name: Create draft release + id: create + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: 'Writer ${{ github.ref_name }}' + body: 'See the assets to download and install this version.' + draft: true + prerelease: false + + build: + needs: create-release permissions: contents: write strategy: @@ -58,9 +78,5 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - tagName: v__VERSION__ - releaseName: 'Writer v__VERSION__' - releaseBody: 'See the assets to download and install this version.' - releaseDraft: true - prerelease: false + releaseId: ${{ needs.create-release.outputs.release-id }} args: ${{ matrix.args }} diff --git a/docs/integration/gh.md b/docs/integration/gh.md new file mode 100644 index 0000000..0125566 --- /dev/null +++ b/docs/integration/gh.md @@ -0,0 +1,252 @@ +--- +title: GitHub Gist Integration Spec +updated: 2026-03-19 +--- + +## Goals + +- Browse and import public gists from any GitHub user. +- Authenticate via GitHub OAuth device flow to access private gists. +- Publish documents as new gists (public or secret) and update existing ones. +- Follow the same architectural patterns as the AT Protocol / Tangled integration. + +## GitHub Gist API + +All endpoints use `https://api.github.com`. Gists are lightweight multi-file snippets. We treat each gist as a single document, using the first file when a gist contains multiple files. + +### Relevant Endpoints + +| Operation | Endpoint | Method | Auth | +| ------------- | ------------------------- | ------ | ---------- | +| List public | `/users/{username}/gists` | GET | No | +| List personal | `/gists` | GET | Required | +| Get | `/gists/{gist_id}` | GET | Optional\* | +| Create | `/gists` | POST | Required | +| Update | `/gists/{gist_id}` | PATCH | Required | +| Delete | `/gists/{gist_id}` | DELETE | Required | +| List starred | `/gists/starred` | GET | Required | + +\* Auth optional for public gists; required for secret gists owned by the user. + +Pagination: `per_page` (max 100), `page`, plus `Link` header with `rel="next"`. + +Rate limits: 60 req/hr unauthenticated, 5 000 req/hr with token. + +### Gist Record Shape + +```json +{ + "id": "aa5a315d61ae9438b18d", + "description": "Example gist", + "public": true, + "html_url": "https://gist.github.com/...", + "files": { + "hello.md": { + "filename": "hello.md", + "type": "text/markdown", + "language": "Markdown", + "size": 1234, + "content": "..." + } + }, + "owner": { "login": "octocat", "avatar_url": "..." }, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-15T12:00:00Z" +} +``` + +Note: `GET /users/{username}/gists` returns truncated gist objects (no `content` in files). A follow-up `GET /gists/{gist_id}` is needed to fetch full file contents. + +## Authentication + +### GitHub OAuth Device Flow + +The device flow is ideal for desktop/CLI apps — no loopback server or redirect URI needed. + +1. `POST https://github.com/login/device/code` with `client_id` and `scope=gist`. +2. Response includes `device_code`, `user_code`, and `verification_uri`. +3. Display `user_code` to the user and open `verification_uri` in the system browser. +4. Poll `POST https://github.com/login/oauth/access_token` with `device_code` + `client_id` until the user authorizes (respect `interval` from step 2). +5. Receive `access_token` (no refresh token for device flow; token does not expire unless revoked). + +### Token Lifecycle + +- **Access token:** Does not expire. Valid until the user revokes it in GitHub settings. +- **Scope:** `gist` — read/write access to gists only. +- **Storage:** Persist token in app data directory (`github-token.json`). Encrypt at rest via Tauri's platform keychain integration if available, otherwise store as plaintext JSON alongside other session files. + +### Client Registration + +Register a GitHub OAuth App at `https://github.com/settings/developers`: + +- Enable "Device flow" in the app settings. +- No callback URL required for device flow. +- `client_id` is public (no client secret needed for device flow). + +## Data Flow + +```text +┌──────────────┐ Tauri commands ┌───────────────────┐ +│ Frontend │ ───────────────────── │ src-tauri/ │ +│ (React/TS) │ │ commands.rs │ +│ │ ◄── CommandResponse │ + github.rs │ +└──────────────┘ └────────┬──────────┘ + │ + reqwest + token auth + │ + ┌────────▼──────────┐ + │ api.github.com │ + │ (REST API v3) │ + └───────────────────┘ +``` + +## Backend Module Structure + +```sh +src-tauri/src/ +├── github/ +│ ├── mod.rs # re-exports GithubState, GithubSession, GistRecord +│ ├── auth.rs # device flow, token persistence, logout +│ └── gists.rs # list, get, create, update, delete helpers +``` + +### Types + +```rust +pub struct GithubState { + client: reqwest::Client, + token: RwLock>, + token_path: PathBuf, + username: RwLock>, +} + +pub struct GithubSession { + pub username: String, + pub avatar_url: String, + pub token_scope: String, +} + +pub struct DeviceCodeResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub interval: u64, + pub expires_in: u64, +} + +pub struct GistRecord { + pub id: String, + pub filename: String, + pub description: String, + pub contents: String, + pub language: Option, + pub public: bool, + pub html_url: String, + pub owner: String, + pub created_at: String, + pub updated_at: String, +} +``` + +### Tauri Commands + +| Command | Args | Returns | Auth | +| -------------------- | ------------------------------------------ | ---------------------------------------- | -------- | +| `github_device_code` | — | `CommandResponse` | No | +| `github_poll_token` | `device_code: String` | `CommandResponse` | No | +| `github_logout` | — | `CommandResponse<()>` | Required | +| `github_session` | — | `CommandResponse>` | No | +| `gist_list_public` | `username: String` | `CommandResponse>` | No | +| `gist_list_personal` | — | `CommandResponse>` | Required | +| `gist_get` | `gist_id: String` | `CommandResponse` | Optional | +| `gist_create` | `filename, description, contents, public` | `CommandResponse` | Required | +| `gist_update` | `gist_id, filename, description, contents` | `CommandResponse` | Required | +| `gist_delete` | `gist_id: String` | `CommandResponse<()>` | Required | + +The two-step device flow (`github_device_code` → `github_poll_token`) lets the frontend control the polling UI. `github_poll_token` blocks server-side (with timeout) so the frontend doesn't need to manage polling intervals. + +## Frontend Structure + +```sh +src/ +├── state/stores/ui.ts # github sheet mode + session state +├── state/selectors.ts # useGithubUiState() selector hook +├── ports/commands.ts # github_* and gist_* command wrappers +├── hooks/controllers/ +│ └── useGithubController.ts +├── components/ +│ ├── Github/ +│ │ ├── GithubAuthSheet.tsx # device flow + session sheet +│ │ ├── GistImportSheet.tsx # public/private gist browser +│ │ └── GistPublishSheet.tsx # publish/update gist form +│ └── AppLayout/LayoutSettingsPanel/ +│ └── GithubSection.tsx +``` + +### UI State + +```typescript +type GithubSheetMode = "closed" | "login" | "session" | "import" | "publish"; + +type GithubUiState = { + githubSheetMode: GithubSheetMode; + githubSession: GithubSession | null; + githubHydrated: boolean; + githubPending: boolean; +}; +``` + +### Auth Flow UI + +1. User clicks GitHub button in toolbar. +2. If no session → open login sheet. +3. Call `github_device_code` → display `user_code` with a "Copy" button and open `verification_uri` in browser. +4. Call `github_poll_token` (awaits backend polling). +5. On success → transition to session sheet showing `username` and avatar. +6. If session exists → show session sheet with Import / Publish / Logout actions. + +### Import Flow UI + +Same two-column layout as Tangled ImportSheet: + +1. **Username input** — enter a GitHub username and browse public gists (no auth required). +2. **"My Gists" toggle** — when authenticated, switch to personal gist list (includes secret gists). +3. **Gist list** — clickable rows with file icon, filename, description, visibility badge, date. +4. **Preview pane** — selected gist content with syntax highlighting. +5. **Destination** — location dropdown + relative path input. +6. **Import** — `doc_exists` guard + `doc_save`. + +Non-markdown/non-plaintext gist content is wrapped in a fenced code block with the language tag from the gist's `language` field. + +### Publish Flow UI + +1. **Filename** — defaults to current document filename. +2. **Description** — user-provided summary. +3. **Visibility** — public or secret toggle (default: secret). +4. **Preview** — document content preview. +5. **Publish** — call `gist_create`, store origin for future updates. +6. **Update** — if origin exists, offer "Update Gist" instead of "Publish". + +## Sync & Origin Tracking + +When a document is published as a gist or imported from one, store the association in SQLite: + +- `gist_origin` table: `doc_id`, `gist_id`, `source_username`, `public`, `last_synced_at`, `remote_updated_at`. +- On publish: insert/update origin row, store `gist_id` for future `PATCH` updates. +- On import: insert origin row linking the new document to the source gist. +- Re-publish: detect local modifications to a previously published document; surface "Update Gist" action. +- Re-import: compare `updated_at` timestamp to detect remote changes; prompt with diff. + +## Constraints + +- **Rate limits:** Surface remaining rate limit info from `X-RateLimit-Remaining` header. Warn the user when approaching the limit. Unauthenticated browsing is limited to 60 req/hr — encourage auth for heavy use. +- **Gist size:** GitHub gists have a 10 MB size limit per file and 300 files per gist. Reject oversized documents with a clear error before upload. +- **Multi-file gists:** For import, use the first file in the gist. For publish, create single-file gists. Display file count badge on multi-file gists in the browser. +- **Truncated content:** The list endpoint returns truncated file content (first 1 MB). Always fetch the full gist via `GET /gists/{id}` before import. +- **Secret vs private:** GitHub calls them "secret" gists (accessible via URL but not listed publicly). Use "secret" in API calls but display as "Private" in the UI for clarity. + +## References + +- [GitHub Gist REST API](https://docs.github.com/en/rest/gists) +- [GitHub OAuth Device Flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow) +- [GitHub Rate Limiting](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api) diff --git a/docs/roadmap.md b/docs/roadmap.md index a192f1b..7c8a93d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -117,3 +117,31 @@ Improve file organization 3. **Recovery** - Corrupt settings/workspace → app resets safely - Missing location root → UI prompts to relink/remove + +## GitHub Gist integration + +Import public gists, read personal/secret gists, and publish documents as gists. +Full spec in [docs/integration/gh.md](../integration/gh.md). + +### Part 1 — Public gist browsing + +1. **Backend gist module** — `src-tauri/src/github/{mod,gists}.rs` with `GithubState`, `GistRecord` +2. **Tauri commands** — `gist_list_public`, `gist_get` (no auth required) +3. **Frontend import UI** — `GistImportSheet.tsx` with username input, gist browser, preview, import to location +4. **Port + state wiring** — command wrappers in `ports/commands.ts`, `GithubUiState` in Zustand store, `useGithubUiState` selector + +### Part 2 — Auth & private gists + +1. **GitHub OAuth device flow** — `src-tauri/src/github/auth.rs` with `github_device_code`, `github_poll_token` +2. **Token persistence** — store access token in app data dir, restore on startup +3. **Tauri commands** — `github_session`, `github_logout`, `gist_list_personal` +4. **Auth UI** — `GithubAuthSheet.tsx` with device code display, session indicator, logout +5. **"My Gists" mode** — toggle in import sheet to browse personal + secret gists + +### Part 3 — Publish & update + +1. **Tauri commands** — `gist_create`, `gist_update`, `gist_delete` +2. **Publish UI** — `GistPublishSheet.tsx` with filename, description, visibility toggle, preview +3. **Origin tracking** — `gist_origin` SQLite table linking documents to gist IDs +4. **Re-publish** — detect previously published docs, surface "Update Gist" action +5. **Re-import** — compare `updated_at` to detect remote changes, prompt with diff diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b921f50..4e6b0fb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,4 +1,4 @@ -use super::atproto::{AtProtoState, SessionInfo, StringRecord}; +use super::atproto::AtProtoState; use super::capture; use super::locations::*; use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; @@ -8,14 +8,21 @@ use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Emitter, State}; use tauri_plugin_dialog::DialogExt; use tauri_plugin_fs::FsExt; +use writer_core::scan_style_matches; use writer_core::{ - scan_style_matches, AppError, BackendEvent, CommandResult, DocContent, DocId, DocListOptions, DocMeta, - LocationDescriptor, LocationId, SaveResult, SearchFilters, SearchHit, StyleCategorySettings, StyleMatch, - StylePatternInput, StyleScanInput, + AppError, BackendEvent, CommandResult, DocContent, DocId, DocListOptions, DocMeta, LocationDescriptor, LocationId, + SaveResult, SearchFilters, SearchHit, StyleCategorySettings, StyleMatch, StylePatternInput, StyleScanInput, }; -use writer_md::{DocxExportResult, MarkdownEngine, MarkdownProfile, PdfRenderResult, RenderResult, TextExportResult}; use writer_store::{Store, StyleCheckSettings, UiLayoutSettings}; +mod atproto; +mod md; +mod strings; + +pub use atproto::*; +pub use md::*; +pub use strings::*; + type CommandResponse = std::result::Result, AppError>; /// Application state shared across commands @@ -33,65 +40,6 @@ impl AppState { } } -#[tauri::command] -pub async fn atproto_login(state: State<'_, AppState>, handle: String) -> CommandResponse { - log::info!("Starting AT Protocol login flow"); - - match state.atproto.login(&handle).await { - Ok(session) => Ok(CommandResult::ok(session)), - Err(error) => { - log::error!("AT Protocol login failed: {}", error); - Ok(CommandResult::err(error)) - } - } -} - -#[tauri::command] -pub async fn atproto_logout(state: State<'_, AppState>) -> CommandResponse<()> { - log::info!("Logging out of AT Protocol"); - - match state.atproto.logout().await { - Ok(()) => Ok(CommandResult::ok(())), - Err(error) => { - log::error!("AT Protocol logout failed: {}", error); - Ok(CommandResult::err(error)) - } - } -} - -#[tauri::command] -pub async fn atproto_session_status(state: State<'_, AppState>) -> CommandResponse> { - Ok(CommandResult::ok(state.atproto.session_status().await)) -} - -#[tauri::command] -pub async fn string_list(state: State<'_, AppState>, did_or_handle: String) -> CommandResponse> { - log::info!("Listing Tangled strings"); - - match state.atproto.string_list(&did_or_handle).await { - Ok(records) => Ok(CommandResult::ok(records)), - Err(error) => { - log::error!("Failed to list Tangled strings: {}", error); - Ok(CommandResult::err(error)) - } - } -} - -#[tauri::command] -pub async fn string_get( - state: State<'_, AppState>, did_or_handle: String, tid: String, -) -> CommandResponse { - log::info!("Fetching Tangled string"); - - match state.atproto.string_get(&did_or_handle, &tid).await { - Ok(record) => Ok(CommandResult::ok(record)), - Err(error) => { - log::error!("Failed to fetch Tangled string: {}", error); - Ok(CommandResult::err(error)) - } - } -} - #[tauri::command] pub fn app_version_get() -> CommandResponse { Ok(CommandResult::ok( @@ -924,171 +872,6 @@ pub fn search( } } -/// Renders markdown text to HTML with metadata extraction -/// -/// This command takes document reference, text content, and a rendering profile, -/// returning HTML with source position attributes for editor-preview sync. -#[tauri::command] -pub fn markdown_render( - _: State<'_, AppState>, location_id: i64, rel_path: String, text: String, profile: Option, -) -> CommandResponse { - let location_id = LocationId(location_id); - let rel_path = PathBuf::from(&rel_path); - - log::debug!( - "Rendering markdown: location={:?}, path={:?}, profile={:?}, text_len={}", - location_id, - rel_path, - profile, - text.len() - ); - - let engine = MarkdownEngine::new(); - let profile = profile.unwrap_or_default(); - - match engine.render(&text, profile) { - Ok(result) => { - log::debug!( - "Markdown rendered successfully: html_len={}, outline_items={}", - result.html.len(), - result.metadata.outline.len() - ); - Ok(CommandResult::ok(result)) - } - Err(e) => { - log::error!("Failed to render markdown: {}", e); - Ok(CommandResult::err(AppError::new( - writer_core::ErrorCode::Parse, - format!("Failed to render markdown: {}", e), - ))) - } - } -} - -/// Renders markdown text to a PDF-compatible AST -/// -/// This command takes document text and returns a structured AST -/// suitable for rendering to PDF on the frontend with @react-pdf/renderer. -#[tauri::command] -pub fn markdown_render_for_pdf( - _: State<'_, AppState>, location_id: i64, rel_path: String, text: String, profile: Option, -) -> CommandResponse { - let location_id = LocationId(location_id); - let rel_path = PathBuf::from(&rel_path); - - log::debug!( - "Rendering markdown for PDF: location={:?}, path={:?}, profile={:?}, text_len={}", - location_id, - rel_path, - profile, - text.len() - ); - - let engine = MarkdownEngine::new(); - let profile = profile.unwrap_or(MarkdownProfile::Extended); - - match engine.render_for_pdf(&text, profile) { - Ok(result) => { - log::debug!( - "Markdown rendered for PDF successfully: nodes={}, word_count={}", - result.nodes.len(), - result.word_count - ); - Ok(CommandResult::ok(result)) - } - Err(e) => { - log::error!("Failed to render markdown for PDF: {}", e); - Ok(CommandResult::err(AppError::new( - writer_core::ErrorCode::Parse, - format!("Failed to render markdown for PDF: {}", e), - ))) - } - } -} - -/// Renders markdown text to plaintext format -/// -/// This command takes document text and returns plain text with -/// Markdown formatting stripped but logical structure preserved. -#[tauri::command] -pub fn markdown_render_for_text( - _: State<'_, AppState>, location_id: i64, rel_path: String, text: String, profile: Option, -) -> CommandResponse { - let location_id = LocationId(location_id); - let rel_path = PathBuf::from(&rel_path); - - log::debug!( - "Rendering markdown for text export: location={:?}, path={:?}, profile={:?}, text_len={}", - location_id, - rel_path, - profile, - text.len() - ); - - let engine = MarkdownEngine::new(); - let profile = profile.unwrap_or(MarkdownProfile::Extended); - - match engine.render_for_text(&text, profile) { - Ok(result) => { - log::debug!( - "Markdown rendered for text export successfully: text_len={}, word_count={}", - result.text.len(), - result.word_count - ); - Ok(CommandResult::ok(result)) - } - Err(e) => { - log::error!("Failed to render markdown for text export: {}", e); - Ok(CommandResult::err(AppError::new( - writer_core::ErrorCode::Parse, - format!("Failed to render markdown for text export: {}", e), - ))) - } - } -} - -/// Renders markdown text to DOCX format -/// -/// This command takes document text and returns DOCX bytes -/// generated via docx-rs with support for headings, bold, italic, -/// code font, ordered/unordered lists, blockquotes, and code blocks. -#[tauri::command] -pub fn markdown_render_for_docx( - _: State<'_, AppState>, location_id: i64, rel_path: String, text: String, profile: Option, -) -> CommandResponse { - let location_id = LocationId(location_id); - let rel_path = PathBuf::from(&rel_path); - - log::debug!( - "Rendering markdown for DOCX: location={:?}, path={:?}, profile={:?}, text_len={}", - location_id, - rel_path, - profile, - text.len() - ); - - let engine = MarkdownEngine::new(); - let profile = profile.unwrap_or(MarkdownProfile::Extended); - - match engine.render_for_docx(&text, profile) { - Ok(result) => { - log::debug!( - "Markdown rendered for DOCX successfully: data_len={}, word_count={}", - result.data.len(), - result.word_count - ); - Ok(CommandResult::ok(result)) - } - Err(e) => { - log::error!("Failed to render markdown for DOCX: {}", e); - Ok(CommandResult::err(AppError::new( - writer_core::ErrorCode::Parse, - format!("Failed to render markdown for DOCX: {}", e), - ))) - } - } -} - #[tauri::command] pub fn style_check_get(state: State<'_, AppState>) -> CommandResponse { log::debug!("Loading persisted style check settings"); @@ -1303,10 +1086,3 @@ pub fn global_capture_validate_shortcut(shortcut: String) -> CommandResponse Ok(CommandResult::err(e)), } } - -/// Returns the markdown help guide content -#[tauri::command] -pub fn markdown_help_get() -> CommandResponse { - log::debug!("Fetching markdown help content"); - Ok(CommandResult::ok(writer_store::get_markdown_help().to_string())) -} diff --git a/src-tauri/src/commands/atproto.rs b/src-tauri/src/commands/atproto.rs new file mode 100644 index 0000000..b6159a0 --- /dev/null +++ b/src-tauri/src/commands/atproto.rs @@ -0,0 +1,35 @@ +use super::{AppState, CommandResponse}; +use crate::atproto::SessionInfo; +use tauri::State; +use writer_core::CommandResult; + +#[tauri::command] +pub async fn atproto_login(state: State<'_, AppState>, handle: String) -> CommandResponse { + log::info!("Starting AT Protocol login flow"); + + match state.atproto.login(&handle).await { + Ok(session) => Ok(CommandResult::ok(session)), + Err(error) => { + log::error!("AT Protocol login failed: {}", error); + Ok(CommandResult::err(error)) + } + } +} + +#[tauri::command] +pub async fn atproto_logout(state: State<'_, AppState>) -> CommandResponse<()> { + log::info!("Logging out of AT Protocol"); + + match state.atproto.logout().await { + Ok(()) => Ok(CommandResult::ok(())), + Err(error) => { + log::error!("AT Protocol logout failed: {}", error); + Ok(CommandResult::err(error)) + } + } +} + +#[tauri::command] +pub async fn atproto_session_status(state: State<'_, AppState>) -> CommandResponse> { + Ok(CommandResult::ok(state.atproto.session_status().await)) +} diff --git a/src-tauri/src/commands/md.rs b/src-tauri/src/commands/md.rs new file mode 100644 index 0000000..eccc601 --- /dev/null +++ b/src-tauri/src/commands/md.rs @@ -0,0 +1,177 @@ +use super::{AppState, CommandResponse}; +use std::path::PathBuf; +use tauri::State; +use writer_core::{AppError, CommandResult, LocationId}; +use writer_md::{DocxExportResult, MarkdownEngine, MarkdownProfile, PdfRenderResult, RenderResult, TextExportResult}; + +/// Returns the markdown help guide content +#[tauri::command] +pub fn markdown_help_get() -> CommandResponse { + log::debug!("Fetching markdown help content"); + Ok(CommandResult::ok(writer_store::get_markdown_help().to_string())) +} + +/// Renders markdown text to HTML with metadata extraction +/// +/// This command takes document reference, text content, and a rendering profile, +/// returning HTML with source position attributes for editor-preview sync. +#[tauri::command] +pub fn markdown_render( + _: State<'_, AppState>, location_id: i64, rel_path: String, text: String, profile: Option, +) -> CommandResponse { + let location_id = LocationId(location_id); + let rel_path = PathBuf::from(&rel_path); + + log::debug!( + "Rendering markdown: location={:?}, path={:?}, profile={:?}, text_len={}", + location_id, + rel_path, + profile, + text.len() + ); + + let engine = MarkdownEngine::new(); + let profile = profile.unwrap_or_default(); + + match engine.render(&text, profile) { + Ok(result) => { + log::debug!( + "Markdown rendered successfully: html_len={}, outline_items={}", + result.html.len(), + result.metadata.outline.len() + ); + Ok(CommandResult::ok(result)) + } + Err(e) => { + log::error!("Failed to render markdown: {}", e); + Ok(CommandResult::err(AppError::new( + writer_core::ErrorCode::Parse, + format!("Failed to render markdown: {}", e), + ))) + } + } +} + +/// Renders markdown text to a PDF-compatible AST +/// +/// This command takes document text and returns a structured AST +/// suitable for rendering to PDF on the frontend with @react-pdf/renderer. +#[tauri::command] +pub fn markdown_render_for_pdf( + _: State<'_, AppState>, location_id: i64, rel_path: String, text: String, profile: Option, +) -> CommandResponse { + let location_id = LocationId(location_id); + let rel_path = PathBuf::from(&rel_path); + + log::debug!( + "Rendering markdown for PDF: location={:?}, path={:?}, profile={:?}, text_len={}", + location_id, + rel_path, + profile, + text.len() + ); + + let engine = MarkdownEngine::new(); + let profile = profile.unwrap_or(MarkdownProfile::Extended); + + match engine.render_for_pdf(&text, profile) { + Ok(result) => { + log::debug!( + "Markdown rendered for PDF successfully: nodes={}, word_count={}", + result.nodes.len(), + result.word_count + ); + Ok(CommandResult::ok(result)) + } + Err(e) => { + log::error!("Failed to render markdown for PDF: {}", e); + Ok(CommandResult::err(AppError::new( + writer_core::ErrorCode::Parse, + format!("Failed to render markdown for PDF: {}", e), + ))) + } + } +} + +/// Renders markdown text to plaintext format +/// +/// This command takes document text and returns plain text with +/// Markdown formatting stripped but logical structure preserved. +#[tauri::command] +pub fn markdown_render_for_text( + _: State<'_, AppState>, location_id: i64, rel_path: String, text: String, profile: Option, +) -> CommandResponse { + let location_id = LocationId(location_id); + let rel_path = PathBuf::from(&rel_path); + + log::debug!( + "Rendering markdown for text export: location={:?}, path={:?}, profile={:?}, text_len={}", + location_id, + rel_path, + profile, + text.len() + ); + + let engine = MarkdownEngine::new(); + let profile = profile.unwrap_or(MarkdownProfile::Extended); + + match engine.render_for_text(&text, profile) { + Ok(result) => { + log::debug!( + "Markdown rendered for text export successfully: text_len={}, word_count={}", + result.text.len(), + result.word_count + ); + Ok(CommandResult::ok(result)) + } + Err(e) => { + log::error!("Failed to render markdown for text export: {}", e); + Ok(CommandResult::err(AppError::new( + writer_core::ErrorCode::Parse, + format!("Failed to render markdown for text export: {}", e), + ))) + } + } +} + +/// Renders markdown text to DOCX format +/// +/// This command takes document text and returns DOCX bytes +/// generated via docx-rs with support for headings, bold, italic, +/// code font, ordered/unordered lists, blockquotes, and code blocks. +#[tauri::command] +pub fn markdown_render_for_docx( + _: State<'_, AppState>, location_id: i64, rel_path: String, text: String, profile: Option, +) -> CommandResponse { + let location_id = LocationId(location_id); + let rel_path = PathBuf::from(&rel_path); + + log::debug!( + "Rendering markdown for DOCX: location={:?}, path={:?}, profile={:?}, text_len={}", + location_id, + rel_path, + profile, + text.len() + ); + + let engine = MarkdownEngine::new(); + let profile = profile.unwrap_or(MarkdownProfile::Extended); + + match engine.render_for_docx(&text, profile) { + Ok(result) => { + log::debug!( + "Markdown rendered for DOCX successfully: data_len={}, word_count={}", + result.data.len(), + result.word_count + ); + Ok(CommandResult::ok(result)) + } + Err(e) => { + log::error!("Failed to render markdown for DOCX: {}", e); + Ok(CommandResult::err(AppError::new( + writer_core::ErrorCode::Parse, + format!("Failed to render markdown for DOCX: {}", e), + ))) + } + } +} diff --git a/src-tauri/src/commands/strings.rs b/src-tauri/src/commands/strings.rs new file mode 100644 index 0000000..f978a30 --- /dev/null +++ b/src-tauri/src/commands/strings.rs @@ -0,0 +1,32 @@ +use super::{AppState, CommandResponse}; +use crate::atproto::StringRecord; +use tauri::State; +use writer_core::CommandResult; + +#[tauri::command] +pub async fn string_list(state: State<'_, AppState>, did_or_handle: String) -> CommandResponse> { + log::info!("Listing Tangled strings"); + + match state.atproto.string_list(&did_or_handle).await { + Ok(records) => Ok(CommandResult::ok(records)), + Err(error) => { + log::error!("Failed to list Tangled strings: {}", error); + Ok(CommandResult::err(error)) + } + } +} + +#[tauri::command] +pub async fn string_get( + state: State<'_, AppState>, did_or_handle: String, tid: String, +) -> CommandResponse { + log::info!("Fetching Tangled string"); + + match state.atproto.string_get(&did_or_handle, &tid).await { + Ok(record) => Ok(CommandResult::ok(record)), + Err(error) => { + log::error!("Failed to fetch Tangled string: {}", error); + Ok(CommandResult::err(error)) + } + } +} -- 2.51.2