diff --git a/README.md b/README.md index ca4d375..4055b14 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The project is under active development. Core blogging workflows are broadly ava - Local preview in the app or system browser. - Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using online or local OpenAI-compatible endpoints with airplane-mode gating. - SSH-agent-based SCP or rsync publishing. -- Site, media, and translation validation plus Blogmark capture and Lua transforms. +- Site, media, and translation validation plus `ruds://new-post` Blogmark capture and Lua transforms; bDS2 keeps its separate `bds2://` bookmarklet protocol. RuDS uses no JavaScript application runtime and loads no CSS or JavaScript from CDNs. The preview is served by the Rust application and displayed by the operating-system webview. diff --git a/RUST_PLAN_EXTENSION.md b/RUST_PLAN_EXTENSION.md index 2a3acee..3ac47a2 100644 --- a/RUST_PLAN_EXTENSION.md +++ b/RUST_PLAN_EXTENSION.md @@ -67,12 +67,12 @@ The generated Lua documentation itself is a core requirement. Done: -- Menu file parsing/rendering, Home-first normalization, macOS URL plumbing, and Blogmark deep-link parsing. +- Menu file parsing/rendering and Home-first normalization. +- macOS URL plumbing and the sole bDS2-compatible Blogmark action at `ruds://new-post`; RuDS neither registers nor accepts bDS2's `bds2://` scheme. Open: - OPML/menu editor UI. -- Remaining application deep-link parity flows. - Replace the Menu Editor placeholder. ### CLI, MCP, and domain events — Open @@ -89,12 +89,8 @@ Open: Done: -- Blogmark deep-link parsing, content capture, post import, transform selection, and Lua transform execution. - -Open: - -- Complete the dedicated user-facing capture/review workflow where the current implicit flow does not cover the bDS2 behavior. -- Close remaining transform-chain differences found against the specs and bDS2. +- Blogmark bookmarklet copy, `ruds://new-post` parsing, content capture, post import, transform selection, and Lua transform execution. +- bDS2-compatible delivery behavior without adding unsupported deep-link actions. ### Headless server — Open diff --git a/SPECIFICATION_INDEX.md b/SPECIFICATION_INDEX.md index cd2cb34..398cc83 100644 --- a/SPECIFICATION_INDEX.md +++ b/SPECIFICATION_INDEX.md @@ -39,6 +39,7 @@ Diese Datei dient als Index zu allen Allium-Spezifikationen und Schema-Inventari | Datei | Scope | Status | Beschreibung | |-------|-------|--------|--------------| | `git.allium` | Extension A | ✅ Existiert | Git-Operationen, LFS, Reconciliation | +| `import.allium` | Extension B | ✨ Neu | WordPress-WXR-Analyse, Konfliktprüfung und Importausführung | | `mcp.allium` | Extension G | ✅ Existiert | MCP-Server (Tools, Resources) | | `ai.allium` | Core/Extension C | ✅ Existiert | AI One-Shot Tasks und Chat | | `embedding.allium` | Extension D | ✅ Existiert | Semantic Similarity (HNSW) | diff --git a/crates/bds-core/src/engine/blogmark.rs b/crates/bds-core/src/engine/blogmark.rs index 83d046c..9acb6f3 100644 --- a/crates/bds-core/src/engine/blogmark.rs +++ b/crates/bds-core/src/engine/blogmark.rs @@ -15,6 +15,7 @@ const MAX_TITLE_LENGTH: usize = 200; const MAX_URL_LENGTH: usize = 2_048; const MAX_TOASTS_TOTAL: usize = 20; const MAX_TOAST_LENGTH: usize = 300; +const BLOGMARK_SCHEME: &str = "ruds"; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct BlogmarkCandidate { @@ -36,7 +37,7 @@ pub struct BlogmarkImportResult { pub fn parse_deep_link(raw: &str) -> EngineResult { let parsed = Url::parse(raw).map_err(|_| EngineError::Validation("invalid blogmark URL".into()))?; - if parsed.scheme() != "bds2" { + if parsed.scheme() != BLOGMARK_SCHEME { return Err(EngineError::Validation( "unsupported blogmark scheme".into(), )); @@ -66,6 +67,14 @@ pub fn parse_deep_link(raw: &str) -> EngineResult { }) } +pub fn bookmarklet(project_id: &str) -> String { + let project_id = + url::form_urlencoded::byte_serialize(project_id.as_bytes()).collect::(); + format!( + "javascript:(()=>{{const t=encodeURIComponent(document.title||'');const u=encodeURIComponent(location.href||'');location.href='ruds://new-post?title='+t+'&url='+u+'&project_id={project_id}';}})();" + ) +} + pub fn receive_deep_link( conn: &Connection, data_dir: &Path, @@ -281,7 +290,7 @@ mod tests { #[test] fn parses_and_hardens_blogmark_links() { let candidate = parse_deep_link( - "bds2://new-post?title=%00Hello&url=https%3A%2F%2Fuser%3Apass%40example.com%2Fa%23frag&tags=one%2Ctwo", + "ruds://new-post?title=%00Hello&url=https%3A%2F%2Fuser%3Apass%40example.com%2Fa%23frag&tags=one%2Ctwo", ) .unwrap(); assert_eq!(candidate.title, "Hello"); @@ -292,13 +301,22 @@ mod tests { #[test] fn rejects_other_schemes_and_actions() { assert!(parse_deep_link("bds://new-post?title=x").is_err()); - assert!(parse_deep_link("bds2://other?title=x").is_err()); + assert!(parse_deep_link("bds2://new-post?title=x").is_err()); + assert!(parse_deep_link("ruds://other?title=x").is_err()); + } + + #[test] + fn bookmarklet_targets_only_ruds_and_the_selected_project() { + let value = bookmarklet("project & seven"); + assert!(value.contains("ruds://new-post?")); + assert!(value.contains("project_id=project+%26+seven")); + assert!(!value.contains("bds2://")); } #[test] fn invalid_source_url_never_reaches_candidate() { let candidate = - parse_deep_link("bds2://new-post?title=x&url=javascript%3Aalert%281%29").unwrap(); + parse_deep_link("ruds://new-post?title=x&url=javascript%3Aalert%281%29").unwrap(); assert!(candidate.url.is_none()); } @@ -328,7 +346,7 @@ mod tests { db.conn(), directory.path(), &project.id, - "bds2://new-post?title=Example&url=https%3A%2F%2Fexample.com", + "ruds://new-post?title=Example&url=https%3A%2F%2Fexample.com", ) .unwrap(); diff --git a/crates/bds-ui/Cargo.toml b/crates/bds-ui/Cargo.toml index 1821579..34e9fff 100644 --- a/crates/bds-ui/Cargo.toml +++ b/crates/bds-ui/Cargo.toml @@ -36,9 +36,10 @@ winresource = "0.1" [package.metadata.packager] product-name = "Blogging Desktop Server" -identifier = "de.rfc1437.bds2" +identifier = "de.rfc1437.ruds" description = "A desktop application for writing and publishing static blogs." before-packaging-command = "cargo build --release -p bds-ui" +deep-link-protocols = [{ schemes = ["ruds"] }] icons = [ "assets/app-icons/bds.icns", "assets/app-icons/bds.ico", diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index ff908ff..c3d437f 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -4427,6 +4427,16 @@ impl BdsApp { SettingsMsg::BlogmarkCategoryChanged(s) => { state.blogmark_category = s; } + SettingsMsg::CopyBlogmarkBookmarklet => { + if let Some(project) = &self.active_project { + let bookmarklet = engine::blogmark::bookmarklet(&project.id); + self.notify( + ToastLevel::Success, + &t(self.ui_locale, "settings.blogmarkBookmarkletCopied"), + ); + return iced::clipboard::write(bookmarklet); + } + } SettingsMsg::SaveProject => { if let (Some(db), Some(data_dir), Some(project)) = (&self.db, &self.data_dir, self.active_project.as_mut()) diff --git a/crates/bds-ui/src/views/settings_view.rs b/crates/bds-ui/src/views/settings_view.rs index c1ed5a4..814759a 100644 --- a/crates/bds-ui/src/views/settings_view.rs +++ b/crates/bds-ui/src/views/settings_view.rs @@ -292,6 +292,7 @@ pub enum SettingsMsg { MaxPostsPerPageChanged(String), ImageImportConcurrencyChanged(String), BlogmarkCategoryChanged(String), + CopyBlogmarkBookmarklet, SaveProject, // Editor DefaultModeChanged(String), @@ -543,6 +544,11 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen .find(|row| row.name == state.blogmark_category), |row| Message::Settings(SettingsMsg::BlogmarkCategoryChanged(row.name)), ); + let copy_blogmark_bookmarklet = + button(text(t(locale, "settings.copyBlogmarkBookmarklet")).size(13)) + .on_press(Message::Settings(SettingsMsg::CopyBlogmarkBookmarklet)) + .style(inputs::secondary_button) + .padding([6, 16]); let save = button(text(t(locale, "common.save")).size(13)) .on_press(Message::Settings(SettingsMsg::SaveProject)) .style(inputs::primary_button) @@ -566,6 +572,7 @@ fn section_project<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen max_posts, image_import_concurrency, blogmark_category, + copy_blogmark_bookmarklet, save, ] .spacing(8) diff --git a/crates/bds-ui/tests/packaging_assets.rs b/crates/bds-ui/tests/packaging_assets.rs index 8b08a0f..c1d4544 100644 --- a/crates/bds-ui/tests/packaging_assets.rs +++ b/crates/bds-ui/tests/packaging_assets.rs @@ -22,6 +22,8 @@ fn desktop_packages_have_native_icons_and_cargo_commands() { "assets/app-icons/bds.png", "assets/app-icons/bds.ico", "assets/app-icons/bds.icns", + "identifier = \"de.rfc1437.ruds\"", + "deep-link-protocols = [{ schemes = [\"ruds\"] }]", "signing-identity = \"-\"", ] { assert!(manifest.contains(required), "missing {required}"); diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index 3d2eb25..b57d90d 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -344,6 +344,8 @@ settings-blogLanguages = Blogsprachen settings-defaultAuthor = Standardautor settings-maxPostsPerPage = Beiträge pro Seite settings-blogmarkCategory = Blogmark-Kategorie +settings-copyBlogmarkBookmarklet = Blogmark-Lesezeichen kopieren +settings-blogmarkBookmarkletCopied = Blogmark-Lesezeichen kopiert settings-defaultMode = Standard-Editormodus settings-diffViewStyle = Diff-Ansicht settings-categoryTitle = Titel für { $category } diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index a306a93..794585d 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -344,6 +344,8 @@ settings-blogLanguages = Blog Languages settings-defaultAuthor = Default Author settings-maxPostsPerPage = Posts per Page settings-blogmarkCategory = Blogmark Category +settings-copyBlogmarkBookmarklet = Copy Blogmark Bookmarklet +settings-blogmarkBookmarkletCopied = Blogmark bookmarklet copied settings-defaultMode = Default Editor Mode settings-diffViewStyle = Diff View Style settings-categoryTitle = Title for { $category } diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index a3e7b0d..ce472ec 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -344,6 +344,8 @@ settings-blogLanguages = Idiomas del blog settings-defaultAuthor = Autor predeterminado settings-maxPostsPerPage = Artículos por página settings-blogmarkCategory = Categoría de Blogmark +settings-copyBlogmarkBookmarklet = Copiar bookmarklet de Blogmark +settings-blogmarkBookmarkletCopied = Bookmarklet de Blogmark copiado settings-defaultMode = Modo de editor predeterminado settings-diffViewStyle = Estilo de diff settings-categoryTitle = Título para { $category } diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index e858d22..5f55d91 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -344,6 +344,8 @@ settings-blogLanguages = Langues du blog settings-defaultAuthor = Auteur par défaut settings-maxPostsPerPage = Articles par page settings-blogmarkCategory = Catégorie Blogmark +settings-copyBlogmarkBookmarklet = Copier le bookmarklet Blogmark +settings-blogmarkBookmarkletCopied = Bookmarklet Blogmark copié settings-defaultMode = Mode d'édition par défaut settings-diffViewStyle = Style de diff settings-categoryTitle = Titre pour { $category } diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index ec330d1..2daf4a4 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -344,6 +344,8 @@ settings-blogLanguages = Lingue del blog settings-defaultAuthor = Autore predefinito settings-maxPostsPerPage = Articoli per pagina settings-blogmarkCategory = Categoria Blogmark +settings-copyBlogmarkBookmarklet = Copia bookmarklet Blogmark +settings-blogmarkBookmarkletCopied = Bookmarklet Blogmark copiato settings-defaultMode = Modalità editor predefinita settings-diffViewStyle = Stile diff settings-categoryTitle = Titolo per { $category } diff --git a/specs/ai.allium b/specs/ai.allium index 3722086..22f332d 100644 --- a/specs/ai.allium +++ b/specs/ai.allium @@ -412,7 +412,7 @@ invariant ChatCancellation { invariant StructuredRenderTools { -- Chat may emit structured render payloads via tool calls prefixed "render_". - -- 9 inline surface types: card, chart, form, list, metric, mindmap, table, tabs, text, json. + -- 10 inline surface types: card, chart, form, list, metric, mindmap, table, tabs, text, json. -- Render tool names: render_card, render_chart, render_form, render_list, -- render_metric, render_mindmap, render_table, render_tabs. -- Unrecognized render tool names render as JSON (raw dump). diff --git a/specs/bds.allium b/specs/bds.allium index c55e7d3..c3e26a0 100644 --- a/specs/bds.allium +++ b/specs/bds.allium @@ -48,7 +48,12 @@ use "./action_patterns.allium" as action_patterns -- AI gating, translation chai -- Integration use "./git.allium" as git -- Git operations, LFS, reconciliation +use "./import.allium" as wordpress_import -- WordPress WXR analysis and execution use "./mcp.allium" as mcp -- MCP server (tools, resources, proposals) +use "./cli.allium" as cli -- Headless automation commands +use "./events.allium" as events -- Multi-client domain event bus +use "./server.allium" as server -- Headless SSH engine host +use "./tui.allium" as tui -- Terminal renderer and remote client use "./ai.allium" as ai -- AI one-shot tasks and chat use "./embedding.allium" as embedding -- Semantic similarity (HNSW vectors) use "./cli_sync.allium" as cli_sync -- CLI-to-app notification sync diff --git a/specs/editor_chat.allium b/specs/editor_chat.allium index 7d612ed..abd2f06 100644 --- a/specs/editor_chat.allium +++ b/specs/editor_chat.allium @@ -36,9 +36,9 @@ value InlineSurface { actions: List -- card only columns: List -- table rows: List> -- table - chart_type: String? -- chart: bar/pie/line + chart_type: String? -- bar | stacked-bar | line | area | pie | donut | heatmap series: List -- chart - max_value: Integer? -- chart + max_value: Decimal? -- chart label: String? -- metric value: String? -- metric items: List -- list @@ -53,14 +53,19 @@ value InlineSurface { value SurfaceAction { label: String - action: String + action: String -- navigation/view action from the allow-list below payload: Map } value ChartSeries { label: String - value: Integer - segments: List + value: Decimal + segments: List +} + +value ChartSegment { + label: String + value: Decimal } value MindmapNode { @@ -72,7 +77,7 @@ value MindmapNode { value FormField { key: String label: String - input_type: String + input_type: String -- text | textarea | select | checkbox | date | number placeholder: String? value: String? options: List @@ -201,14 +206,18 @@ surface ChatPanelSurface { @guarantee AssistantActionDispatch -- Assistant tool calls can trigger navigation actions: - -- open_post(id), open_media(id), open_settings(), etc. + -- open_post(id), open_media(id), open_settings(), open_chat(id), + -- switch_view(view), toggle_sidebar(), toggle_panel(), and + -- toggle_assistant_sidebar(). Snake-case and camel-case spellings + -- are equivalent. Unknown actions or invalid identifiers are refused + -- with a visible error and never execute arbitrary code. -- Actions dispatched through store, same as user clicks. -- Navigation actions open tabs with pin intent. @guarantee InlineSurfaceRendering -- Assistant render-tool calls produce structured inline surfaces -- rendered between message content and tool markers. - -- 9 surface types: card, chart, form, list, metric, mindmap, table, tabs, text, json. + -- 10 surface types: card, chart, form, list, metric, mindmap, table, tabs, text, json. -- Render tool names: render_card, render_chart, render_form, render_list, -- render_metric, render_mindmap, render_table, render_tabs. -- Unrecognized render names render as json (raw dump). @@ -216,8 +225,24 @@ surface ChatPanelSurface { -- Form surfaces persist field values in conversation surface_state. -- Tab surfaces track selected_index in conversation surface_state. -- Surfaces can be dismissed, persisted in conversation surface_state. + -- Stable message/tool-index IDs bind all three kinds of state to the + -- same surface when a conversation is reopened. + -- Every non-dismissed surface is expanded. Existing surfaces remain + -- visible while later assistant content and surfaces stream in. + -- Form submission adds the current field values to the action payload. -- Form debounce: config.surface_form_debounce_ms. + @guarantee ChartSurfaceParity + -- Charts support bar, stacked-bar, line, area, pie, donut, and heatmap. + -- chartType and legacy chart_type inputs are accepted; absent type + -- defaults to bar. Stacked bars and heatmaps use labelled segments. + -- Non-numeric values are rendered as zero rather than breaking chat. + + @guarantee StructuredSurfaceSafety + -- Surface payloads are rendered only through the fixed component set. + -- Raw assistant HTML or JavaScript is never executed. Unknown nested + -- content renders as text or an inspectable JSON value. + @guarantee TokenTracking -- Token usage tracked per conversation. -- Displayed in status bar, not in the chat panel itself. diff --git a/specs/editor_settings.allium b/specs/editor_settings.allium index fb6d703..115ebd1 100644 --- a/specs/editor_settings.allium +++ b/specs/editor_settings.allium @@ -161,7 +161,8 @@ surface SettingsViewSurface { -- clipboard for pasting into a browser bookmark. -- The bookmarklet captures the active tab's document.title and -- location.href (each URI-encoded) and navigates to a - -- bds2://new-post?title=...&url=... deep link. When a project is active + -- ruds://new-post?title=...&url=... deep link. RuDS does not register or + -- accept bds2://, which remains owned by bDS2. When a project is active -- its id is appended as &project_id=... so the link targets that -- project regardless of which one is currently open. diff --git a/specs/git.allium b/specs/git.allium index eab9fdb..20c2cd9 100644 --- a/specs/git.allium +++ b/specs/git.allium @@ -17,6 +17,34 @@ value GitSyncStatus { kind: local_only | remote_only | both } +value GitFileStatus { + path: String + old_path: String? + kind: added | modified | deleted | renamed | untracked +} + +value GitCommit { + hash: String + subject: String? + author: String? + date: String? + sync_status: GitSyncStatus +} + +value GitRemoteState { + local_branch: String? + upstream_branch: String? + has_upstream: Boolean + ahead: Integer + behind: Integer +} + +config { + local_timeout: Duration = 15.seconds + network_timeout: Duration = 120.seconds + file_history_limit: Integer = 50 +} + surface GitSyncStatusSurface { context status: GitSyncStatus @@ -46,14 +74,24 @@ surface GitRepositorySurface { surface GitControlSurface { facing _: GitOperator + exposes: + GitFileStatus + GitCommit + GitRemoteState + provides: InitializeRepoRequested(project) GitStatusRequested(project) GitDiffRequested(project) + GitFileDiffRequested(project, file_path) GitHistoryRequested(project, branch) + GitFileHistoryRequested(project, file_path) + GitRemoteStateRequested(project) + GitRemoteSetRequested(project, remote_url) GitFetchRequested(project) GitPullRequested(project) GitPushRequested(project) + GitNetworkRunCancelled(project) GitCommitAllRequested(project, message) GitReconcileRequested(project, old_commit, new_commit) PruneLfsCacheRequested(project, retain_recent) @@ -85,6 +123,13 @@ rule GetDiff { ensures: GitDiffReport(staged_diff, unstaged_diff) } +rule GetFileDiff { + when: GitFileDiffRequested(project, file_path) + -- Returns the file at HEAD and in the working tree. A missing side is + -- represented by empty content so added and deleted files remain viewable. + ensures: GitFileDiffReport(file_path, original_content, modified_content) +} + rule GetHistory { when: GitHistoryRequested(project, branch) -- Returns commit history with sync status per commit @@ -94,9 +139,32 @@ rule GetHistory { -- This drives the "push needed" / "pull needed" indicators } + +rule GetFileHistory { + when: GitFileHistoryRequested(project, file_path) + -- Follows renames and returns the newest config.file_history_limit commits. + -- A file with no history produces an empty report rather than an error. + ensures: GitFileHistoryReport(file_path, commits) +} + +rule GetRemoteState { + when: GitRemoteStateRequested(project) + -- A repository without an upstream reports zero ahead/behind counts. + ensures: GitRemoteStateReport(project, local_branch, upstream_branch, ahead, behind) +} + +rule SetRemote { + when: GitRemoteSetRequested(project, remote_url) + -- Creates origin when absent and replaces its URL when already configured. + ensures: GitRemoteUpdated(project, remote_url) +} + rule Fetch { when: GitFetchRequested(project) ensures: RemoteRefsUpdated(project) + + @guidance + -- Network output is streamed live and the run can be cancelled. } rule Pull { @@ -104,15 +172,29 @@ rule Pull { ensures: LocalBranchUpdated(project) ensures: GitReconcileRequested(project, previous_head(project), current_head(project)) -- After pull, detect changed files and reconcile DB + + @guidance + -- Pull is fast-forward-only. Network output is streamed live and the + -- run can be cancelled. } rule Push { when: GitPushRequested(project) ensures: RemoteBranchUpdated(project) + + @guidance + -- Network output is streamed live and the run can be cancelled. +} + +rule CancelNetworkRun { + when: GitNetworkRunCancelled(project) + ensures: RunningGitProcessTerminated(project) + ensures: GitNetworkRunFinished(project, cancelled) } rule CommitAll { when: GitCommitAllRequested(project, message) + requires: message != "" ensures: AllChangesStaged(project) ensures: CommitCreated(project, message) } @@ -159,6 +241,18 @@ invariant StructuredAuthErrors { -- Instead of raw git error messages } +invariant BoundedGitExecution { + -- Local operations stop after config.local_timeout and network operations + -- stop after config.network_timeout. Timeout errors identify the operation + -- and preserve captured output; the spawned process is terminated. +} + +invariant OfflineNetworkGate { + -- Fetch, pull, and push are refused by their UI/automation caller while + -- airplane mode is active. Repository inspection and local commits remain + -- available. +} + rule PruneLfsCache { when: PruneLfsCacheRequested(project, retain_recent) -- Prunes LFS cache with configurable recent commit retention diff --git a/specs/import.allium b/specs/import.allium new file mode 100644 index 0000000..ae833fb --- /dev/null +++ b/specs/import.allium @@ -0,0 +1,291 @@ +-- allium: 1 +-- WordPress WXR Import +-- Scope: extension (saved analysis, conflict review, recoverable execution) +-- Distilled from: ../bDS2/lib/bds/wxr_parser.ex, +-- ../bDS2/lib/bds/import_analysis.ex, +-- ../bDS2/lib/bds/import_execution.ex, +-- ../bDS2/lib/bds/import_definitions.ex, +-- ../bDS2/lib/bds/desktop/shell_live/import_editor* + +use "./project.allium" as project + +enum ImportItemStatus { new | update | conflict | content_duplicate | missing } +enum ImportResolution { ignore | overwrite | import } + +value ImportedSite { + title: String + url: String? + language: String? +} + +value ImportCandidate { + kind: post | page | media + source_id: Integer? + title: String + slug: String? + filename: String? + status: ImportItemStatus + resolution: ImportResolution? + existing_id: String? + author: String? + excerpt: String? + content: String? + source_status: String? + categories: List + tags: List + source_path: String? + parent_source_id: Integer? + created_at: Timestamp? + updated_at: Timestamp? + published_at: Timestamp? +} + +value TaxonomyCandidate { + kind: category | tag + name: String + slug: String? + exists_in_project: Boolean + mapped_to: String? +} + +value ImportCounts { + new_count: Integer + update_count: Integer + conflict_count: Integer + duplicate_count: Integer + missing_count: Integer +} + +value ImportDateBucket { + year: Integer + post_count: Integer + media_count: Integer +} + +value ImportMacroUsage { + name: String + total_count: Integer + post_slugs: List +} + +value ImportReport { + source_file: String + uploads_folder: String? + site: ImportedSite + posts: List + pages: List + media: List + taxonomies: List + post_counts: ImportCounts + page_counts: ImportCounts + media_counts: ImportCounts + date_distribution: List + macros: List +} + +entity ImportDefinition { + project: project/Project + name: String + wxr_file_path: String? + uploads_folder_path: String? + last_analysis: ImportReport? + created_at: Timestamp + updated_at: Timestamp +} + +config { + transaction_batch_size: Integer = 500 +} + +surface ImportDefinitionSurface { + context definition: ImportDefinition + + exposes: + definition.project + definition.name + definition.wxr_file_path when definition.wxr_file_path != null + definition.uploads_folder_path when definition.uploads_folder_path != null + definition.last_analysis when definition.last_analysis != null + definition.created_at + definition.updated_at +} + +surface WordPressImportSurface { + facing _: ImportOperator + + exposes: + ImportReport + ImportCandidate + TaxonomyCandidate + + provides: + CreateImportDefinitionRequested(project, name) + RenameImportDefinitionRequested(definition, name) + SelectWxrFileRequested(definition, file_path) + SelectUploadsFolderRequested(definition, folder_path) + AnalyzeWxrRequested(definition) + ResolveImportConflictRequested(definition, kind, item_name, resolution) + MapImportTaxonomyRequested(definition, kind, source_name, target_name) + AutoMapImportTaxonomyRequested(definition, model) + ExecuteWordPressImportRequested(definition) + DeleteImportDefinitionRequested(definition) + + @guarantee AnalysisReview + -- Analysis runs asynchronously and reports localized progress while it + -- loads project state, analyzes posts, pages and media, processes + -- taxonomy, and discovers WordPress shortcodes. The saved report shows + -- item counts, conflicts, missing uploads, year distribution, macro + -- usage, and importable totals before any project content is changed. + + @guarantee ConflictReview + -- Conflicts default to ignore. The operator can keep ignoring, overwrite + -- the existing item, or import a second item with a unique slug. + -- Same-content updates and duplicates are reported but skipped during + -- execution, matching bDS2. + + @guarantee TaxonomyReview + -- Existing terms are matched case-insensitively. New source categories + -- and tags may be mapped to existing project terms, cleared, or created + -- as new terms. Free-text category mappings are normalized before save. + + @guarantee AiTaxonomyGate + -- Optional automatic taxonomy mapping uses the selected configured AI + -- model. In airplane mode it runs only through a configured local + -- endpoint; otherwise it is refused with localized guidance. The saved + -- report is updated only after a successful mapping response. + + @guarantee ExecutionProgress + -- Execution is unavailable when the report contains no importable + -- items. While running, the editor shows phase, completed/total count, + -- current detail and estimated remaining time for taxonomy, posts, + -- media and pages, followed by a localized result summary or error. +} + +rule CreateImportDefinition { + when: CreateImportDefinitionRequested(project, name) + ensures: ImportDefinition.created( + project: project, + name: name, + wxr_file_path: null, + uploads_folder_path: null, + last_analysis: null, + created_at: now, + updated_at: now + ) +} + +rule RenameImportDefinition { + when: RenameImportDefinitionRequested(definition, name) + ensures: definition.name = name + ensures: definition.updated_at = now +} + +rule SelectWxrFile { + when: SelectWxrFileRequested(definition, file_path) + ensures: definition.wxr_file_path = file_path + ensures: definition.last_analysis = null + ensures: definition.updated_at = now +} + +rule SelectUploadsFolder { + when: SelectUploadsFolderRequested(definition, folder_path) + ensures: definition.uploads_folder_path = folder_path + ensures: definition.updated_at = now +} + +rule AnalyzeWxr { + when: AnalyzeWxrRequested(definition) + requires: definition.wxr_file_path != null + let report = analyze_wordpress_export( + definition.project, + definition.wxr_file_path, + definition.uploads_folder_path + ) + ensures: definition.last_analysis = report + ensures: definition.updated_at = now +} + +rule ResolveImportConflict { + when: ResolveImportConflictRequested(definition, kind, item_name, resolution) + requires: definition.last_analysis != null + requires: resolution = ignore or resolution = overwrite or resolution = import + ensures: definition.last_analysis = with_conflict_resolution( + definition.last_analysis, + kind, + item_name, + resolution + ) + ensures: definition.updated_at = now +} + +rule MapImportTaxonomy { + when: MapImportTaxonomyRequested(definition, kind, source_name, target_name) + requires: definition.last_analysis != null + ensures: definition.last_analysis = with_taxonomy_mapping( + definition.last_analysis, + kind, + source_name, + target_name + ) + ensures: definition.updated_at = now +} + +rule AutoMapImportTaxonomy { + when: AutoMapImportTaxonomyRequested(definition, model) + requires: definition.last_analysis != null + ensures: definition.last_analysis = with_ai_taxonomy_mappings(definition.last_analysis, model) + ensures: definition.updated_at = now +} + +rule ExecuteWordPressImport { + when: ExecuteWordPressImportRequested(definition) + requires: definition.last_analysis != null + requires: importable_count(definition.last_analysis) > 0 + ensures: WordPressImportExecuted(definition, result) + + @guidance + -- New posts/pages use the source author or project default, preserve + -- source timestamps, taxonomy and published/draft status, and route + -- through normal post persistence/publishing. Pages also receive the + -- page category. Imported media route through normal media import, + -- preserve description as alt text, and link to an imported parent post + -- when its WordPress parent is known. Missing media are skipped. +} + +rule DeleteImportDefinition { + when: DeleteImportDefinitionRequested(definition) + ensures: not exists definition +} + +invariant WxrConversionParity { + -- A valid export requires an RSS channel and extracts site metadata, posts, + -- pages, attachments, categories and tags. Post HTML is converted to + -- Markdown and WordPress shortcodes become bDS [[macro ...]] calls. +} + +invariant AnalysisClassification { + -- Posts/pages: same slug and checksum => update; same slug with different + -- content => conflict; checksum matching another post => content duplicate; + -- otherwise new. Media applies the same rules by original filename and + -- checksum, with missing selected-upload files classified as missing. +} + +invariant CoreEngineReuse { + -- Execution uses the ordinary tag, post, publishing, media, filesystem, + -- search, embedding and domain-event paths. It does not create a parallel + -- import-only data model or bypass normal side effects. +} + +invariant RecoverableExecution { + -- Taxonomy, posts, media and pages execute in that order. Each phase is + -- committed in batches of config.transaction_batch_size. A failure rolls + -- back the current batch and stops with a structured error; completed + -- batches remain available for inspection and a later retry. +} + +invariant SafeUntrustedInput { + -- Unknown XML elements and report keys are ignored without creating + -- unbounded runtime identifiers. Malformed XML or a missing RSS channel + -- fails analysis without changing project content. Progress/reporting + -- callback failures never abort otherwise valid analysis or execution. +} diff --git a/specs/menu.allium b/specs/menu.allium index 1a9363d..e370548 100644 --- a/specs/menu.allium +++ b/specs/menu.allium @@ -13,6 +13,40 @@ surface MenuManagementSurface { SyncMenuFromFilesystemRequested(project_id) } +surface MenuEditorSurface { + facing _: MenuOperator + + provides: + MenuEditorOpened(project_id) + MenuEntryDraftRequested(menu, selected_item, kind) + MenuEntryDraftCompleted(menu, selection_or_label) + MenuEntryDraftCancelled(menu) + MenuTreeEditRequested(menu, selected_item, operation, target_item, drop_position) + UpdateMenuRequested(items) + + @guarantee EntryCreation + -- A new page/category draft is inserted directly after the selected + -- item, or as the first child when a submenu is selected. A page draft + -- can select an existing page; submitting free text instead creates a + -- submenu. A category draft can select an existing category or create + -- a new category from non-empty free text. Cancelling removes the draft. + + @guarantee TreeEditing + -- Entries may move among siblings, indent only beneath a preceding + -- submenu, unindent to immediately after their parent, or be dragged + -- before/after an entry or inside a submenu. A drop onto itself, one of + -- its descendants, or inside a non-submenu has no effect. + + @guarantee HomeProtection + -- The canonical Home entry cannot be moved, indented, unindented, + -- deleted, or dragged. Those actions are unavailable while it is + -- selected and remain no-ops if invoked indirectly. + + @guarantee SaveFeedback + -- Save persists the current normalized tree through UpdateMenu and + -- informs the operator that the blog menu was saved. +} + value MenuItem { kind: page | submenu | category_archive | home label: String @@ -71,3 +105,30 @@ rule SyncMenuFromFilesystem { ensures: MenuLoaded(project_id, _) ensures: MenuFileWritten(_) } + +rule OpenMenuEditor { + when: MenuEditorOpened(project_id) + ensures: MenuLoaded(project_id, _) + ensures: MenuEditorReady(project_id) +} + +rule StartMenuEntryDraft { + when: MenuEntryDraftRequested(menu, selected_item, kind) + requires: kind = page or kind = category_archive + ensures: MenuDraftInserted(menu, selected_item, kind) +} + +rule CompleteMenuEntryDraft { + when: MenuEntryDraftCompleted(menu, selection_or_label) + ensures: MenuDraftResolved(menu, selection_or_label) +} + +rule CancelMenuEntryDraft { + when: MenuEntryDraftCancelled(menu) + ensures: MenuDraftRemoved(menu) +} + +rule EditMenuTree { + when: MenuTreeEditRequested(menu, selected_item, operation, target_item, drop_position) + ensures: MenuTreePreviewUpdated(menu) +} diff --git a/specs/script.allium b/specs/script.allium index 6ad77e0..9af3dcb 100644 --- a/specs/script.allium +++ b/specs/script.allium @@ -368,8 +368,8 @@ rule ExecuteTransform { -- config.transform_max_toast_length characters. @guidance - -- bds2://new-post deep links from browser bookmarks - -- (bds2:// scheme avoids clashing with the legacy app's bds:// scheme) + -- RuDS accepts only ruds://new-post deep links from browser bookmarks. + -- bds2:// remains assigned to bDS2 so both applications can be installed. -- Ordering is deterministic: updated_at, then slug, then id }