Compare commits
239 Commits
f99e139fa5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 655069a28a | |||
| 593f1646a1 | |||
| c77450d497 | |||
| 23f05b72f7 | |||
| dd53ca3fbc | |||
| 6c9dd9605b | |||
| 46c239df56 | |||
| 2fd132e827 | |||
| 0f3f1efa08 | |||
| e4fa61ae07 | |||
| 8fe18d108f | |||
| b517672663 | |||
| b016f6d812 | |||
| 505527ae4f | |||
| b6b1d16e54 | |||
| 46fd6c1b85 | |||
| 5433cb59ac | |||
| e027364c0e | |||
| 3675a26407 | |||
| 9a1f301527 | |||
| 59333ac920 | |||
| 381deb417d | |||
| 5801e49dc1 | |||
| a8ca3b643b | |||
| 39bb232b57 | |||
| 824313f106 | |||
| 01b90e00a0 | |||
| c77686250e | |||
| 48e0903542 | |||
| df2bdc2041 | |||
| a3ca44640c | |||
| 3a8fafd2fd | |||
| 16f45d77b2 | |||
| 700ed79c43 | |||
| 6ec61610f1 | |||
| 89859684eb | |||
| dc35518bad | |||
| 4a03fc0ba2 | |||
| 10fa174388 | |||
| 8ee51c6626 | |||
| d31ceb72cb | |||
| c8d2c73a72 | |||
| a772e3abea | |||
| c735c1afa7 | |||
| d85654d4b2 | |||
| ae2e534fdb | |||
| ae1c8703e7 | |||
| d944c07838 | |||
| bb82f1d4a1 | |||
| 7dfcd78abd | |||
| 8e60fe31a5 | |||
| 28398b17f4 | |||
| cf1d4bd46f | |||
| 7fa6e6232c | |||
| fcc574428c | |||
| 1917043c10 | |||
| 4b52ac6156 | |||
| 1487c0337e | |||
| 43f04dcd86 | |||
| c07ba9a4d8 | |||
| ef6c970654 | |||
| e7d5cba01b | |||
| 82c69a2c35 | |||
| b11be9b521 | |||
| 1b0bae37bf | |||
| c2b2b1ff11 | |||
| 5f77641f3f | |||
| ba9634c478 | |||
| 49675a49d2 | |||
| d1acfd4d71 | |||
| 68de265137 | |||
| 57bdcef990 | |||
| 52809b39b1 | |||
| e3e94ac2d9 | |||
| d78cb5c07b | |||
| 211271efca | |||
| c2cecd9383 | |||
| d0e1ac5559 | |||
| cea47f10e2 | |||
| e1abe457e8 | |||
| 562eed2b98 | |||
| 7c38061c1c | |||
| 67a1f8a5f0 | |||
| d3c21247e0 | |||
| cd4a01a2c8 | |||
| b93b650689 | |||
| e284f030ae | |||
| 4c9c432d33 | |||
| 64ca5d4637 | |||
| a89175e230 | |||
| 26f06f3298 | |||
| 6a7a8c4288 | |||
| 62f87b8ab4 | |||
| ff777e590f | |||
| 7f1dbc390c | |||
| 59c236d871 | |||
| c3a8a8a755 | |||
| 2e61059568 | |||
| 936a20a03e | |||
| 0da149e105 | |||
| dc6fc4c631 | |||
| d3e2397217 | |||
| 3b14c1ecf3 | |||
| bb58f98afd | |||
| 66c24b565f | |||
| ccfaa4afe6 | |||
| f46239188a | |||
| 4a8a420c62 | |||
| b3d69f0291 | |||
| f8f6a242d6 | |||
| 341c1bf8ee | |||
| 4f6f7a6e1e | |||
| 72f6cc7d97 | |||
| f52e33760e | |||
| e87c35e256 | |||
| 2d98780007 | |||
| 26c481ea1f | |||
| 65d89a198e | |||
| 8f073bbebd | |||
| 4dd4781c5a | |||
| a00e4b85ac | |||
| caaec98225 | |||
| 4b1557cf6a | |||
| 985d8b53c2 | |||
| 941db4c6f4 | |||
| a73af6b44d | |||
| e2054c9c12 | |||
| 28e08451e4 | |||
| abbcef594a | |||
| 8224b3d59f | |||
| f7e1662bca | |||
| ae66775cb7 | |||
| 741979fc39 | |||
| 4859c9708a | |||
| cd72998a13 | |||
| f088cfb77b | |||
| bad656924b | |||
| 8ee2b9a7f7 | |||
| 8d245b3492 | |||
| 66938c23f2 | |||
| 2e633922f9 | |||
| e3a1010ae9 | |||
| eac6d543d2 | |||
| 8546080a3d | |||
| d8b24c9b72 | |||
| 63e35d19e3 | |||
| 9325de2db4 | |||
| a5391e8e25 | |||
| 284637970f | |||
| 21b11ef87e | |||
| e6a2055e18 | |||
|
|
bf9340352c | ||
| db98944d10 | |||
| 5e99cb7a09 | |||
| 040b5db37b | |||
| a33131ddea | |||
| ef6f8a54b2 | |||
| 70d2342274 | |||
| c1b7ceae6c | |||
| 1d17b6e884 | |||
| 360a8d971a | |||
| c30757b3b7 | |||
| f6e1b679f0 | |||
| 63d6c9f215 | |||
| 2ba8be2fc6 | |||
| 4731bc0cd2 | |||
| 8bc371eb3f | |||
| b65c2be29b | |||
| ee4d0dd33f | |||
| 8c71ece887 | |||
| b1438d5222 | |||
| 87f2f22241 | |||
| 60acda3fee | |||
| ab6a03dc54 | |||
| 0afb017e43 | |||
| cf553e2f78 | |||
| cb658aba1a | |||
| 544ff65e3b | |||
| 08eb9e4ea1 | |||
| 723a7ec1f7 | |||
| dfb2f8870b | |||
| f0919f24a5 | |||
| 72f2c829ca | |||
| 7c7f629dd2 | |||
| dd760d0f2b | |||
| fb794ae833 | |||
| df0ae6a41b | |||
| e515cfacc6 | |||
| 7e9cc72e1f | |||
| 257a06e5d1 | |||
| 1b37f1fcec | |||
| 56caa653bb | |||
| 925fe97007 | |||
| d688c61b0e | |||
| 8db7bcf357 | |||
| 2bed225133 | |||
| 7045b10738 | |||
| ebf6136d2f | |||
| ae6659bcf3 | |||
| 8bfc509472 | |||
| e89a061d8f | |||
| d606d9b26b | |||
| a9740207cc | |||
| 535ab81082 | |||
| 0ce90e96e5 | |||
| 8cb6d238b9 | |||
| cf8b0af15f | |||
| 9d5764b251 | |||
| 3a77761f96 | |||
| aff4b63188 | |||
| 91b0ffe4c5 | |||
| 84b91750fb | |||
| d03d033548 | |||
| 74ceaeb971 | |||
| 61ff2a77c0 | |||
| 744f7543d7 | |||
| a1004d72bf | |||
| 489d787306 | |||
| babae1838d | |||
| 5b619f492a | |||
| b3434b3054 | |||
| 5b21dcb17d | |||
| 1f645f6e5e | |||
| 99d36e6e2f | |||
| d7e30b94cb | |||
| f1265ee326 | |||
| c5e09e7316 | |||
| 1ae6152da7 | |||
| 0305d80051 | |||
| a021fc45cd | |||
| fceb995c7c | |||
| e58d68e73e | |||
| 0f30221907 | |||
| d423b6db98 | |||
| 3adb4407a0 | |||
| 05923f255b | |||
| ff89d78ab4 | |||
| e2c92cb90d | |||
| 82ce445c44 |
@@ -16,7 +16,16 @@
|
|||||||
"mcp__Claude_in_Chrome__computer",
|
"mcp__Claude_in_Chrome__computer",
|
||||||
"mcp__Claude_in_Chrome__browser_batch",
|
"mcp__Claude_in_Chrome__browser_batch",
|
||||||
"mcp__Claude_in_Chrome__javascript_tool",
|
"mcp__Claude_in_Chrome__javascript_tool",
|
||||||
"Bash(allium check *)"
|
"Bash(allium check *)",
|
||||||
|
"Bash(mix deps.get)",
|
||||||
|
"Bash(allium --help)",
|
||||||
|
"WebSearch",
|
||||||
|
"WebFetch(domain:github.com)",
|
||||||
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
|
"Bash(echo \"EXIT: $?\")",
|
||||||
|
"Bash(echo \"exit: $?\")",
|
||||||
|
"Bash(MIX_ENV=prod mix release bds --overwrite)",
|
||||||
|
"Bash(MIX_ENV=prod mix bds.bundle.macos)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
53
.credo.exs
Normal file
53
.credo.exs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
%{
|
||||||
|
configs: [
|
||||||
|
%{
|
||||||
|
name: "default",
|
||||||
|
files: %{
|
||||||
|
included: ["lib/", "test/", "config/", "mix.exs"],
|
||||||
|
excluded: [~r"/deps/", ~r"/_build/", ~r"/priv/static/"]
|
||||||
|
},
|
||||||
|
strict: true,
|
||||||
|
parse_timeout: 10_000,
|
||||||
|
color: true,
|
||||||
|
checks: [
|
||||||
|
{Credo.Check.Consistency.ExceptionNames},
|
||||||
|
{Credo.Check.Consistency.LineEndings},
|
||||||
|
{Credo.Check.Consistency.SpaceAroundOperators},
|
||||||
|
{Credo.Check.Consistency.SpaceInParentheses},
|
||||||
|
{Credo.Check.Consistency.TabsOrSpaces},
|
||||||
|
{Credo.Check.Design.AliasUsage, false},
|
||||||
|
{Credo.Check.Readability.BlockPipe, false},
|
||||||
|
{Credo.Check.Readability.AliasOrder, false},
|
||||||
|
{Credo.Check.Readability.LargeNumbers, false},
|
||||||
|
{Credo.Check.Readability.MaxLineLength, false},
|
||||||
|
{Credo.Check.Readability.ModuleDoc, false},
|
||||||
|
{Credo.Check.Readability.PreferImplicitTry, false},
|
||||||
|
{Credo.Check.Readability.Semicolons, false},
|
||||||
|
{Credo.Check.Readability.StringSigils, false},
|
||||||
|
{Credo.Check.Readability.TrailingBlankLine, false},
|
||||||
|
{Credo.Check.Readability.UnnecessaryAliasExpansion, false},
|
||||||
|
{Credo.Check.Readability.WithSingleClause, false},
|
||||||
|
{Credo.Check.Refactor.Apply, false},
|
||||||
|
{Credo.Check.Refactor.CondStatements, false},
|
||||||
|
{Credo.Check.Refactor.CyclomaticComplexity, false},
|
||||||
|
{Credo.Check.Refactor.FilterFilter, false},
|
||||||
|
{Credo.Check.Refactor.FilterReject, false},
|
||||||
|
{Credo.Check.Refactor.FunctionArity, false},
|
||||||
|
{Credo.Check.Refactor.MapJoin, false},
|
||||||
|
{Credo.Check.Refactor.Nesting, false},
|
||||||
|
{Credo.Check.Refactor.NegatedConditionsWithElse, false},
|
||||||
|
{Credo.Check.Refactor.RejectFilter, false},
|
||||||
|
{Credo.Check.Refactor.RejectReject, false},
|
||||||
|
{Credo.Check.Refactor.RedundantWithClauseResult, false},
|
||||||
|
{Credo.Check.Warning.ApplicationConfigInModuleAttribute},
|
||||||
|
{Credo.Check.Warning.BoolOperationOnSameValues},
|
||||||
|
{Credo.Check.Warning.ExpensiveEmptyEnumCheck},
|
||||||
|
{Credo.Check.Warning.IExPry},
|
||||||
|
{Credo.Check.Warning.LazyLogging},
|
||||||
|
{Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, false},
|
||||||
|
{Credo.Check.Warning.OperationOnSameValues},
|
||||||
|
{Credo.Check.Warning.RaiseInsideRescue}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Downloaded JS — exclude from language detection
|
||||||
|
priv/**/*.js linguist-vendored=true
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -3,10 +3,17 @@
|
|||||||
/deps/
|
/deps/
|
||||||
/dist/
|
/dist/
|
||||||
/doc/
|
/doc/
|
||||||
|
/tmp/
|
||||||
/.elixir_ls/
|
/.elixir_ls/
|
||||||
/erl_crash.dump
|
/erl_crash.dump
|
||||||
/node_modules/
|
/node_modules/
|
||||||
/priv/data/*.db
|
/priv/data/*.db
|
||||||
/priv/data/*.db-shm
|
/priv/data/*.db-shm
|
||||||
/priv/data/*.db-wal
|
/priv/data/*.db-wal
|
||||||
*.ez
|
# Project public content (posts, media, templates, generated html) lives in the
|
||||||
|
# per-user default content folder, never the repo. See PublicContentLivesInProjectFolder.
|
||||||
|
/priv/data/projects/
|
||||||
|
# Embeddings index artifacts are per-project runtime caches, never committed.
|
||||||
|
*.usearch
|
||||||
|
*.usearch.meta.json
|
||||||
|
*.eztmp/
|
||||||
|
|||||||
4
.mix_audit.ignore
Normal file
4
.mix_audit.ignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# GHSA-rhv4-8758-jx7v is pinned transitively through bumblebee -> progress_bar.
|
||||||
|
# ecto_sqlite3 0.24.x can move to decimal 3.x, but that line is currently
|
||||||
|
# unsatisfiable alongside the app's Bumblebee dependency.
|
||||||
|
GHSA-rhv4-8758-jx7v
|
||||||
58
AGENTS.md
58
AGENTS.md
@@ -28,7 +28,9 @@ This document provides context and best practices for GitHub Copilot when workin
|
|||||||
- we have an allium spec in the specs/ folder. you must weed the specs against built code to make sure you follow the spec.
|
- we have an allium spec in the specs/ folder. you must weed the specs against built code to make sure you follow the spec.
|
||||||
- when changing the spec, validate the spec with the available command line tool.
|
- when changing the spec, validate the spec with the available command line tool.
|
||||||
- you MUST run tests with command line tools at least once to capture compile errors in tests, do not use the integrated testing of vscode, as that blocks on compile errors
|
- you MUST run tests with command line tools at least once to capture compile errors in tests, do not use the integrated testing of vscode, as that blocks on compile errors
|
||||||
- you MUST run build, test and check dialyzer messages and you MUST treet warnings as errors and fix them. we want clean builds, clean tests and clean dialyzer results
|
- you MUST run build, test, credo, deps.audit and check dialyzer messages and you MUST treet warnings as errors and fix them. we want clean builds, clean tests, clean credo, clean dependency audits and clean dialyzer results
|
||||||
|
- on a headless Linux machine, you have to run tests with this command (if mix test complains about DISPLAX): xvfb-run mix test
|
||||||
|
- CSS precedence: Tailwind utilities and the ui-* kit (utilities.css) are in @layer and ALWAYS lose to the unlayered hand-written files (shell.css, panel.css, ...). Convention: hand-written CSS owns appearance, Tailwind utilities in templates own layout — never put display/flex-direction/size rules in hand-written CSS for elements whose layout the template controls, and define each selector in exactly one file.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -54,7 +56,16 @@ This document provides context and best practices for GitHub Copilot when workin
|
|||||||
|
|
||||||
- Never leave tests failing, even if they appear unrelated to your changes
|
- Never leave tests failing, even if they appear unrelated to your changes
|
||||||
- If a test failure is pre-existing, fix it as part of your current work
|
- If a test failure is pre-existing, fix it as part of your current work
|
||||||
- Run the full test suite (`npm test`) before considering any task complete
|
- **Always write the full test run to a log file first, then grep that file for failures.** Never run `mix test` in the terminal and try to grep live output — the output is interleaved, truncated, and nearly impossible to parse. Pattern:
|
||||||
|
```
|
||||||
|
mix test 2>&1 > /tmp/test_run.log
|
||||||
|
grep -E "FAILED|^[ ]*1\)" /tmp/test_run.log
|
||||||
|
grep -A 15 "^[ ]*1\)" /tmp/test_run.log # details for the first failure
|
||||||
|
```
|
||||||
|
Work from the log file, not from repeated terminal runs.
|
||||||
|
|
||||||
|
> **Zero failing tests. No exceptions.**
|
||||||
|
- Run the full test suite (`mix test`) before considering any task complete
|
||||||
- If you cannot fix a test, explain why and propose a solution
|
- If you cannot fix a test, explain why and propose a solution
|
||||||
|
|
||||||
> **Zero failing tests. No exceptions.**
|
> **Zero failing tests. No exceptions.**
|
||||||
@@ -101,11 +112,52 @@ This document provides context and best practices for GitHub Copilot when workin
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ⚠️ MANDATORY: macOS Bundle Must Be Fully Standalone
|
||||||
|
|
||||||
|
**The built `BDS2.app` MUST run on a Mac without Homebrew.**
|
||||||
|
|
||||||
|
- No Mach-O inside the `.app` may reference a Homebrew/local path (`/opt/homebrew`, `/usr/local`)
|
||||||
|
- Every external dylib reachable from the release's NIFs must be copied into `Contents/Frameworks` and rewritten to `@loader_path`-relative install names (`BDS.MacBundle.Dylibs`)
|
||||||
|
- The same physical dylib referenced under multiple names must be deduped (one real file + symlinks) so dyld loads a single image — duplicate images cause duplicate Objective-C class / wx event-table registrations
|
||||||
|
- `BDS.MacBundle.verify_standalone/1` runs after relocation and fails the build if any external reference remains — do not weaken or bypass this gate
|
||||||
|
|
||||||
|
> **The bundle must be self-contained. No outside references. No exceptions.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ MANDATORY: macOS Bundle Build Order and Blogmark Verification
|
||||||
|
|
||||||
|
**`mix release` copies `priv/static` as-is — it never rebuilds assets.** Always build in this order, or the bundle ships stale JS while the server code is current (this silently broke blogmark deep links in July 2026: the server waited for the client's `shell_ready` event that the stale `app.js` never sent, so every deep link queued forever with no error, no toast, no log):
|
||||||
|
|
||||||
|
```
|
||||||
|
mix assets.deploy
|
||||||
|
MIX_ENV=prod mix release bds --overwrite
|
||||||
|
mix bds.bundle.macos
|
||||||
|
```
|
||||||
|
|
||||||
|
**After building a bundle, verify blogmark deep links end-to-end with:**
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/verify_blogmark_macos.sh [path/to/BDS2.app] # defaults to dist/macos/BDS2.app
|
||||||
|
```
|
||||||
|
|
||||||
|
The script launches the bundle via LaunchServices with an isolated database (`launchctl setenv BDS_DATABASE_PATH ...`), fires `bds2://new-post` links with `open -a` exactly like a browser bookmarklet, and asserts draft posts appear in the database — both for cold start (link launches the app, queued until the shell attaches) and for an already-running app. It refuses to run while any BDS2 instance is up; quit the app first.
|
||||||
|
|
||||||
|
Things learned the hard way, encoded in the script — do not "simplify" them away:
|
||||||
|
|
||||||
|
- Deep links only reach an app **launched through LaunchServices** (`open -a`). A process started by exec'ing `Contents/MacOS/bds2` from a shell never receives Apple Events, so deep links vanish without a trace. For the same reason `open` does not inherit the shell environment — inject env vars with `launchctl setenv` (and unset them afterwards).
|
||||||
|
- To introspect a running bundle, launch it with `RELEASE_DISTRIBUTION=name` and `RELEASE_NODE=bds@127.0.0.1` set (via `launchctl setenv`), then use `.../Contents/Resources/rel/bin/bds rpc '<elixir>'` with the same env. The default `-sname` node is unreachable when the hostname resolves to LAN addresses.
|
||||||
|
- The wx → `Desktop.Env` → `BDS.Desktop.DeepLink` → shell pipeline downstream of the OS can be exercised without LaunchServices via `send(Process.whereis(BDS.Desktop.DeepLink), {:open_url, ~c"bds2://new-post?..."})` over rpc.
|
||||||
|
|
||||||
|
> **Assets before release. Verify blogmarks after bundling. No exceptions.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ⚠️ MANDATORY: Proper I18N for UI and Rendering Text
|
## ⚠️ MANDATORY: Proper I18N for UI and Rendering Text
|
||||||
|
|
||||||
**All user-facing text MUST follow proper i18n patterns.**
|
**All user-facing text MUST follow proper i18n patterns.**
|
||||||
|
|
||||||
- Do not hardcode UI strings directly in React components, menu templates, dialogs, or toasts
|
- Do not hardcode UI strings directly in LiveView/HEEx components, menu templates, dialogs, or toasts
|
||||||
- Store UI copy in language resources and resolve text through i18n helpers/hooks
|
- Store UI copy in language resources and resolve text through i18n helpers/hooks
|
||||||
- UI language MUST come from the operating system locale
|
- UI language MUST come from the operating system locale
|
||||||
- Rendering/preview/generated-content language MUST come from project preferences (`mainLanguage`), not UI locale
|
- Rendering/preview/generated-content language MUST come from project preferences (`mainLanguage`), not UI locale
|
||||||
|
|||||||
29
API.md
29
API.md
@@ -40,6 +40,7 @@ local meta = bds.meta.get_project_metadata()
|
|||||||
- [app.get_default_project_path](#appget_default_project_path)
|
- [app.get_default_project_path](#appget_default_project_path)
|
||||||
- [app.get_system_language](#appget_system_language)
|
- [app.get_system_language](#appget_system_language)
|
||||||
- [app.get_title_bar_metrics](#appget_title_bar_metrics)
|
- [app.get_title_bar_metrics](#appget_title_bar_metrics)
|
||||||
|
- [app.log](#applog)
|
||||||
- [app.notify_renderer_ready](#appnotify_renderer_ready)
|
- [app.notify_renderer_ready](#appnotify_renderer_ready)
|
||||||
- [app.open_folder](#appopen_folder)
|
- [app.open_folder](#appopen_folder)
|
||||||
- [app.read_project_metadata](#appread_project_metadata)
|
- [app.read_project_metadata](#appread_project_metadata)
|
||||||
@@ -150,7 +151,7 @@ local result = bds.app.get_default_project_path()
|
|||||||
|
|
||||||
### app.get_system_language
|
### app.get_system_language
|
||||||
|
|
||||||
Return the current UI locale.
|
Return the current UI locale (the server-side UI language setting, falling back to the OS locale when unset).
|
||||||
|
|
||||||
**Parameters**
|
**Parameters**
|
||||||
|
|
||||||
@@ -202,6 +203,30 @@ nil -- or
|
|||||||
local result = bds.app.get_title_bar_metrics()
|
local result = bds.app.get_title_bar_metrics()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### app.log
|
||||||
|
|
||||||
|
Append a line to the script output stream. Multiple arguments are joined with spaces. Output appears in the desktop app's Output panel (and on stdout in the CLI); Lua's global `print` is routed the same way.
|
||||||
|
|
||||||
|
**Parameters**
|
||||||
|
|
||||||
|
- text (string, required)
|
||||||
|
|
||||||
|
**Response specification**
|
||||||
|
|
||||||
|
- Return type: `boolean`
|
||||||
|
|
||||||
|
**Example response**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example call**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.app.log("value")
|
||||||
|
```
|
||||||
|
|
||||||
### app.notify_renderer_ready
|
### app.notify_renderer_ready
|
||||||
|
|
||||||
Notify the host application that the renderer is ready.
|
Notify the host application that the renderer is ready.
|
||||||
@@ -3443,7 +3468,7 @@ local result = bds.meta.sync_on_startup()
|
|||||||
|
|
||||||
### meta.update_project_metadata
|
### meta.update_project_metadata
|
||||||
|
|
||||||
Update metadata for the current project.
|
Update metadata for the current project. Keys omitted from updates keep their current values.
|
||||||
|
|
||||||
**Parameters**
|
**Parameters**
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
- [Generating and publishing](#generating-and-publishing)
|
- [Generating and publishing](#generating-and-publishing)
|
||||||
- [Typical editorial workflows](#typical-editorial-workflows)
|
- [Typical editorial workflows](#typical-editorial-workflows)
|
||||||
- [Working fully offline](#working-fully-offline)
|
- [Working fully offline](#working-fully-offline)
|
||||||
|
- [Running a headless server and the terminal UI](#running-a-headless-server-and-the-terminal-ui)
|
||||||
- [Troubleshooting and recovery](#troubleshooting-and-recovery)
|
- [Troubleshooting and recovery](#troubleshooting-and-recovery)
|
||||||
- [Team conventions](#team-conventions)
|
- [Team conventions](#team-conventions)
|
||||||
|
|
||||||
@@ -443,6 +444,29 @@ When AI is involved, airplane mode determines which automatic actions are allowe
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Running a headless server and the terminal UI
|
||||||
|
|
||||||
|
bDS2 can run without a window on a server (for example a Linux VPS) and be used from several places at once. Start it with `BDS_MODE=server`; the same release binary that runs the desktop app then runs headless. All clients connect through one SSH port (default 2222) — the web endpoint itself stays private on the server.
|
||||||
|
|
||||||
|
Access is controlled by SSH keys, not passwords. On first start the server creates an `ssh/` folder next to its database (for example `~/Library/Application Support/BDS2/ssh/` on macOS) containing the host key and an empty `authorized_keys` file. Paste the public keys of every machine that may connect into `authorized_keys`, one per line — the same file format OpenSSH uses.
|
||||||
|
|
||||||
|
To work in a terminal, connect with plain `ssh -p 2222 user@server`: the terminal UI opens directly. You can browse posts, media, templates, scripts, and tags, create and edit posts, publish, preview images, and run the one-shot AI actions. The status line at the bottom always shows the available keys.
|
||||||
|
|
||||||
|
To work in the desktop app against a remote server, use **File → Connect to Server…** and enter `user@host` (or `user@host:port`). The app connects with the SSH key from its own local `ssh/` folder and shows the remote workspace in the window; **File → Disconnect from Server** returns to the local workspace. A plain browser works too, through a manual SSH tunnel (`ssh -p 2222 -L 4010:127.0.0.1:4010 user@server`, then open `http://127.0.0.1:4010`).
|
||||||
|
|
||||||
|
Everything you see stays synchronized: when a script, another client, or a pipeline changes content on the server, every connected terminal and window updates. The interface language is a server-side setting — changing it in any client changes it for all of them.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- `BDS_MODE=server` runs the same app headless; only the SSH port is exposed.
|
||||||
|
- Access is public-key only: manage `authorized_keys` in the server's `ssh/` folder.
|
||||||
|
- `ssh` gives the terminal UI; **File → Connect to Server…** gives the desktop app; both use the same keys.
|
||||||
|
- All connected clients stay synchronized and share one interface language.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Troubleshooting and recovery
|
## Troubleshooting and recovery
|
||||||
|
|
||||||
If content looks correct locally but is missing for collaborators, the usual cause is that changes were published but not committed and pushed. Check repository status, create a commit, then push to the expected remote.
|
If content looks correct locally but is missing for collaborators, the usual cause is that changes were published but not committed and pushed. Check repository status, create a commit, then push to the expected remote.
|
||||||
|
|||||||
134
README.md
134
README.md
@@ -12,6 +12,7 @@ The major architectural rework is in place.
|
|||||||
- Assets use Phoenix-default Tailwind and esbuild tooling from [assets/](/Users/gb/Projects/bDS2/assets) into [priv/static/](/Users/gb/Projects/bDS2/priv/static).
|
- Assets use Phoenix-default Tailwind and esbuild tooling from [assets/](/Users/gb/Projects/bDS2/assets) into [priv/static/](/Users/gb/Projects/bDS2/priv/static).
|
||||||
- Core editorial flows are implemented in the main application: posts, media, tags, templates, scripts, imports, preview, generation, publishing, maintenance, AI, and MCP.
|
- Core editorial flows are implemented in the main application: posts, media, tags, templates, scripts, imports, preview, generation, publishing, maintenance, AI, and MCP.
|
||||||
- Localization is now a first-class architectural concern rather than an afterthought: UI chrome and rendered site output have separate locale flows, and post/media translation workflows are built into the domain model.
|
- Localization is now a first-class architectural concern rather than an afterthought: UI chrome and rendered site output have separate locale flows, and post/media translation workflows are built into the domain model.
|
||||||
|
- The app boots in three modes: the desktop app, a headless server (SSH-served terminal UI + tunneled GUI), and a local TUI — see [Headless Server Mode And Terminal UI](#headless-server-mode-and-terminal-ui).
|
||||||
|
|
||||||
The rewrite still aims to preserve the product behavior of bDS while replacing the technical stack. The contract is product behavior, not the old implementation language or framework choices.
|
The rewrite still aims to preserve the product behavior of bDS while replacing the technical stack. The contract is product behavior, not the old implementation language or framework choices.
|
||||||
|
|
||||||
@@ -36,7 +37,7 @@ If you are tracing UI behavior, start there first:
|
|||||||
|
|
||||||
- LiveView event routing, workbench state, overlays, and menu handling live in the desktop shell modules.
|
- LiveView event routing, workbench state, overlays, and menu handling live in the desktop shell modules.
|
||||||
- HEEx templates under the same tree now own most common layout and state styling.
|
- HEEx templates under the same tree now own most common layout and state styling.
|
||||||
- Monaco remains a vendor drop under [priv/ui/monaco/](/Users/gb/Projects/bDS2/priv/ui/monaco).
|
- Monaco is bundled as an ESM bundle via esbuild into `priv/static/assets/monaco.js` and `monaco.css` (source entry at `assets/js/monaco_entry.js`).
|
||||||
|
|
||||||
### Domain Modules
|
### Domain Modules
|
||||||
|
|
||||||
@@ -156,9 +157,140 @@ mix dialyzer
|
|||||||
mix assets.build
|
mix assets.build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Headless Server Mode And Terminal UI
|
||||||
|
|
||||||
|
Since issue #26 the app has three boot modes, selected with the `BDS_MODE`
|
||||||
|
environment variable (any release or dev boot; default is the desktop app):
|
||||||
|
|
||||||
|
- `desktop` — the native desktop app, unchanged.
|
||||||
|
- `server` — headless: no window, loopback-only HTTP endpoint, CLI sync
|
||||||
|
watcher, and an SSH daemon serving the terminal UI. Works on macOS,
|
||||||
|
Windows, and Linux (no display needed).
|
||||||
|
- `tui` — the headless server plus a TUI attached to the launching terminal.
|
||||||
|
|
||||||
|
### Build and run a headless server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MIX_ENV=prod mix release bds --overwrite
|
||||||
|
BDS_MODE=server _build/prod/rel/bds/bin/bds start
|
||||||
|
```
|
||||||
|
|
||||||
|
The SSH daemon listens on port `2222` (`BDS_SSH_PORT` or
|
||||||
|
`config :bds, :server, ssh_port:` to change). Authentication is public-key
|
||||||
|
only: on first boot the server generates a host key and an empty
|
||||||
|
`authorized_keys` (mode 600) in `ssh/` next to the database (default
|
||||||
|
`~/Library/Application Support/BDS2/ssh/`, or under `BDS_DATABASE_PATH`).
|
||||||
|
Add your public key there, then connect from any terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -p 2222 <server-host> # opens the TUI
|
||||||
|
```
|
||||||
|
|
||||||
|
Each SSH connection gets its own TUI session on the server; only terminal
|
||||||
|
cells cross the wire. All clients stay synchronized through the domain
|
||||||
|
event bus (`BDS.Events`) — a post created by a pipeline or another client
|
||||||
|
appears in every open shell.
|
||||||
|
|
||||||
|
### TUI keys
|
||||||
|
|
||||||
|
`↑/↓` or `j/k` navigate · `enter` open · `n` new post · `1-5` switch view
|
||||||
|
(posts/media/templates/scripts/tags) · `:` command prompt (metadata
|
||||||
|
diff, validate site, force render, rebuilds, …; `:?` lists all) ·
|
||||||
|
metadata-diff/site-validation reports open as panels — `enter` applies
|
||||||
|
the whole report (repair from files / incremental apply), `esc` cancels ·
|
||||||
|
`r` refresh · in the editor:
|
||||||
|
`ctrl+s` save, `ctrl+p` publish, `ctrl+e` word-wrapped preview,
|
||||||
|
`ctrl+t` title, `ctrl+l` language, `ctrl+g` AI suggestions
|
||||||
|
(airplane-mode gated), `esc` back · `ctrl+q` quit.
|
||||||
|
Images preview inline via the terminal image protocol. The UI language is
|
||||||
|
the server-side setting shared by every client.
|
||||||
|
|
||||||
|
### Remote GUI
|
||||||
|
|
||||||
|
The HTTP endpoint stays bound to `127.0.0.1` and is never exposed; GUI
|
||||||
|
clients tunnel through the same SSH daemon and keys:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -p 2222 -L 4010:127.0.0.1:4010 -N <server-host> &
|
||||||
|
open http://127.0.0.1:4010
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the desktop app directly: **File → Connect to Server…** takes
|
||||||
|
`user@host[:port]`, connects with the client key from the same local
|
||||||
|
`ssh/` directory (public-key auth, trust-on-first-use `known_hosts`),
|
||||||
|
tunnels to the server, and points the window at it. **File → Disconnect
|
||||||
|
from Server** returns to the local workspace.
|
||||||
|
|
||||||
|
## Monaco Editor
|
||||||
|
|
||||||
|
Monaco is bundled via esbuild using its ESM entry point (`monaco-editor/esm/vs/editor/editor.main.js`). The
|
||||||
|
main bundle is built as part of `mix assets.build` and lives at `priv/static/assets/monaco.js` and
|
||||||
|
`monaco.css`; ESM worker bundles live under `priv/static/assets/monaco/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mix monaco.version # show current monaco-editor version
|
||||||
|
mix monaco.update 0.55.1 # update to a specific version and rebuild the bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
The update task runs `npm install monaco-editor@<version>`, cleans the old bundle, and rebuilds via esbuild.
|
||||||
|
See https://www.npmjs.com/package/monaco-editor for available versions.
|
||||||
|
|
||||||
Notes for developers:
|
Notes for developers:
|
||||||
|
|
||||||
- Specs in [specs/](/Users/gb/Projects/bDS2/specs) define the intended product behavior.
|
- Specs in [specs/](/Users/gb/Projects/bDS2/specs) define the intended product behavior.
|
||||||
- [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md) is for end users, not implementation details.
|
- [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md) is for end users, not implementation details.
|
||||||
- [API.md](/Users/gb/Projects/bDS2/API.md) is generated from the live scripting capability map and should stay in sync with runtime changes.
|
- [API.md](/Users/gb/Projects/bDS2/API.md) is generated from the live scripting capability map and should stay in sync with runtime changes.
|
||||||
- When changing persistence or localization behavior, check both the database side and the filesystem/render side before assuming the change is complete.
|
- When changing persistence or localization behavior, check both the database side and the filesystem/render side before assuming the change is complete.
|
||||||
|
|
||||||
|
## Packaging The macOS App
|
||||||
|
|
||||||
|
`mix bds.bundle.macos` produces a self-contained, ad-hoc-signed `BDS2.app`. It
|
||||||
|
bundles the `:bds` release (ERTS included) plus the wxWidgets dylibs relocated
|
||||||
|
via `@loader_path`, so it runs on a clean Mac with nothing installed. The bundle
|
||||||
|
registers the `bds2://` URL scheme (for blogmark deep links) and ships the red-pen
|
||||||
|
`AppIcon`. Output: `dist/macos/BDS2.app`. (arm64 / Apple Silicon only.)
|
||||||
|
|
||||||
|
### 1. Build the release, then the bundle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MIX_ENV=prod mix release bds --overwrite
|
||||||
|
mix bds.bundle.macos --app-release _build/prod/rel/bds
|
||||||
|
```
|
||||||
|
|
||||||
|
The icon `.icns` is generated on first run from
|
||||||
|
[priv/desktop/macos/icon-source.svg](/Users/gb/Projects/bDS2/priv/desktop/macos/icon-source.svg)
|
||||||
|
and committed under `priv/desktop/macos/`. Pass `--regen-icon` to rebuild it.
|
||||||
|
|
||||||
|
### 2. Register the `bds2://` scheme (optional)
|
||||||
|
|
||||||
|
`--register` tells LaunchServices about the scheme without moving the app to
|
||||||
|
`/Applications` — useful for testing blogmark deep links locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mix bds.bundle.macos --app-release _build/prod/rel/bds --register
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run / smoke-test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open dist/macos/BDS2.app
|
||||||
|
open "bds2://new-post?title=Hello&url=https://example.com" # opens a draft
|
||||||
|
```
|
||||||
|
|
||||||
|
### Useful flags
|
||||||
|
|
||||||
|
- `--app-release PATH` — release dir to wrap (default `_build/<env>/rel/bds`).
|
||||||
|
- `--output DIR` — output directory (default `dist/macos`).
|
||||||
|
- `--version V` — version string for `Info.plist` (default from `mix.exs`).
|
||||||
|
- `--regen-icon` — regenerate `AppIcon.icns` from the source SVG.
|
||||||
|
- `--register` — register the `bds2://` scheme via LaunchServices.
|
||||||
|
- `--no-codesign` — skip the ad-hoc codesign step.
|
||||||
|
|
||||||
|
### Verifying the bundle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
plutil -lint dist/macos/BDS2.app/Contents/Info.plist
|
||||||
|
codesign --verify --deep --strict dist/macos/BDS2.app
|
||||||
|
# no /opt/homebrew or /usr/local references should appear:
|
||||||
|
otool -L dist/macos/BDS2.app/Contents/Resources/rel/lib/wx-*/priv/wxe_driver.so
|
||||||
|
```
|
||||||
|
|||||||
193
SPECGAPS.md
193
SPECGAPS.md
@@ -1,193 +0,0 @@
|
|||||||
# Spec Gaps — Allium Specs vs Code vs Tests
|
|
||||||
|
|
||||||
Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update spec | **ST** = write test | **SD** = decide | **SI** = fix internal spec inconsistency
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. Spec Claims Not Fulfilled by Code
|
|
||||||
|
|
||||||
### A1. Code Must Change (spec is normative)
|
|
||||||
|
|
||||||
| ID | Gap | Spec | Code | Path |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| A1-1 | No `archived→draft` or `archived→published` transition | post.allium:121-122 | No code path to unarchive | Fix code: implement unarchive transitions |
|
|
||||||
| A1-2 | `DeletePost` must delete translations + translation files | post.allium:209-212 | `delete_post/1` skips translation cleanup | Fix code: delete PostTranslation rows + files |
|
|
||||||
| A1-3 | Publish must delete old file when path changes | engine_side_effects.allium:73-74 | `publish_post` does not delete old file | Fix code: add old file deletion on path change |
|
|
||||||
| A1-4 | `doNotTranslate: false` written to frontmatter despite "only when true" | frontmatter.allium:398 | `lib/bds/frontmatter.ex:38-39` writes false | Fix code: omit `doNotTranslate` when false |
|
|
||||||
| A1-5 | Auto-save after 3000ms idle | editor_post.allium:183-188 | No auto-save timer | Fix code: implement auto-save on idle + unmount + tab switch |
|
|
||||||
| A1-6 | On-demand rendering in preview server | preview.allium:53-93 | Server serves static pre-generated files | Fix code: implement on-demand template rendering for post/archive/language routes |
|
|
||||||
| A1-7 | Template lookup must use all 4 levels (post→tag→category→default) | template_context.allium:267-277 | Only levels 1 and 4 implemented; tag/category fallback unused | Fix code: implement levels 2-3 in template_selection.ex |
|
|
||||||
| A1-8 | `ValidateLiquid`/`ValidateScript` before publish | template.allium:110, script.allium:165 | No validation gate before publish | Fix code: add validation step before publish |
|
|
||||||
| A1-9 | 17 preset colors + custom hex in tag picker | editor_tags.allium | Native `<input type="color">`, no preset palette | Fix code: implement preset color palette popover |
|
|
||||||
| A1-10 | Template file written on create | engine_side_effects.allium:151-153 | Draft templates have `file_path=""` | Fix code: write template file on create |
|
|
||||||
| A1-11 | Graceful shutdown with inflight request tracking | preview.allium:47-48 | Kills acceptor process, no inflight tracking | Fix code: track inflight requests, drain before shutdown |
|
|
||||||
| A1-12 | Real Pagefind integration for search | generation.allium:208 | Stub only: `pagefind-ui.js` is one-liner, `PagefindUI` never defined, search-runtime.js silently bails, client-side search non-functional | Fix code: bundle real Pagefind, build proper fragment index, wire PagefindUI |
|
|
||||||
| A1-13 | Git sidebar shows only "Working tree" placeholder | sidebar_views.allium:651-770 | `sidebar.ex:782-798` returns single entity_list item; `BDS.Git` has full status/diff/commit/history/fetch/pull/push/prune_lfs but sidebar doesn't use it | Fix code: wire sidebar `git_view/0` to `BDS.Git` — render branch, ahead/behind, status file list, commit input, history entries, action buttons per spec |
|
|
||||||
|
|
||||||
### A2. Spec Should Update (code is normative)
|
|
||||||
|
|
||||||
| ID | Gap | Spec | Code | Path |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| A2-1 | ~~WYSIWYG/visual editor mode (3 modes)~~ | editor_post.allium:159-164 | Only markdown+preview; visual normalizes to markdown | **Resolved:** spec updated to 2 modes (markdown/preview), visual/WYSIWYG dropped |
|
|
||||||
| A2-2 | ~~Template/Script are global entities~~ | template.allium, script.allium | Both have `project_id`, per-project uniqueness | **Resolved:** spec updated — added `project_id` to entities, scoped uniqueness invariants and create rules per project |
|
|
||||||
| A2-3 | ~~TagsFile uses `{tags: [...]}` wrapper~~ | frontmatter.allium:255-273 | Code writes bare array `[...]` | **Resolved:** spec updated — removed wrapper object, TagEntry is now the top-level value, bare array in invariant, camelCase keys |
|
|
||||||
| A2-4 | ~~Sidecar is "YAML-like, not gray-matter"~~ | frontmatter.allium:174 | Code wraps with `---` delimiters | **Resolved:** spec updated — format comment now says gray-matter style with --- delimiters |
|
|
||||||
| A2-5 | ~~Translation frontmatter omits status/timestamps~~ | frontmatter.allium:107-117 | Code writes status, createdAt, updatedAt, publishedAt | **Resolved:** spec updated — TranslationFrontmatter now includes status, created_at, updated_at, published_at; TranslationFilesInheritCanonicalMetadata renamed to TranslationFrontmatterRoundtrip; translation.allium invariant updated to TranslationFilesCarryFullMetadata |
|
|
||||||
| A2-6 | ~~Search index has single `stemmed_content`~~ | search.allium:40-54 | FTS5 per-field stemmed columns | **Resolved:** spec updated — PostSearchIndex has title/excerpt/content/tags/categories; MediaSearchIndex has title/alt/caption/original_name/tags; SearchMedia now accepts filters; index rules use delete-and-reinsert with per-field stemming |
|
|
||||||
| A2-7 | ~~Tag archives are single-page~~ | generation.allium:142-147 | Code paginates | **Resolved:** spec updated — GenerateTagPages now paginated like categories, using max_posts_per_page |
|
|
||||||
| A2-8 | ~~Date archives year+month only~~ | generation.allium:151-159 | Code also generates day-level | **Resolved:** spec updated — GenerateDateArchivePages now includes day-level archives, all three levels paginated |
|
|
||||||
| A2-9 | ~~Menu is DB entity~~ | menu.allium:20-26 | Purely file-based OPML, no DB table | **Resolved:** spec updated — `entity Menu` changed to `value Menu`, file-only model with OPML persistence, added LoadMenu/SyncMenuFromFilesystem rules |
|
|
||||||
| A2-10 | ~~Panel tabs: problems, terminal~~ | layout.allium:235-240 | `[:tasks, :output, :post_links, :git_log]` | **Resolved:** spec already lists tasks/output/post_links/git_log with availability and fallback rules matching code |
|
|
||||||
| A2-11 | ~~Git sidebar: commit input, history, push/pull~~ | sidebar_views.allium | Only "Working tree" item | **Moved to A1-13:** backend code exists in BDS.Git, sidebar must wire it up |
|
|
||||||
| A2-12 | ~~Slug timestamp fallback after 999~~ | post.allium:21 | Unbounded numeric suffix | **Resolved:** spec updated — uniqueness comment now says unbounded numeric suffix, no 999 cap or timestamp fallback |
|
|
||||||
| A2-13 | ~~Thumbnail generation is async~~ | engine_side_effects.allium:117 | Synchronous | **Resolved:** spec updated — import thumbnail generation now says synchronous (awaited, logged on error), matching code; summary table changed from `async` to `sync` |
|
|
||||||
| A2-14 | ~~AiModelModality: :video vs :file/:tool~~ | schema.allium:291 | Code has `:file`, `:tool` instead of `:video` | **Resolved:** spec updated — modality enum now lists "text" \| "image" \| "audio" \| "file" \| "tool", matching code |
|
|
||||||
| A2-15 | ~~JSON key convention: snake_case vs camelCase~~ | frontmatter.allium values | Code uses camelCase for all metadata JSON | **Resolved:** all value types in frontmatter.allium updated to camelCase field names; added CamelCaseKeys invariant; surfaces updated; also added linkedPostIds to MediaSidecar (C-2) and projectId to TemplateFrontmatter/ScriptFrontmatter (B1-9) |
|
|
||||||
| A2-16 | ~~Snowball stemmer language list~~ | search.allium:26-31 | Library determines which have algorithms vs passthrough | **Resolved:** spec updated — StemmerLanguage comment now says "Snowball stemmers via library (Stemex); languages with algorithm get real stemming, others pass through" |
|
|
||||||
| A2-17 | ~~`provider_package_ref` on AiModel~~ | schema.allium:282 | Not in code; legacy field not needed | **Resolved:** dropped from AiModel entity and AiModelRecordSurface in schema.allium; DB column retained (migration artifact) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. Code Behavior Not in Spec
|
|
||||||
|
|
||||||
### B1. Must Add to Spec (domain-level, affects behavior)
|
|
||||||
|
|
||||||
| ID | Behavior | Code Location | Path |
|
|
||||||
|---|---|---|---|
|
|
||||||
| B1-1 | Chat inline surfaces (9 types: card, chart, form, list, metric, mindmap, table, tabs, text/json) | `lib/bds/ui/chat/tool_surfaces.ex:6-15` | Distill into spec |
|
|
||||||
| B1-2 | Auto-translation system (AutoTranslation.maybe_schedule, media cascade, batch fill) | `lib/bds/posts/auto_translation.ex` | Distill into spec |
|
|
||||||
| B1-3 | 3 extra settings sections (Technology, MCP, Data Maintenance) | `lib/bds/ui/settings_editor/` | Distill into spec |
|
|
||||||
| B1-4 | Style/Theme as separate tab (`:style`), not settings section | `lib/bds/ui/style_editor.ex` | Distill into spec |
|
|
||||||
| B1-5 | `published_*` snapshot fields on Post for diffing | `lib/bds/posts/post.ex:61-65` | Add to post.allium entity |
|
|
||||||
| B1-6 | Full rendering subsystem (Liquex, Filters, Labels, LinksAndLanguages, PostRendering) | `lib/bds/rendering/` | Distill into spec |
|
|
||||||
| B1-7 | 404.html generation | `lib/bds/generation/outputs.ex:344-345` | Add to generation.allium |
|
|
||||||
| B1-8 | ~~`linkedPostIds` in media sidecar~~ | `lib/bds/media/sidecars.ex:42` | **Resolved:** added to MediaSidecar value in frontmatter.allium (with A2-15) |
|
|
||||||
| B1-9 | ~~`projectId` in template/script frontmatter~~ | `templates.ex:337`, `scripts.ex:268` | **Resolved:** added projectId to TemplateFrontmatter and ScriptFrontmatter in frontmatter.allium (with A2-15) |
|
|
||||||
| B1-10 | Media translation editing modal | `media_editor.html.heex:275-303` | Add to editor_media.allium |
|
|
||||||
| B1-11 | Menu editor drag-drop, indent/unindent/move | `lib/bds/desktop/menu_editor/tree_ops.ex` | Add to editor_misc.allium |
|
|
||||||
| B1-12 | `:language_picker` overlay with flag emojis | `shell_overlay.html.heex:116-139` | Add to modals.allium |
|
|
||||||
| B1-13 | `:confirm_dialog` generic confirmation | `shell_overlay.html.heex:171-187` | Add to modals.allium |
|
|
||||||
| B1-14 | Publish actions for scripts and templates | `script_editor.html.heex:10-12`, `template_editor.html.heex:10-12` | Add to editor_script.allium, editor_template.allium |
|
|
||||||
| B1-15 | `:import` as full editor tab | `lib/bds/ui/import_editor.ex` | Add to tabs.allium |
|
|
||||||
| B1-16 | `:documentation`/`:api_documentation` tab types | `lib/bds/desktop/misc_editor/` | Add to tabs.allium |
|
|
||||||
| B1-17 | Metadata diff covers embedding, media_translation, post_translation as entity types | `lib/bds/maintenance/repair.ex` | Add to metadata_diff.allium |
|
|
||||||
| B1-18 | Finished task TTL eviction (1h, keep last 10) | `lib/bds/tasks.ex:365-386` | Add to task.allium |
|
|
||||||
| B1-19 | `discard_post_changes/1` | `lib/bds/posts.ex:201-227` | Add to post.allium |
|
|
||||||
| B1-20 | `replace_media_file/2` with checksum/backup | `lib/bds/media.ex:288-337` | Add to media.allium |
|
|
||||||
|
|
||||||
### B2. Lower Priority (implementation detail or minor)
|
|
||||||
|
|
||||||
| ID | Behavior | Code Location |
|
|
||||||
|---|---|---|
|
|
||||||
| B2-1 | `editor_body/1` content resolver | `lib/bds/posts.ex:229-252` |
|
|
||||||
| B2-2 | `sync_post_from_file/1` single-post reimport | `lib/bds/posts.ex:254-279` |
|
|
||||||
| B2-3 | `import_orphan_post_file/1` | `lib/bds/posts.ex:289-291` |
|
|
||||||
| B2-4 | `dashboard_stats/1`, `post_counts_by_year_month/1` | `lib/bds/posts.ex:378-413` |
|
|
||||||
| B2-5 | `regenerate_missing_thumbnails/2` | `lib/bds/media.ex:47-48` |
|
|
||||||
| B2-6 | Cache dir computation | `lib/bds/projects.ex:101-106` |
|
|
||||||
| B2-7 | `remove_stale_published_templates` | `lib/bds/templates.ex:524-552` |
|
|
||||||
| B2-8 | Rendering Labels module (30+ i18n strings) | `lib/bds/rendering/labels.ex` |
|
|
||||||
| B2-9 | Progress reporting during reindex | `lib/bds/generation/progress.ex` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. Internal Spec Inconsistencies
|
|
||||||
|
|
||||||
All reconciled to follow code. Specs must be self-consistent and match code.
|
|
||||||
|
|
||||||
| ID | Conflict | Resolution | Path |
|
|
||||||
|---|---|---|---|
|
|
||||||
| C-1 | schema.allium ChatMessage has no cache tokens; ai.allium ChatMessage has `cache_read_tokens`/`cache_write_tokens` | Code has cache tokens → align schema.allium with ai.allium | Update schema.allium |
|
|
||||||
| C-2 | ~~media.allium SidecarFile mentions `linkedPostIds`; frontmatter.allium MediaSidecar does NOT list it~~ | Code writes `linkedPostIds` → add to frontmatter.allium | **Resolved:** linkedPostIds added to MediaSidecar in frontmatter.allium (with A2-15) |
|
|
||||||
| C-3 | ~~translation.allium says status/timestamps omitted; frontmatter.allium TranslationFrontmatter defines only 5 fields; code writes 8+ fields~~ | Code writes status/timestamps → update both specs to match code | **Resolved:** both specs updated (see A2-5) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. Spec Claims Not Covered by Tests
|
|
||||||
|
|
||||||
### D1. No Test Coverage (HIGH priority — invariants/guarantees)
|
|
||||||
|
|
||||||
| ID | Claim | Spec | Path |
|
|
||||||
|---|---|---|---|
|
|
||||||
| D1-1 | UniqueMediaTranslation invariant | media.allium:108 | Write test: create duplicate media translation, expect rejection |
|
|
||||||
| D1-2 | UniqueTranslationPerLanguage invariant | translation.allium:94 | Write test: create duplicate post translation, expect rejection |
|
|
||||||
| D1-3 | BundledDefaultTemplatesExistOutsideProjectData | template.allium:65 | Write test: render with no Template rows, bundled template found |
|
|
||||||
| D1-4 | UserTemplateDirectoryOverridesBundledDefaults | template.allium:75 | Write test: project template overrides bundled same-slug |
|
|
||||||
| D1-5 | LiquidTagSubset (5 tags only) | template.allium:179 | Write test: unsupported tag raises error |
|
|
||||||
| D1-6 | LiquidFilterSubset (4 standard + 2 custom) | template.allium:191 | Write test: unsupported filter raises error |
|
|
||||||
| D1-7 | LiquidOperatorSubset | template.allium:210 | Write test: unsupported operator raises error |
|
|
||||||
| D1-8 | MacroTimeout guarantee | script.allium:94-95 | Write test: macro times out within budget |
|
|
||||||
| D1-9 | ExecuteTransform rule (pipeline, ordering, toast budget) | script.allium:229-263 | Write test: transform pipeline executes in order, toast budget enforced |
|
|
||||||
| D1-10 | TransformPipelineContinuation | script.allium:247-249 | Write test: error in transform doesn't halt pipeline |
|
|
||||||
| D1-11 | ChatContextTruncation invariant | ai.allium:375-379 | Write test: long chat history trimmed to context window |
|
|
||||||
| D1-12 | BoundedToolLoop enforcement | ai.allium:381-385 | Write test: tool rounds bounded by chat_max_tool_rounds |
|
|
||||||
| D1-13 | DiscardPostChangesSideEffects | engine_side_effects.allium:99-104 | Write test: FTS updated after discard |
|
|
||||||
| D1-14 | ReplaceMediaFileSideEffects | engine_side_effects.allium:128-134 | Write test: file replaced, thumbnails regenerated |
|
|
||||||
| D1-15 | Drag-and-drop image chain | action_patterns.allium:84-103 | Write integration test |
|
|
||||||
| D1-16 | DebouncedPersistence (5s) | embedding.allium:204-208 | Write test: index persistence debounced |
|
|
||||||
| D1-17 | Protected categories cannot be deleted | editor_settings.allium:81-84 | Write test: article/aside/page/picture deletion rejected |
|
|
||||||
| D1-18 | HomeItemProtection (menu) | editor_misc.allium:206-209 | Write test: cannot move/reorder/delete Home |
|
|
||||||
|
|
||||||
### D2. No Test Coverage (MEDIUM priority — rules/behaviors)
|
|
||||||
|
|
||||||
| ID | Claim | Spec | Path |
|
|
||||||
|---|---|---|---|
|
|
||||||
| D2-1 | RemoveCategory rule | metadata.allium:100 | Write test: remove category, verify list+settings+JSON updated |
|
|
||||||
| D2-2 | CreateAndPublishTemplate rule | template.allium:105 | Write test: create+publish in one step |
|
|
||||||
| D2-3 | CreateAndPublishScript rule | script.allium:160 | Write test: create+publish in one step |
|
|
||||||
| D2-4 | UniqueScriptSlug dedup | script.allium:115 | Write test: two scripts same title → dedup slug |
|
|
||||||
| D2-5 | FrontmatterRoundtrip invariant | post.allium:223 | Write test: write file, read back, assert all DB fields match |
|
|
||||||
| D2-6 | SidecarRoundtrip invariant | media.allium:198 | Write test: write sidecar, read back, assert all fields match |
|
|
||||||
| D2-7 | ConditionalPostFields: nil fields absent from frontmatter | frontmatter.allium:398 | Write test: post with nil excerpt/author/language → fields not in file |
|
|
||||||
| D2-8 | ConditionalMediaFields: nil fields absent from sidecar | frontmatter.allium:417 | Write test: media with nil title/alt → fields not in sidecar |
|
|
||||||
| D2-9 | max_posts_per_page 1..500 constraint | metadata.allium:75-77 | Write test: values outside range rejected |
|
|
||||||
| D2-10 | SandboxedExecution: restricted capabilities blocked | script.allium:84-88 | Write test: filesystem/process/package loading blocked |
|
|
||||||
| D2-11 | TransformToastBudget enforcement | script.allium:251-258 | Write test: per-script and total toast limits enforced |
|
|
||||||
| D2-12 | ProgressThrottled: 250ms throttle | task.allium:110-113 | Write test: rapid progress reports throttled |
|
|
||||||
| D2-13 | archived→draft transition | post.allium:121 | Write test: unarchive post → draft |
|
|
||||||
| D2-14 | archived→published transition | post.allium:122 | Write test: unarchive post → published |
|
|
||||||
| D2-15 | AppNoopNotifier: app writes don't produce notification rows | cli_sync.allium:64-68 | Write test: app mutation produces no notification row |
|
|
||||||
| D2-16 | ValidateMedia rule | media_processing.allium:318-343 | Write test: missing/corrupted/orphan media detected |
|
|
||||||
| D2-17 | ContentHashSkipsUnchanged during reindex | embedding.allium:199-202 | Write test: unchanged content_hash skips re-embedding |
|
|
||||||
|
|
||||||
### D3. Partial Test Coverage (needs expansion)
|
|
||||||
|
|
||||||
| ID | Claim | Spec | Gap | Path |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| D3-1 | PublishPost: content=null after publish | post.allium:186 | Not explicitly tested | Add assertion |
|
|
||||||
| D3-2 | PublishPost: old file deleted on path change | engine_side_effects.allium:73-74 | Not tested | Add test |
|
|
||||||
| D3-3 | UpsertPostTranslation: do_not_translate guard | translation.allium:113 | Indirectly covered only | Add direct test |
|
|
||||||
| D3-4 | PublishTemplate: Liquid validation prerequisite | template.allium:139 | Not tested as publish gate | Add test |
|
|
||||||
| D3-5 | PublishScript: validation prerequisite | script.allium:181 | Not tested as publish gate | Add test |
|
|
||||||
| D3-6 | ExecuteMacro failure degrades to empty | script.allium:199 | Returns error tuple, not empty | Fix code or update spec |
|
|
||||||
| D3-7 | TemplateFrontmatter roundtrip | template.allium:53 | Slug verified, no full parse-back | Add roundtrip test |
|
|
||||||
| D3-8 | DefaultCategories for fresh project | metadata.allium:60 | Defaults present after add, not verified fresh | Add fresh-project test |
|
|
||||||
| D3-9 | FtsIncludesTranslations | translation.allium:178 | Tested for one language; expand | Test all stemmer languages |
|
|
||||||
| D3-10 | PostCanonicalUrl format | post.allium:33-40 | Constructed in links test, not asserted as invariant | Add format assertion |
|
|
||||||
| D3-11 | Slug generation: German transliteration | post.allium:14-22 | "Föö Bär" → "foo-bar-blog" tested; expand ä/ö/ü/ß/ÄÖÜ | Expand test |
|
|
||||||
|
|
||||||
### D4. UI Test Coverage Gaps (whole-editor specs)
|
|
||||||
|
|
||||||
| ID | Spec | Covered | Not Covered |
|
|
||||||
|---|---|---|---|
|
|
||||||
| D4-1 | editor_media.allium | AI analysis, delete | Translate, replace file, link-to-post, translation CRUD, detect language |
|
|
||||||
| D4-2 | editor_settings.allium | AI endpoints, airplane toggle, rebuild | Protected categories, MCP agents, style/theme, search filter, categories CRUD |
|
|
||||||
| D4-3 | editor_chat.allium | Chat creation, pinned tab | API key screen, message rendering, input area, model selector, inline surfaces |
|
|
||||||
| D4-4 | editor_script.allium | Editor layout, create defaults | Save, syntax check, run, delete |
|
|
||||||
| D4-5 | editor_template.allium | Editor layout, create defaults | Save with validation, validate, delete with references |
|
|
||||||
| D4-6 | editor_tags.allium | Sync/discover, merge | Cloud sizing, color picker, delete confirmation, create form |
|
|
||||||
| D4-7 | editor_misc.allium | Menu add/save, metadata diff, validation | Menu protection, import analysis, translation fix, duplicate dismiss, git diff |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Priority Order for Resolution
|
|
||||||
|
|
||||||
1. **A1-1 through A1-12** — code must follow spec (includes auto-save, on-demand preview, template lookup, validation gates, real Pagefind, graceful shutdown)
|
|
||||||
2. **D1-1 through D1-18** — untested invariants/guarantees
|
|
||||||
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
|
|
||||||
4. **B1-1 through B1-6** — major code behaviors missing from spec
|
|
||||||
5. **A2-1 through A2-17** — spec drift (code is normative, update spec)
|
|
||||||
6. **D2-1 through D2-17** — untested rules
|
|
||||||
7. **D3-1 through D3-11** — partial test coverage
|
|
||||||
8. **B1-7 through B1-20** — minor code behaviors missing from spec
|
|
||||||
9. **D4-1 through D4-7** — UI test coverage
|
|
||||||
223
TUI_EDITOR.md
Normal file
223
TUI_EDITOR.md
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
# TUI Editor — Implementation Plan (Issue #32)
|
||||||
|
|
||||||
|
Tracking issue: **#32** — "markdown, liquid and lua code editor with syntax
|
||||||
|
highlighting in the TUI".
|
||||||
|
Spec under change: `specs/tui.allium`.
|
||||||
|
|
||||||
|
Goal: a syntax-highlighted, word-wrapping TUI editor for posts (markdown
|
||||||
|
with `[[macro]]` syntax), templates (HTML + Liquid), and scripts (Lua),
|
||||||
|
replacing the current plain `ExRatatui.Widgets.Textarea` body.
|
||||||
|
|
||||||
|
## Decision: pure Elixir — no new Rust
|
||||||
|
|
||||||
|
`ExRatatui` already ships everything we need:
|
||||||
|
|
||||||
|
- `ExRatatui.CodeBlock.highlight/3` (`deps/ex_ratatui/lib/ex_ratatui/code_block.ex:103`)
|
||||||
|
returns `[%ExRatatui.Text.Line{}]` of styled spans via the bundled
|
||||||
|
syntect NIF. Supports Lua, Markdown, HTML, … out of the box.
|
||||||
|
- `ExRatatui.Widgets.CodeBlock` is read-only; we do **not** use it
|
||||||
|
directly for editing.
|
||||||
|
- `ExRatatui.Widget` protocol (`deps/ex_ratatui/lib/ex_ratatui/widget.ex:1`)
|
||||||
|
lets us implement a custom widget in pure Elixir that composes
|
||||||
|
primitives every frame. This is the seam we use.
|
||||||
|
- The editing engine stays Rust-owned
|
||||||
|
(`ExRatatui.textarea_new/0`, `textarea_handle_key/3`, `textarea_get_value/1`,
|
||||||
|
`textarea_cursor/1`, `textarea_line_count/1`). The custom widget only
|
||||||
|
re-renders the buffer through a styling + wrap pipeline.
|
||||||
|
- `ExRatatui.Widgets.Paragraph` accepts `[%Text.Line{}]` of styled spans
|
||||||
|
with `wrap: true` and a `scroll: {vertical, horizontal}` offset —
|
||||||
|
exactly the output shape we need.
|
||||||
|
|
||||||
|
The existing comment at `lib/bds/tui.ex:1451-1454` already pre-announces
|
||||||
|
this approach as "the planned MarkdownEditor custom widget".
|
||||||
|
|
||||||
|
Languages supported (matches GUI Monaco registrations in
|
||||||
|
`assets/js/monaco/languages.js`):
|
||||||
|
|
||||||
|
| Entity | Base lexer (NIF) | Elixir overlay |
|
||||||
|
|-----------------------|------------------|---------------------------------|
|
||||||
|
| post body | `markdown` | `[[macro …]]` recoloured |
|
||||||
|
| script body | `lua` | none |
|
||||||
|
| template body | `html` | `{{ … }}` and `{% … %}` recoloured |
|
||||||
|
|
||||||
|
Liquid is always HTML+Liquid in this codebase (no markdown+Liquid
|
||||||
|
templates exist). Plain text falls back to syntect's plain renderer.
|
||||||
|
|
||||||
|
## GUI styling parity (from `assets/js/monaco/theme.js` + `languages.js`)
|
||||||
|
|
||||||
|
- **macro** `[[ … ]]` → `keyword.macro` = `#C586C0`, **bold**.
|
||||||
|
- inside macro: `attribute.name` (e.g. `names`) → `#9CDCFE`,
|
||||||
|
`attribute.value` (e.g. `"x"`) → `#CE9178`.
|
||||||
|
- Liquid uses base vs-dark tokens; terminal approximation uses the same
|
||||||
|
base theme accent colours. No custom Liquid token colours overridden.
|
||||||
|
|
||||||
|
Use `{:rgb,197,134,192}` etc. via `ExRatatui.Style` (already supported
|
||||||
|
by `ExRatatui.CodeBlock.from_native/1`).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
state.editor.textarea (unchanged, Rust-owned buffer/cursor/undo)
|
||||||
|
state.editor.language (:markdown_macros | :liquid | :lua | :text)
|
||||||
|
state.editor.preview? (unchanged ctrl+e toggle — default false for posts)
|
||||||
|
|
||||||
|
body_widget/3 edit branch: %BDS.UI.CodeEditor{textarea:, language:, theme:, wrap:, cursor_style:, line_highlight_style:, block:}
|
||||||
|
↳ impl ExRatatui.Widget.render:
|
||||||
|
1. content = ExRatatui.textarea_get_value(textarea)
|
||||||
|
2. {row, col} = ExRatatui.textarea_cursor(textarea)
|
||||||
|
3. lines = BDS.UI.CodeEditor.Highlight.highlight(content, language, theme)
|
||||||
|
4. apply current-line bg style into every span on lines[row]
|
||||||
|
5. cut the span at byte col on lines[row], splice cursor-style cell
|
||||||
|
6. top = vertical-scroll offset so cursor visual row stays in window (Latin-width count)
|
||||||
|
7. return [{%Paragraph{text: lines, wrap: true, scroll: {top,0}, block: block}, rect}]
|
||||||
|
```
|
||||||
|
|
||||||
|
`editor_key/1` in `lib/bds/tui.ex` keeps calling
|
||||||
|
`ExRatatui.textarea_handle_key/3` — no edit-model rewrite, the
|
||||||
|
textarea's own undo/redo/clipboard keeps working.
|
||||||
|
|
||||||
|
## Phases (test-first, per AGENTS.md)
|
||||||
|
|
||||||
|
### Phase 0 — Spec
|
||||||
|
|
||||||
|
Edit `specs/tui.allium`:
|
||||||
|
|
||||||
|
- `OpenEntry`: posts open in the **editor** (wrap on, highlighting on)
|
||||||
|
by default — not the rendered Markdown preview. New empty posts still
|
||||||
|
open in the editor (unchanged).
|
||||||
|
- Rename `WrappedPreview` → `EditorMode`: `ctrl+e` toggles editor ↔
|
||||||
|
read-only rendered-Markdown preview (via `ExRatatui.Widgets.Markdown`).
|
||||||
|
Drop the "textarea cannot wrap" caveat.
|
||||||
|
- Add `CodeEditorWrapping` rule: syntax highlighting + auto wrap +
|
||||||
|
highlighted current line. Explicitly **no line-number gutter**.
|
||||||
|
- Validate with `allium check specs/tui.allium`.
|
||||||
|
|
||||||
|
### Phase 1 — Red tests
|
||||||
|
|
||||||
|
New file `test/bds/ui/code_editor_test.exs`:
|
||||||
|
|
||||||
|
- `BDS.UI.CodeEditor.Highlight.highlight/3` for `:lua` colours a
|
||||||
|
`function … end` keyword with the theme's keyword colour.
|
||||||
|
- `:markdown_macros` recolours `[[gallery names="x"]]`:
|
||||||
|
- `[[`, `]]` and macro name → `{:rgb,197,134,192}` bold
|
||||||
|
- `names` → `{:rgb,156,220,254}`
|
||||||
|
- `"x"` → `{:rgb,206,145,120}`
|
||||||
|
- surrounding markdown still base-styled.
|
||||||
|
- `:liquid` recolours `{{ x }}` and `{% if x %}` over an HTML base;
|
||||||
|
`if` keyword distinct from identifiers.
|
||||||
|
- Widget emits a `%Paragraph{wrap: true, scroll:}`, never a bare `%Textarea`,
|
||||||
|
when language is one of the highlighted set.
|
||||||
|
|
||||||
|
Extended `test/bds/tui_test.exs`:
|
||||||
|
|
||||||
|
- Opening an existing post lands in the **editor** (`preview? == false`),
|
||||||
|
not the rendered preview. Replace the assertion in
|
||||||
|
`test "opening a post lands in the markdown preview, not the editor"`.
|
||||||
|
Test name/logic flips.
|
||||||
|
- Long line wraps across N visual rows in a 40-wide viewport
|
||||||
|
(assert via `CellSession.take_cells/1`).
|
||||||
|
- Cursor at the last visual row stays visible when scrolling a small
|
||||||
|
viewport (assert cursor-style cell appears in snapshot cells).
|
||||||
|
- Current line's cells have a distinct `bg` from non-current rows on
|
||||||
|
the same buffer.
|
||||||
|
- `ctrl+e` flips to preview and back; preview still rendered Markdown.
|
||||||
|
- `ctrl+s` persists textarea bytes unchanged regardless of highlight/wrap
|
||||||
|
(existing assertion still covers this — keep it green).
|
||||||
|
|
||||||
|
### Phase 2 — `lib/bds/ui/code_editor/highlight.ex` + `overlay.ex`
|
||||||
|
|
||||||
|
- `BDS.UI.CodeEditor.Highlight.highlight(content, language, theme)` →
|
||||||
|
`[%ExRatatui.Text.Line{}]`:
|
||||||
|
- `:lua`, `:text` → pass straight through to
|
||||||
|
`ExRatatui.CodeBlock.highlight/3`.
|
||||||
|
- `:markdown_macros` → base highlight as `"markdown"`, then apply
|
||||||
|
`Overlay.markdown_macros/1` per source line.
|
||||||
|
- `:liquid` → base highlight as `"html"`, then apply
|
||||||
|
`Overlay.liquid/1` per source line.
|
||||||
|
- `BDS.UI.CodeEditor.Overlay`:
|
||||||
|
- `apply_ranges(line, ranges_with_styles)` — re-splits a `Line`'s
|
||||||
|
spans into a new span list, preserving the base styling outside
|
||||||
|
each range.
|
||||||
|
- `markdown_macro_ranges/1` — `~r/\[\[\s*[a-zA-Z][\w-]*[^]]*\]\]/s`.
|
||||||
|
- `macro_inner_ranges/1` — break `attr=` (attr-name) and quoted value
|
||||||
|
into separate styled sub-ranges inside a macro.
|
||||||
|
- `liquid_ranges/1` — `~r/(\{\{.*?\}\}|\{%-?\s*(\w+).*?-?%\})/s`,
|
||||||
|
with a sub-range for the tag keyword (`if`, `for`, `assign`, …).
|
||||||
|
- Cache results by `{content_hash, language, theme}` if profiling shows
|
||||||
|
re-tokenisation overhead on keystroke (Phase 2 stretch goal —
|
||||||
|
skip until a test says otherwise).
|
||||||
|
|
||||||
|
### Phase 3 — `lib/bds/ui/code_editor/widget.ex`
|
||||||
|
|
||||||
|
- `%BDS.UI.CodeEditor{}` struct fields: `textarea`, `language`,
|
||||||
|
`theme` (default `:base16_ocean_dark`, readable in dark terminals),
|
||||||
|
`wrap: true`, `cursor_style`, `line_highlight_style`, `block`, `scroll`.
|
||||||
|
- `defimpl ExRatatui.Widget` per the pipeline above.
|
||||||
|
- Cursor cell: re-style the single codepoint at `col` on source line
|
||||||
|
`row` into `cursor_style`. Paragraph wraps the same line; the styled
|
||||||
|
cell rides the wrap.
|
||||||
|
- Scroll math: count display rows by wrapping each source line into
|
||||||
|
`width` columns (Latin-width assumption — 1 char = 1 cell). Note the
|
||||||
|
limitation explicitly; CJK width is a follow-up if/when needed.
|
||||||
|
- Line highlight: apply `line_highlight_style` (a `bg:` colour) to
|
||||||
|
every span on the cursor's source line before cursor cut-in.
|
||||||
|
|
||||||
|
### Phase 4 — Wire into `lib/bds/tui.ex`
|
||||||
|
|
||||||
|
- `build_editor/4` (around `lib/bds/tui.ex:1968`): default
|
||||||
|
`preview? = false` for existing posts. New posts already open in
|
||||||
|
editor — unchanged.
|
||||||
|
- `body_widget/3` edit branch (`lib/bds/tui.ex:1466`): emit
|
||||||
|
`%BDS.UI.CodeEditor{}` instead of `%ExRatatui.Widgets.Textarea{}` for
|
||||||
|
entities that have a syntax highlighter (`:lua`, `:markdown_macros`,
|
||||||
|
`:liquid`). Plain text can keep `%Textarea{}` or feed `:text` through
|
||||||
|
the new widget — pick whichever keeps the test surface smaller.
|
||||||
|
- Remove the "wrap limitation" comment block at
|
||||||
|
`lib/bds/tui.ex:1451-1454`.
|
||||||
|
- `cycle_language/1` continues per post; cycle through the active
|
||||||
|
set (`:markdown_macros`, `:liquid`, `:lua`, `:text`) matching the
|
||||||
|
existing GUI editor language options.
|
||||||
|
- Preview branch (`body_widget/3` preview arm at `lib/bds/tui.ex:1455`)
|
||||||
|
stays on `ExRatatui.Widgets.Markdown` — unchanged.
|
||||||
|
- No new user-facing strings: the existing body title already flows
|
||||||
|
through `dgettext("ui", …)`. If a new toolbar/hint is added later,
|
||||||
|
it MUST go through gettext and provide de/fr/it/es translations.
|
||||||
|
|
||||||
|
### Phase 5 — Verify
|
||||||
|
|
||||||
|
- `mix test` (write to `/tmp/test_run.log` first, grep for failures —
|
||||||
|
per AGENTS.md).
|
||||||
|
- `mix credo --strict`
|
||||||
|
- `mix deps.audit --ignore-file .mix_audit.ignore`
|
||||||
|
- `mix dialyzer` — treat warnings as errors.
|
||||||
|
|
||||||
|
No bundle rebuild, no asset change, no NIF rebuild. Monaco still drives
|
||||||
|
the GUI editor; this change is TUI-only.
|
||||||
|
|
||||||
|
## Risks / limits
|
||||||
|
|
||||||
|
- **Per-frame NIF cost:** re-tokenises the whole buffer on every
|
||||||
|
keystroke. For typical post sizes (<50 KB) syntect is sub-ms. Add the
|
||||||
|
`{content_hash, language, theme}` cache only if a test shows it.
|
||||||
|
- **CJK cell width:** wrap math assumes 1 char = 1 cell. Fine for
|
||||||
|
de/fr/it/es. Note as TODO; not in scope for this phase.
|
||||||
|
- **Liquid mis-lexing inside HTML** is cosmetically possible (an HTML
|
||||||
|
attribute containing `{{`). Overlay wins after the base lexer; the
|
||||||
|
result is visually tolerable. A custom `.sublime-syntax` in the
|
||||||
|
dep would be the real fix — out of scope here per "stay on Elixir".
|
||||||
|
- **Cursor in a wrapped paragraph** rides the styled cell. Equivalent
|
||||||
|
to a cell-caret; GUI uses a real caret — acceptable TUI parity.
|
||||||
|
|
||||||
|
## Files touched (expected)
|
||||||
|
|
||||||
|
- `specs/tui.allium` — edited.
|
||||||
|
- `lib/bds/ui/code_editor/highlight.ex` — new.
|
||||||
|
- `lib/bds/ui/code_editor/overlay.ex` — new.
|
||||||
|
- `lib/bds/ui/code_editor/widget.ex` — new.
|
||||||
|
- `lib/bds/tui.ex` — edited (`build_editor`, `body_widget`, comment
|
||||||
|
removal, default `preview?`).
|
||||||
|
- `test/bds/ui/code_editor_test.exs` — new.
|
||||||
|
- `test/bds/tui_test.exs` — edited (flip the "opens in preview"
|
||||||
|
assertion, add wrap/cursor/current-line tests).
|
||||||
|
|
||||||
|
No changes to `mix.exs`, no new deps, no Rust, no assets.
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
@source "../css";
|
@source "../css";
|
||||||
@source "../js";
|
@source "../js";
|
||||||
@source "../../lib/bds/desktop";
|
@source "../../lib/bds/desktop";
|
||||||
|
@source "../../deps/live_toast/lib";
|
||||||
|
|
||||||
@import "./tokens.css";
|
@import "./tokens.css";
|
||||||
@import "./shell.css";
|
@import "./shell.css";
|
||||||
@import "./sidebar.css";
|
@import "./sidebar.css";
|
||||||
|
@import "./git_sidebar.css";
|
||||||
@import "./tabs.css";
|
@import "./tabs.css";
|
||||||
@import "./editor.css";
|
@import "./editor.css";
|
||||||
@import "./forms.css";
|
@import "./forms.css";
|
||||||
@@ -16,4 +18,5 @@
|
|||||||
@import "./menu_editor.css";
|
@import "./menu_editor.css";
|
||||||
@import "./media_editor.css";
|
@import "./media_editor.css";
|
||||||
@import "./import_editor.css";
|
@import "./import_editor.css";
|
||||||
|
@import "./misc_editor.css";
|
||||||
@import "./utilities.css";
|
@import "./utilities.css";
|
||||||
@@ -272,6 +272,142 @@
|
|||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stacked bars: segments sit side by side inside the track. */
|
||||||
|
.chat-surface-chart-bar-stacked {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-bar-segment {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared legend (pie/donut/stacked-bar). */
|
||||||
|
.chat-surface-chart-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-legend-swatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pie / donut. */
|
||||||
|
.chat-surface-chart-pie {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-pie-svg {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-pie-slice {
|
||||||
|
stroke: var(--vscode-editor-background, #1e1e1e);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-donut-hole {
|
||||||
|
fill: var(--vscode-editor-background, #1e1e1e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-donut-total {
|
||||||
|
fill: var(--vscode-editor-foreground);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line / area. */
|
||||||
|
.chat-surface-chart-line-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-line-grid {
|
||||||
|
stroke: rgba(255, 255, 255, 0.08);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-line-y-label,
|
||||||
|
.chat-surface-chart-line-x-label {
|
||||||
|
fill: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-line-path {
|
||||||
|
stroke: var(--accent-color);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-area-fill {
|
||||||
|
fill: var(--accent-color);
|
||||||
|
opacity: 0.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-line-dot {
|
||||||
|
fill: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Heatmap. */
|
||||||
|
.chat-surface-chart-heatmap {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-heatmap-corner {
|
||||||
|
/* empty top-left cell */
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-heatmap-col-label {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-heatmap-row-label {
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 4px;
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-heatmap-cell {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
min-width: 14px;
|
||||||
|
min-height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Card surface ──────────────────────────────────────────────────── */
|
/* ── Card surface ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.chat-surface-card {
|
.chat-surface-card {
|
||||||
@@ -510,12 +646,6 @@
|
|||||||
max-width: 22px;
|
max-width: 22px;
|
||||||
max-height: 22px;
|
max-height: 22px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: var(--vscode-button-background);
|
|
||||||
color: var(--vscode-button-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-panel .chat-send-button:hover:not(:disabled) {
|
|
||||||
background: var(--vscode-button-hoverBackground);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-panel .chat-send-button:disabled {
|
.chat-panel .chat-send-button:disabled {
|
||||||
@@ -555,3 +685,88 @@
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Colour picker popover */
|
||||||
|
.colour-picker-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-trigger {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-trigger:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 30;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
width: 196px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-swatch {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: border-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-swatch:hover {
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-swatch.selected {
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
box-shadow: 0 0 0 1px var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-custom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-custom label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-custom input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,8 +14,7 @@
|
|||||||
|
|
||||||
.editor-main,
|
.editor-main,
|
||||||
.editor-meta,
|
.editor-meta,
|
||||||
.panel-shell,
|
.panel-shell {
|
||||||
.assistant-card {
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,8 +49,7 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar-button:hover,
|
.editor-toolbar-button:hover {
|
||||||
.panel-tab:hover {
|
|
||||||
background: var(--vscode-toolbar-hoverBackground);
|
background: var(--vscode-toolbar-hoverBackground);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,6 +615,14 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-suggestion-section-label {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--vscode-descriptionForeground, #a6a6a6);
|
||||||
|
}
|
||||||
|
|
||||||
.post-editor .tag-suggestion.create-new {
|
.post-editor .tag-suggestion.create-new {
|
||||||
border-top: 1px solid var(--vscode-widget-border, #454545);
|
border-top: 1px solid var(--vscode-widget-border, #454545);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
@@ -742,8 +748,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.post-editor .editor-toolbar {
|
.post-editor .editor-toolbar {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@@ -751,23 +756,27 @@
|
|||||||
|
|
||||||
.post-editor .editor-toolbar-left {
|
.post-editor .editor-toolbar-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-editor .editor-toolbar-center {
|
.post-editor .editor-toolbar-center {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ponytail: right group keeps its content width so buttons never wrap or
|
||||||
|
overlap the mode toggle; the left label shrinks instead */
|
||||||
.post-editor .editor-toolbar-right {
|
.post-editor .editor-toolbar-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 1 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-width: 0;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-editor .editor-mode-toggle {
|
.post-editor .editor-mode-toggle {
|
||||||
@@ -809,38 +818,6 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-editor .gallery-button {
|
|
||||||
padding: 4px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: var(--vscode-button-secondaryBackground);
|
|
||||||
color: var(--vscode-button-secondaryForeground);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-editor .gallery-button:hover {
|
|
||||||
background-color: var(--vscode-button-secondaryHoverBackground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-editor .insert-post-link-button,
|
|
||||||
.post-editor .insert-media-button {
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
border-radius: 4px;
|
|
||||||
background-color: var(--vscode-button-secondaryBackground);
|
|
||||||
color: var(--vscode-button-secondaryForeground);
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-editor .insert-post-link-button:hover,
|
|
||||||
.post-editor .insert-media-button:hover {
|
|
||||||
background-color: var(--vscode-button-secondaryHoverBackground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-editor .editor-preview {
|
.post-editor .editor-preview {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background-color: var(--vscode-input-background);
|
background-color: var(--vscode-input-background);
|
||||||
@@ -929,8 +906,7 @@
|
|||||||
.post-editor .editor-header,
|
.post-editor .editor-header,
|
||||||
.scripts-view-shell .ui-editor-header,
|
.scripts-view-shell .ui-editor-header,
|
||||||
.templates-view-shell .ui-editor-header,
|
.templates-view-shell .ui-editor-header,
|
||||||
.post-editor .metadata-toggle-header,
|
.post-editor .metadata-toggle-header {
|
||||||
.post-editor .editor-toolbar {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -946,7 +922,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-editor .editor-toolbar-right,
|
|
||||||
.post-editor .ui-editor-actions,
|
.post-editor .ui-editor-actions,
|
||||||
.scripts-view-shell .ui-editor-actions,
|
.scripts-view-shell .ui-editor-actions,
|
||||||
.templates-view-shell .ui-editor-actions {
|
.templates-view-shell .ui-editor-actions {
|
||||||
|
|||||||
294
assets/css/git_sidebar.css
Normal file
294
assets/css/git_sidebar.css
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
/* ── Git sidebar ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-header {
|
||||||
|
padding: 8px 12px 12px;
|
||||||
|
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-branch {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-sideBar-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-branch-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-upstream {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--vscode-badge-background);
|
||||||
|
color: var(--vscode-badge-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-ahead {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-testing-iconPassed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-behind {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-notificationsInfoIcon-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sync legend ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-legend-item {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sync-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sync-synced {
|
||||||
|
background-color: var(--vscode-testing-iconPassed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sync-local_only {
|
||||||
|
background-color: var(--vscode-editorWarning-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sync-remote_only {
|
||||||
|
background-color: var(--vscode-notificationsInfoIcon-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Actions ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-actions {
|
||||||
|
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-action-button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
background-color: var(--vscode-button-secondaryBackground);
|
||||||
|
color: var(--vscode-button-secondaryForeground);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-action-button:hover:not(:disabled) {
|
||||||
|
background-color: var(--vscode-button-secondaryHoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-action-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sections ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-section {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--vscode-sideBar-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-section-count {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Forms ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-commit-form,
|
||||||
|
.git-init-form {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-commit-form input,
|
||||||
|
.git-init-form input {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
background-color: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-commit-form input::placeholder,
|
||||||
|
.git-init-form input::placeholder {
|
||||||
|
color: var(--vscode-input-placeholderForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-commit-form input:focus,
|
||||||
|
.git-init-form input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-commit-form .git-action-button,
|
||||||
|
.git-init-form .git-action-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status file list ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-status-file {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-sideBar-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-file:hover {
|
||||||
|
background-color: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-path {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-added {
|
||||||
|
color: var(--vscode-testing-iconPassed);
|
||||||
|
background: color-mix(in srgb, var(--vscode-testing-iconPassed) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-modified {
|
||||||
|
color: var(--vscode-editorWarning-foreground);
|
||||||
|
background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-deleted {
|
||||||
|
color: var(--vscode-errorForeground);
|
||||||
|
background: color-mix(in srgb, var(--vscode-errorForeground) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-renamed {
|
||||||
|
color: var(--vscode-notificationsInfoIcon-foreground);
|
||||||
|
background: color-mix(in srgb, var(--vscode-notificationsInfoIcon-foreground) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-untracked {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
background: color-mix(in srgb, var(--vscode-descriptionForeground) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Commit history list ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-history-entry {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-sideBar-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-history-entry:hover {
|
||||||
|
background-color: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-history-subject {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-sideBar-foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-history-meta {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-history-hash,
|
||||||
|
.git-history-author,
|
||||||
|
.git-history-date {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-history-hash {
|
||||||
|
font-family: var(--vscode-editor-font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-history-more {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty / Not-a-repo state ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-not-a-repo {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-empty-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Divider between sections ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-section + .git-section {
|
||||||
|
border-top: 1px solid var(--vscode-sideBar-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section header that matches sidebar-section-header ───────────────── */
|
||||||
|
|
||||||
|
.git-header + .git-actions {
|
||||||
|
border-top: 1px solid var(--vscode-sideBar-border);
|
||||||
|
}
|
||||||
@@ -67,30 +67,12 @@
|
|||||||
color: var(--vscode-descriptionForeground);
|
color: var(--vscode-descriptionForeground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-analysis button,
|
|
||||||
.import-analysis select {
|
.import-analysis select {
|
||||||
border: 1px solid var(--vscode-button-border, transparent);
|
border: 1px solid var(--vscode-button-border, transparent);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.import-analysis button {
|
|
||||||
background: var(--vscode-button-secondaryBackground, var(--vscode-button-background));
|
|
||||||
color: var(--vscode-button-secondaryForeground, var(--vscode-button-foreground));
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-analysis button:hover:not(:disabled) {
|
|
||||||
background: var(--vscode-button-secondaryHoverBackground, var(--vscode-button-hoverBackground));
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-analyze-btn,
|
|
||||||
.import-execute-btn {
|
|
||||||
background: var(--vscode-button-background) !important;
|
|
||||||
color: var(--vscode-button-foreground) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.import-analysis button:disabled {
|
.import-analysis button:disabled {
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -110,7 +92,7 @@
|
|||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border: 2px solid var(--vscode-descriptionForeground);
|
border: 2px solid var(--vscode-descriptionForeground);
|
||||||
border-top-color: var(--vscode-button-background);
|
border-top-color: var(--vscode-button-background, #0e639c);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: import-spinner-rotate 0.8s linear infinite;
|
animation: import-spinner-rotate 0.8s linear infinite;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -106,8 +106,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-testid="media-editor"] .media-preview-image img {
|
[data-testid="media-editor"] .media-preview-image img {
|
||||||
width: 100%;
|
max-width: 100%;
|
||||||
height: 100%;
|
max-height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
@@ -128,20 +128,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-testid="media-editor"] .add-link-btn {
|
|
||||||
background: var(--vscode-button-secondaryBackground);
|
|
||||||
border: none;
|
|
||||||
color: var(--vscode-button-secondaryForeground);
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-testid="media-editor"] .add-link-btn:hover {
|
|
||||||
background: var(--vscode-button-secondaryHoverBackground);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-testid="media-editor"] .post-picker {
|
[data-testid="media-editor"] .post-picker {
|
||||||
background: var(--vscode-dropdown-background);
|
background: var(--vscode-dropdown-background);
|
||||||
border: 1px solid var(--vscode-dropdown-border);
|
border: 1px solid var(--vscode-dropdown-border);
|
||||||
|
|||||||
544
assets/css/misc_editor.css
Normal file
544
assets/css/misc_editor.css
Normal file
@@ -0,0 +1,544 @@
|
|||||||
|
/* ── Misc-editor shell (shared by all misc tabs) ──────────────────────── */
|
||||||
|
|
||||||
|
.misc-editor-shell {
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-editor-header {
|
||||||
|
padding: 12px 16px 8px;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
background: var(--vscode-tab-activeBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-editor-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-editor-header p {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-editor-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-editor-summary {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-editor-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Summary pills ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.misc-summary-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-summary-pill span {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-summary-pill strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Misc card (used by site-validation, empty states) ───────────────── */
|
||||||
|
|
||||||
|
.misc-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-card h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-card ul {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Misc columns (site-validation 3-column layout) ──────────────────── */
|
||||||
|
|
||||||
|
.misc-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Misc list (find-duplicates) ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.misc-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-list-item:hover {
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-pair-row label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-pair-row .linkish {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--vscode-textLink-foreground, #3794ff);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 0.14em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-pair-row .linkish:hover {
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metadata-diff: tab bar ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.metadata-diff-tool {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-tab-inactiveForeground, var(--vscode-descriptionForeground));
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-tab:hover {
|
||||||
|
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-tab.active {
|
||||||
|
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
|
||||||
|
border-bottom-color: var(--vscode-focusBorder, #007fd4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--vscode-activityBarBadge-background, #007acc);
|
||||||
|
color: var(--vscode-activityBarBadge-foreground, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metadata-diff: field pills ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.metadata-diff-field-pills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-field-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-field-pill.active {
|
||||||
|
border-color: var(--vscode-focusBorder, #007fd4);
|
||||||
|
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-field-pill-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-field-pill-toggle:hover {
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-pill-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-pill-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-field-pill-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-left: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-action-button {
|
||||||
|
font-size: 11px !important;
|
||||||
|
padding: 2px 8px !important;
|
||||||
|
min-height: 22px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metadata-diff: results area ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.metadata-diff-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-empty p {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Diff item cards (shared by metadata-diff and orphan sections) ──── */
|
||||||
|
|
||||||
|
.diff-item-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-item-card {
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-item-card.orphan-file {
|
||||||
|
border-left: 3px solid var(--vscode-editorWarning-foreground, #cca700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-item-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-sideBar-background) 50%, transparent);
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-item-header strong {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-item-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-item-fields {
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 0;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--vscode-panel-border) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-name {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-values {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-value.db-value {
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-value.file-value {
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-source-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 28px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-value .diff-source-label {
|
||||||
|
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 22%, transparent);
|
||||||
|
color: var(--vscode-focusBorder, #007fd4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-value .diff-source-label {
|
||||||
|
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 22%, transparent);
|
||||||
|
color: var(--vscode-testing-iconPassed, #73c991);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Orphan files section ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.orphan-files-section {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 35%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 5%, var(--vscode-editor-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphan-files-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphan-files-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphan-files-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphan-path span {
|
||||||
|
font-family: "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Translation validation ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.translation-validation-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-summary {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-summary p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-section h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-empty {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card-db {
|
||||||
|
border-left: 3px solid var(--vscode-focusBorder, #007fd4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card-file {
|
||||||
|
border-left: 3px solid var(--vscode-testing-iconPassed, #73c991);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card-title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 3px 12px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card-meta dt {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card-meta dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Git diff ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-diff-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-empty {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-toolbar label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-toolbar select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-editor {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monaco-diff-editor-instance {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
.language-picker-modal-backdrop,
|
.language-picker-modal-backdrop,
|
||||||
.confirm-delete-modal-backdrop,
|
.confirm-delete-modal-backdrop,
|
||||||
.confirm-dialog-overlay,
|
.confirm-dialog-overlay,
|
||||||
|
.git-run-backdrop,
|
||||||
.gallery-overlay,
|
.gallery-overlay,
|
||||||
.lightbox-overlay {
|
.lightbox-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
.language-picker-modal,
|
.language-picker-modal,
|
||||||
.confirm-delete-modal,
|
.confirm-delete-modal,
|
||||||
.confirm-dialog,
|
.confirm-dialog,
|
||||||
|
.git-run-modal,
|
||||||
.gallery-overlay-content {
|
.gallery-overlay-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -44,6 +46,76 @@
|
|||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.git-run-modal {
|
||||||
|
width: min(760px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #3c3c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-status {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-status.running {
|
||||||
|
color: #d7ba7d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-status.ok {
|
||||||
|
color: #4ec9b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-status.fail {
|
||||||
|
color: #f48771;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-output {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
overflow: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 160px;
|
||||||
|
max-height: 60vh;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: #141414;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-stdout {
|
||||||
|
color: #d4d4d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-stderr {
|
||||||
|
color: #f48771;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #3c3c3c;
|
||||||
|
}
|
||||||
|
|
||||||
.ai-suggestions-modal,
|
.ai-suggestions-modal,
|
||||||
.language-picker-modal,
|
.language-picker-modal,
|
||||||
.confirm-delete-modal,
|
.confirm-delete-modal,
|
||||||
@@ -257,8 +329,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ai-suggestions-modal-footer,
|
.ai-suggestions-modal-footer,
|
||||||
.confirm-delete-modal-footer,
|
.confirm-delete-modal-footer {
|
||||||
.confirm-dialog-actions {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -266,42 +337,12 @@
|
|||||||
border-top: 1px solid #3c3c3c;
|
border-top: 1px solid #3c3c3c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-cancel,
|
|
||||||
.button-delete,
|
|
||||||
.button-apply,
|
|
||||||
.confirm-dialog-actions button,
|
|
||||||
.insert-modal-submit,
|
|
||||||
.language-picker-row,
|
.language-picker-row,
|
||||||
.shared-popover-entry,
|
.shared-popover-entry,
|
||||||
.colour-swatch {
|
.colour-swatch {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button-cancel,
|
|
||||||
.confirm-dialog-actions button,
|
|
||||||
.insert-modal-submit {
|
|
||||||
border: 1px solid #4c4c4c;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px 14px;
|
|
||||||
background: transparent;
|
|
||||||
color: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-apply,
|
|
||||||
.confirm-dialog-actions .primary,
|
|
||||||
.insert-modal-submit {
|
|
||||||
background: #0e639c;
|
|
||||||
border-color: #0e639c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-delete {
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px 14px;
|
|
||||||
background: #c73c3c;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.insert-modal-tabs {
|
.insert-modal-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin: 0 -20px;
|
margin: 0 -20px;
|
||||||
@@ -336,3 +377,78 @@
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.insert-modal-results {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 10px 20px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-item:hover {
|
||||||
|
background: #252526;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-title {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-meta {
|
||||||
|
color: #9d9d9d;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-status {
|
||||||
|
padding: 16px 20px;
|
||||||
|
color: #9d9d9d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-external {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-external .insert-modal-input {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #252526;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-label {
|
||||||
|
color: #9d9d9d;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-submit {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Alert modal (errors) — reuses .confirm-delete-modal with a tone accent. */
|
||||||
|
.alert-modal-error {
|
||||||
|
border-top: 3px solid #ff6b6b;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,14 +8,11 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-tabs {
|
.panel-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-tab {
|
.panel-tab {
|
||||||
@@ -116,7 +113,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-prompt-form,
|
.assistant-sidebar-prompt-form,
|
||||||
.assistant-sidebar-welcome,
|
|
||||||
.assistant-sidebar-transcript {
|
.assistant-sidebar-transcript {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -142,12 +138,6 @@
|
|||||||
|
|
||||||
.assistant-sidebar-start-button {
|
.assistant-sidebar-start-button {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
border: 1px solid var(--vscode-button-border, transparent);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--vscode-button-background);
|
|
||||||
color: var(--vscode-button-foreground);
|
|
||||||
padding: 7px 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-sidebar-start-button:disabled {
|
.assistant-sidebar-start-button:disabled {
|
||||||
@@ -155,7 +145,6 @@
|
|||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-card,
|
|
||||||
.assistant-sidebar-message {
|
.assistant-sidebar-message {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -254,6 +243,12 @@
|
|||||||
background-color: var(--vscode-sideBar-background);
|
background-color: var(--vscode-sideBar-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* .panel-entry (shell.css) forces flex-direction: column and, being unlayered,
|
||||||
|
beats the Tailwind row utilities on this button — force the row here. */
|
||||||
|
.task-group-toggle {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
.output-entry {
|
.output-entry {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
|
|||||||
@@ -402,24 +402,6 @@
|
|||||||
border-bottom: 1px solid var(--vscode-panel-border);
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-tabs {
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-tab {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--vscode-descriptionForeground);
|
|
||||||
padding: 0 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-tab.active {
|
|
||||||
color: var(--vscode-tab-activeForeground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-close {
|
.panel-close {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -446,8 +428,7 @@
|
|||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-entry,
|
.panel-entry {
|
||||||
.assistant-card {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -579,6 +560,13 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-message-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.status-bar-item.theme-badge {
|
.status-bar-item.theme-badge {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -595,14 +583,16 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.4;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-bar-item.offline-badge.active {
|
.status-bar-item.offline-badge.active {
|
||||||
background-color: rgba(255, 196, 0, 0.28);
|
background-color: #e6a800;
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-selector {
|
.project-selector {
|
||||||
@@ -770,19 +760,6 @@
|
|||||||
padding-left: 18px;
|
padding-left: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-toolbar {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-toolbar button {
|
|
||||||
padding: 9px 14px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--panel-3);
|
|
||||||
color: var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-meta {
|
.editor-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -790,18 +767,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-meta-card,
|
.editor-meta-card,
|
||||||
.assistant-card,
|
|
||||||
.panel-entry {
|
.panel-entry {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header,
|
|
||||||
.assistant-header,
|
|
||||||
.panel-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px 18px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,6 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assistant-card span,
|
|
||||||
.panel-entry span,
|
.panel-entry span,
|
||||||
.editor-meta-row span,
|
.editor-meta-row span,
|
||||||
.editor-subtitle,
|
.editor-subtitle,
|
||||||
@@ -777,8 +776,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.filter-chip.active {
|
.filter-chip.active {
|
||||||
background-color: var(--vscode-button-background);
|
background-color: var(--vscode-button-background, #0e639c);
|
||||||
color: var(--vscode-button-foreground);
|
color: var(--vscode-button-foreground, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-chip.has-color {
|
.filter-chip.has-color {
|
||||||
|
|||||||
@@ -37,6 +37,9 @@
|
|||||||
--vscode-editorGroupHeader-tabsBorder: #1e1e1e;
|
--vscode-editorGroupHeader-tabsBorder: #1e1e1e;
|
||||||
--vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31);
|
--vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31);
|
||||||
--vscode-toolbar-activeBackground: rgba(99, 102, 103, 0.31);
|
--vscode-toolbar-activeBackground: rgba(99, 102, 103, 0.31);
|
||||||
|
--vscode-button-secondaryBackground: rgba(255, 255, 255, 0.08);
|
||||||
|
--vscode-button-secondaryForeground: #cccccc;
|
||||||
|
--vscode-button-secondaryHoverBackground: #4a4d51;
|
||||||
--vscode-foreground: #cccccc;
|
--vscode-foreground: #cccccc;
|
||||||
--vscode-descriptionForeground: #858585;
|
--vscode-descriptionForeground: #858585;
|
||||||
--vscode-panel-border: #80808059;
|
--vscode-panel-border: #80808059;
|
||||||
@@ -130,12 +133,13 @@ button.compact {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button.primary {
|
button.primary {
|
||||||
background-color: var(--vscode-button-background);
|
color: var(--vscode-button-foreground, #ffffff);
|
||||||
|
background-color: var(--vscode-button-background, #0e639c);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.primary:hover {
|
button.primary:hover {
|
||||||
background-color: var(--vscode-button-hoverBackground);
|
background-color: var(--vscode-button-hoverBackground, #1177bb);
|
||||||
}
|
}
|
||||||
|
|
||||||
button.success {
|
button.success {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "../utils/shortcuts.js";
|
} from "../utils/shortcuts.js";
|
||||||
import { syncTitlebarOverlayInsets } from "../bridges/titlebar_overlay.js";
|
import { syncTitlebarOverlayInsets } from "../bridges/titlebar_overlay.js";
|
||||||
import { runMenuRuntimeCommand } from "../bridges/menu_runtime.js";
|
import { runMenuRuntimeCommand } from "../bridges/menu_runtime.js";
|
||||||
|
import { copyTextToClipboard } from "../utils/clipboard.js";
|
||||||
|
|
||||||
export const AppShell = {
|
export const AppShell = {
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -179,6 +180,10 @@ export const AppShell = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.handleEvent("clipboard", ({ text }) => {
|
||||||
|
copyTextToClipboard(text || "");
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
|
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
|
||||||
window.addEventListener("keydown", this.handleShortcutKeyDown, true);
|
window.addEventListener("keydown", this.handleShortcutKeyDown, true);
|
||||||
this.el.addEventListener("load", this.handleThumbnailLoad, true);
|
this.el.addEventListener("load", this.handleThumbnailLoad, true);
|
||||||
@@ -186,6 +191,9 @@ export const AppShell = {
|
|||||||
this.el.addEventListener("change", this.handleChange);
|
this.el.addEventListener("change", this.handleChange);
|
||||||
syncMediaThumbnailState(this.el);
|
syncMediaThumbnailState(this.el);
|
||||||
this.restoreStoredWorkbenchSession();
|
this.restoreStoredWorkbenchSession();
|
||||||
|
// Rehydration is done (any stored-session push is ordered before this):
|
||||||
|
// tell the server it may now deliver queued deep links.
|
||||||
|
this.pushEvent("shell_ready", {});
|
||||||
},
|
},
|
||||||
|
|
||||||
updated() {
|
updated() {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ export const ChatSurface = {
|
|||||||
this.stickToBottom = true;
|
this.stickToBottom = true;
|
||||||
this.scrollContainer = null;
|
this.scrollContainer = null;
|
||||||
|
|
||||||
|
this._enterKeyHandled = false;
|
||||||
|
this._prevInputValue = "";
|
||||||
|
|
||||||
this.autoResize = () => {
|
this.autoResize = () => {
|
||||||
const textarea = this.el.querySelector(".chat-input");
|
const textarea = this.el.querySelector(".chat-input");
|
||||||
|
|
||||||
@@ -85,11 +88,34 @@ export const ChatSurface = {
|
|||||||
this.stickToBottom = distanceFromBottom < 48;
|
this.stickToBottom = distanceFromBottom < 48;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this._submitChat = () => {
|
||||||
|
const form = this.el.querySelector(".chat-input-wrapper");
|
||||||
|
if (form && typeof form.requestSubmit === "function") {
|
||||||
|
form.requestSubmit();
|
||||||
|
} else {
|
||||||
|
const sendButton = this.el.querySelector("[data-testid='chat-send-button']");
|
||||||
|
if (sendButton) sendButton.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
this.handleInput = (event) => {
|
this.handleInput = (event) => {
|
||||||
if (!event.target.closest(".chat-input")) {
|
if (!event.target.closest(".chat-input")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const textarea = event.target;
|
||||||
|
|
||||||
|
if (!this._enterKeyHandled && textarea.value.includes("\n") && !this._prevInputValue.includes("\n")) {
|
||||||
|
textarea.value = textarea.value.replace(/\n/g, "");
|
||||||
|
this._prevInputValue = textarea.value;
|
||||||
|
this.stickToBottom = true;
|
||||||
|
this.autoResize();
|
||||||
|
this._submitChat();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._enterKeyHandled = false;
|
||||||
|
this._prevInputValue = textarea.value;
|
||||||
this.stickToBottom = true;
|
this.stickToBottom = true;
|
||||||
this.autoResize();
|
this.autoResize();
|
||||||
};
|
};
|
||||||
@@ -101,12 +127,8 @@ export const ChatSurface = {
|
|||||||
|
|
||||||
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
this._enterKeyHandled = true;
|
||||||
const sendButton = this.el.querySelector("[data-testid='chat-send-button']");
|
this._submitChat();
|
||||||
|
|
||||||
if (sendButton && !sendButton.disabled) {
|
|
||||||
sendButton.click();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
45
assets/js/hooks/colour_picker.js
Normal file
45
assets/js/hooks/colour_picker.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
export const ColourPicker = {
|
||||||
|
mounted() {
|
||||||
|
this._onClickAway = (e) => {
|
||||||
|
if (!this.el.contains(e.target)) {
|
||||||
|
this.el.querySelector(".colour-picker-popover")?.classList.add("hidden");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", this._onClickAway);
|
||||||
|
|
||||||
|
this._setupCustomInput();
|
||||||
|
},
|
||||||
|
|
||||||
|
updated() {
|
||||||
|
this._setupCustomInput();
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
document.removeEventListener("mousedown", this._onClickAway);
|
||||||
|
},
|
||||||
|
|
||||||
|
_setupCustomInput() {
|
||||||
|
const input = this.el.querySelector(".colour-picker-custom input");
|
||||||
|
if (!input || input._cpBound) return;
|
||||||
|
input._cpBound = true;
|
||||||
|
|
||||||
|
const pushColor = () => {
|
||||||
|
let val = input.value.trim();
|
||||||
|
if (val && !val.startsWith("#")) val = "#" + val;
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
|
||||||
|
const event = this.el.dataset.pickEvent;
|
||||||
|
this.pushEventTo(this.el.dataset.target, event, { color: val });
|
||||||
|
this.el.querySelector(".colour-picker-popover")?.classList.add("hidden");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
pushColor();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener("blur", pushColor);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -2,16 +2,20 @@ import { AppShell } from "./app_shell.js";
|
|||||||
import { SidebarInteractions } from "./sidebar_interactions.js";
|
import { SidebarInteractions } from "./sidebar_interactions.js";
|
||||||
import { SettingsSectionScroll, TagsSectionScroll } from "./section_scroll.js";
|
import { SettingsSectionScroll, TagsSectionScroll } from "./section_scroll.js";
|
||||||
import { ChatSurface } from "./chat_surface.js";
|
import { ChatSurface } from "./chat_surface.js";
|
||||||
|
import { ColourPicker } from "./colour_picker.js";
|
||||||
import { MenuEditorTree } from "./menu_editor_tree.js";
|
import { MenuEditorTree } from "./menu_editor_tree.js";
|
||||||
import { MonacoEditor } from "./monaco_editor.js";
|
import { MonacoEditor } from "./monaco_editor.js";
|
||||||
import { MonacoDiffEditor } from "./monaco_diff_editor.js";
|
import { MonacoDiffEditor } from "./monaco_diff_editor.js";
|
||||||
|
import { createLiveToastHook } from "live_toast";
|
||||||
|
|
||||||
export const Hooks = {
|
export const Hooks = {
|
||||||
|
LiveToast: createLiveToastHook(),
|
||||||
AppShell,
|
AppShell,
|
||||||
SidebarInteractions,
|
SidebarInteractions,
|
||||||
SettingsSectionScroll,
|
SettingsSectionScroll,
|
||||||
TagsSectionScroll,
|
TagsSectionScroll,
|
||||||
ChatSurface,
|
ChatSurface,
|
||||||
|
ColourPicker,
|
||||||
MenuEditorTree,
|
MenuEditorTree,
|
||||||
MonacoEditor,
|
MonacoEditor,
|
||||||
MonacoDiffEditor
|
MonacoDiffEditor
|
||||||
|
|||||||
@@ -5,6 +5,66 @@ import {
|
|||||||
unregisterMonacoEditor
|
unregisterMonacoEditor
|
||||||
} from "../monaco/services.js";
|
} from "../monaco/services.js";
|
||||||
|
|
||||||
|
const WEBKIT_TEXTAREA_ARROW_COMMANDS = {
|
||||||
|
ArrowLeft: "cursorLeft",
|
||||||
|
ArrowRight: "cursorRight",
|
||||||
|
ArrowUp: "cursorUp",
|
||||||
|
ArrowDown: "cursorDown"
|
||||||
|
};
|
||||||
|
|
||||||
|
const isWebKitEngine = () => {
|
||||||
|
const userAgent = globalThis.navigator?.userAgent || "";
|
||||||
|
return userAgent.includes("AppleWebKit") && !userAgent.includes("Chrome") && !userAgent.includes("Chromium");
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMonacoInputArea = (target) =>
|
||||||
|
target instanceof HTMLTextAreaElement && target.classList.contains("inputarea");
|
||||||
|
|
||||||
|
export const webKitTextAreaArrowCommand = (event) => {
|
||||||
|
if (event?.altKey || event?.ctrlKey || event?.metaKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = WEBKIT_TEXTAREA_ARROW_COMMANDS[event?.key];
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return event.shiftKey ? `${command}Select` : command;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Applying a remote value via setValue resets the cursor to the buffer start,
|
||||||
|
// which then makes follow-up actions (like link inserts targeting the current
|
||||||
|
// selection) land at position 1,1. Capture and restore the selection so the
|
||||||
|
// cursor survives server-driven reconciles; Monaco clamps out-of-range
|
||||||
|
// positions to the new content.
|
||||||
|
export const applyRemoteEditorValue = (editor, value) => {
|
||||||
|
const selections = editor.getSelections ? editor.getSelections() : null;
|
||||||
|
editor.setValue(value);
|
||||||
|
|
||||||
|
if (selections && selections.length > 0 && editor.setSelections) {
|
||||||
|
editor.setSelections(selections);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bridgeWebKitTextAreaArrowKey = (editor, event) => {
|
||||||
|
if (!editor || !isMonacoInputArea(event.target)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = webKitTextAreaArrowCommand(event);
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
editor.trigger("keyboard", command, { source: "keyboard" });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
export const MonacoEditor = {
|
export const MonacoEditor = {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
|
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
|
||||||
@@ -17,6 +77,10 @@ export const MonacoEditor = {
|
|||||||
this.isApplyingRemoteUpdate = false;
|
this.isApplyingRemoteUpdate = false;
|
||||||
this.lastKnownValue = this.textarea?.value || "";
|
this.lastKnownValue = this.textarea?.value || "";
|
||||||
|
|
||||||
|
this.handleWebKitTextAreaArrowKey = (event) => {
|
||||||
|
bridgeWebKitTextAreaArrowKey(this.editor, event);
|
||||||
|
};
|
||||||
|
|
||||||
this.syncEditorFromTextarea = () => {
|
this.syncEditorFromTextarea = () => {
|
||||||
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
|
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
|
||||||
|
|
||||||
@@ -26,10 +90,28 @@ export const MonacoEditor = {
|
|||||||
|
|
||||||
const value = this.textarea.value || "";
|
const value = this.textarea.value || "";
|
||||||
|
|
||||||
|
// ponytail: ignore the server echoing back our own last-emitted value;
|
||||||
|
// only a genuinely remote value (translation switch, etc.) should reset
|
||||||
|
// the editor. Otherwise mid-typing echoes clobber the cursor to offset 0.
|
||||||
|
if (value === this.lastKnownValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// While the user is actively typing, Monaco owns the content. Never let a
|
||||||
|
// server re-render (autosave, dirty toggle, metadata) yank it out from
|
||||||
|
// under the cursor — reconcile only once the editor is blurred.
|
||||||
|
if (this.editor.hasTextFocus()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.editor.getValue() !== value) {
|
if (this.editor.getValue() !== value) {
|
||||||
this.isApplyingRemoteUpdate = true;
|
this.isApplyingRemoteUpdate = true;
|
||||||
this.editor.setValue(value);
|
|
||||||
this.isApplyingRemoteUpdate = false;
|
try {
|
||||||
|
applyRemoteEditorValue(this.editor, value);
|
||||||
|
} finally {
|
||||||
|
this.isApplyingRemoteUpdate = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastKnownValue = value;
|
this.lastKnownValue = value;
|
||||||
@@ -118,6 +200,36 @@ export const MonacoEditor = {
|
|||||||
}, 120);
|
}, 120);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.dropEvent = this.el.dataset.monacoDropEvent || "";
|
||||||
|
this.dropPostId = this.el.dataset.monacoDropPostId || "";
|
||||||
|
|
||||||
|
this.handleDragOver = (event) => {
|
||||||
|
if (event.dataTransfer && Array.from(event.dataTransfer.types || []).includes("Files")) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "copy";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleDrop = (event) => {
|
||||||
|
if (!this.dropEvent || !event.dataTransfer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = Array.from(event.dataTransfer.files || []);
|
||||||
|
const images = files.filter((file) => (file.type || "").startsWith("image/") && file.path);
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
images.forEach((file) => {
|
||||||
|
this.pushEvent(this.dropEvent, { "post-id": this.dropPostId, path: file.path });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
this.handleInsert = ({ id, content }) => {
|
this.handleInsert = ({ id, content }) => {
|
||||||
if (!this.editor || !content || String(id) !== String(this.editorId)) {
|
if (!this.editor || !content || String(id) !== String(this.editorId)) {
|
||||||
return;
|
return;
|
||||||
@@ -197,6 +309,15 @@ export const MonacoEditor = {
|
|||||||
if (this.insertEvent) {
|
if (this.insertEvent) {
|
||||||
this.handleEvent(this.insertEvent, this.handleInsert);
|
this.handleEvent(this.insertEvent, this.handleInsert);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.dropEvent) {
|
||||||
|
this.el.addEventListener("dragover", this.handleDragOver);
|
||||||
|
this.el.addEventListener("drop", this.handleDrop);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWebKitEngine()) {
|
||||||
|
this.host.addEventListener("keydown", this.handleWebKitTextAreaArrowKey, true);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Failed to load Monaco editor", error);
|
console.error("Failed to load Monaco editor", error);
|
||||||
@@ -232,6 +353,14 @@ export const MonacoEditor = {
|
|||||||
window.clearTimeout(this.syncTimer);
|
window.clearTimeout(this.syncTimer);
|
||||||
this.visibleSizeObserver?.disconnect();
|
this.visibleSizeObserver?.disconnect();
|
||||||
this.changeSubscription?.dispose();
|
this.changeSubscription?.dispose();
|
||||||
|
|
||||||
|
if (this.dropEvent) {
|
||||||
|
this.el.removeEventListener("dragover", this.handleDragOver);
|
||||||
|
this.el.removeEventListener("drop", this.handleDrop);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.host?.removeEventListener("keydown", this.handleWebKitTextAreaArrowKey, true);
|
||||||
|
|
||||||
unregisterMonacoEditor(this.editorId || this.el.id);
|
unregisterMonacoEditor(this.editorId || this.el.id);
|
||||||
this.editor?.dispose();
|
this.editor?.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,54 @@
|
|||||||
import { loadScript } from "../utils/script_loader.js";
|
|
||||||
import { ensureMonacoTheme } from "./theme.js";
|
import { ensureMonacoTheme } from "./theme.js";
|
||||||
import { registerLiquidLanguage, registerMarkdownWithMacrosLanguage } from "./languages.js";
|
import { registerLiquidLanguage, registerMarkdownWithMacrosLanguage } from "./languages.js";
|
||||||
|
|
||||||
let monacoLoaderPromise;
|
let monacoLoaderPromise;
|
||||||
const monacoEditors = new Map();
|
const monacoEditors = new Map();
|
||||||
|
|
||||||
|
const monacoWorkerUrls = {
|
||||||
|
editor: "/assets/monaco/editor.worker.js",
|
||||||
|
css: "/assets/monaco/css.worker.js",
|
||||||
|
html: "/assets/monaco/html.worker.js",
|
||||||
|
json: "/assets/monaco/json.worker.js",
|
||||||
|
ts: "/assets/monaco/ts.worker.js"
|
||||||
|
};
|
||||||
|
|
||||||
|
const workerNameForLanguage = (label) => {
|
||||||
|
switch (label) {
|
||||||
|
case "css":
|
||||||
|
case "scss":
|
||||||
|
case "less":
|
||||||
|
return "css";
|
||||||
|
case "html":
|
||||||
|
case "handlebars":
|
||||||
|
case "razor":
|
||||||
|
return "html";
|
||||||
|
case "json":
|
||||||
|
return "json";
|
||||||
|
case "typescript":
|
||||||
|
case "javascript":
|
||||||
|
return "ts";
|
||||||
|
default:
|
||||||
|
return "editor";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureMonacoEnvironment = () => {
|
||||||
|
if (globalThis.MonacoEnvironment?.getWorker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.MonacoEnvironment = {
|
||||||
|
...globalThis.MonacoEnvironment,
|
||||||
|
getWorker(_workerId, label) {
|
||||||
|
const workerName = workerNameForLanguage(label);
|
||||||
|
return new Worker(monacoWorkerUrls[workerName], { name: label, type: "module" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const loadMonaco = () => {
|
export const loadMonaco = () => {
|
||||||
|
ensureMonacoEnvironment();
|
||||||
|
|
||||||
if (window.monaco?.editor) {
|
if (window.monaco?.editor) {
|
||||||
ensureMonacoTheme(window.monaco);
|
ensureMonacoTheme(window.monaco);
|
||||||
registerLiquidLanguage(window.monaco);
|
registerLiquidLanguage(window.monaco);
|
||||||
@@ -17,20 +60,17 @@ export const loadMonaco = () => {
|
|||||||
return monacoLoaderPromise;
|
return monacoLoaderPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
monacoLoaderPromise = loadScript("/monaco/vs/loader.js")
|
monacoLoaderPromise = import("/assets/monaco.js")
|
||||||
.then(
|
.then((monaco) => {
|
||||||
() =>
|
window.monaco = monaco;
|
||||||
new Promise((resolve, reject) => {
|
if (!monaco.editor) throw new Error("Monaco loaded but monaco.editor is missing");
|
||||||
window.require.config({ paths: { vs: "/monaco/vs" } });
|
ensureMonacoTheme(monaco);
|
||||||
window.require(["vs/editor/editor.main"], () => {
|
registerLiquidLanguage(monaco);
|
||||||
ensureMonacoTheme(window.monaco);
|
registerMarkdownWithMacrosLanguage(monaco);
|
||||||
registerLiquidLanguage(window.monaco);
|
return monaco;
|
||||||
registerMarkdownWithMacrosLanguage(window.monaco);
|
})
|
||||||
resolve(window.monaco);
|
|
||||||
}, reject);
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
console.error("Monaco load failed:", error);
|
||||||
monacoLoaderPromise = null;
|
monacoLoaderPromise = null;
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|||||||
3
assets/js/monaco_entry.js
Normal file
3
assets/js/monaco_entry.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// ESM entry point for Monaco. Bundled by esbuild into priv/static/assets/monaco.js
|
||||||
|
// editor.main.js loads the API AND registers all bundled languages (api.js only exposes the API)
|
||||||
|
export * from "monaco-editor/esm/vs/editor/editor.main.js";
|
||||||
1
assets/js/monaco_workers/css.worker.js
Normal file
1
assets/js/monaco_workers/css.worker.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "monaco-editor/esm/vs/language/css/css.worker.js";
|
||||||
1
assets/js/monaco_workers/editor.worker.js
Normal file
1
assets/js/monaco_workers/editor.worker.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "monaco-editor/esm/vs/editor/editor.worker.js";
|
||||||
1
assets/js/monaco_workers/html.worker.js
Normal file
1
assets/js/monaco_workers/html.worker.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "monaco-editor/esm/vs/language/html/html.worker.js";
|
||||||
1
assets/js/monaco_workers/json.worker.js
Normal file
1
assets/js/monaco_workers/json.worker.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "monaco-editor/esm/vs/language/json/json.worker.js";
|
||||||
1
assets/js/monaco_workers/ts.worker.js
Normal file
1
assets/js/monaco_workers/ts.worker.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import "monaco-editor/esm/vs/language/typescript/ts.worker.js";
|
||||||
27
assets/js/utils/clipboard.js
Normal file
27
assets/js/utils/clipboard.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Copies text to the system clipboard, mirroring the old app's behavior:
|
||||||
|
// navigator.clipboard when available, with a hidden-textarea fallback for
|
||||||
|
// webviews that lack the async clipboard API.
|
||||||
|
export const copyTextToClipboard = async (text) => {
|
||||||
|
if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document === "undefined" || typeof document.createElement !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = text;
|
||||||
|
textArea.setAttribute("readonly", "");
|
||||||
|
textArea.style.position = "absolute";
|
||||||
|
textArea.style.left = "-9999px";
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
if (typeof document.execCommand === "function") {
|
||||||
|
document.execCommand("copy");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
};
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
export const loadScript = (src) =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
const existing = document.querySelector(`script[src="${src}"]`);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
if (existing.dataset.loaded === "true") {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
existing.addEventListener("load", () => resolve(), { once: true });
|
|
||||||
existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), {
|
|
||||||
once: true
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const script = document.createElement("script");
|
|
||||||
script.src = src;
|
|
||||||
script.async = true;
|
|
||||||
script.addEventListener(
|
|
||||||
"load",
|
|
||||||
() => {
|
|
||||||
script.dataset.loaded = "true";
|
|
||||||
resolve();
|
|
||||||
},
|
|
||||||
{ once: true }
|
|
||||||
);
|
|
||||||
script.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), {
|
|
||||||
once: true
|
|
||||||
});
|
|
||||||
document.head.appendChild(script);
|
|
||||||
});
|
|
||||||
@@ -4,7 +4,9 @@ config :bds,
|
|||||||
ecto_repos: [BDS.Repo]
|
ecto_repos: [BDS.Repo]
|
||||||
|
|
||||||
config :bds, BDS.Repo,
|
config :bds, BDS.Repo,
|
||||||
database: Path.expand("../priv/data/bds_dev.db", __DIR__),
|
database:
|
||||||
|
System.get_env("BDS_DATABASE_PATH") ||
|
||||||
|
Path.expand("~/Library/Application Support/BDS2/bds_dev.db"),
|
||||||
pool_size: 5,
|
pool_size: 5,
|
||||||
journal_mode: :wal,
|
journal_mode: :wal,
|
||||||
busy_timeout: 15_000,
|
busy_timeout: 15_000,
|
||||||
@@ -14,15 +16,22 @@ config :bds, BDS.Repo,
|
|||||||
|
|
||||||
config :bds, BDS.Application, desktop_adapter: :desktop
|
config :bds, BDS.Application, desktop_adapter: :desktop
|
||||||
|
|
||||||
|
# No secrets live in this file: the endpoint signing secret is generated per
|
||||||
|
# boot (BDS.Application) and the AI secret master key comes from the OS
|
||||||
|
# keyring (BDS.AI.SecretKey).
|
||||||
config :bds, :desktop,
|
config :bds, :desktop,
|
||||||
port: 4010,
|
port: 4010,
|
||||||
window_size: {1280, 780},
|
window_size: {1280, 780},
|
||||||
window_min_size: {800, 600},
|
window_min_size: {800, 600},
|
||||||
title: "Blogging Desktop Server",
|
title: "Blogging Desktop Server"
|
||||||
secret_key_base: "bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001"
|
|
||||||
|
config :bds, :server, ssh_port: 2222
|
||||||
|
|
||||||
config :bds, BDS.Desktop.Endpoint,
|
config :bds, BDS.Desktop.Endpoint,
|
||||||
url: [host: "127.0.0.1"],
|
url: [host: "127.0.0.1"],
|
||||||
|
# Any-port loopback origins: the remote-GUI SSH tunnel terminates on an
|
||||||
|
# ephemeral local port, so the websocket origin port varies per connect.
|
||||||
|
check_origin: ["//127.0.0.1", "//localhost"],
|
||||||
adapter: Bandit.PhoenixAdapter,
|
adapter: Bandit.PhoenixAdapter,
|
||||||
render_errors: [formats: [html: BDS.Desktop.ErrorHTML], layout: false],
|
render_errors: [formats: [html: BDS.Desktop.ErrorHTML], layout: false],
|
||||||
pubsub_server: BDS.PubSub,
|
pubsub_server: BDS.PubSub,
|
||||||
@@ -51,6 +60,34 @@ config :esbuild,
|
|||||||
--external:/images/*
|
--external:/images/*
|
||||||
),
|
),
|
||||||
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
||||||
|
],
|
||||||
|
monaco: [
|
||||||
|
cd: Path.expand("../assets", __DIR__),
|
||||||
|
args: ~w(
|
||||||
|
js/monaco_entry.js
|
||||||
|
--bundle
|
||||||
|
--target=es2022
|
||||||
|
--format=esm
|
||||||
|
--outfile=../priv/static/assets/monaco.js
|
||||||
|
--loader:.ttf=dataurl
|
||||||
|
),
|
||||||
|
env: %{"NODE_PATH" => Path.expand("../node_modules", __DIR__)}
|
||||||
|
],
|
||||||
|
monaco_workers: [
|
||||||
|
cd: Path.expand("../assets", __DIR__),
|
||||||
|
args: ~w(
|
||||||
|
js/monaco_workers/editor.worker.js
|
||||||
|
js/monaco_workers/css.worker.js
|
||||||
|
js/monaco_workers/html.worker.js
|
||||||
|
js/monaco_workers/json.worker.js
|
||||||
|
js/monaco_workers/ts.worker.js
|
||||||
|
--bundle
|
||||||
|
--target=es2022
|
||||||
|
--format=esm
|
||||||
|
--outdir=../priv/static/assets/monaco
|
||||||
|
--loader:.ttf=dataurl
|
||||||
|
),
|
||||||
|
env: %{"NODE_PATH" => Path.expand("../node_modules", __DIR__)}
|
||||||
]
|
]
|
||||||
|
|
||||||
config :bds, :scripting,
|
config :bds, :scripting,
|
||||||
@@ -58,12 +95,44 @@ config :bds, :scripting,
|
|||||||
timeout: 300_000,
|
timeout: 300_000,
|
||||||
max_reductions: 5_000_000,
|
max_reductions: 5_000_000,
|
||||||
job_timeout: :infinity,
|
job_timeout: :infinity,
|
||||||
job_max_reductions: :none
|
job_max_reductions: :none,
|
||||||
|
transform_max_toasts_per_script: 5,
|
||||||
|
transform_max_toasts_total: 20,
|
||||||
|
transform_max_toast_length: 300
|
||||||
|
|
||||||
|
# streaming: chat completions use SSE when the provider supports it (set to
|
||||||
|
# false for OpenAI-compatible servers that reject the "stream" flag).
|
||||||
|
# stream_emit_interval_ms throttles how often streamed content reaches the UI.
|
||||||
|
# await_timeout_margin_ms is added on top of the per-request HTTP budget across
|
||||||
|
# the bounded tool-call loop, so the caller never waits forever.
|
||||||
|
config :bds, :chat,
|
||||||
|
max_tool_rounds: 10,
|
||||||
|
streaming: true,
|
||||||
|
stream_emit_interval_ms: 100,
|
||||||
|
await_timeout_margin_ms: 5_000
|
||||||
|
|
||||||
|
config :bds, :git,
|
||||||
|
local_timeout_ms: 15_000,
|
||||||
|
network_timeout_ms: 120_000
|
||||||
|
|
||||||
config :bds, :embeddings,
|
config :bds, :embeddings,
|
||||||
backend: BDS.Embeddings.Backends.InApp,
|
backend: BDS.Embeddings.Backends.Neural,
|
||||||
model_id: "Xenova/multilingual-e5-small",
|
model_id: "Xenova/multilingual-e5-small",
|
||||||
dimensions: 384
|
model_repo: "intfloat/multilingual-e5-small",
|
||||||
|
dimensions: 384,
|
||||||
|
# Inference is batched: batch_size texts per compiled run, truncated to
|
||||||
|
# sequence_length tokens. Tuning these trades throughput against memory.
|
||||||
|
batch_size: 16,
|
||||||
|
sequence_length: 256,
|
||||||
|
# Hardware acceleration: :auto prefers the Apple GPU (EMLX/Metal) on Apple
|
||||||
|
# Silicon and falls back to EXLA-CPU elsewhere. Force with :emlx or :exla.
|
||||||
|
accelerator: :auto
|
||||||
|
|
||||||
|
# Cache downloaded model files under the app data directory so they persist
|
||||||
|
# across sessions (ModelCaching invariant). Overridden at runtime in prod.
|
||||||
|
config :bumblebee, :cache_dir,
|
||||||
|
System.get_env("BDS_MODEL_CACHE_DIR") ||
|
||||||
|
Path.expand("~/Library/Application Support/BDS2/models")
|
||||||
|
|
||||||
config :logger, :console,
|
config :logger, :console,
|
||||||
format: "$time $metadata[$level] $message\n",
|
format: "$time $metadata[$level] $message\n",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Config
|
import Config
|
||||||
|
|
||||||
config :bds, BDS.Repo,
|
config :bds, BDS.Repo,
|
||||||
database: Path.expand("../priv/data/bds_prod.db", __DIR__),
|
|
||||||
pool_size: 5,
|
pool_size: 5,
|
||||||
stacktrace: false,
|
stacktrace: false,
|
||||||
show_sensitive_data_on_connection_error: false
|
show_sensitive_data_on_connection_error: false
|
||||||
|
|||||||
@@ -1,11 +1,30 @@
|
|||||||
import Config
|
import Config
|
||||||
|
|
||||||
|
# Headless boots (BDS_MODE=server/tui, or desktop mode without a graphical
|
||||||
|
# display — issue #33) must neuter wx before the :desktop dependency
|
||||||
|
# application starts it and crashes the VM. Local tui mode additionally
|
||||||
|
# silences all other terminal writers (console logging goes to a file,
|
||||||
|
# model-download progress bars are disabled) — the TUI owns the terminal
|
||||||
|
# and every stray line scrolls its rendering up one row. Runtime config is
|
||||||
|
# the only hook that runs before dependency applications start.
|
||||||
|
BDS.Server.prepare_boot_env()
|
||||||
|
|
||||||
if config_env() == :prod do
|
if config_env() == :prod do
|
||||||
database_path =
|
database_path =
|
||||||
System.get_env("BDS_DATABASE_PATH") ||
|
System.get_env("BDS_DATABASE_PATH") ||
|
||||||
Path.expand("../priv/data/bds_prod.db", __DIR__)
|
Path.expand("~/Library/Application Support/BDS2/bds.db")
|
||||||
|
|
||||||
|
File.mkdir_p!(Path.dirname(database_path))
|
||||||
|
|
||||||
|
# Keep prod on the same modest SQLite pool as dev so WAL + busy_timeout see
|
||||||
|
# the same concurrency behavior in both environments unless explicitly tuned.
|
||||||
config :bds, BDS.Repo,
|
config :bds, BDS.Repo,
|
||||||
database: database_path,
|
database: database_path,
|
||||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "1")
|
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
|
||||||
|
|
||||||
|
# Persist downloaded embedding model files alongside the database data dir.
|
||||||
|
config :bumblebee,
|
||||||
|
:cache_dir,
|
||||||
|
System.get_env("BDS_MODEL_CACHE_DIR") ||
|
||||||
|
Path.join(Path.dirname(Path.expand(database_path)), "models")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,23 @@ config :bds, BDS.Repo,
|
|||||||
pool: Ecto.Adapters.SQL.Sandbox,
|
pool: Ecto.Adapters.SQL.Sandbox,
|
||||||
pool_size: 5,
|
pool_size: 5,
|
||||||
journal_mode: :wal,
|
journal_mode: :wal,
|
||||||
busy_timeout: 15_000
|
# Desktop/LiveView tests can leave short-lived reader/writer processes
|
||||||
|
# draining after assertions. Give SQLite more time to wait out those locks
|
||||||
|
# during serialized suite runs instead of failing the next test setup.
|
||||||
|
busy_timeout: 60_000
|
||||||
|
|
||||||
config :logger, level: :warning
|
config :logger, level: :warning
|
||||||
|
|
||||||
|
# Deterministic, test-only master key so secret round-trips never touch the
|
||||||
|
# OS keyring (Keychain) or write key files on developer machines.
|
||||||
|
config :bds, :ai_secret_key, "bds-test-only-ai-secret-key-not-used-outside-the-test-env"
|
||||||
|
|
||||||
|
# Tests use the deterministic lexical stub backend so the suite stays offline
|
||||||
|
# and never downloads the ~100 MB neural model.
|
||||||
|
config :bds, :embeddings,
|
||||||
|
backend: BDS.Embeddings.Backends.InApp,
|
||||||
|
model_id: "Xenova/multilingual-e5-small",
|
||||||
|
model_repo: "intfloat/multilingual-e5-small",
|
||||||
|
dimensions: 384,
|
||||||
|
batch_size: 16,
|
||||||
|
sequence_length: 256
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
defmodule BDS.AI do
|
defmodule BDS.AI do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Public interface for AI features — endpoint configuration, secret management,
|
Stable public interface for AI features.
|
||||||
model catalog access, and dispatching chat and one-shot inference requests.
|
|
||||||
|
This facade owns the app-level AI state that other modules are expected to
|
||||||
|
depend on directly: endpoint configuration, secret management, airplane-mode
|
||||||
|
gating, and per-feature model preferences, alongside model catalog access and
|
||||||
|
dispatching chat and one-shot inference requests.
|
||||||
|
|
||||||
|
`BDS.AI.Catalog`, `BDS.AI.OneShot`, and `BDS.AI.Chat` are internal
|
||||||
|
implementation modules behind this boundary. Callers should treat `BDS.AI` as
|
||||||
|
the durable surface for AI behavior and configuration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias BDS.AI.Catalog
|
alias BDS.AI.Catalog
|
||||||
@@ -46,13 +54,21 @@ defmodule BDS.AI do
|
|||||||
model = MapUtils.attr(attrs, :model)
|
model = MapUtils.attr(attrs, :model)
|
||||||
api_key = MapUtils.attr(attrs, :api_key)
|
api_key = MapUtils.attr(attrs, :api_key)
|
||||||
|
|
||||||
with :ok <- put_setting("ai.#{kind_key}.url", url),
|
with :ok <- put_or_delete_setting("ai.#{kind_key}.url", url),
|
||||||
:ok <- put_setting("ai.#{kind_key}.model", model),
|
:ok <- put_or_delete_setting("ai.#{kind_key}.model", model),
|
||||||
:ok <- put_secret("ai.#{kind_key}.api_key", api_key, backend) do
|
:ok <- put_or_delete_secret("ai.#{kind_key}.api_key", api_key, backend) do
|
||||||
{:ok, %{kind: kind, url: url, api_key: api_key, model: model}}
|
{:ok, %{kind: kind, url: url, api_key: api_key, model: model}}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# A nil field means "clear it" — writing nil into put_setting/2 would raise a
|
||||||
|
# FunctionClauseError and crash the caller (the preferences panel).
|
||||||
|
defp put_or_delete_setting(key, nil), do: delete_setting(key)
|
||||||
|
defp put_or_delete_setting(key, value) when is_binary(value), do: put_setting(key, value)
|
||||||
|
|
||||||
|
defp put_or_delete_secret(key, nil, _backend), do: delete_setting(encrypted_key(key))
|
||||||
|
defp put_or_delete_secret(key, value, backend), do: put_secret(key, value, backend)
|
||||||
|
|
||||||
@spec get_endpoint(endpoint_kind(), keyword()) ::
|
@spec get_endpoint(endpoint_kind(), keyword()) ::
|
||||||
{:ok, endpoint() | nil} | {:error, term()}
|
{:ok, endpoint() | nil} | {:error, term()}
|
||||||
def get_endpoint(kind, opts \\ []) when is_atom(kind) and is_list(opts) do
|
def get_endpoint(kind, opts \\ []) when is_atom(kind) and is_list(opts) do
|
||||||
@@ -111,6 +127,19 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
True when the airplane (local) endpoint has both a URL and a model
|
||||||
|
configured, so gated AI features can run against the local model.
|
||||||
|
"""
|
||||||
|
@spec airplane_endpoint_configured?() :: boolean()
|
||||||
|
def airplane_endpoint_configured? do
|
||||||
|
present_setting?(get_setting("ai.airplane.url")) and
|
||||||
|
present_setting?(get_setting("ai.airplane.model"))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp present_setting?(value) when is_binary(value), do: String.trim(value) != ""
|
||||||
|
defp present_setting?(_value), do: false
|
||||||
|
|
||||||
@spec put_model_preference(atom(), String.t()) ::
|
@spec put_model_preference(atom(), String.t()) ::
|
||||||
:ok | {:error, :unknown_model_preference | term()}
|
:ok | {:error, :unknown_model_preference | term()}
|
||||||
def put_model_preference(key, model) when is_atom(key) and is_binary(model) do
|
def put_model_preference(key, model) when is_atom(key) and is_binary(model) do
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
defmodule BDS.AI.Chat do
|
defmodule BDS.AI.Chat do
|
||||||
@moduledoc false
|
@moduledoc """
|
||||||
|
Context for AI chat conversations: start, list, fetch, and delete
|
||||||
|
conversations, persist surface state, and resolve the effective chat model.
|
||||||
|
|
||||||
|
Automatic AI activity is gated by the app's airplane (offline) mode; callers
|
||||||
|
must respect that gating before invoking model-backed operations.
|
||||||
|
"""
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
require Logger
|
require Logger
|
||||||
@@ -9,6 +15,7 @@ defmodule BDS.AI.Chat do
|
|||||||
alias BDS.AI.CatalogProvider
|
alias BDS.AI.CatalogProvider
|
||||||
alias BDS.AI.ChatConversation
|
alias BDS.AI.ChatConversation
|
||||||
alias BDS.AI.ChatMessage
|
alias BDS.AI.ChatMessage
|
||||||
|
alias BDS.AI.ChatTitleGenerator
|
||||||
alias BDS.AI.ChatTools
|
alias BDS.AI.ChatTools
|
||||||
alias BDS.AI.InFlight
|
alias BDS.AI.InFlight
|
||||||
alias BDS.AI.OpenAICompatibleRuntime
|
alias BDS.AI.OpenAICompatibleRuntime
|
||||||
@@ -24,16 +31,15 @@ defmodule BDS.AI.Chat do
|
|||||||
|
|
||||||
@default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts."
|
@default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts."
|
||||||
@default_max_output_tokens 16_384
|
@default_max_output_tokens 16_384
|
||||||
@title_max_output_tokens 256
|
|
||||||
@chat_title_max_length 30
|
|
||||||
@chat_max_tool_rounds 10
|
@chat_max_tool_rounds 10
|
||||||
|
@chat_await_timeout_margin_ms 5_000
|
||||||
@default_context_window 128_000
|
@default_context_window 128_000
|
||||||
|
|
||||||
@spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
|
@spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
|
||||||
def start_chat(attrs \\ %{}) when is_map(attrs) do
|
def start_chat(attrs \\ %{}) when is_map(attrs) do
|
||||||
now = Persistence.now_ms()
|
now = Persistence.now_ms()
|
||||||
model = MapUtils.attr(attrs, :model)
|
model = MapUtils.attr(attrs, :model)
|
||||||
title = MapUtils.attr(attrs, :title) || generated_chat_title(model)
|
title = MapUtils.attr(attrs, :title) || ChatTitleGenerator.generated_chat_title(model)
|
||||||
|
|
||||||
%ChatConversation{}
|
%ChatConversation{}
|
||||||
|> ChatConversation.changeset(%{
|
|> ChatConversation.changeset(%{
|
||||||
@@ -105,12 +111,24 @@ defmodule BDS.AI.Chat do
|
|||||||
{:error, :not_found}
|
{:error, :not_found}
|
||||||
|
|
||||||
%ChatConversation{} = conversation ->
|
%ChatConversation{} = conversation ->
|
||||||
Repo.delete_all(
|
Repo.transaction(fn ->
|
||||||
from message in ChatMessage, where: message.conversation_id == ^conversation_id
|
Repo.delete_all(
|
||||||
)
|
from message in ChatMessage, where: message.conversation_id == ^conversation_id
|
||||||
|
)
|
||||||
|
|
||||||
case Repo.delete(conversation) do
|
case delete_chat_conversation_test_hook(conversation_id) do
|
||||||
{:ok, _conversation} -> {:ok, :deleted}
|
:ok ->
|
||||||
|
case Repo.delete(conversation) do
|
||||||
|
{:ok, _conversation} -> :ok
|
||||||
|
{:error, reason} -> Repo.rollback(reason)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Repo.rollback(reason)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> case do
|
||||||
|
{:ok, :ok} -> {:ok, :deleted}
|
||||||
{:error, reason} -> {:error, reason}
|
{:error, reason} -> {:error, reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -196,19 +214,13 @@ defmodule BDS.AI.Chat do
|
|||||||
}) do
|
}) do
|
||||||
task =
|
task =
|
||||||
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
|
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
|
||||||
receive do
|
|
||||||
:sandbox_ready -> :ok
|
|
||||||
end
|
|
||||||
|
|
||||||
do_send_chat_message(conversation, user_message, opts)
|
do_send_chat_message(conversation, user_message, opts)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
InFlight.register(conversation.id, task.pid)
|
InFlight.register(conversation.id, task.pid)
|
||||||
:ok = allow_repo_sandbox(task.pid)
|
|
||||||
send(task.pid, :sandbox_ready)
|
|
||||||
|
|
||||||
try do
|
try do
|
||||||
await_chat_task(task)
|
await_chat_task(task, chat_await_timeout_ms())
|
||||||
after
|
after
|
||||||
InFlight.unregister(conversation.id)
|
InFlight.unregister(conversation.id)
|
||||||
end
|
end
|
||||||
@@ -411,7 +423,7 @@ defmodule BDS.AI.Chat do
|
|||||||
tools,
|
tools,
|
||||||
runtime,
|
runtime,
|
||||||
opts,
|
opts,
|
||||||
@chat_max_tool_rounds
|
chat_max_tool_rounds()
|
||||||
),
|
),
|
||||||
{:ok, reply} <-
|
{:ok, reply} <-
|
||||||
maybe_generate_chat_title(conversation.id, user_message.content, reply, opts) do
|
maybe_generate_chat_title(conversation.id, user_message.content, reply, opts) do
|
||||||
@@ -423,11 +435,11 @@ defmodule BDS.AI.Chat do
|
|||||||
conversation = Repo.get!(ChatConversation, conversation_id)
|
conversation = Repo.get!(ChatConversation, conversation_id)
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
chat_user_message_count(conversation_id) < 1 ->
|
ChatTitleGenerator.chat_user_message_count(conversation_id) < 1 ->
|
||||||
Logger.debug("Chat title generation skipped reason=:no_user_messages")
|
Logger.debug("Chat title generation skipped reason=:no_user_messages")
|
||||||
{:ok, reply}
|
{:ok, reply}
|
||||||
|
|
||||||
not generated_chat_title?(conversation.title, conversation.model) ->
|
not ChatTitleGenerator.generated_chat_title?(conversation.title, conversation.model) ->
|
||||||
Logger.debug(
|
Logger.debug(
|
||||||
"Chat title generation skipped reason=:conversation_already_titled title=#{inspect(conversation.title)}"
|
"Chat title generation skipped reason=:conversation_already_titled title=#{inspect(conversation.title)}"
|
||||||
)
|
)
|
||||||
@@ -437,7 +449,7 @@ defmodule BDS.AI.Chat do
|
|||||||
true ->
|
true ->
|
||||||
Logger.debug("Chat title generation requested conversation_id=#{conversation_id}")
|
Logger.debug("Chat title generation requested conversation_id=#{conversation_id}")
|
||||||
|
|
||||||
case generate_chat_title(user_content, opts) do
|
case ChatTitleGenerator.generate_chat_title(user_content, opts) do
|
||||||
{:ok, title} when is_binary(title) and title != "" ->
|
{:ok, title} when is_binary(title) and title != "" ->
|
||||||
now = Persistence.now_ms()
|
now = Persistence.now_ms()
|
||||||
|
|
||||||
@@ -458,87 +470,6 @@ defmodule BDS.AI.Chat do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp generate_chat_title(user_content, opts) when is_binary(user_content) do
|
|
||||||
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
|
||||||
|
|
||||||
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts),
|
|
||||||
:ok <- Runtime.validate_target(:chat_title, model, mode),
|
|
||||||
request <- build_chat_title_request(user_content, model),
|
|
||||||
{:ok, response} <-
|
|
||||||
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
|
|
||||||
title = sanitize_chat_title(Map.get(response, :content))
|
|
||||||
|
|
||||||
if title == "" do
|
|
||||||
Logger.warning("Chat title generation returned an empty title",
|
|
||||||
model: model,
|
|
||||||
content: inspect(Map.get(response, :content)),
|
|
||||||
usage: inspect(Map.get(response, :usage))
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
{:ok, title}
|
|
||||||
else
|
|
||||||
{:error, reason} = error ->
|
|
||||||
Logger.warning("Chat title generation failed", reason: inspect(reason))
|
|
||||||
error
|
|
||||||
|
|
||||||
other ->
|
|
||||||
Logger.warning("Chat title generation failed", reason: inspect(other))
|
|
||||||
other
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_chat_title_request(user_content, model) do
|
|
||||||
%{
|
|
||||||
operation: :chat_title,
|
|
||||||
model: model,
|
|
||||||
max_output_tokens: @title_max_output_tokens,
|
|
||||||
messages: [
|
|
||||||
%{
|
|
||||||
"role" => "system",
|
|
||||||
"content" =>
|
|
||||||
"Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Do not include reasoning. Output ONLY the title text."
|
|
||||||
},
|
|
||||||
%{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp sanitize_chat_title(title) when is_binary(title) do
|
|
||||||
title =
|
|
||||||
title
|
|
||||||
|> String.trim()
|
|
||||||
|> String.trim_leading("\"")
|
|
||||||
|> String.trim_leading("'")
|
|
||||||
|> String.trim_trailing("\"")
|
|
||||||
|> String.trim_trailing("'")
|
|
||||||
|> String.trim_trailing(".")
|
|
||||||
|> String.trim_trailing("!")
|
|
||||||
|> String.trim_trailing("?")
|
|
||||||
|
|
||||||
if String.length(title) > @chat_title_max_length do
|
|
||||||
String.slice(title, 0, @chat_title_max_length - 3) <> "..."
|
|
||||||
else
|
|
||||||
title
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp sanitize_chat_title(_title), do: ""
|
|
||||||
|
|
||||||
defp chat_user_message_count(conversation_id) do
|
|
||||||
Repo.aggregate(
|
|
||||||
from(message in ChatMessage,
|
|
||||||
where: message.conversation_id == ^conversation_id and message.role == :user
|
|
||||||
),
|
|
||||||
:count,
|
|
||||||
:id
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp generated_chat_title?(title, model) do
|
|
||||||
title in [generated_chat_title(nil), generated_chat_title(model)]
|
|
||||||
end
|
|
||||||
|
|
||||||
defp chat_round(
|
defp chat_round(
|
||||||
_conversation,
|
_conversation,
|
||||||
_messages,
|
_messages,
|
||||||
@@ -565,52 +496,35 @@ defmodule BDS.AI.Chat do
|
|||||||
rounds_left
|
rounds_left
|
||||||
) do
|
) do
|
||||||
request = build_chat_request(conversation, messages, model, project_id, tools)
|
request = build_chat_request(conversation, messages, model, project_id, tools)
|
||||||
|
generate_opts = put_stream_callback(opts, conversation.id)
|
||||||
|
|
||||||
with {:ok, response} <-
|
with {:ok, response} <-
|
||||||
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
|
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, generate_opts),
|
||||||
{:ok, assistant_message} <- persist_assistant_response(conversation.id, response),
|
{:ok, assistant_message} <- persist_assistant_response(conversation.id, response),
|
||||||
:ok <- touch_conversation(conversation.id) do
|
:ok <- touch_conversation(conversation.id) do
|
||||||
if is_binary(Map.get(response, :content)) and String.trim(Map.get(response, :content)) != "" do
|
notify_assistant_content(response, conversation.id, opts)
|
||||||
notify_chat_event(
|
|
||||||
opts,
|
|
||||||
{:chat_streaming_content, conversation.id, Map.get(response, :content)}
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
tool_calls = decode_tool_calls(Map.get(response, :tool_calls))
|
response
|
||||||
|
|> Map.get(:tool_calls)
|
||||||
|
|> decode_tool_calls()
|
||||||
|
|> continue_chat_round(
|
||||||
|
conversation.id,
|
||||||
|
assistant_message,
|
||||||
|
endpoint,
|
||||||
|
model,
|
||||||
|
project_id,
|
||||||
|
tools,
|
||||||
|
runtime,
|
||||||
|
opts,
|
||||||
|
rounds_left
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
Enum.each(tool_calls, fn tool_call ->
|
defp delete_chat_conversation_test_hook(conversation_id) do
|
||||||
notify_chat_event(opts, {:chat_tool_call, conversation.id, tool_call})
|
case Application.get_env(:bds, :chat_delete_conversation_test_hook) do
|
||||||
end)
|
hook when is_function(hook, 1) -> hook.(conversation_id)
|
||||||
|
_other -> :ok
|
||||||
cond do
|
|
||||||
tool_calls != [] and tools != [] ->
|
|
||||||
with {:ok, tool_messages} <-
|
|
||||||
execute_tool_calls(conversation.id, tool_calls, project_id, opts),
|
|
||||||
updated_messages <- load_chat_messages(conversation.id),
|
|
||||||
{:ok, reply} <-
|
|
||||||
chat_round(
|
|
||||||
Repo.get!(ChatConversation, conversation.id),
|
|
||||||
updated_messages,
|
|
||||||
endpoint,
|
|
||||||
model,
|
|
||||||
project_id,
|
|
||||||
tools,
|
|
||||||
runtime,
|
|
||||||
opts,
|
|
||||||
rounds_left - 1
|
|
||||||
) do
|
|
||||||
{:ok, Map.put(reply, :tool_messages, tool_messages)}
|
|
||||||
end
|
|
||||||
|
|
||||||
true ->
|
|
||||||
{:ok,
|
|
||||||
%{
|
|
||||||
conversation: format_conversation(Repo.get!(ChatConversation, conversation.id)),
|
|
||||||
assistant_message: format_chat_message(assistant_message),
|
|
||||||
tool_messages: []
|
|
||||||
}}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -658,6 +572,63 @@ defmodule BDS.AI.Chat do
|
|||||||
{:ok, tool_messages}
|
{:ok, tool_messages}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp continue_chat_round(
|
||||||
|
tool_calls,
|
||||||
|
conversation_id,
|
||||||
|
assistant_message,
|
||||||
|
endpoint,
|
||||||
|
model,
|
||||||
|
project_id,
|
||||||
|
tools,
|
||||||
|
runtime,
|
||||||
|
opts,
|
||||||
|
rounds_left
|
||||||
|
) do
|
||||||
|
Enum.each(tool_calls, fn tool_call ->
|
||||||
|
notify_chat_event(opts, {:chat_tool_call, conversation_id, tool_call})
|
||||||
|
end)
|
||||||
|
|
||||||
|
if tool_calls != [] and tools != [] do
|
||||||
|
with {:ok, tool_messages} <- execute_tool_calls(conversation_id, tool_calls, project_id, opts),
|
||||||
|
updated_messages <- load_chat_messages(conversation_id),
|
||||||
|
{:ok, reply} <-
|
||||||
|
chat_round(
|
||||||
|
Repo.get!(ChatConversation, conversation_id),
|
||||||
|
updated_messages,
|
||||||
|
endpoint,
|
||||||
|
model,
|
||||||
|
project_id,
|
||||||
|
tools,
|
||||||
|
runtime,
|
||||||
|
opts,
|
||||||
|
rounds_left - 1
|
||||||
|
) do
|
||||||
|
{:ok, Map.put(reply, :tool_messages, tool_messages)}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
final_chat_reply(conversation_id, assistant_message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp final_chat_reply(conversation_id, assistant_message) do
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
conversation: format_conversation(Repo.get!(ChatConversation, conversation_id)),
|
||||||
|
assistant_message: format_chat_message(assistant_message),
|
||||||
|
tool_messages: []
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp notify_assistant_content(response, conversation_id, opts) do
|
||||||
|
content = Map.get(response, :content)
|
||||||
|
|
||||||
|
if is_binary(content) and String.trim(content) != "" do
|
||||||
|
notify_chat_event(opts, {:chat_streaming_content, conversation_id, content})
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
defp build_chat_request(conversation, messages, model, project_id, tools) do
|
defp build_chat_request(conversation, messages, model, project_id, tools) do
|
||||||
system_message = %{"role" => "system", "content" => chat_system_prompt(project_id, tools)}
|
system_message = %{"role" => "system", "content" => chat_system_prompt(project_id, tools)}
|
||||||
|
|
||||||
@@ -752,6 +723,25 @@ defmodule BDS.AI.Chat do
|
|||||||
ChatTools.available_specs(project_id, Catalog.model_capabilities(model))
|
ChatTools.available_specs(project_id, Catalog.model_capabilities(model))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# BoundedToolLoop: the tool-calling round count is capped by
|
||||||
|
# config.chat_max_tool_rounds (falling back to the built-in default).
|
||||||
|
defp chat_max_tool_rounds do
|
||||||
|
chat_config(:max_tool_rounds, @chat_max_tool_rounds)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp chat_await_timeout_ms do
|
||||||
|
per_request_timeout_ms = BDS.AI.HttpClient.request_timeout_ms()
|
||||||
|
|
||||||
|
per_request_timeout_ms * (chat_max_tool_rounds() + 1) +
|
||||||
|
chat_config(:await_timeout_margin_ms, @chat_await_timeout_margin_ms)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp chat_config(key, default) do
|
||||||
|
:bds
|
||||||
|
|> Application.get_env(:chat, [])
|
||||||
|
|> Keyword.get(key, default)
|
||||||
|
end
|
||||||
|
|
||||||
defp chat_system_prompt(project_id, tools) do
|
defp chat_system_prompt(project_id, tools) do
|
||||||
base = get_setting("ai.system_prompt") || @default_system_prompt
|
base = get_setting("ai.system_prompt") || @default_system_prompt
|
||||||
|
|
||||||
@@ -778,15 +768,16 @@ defmodule BDS.AI.Chat do
|
|||||||
"- Use list_tags, list_categories, and count_posts for taxonomy and grouped analytics questions.",
|
"- Use list_tags, list_categories, and count_posts for taxonomy and grouped analytics questions.",
|
||||||
"If a requested blog fact is available through these tools, call the tool instead of saying you cannot access the data.",
|
"If a requested blog fact is available through these tools, call the tool instead of saying you cannot access the data.",
|
||||||
"",
|
"",
|
||||||
"Available UI Render Tools:",
|
"Available UI Render Tools (use these to show rich interactive elements):",
|
||||||
"- Use render_chart to show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use it when presenting statistics or comparisons. Prefer heatmap over tables with emoji or color indicators for intensity grids or calendar-style activity.",
|
"- render_chart: Show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use when presenting statistics or comparisons. Use stacked-bar when each bar has multiple segments (e.g., published vs draft posts per year). Use area for cumulative or trend data where the filled region emphasizes volume. Use donut for proportional breakdowns with a total displayed in the center. Use heatmap for grid/matrix visualizations where color intensity shows magnitude — e.g., posts per month across years (each series entry is a row like a year, each segment is a column like a month), or a calendar view where rows are weekdays and columns are week numbers. ALWAYS prefer heatmap over a table with emojis or color indicators when showing intensity grids or calendar-style activity views. IMPORTANT: a heatmap needs structured data — each entry in 'series' is a ROW and must include a 'segments' array whose entries are the COLUMNS (every segment needs a 'label' and a numeric 'value'); the row's own 'value' is ignored. Plan what the rows and columns represent before fetching data (e.g. rows = years, columns = months). A heatmap sent without segments renders empty.",
|
||||||
"- Use render_table for tabular data, comparisons, and structured listings.",
|
"- render_table: Show data in a structured table. Use for tabular comparisons and listings.",
|
||||||
"- Use render_form to collect structured user input.",
|
"- render_form: Show an interactive form to collect user input (e.g., metadata edits, settings).",
|
||||||
"- Use render_card for summaries, highlights, or actionable items.",
|
"- render_card: Show an information card with title, body, and action buttons.",
|
||||||
"- Use render_metric for a single KPI or important statistic.",
|
"- render_metric: Show a single KPI or statistic prominently.",
|
||||||
"- Use render_list for bullet lists, checklists, or simple enumerations.",
|
"- render_list: Show a bulleted list of items.",
|
||||||
"- Use render_tabs to organize multiple views into switchable tabs; tab content can contain text, metrics, lists, charts, and tables.",
|
"- render_tabs: Organize information into switchable tabs. Tab content supports all content types: text, metrics, lists, charts, and tables.",
|
||||||
"When presenting data, statistics, or comparisons, prefer render tools over plain text. When building any visualization, render it as soon as you have enough data."
|
"",
|
||||||
|
"When presenting data, statistics, or comparisons, prefer using render tools (render_chart, render_table, render_metric) to show rich interactive UI instead of plain text. When you need user input for a multi-field operation, use render_form to present a structured form. Use render_card with action buttons when presenting items the user might want to navigate to (e.g., posts, media). When comparing data across multiple dimensions (e.g., statistics per year), use render_tabs with embedded charts or tables in each tab. When building any visualization, render it as soon as you have enough data."
|
||||||
],
|
],
|
||||||
"\n"
|
"\n"
|
||||||
)
|
)
|
||||||
@@ -815,9 +806,6 @@ defmodule BDS.AI.Chat do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp generated_chat_title(nil), do: "New Chat"
|
|
||||||
defp generated_chat_title(model), do: "Chat with #{model}"
|
|
||||||
|
|
||||||
defp load_chat_messages(conversation_id) do
|
defp load_chat_messages(conversation_id) do
|
||||||
Repo.all(
|
Repo.all(
|
||||||
from message in ChatMessage,
|
from message in ChatMessage,
|
||||||
@@ -843,7 +831,7 @@ defmodule BDS.AI.Chat do
|
|||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
defp await_chat_task(task) do
|
defp await_chat_task(task, timeout_ms) do
|
||||||
ref = task.ref
|
ref = task.ref
|
||||||
|
|
||||||
receive do
|
receive do
|
||||||
@@ -869,6 +857,10 @@ defmodule BDS.AI.Chat do
|
|||||||
_other ->
|
_other ->
|
||||||
{:error, :cancelled}
|
{:error, :cancelled}
|
||||||
end
|
end
|
||||||
|
after
|
||||||
|
timeout_ms ->
|
||||||
|
_ = Task.shutdown(task, 100)
|
||||||
|
{:error, :chat_timeout}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -912,6 +904,26 @@ defmodule BDS.AI.Chat do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# When someone is listening for chat events, ask the runtime to stream:
|
||||||
|
# it emits cumulative content snapshots, which the editor renders with
|
||||||
|
# replace semantics. The full-content notify after each round stays the
|
||||||
|
# authoritative final state (and the only event for non-streaming runtimes).
|
||||||
|
defp put_stream_callback(opts, conversation_id) do
|
||||||
|
case Keyword.get(opts, :event_target) do
|
||||||
|
nil ->
|
||||||
|
opts
|
||||||
|
|
||||||
|
_target ->
|
||||||
|
Keyword.put(opts, :on_stream, fn %{content: content} ->
|
||||||
|
if is_binary(content) and String.trim(content) != "" do
|
||||||
|
notify_chat_event(opts, {:chat_streaming_content, conversation_id, content})
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp notify_chat_event(opts, event) do
|
defp notify_chat_event(opts, event) do
|
||||||
case Keyword.get(opts, :event_target) do
|
case Keyword.get(opts, :event_target) do
|
||||||
pid when is_pid(pid) -> send(pid, event)
|
pid when is_pid(pid) -> send(pid, event)
|
||||||
@@ -926,22 +938,8 @@ defmodule BDS.AI.Chat do
|
|||||||
Repo.one(from project in Project, where: project.is_active == true, select: project.id)
|
Repo.one(from project in Project, where: project.is_active == true, select: project.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp allow_repo_sandbox(pid) when is_pid(pid) do
|
|
||||||
if Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox) do
|
|
||||||
try do
|
|
||||||
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
|
|
||||||
rescue
|
|
||||||
_error -> :ok
|
|
||||||
end
|
|
||||||
else
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
|
|
||||||
defp encode_nullable(nil), do: nil
|
defp encode_nullable(nil), do: nil
|
||||||
defp encode_nullable(value), do: Jason.encode!(value)
|
defp encode_nullable(value), do: Jason.encode!(value)
|
||||||
|
|
||||||
defp blank?(value), do: value in [nil, ""]
|
defp blank?(value), do: BDS.Values.blank?(value)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ defmodule BDS.AI.ChatConversation do
|
|||||||
|
|
||||||
def changeset(conversation, attrs) do
|
def changeset(conversation, attrs) do
|
||||||
conversation
|
conversation
|
||||||
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :surface_state, :created_at, :updated_at],
|
|> cast(
|
||||||
|
attrs,
|
||||||
|
[:id, :title, :model, :copilot_session_id, :surface_state, :created_at, :updated_at],
|
||||||
empty_values: [nil]
|
empty_values: [nil]
|
||||||
)
|
)
|
||||||
|> validate_required([:id, :title, :created_at, :updated_at])
|
|> validate_required([:id, :title, :created_at, :updated_at])
|
||||||
|
|||||||
99
lib/bds/ai/chat_title_generator.ex
Normal file
99
lib/bds/ai/chat_title_generator.ex
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
defmodule BDS.AI.ChatTitleGenerator do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias BDS.AI.ChatMessage
|
||||||
|
alias BDS.AI.OpenAICompatibleRuntime
|
||||||
|
alias BDS.AI.Runtime
|
||||||
|
alias BDS.Repo
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
@title_max_output_tokens 256
|
||||||
|
@chat_title_max_length 30
|
||||||
|
|
||||||
|
def generate_chat_title(user_content, opts) when is_binary(user_content) do
|
||||||
|
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
||||||
|
|
||||||
|
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts),
|
||||||
|
:ok <- Runtime.validate_target(:chat_title, model, mode),
|
||||||
|
request <- build_chat_title_request(user_content, model),
|
||||||
|
{:ok, response} <-
|
||||||
|
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
|
||||||
|
title = sanitize_chat_title(Map.get(response, :content))
|
||||||
|
|
||||||
|
if title == "" do
|
||||||
|
Logger.warning("Chat title generation returned an empty title",
|
||||||
|
model: model,
|
||||||
|
content: inspect(Map.get(response, :content)),
|
||||||
|
usage: inspect(Map.get(response, :usage))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, title}
|
||||||
|
else
|
||||||
|
{:error, reason} = error ->
|
||||||
|
Logger.warning("Chat title generation failed", reason: inspect(reason))
|
||||||
|
error
|
||||||
|
|
||||||
|
other ->
|
||||||
|
Logger.warning("Chat title generation failed", reason: inspect(other))
|
||||||
|
other
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_chat_title_request(user_content, model) do
|
||||||
|
%{
|
||||||
|
operation: :chat_title,
|
||||||
|
model: model,
|
||||||
|
max_output_tokens: @title_max_output_tokens,
|
||||||
|
messages: [
|
||||||
|
%{
|
||||||
|
"role" => "system",
|
||||||
|
"content" =>
|
||||||
|
"Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Do not include reasoning. Output ONLY the title text."
|
||||||
|
},
|
||||||
|
%{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def sanitize_chat_title(title) when is_binary(title) do
|
||||||
|
title =
|
||||||
|
title
|
||||||
|
|> String.trim()
|
||||||
|
|> String.trim_leading("\"")
|
||||||
|
|> String.trim_leading("'")
|
||||||
|
|> String.trim_trailing("\"")
|
||||||
|
|> String.trim_trailing("'")
|
||||||
|
|> String.trim_trailing(".")
|
||||||
|
|> String.trim_trailing("!")
|
||||||
|
|> String.trim_trailing("?")
|
||||||
|
|
||||||
|
if String.length(title) > @chat_title_max_length do
|
||||||
|
String.slice(title, 0, @chat_title_max_length - 3) <> "..."
|
||||||
|
else
|
||||||
|
title
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sanitize_chat_title(_title), do: ""
|
||||||
|
|
||||||
|
def chat_user_message_count(conversation_id) do
|
||||||
|
Repo.aggregate(
|
||||||
|
from(message in ChatMessage,
|
||||||
|
where: message.conversation_id == ^conversation_id and message.role == :user
|
||||||
|
),
|
||||||
|
:count,
|
||||||
|
:id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def generated_chat_title?(title, model) do
|
||||||
|
title in [generated_chat_title(nil), generated_chat_title(model)]
|
||||||
|
end
|
||||||
|
|
||||||
|
def generated_chat_title(nil), do: "New Chat"
|
||||||
|
def generated_chat_title(model), do: "Chat with #{model}"
|
||||||
|
end
|
||||||
49
lib/bds/ai/chat_tool_query_helpers.ex
Normal file
49
lib/bds/ai/chat_tool_query_helpers.ex
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
defmodule BDS.AI.ChatToolQueryHelpers do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias BDS.Search
|
||||||
|
|
||||||
|
def normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
|
||||||
|
def normalize_limit(_value), do: 10
|
||||||
|
|
||||||
|
def normalize_offset(value) when is_integer(value) and value >= 0, do: value
|
||||||
|
def normalize_offset(_value), do: 0
|
||||||
|
|
||||||
|
def search_filters(arguments) do
|
||||||
|
%{}
|
||||||
|
|> BDS.MapUtils.maybe_put(:category, BDS.MapUtils.blank_to_nil(arguments["category"]))
|
||||||
|
|> BDS.MapUtils.maybe_put(:tags, BDS.MapUtils.blank_to_nil(arguments["tags"]))
|
||||||
|
|> BDS.MapUtils.maybe_put(:language, BDS.MapUtils.blank_to_nil(arguments["language"]))
|
||||||
|
|> BDS.MapUtils.maybe_put(
|
||||||
|
:missing_translation_language,
|
||||||
|
BDS.MapUtils.blank_to_nil(arguments["missingTranslationLanguage"])
|
||||||
|
)
|
||||||
|
|> BDS.MapUtils.maybe_put(:year, arguments["year"])
|
||||||
|
|> BDS.MapUtils.maybe_put(:month, arguments["month"])
|
||||||
|
|> BDS.MapUtils.maybe_put(:status, BDS.BoundedAtoms.post_status(arguments["status"]))
|
||||||
|
|> Map.put(:offset, normalize_offset(arguments["offset"]))
|
||||||
|
|> Map.put(:limit, normalize_limit(arguments["limit"]))
|
||||||
|
end
|
||||||
|
|
||||||
|
def search_all_counted_posts(project_id, arguments) do
|
||||||
|
filters = search_filters(arguments) |> Map.put(:offset, 0) |> Map.put(:limit, 1)
|
||||||
|
{:ok, %{total: total}} = Search.search_posts(project_id, "", filters)
|
||||||
|
|
||||||
|
filters = Map.put(filters, :limit, max(total, 1))
|
||||||
|
{:ok, result} = Search.search_posts(project_id, "", filters)
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def search_result(project_id, query, filters, mapper) do
|
||||||
|
{:ok, result} = Search.search_posts(project_id, query, filters)
|
||||||
|
|
||||||
|
%{
|
||||||
|
posts: Enum.map(result.posts, mapper),
|
||||||
|
total: result.total,
|
||||||
|
offset: result.offset,
|
||||||
|
limit: result.limit,
|
||||||
|
has_more: result.offset + result.limit < result.total
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
350
lib/bds/ai/chat_tool_schemas.ex
Normal file
350
lib/bds/ai/chat_tool_schemas.ex
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
defmodule BDS.AI.ChatToolSchemas do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
def tool_spec(name, description, parameters) do
|
||||||
|
%{
|
||||||
|
"type" => "function",
|
||||||
|
"function" => %{
|
||||||
|
"name" => name,
|
||||||
|
"description" => description,
|
||||||
|
"parameters" => parameters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def limit_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def post_search_schema(require_query) do
|
||||||
|
schema = %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"query" => %{"type" => "string"},
|
||||||
|
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
|
||||||
|
"category" => %{"type" => "string"},
|
||||||
|
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||||
|
"language" => %{"type" => "string"},
|
||||||
|
"missingTranslationLanguage" => %{"type" => "string"},
|
||||||
|
"year" => %{"type" => "integer"},
|
||||||
|
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
|
||||||
|
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50},
|
||||||
|
"offset" => %{"type" => "integer", "minimum" => 0}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if require_query, do: Map.put(schema, "required", ["query"]), else: schema
|
||||||
|
end
|
||||||
|
|
||||||
|
def count_posts_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"groupBy" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "string",
|
||||||
|
"enum" => ["year", "month", "tag", "category", "status"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"year" => %{"type" => "integer"},
|
||||||
|
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
|
||||||
|
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
|
||||||
|
"category" => %{"type" => "string"},
|
||||||
|
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
|
||||||
|
},
|
||||||
|
"required" => ["groupBy"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def post_id_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{"postId" => %{"type" => "string"}},
|
||||||
|
"required" => ["postId"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def media_id_schema(extra_properties \\ %{}) do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => Map.merge(%{"mediaId" => %{"type" => "string"}}, extra_properties),
|
||||||
|
"required" => ["mediaId"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_post_metadata_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"postId" => %{"type" => "string"},
|
||||||
|
"title" => %{"type" => "string"},
|
||||||
|
"excerpt" => %{"type" => "string"},
|
||||||
|
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||||
|
"categories" => %{"type" => "array", "items" => %{"type" => "string"}}
|
||||||
|
},
|
||||||
|
"required" => ["postId"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_media_metadata_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"mediaId" => %{"type" => "string"},
|
||||||
|
"title" => %{"type" => "string"},
|
||||||
|
"alt" => %{"type" => "string"},
|
||||||
|
"caption" => %{"type" => "string"},
|
||||||
|
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
|
||||||
|
},
|
||||||
|
"required" => ["mediaId"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_table_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"title" => %{"type" => "string", "description" => "Optional table title"},
|
||||||
|
"columns" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "string"},
|
||||||
|
"description" => "Column header names"
|
||||||
|
},
|
||||||
|
"rows" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||||
|
"description" => "Table rows, each row is an array of cell values"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_chart_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"chartType" => %{
|
||||||
|
"type" => "string",
|
||||||
|
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
|
||||||
|
"description" =>
|
||||||
|
"The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations."
|
||||||
|
},
|
||||||
|
"title" => %{"type" => "string", "description" => "Optional chart title"},
|
||||||
|
"series" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Array of data points.",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"label" => %{"type" => "string", "description" => "Data point label"},
|
||||||
|
"value" => %{"type" => "number", "description" => "Data point value"},
|
||||||
|
"segments" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" =>
|
||||||
|
"Segments within this data point. Required for stacked-bar and heatmap charts.",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"label" => %{"type" => "string"},
|
||||||
|
"value" => %{"type" => "number"}
|
||||||
|
},
|
||||||
|
"required" => ["label", "value"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => ["label"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => ["chartType", "series"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_form_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"title" => %{"type" => "string", "description" => "Optional form title"},
|
||||||
|
"fields" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Form fields to display",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"key" => %{"type" => "string", "description" => "Field identifier"},
|
||||||
|
"label" => %{"type" => "string", "description" => "Field label shown to user"},
|
||||||
|
"inputType" => %{
|
||||||
|
"type" => "string",
|
||||||
|
"enum" => ["text", "textarea", "select", "checkbox", "date", "number"],
|
||||||
|
"description" => "Type of input control"
|
||||||
|
},
|
||||||
|
"placeholder" => %{"type" => "string", "description" => "Placeholder text"},
|
||||||
|
"defaultValue" => %{"type" => "string", "description" => "Default value"},
|
||||||
|
"options" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Options for select fields",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"label" => %{"type" => "string"},
|
||||||
|
"value" => %{"type" => "string"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => %{"type" => "boolean", "description" => "Whether the field is required"}
|
||||||
|
},
|
||||||
|
"required" => ["key", "label", "inputType"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submitLabel" => %{"type" => "string", "description" => "Label for the submit button"},
|
||||||
|
"submitAction" => %{"type" => "string", "description" => "Action to dispatch on submit"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_card_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"title" => %{"type" => "string", "description" => "Card title"},
|
||||||
|
"subtitle" => %{"type" => "string", "description" => "Optional subtitle"},
|
||||||
|
"body" => %{"type" => "string", "description" => "Card body text (supports markdown)"},
|
||||||
|
"actions" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Optional action buttons on the card",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"label" => %{"type" => "string", "description" => "Button label"},
|
||||||
|
"action" => %{"type" => "string", "description" => "Action name to dispatch"},
|
||||||
|
"payload" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"description" => "Optional action payload"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => ["label", "action"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_metric_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"label" => %{"type" => "string", "description" => "Metric label"},
|
||||||
|
"value" => %{"type" => "string", "description" => "Metric value (displayed prominently)"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_list_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"title" => %{"type" => "string", "description" => "Optional list title"},
|
||||||
|
"items" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "string"},
|
||||||
|
"description" => "List items"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_tabs_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"title" => %{"type" => "string", "description" => "Optional tabs title"},
|
||||||
|
"tabs" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Array of tabs",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"label" => %{"type" => "string", "description" => "Tab label"},
|
||||||
|
"content" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Content items within the tab",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"type" => %{
|
||||||
|
"type" => "string",
|
||||||
|
"enum" => ["text", "metric", "list", "chart", "table"],
|
||||||
|
"description" => "Content type"
|
||||||
|
},
|
||||||
|
"text" => %{"type" => "string", "description" => "Text content (for type text)"},
|
||||||
|
"label" => %{"type" => "string", "description" => "Label (for type metric)"},
|
||||||
|
"value" => %{"type" => "string", "description" => "Display value (for type metric)"},
|
||||||
|
"title" => %{"type" => "string", "description" => "Title (for type list, chart, or table)"},
|
||||||
|
"items" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "string"},
|
||||||
|
"description" => "Items (for type list)"
|
||||||
|
},
|
||||||
|
"chartType" => %{
|
||||||
|
"type" => "string",
|
||||||
|
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
|
||||||
|
"description" => "Chart type (for type chart)"
|
||||||
|
},
|
||||||
|
"series" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Data series (for type chart)"
|
||||||
|
},
|
||||||
|
"columns" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "string"},
|
||||||
|
"description" => "Column headers (for type table)"
|
||||||
|
},
|
||||||
|
"rows" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||||
|
"description" => "Table rows (for type table)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => ["type"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => ["label", "content"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_mindmap_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"title" => %{"type" => "string", "description" => "Optional mind map title"},
|
||||||
|
"nodes" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Flat array of nodes. The first node is the root. Each node references children by ID.",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"id" => %{"type" => "string", "description" => "Unique node identifier"},
|
||||||
|
"label" => %{"type" => "string", "description" => "Node label text"},
|
||||||
|
"children" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "string"},
|
||||||
|
"description" => "IDs of child nodes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => ["id", "label"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,6 +4,8 @@ defmodule BDS.AI.ChatTools do
|
|||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias BDS.AI.Chat
|
alias BDS.AI.Chat
|
||||||
|
alias BDS.AI.ChatToolQueryHelpers, as: QueryHelpers
|
||||||
|
alias BDS.AI.ChatToolSchemas, as: Schemas
|
||||||
alias BDS.Media, as: MediaContext
|
alias BDS.Media, as: MediaContext
|
||||||
alias BDS.Media.Media
|
alias BDS.Media.Media
|
||||||
alias BDS.MCP.Queries
|
alias BDS.MCP.Queries
|
||||||
@@ -12,7 +14,6 @@ defmodule BDS.AI.ChatTools do
|
|||||||
alias BDS.Posts.PostMedia
|
alias BDS.Posts.PostMedia
|
||||||
alias BDS.Projects.Project
|
alias BDS.Projects.Project
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
alias BDS.Search
|
|
||||||
|
|
||||||
@spec execute(String.t(), map(), String.t() | nil) :: map()
|
@spec execute(String.t(), map(), String.t() | nil) :: map()
|
||||||
def execute("blog_stats", _arguments, project_id) do
|
def execute("blog_stats", _arguments, project_id) do
|
||||||
@@ -58,17 +59,14 @@ defmodule BDS.AI.ChatTools do
|
|||||||
|
|
||||||
def execute("search_posts", arguments, project_id) do
|
def execute("search_posts", arguments, project_id) do
|
||||||
project_id = project_id || active_project_id()
|
project_id = project_id || active_project_id()
|
||||||
filters = search_filters(arguments)
|
filters = QueryHelpers.search_filters(arguments)
|
||||||
|
|
||||||
{:ok, result} = Search.search_posts(project_id, arguments["query"] || "", filters)
|
QueryHelpers.search_result(
|
||||||
|
project_id,
|
||||||
%{
|
arguments["query"] || "",
|
||||||
posts: Enum.map(result.posts, &Queries.post_summary/1),
|
filters,
|
||||||
total: result.total,
|
&Queries.post_summary/1
|
||||||
offset: result.offset,
|
)
|
||||||
limit: result.limit,
|
|
||||||
has_more: result.offset + result.limit < result.total
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def execute("read_post_by_slug", arguments, project_id) do
|
def execute("read_post_by_slug", arguments, project_id) do
|
||||||
@@ -83,41 +81,28 @@ defmodule BDS.AI.ChatTools do
|
|||||||
def execute("read_post", arguments, project_id) do
|
def execute("read_post", arguments, project_id) do
|
||||||
project_id = project_id || active_project_id()
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
case Repo.get_by(Post,
|
with_post(arguments, project_id, %{success: false, error: "not_found"}, fn post ->
|
||||||
id: arguments["postId"] || arguments["post_id"],
|
%{post: Queries.post_detail(post)}
|
||||||
project_id: project_id
|
end)
|
||||||
) do
|
|
||||||
%Post{} = post -> %{post: Queries.post_detail(post)}
|
|
||||||
nil -> %{success: false, error: "not_found"}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def execute("list_posts", arguments, project_id) do
|
def execute("list_posts", arguments, project_id) do
|
||||||
project_id = project_id || active_project_id()
|
project_id = project_id || active_project_id()
|
||||||
limit = normalize_limit(arguments["limit"])
|
limit = QueryHelpers.normalize_limit(arguments["limit"])
|
||||||
offset = normalize_offset(arguments["offset"])
|
offset = QueryHelpers.normalize_offset(arguments["offset"])
|
||||||
filters = search_filters(arguments) |> Map.merge(%{limit: limit, offset: offset})
|
filters = QueryHelpers.search_filters(arguments) |> Map.merge(%{limit: limit, offset: offset})
|
||||||
|
|
||||||
{:ok, result} = Search.search_posts(project_id, "", filters)
|
QueryHelpers.search_result(project_id, "", filters, fn post ->
|
||||||
|
post
|
||||||
%{
|
|> Queries.post_summary()
|
||||||
posts:
|
|> Map.put("url", "/posts/#{post.slug}")
|
||||||
Enum.map(result.posts, fn post ->
|
|> Map.put("updated_at", post.updated_at)
|
||||||
post
|
end)
|
||||||
|> Queries.post_summary()
|
|
||||||
|> Map.put("url", "/posts/#{post.slug}")
|
|
||||||
|> Map.put("updated_at", post.updated_at)
|
|
||||||
end),
|
|
||||||
total: result.total,
|
|
||||||
offset: result.offset,
|
|
||||||
limit: result.limit,
|
|
||||||
has_more: result.offset + result.limit < result.total
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def execute("list_media", arguments, project_id) do
|
def execute("list_media", arguments, project_id) do
|
||||||
project_id = project_id || active_project_id()
|
project_id = project_id || active_project_id()
|
||||||
limit = normalize_limit(arguments["limit"])
|
limit = QueryHelpers.normalize_limit(arguments["limit"])
|
||||||
|
|
||||||
Repo.all(
|
Repo.all(
|
||||||
from(media in Media,
|
from(media in Media,
|
||||||
@@ -138,13 +123,9 @@ defmodule BDS.AI.ChatTools do
|
|||||||
def execute("get_media", arguments, project_id) do
|
def execute("get_media", arguments, project_id) do
|
||||||
project_id = project_id || active_project_id()
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
case Repo.get_by(Media,
|
with_media(arguments, project_id, %{success: false, error: "not_found"}, fn media ->
|
||||||
id: arguments["mediaId"] || arguments["media_id"],
|
%{media: media_summary(media)}
|
||||||
project_id: project_id
|
end)
|
||||||
) do
|
|
||||||
%Media{} = media -> %{media: media_summary(media)}
|
|
||||||
nil -> %{success: false, error: "not_found"}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def execute("view_image", arguments, project_id) do
|
def execute("view_image", arguments, project_id) do
|
||||||
@@ -220,7 +201,7 @@ defmodule BDS.AI.ChatTools do
|
|||||||
def execute("count_posts", arguments, project_id) do
|
def execute("count_posts", arguments, project_id) do
|
||||||
project_id = project_id || active_project_id()
|
project_id = project_id || active_project_id()
|
||||||
group_by = List.wrap(arguments["groupBy"] || arguments["group_by"]) |> Enum.map(&to_string/1)
|
group_by = List.wrap(arguments["groupBy"] || arguments["group_by"]) |> Enum.map(&to_string/1)
|
||||||
result = search_all_counted_posts(project_id, arguments)
|
result = QueryHelpers.search_all_counted_posts(project_id, arguments)
|
||||||
|
|
||||||
groups =
|
groups =
|
||||||
result.posts
|
result.posts
|
||||||
@@ -235,51 +216,33 @@ defmodule BDS.AI.ChatTools do
|
|||||||
def execute("get_post_backlinks", arguments, project_id) do
|
def execute("get_post_backlinks", arguments, project_id) do
|
||||||
project_id = project_id || active_project_id()
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
case Repo.get_by(Post,
|
with_post(arguments, project_id, %{success: false, error: "not_found"}, fn post ->
|
||||||
id: arguments["postId"] || arguments["post_id"],
|
%{success: true, post_id: post.id, linked_by: Queries.linked_posts(post.id, :incoming)}
|
||||||
project_id: project_id
|
end)
|
||||||
) do
|
|
||||||
%Post{} = post ->
|
|
||||||
%{success: true, post_id: post.id, linked_by: Queries.linked_posts(post.id, :incoming)}
|
|
||||||
|
|
||||||
nil ->
|
|
||||||
%{success: false, error: "not_found"}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def execute("get_post_outlinks", arguments, project_id) do
|
def execute("get_post_outlinks", arguments, project_id) do
|
||||||
project_id = project_id || active_project_id()
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
case Repo.get_by(Post,
|
with_post(arguments, project_id, %{success: false, error: "not_found"}, fn post ->
|
||||||
id: arguments["postId"] || arguments["post_id"],
|
%{success: true, post_id: post.id, links_to: Queries.linked_posts(post.id, :outgoing)}
|
||||||
project_id: project_id
|
end)
|
||||||
) do
|
|
||||||
%Post{} = post ->
|
|
||||||
%{success: true, post_id: post.id, links_to: Queries.linked_posts(post.id, :outgoing)}
|
|
||||||
|
|
||||||
nil ->
|
|
||||||
%{success: false, error: "not_found"}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def execute("get_post_media", arguments, project_id) do
|
def execute("get_post_media", arguments, project_id) do
|
||||||
project_id = project_id || active_project_id()
|
project_id = project_id || active_project_id()
|
||||||
post_id = arguments["postId"] || arguments["post_id"]
|
|
||||||
|
|
||||||
case Repo.get_by(Post, id: post_id, project_id: project_id) do
|
with_post(arguments, project_id, %{success: false, error: "not_found"}, fn post ->
|
||||||
%Post{} = post -> %{success: true, post_id: post.id, media: post_media(project_id, post.id)}
|
%{success: true, post_id: post.id, media: post_media(project_id, post.id)}
|
||||||
nil -> %{success: false, error: "not_found"}
|
end)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def execute("get_media_posts", arguments, project_id) do
|
def execute("get_media_posts", arguments, project_id) do
|
||||||
project_id = project_id || active_project_id()
|
project_id = project_id || active_project_id()
|
||||||
media_id = arguments["mediaId"] || arguments["media_id"]
|
|
||||||
|
|
||||||
case Repo.get_by(Media, id: media_id, project_id: project_id) do
|
with_media(arguments, project_id, %{success: false, error: "not_found"}, fn media ->
|
||||||
%Media{} = media -> %{success: true, media_id: media.id, posts: media_posts(media.id)}
|
%{success: true, media_id: media.id, posts: media_posts(media.id)}
|
||||||
nil -> %{success: false, error: "not_found"}
|
end)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def execute("render_table", arguments, _project_id) do
|
def execute("render_table", arguments, _project_id) do
|
||||||
@@ -365,7 +328,7 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
name: "blog_stats",
|
name: "blog_stats",
|
||||||
spec:
|
spec:
|
||||||
tool_spec("blog_stats", "Return aggregate blog statistics", %{
|
Schemas.tool_spec("blog_stats", "Return aggregate blog statistics", %{
|
||||||
"type" => "object",
|
"type" => "object",
|
||||||
"properties" => %{}
|
"properties" => %{}
|
||||||
})
|
})
|
||||||
@@ -373,7 +336,7 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
name: "get_blog_stats",
|
name: "get_blog_stats",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"get_blog_stats",
|
"get_blog_stats",
|
||||||
"Get comprehensive blog statistics: total posts, media count, unique tag count, and unique category count. Use this first when you need to understand the scope of the data.",
|
"Get comprehensive blog statistics: total posts, media count, unique tag count, and unique category count. Use this first when you need to understand the scope of the data.",
|
||||||
%{"type" => "object", "properties" => %{}}
|
%{"type" => "object", "properties" => %{}}
|
||||||
@@ -382,7 +345,7 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
name: "check_term",
|
name: "check_term",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"check_term",
|
"check_term",
|
||||||
"Check whether a term exists as a category, tag, or both. Returns post counts for each. Use before search_posts or list_posts when unsure whether a term is a category or tag.",
|
"Check whether a term exists as a category, tag, or both. Returns post counts for each. Use before search_posts or list_posts when unsure whether a term is a category or tag.",
|
||||||
%{
|
%{
|
||||||
@@ -395,16 +358,16 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
name: "search_posts",
|
name: "search_posts",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"search_posts",
|
"search_posts",
|
||||||
"Search blog posts using full-text search. Can filter by category, tags, language, missing translation language, year, month, or status. Returns paginated concrete post data with titles, slugs, tags, categories, backlinks, and links_to.",
|
"Search blog posts using full-text search. Can filter by category, tags, language, missing translation language, year, month, or status. Returns paginated concrete post data with titles, slugs, tags, categories, backlinks, and links_to.",
|
||||||
post_search_schema(true)
|
Schemas.post_search_schema(true)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "read_post",
|
name: "read_post",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"read_post",
|
"read_post",
|
||||||
"Read full content and metadata of a specific blog post by ID. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
|
"Read full content and metadata of a specific blog post by ID. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
|
||||||
%{
|
%{
|
||||||
@@ -417,7 +380,7 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
name: "read_post_by_slug",
|
name: "read_post_by_slug",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"read_post_by_slug",
|
"read_post_by_slug",
|
||||||
"Read full content and metadata of a specific blog post by slug. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
|
"Read full content and metadata of a specific blog post by slug. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
|
||||||
%{
|
%{
|
||||||
@@ -430,37 +393,37 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
name: "list_posts",
|
name: "list_posts",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"list_posts",
|
"list_posts",
|
||||||
"List blog posts with optional filtering by status, category, tags, language, year, or month. Returns paginated concrete post data with titles, slugs, URLs, statuses, tags, categories, backlinks, and links_to. Use for recent, latest, top, or title-list requests.",
|
"List blog posts with optional filtering by status, category, tags, language, year, or month. Returns paginated concrete post data with titles, slugs, URLs, statuses, tags, categories, backlinks, and links_to. Use for recent, latest, top, or title-list requests.",
|
||||||
post_search_schema(false)
|
Schemas.post_search_schema(false)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "get_media",
|
name: "get_media",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"get_media",
|
"get_media",
|
||||||
"Get information about a specific media file by ID, including title, alt text, caption, tags, filename, MIME type, dimensions, and update time.",
|
"Get information about a specific media file by ID, including title, alt text, caption, tags, filename, MIME type, dimensions, and update time.",
|
||||||
media_id_schema()
|
Schemas.media_id_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "list_media",
|
name: "list_media",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"list_media",
|
"list_media",
|
||||||
"List concrete media data in the active project, including titles, filenames, MIME types, and update times.",
|
"List concrete media data in the active project, including titles, filenames, MIME types, and update times.",
|
||||||
limit_schema()
|
Schemas.limit_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "view_image",
|
name: "view_image",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"view_image",
|
"view_image",
|
||||||
"View an image thumbnail as a local data URL for visual inspection. Only works with image media files.",
|
"View an image thumbnail as a local data URL for visual inspection. Only works with image media files.",
|
||||||
media_id_schema(%{
|
Schemas.media_id_schema(%{
|
||||||
"size" => %{"type" => "string", "enum" => ["small", "medium", "large"]}
|
"size" => %{"type" => "string", "enum" => ["small", "medium", "large"]}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -468,25 +431,25 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
name: "update_post_metadata",
|
name: "update_post_metadata",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"update_post_metadata",
|
"update_post_metadata",
|
||||||
"Update metadata for a blog post: title, excerpt, tags, or categories. Does not update post body content.",
|
"Update metadata for a blog post: title, excerpt, tags, or categories. Does not update post body content.",
|
||||||
update_post_metadata_schema()
|
Schemas.update_post_metadata_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "update_media_metadata",
|
name: "update_media_metadata",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"update_media_metadata",
|
"update_media_metadata",
|
||||||
"Update metadata for a media file: title, alt text, caption, or tags.",
|
"Update metadata for a media file: title, alt text, caption, or tags.",
|
||||||
update_media_metadata_schema()
|
Schemas.update_media_metadata_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "list_tags",
|
name: "list_tags",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"list_tags",
|
"list_tags",
|
||||||
"List all tags used across blog posts with post counts.",
|
"List all tags used across blog posts with post counts.",
|
||||||
%{
|
%{
|
||||||
@@ -498,7 +461,7 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
name: "list_categories",
|
name: "list_categories",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"list_categories",
|
"list_categories",
|
||||||
"List all categories used across blog posts with post counts.",
|
"List all categories used across blog posts with post counts.",
|
||||||
%{"type" => "object", "properties" => %{}}
|
%{"type" => "object", "properties" => %{}}
|
||||||
@@ -507,46 +470,46 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
name: "count_posts",
|
name: "count_posts",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"count_posts",
|
"count_posts",
|
||||||
"Count posts grouped by dimensions such as year, month, tag, category, or status. Use for analytics, distributions, and heat maps without transferring full post content.",
|
"Count posts grouped by dimensions such as year, month, tag, category, or status. Use for analytics, distributions, and heat maps without transferring full post content.",
|
||||||
count_posts_schema()
|
Schemas.count_posts_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "get_post_backlinks",
|
name: "get_post_backlinks",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"get_post_backlinks",
|
"get_post_backlinks",
|
||||||
"Get all posts that link to a specific post.",
|
"Get all posts that link to a specific post.",
|
||||||
post_id_schema()
|
Schemas.post_id_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "get_post_outlinks",
|
name: "get_post_outlinks",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"get_post_outlinks",
|
"get_post_outlinks",
|
||||||
"Get all posts that a specific post links to.",
|
"Get all posts that a specific post links to.",
|
||||||
post_id_schema()
|
Schemas.post_id_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "get_post_media",
|
name: "get_post_media",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"get_post_media",
|
"get_post_media",
|
||||||
"Get media files linked to a specific post.",
|
"Get media files linked to a specific post.",
|
||||||
post_id_schema()
|
Schemas.post_id_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "get_media_posts",
|
name: "get_media_posts",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"get_media_posts",
|
"get_media_posts",
|
||||||
"Get posts that use a specific media file.",
|
"Get posts that use a specific media file.",
|
||||||
media_id_schema()
|
Schemas.media_id_schema()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -559,73 +522,73 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
name: "render_card",
|
name: "render_card",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"render_card",
|
"render_card",
|
||||||
"Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item.",
|
"Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item.",
|
||||||
render_card_schema()
|
Schemas.render_card_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_table",
|
name: "render_table",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"render_table",
|
"render_table",
|
||||||
"Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.",
|
"Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.",
|
||||||
render_table_schema()
|
Schemas.render_table_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_chart",
|
name: "render_chart",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"render_chart",
|
"render_chart",
|
||||||
"Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. Supports bar, stacked-bar, line, area, pie, donut, and heatmap charts. Use stacked-bar for multi-segment bars and heatmap for grid/matrix visualizations.",
|
"Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. Supports bar, stacked-bar, line, area, pie, donut, and heatmap charts. Use stacked-bar for multi-segment bars and heatmap for grid/matrix visualizations.",
|
||||||
render_chart_schema()
|
Schemas.render_chart_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_form",
|
name: "render_form",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"render_form",
|
"render_form",
|
||||||
"Render an interactive form in the chat UI. Use this when you need to collect structured input from the user.",
|
"Render an interactive form in the chat UI. Use this when you need to collect structured input from the user.",
|
||||||
render_form_schema()
|
Schemas.render_form_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_metric",
|
name: "render_metric",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"render_metric",
|
"render_metric",
|
||||||
"Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.",
|
"Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.",
|
||||||
render_metric_schema()
|
Schemas.render_metric_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_list",
|
name: "render_list",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"render_list",
|
"render_list",
|
||||||
"Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.",
|
"Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.",
|
||||||
render_list_schema()
|
Schemas.render_list_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_tabs",
|
name: "render_tabs",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"render_tabs",
|
"render_tabs",
|
||||||
"Render a tabbed interface in the chat UI. Use this to organize information into multiple tabs that the user can switch between.",
|
"Render a tabbed interface in the chat UI. Use this to organize information into multiple tabs that the user can switch between.",
|
||||||
render_tabs_schema()
|
Schemas.render_tabs_schema()
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "render_mindmap",
|
name: "render_mindmap",
|
||||||
spec:
|
spec:
|
||||||
tool_spec(
|
Schemas.tool_spec(
|
||||||
"render_mindmap",
|
"render_mindmap",
|
||||||
"Render a mind map diagram in the chat UI. Use this when the user asks for a mind map, concept map, topic tree, brainstorming diagram, or hierarchical overview of ideas.",
|
"Render a mind map diagram in the chat UI. Use this when the user asks for a mind map, concept map, topic tree, brainstorming diagram, or hierarchical overview of ideas.",
|
||||||
render_mindmap_schema()
|
Schemas.render_mindmap_schema()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -634,268 +597,26 @@ defmodule BDS.AI.ChatTools do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp tool_spec(name, description, parameters) do
|
defp with_post(arguments, project_id, not_found_result, success_fun) do
|
||||||
%{
|
case Repo.get_by(Post,
|
||||||
"type" => "function",
|
id: arguments["postId"] || arguments["post_id"],
|
||||||
"function" => %{
|
project_id: project_id
|
||||||
"name" => name,
|
) do
|
||||||
"description" => description,
|
%Post{} = post -> success_fun.(post)
|
||||||
"parameters" => parameters
|
nil -> not_found_result
|
||||||
}
|
end
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp limit_schema do
|
defp with_media(arguments, project_id, not_found_result, success_fun) do
|
||||||
%{
|
case Repo.get_by(Media,
|
||||||
"type" => "object",
|
id: arguments["mediaId"] || arguments["media_id"],
|
||||||
"properties" => %{
|
project_id: project_id
|
||||||
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50}
|
) do
|
||||||
}
|
%Media{} = media -> success_fun.(media)
|
||||||
}
|
nil -> not_found_result
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp post_search_schema(require_query) do
|
|
||||||
schema = %{
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => %{
|
|
||||||
"query" => %{"type" => "string"},
|
|
||||||
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
|
|
||||||
"category" => %{"type" => "string"},
|
|
||||||
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
|
|
||||||
"language" => %{"type" => "string"},
|
|
||||||
"missingTranslationLanguage" => %{"type" => "string"},
|
|
||||||
"year" => %{"type" => "integer"},
|
|
||||||
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
|
|
||||||
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50},
|
|
||||||
"offset" => %{"type" => "integer", "minimum" => 0}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if require_query, do: Map.put(schema, "required", ["query"]), else: schema
|
|
||||||
end
|
|
||||||
|
|
||||||
defp count_posts_schema do
|
|
||||||
%{
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => %{
|
|
||||||
"groupBy" => %{
|
|
||||||
"type" => "array",
|
|
||||||
"items" => %{
|
|
||||||
"type" => "string",
|
|
||||||
"enum" => ["year", "month", "tag", "category", "status"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"year" => %{"type" => "integer"},
|
|
||||||
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
|
|
||||||
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
|
|
||||||
"category" => %{"type" => "string"},
|
|
||||||
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
|
|
||||||
},
|
|
||||||
"required" => ["groupBy"]
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp post_id_schema do
|
|
||||||
%{
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => %{"postId" => %{"type" => "string"}},
|
|
||||||
"required" => ["postId"]
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp media_id_schema(extra_properties \\ %{}) do
|
|
||||||
%{
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => Map.merge(%{"mediaId" => %{"type" => "string"}}, extra_properties),
|
|
||||||
"required" => ["mediaId"]
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp update_post_metadata_schema do
|
|
||||||
%{
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => %{
|
|
||||||
"postId" => %{"type" => "string"},
|
|
||||||
"title" => %{"type" => "string"},
|
|
||||||
"excerpt" => %{"type" => "string"},
|
|
||||||
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
|
|
||||||
"categories" => %{"type" => "array", "items" => %{"type" => "string"}}
|
|
||||||
},
|
|
||||||
"required" => ["postId"]
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp update_media_metadata_schema do
|
|
||||||
%{
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => %{
|
|
||||||
"mediaId" => %{"type" => "string"},
|
|
||||||
"title" => %{"type" => "string"},
|
|
||||||
"alt" => %{"type" => "string"},
|
|
||||||
"caption" => %{"type" => "string"},
|
|
||||||
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
|
|
||||||
},
|
|
||||||
"required" => ["mediaId"]
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp render_table_schema do
|
|
||||||
%{
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => %{
|
|
||||||
"title" => %{"type" => "string", "description" => "Optional table title"},
|
|
||||||
"columns" => %{
|
|
||||||
"type" => "array",
|
|
||||||
"items" => %{"type" => "string"},
|
|
||||||
"description" => "Column header names"
|
|
||||||
},
|
|
||||||
"rows" => %{
|
|
||||||
"type" => "array",
|
|
||||||
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
|
|
||||||
"description" => "Table rows, each row is an array of cell values"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp render_chart_schema do
|
|
||||||
%{
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => %{
|
|
||||||
"chartType" => %{
|
|
||||||
"type" => "string",
|
|
||||||
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
|
|
||||||
"description" =>
|
|
||||||
"The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations."
|
|
||||||
},
|
|
||||||
"title" => %{"type" => "string", "description" => "Optional chart title"},
|
|
||||||
"series" => %{
|
|
||||||
"type" => "array",
|
|
||||||
"description" => "Array of data points.",
|
|
||||||
"items" => %{
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => %{
|
|
||||||
"label" => %{"type" => "string", "description" => "Data point label"},
|
|
||||||
"value" => %{"type" => "number", "description" => "Data point value"},
|
|
||||||
"segments" => %{
|
|
||||||
"type" => "array",
|
|
||||||
"description" =>
|
|
||||||
"Segments within this data point. Required for stacked-bar and heatmap charts.",
|
|
||||||
"items" => %{
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => %{
|
|
||||||
"label" => %{"type" => "string"},
|
|
||||||
"value" => %{"type" => "number"}
|
|
||||||
},
|
|
||||||
"required" => ["label", "value"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required" => ["label"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required" => ["chartType", "series"]
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp render_form_schema do
|
|
||||||
%{
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => %{
|
|
||||||
"title" => %{"type" => "string"},
|
|
||||||
"fields" => %{"type" => "array"},
|
|
||||||
"submitLabel" => %{"type" => "string"},
|
|
||||||
"submitAction" => %{"type" => "string"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp render_card_schema do
|
|
||||||
%{
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => %{
|
|
||||||
"title" => %{"type" => "string"},
|
|
||||||
"subtitle" => %{"type" => "string"},
|
|
||||||
"body" => %{"type" => "string"},
|
|
||||||
"actions" => %{"type" => "array"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp render_metric_schema do
|
|
||||||
%{
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => %{
|
|
||||||
"label" => %{"type" => "string"},
|
|
||||||
"value" => %{"type" => "string"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp render_list_schema do
|
|
||||||
%{
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => %{
|
|
||||||
"title" => %{"type" => "string"},
|
|
||||||
"items" => %{"type" => "array"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp render_tabs_schema do
|
|
||||||
%{
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => %{
|
|
||||||
"title" => %{"type" => "string"},
|
|
||||||
"tabs" => %{"type" => "array"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp render_mindmap_schema do
|
|
||||||
%{
|
|
||||||
"type" => "object",
|
|
||||||
"properties" => %{
|
|
||||||
"title" => %{"type" => "string"},
|
|
||||||
"nodes" => %{"type" => "array"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
|
|
||||||
defp normalize_limit(_value), do: 10
|
|
||||||
|
|
||||||
defp normalize_offset(value) when is_integer(value) and value >= 0, do: value
|
|
||||||
defp normalize_offset(_value), do: 0
|
|
||||||
|
|
||||||
defp search_filters(arguments) do
|
|
||||||
%{}
|
|
||||||
|> maybe_put(:category, arguments["category"])
|
|
||||||
|> maybe_put(:tags, arguments["tags"])
|
|
||||||
|> maybe_put(:language, arguments["language"])
|
|
||||||
|> maybe_put(:missing_translation_language, arguments["missingTranslationLanguage"])
|
|
||||||
|> maybe_put(:year, arguments["year"])
|
|
||||||
|> maybe_put(:month, arguments["month"])
|
|
||||||
|> maybe_put(:status, BDS.BoundedAtoms.post_status(arguments["status"]))
|
|
||||||
|> Map.put(:offset, normalize_offset(arguments["offset"]))
|
|
||||||
|> Map.put(:limit, normalize_limit(arguments["limit"]))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp search_all_counted_posts(project_id, arguments) do
|
|
||||||
filters = search_filters(arguments) |> Map.put(:offset, 0) |> Map.put(:limit, 1)
|
|
||||||
{:ok, %{total: total}} = Search.search_posts(project_id, "", filters)
|
|
||||||
|
|
||||||
filters = Map.put(filters, :limit, max(total, 1))
|
|
||||||
{:ok, result} = Search.search_posts(project_id, "", filters)
|
|
||||||
|
|
||||||
result
|
|
||||||
end
|
|
||||||
|
|
||||||
defp maybe_put(map, _key, nil), do: map
|
|
||||||
defp maybe_put(map, _key, ""), do: map
|
|
||||||
defp maybe_put(map, key, value), do: Map.put(map, key, value)
|
|
||||||
|
|
||||||
defp counted_terms(project_id, field) do
|
defp counted_terms(project_id, field) do
|
||||||
Repo.all(
|
Repo.all(
|
||||||
from post in Post, where: post.project_id == ^project_id, select: field(post, ^field)
|
from post in Post, where: post.project_id == ^project_id, select: field(post, ^field)
|
||||||
@@ -909,7 +630,11 @@ defmodule BDS.AI.ChatTools do
|
|||||||
|
|
||||||
defp metadata_attrs(arguments, keys) do
|
defp metadata_attrs(arguments, keys) do
|
||||||
Enum.reduce(keys, %{}, fn key, acc ->
|
Enum.reduce(keys, %{}, fn key, acc ->
|
||||||
maybe_put(acc, BDS.MapUtils.safe_atomize_key(key), arguments[key])
|
BDS.MapUtils.maybe_put(
|
||||||
|
acc,
|
||||||
|
BDS.MapUtils.safe_atomize_key(key),
|
||||||
|
BDS.MapUtils.blank_to_nil(arguments[key])
|
||||||
|
)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,58 +1,150 @@
|
|||||||
defmodule BDS.AI.HttpClient do
|
defmodule BDS.AI.HttpClient do
|
||||||
@moduledoc false
|
@moduledoc """
|
||||||
|
Req-based HTTP client for AI endpoints.
|
||||||
|
|
||||||
def get(url, headers) when is_binary(url) and is_map(headers) do
|
Replaces the previous `:httpc` wrapper with explicit connect/receive
|
||||||
request =
|
timeouts, TLS verification via Req's defaults, and transient retries for
|
||||||
{String.to_charlist(url),
|
idempotent GETs only — POSTs (chat completions) are never retried.
|
||||||
Enum.map(headers, fn {key, value} ->
|
|
||||||
{String.to_charlist(key), String.to_charlist(value)}
|
|
||||||
end)}
|
|
||||||
|
|
||||||
:inets.start()
|
The response contract is unchanged: `{:ok, %{status, headers, body}}` with
|
||||||
:ssl.start()
|
downcased single-valued header names and the body as a raw binary (callers
|
||||||
|
decode JSON themselves), or `{:error, reason}` where transport failures
|
||||||
|
surface as plain reason atoms such as `:timeout` or `:econnrefused`.
|
||||||
|
|
||||||
case :httpc.request(:get, request, [], body_format: :binary) do
|
Config (`config :bds, BDS.AI.HttpClient`):
|
||||||
{:ok, {{_version, status, _reason}, response_headers, body}} ->
|
|
||||||
{:ok,
|
|
||||||
%{
|
|
||||||
status: status,
|
|
||||||
headers: normalize_headers(response_headers),
|
|
||||||
body: body
|
|
||||||
}}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
* `:connect_timeout_ms` — TCP/TLS connect budget (default 5_000)
|
||||||
{:error, reason}
|
* `:receive_timeout_ms` — response budget (default 120_000; generous
|
||||||
end
|
because local LLM completions are slow)
|
||||||
|
* `:get_max_retries` — transient retries for GETs (default 2)
|
||||||
|
* `:retry_delay_ms` — constant delay between retries (default 500)
|
||||||
|
"""
|
||||||
|
|
||||||
|
@default_connect_timeout_ms 5_000
|
||||||
|
@default_receive_timeout_ms 120_000
|
||||||
|
@default_get_max_retries 2
|
||||||
|
@default_retry_delay_ms 500
|
||||||
|
|
||||||
|
@spec request_timeout_ms() :: pos_integer()
|
||||||
|
def request_timeout_ms do
|
||||||
|
max(
|
||||||
|
config(:connect_timeout_ms, @default_connect_timeout_ms),
|
||||||
|
config(:receive_timeout_ms, @default_receive_timeout_ms)
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec get(String.t(), %{String.t() => String.t()}) ::
|
||||||
|
{:ok, %{status: non_neg_integer(), headers: map(), body: binary()}} | {:error, term()}
|
||||||
|
def get(url, headers) when is_binary(url) and is_map(headers) do
|
||||||
|
[
|
||||||
|
method: :get,
|
||||||
|
url: url,
|
||||||
|
headers: headers,
|
||||||
|
retry: :transient,
|
||||||
|
max_retries: config(:get_max_retries, @default_get_max_retries),
|
||||||
|
retry_delay: fn _retry_count -> config(:retry_delay_ms, @default_retry_delay_ms) end,
|
||||||
|
retry_log_level: false
|
||||||
|
]
|
||||||
|
|> Keyword.merge(base_options())
|
||||||
|
|> Req.request()
|
||||||
|
|> normalize_result()
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec post(String.t(), %{String.t() => String.t()}, binary()) ::
|
||||||
|
{:ok, %{status: non_neg_integer(), headers: map(), body: binary()}} | {:error, term()}
|
||||||
def post(url, headers, body)
|
def post(url, headers, body)
|
||||||
when is_binary(url) and is_map(headers) and is_binary(body) do
|
when is_binary(url) and is_map(headers) and is_binary(body) do
|
||||||
request =
|
[
|
||||||
{String.to_charlist(url),
|
method: :post,
|
||||||
Enum.map(headers, fn {key, value} ->
|
url: url,
|
||||||
{String.to_charlist(key), String.to_charlist(value)}
|
headers: headers,
|
||||||
end), ~c"application/json", body}
|
body: body,
|
||||||
|
# Completions are not idempotent; a retry could bill or generate twice.
|
||||||
|
retry: false
|
||||||
|
]
|
||||||
|
|> Keyword.merge(base_options())
|
||||||
|
|> Req.request()
|
||||||
|
|> normalize_result()
|
||||||
|
end
|
||||||
|
|
||||||
:inets.start()
|
@doc """
|
||||||
:ssl.start()
|
Streaming POST: body chunks of a 200 response are folded into `acc` via
|
||||||
|
`reducer.(chunk, acc)` as they arrive; non-200 bodies are collected whole
|
||||||
|
for error reporting. Returns the final accumulator alongside the response.
|
||||||
|
|
||||||
case :httpc.request(:post, request, [], body_format: :binary) do
|
Never retried (same reasoning as `post/3`), and `accept-encoding` is
|
||||||
{:ok, {{_version, status, _reason}, response_headers, response_body}} ->
|
disabled so event-stream chunks arrive uncompressed. The request runs in
|
||||||
{:ok,
|
the calling process — killing that process aborts the underlying
|
||||||
%{
|
connection, which is what makes mid-stream chat cancellation work.
|
||||||
status: status,
|
"""
|
||||||
headers: normalize_headers(response_headers),
|
@spec post_stream(String.t(), %{String.t() => String.t()}, binary(), acc, (binary(), acc ->
|
||||||
body: response_body
|
acc)) ::
|
||||||
}}
|
{:ok, %{status: non_neg_integer(), headers: map(), body: binary()}, acc}
|
||||||
|
| {:error, term()}
|
||||||
|
when acc: term()
|
||||||
|
def post_stream(url, headers, body, acc, reducer)
|
||||||
|
when is_binary(url) and is_map(headers) and is_binary(body) and is_function(reducer, 2) do
|
||||||
|
into = fn {:data, data}, {req, resp} ->
|
||||||
|
resp =
|
||||||
|
if resp.status == 200 do
|
||||||
|
next_acc = reducer.(data, Req.Response.get_private(resp, :bds_stream_acc, acc))
|
||||||
|
Req.Response.put_private(resp, :bds_stream_acc, next_acc)
|
||||||
|
else
|
||||||
|
%{resp | body: collected_body(resp.body) <> data}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:cont, {req, resp}}
|
||||||
|
end
|
||||||
|
|
||||||
|
[
|
||||||
|
method: :post,
|
||||||
|
url: url,
|
||||||
|
headers: headers,
|
||||||
|
body: body,
|
||||||
|
retry: false,
|
||||||
|
compressed: false,
|
||||||
|
into: into
|
||||||
|
]
|
||||||
|
|> Keyword.merge(base_options())
|
||||||
|
|> Req.request()
|
||||||
|
|> case do
|
||||||
|
{:ok, %Req.Response{} = resp} ->
|
||||||
|
{:ok, %{status: resp.status, headers: normalize_headers(resp.headers), body: collected_body(resp.body)},
|
||||||
|
Req.Response.get_private(resp, :bds_stream_acc, acc)}
|
||||||
|
|
||||||
|
{:error, %Req.TransportError{reason: reason}} ->
|
||||||
|
{:error, reason}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
{:error, reason}
|
{:error, reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp collected_body(body) when is_binary(body), do: body
|
||||||
|
defp collected_body(_body), do: ""
|
||||||
|
|
||||||
|
defp base_options do
|
||||||
|
[
|
||||||
|
connect_options: [timeout: config(:connect_timeout_ms, @default_connect_timeout_ms)],
|
||||||
|
receive_timeout: config(:receive_timeout_ms, @default_receive_timeout_ms),
|
||||||
|
# Callers parse the body themselves; keep it a raw binary.
|
||||||
|
decode_body: false
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_result({:ok, %Req.Response{status: status, headers: headers, body: body}}) do
|
||||||
|
{:ok, %{status: status, headers: normalize_headers(headers), body: body}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_result({:error, %Req.TransportError{reason: reason}}), do: {:error, reason}
|
||||||
|
defp normalize_result({:error, reason}), do: {:error, reason}
|
||||||
|
|
||||||
|
# Req header names are already downcased; values arrive as lists.
|
||||||
defp normalize_headers(headers) do
|
defp normalize_headers(headers) do
|
||||||
Enum.into(headers, %{}, fn {key, value} ->
|
Map.new(headers, fn {name, values} -> {name, Enum.join(List.wrap(values), ", ")} end)
|
||||||
{key |> to_string() |> String.downcase(), to_string(value)}
|
end
|
||||||
end)
|
|
||||||
|
defp config(key, default) do
|
||||||
|
Application.get_env(:bds, __MODULE__, []) |> Keyword.get(key, default)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,29 +1,38 @@
|
|||||||
defmodule BDS.AI.InFlight do
|
defmodule BDS.AI.InFlight do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
|
# Registry of in-flight chat tasks keyed by conversation id. The named ETS
|
||||||
|
# table is owned by this supervised GenServer (started from the application
|
||||||
|
# supervision tree), so registrations survive the exit of the registering
|
||||||
|
# process and there is no creation race between concurrent first callers.
|
||||||
|
use GenServer
|
||||||
|
|
||||||
@table :bds_ai_in_flight
|
@table :bds_ai_in_flight
|
||||||
|
|
||||||
|
def start_link(opts) do
|
||||||
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_opts) do
|
||||||
|
table = :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
|
||||||
|
{:ok, table}
|
||||||
|
end
|
||||||
|
|
||||||
def register(conversation_id, pid) when is_binary(conversation_id) and is_pid(pid) do
|
def register(conversation_id, pid) when is_binary(conversation_id) and is_pid(pid) do
|
||||||
:ets.insert(table(), {conversation_id, pid})
|
:ets.insert(@table, {conversation_id, pid})
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
def unregister(conversation_id) when is_binary(conversation_id) do
|
def unregister(conversation_id) when is_binary(conversation_id) do
|
||||||
:ets.delete(table(), conversation_id)
|
:ets.delete(@table, conversation_id)
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
def lookup(conversation_id) when is_binary(conversation_id) do
|
def lookup(conversation_id) when is_binary(conversation_id) do
|
||||||
case :ets.lookup(table(), conversation_id) do
|
case :ets.lookup(@table, conversation_id) do
|
||||||
[{^conversation_id, pid}] -> pid
|
[{^conversation_id, pid}] -> pid
|
||||||
_other -> nil
|
_other -> nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp table do
|
|
||||||
case :ets.whereis(@table) do
|
|
||||||
:undefined -> :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
|
|
||||||
table -> table
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
42
lib/bds/ai/json_content.ex
Normal file
42
lib/bds/ai/json_content.ex
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
defmodule BDS.AI.JsonContent do
|
||||||
|
@moduledoc """
|
||||||
|
Decodes JSON object payloads from model responses, tolerating the markdown
|
||||||
|
code fences and surrounding prose that smaller (local) models often emit
|
||||||
|
instead of bare JSON.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@fence_pattern ~r/```(?:json)?\s*\n?(.*?)```/is
|
||||||
|
|
||||||
|
@spec decode(term()) :: map() | nil
|
||||||
|
def decode(content) when is_binary(content) do
|
||||||
|
decode_strict(content) || decode_fenced(content) || decode_embedded_object(content)
|
||||||
|
end
|
||||||
|
|
||||||
|
def decode(_content), do: nil
|
||||||
|
|
||||||
|
defp decode_strict(content) do
|
||||||
|
case Jason.decode(content) do
|
||||||
|
{:ok, decoded} when is_map(decoded) -> decoded
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_fenced(content) do
|
||||||
|
case Regex.run(@fence_pattern, content, capture: :all_but_first) do
|
||||||
|
[inner] -> decode_strict(String.trim(inner)) || decode_embedded_object(inner)
|
||||||
|
_no_fence -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_embedded_object(content) do
|
||||||
|
with {start, _length} <- :binary.match(content, "{"),
|
||||||
|
[{last, _} | _] <- content |> :binary.matches("}") |> Enum.take(-1),
|
||||||
|
true <- last > start do
|
||||||
|
content
|
||||||
|
|> binary_part(start, last - start + 1)
|
||||||
|
|> decode_strict()
|
||||||
|
else
|
||||||
|
_no_object -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,6 +4,7 @@ defmodule BDS.AI.OneShot do
|
|||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias BDS.AI.Chat
|
alias BDS.AI.Chat
|
||||||
|
alias BDS.AI.JsonContent
|
||||||
alias BDS.AI.OpenAICompatibleRuntime
|
alias BDS.AI.OpenAICompatibleRuntime
|
||||||
alias BDS.AI.Runtime
|
alias BDS.AI.Runtime
|
||||||
alias BDS.Media.Media
|
alias BDS.Media.Media
|
||||||
@@ -166,28 +167,13 @@ defmodule BDS.AI.OneShot do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp run_one_shot(:analyze_image = operation, payload, opts, formatter) do
|
|
||||||
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
|
||||||
|
|
||||||
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts),
|
|
||||||
:ok <- Runtime.validate_target(operation, model, mode),
|
|
||||||
{:ok, payload} <- resolve_image_data_url(payload),
|
|
||||||
request <- build_one_shot_request(operation, payload, model, opts),
|
|
||||||
{:ok, response} <-
|
|
||||||
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
|
|
||||||
{:ok, json} <- extract_json_response(response),
|
|
||||||
usage <- Chat.normalize_usage(response.usage),
|
|
||||||
{:ok, result} <- formatter.(json, usage) do
|
|
||||||
{:ok, result}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp run_one_shot(operation, payload, opts, formatter) do
|
defp run_one_shot(operation, payload, opts, formatter) do
|
||||||
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
||||||
|
|
||||||
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts),
|
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts),
|
||||||
:ok <- Runtime.validate_target(operation, model, mode),
|
:ok <- Runtime.validate_target(operation, model, mode),
|
||||||
request <- build_one_shot_request(operation, payload, model, opts),
|
{:ok, prepared_payload} <- prepare_one_shot_payload(operation, payload),
|
||||||
|
request <- build_one_shot_request(operation, prepared_payload, model, opts),
|
||||||
{:ok, response} <-
|
{:ok, response} <-
|
||||||
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
|
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
|
||||||
{:ok, json} <- extract_json_response(response),
|
{:ok, json} <- extract_json_response(response),
|
||||||
@@ -197,6 +183,9 @@ defmodule BDS.AI.OneShot do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp prepare_one_shot_payload(:analyze_image, payload), do: resolve_image_data_url(payload)
|
||||||
|
defp prepare_one_shot_payload(_operation, payload), do: {:ok, payload}
|
||||||
|
|
||||||
defp build_one_shot_request(operation, payload, model, opts) do
|
defp build_one_shot_request(operation, payload, model, opts) do
|
||||||
language = Keyword.get(opts, :language)
|
language = Keyword.get(opts, :language)
|
||||||
|
|
||||||
@@ -213,7 +202,9 @@ defmodule BDS.AI.OneShot do
|
|||||||
messages: [
|
messages: [
|
||||||
%{
|
%{
|
||||||
"role" => "system",
|
"role" => "system",
|
||||||
"content" => one_shot_system_prompt(operation, language, source_language)
|
"content" =>
|
||||||
|
one_shot_system_prompt(operation, language, source_language) <>
|
||||||
|
" Output raw JSON only, without markdown code fences."
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
"role" => "user",
|
"role" => "user",
|
||||||
@@ -351,11 +342,11 @@ defmodule BDS.AI.OneShot do
|
|||||||
defp extract_json_response(%{json: json}) when is_map(json), do: {:ok, json}
|
defp extract_json_response(%{json: json}) when is_map(json), do: {:ok, json}
|
||||||
|
|
||||||
defp extract_json_response(%{content: content}) when is_binary(content) do
|
defp extract_json_response(%{content: content}) when is_binary(content) do
|
||||||
case Jason.decode(content) do
|
case JsonContent.decode(content) do
|
||||||
{:ok, json} when is_map(json) ->
|
json when is_map(json) ->
|
||||||
{:ok, json}
|
{:ok, json}
|
||||||
|
|
||||||
_other ->
|
nil ->
|
||||||
Logger.error(
|
Logger.error(
|
||||||
"AI extract_json_response failed to parse content as JSON. Content: #{String.slice(content, 0, 1000)}"
|
"AI extract_json_response failed to parse content as JSON. Content: #{String.slice(content, 0, 1000)}"
|
||||||
)
|
)
|
||||||
@@ -406,7 +397,9 @@ defmodule BDS.AI.OneShot do
|
|||||||
title: media.title || "",
|
title: media.title || "",
|
||||||
alt: media.alt || "",
|
alt: media.alt || "",
|
||||||
caption: media.caption || "",
|
caption: media.caption || "",
|
||||||
image_url: Map.get(media, :image_url),
|
# A stored media row has no remote URL; resolve_image_data_url/1 fills
|
||||||
|
# this from file_path before an :analyze_image request is built.
|
||||||
|
image_url: nil,
|
||||||
file_path: media.file_path,
|
file_path: media.file_path,
|
||||||
project_id: media.project_id,
|
project_id: media.project_id,
|
||||||
language: media.language || ""
|
language: media.language || ""
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
|||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
alias BDS.AI.HttpClient
|
alias BDS.AI.HttpClient
|
||||||
|
alias BDS.AI.SSE
|
||||||
|
|
||||||
def list_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
|
def list_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
|
||||||
http_client = Keyword.get(opts, :http_client, HttpClient)
|
http_client = Keyword.get(opts, :http_client, HttpClient)
|
||||||
url = models_url(endpoint.url)
|
url = models_url(endpoint.url)
|
||||||
|
|
||||||
headers =
|
headers = request_headers(endpoint.api_key, false)
|
||||||
%{"accept" => "application/json"}
|
|
||||||
|> maybe_put_auth(endpoint.api_key)
|
|
||||||
|
|
||||||
with {:ok, response} <- http_client.get(url, headers),
|
with {:ok, response} <- http_client.get(url, headers),
|
||||||
200 <- response.status do
|
200 <- response.status do
|
||||||
@@ -22,25 +21,20 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def generate(endpoint, request, _opts) when is_map(endpoint) and is_map(request) do
|
def generate(endpoint, request, opts) when is_map(endpoint) and is_map(request) do
|
||||||
url = completions_url(endpoint.url)
|
url = completions_url(endpoint.url)
|
||||||
|
|
||||||
headers =
|
headers = request_headers(endpoint.api_key, true)
|
||||||
%{
|
payload = base_payload(request)
|
||||||
"content-type" => "application/json",
|
|
||||||
"accept" => "application/json"
|
|
||||||
}
|
|
||||||
|> maybe_put_auth(endpoint.api_key)
|
|
||||||
|
|
||||||
payload =
|
if stream?(request, opts) do
|
||||||
%{
|
generate_streaming(url, headers, payload, request, Keyword.fetch!(opts, :on_stream))
|
||||||
"model" => request.model,
|
else
|
||||||
"messages" => request.messages,
|
generate_blocking(url, headers, payload, request)
|
||||||
"max_tokens" => request.max_output_tokens
|
end
|
||||||
}
|
end
|
||||||
|> maybe_disable_thinking(request.model)
|
|
||||||
|> maybe_put_tools(Map.get(request, :tools, []))
|
|
||||||
|
|
||||||
|
defp generate_blocking(url, headers, payload, request) do
|
||||||
payload_json = Jason.encode!(payload)
|
payload_json = Jason.encode!(payload)
|
||||||
|
|
||||||
Logger.debug(
|
Logger.debug(
|
||||||
@@ -69,18 +63,83 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
|||||||
result
|
result
|
||||||
|
|
||||||
{:ok, %{status: status, body: body}} ->
|
{:ok, %{status: status, body: body}} ->
|
||||||
Logger.error(
|
log_http_status_error("AI OpenAI-compatible", status, body)
|
||||||
"AI OpenAI-compatible HTTP error status=#{status} body=#{String.slice(body, 0, 2000)}"
|
http_status_error(status, body)
|
||||||
)
|
|
||||||
|
|
||||||
{:error, %{kind: :http_error, status: status, body: body}}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
Logger.error("AI OpenAI-compatible HTTP request failed: #{inspect(reason)}")
|
log_http_request_error("AI OpenAI-compatible", reason)
|
||||||
{:error, %{kind: :http_error, reason: reason}}
|
http_request_error(reason)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Streaming variant: same request payload plus stream flags; SSE chunks are
|
||||||
|
# folded into a BDS.AI.SSE assembler that emits cumulative content
|
||||||
|
# snapshots to `on_stream` as they arrive. The assembled message goes
|
||||||
|
# through the same normalization as the blocking path.
|
||||||
|
defp generate_streaming(url, headers, payload, request, on_stream) do
|
||||||
|
payload_json = payload |> streaming_payload() |> Jason.encode!()
|
||||||
|
|
||||||
|
Logger.debug(
|
||||||
|
"AI OpenAI-compatible streaming request operation=#{inspect(Map.get(request, :operation))} model=#{inspect(request.model)} url=#{url} payload_size=#{byte_size(payload_json)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
sse = SSE.new(on_stream, emit_interval_ms: stream_emit_interval_ms())
|
||||||
|
|
||||||
|
case HttpClient.post_stream(url, headers, payload_json, sse, fn chunk, acc ->
|
||||||
|
SSE.feed(acc, chunk)
|
||||||
|
end) do
|
||||||
|
{:ok, %{status: 200, headers: response_headers}, sse} ->
|
||||||
|
if event_stream?(response_headers) do
|
||||||
|
assembled = SSE.finish(sse)
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
content: assembled.content,
|
||||||
|
json: decode_json_content(assembled.content),
|
||||||
|
tool_calls: normalize_tool_calls(assembled.tool_calls),
|
||||||
|
usage: normalize_usage(assembled.usage || %{})
|
||||||
|
}}
|
||||||
|
else
|
||||||
|
# The provider ignored the stream flag and sent a plain completion.
|
||||||
|
normalize_response(SSE.raw_body(sse))
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, %{status: status, body: body}, _sse} ->
|
||||||
|
log_http_status_error("AI OpenAI-compatible streaming", status, body)
|
||||||
|
http_status_error(status, body)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
log_http_request_error("AI OpenAI-compatible streaming", reason)
|
||||||
|
http_request_error(reason)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Streaming is opt-in per request (the caller passes :on_stream), limited
|
||||||
|
# to interactive chat, and can be disabled globally for providers that do
|
||||||
|
# not support SSE (config :bds, :chat, streaming: false).
|
||||||
|
defp stream?(request, opts) do
|
||||||
|
Map.get(request, :operation) == :chat and
|
||||||
|
is_function(Keyword.get(opts, :on_stream), 1) and
|
||||||
|
chat_config(:streaming, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp stream_emit_interval_ms, do: chat_config(:stream_emit_interval_ms, 100)
|
||||||
|
|
||||||
|
defp event_stream?(headers) do
|
||||||
|
case headers["content-type"] do
|
||||||
|
content_type when is_binary(content_type) ->
|
||||||
|
String.contains?(content_type, "text/event-stream")
|
||||||
|
|
||||||
|
_missing ->
|
||||||
|
# No content type: trust the request we made and parse as SSE.
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp chat_config(key, default) do
|
||||||
|
:bds |> Application.get_env(:chat, []) |> Keyword.get(key, default)
|
||||||
|
end
|
||||||
|
|
||||||
defp normalize_response(body) do
|
defp normalize_response(body) do
|
||||||
with {:ok, payload} <- decode_json_body(body) do
|
with {:ok, payload} <- decode_json_body(body) do
|
||||||
message = get_in(payload, ["choices", Access.at(0), "message"]) || %{}
|
message = get_in(payload, ["choices", Access.at(0), "message"]) || %{}
|
||||||
@@ -88,22 +147,18 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
|||||||
tool_calls = normalize_tool_calls(message["tool_calls"] || [])
|
tool_calls = normalize_tool_calls(message["tool_calls"] || [])
|
||||||
usage = normalize_usage(payload["usage"] || %{})
|
usage = normalize_usage(payload["usage"] || %{})
|
||||||
|
|
||||||
json =
|
{:ok,
|
||||||
case content do
|
%{
|
||||||
nil ->
|
content: content,
|
||||||
nil
|
json: decode_json_content(content),
|
||||||
|
tool_calls: tool_calls,
|
||||||
value when is_binary(value) ->
|
usage: usage
|
||||||
case Jason.decode(value) do
|
}}
|
||||||
{:ok, decoded} when is_map(decoded) -> decoded
|
|
||||||
_other -> nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
{:ok, %{content: content, json: json, tool_calls: tool_calls, usage: usage}}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp decode_json_content(content), do: BDS.AI.JsonContent.decode(content)
|
||||||
|
|
||||||
defp completions_url(url) do
|
defp completions_url(url) do
|
||||||
cond do
|
cond do
|
||||||
String.ends_with?(url, "/chat/completions") -> url
|
String.ends_with?(url, "/chat/completions") -> url
|
||||||
@@ -156,6 +211,44 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp request_headers(api_key, include_content_type?) do
|
||||||
|
%{"accept" => "application/json"}
|
||||||
|
|> maybe_put_content_type(include_content_type?)
|
||||||
|
|> maybe_put_auth(api_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_put_content_type(headers, true),
|
||||||
|
do: Map.put(headers, "content-type", "application/json")
|
||||||
|
|
||||||
|
defp maybe_put_content_type(headers, false), do: headers
|
||||||
|
|
||||||
|
defp base_payload(request) do
|
||||||
|
%{
|
||||||
|
"model" => request.model,
|
||||||
|
"messages" => request.messages,
|
||||||
|
"max_tokens" => request.max_output_tokens
|
||||||
|
}
|
||||||
|
|> maybe_disable_thinking(request.model)
|
||||||
|
|> maybe_put_tools(Map.get(request, :tools, []))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp streaming_payload(payload) do
|
||||||
|
payload
|
||||||
|
|> Map.put("stream", true)
|
||||||
|
|> Map.put("stream_options", %{"include_usage" => true})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp log_http_status_error(prefix, status, body) do
|
||||||
|
Logger.error("#{prefix} HTTP error status=#{status} body=#{String.slice(body, 0, 2000)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp log_http_request_error(prefix, reason) do
|
||||||
|
Logger.error("#{prefix} request failed: #{inspect(reason)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp http_status_error(status, body), do: {:error, %{kind: :http_error, status: status, body: body}}
|
||||||
|
defp http_request_error(reason), do: {:error, %{kind: :http_error, reason: reason}}
|
||||||
|
|
||||||
defp maybe_put_auth(headers, nil), do: headers
|
defp maybe_put_auth(headers, nil), do: headers
|
||||||
defp maybe_put_auth(headers, ""), do: headers
|
defp maybe_put_auth(headers, ""), do: headers
|
||||||
|
|
||||||
|
|||||||
@@ -107,5 +107,5 @@ defmodule BDS.AI.Runtime do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp blank?(value), do: value in [nil, ""]
|
defp blank?(value), do: BDS.Values.blank?(value)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,25 +1,95 @@
|
|||||||
defmodule BDS.AI.SecretBackend do
|
defmodule BDS.AI.SecretBackend do
|
||||||
@moduledoc false
|
@moduledoc """
|
||||||
|
Encrypts and decrypts AI provider secrets (AES-256-GCM) with the
|
||||||
|
machine-local master key resolved by `BDS.AI.SecretKey`.
|
||||||
|
|
||||||
|
Values written by earlier releases — encrypted with key material that
|
||||||
|
shipped in the repository, or with the deterministic node-name fallback —
|
||||||
|
are still readable: `decrypt/1` falls back to the legacy keys, and
|
||||||
|
`BDS.AI.SecretMigration` re-encrypts such rows at boot. When no master key
|
||||||
|
can be obtained, both operations return `{:error, :secret_key_unavailable}`
|
||||||
|
instead of degrading to a weaker key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias BDS.AI.SecretKey
|
||||||
|
|
||||||
@aad "bds-ai-secret"
|
@aad "bds-ai-secret"
|
||||||
|
|
||||||
|
# Key material shipped in the repository before TD-01. Retained only so
|
||||||
|
# existing user databases can be read and re-encrypted by
|
||||||
|
# BDS.AI.SecretMigration; remove both together in a future release.
|
||||||
|
@legacy_repo_key binary_part(
|
||||||
|
"bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001",
|
||||||
|
0,
|
||||||
|
32
|
||||||
|
)
|
||||||
|
|
||||||
|
@spec encrypt(String.t()) :: {:ok, String.t()} | {:error, term()}
|
||||||
def encrypt(value) when is_binary(value) do
|
def encrypt(value) when is_binary(value) do
|
||||||
key = secret_key()
|
with {:ok, key} <- secret_key() do
|
||||||
iv = :crypto.strong_rand_bytes(12)
|
iv = :crypto.strong_rand_bytes(12)
|
||||||
|
|
||||||
{ciphertext, tag} =
|
{ciphertext, tag} =
|
||||||
:crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, value, @aad, true)
|
:crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, value, @aad, true)
|
||||||
|
|
||||||
{:ok, Base.encode64(iv <> tag <> ciphertext)}
|
{:ok, Base.encode64(iv <> tag <> ciphertext)}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec decrypt(String.t()) :: {:ok, String.t()} | {:error, term()}
|
||||||
def decrypt(encoded) when is_binary(encoded) do
|
def decrypt(encoded) when is_binary(encoded) do
|
||||||
|
with {:ok, key} <- secret_key() do
|
||||||
|
decrypt_with_fallback(encoded, key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Decrypts strictly with the current master key — no legacy fallback. Used by
|
||||||
|
`BDS.AI.SecretMigration` to detect rows that still need re-encryption.
|
||||||
|
"""
|
||||||
|
@spec decrypt_with_current_key(String.t()) :: {:ok, String.t()} | {:error, term()}
|
||||||
|
def decrypt_with_current_key(encoded) when is_binary(encoded) do
|
||||||
|
with {:ok, key} <- secret_key() do
|
||||||
|
decrypt_with(encoded, key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Attempts decryption with the legacy keys used by earlier releases.
|
||||||
|
"""
|
||||||
|
@spec decrypt_legacy(String.t()) :: {:ok, String.t()} | {:error, :invalid_ciphertext}
|
||||||
|
def decrypt_legacy(encoded) when is_binary(encoded) do
|
||||||
|
Enum.find_value(legacy_keys(), {:error, :invalid_ciphertext}, fn key ->
|
||||||
|
case decrypt_with(encoded, key) do
|
||||||
|
{:ok, plaintext} -> {:ok, plaintext}
|
||||||
|
{:error, :invalid_ciphertext} -> nil
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp legacy_keys do
|
||||||
|
[
|
||||||
|
@legacy_repo_key,
|
||||||
|
:crypto.hash(:sha256, Atom.to_string(node()) <> ":bds:ai")
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decrypt_with_fallback(encoded, key) do
|
||||||
|
case decrypt_with(encoded, key) do
|
||||||
|
{:ok, _plaintext} = ok -> ok
|
||||||
|
{:error, :invalid_ciphertext} -> decrypt_legacy(encoded)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decrypt_with(encoded, key) do
|
||||||
with {:ok, binary} <- Base.decode64(encoded),
|
with {:ok, binary} <- Base.decode64(encoded),
|
||||||
<<iv::binary-size(12), tag::binary-size(16), ciphertext::binary>> <- binary,
|
<<iv::binary-size(12), tag::binary-size(16), ciphertext::binary>> <- binary,
|
||||||
plaintext when is_binary(plaintext) <-
|
plaintext when is_binary(plaintext) <-
|
||||||
:crypto.crypto_one_time_aead(
|
:crypto.crypto_one_time_aead(
|
||||||
:aes_256_gcm,
|
:aes_256_gcm,
|
||||||
secret_key(),
|
key,
|
||||||
iv,
|
iv,
|
||||||
ciphertext,
|
ciphertext,
|
||||||
@aad,
|
@aad,
|
||||||
@@ -33,9 +103,13 @@ defmodule BDS.AI.SecretBackend do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp secret_key do
|
defp secret_key do
|
||||||
case Application.get_env(:bds, :ai_secret_key) do
|
case SecretKey.fetch() do
|
||||||
key when is_binary(key) and byte_size(key) >= 32 -> binary_part(key, 0, 32)
|
{:ok, key} ->
|
||||||
_other -> :crypto.hash(:sha256, Atom.to_string(node()) <> ":bds:ai")
|
{:ok, key}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("AI secret key unavailable: #{inspect(reason)}")
|
||||||
|
{:error, :secret_key_unavailable}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
232
lib/bds/ai/secret_key.ex
Normal file
232
lib/bds/ai/secret_key.ex
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
defmodule BDS.AI.SecretKey do
|
||||||
|
@moduledoc """
|
||||||
|
Resolves the 32-byte machine-local master key that encrypts AI provider
|
||||||
|
secrets at rest (the `SecureKeyStore` entity in `specs/ai.allium`).
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
|
||||||
|
1. `config :bds, :ai_secret_key` — explicit override. Used by the test
|
||||||
|
suite for determinism; must be at least 32 bytes (the first 32 are
|
||||||
|
used). An invalid value is an error, never a silent fallback.
|
||||||
|
2. A previously resolved key cached in `:persistent_term`.
|
||||||
|
3. The OS keyring: on macOS the login Keychain via the `security` CLI; on
|
||||||
|
other platforms — or when the Keychain is unavailable — a random key
|
||||||
|
file under the private app dir, written with `0600` permissions.
|
||||||
|
|
||||||
|
A fresh random key is generated and stored on first use. There is no
|
||||||
|
deterministic fallback: when no key can be obtained or persisted, `fetch/0`
|
||||||
|
returns `{:error, reason}` and secret encryption/decryption fails loudly
|
||||||
|
instead of degrading to obfuscation.
|
||||||
|
|
||||||
|
Config (`config :bds, BDS.AI.SecretKey`):
|
||||||
|
|
||||||
|
* `:strategy` — `:auto` (default; Keychain on macOS, key file elsewhere),
|
||||||
|
`:keychain`, or `:file`
|
||||||
|
* `:key_file_path` — overrides the key file location
|
||||||
|
* `:command_runner` — 3-arity replacement for `System.cmd/3` (tests)
|
||||||
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@key_bytes 32
|
||||||
|
@cache_key {__MODULE__, :key}
|
||||||
|
@keychain_service "bDS2"
|
||||||
|
@keychain_account "ai-secret-key"
|
||||||
|
@keychain_not_found_status 44
|
||||||
|
@key_file_name "ai_secret.key"
|
||||||
|
|
||||||
|
@spec fetch() :: {:ok, binary()} | {:error, term()}
|
||||||
|
def fetch do
|
||||||
|
case configured_key() do
|
||||||
|
{:ok, key} -> {:ok, key}
|
||||||
|
{:error, _detail} = error -> error
|
||||||
|
:unset -> cached_or_resolve()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Clears the cached key so the next fetch re-resolves it (test helper)."
|
||||||
|
@spec reset_cache() :: :ok
|
||||||
|
def reset_cache do
|
||||||
|
:persistent_term.erase(@cache_key)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp configured_key do
|
||||||
|
case Application.get_env(:bds, :ai_secret_key) do
|
||||||
|
nil ->
|
||||||
|
:unset
|
||||||
|
|
||||||
|
key when is_binary(key) and byte_size(key) >= @key_bytes ->
|
||||||
|
{:ok, binary_part(key, 0, @key_bytes)}
|
||||||
|
|
||||||
|
other ->
|
||||||
|
{:error, {:invalid_configured_key, "expected a binary of at least #{@key_bytes} bytes, got: #{inspect(other)}"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cached_or_resolve do
|
||||||
|
case :persistent_term.get(@cache_key, nil) do
|
||||||
|
key when is_binary(key) ->
|
||||||
|
{:ok, key}
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
with {:ok, key} <- resolve() do
|
||||||
|
:persistent_term.put(@cache_key, key)
|
||||||
|
{:ok, key}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve do
|
||||||
|
case strategy() do
|
||||||
|
:keychain -> resolve_keychain()
|
||||||
|
:file -> resolve_file()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp strategy do
|
||||||
|
case config(:strategy, :auto) do
|
||||||
|
:auto ->
|
||||||
|
if match?({:unix, :darwin}, :os.type()), do: :keychain, else: :file
|
||||||
|
|
||||||
|
explicit when explicit in [:keychain, :file] ->
|
||||||
|
explicit
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ─── macOS Keychain ─────────────────────────────────────────
|
||||||
|
|
||||||
|
defp resolve_keychain do
|
||||||
|
case keychain_find() do
|
||||||
|
{:ok, key} ->
|
||||||
|
{:ok, key}
|
||||||
|
|
||||||
|
:not_found ->
|
||||||
|
keychain_create()
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
fallback_to_key_file("AI secret key: macOS Keychain unavailable", inspect(reason))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp keychain_find do
|
||||||
|
case run_security([
|
||||||
|
"find-generic-password",
|
||||||
|
"-s",
|
||||||
|
@keychain_service,
|
||||||
|
"-a",
|
||||||
|
@keychain_account,
|
||||||
|
"-w"
|
||||||
|
]) do
|
||||||
|
{output, 0} ->
|
||||||
|
case output |> String.trim() |> Base.decode64() do
|
||||||
|
{:ok, key} when byte_size(key) == @key_bytes -> {:ok, key}
|
||||||
|
_other -> {:error, :corrupt_keychain_item}
|
||||||
|
end
|
||||||
|
|
||||||
|
{_output, @keychain_not_found_status} ->
|
||||||
|
:not_found
|
||||||
|
|
||||||
|
{output, status} ->
|
||||||
|
{:error, {:security_failed, status, String.trim(output)}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# The generated key passes through `security`'s argv, which is briefly
|
||||||
|
# visible to other local processes of the same user. Accepted trade-off for
|
||||||
|
# a single-user desktop app; `security` offers no non-interactive way to
|
||||||
|
# take the password on stdin in one shot.
|
||||||
|
defp keychain_create do
|
||||||
|
key = :crypto.strong_rand_bytes(@key_bytes)
|
||||||
|
|
||||||
|
case run_security([
|
||||||
|
"add-generic-password",
|
||||||
|
"-U",
|
||||||
|
"-s",
|
||||||
|
@keychain_service,
|
||||||
|
"-a",
|
||||||
|
@keychain_account,
|
||||||
|
"-w",
|
||||||
|
Base.encode64(key)
|
||||||
|
]) do
|
||||||
|
{_output, 0} ->
|
||||||
|
{:ok, key}
|
||||||
|
|
||||||
|
{output, status} ->
|
||||||
|
fallback_to_key_file(
|
||||||
|
"AI secret key: could not store the key in the Keychain",
|
||||||
|
"status #{status}: #{String.trim(output)}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run_security(args) do
|
||||||
|
runner = config(:command_runner, &default_runner/3)
|
||||||
|
runner.("security", args, stderr_to_stdout: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp default_runner(command, args, opts) do
|
||||||
|
System.cmd(command, args, opts)
|
||||||
|
rescue
|
||||||
|
error in ErlangError -> {"#{command} unavailable: #{inspect(error.original)}", 127}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ─── Key file ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp resolve_file do
|
||||||
|
path = key_file_path()
|
||||||
|
|
||||||
|
case File.read(path) do
|
||||||
|
{:ok, contents} -> decode_key_file(contents, path)
|
||||||
|
{:error, :enoent} -> create_key_file(path)
|
||||||
|
{:error, reason} -> {:error, {:key_file_unreadable, path, reason}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_key_file(contents, path) do
|
||||||
|
case contents |> String.trim() |> Base.decode64() do
|
||||||
|
{:ok, key} when byte_size(key) == @key_bytes -> {:ok, key}
|
||||||
|
_other -> {:error, {:key_file_corrupt, path}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_key_file(path) do
|
||||||
|
key = :crypto.strong_rand_bytes(@key_bytes)
|
||||||
|
temp_path = path <> ".tmp." <> Integer.to_string(System.unique_integer([:positive]))
|
||||||
|
|
||||||
|
with :ok <- File.mkdir_p(Path.dirname(path)),
|
||||||
|
:ok <- File.write(temp_path, Base.encode64(key) <> "\n"),
|
||||||
|
:ok <- File.chmod(temp_path, 0o600),
|
||||||
|
:ok <- File.rename(temp_path, path) do
|
||||||
|
{:ok, key}
|
||||||
|
else
|
||||||
|
{:error, reason} ->
|
||||||
|
_ = File.rm(temp_path)
|
||||||
|
{:error, {:key_file_write_failed, path, reason}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp key_file_path do
|
||||||
|
config(:key_file_path, nil) || Path.join(private_app_dir(), @key_file_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Same private app dir as BDS.Projects.private_app_dir/0 — on macOS
|
||||||
|
# ~/Library/Application Support/BDS2. Duplicated to keep this module free of
|
||||||
|
# project/DB dependencies.
|
||||||
|
defp private_app_dir do
|
||||||
|
case :filename.basedir(:user_config, "BDS2") do
|
||||||
|
path when is_list(path) -> List.to_string(path)
|
||||||
|
path -> path
|
||||||
|
end
|
||||||
|
|> Path.expand()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp config(key, default) do
|
||||||
|
Application.get_env(:bds, __MODULE__, []) |> Keyword.get(key, default)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fallback_to_key_file(prefix, detail) do
|
||||||
|
Logger.warning(prefix <> " (" <> detail <> "); falling back to the key file")
|
||||||
|
resolve_file()
|
||||||
|
end
|
||||||
|
end
|
||||||
67
lib/bds/ai/secret_migration.ex
Normal file
67
lib/bds/ai/secret_migration.ex
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
defmodule BDS.AI.SecretMigration do
|
||||||
|
@moduledoc """
|
||||||
|
Idempotent boot-time re-encryption of stored AI secrets.
|
||||||
|
|
||||||
|
Earlier releases encrypted secrets with key material shipped in the
|
||||||
|
repository (or a deterministic node-name fallback). This pass finds the
|
||||||
|
`__encrypted_*` rows in `settings`, decrypts them with the legacy keys, and
|
||||||
|
re-encrypts them with the machine-local key from `BDS.AI.SecretKey`. Rows
|
||||||
|
already encrypted with the current key are left untouched; rows no known
|
||||||
|
key can decrypt are left in place and reported, so the user can re-enter
|
||||||
|
the secret. Runs from `BDS.RepoBootstrap` on every boot; on a migrated
|
||||||
|
database it is a cheap no-op.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias BDS.AI.SecretBackend
|
||||||
|
alias BDS.Persistence
|
||||||
|
alias BDS.Repo
|
||||||
|
alias BDS.Settings.Setting
|
||||||
|
|
||||||
|
@encrypted_prefix "__encrypted_"
|
||||||
|
|
||||||
|
@spec migrate_legacy_secrets(module()) ::
|
||||||
|
{:ok, %{migrated: non_neg_integer(), failed: non_neg_integer()}}
|
||||||
|
def migrate_legacy_secrets(repo \\ Repo) do
|
||||||
|
summary =
|
||||||
|
from(setting in Setting, where: like(setting.key, ^"#{@encrypted_prefix}%"))
|
||||||
|
|> repo.all()
|
||||||
|
|> Enum.reduce(%{migrated: 0, failed: 0}, fn setting, acc ->
|
||||||
|
case migrate_row(repo, setting) do
|
||||||
|
:current -> acc
|
||||||
|
:migrated -> %{acc | migrated: acc.migrated + 1}
|
||||||
|
:failed -> %{acc | failed: acc.failed + 1}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, summary}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp migrate_row(repo, setting) do
|
||||||
|
with {:error, _no_current_key_match} <- SecretBackend.decrypt_with_current_key(setting.value),
|
||||||
|
{:ok, plaintext} <- SecretBackend.decrypt_legacy(setting.value),
|
||||||
|
{:ok, reencrypted} <- SecretBackend.encrypt(plaintext) do
|
||||||
|
repo.update_all(
|
||||||
|
from(s in Setting, where: s.key == ^setting.key),
|
||||||
|
set: [value: reencrypted, updated_at: Persistence.now_ms()]
|
||||||
|
)
|
||||||
|
|
||||||
|
Logger.info("AI secret #{setting.key} re-encrypted with the machine-local key")
|
||||||
|
:migrated
|
||||||
|
else
|
||||||
|
{:ok, _already_current} ->
|
||||||
|
:current
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning(
|
||||||
|
"AI secret #{setting.key} could not be re-encrypted (#{inspect(reason)}); " <>
|
||||||
|
"leaving it unchanged — the secret may need to be entered again"
|
||||||
|
)
|
||||||
|
|
||||||
|
:failed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
176
lib/bds/ai/sse.ex
Normal file
176
lib/bds/ai/sse.ex
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
defmodule BDS.AI.SSE do
|
||||||
|
@moduledoc """
|
||||||
|
Incremental assembler for OpenAI-compatible `text/event-stream` chat
|
||||||
|
completions.
|
||||||
|
|
||||||
|
Fed raw transport chunks via `feed/2`, it buffers partial events, decodes
|
||||||
|
`data:` payloads, and accumulates content deltas, tool-call fragments, and
|
||||||
|
usage. Content is reported to the optional `on_event` callback as
|
||||||
|
**cumulative snapshots** (`%{content: binary}`) — replace semantics, which
|
||||||
|
matches how the chat editor renders streaming state and resets naturally
|
||||||
|
between tool rounds. Emissions are throttled to `:emit_interval_ms`
|
||||||
|
(the first delta always emits immediately for perceived latency).
|
||||||
|
|
||||||
|
`finish/1` returns the assembled message in OpenAI wire shape so the
|
||||||
|
runtime can reuse its non-streaming normalization:
|
||||||
|
`%{content: binary | nil, tool_calls: [%{"id" => _, "function" => %{"name" => _, "arguments" => json_string}}], usage: map | nil}`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
defstruct buffer: "",
|
||||||
|
raw: [],
|
||||||
|
content: [],
|
||||||
|
content?: false,
|
||||||
|
tool_calls: %{},
|
||||||
|
usage: nil,
|
||||||
|
done?: false,
|
||||||
|
on_event: nil,
|
||||||
|
emit_interval_ms: 100,
|
||||||
|
last_emit_at: nil
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
|
@spec new((map() -> any()) | nil, keyword()) :: t()
|
||||||
|
def new(on_event \\ nil, opts \\ []) when is_list(opts) do
|
||||||
|
%__MODULE__{
|
||||||
|
on_event: on_event,
|
||||||
|
emit_interval_ms: Keyword.get(opts, :emit_interval_ms, 100)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec feed(t(), binary()) :: t()
|
||||||
|
def feed(%__MODULE__{done?: true} = sse, _chunk), do: sse
|
||||||
|
|
||||||
|
def feed(%__MODULE__{} = sse, chunk) when is_binary(chunk) do
|
||||||
|
sse = %{sse | raw: [chunk | sse.raw]}
|
||||||
|
parts = String.split(sse.buffer <> chunk, ~r/\r?\n\r?\n/)
|
||||||
|
{complete_events, [rest]} = Enum.split(parts, -1)
|
||||||
|
|
||||||
|
Enum.reduce(complete_events, %{sse | buffer: rest}, &process_event(&2, &1))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
The unparsed transport bytes, for callers that discover after the fact
|
||||||
|
that the response was not an event stream (e.g. a provider that ignored
|
||||||
|
the `stream` flag and answered with plain JSON).
|
||||||
|
"""
|
||||||
|
@spec raw_body(t()) :: binary()
|
||||||
|
def raw_body(%__MODULE__{} = sse) do
|
||||||
|
sse.raw |> Enum.reverse() |> IO.iodata_to_binary()
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec finish(t()) :: %{content: binary() | nil, tool_calls: [map()], usage: map() | nil}
|
||||||
|
def finish(%__MODULE__{} = sse) do
|
||||||
|
# A final event may arrive without its trailing blank line.
|
||||||
|
sse =
|
||||||
|
case String.trim(sse.buffer) do
|
||||||
|
"" -> sse
|
||||||
|
remnant -> process_event(%{sse | buffer: ""}, remnant)
|
||||||
|
end
|
||||||
|
|
||||||
|
%{
|
||||||
|
content: assembled_content(sse),
|
||||||
|
tool_calls: assembled_tool_calls(sse),
|
||||||
|
usage: sse.usage
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp process_event(%{done?: true} = sse, _event), do: sse
|
||||||
|
|
||||||
|
defp process_event(sse, event) do
|
||||||
|
data =
|
||||||
|
event
|
||||||
|
|> String.split(~r/\r?\n/)
|
||||||
|
|> Enum.flat_map(&data_line/1)
|
||||||
|
|> Enum.join("\n")
|
||||||
|
|
||||||
|
cond do
|
||||||
|
data == "" ->
|
||||||
|
sse
|
||||||
|
|
||||||
|
String.trim(data) == "[DONE]" ->
|
||||||
|
%{sse | done?: true}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
case Jason.decode(data) do
|
||||||
|
{:ok, payload} when is_map(payload) -> apply_payload(sse, payload)
|
||||||
|
_other -> sse
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp data_line("data: " <> rest), do: [rest]
|
||||||
|
defp data_line("data:" <> rest), do: [rest]
|
||||||
|
defp data_line(_line), do: []
|
||||||
|
|
||||||
|
defp apply_payload(sse, payload) do
|
||||||
|
delta = get_in(payload, ["choices", Access.at(0), "delta"]) || %{}
|
||||||
|
|
||||||
|
sse
|
||||||
|
|> apply_content(delta["content"])
|
||||||
|
|> apply_tool_calls(delta["tool_calls"])
|
||||||
|
|> apply_usage(payload["usage"])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_content(sse, content) when is_binary(content) and content != "" do
|
||||||
|
%{sse | content: [content | sse.content], content?: true}
|
||||||
|
|> maybe_emit()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_content(sse, _content), do: sse
|
||||||
|
|
||||||
|
defp apply_tool_calls(sse, [_ | _] = fragments) do
|
||||||
|
Enum.reduce(fragments, sse, fn fragment, acc ->
|
||||||
|
index = fragment["index"] || 0
|
||||||
|
existing = Map.get(acc.tool_calls, index, %{id: nil, name: nil, arguments: []})
|
||||||
|
function_part = fragment["function"] || %{}
|
||||||
|
|
||||||
|
merged = %{
|
||||||
|
id: existing.id || fragment["id"],
|
||||||
|
name: existing.name || function_part["name"],
|
||||||
|
arguments: [existing.arguments, function_part["arguments"] || ""]
|
||||||
|
}
|
||||||
|
|
||||||
|
%{acc | tool_calls: Map.put(acc.tool_calls, index, merged)}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_tool_calls(sse, _fragments), do: sse
|
||||||
|
|
||||||
|
defp apply_usage(sse, usage) when is_map(usage) and map_size(usage) > 0,
|
||||||
|
do: %{sse | usage: usage}
|
||||||
|
|
||||||
|
defp apply_usage(sse, _usage), do: sse
|
||||||
|
|
||||||
|
defp maybe_emit(%{on_event: nil} = sse), do: sse
|
||||||
|
|
||||||
|
defp maybe_emit(sse) do
|
||||||
|
now = System.monotonic_time(:millisecond)
|
||||||
|
|
||||||
|
if is_nil(sse.last_emit_at) or now - sse.last_emit_at >= sse.emit_interval_ms do
|
||||||
|
sse.on_event.(%{content: assembled_content(sse) || ""})
|
||||||
|
%{sse | last_emit_at: now}
|
||||||
|
else
|
||||||
|
sse
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assembled_content(%{content?: false}), do: nil
|
||||||
|
|
||||||
|
defp assembled_content(sse) do
|
||||||
|
sse.content |> Enum.reverse() |> IO.iodata_to_binary()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assembled_tool_calls(sse) do
|
||||||
|
sse.tool_calls
|
||||||
|
|> Enum.sort_by(fn {index, _tool_call} -> index end)
|
||||||
|
|> Enum.map(fn {_index, tool_call} ->
|
||||||
|
%{
|
||||||
|
"id" => tool_call.id,
|
||||||
|
"function" => %{
|
||||||
|
"name" => tool_call.name,
|
||||||
|
"arguments" => IO.iodata_to_binary(tool_call.arguments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -5,6 +5,27 @@ defmodule BDS.Application do
|
|||||||
|
|
||||||
@compiled_env Application.compile_env(:bds, :current_env, Mix.env())
|
@compiled_env Application.compile_env(:bds, :current_env, Mix.env())
|
||||||
|
|
||||||
|
# Children that depend on the boot mode (issue #26): desktop mode runs the
|
||||||
|
# wx window shell, server mode runs headless behind the SSH daemon. Both
|
||||||
|
# start the loopback HTTP endpoint and the CLI sync watcher.
|
||||||
|
def mode_children(:server, _env) do
|
||||||
|
[{BDS.Desktop.Server, []}, BDS.CliSync.Watcher, BDS.Server]
|
||||||
|
end
|
||||||
|
|
||||||
|
# CLI mode (issue #25): a one-shot command run against the shared database.
|
||||||
|
# No HTTP listener, SSH daemon, window, or sync watcher — the CLI is the
|
||||||
|
# external writer the watcher in a concurrently running app listens for.
|
||||||
|
def mode_children(:cli, _env), do: []
|
||||||
|
|
||||||
|
# Local TUI: the headless server plus a TUI on the launching terminal, so
|
||||||
|
# a GUI (via tunnel) and the local terminal can work in parallel. Quitting
|
||||||
|
# this TUI stops the whole VM — the terminal is the app in this mode.
|
||||||
|
# SSH-served TUI sessions never get this flag and stay session-local.
|
||||||
|
def mode_children(:tui, env),
|
||||||
|
do: mode_children(:server, env) ++ [{BDS.TUI, [stop_vm_on_exit: true]}]
|
||||||
|
|
||||||
|
def mode_children(:desktop, env), do: desktop_children(env)
|
||||||
|
|
||||||
def desktop_children(env \\ nil)
|
def desktop_children(env \\ nil)
|
||||||
|
|
||||||
def desktop_children(:test) do
|
def desktop_children(:test) do
|
||||||
@@ -25,34 +46,62 @@ defmodule BDS.Application do
|
|||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
children = [
|
children =
|
||||||
{Phoenix.PubSub, name: BDS.PubSub},
|
[
|
||||||
{BDS.Desktop.Endpoint, secret_key_base: desktop_secret_key_base()},
|
{Phoenix.PubSub, name: BDS.PubSub},
|
||||||
BDS.Repo,
|
{BDS.Desktop.Endpoint, secret_key_base: desktop_secret_key_base()},
|
||||||
BDS.RepoBootstrap,
|
BDS.Repo,
|
||||||
BDS.Tasks,
|
BDS.RepoBootstrap,
|
||||||
BDS.Preview,
|
BDS.Tasks,
|
||||||
BDS.Publishing,
|
BDS.AI.InFlight,
|
||||||
{Task.Supervisor, name: BDS.Tasks.TaskSupervisor},
|
BDS.Preview,
|
||||||
{Task.Supervisor, name: BDS.TCP.TaskSupervisor},
|
BDS.Publishing,
|
||||||
BDS.Scripting.JobStore,
|
{Task.Supervisor, name: BDS.Tasks.TaskSupervisor},
|
||||||
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
{Task.Supervisor, name: BDS.TCP.TaskSupervisor},
|
||||||
BDS.Scripting.JobSupervisor
|
BDS.Scripting.JobStore,
|
||||||
| desktop_children(current_env())
|
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
||||||
]
|
BDS.Scripting.JobSupervisor,
|
||||||
|
BDS.Embeddings.Index
|
||||||
|
] ++ embedding_children() ++ mode_children(boot_mode(), current_env())
|
||||||
|
|
||||||
opts = [strategy: :one_for_one, name: BDS.Supervisor]
|
opts = [strategy: :one_for_one, name: BDS.Supervisor]
|
||||||
Supervisor.start_link(children, opts)
|
Supervisor.start_link(children, opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The neural embedding backend runs as a supervised, lazily-initialised
|
||||||
|
# GenServer (it loads the model only on the first embedding request). Only
|
||||||
|
# start it when it is the configured backend.
|
||||||
|
defp embedding_children do
|
||||||
|
case Application.get_env(:bds, :embeddings, [])[:backend] do
|
||||||
|
BDS.Embeddings.Backends.Neural -> [BDS.Embeddings.Backends.Neural]
|
||||||
|
_other -> []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp current_env do
|
defp current_env do
|
||||||
Application.get_env(:bds, :current_env_override) || @compiled_env
|
Application.get_env(:bds, :current_env_override) || @compiled_env
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Desktop mode without a graphical display boots the TUI instead
|
||||||
|
# (issue #33); the log line explains the surprise mode switch.
|
||||||
|
defp boot_mode do
|
||||||
|
resolved = BDS.Server.mode()
|
||||||
|
effective = BDS.Server.effective_mode(resolved)
|
||||||
|
|
||||||
|
if effective != resolved do
|
||||||
|
require Logger
|
||||||
|
Logger.info("No graphical display available, starting the terminal UI instead")
|
||||||
|
end
|
||||||
|
|
||||||
|
effective
|
||||||
|
end
|
||||||
|
|
||||||
defp desktop_window_children do
|
defp desktop_window_children do
|
||||||
if desktop_automation?() do
|
if desktop_automation?() do
|
||||||
[]
|
[]
|
||||||
else
|
else
|
||||||
|
disable_auto_window_menu()
|
||||||
|
|
||||||
window_opts =
|
window_opts =
|
||||||
BDS.Desktop.MainWindow.window_options(
|
BDS.Desktop.MainWindow.window_options(
|
||||||
menubar: BDS.Desktop.MenuBar,
|
menubar: BDS.Desktop.MenuBar,
|
||||||
@@ -62,17 +111,33 @@ defmodule BDS.Application do
|
|||||||
|
|
||||||
[
|
[
|
||||||
{Desktop.Window, window_opts},
|
{Desktop.Window, window_opts},
|
||||||
Supervisor.child_spec({BDS.Desktop.MainWindow, []}, id: BDS.Desktop.MainWindow.Watcher)
|
Supervisor.child_spec({BDS.Desktop.MainWindow, []}, id: BDS.Desktop.MainWindow.Watcher),
|
||||||
|
{BDS.Desktop.DeepLink, []},
|
||||||
|
{BDS.Desktop.RemoteConnection, []}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# wxOSX injects an untracked "Window" menu into the menubar at install time,
|
||||||
|
# which races elixir-desktop's async menu populate and ends up at an
|
||||||
|
# unstable position. We disable it and ship our own :window menu group
|
||||||
|
# (BDS.UI.MenuBar) placed between Blog and Help.
|
||||||
|
defp disable_auto_window_menu do
|
||||||
|
if :os.type() == {:unix, :darwin} do
|
||||||
|
:wx.set_env(Desktop.Env.wx_env())
|
||||||
|
:wxMenuBar.setAutoWindowMenu(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp desktop_automation? do
|
defp desktop_automation? do
|
||||||
System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"]
|
System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The desktop endpoint binds to loopback only and its sessions do not need
|
||||||
|
# to survive restarts, so without explicit config the signing secret is a
|
||||||
|
# random per-boot value rather than a static one shipped in the repo.
|
||||||
defp desktop_secret_key_base do
|
defp desktop_secret_key_base do
|
||||||
Application.get_env(:bds, :desktop)[:secret_key_base] ||
|
Application.get_env(:bds, :desktop)[:secret_key_base] ||
|
||||||
raise "missing :desktop secret_key_base configuration"
|
Base.encode64(:crypto.strong_rand_bytes(48))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
262
lib/bds/blogmark.ex
Normal file
262
lib/bds/blogmark.ex
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
defmodule BDS.Blogmark do
|
||||||
|
@moduledoc """
|
||||||
|
Receives `bds2://new-post` blogmark deep links and turns them into draft posts
|
||||||
|
(spec: script.allium `BlogmarkReceived`/`ExecuteTransform`,
|
||||||
|
editor_settings.allium `BookmarkletCopy`).
|
||||||
|
|
||||||
|
The browser bookmarklet (`BDS.Scripting.Capabilities.AppShell`) navigates to a
|
||||||
|
`bds2://new-post?title=&url=` URL. The desktop layer hands that URL here, where
|
||||||
|
it is parsed into a post candidate, run through the enabled transform pipeline
|
||||||
|
(`BDS.Scripts.Transforms`), and finally persisted as a draft post — defaulting
|
||||||
|
the category from the project's `blogmark_category` setting when neither the
|
||||||
|
link nor a transform supplied one.
|
||||||
|
|
||||||
|
The `bds2://` scheme deliberately differs from the legacy app's `bds://` scheme
|
||||||
|
so the two installs do not fight over the same registration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias BDS.Metadata
|
||||||
|
alias BDS.Posts
|
||||||
|
alias BDS.Scripts.Transforms
|
||||||
|
|
||||||
|
@scheme "bds2"
|
||||||
|
@new_post_action "new-post"
|
||||||
|
|
||||||
|
# Input-hardening limits mirrored from the legacy bDS app (shared/blogmark.ts).
|
||||||
|
@max_title_length 200
|
||||||
|
@max_url_length 2048
|
||||||
|
@control_chars ~r/[\x00-\x1F\x7F]/
|
||||||
|
|
||||||
|
@type candidate :: %{required(String.t()) => term()}
|
||||||
|
|
||||||
|
@type receive_result :: %{
|
||||||
|
post: Posts.Post.t(),
|
||||||
|
toasts: [String.t()],
|
||||||
|
errors: [%{slug: String.t() | nil, reason: term()}]
|
||||||
|
}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Parses a `bds2://new-post` deep link into a post candidate map.
|
||||||
|
|
||||||
|
Returns `{:ok, candidate}` with string-keyed `title`, `url`, `content`,
|
||||||
|
`tags`, `categories` and optional `project_id`, or an error when the scheme
|
||||||
|
or action is unsupported.
|
||||||
|
"""
|
||||||
|
@spec parse_deep_link(String.t()) ::
|
||||||
|
{:ok, candidate()} | {:error, :unsupported_scheme | :unsupported_action | :invalid_url}
|
||||||
|
def parse_deep_link(url) when is_binary(url) do
|
||||||
|
case URI.parse(url) do
|
||||||
|
%URI{scheme: @scheme, host: @new_post_action, query: query} ->
|
||||||
|
{:ok, candidate_from_query(query)}
|
||||||
|
|
||||||
|
%URI{scheme: @scheme} ->
|
||||||
|
{:error, :unsupported_action}
|
||||||
|
|
||||||
|
%URI{scheme: nil} ->
|
||||||
|
{:error, :invalid_url}
|
||||||
|
|
||||||
|
%URI{} ->
|
||||||
|
{:error, :unsupported_scheme}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the `project_id` a deep link targets, or `nil` when the link carries
|
||||||
|
none or cannot be parsed. Used by the shell to switch to the link's project
|
||||||
|
before importing.
|
||||||
|
"""
|
||||||
|
@spec deep_link_project_id(String.t()) :: String.t() | nil
|
||||||
|
def deep_link_project_id(url) when is_binary(url) do
|
||||||
|
case parse_deep_link(url) do
|
||||||
|
{:ok, %{"project_id" => project_id}} -> project_id
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Receives a blogmark deep link for `project_id`: parses it, runs the transform
|
||||||
|
pipeline, and creates a draft post from the resulting candidate.
|
||||||
|
|
||||||
|
When the deep link URL carries a `project_id` query parameter, it takes
|
||||||
|
precedence over the passed `project_id` so the bookmarklet can target a
|
||||||
|
specific project regardless of which project is currently active.
|
||||||
|
|
||||||
|
Returns `{:ok, %{post:, toasts:, errors:}}` where `toasts` are the
|
||||||
|
budget-enforced transform messages and `errors` records any failed transforms.
|
||||||
|
"""
|
||||||
|
@spec receive_deep_link(String.t(), String.t(), keyword()) ::
|
||||||
|
{:ok, receive_result()} | {:error, term()}
|
||||||
|
def receive_deep_link(project_id, url, opts \\ [])
|
||||||
|
when is_binary(project_id) and is_binary(url) and is_list(opts) do
|
||||||
|
with {:ok, candidate} <- parse_deep_link(url),
|
||||||
|
target_project <- resolve_target_project(project_id, candidate),
|
||||||
|
candidate <- apply_default_content(candidate),
|
||||||
|
{:ok, %{data: data, toasts: toasts, errors: errors}} <-
|
||||||
|
Transforms.run(target_project, candidate, opts),
|
||||||
|
data <- apply_default_category(target_project, data),
|
||||||
|
{:ok, post} <- create_draft(target_project, data) do
|
||||||
|
{:ok, %{post: post, toasts: toasts, errors: errors}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp candidate_from_query(query) do
|
||||||
|
params = URI.decode_query(query || "")
|
||||||
|
|
||||||
|
project_id =
|
||||||
|
case Map.get(params, "project_id") do
|
||||||
|
nil -> nil
|
||||||
|
"" -> nil
|
||||||
|
id -> id
|
||||||
|
end
|
||||||
|
|
||||||
|
url = sanitize_http_url(Map.get(params, "url"))
|
||||||
|
|
||||||
|
%{
|
||||||
|
"title" => sanitize_title(Map.get(params, "title"), url_host(url)),
|
||||||
|
"url" => url,
|
||||||
|
"content" => optional(params, "content"),
|
||||||
|
"tags" => list_param(params, "tags"),
|
||||||
|
"categories" => list_param(params, "categories"),
|
||||||
|
"project_id" => project_id
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Strip control characters, trim, cap length; fall back to the URL host when
|
||||||
|
# blank — mirrors bDS sanitizeTitle. Title/url are single-line fields, so
|
||||||
|
# control-character stripping is safe here (unlike the multi-line body).
|
||||||
|
defp sanitize_title(raw, fallback_host) do
|
||||||
|
title =
|
||||||
|
raw
|
||||||
|
|> to_string()
|
||||||
|
|> strip_control()
|
||||||
|
|> String.trim()
|
||||||
|
|> String.slice(0, @max_title_length)
|
||||||
|
|
||||||
|
if title == "", do: fallback_host || "", else: title
|
||||||
|
end
|
||||||
|
|
||||||
|
# Accept only http(s) URLs, strip credentials and fragment, enforce a length
|
||||||
|
# cap; anything else collapses to nil so it can never reach the post body.
|
||||||
|
defp sanitize_http_url(raw) when is_binary(raw) do
|
||||||
|
trimmed = raw |> strip_control() |> String.trim()
|
||||||
|
|
||||||
|
case URI.parse(trimmed) do
|
||||||
|
%URI{scheme: scheme, host: host} = uri
|
||||||
|
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
|
||||||
|
normalized = URI.to_string(%URI{uri | userinfo: nil, fragment: nil})
|
||||||
|
if String.length(normalized) <= @max_url_length, do: normalized, else: nil
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanitize_http_url(_other), do: nil
|
||||||
|
|
||||||
|
defp url_host(url) when is_binary(url), do: URI.parse(url).host
|
||||||
|
defp url_host(_other), do: nil
|
||||||
|
|
||||||
|
defp strip_control(value), do: String.replace(value, @control_chars, "")
|
||||||
|
|
||||||
|
# If the deep link carries a project_id, route to that project; otherwise
|
||||||
|
# use the currently active project passed by the caller. This lets the
|
||||||
|
# bookmarklet target a specific project even when a different one is open.
|
||||||
|
defp resolve_target_project(_caller_project_id, %{"project_id" => pid}) when is_binary(pid) do
|
||||||
|
pid
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_target_project(project_id, _candidate), do: project_id
|
||||||
|
|
||||||
|
# Match the legacy bDS app: when the bookmarklet supplies no body, seed the
|
||||||
|
# post content with a markdown link to the bookmarked page so transforms run
|
||||||
|
# against the same input. Explicit content always wins.
|
||||||
|
defp apply_default_content(candidate) do
|
||||||
|
with nil <- optional_string(Map.get(candidate, "content")),
|
||||||
|
url when is_binary(url) <- optional_string(Map.get(candidate, "url")) do
|
||||||
|
title = Map.get(candidate, "title", "")
|
||||||
|
Map.put(candidate, "content", blogmark_markdown_link(title, url))
|
||||||
|
else
|
||||||
|
_ -> candidate
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp blogmark_markdown_link(title, url) do
|
||||||
|
"[" <> escape_markdown_link_text(title) <> "](" <> url <> ")"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mirrors bDS escapeMarkdownLinkText: backslash first, then brackets/parens,
|
||||||
|
# then collapse newlines to spaces.
|
||||||
|
defp escape_markdown_link_text(value) do
|
||||||
|
value
|
||||||
|
|> to_string()
|
||||||
|
|> String.trim()
|
||||||
|
|> String.replace("\\", "\\\\")
|
||||||
|
|> String.replace("[", "\\[")
|
||||||
|
|> String.replace("]", "\\]")
|
||||||
|
|> String.replace("(", "\\(")
|
||||||
|
|> String.replace(")", "\\)")
|
||||||
|
|> String.replace(~r/\r?\n/, " ")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp optional(params, key) do
|
||||||
|
case Map.get(params, key) do
|
||||||
|
nil -> nil
|
||||||
|
"" -> nil
|
||||||
|
value -> value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp list_param(params, key) do
|
||||||
|
case Map.get(params, key) do
|
||||||
|
value when is_binary(value) ->
|
||||||
|
value
|
||||||
|
|> String.split(",")
|
||||||
|
|> Enum.map(&String.trim/1)
|
||||||
|
|> Enum.reject(&(&1 == ""))
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Apply the project default category only when neither the deep link nor a
|
||||||
|
# transform produced one, so explicit categories always win.
|
||||||
|
defp apply_default_category(project_id, data) do
|
||||||
|
case Map.get(data, "categories") do
|
||||||
|
categories when is_list(categories) and categories != [] ->
|
||||||
|
data
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
case default_category(project_id) do
|
||||||
|
nil -> data
|
||||||
|
category -> Map.put(data, "categories", [category])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp default_category(project_id) do
|
||||||
|
case Metadata.get_project_metadata(project_id) do
|
||||||
|
{:ok, %{blogmark_category: category}} when is_binary(category) and category != "" ->
|
||||||
|
category
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_draft(project_id, data) do
|
||||||
|
Posts.create_post(%{
|
||||||
|
project_id: project_id,
|
||||||
|
title: Map.get(data, "title", ""),
|
||||||
|
content: optional_string(Map.get(data, "content")),
|
||||||
|
tags: string_list(Map.get(data, "tags")),
|
||||||
|
categories: string_list(Map.get(data, "categories"))
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp optional_string(value) when is_binary(value), do: value
|
||||||
|
defp optional_string(_value), do: nil
|
||||||
|
|
||||||
|
defp string_list(list) when is_list(list), do: Enum.filter(list, &is_binary/1)
|
||||||
|
defp string_list(_other), do: []
|
||||||
|
end
|
||||||
@@ -54,19 +54,22 @@ defmodule BDS.BoundedAtoms do
|
|||||||
:fill_missing_translations,
|
:fill_missing_translations,
|
||||||
:find_duplicates,
|
:find_duplicates,
|
||||||
:generate_sitemap,
|
:generate_sitemap,
|
||||||
|
:force_render_site,
|
||||||
:validate_site,
|
:validate_site,
|
||||||
:upload_site,
|
:upload_site,
|
||||||
:documentation,
|
:documentation,
|
||||||
:api_documentation,
|
:api_documentation,
|
||||||
:close_tab
|
:close_tab
|
||||||
]
|
]
|
||||||
@menu_actions MenuBar.default_groups(dev_mode?: true)
|
@menu_actions MenuBar.default_groups(dev_mode?: true, window_menu?: true)
|
||||||
|> Enum.flat_map(fn group ->
|
|> Enum.flat_map(fn group ->
|
||||||
Enum.flat_map(group.items, fn
|
Enum.flat_map(group.items, fn
|
||||||
%{separator: true} -> []
|
%{separator: true} -> []
|
||||||
%{id: id} -> [id]
|
%{id: id} -> [id]
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
@sidebar_view_ids Enum.map(Registry.sidebar_views(), & &1.id)
|
||||||
|
@editor_route_ids Enum.map(Registry.editor_routes(), & &1.id)
|
||||||
|
|
||||||
def atom(value, allowed, fallback \\ nil)
|
def atom(value, allowed, fallback \\ nil)
|
||||||
|
|
||||||
@@ -101,13 +104,9 @@ defmodule BDS.BoundedAtoms do
|
|||||||
Enum.find(allowed, fallback, &(Atom.to_string(&1) == value))
|
Enum.find(allowed, fallback, &(Atom.to_string(&1) == value))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp sidebar_views do
|
defp sidebar_views, do: @sidebar_view_ids
|
||||||
Enum.map(Registry.sidebar_views(), & &1.id)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp editor_routes do
|
defp editor_routes, do: @editor_route_ids
|
||||||
Enum.map(Registry.editor_routes(), & &1.id)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp normalize_menu_kind("category-archive"), do: "category_archive"
|
defp normalize_menu_kind("category-archive"), do: "category_archive"
|
||||||
defp normalize_menu_kind(value), do: value
|
defp normalize_menu_kind(value), do: value
|
||||||
|
|||||||
335
lib/bds/cli.ex
Normal file
335
lib/bds/cli.ex
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
defmodule BDS.CLI do
|
||||||
|
@moduledoc """
|
||||||
|
Command-line interface for managing content in a bDS2 workspace (issue #25).
|
||||||
|
|
||||||
|
Runs inside the release VM (`BDS_MODE=cli`) against the same settings and
|
||||||
|
cache database as the GUI/TUI application; mutations write
|
||||||
|
`BDS.CliSync.Notification` rows so a concurrently running app picks them
|
||||||
|
up through `BDS.CliSync.Watcher`.
|
||||||
|
|
||||||
|
The launcher script (`cli/bin/bds-cli` in the release, installable to
|
||||||
|
`~/.local/bin` via `BDS.CLI.Install`) passes the argv via `BDS_CLI_ARGV`
|
||||||
|
(unit-separator joined) because `bin/bds eval` cannot forward arguments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias BDS.CLI.Commands
|
||||||
|
|
||||||
|
@argv_env "BDS_CLI_ARGV"
|
||||||
|
@argv_separator <<0x1F>>
|
||||||
|
|
||||||
|
@doc "Release entry point: run the command from `BDS_CLI_ARGV` and halt."
|
||||||
|
@spec main() :: no_return()
|
||||||
|
def main do
|
||||||
|
System.halt(run(env_argv()))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Decodes the unit-separator-joined argv from the launcher script."
|
||||||
|
@spec env_argv(String.t() | nil) :: [String.t()]
|
||||||
|
def env_argv(value \\ System.get_env(@argv_env))
|
||||||
|
def env_argv(nil), do: []
|
||||||
|
def env_argv(value), do: String.split(value, @argv_separator, trim: true)
|
||||||
|
|
||||||
|
@doc "Parses and executes an argv, returning the process exit code."
|
||||||
|
@spec run([String.t()]) :: non_neg_integer()
|
||||||
|
def run(argv) when is_list(argv) do
|
||||||
|
optimus = optimus()
|
||||||
|
|
||||||
|
case Optimus.parse(optimus, argv) do
|
||||||
|
{:ok, %Optimus.ParseResult{}} ->
|
||||||
|
print_help(optimus, [])
|
||||||
|
0
|
||||||
|
|
||||||
|
{:ok, subcommand_path, parse_result} ->
|
||||||
|
execute(subcommand_path, parse_result)
|
||||||
|
|
||||||
|
:help ->
|
||||||
|
print_help(optimus, [])
|
||||||
|
0
|
||||||
|
|
||||||
|
{:help, subcommand_path} ->
|
||||||
|
print_help(optimus, subcommand_path)
|
||||||
|
0
|
||||||
|
|
||||||
|
:version ->
|
||||||
|
IO.puts(version())
|
||||||
|
0
|
||||||
|
|
||||||
|
{:error, errors} ->
|
||||||
|
print_errors(optimus, [], errors)
|
||||||
|
1
|
||||||
|
|
||||||
|
{:error, subcommand_path, errors} ->
|
||||||
|
print_errors(optimus, subcommand_path, errors)
|
||||||
|
1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Executes a parsed subcommand, returning the process exit code."
|
||||||
|
@spec execute([atom()], Optimus.ParseResult.t()) :: non_neg_integer()
|
||||||
|
def execute(subcommand_path, parse_result) do
|
||||||
|
{:ok, _apps} = Application.ensure_all_started(:bds)
|
||||||
|
|
||||||
|
subcommand_path
|
||||||
|
|> Commands.dispatch(parse_result)
|
||||||
|
|> report()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp report(:ok), do: 0
|
||||||
|
|
||||||
|
defp report({:ok, message}) when is_binary(message) do
|
||||||
|
IO.puts(message)
|
||||||
|
0
|
||||||
|
end
|
||||||
|
|
||||||
|
defp report({:error, message}) do
|
||||||
|
IO.puts(:stderr, "Error: " <> format_error(message))
|
||||||
|
1
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_error(message) when is_binary(message), do: message
|
||||||
|
defp format_error(%{message: message}) when is_binary(message), do: message
|
||||||
|
defp format_error(other), do: inspect(other)
|
||||||
|
|
||||||
|
@repair_parts ~w(post-links media-links thumbnails embeddings search)
|
||||||
|
|
||||||
|
@doc "The Optimus command definition (public for tests and help rendering)."
|
||||||
|
def optimus do
|
||||||
|
Optimus.new!(
|
||||||
|
name: "bds-cli",
|
||||||
|
description: "bDS2 workspace CLI",
|
||||||
|
about:
|
||||||
|
"Manages content in a bDS2 workspace using the same settings and " <>
|
||||||
|
"cache database as the desktop application.",
|
||||||
|
version: version(),
|
||||||
|
allow_unknown_args: false,
|
||||||
|
parse_double_dash: true,
|
||||||
|
subcommands: [
|
||||||
|
rebuild: [
|
||||||
|
name: "rebuild",
|
||||||
|
about: "Rebuild the caching database from the workspace files",
|
||||||
|
flags: [
|
||||||
|
incremental: [
|
||||||
|
long: "--incremental",
|
||||||
|
help:
|
||||||
|
"Run a metadata diff and auto-apply every difference from " <>
|
||||||
|
"file to database instead of a full rebuild"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
repair: [
|
||||||
|
name: "repair",
|
||||||
|
about: "Run one of the standard repair tasks outside the full rebuild",
|
||||||
|
args: [
|
||||||
|
part: [
|
||||||
|
value_name: "PART",
|
||||||
|
help: "One of: " <> Enum.join(@repair_parts, ", "),
|
||||||
|
required: true,
|
||||||
|
parser: &parse_repair_part/1
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
render: [
|
||||||
|
name: "render",
|
||||||
|
about: "Render the blog from the current content",
|
||||||
|
flags: [
|
||||||
|
incremental: [
|
||||||
|
long: "--incremental",
|
||||||
|
help: "Validate the generated output and apply only the differences"
|
||||||
|
],
|
||||||
|
force: [
|
||||||
|
long: "--force",
|
||||||
|
help: "Full re-render ignoring (but updating) stored content hashes"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
upload: [
|
||||||
|
name: "upload",
|
||||||
|
about: "Upload the rendered site to the configured host (rsync/scp)"
|
||||||
|
],
|
||||||
|
push: [
|
||||||
|
name: "push",
|
||||||
|
about: "Push the project repository to its origin"
|
||||||
|
],
|
||||||
|
pull: [
|
||||||
|
name: "pull",
|
||||||
|
about: "Pull the project repository and update the cache database"
|
||||||
|
],
|
||||||
|
post: [
|
||||||
|
name: "post",
|
||||||
|
about:
|
||||||
|
"Create a post from parameters or from JSON on stdin " <>
|
||||||
|
"(keys: title, content, excerpt, author, language, tags, categories, template)",
|
||||||
|
flags: [
|
||||||
|
stdin: [long: "--stdin", help: "Read post data as JSON from stdin"],
|
||||||
|
no_translate: [
|
||||||
|
long: "--no-translate",
|
||||||
|
help: "Skip automatic translation of the created post"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
options: post_content_options()
|
||||||
|
],
|
||||||
|
media: [
|
||||||
|
name: "media",
|
||||||
|
about:
|
||||||
|
"Import an image with automatically generated title, alt text, " <>
|
||||||
|
"caption and translations",
|
||||||
|
args: [
|
||||||
|
file: [
|
||||||
|
value_name: "FILE",
|
||||||
|
help: "Path of the image file to import",
|
||||||
|
required: true
|
||||||
|
]
|
||||||
|
],
|
||||||
|
options: [
|
||||||
|
language: [
|
||||||
|
long: "--language",
|
||||||
|
value_name: "LANG",
|
||||||
|
help: "Language for the generated texts (defaults to the project main language)"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
gallery: [
|
||||||
|
name: "gallery",
|
||||||
|
about:
|
||||||
|
"Create a gallery post and import all referenced images as new " <>
|
||||||
|
"media with generated texts and translations",
|
||||||
|
allow_unknown_args: true,
|
||||||
|
flags: [
|
||||||
|
stdin: [
|
||||||
|
long: "--stdin",
|
||||||
|
help: "Read gallery data as JSON from stdin (adds an \"images\" key to post keys)"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
options: post_content_options()
|
||||||
|
],
|
||||||
|
config: [
|
||||||
|
name: "config",
|
||||||
|
about: "Read and write global preference values",
|
||||||
|
subcommands: [
|
||||||
|
get: [
|
||||||
|
name: "get",
|
||||||
|
about: "Print a preference value",
|
||||||
|
args: [key: [value_name: "KEY", help: "Preference key", required: true]]
|
||||||
|
],
|
||||||
|
set: [
|
||||||
|
name: "set",
|
||||||
|
about: "Set a preference value",
|
||||||
|
args: [
|
||||||
|
key: [value_name: "KEY", help: "Preference key", required: true],
|
||||||
|
value: [value_name: "VALUE", help: "New value", required: true]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
list: [
|
||||||
|
name: "list",
|
||||||
|
about: "List all preference keys and values"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
project: [
|
||||||
|
name: "project",
|
||||||
|
about: "Manage the projects registered in the cache database",
|
||||||
|
subcommands: [
|
||||||
|
list: [
|
||||||
|
name: "list",
|
||||||
|
about: "List all projects"
|
||||||
|
],
|
||||||
|
add: [
|
||||||
|
name: "add",
|
||||||
|
about: "Open a project folder and add it to the cache database",
|
||||||
|
args: [
|
||||||
|
path: [value_name: "PATH", help: "Project data folder", required: true]
|
||||||
|
],
|
||||||
|
options: [
|
||||||
|
name: [
|
||||||
|
long: "--name",
|
||||||
|
value_name: "NAME",
|
||||||
|
help: "Project name (defaults to the folder name)"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
switch: [
|
||||||
|
name: "switch",
|
||||||
|
about: "Switch the active project",
|
||||||
|
args: [
|
||||||
|
project: [
|
||||||
|
value_name: "PROJECT",
|
||||||
|
help: "Project id, slug, or name",
|
||||||
|
required: true
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
tui: [
|
||||||
|
name: "tui",
|
||||||
|
about: "Open the app in TUI mode for interactive use"
|
||||||
|
],
|
||||||
|
lua: [
|
||||||
|
name: "lua",
|
||||||
|
about: "Run a utility (long-running task) Lua script from the database",
|
||||||
|
allow_unknown_args: true,
|
||||||
|
args: [
|
||||||
|
script: [
|
||||||
|
value_name: "SCRIPT",
|
||||||
|
help: "Script slug in the active project",
|
||||||
|
required: true
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp post_content_options do
|
||||||
|
[
|
||||||
|
title: [long: "--title", value_name: "TITLE", help: "Post title"],
|
||||||
|
content: [long: "--content", value_name: "MARKDOWN", help: "Post body (markdown)"],
|
||||||
|
excerpt: [long: "--excerpt", value_name: "TEXT", help: "Post excerpt"],
|
||||||
|
author: [long: "--author", value_name: "NAME", help: "Post author"],
|
||||||
|
language: [
|
||||||
|
long: "--language",
|
||||||
|
value_name: "LANG",
|
||||||
|
help: "Post language (auto-detected from the content when omitted)"
|
||||||
|
],
|
||||||
|
template: [long: "--template", value_name: "SLUG", help: "Template slug"],
|
||||||
|
tags: [long: "--tags", value_name: "TAGS", help: "Comma-separated tags"],
|
||||||
|
categories: [
|
||||||
|
long: "--categories",
|
||||||
|
value_name: "CATEGORIES",
|
||||||
|
help: "Comma-separated categories"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_repair_part(value) when value in @repair_parts, do: {:ok, value}
|
||||||
|
|
||||||
|
defp parse_repair_part(value),
|
||||||
|
do: {:error, "unknown repair part #{inspect(value)}; expected one of: #{Enum.join(@repair_parts, ", ")}"}
|
||||||
|
|
||||||
|
defp print_help(optimus, subcommand_path) do
|
||||||
|
optimus
|
||||||
|
|> Optimus.Help.help(subcommand_path, terminal_width())
|
||||||
|
|> Enum.each(&IO.puts/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp print_errors(optimus, [], errors) do
|
||||||
|
optimus |> Optimus.Errors.format(errors) |> Enum.each(&IO.puts(:stderr, &1))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp print_errors(optimus, subcommand_path, errors) do
|
||||||
|
optimus
|
||||||
|
|> Optimus.Errors.format(subcommand_path, errors)
|
||||||
|
|> Enum.each(&IO.puts(:stderr, &1))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp terminal_width do
|
||||||
|
case Optimus.Term.width() do
|
||||||
|
{:ok, width} -> width
|
||||||
|
_other -> 80
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp version do
|
||||||
|
to_string(Application.spec(:bds, :vsn) || "dev")
|
||||||
|
end
|
||||||
|
end
|
||||||
696
lib/bds/cli/commands.ex
Normal file
696
lib/bds/cli/commands.ex
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
defmodule BDS.CLI.Commands do
|
||||||
|
@moduledoc """
|
||||||
|
Implementations of the `bds-cli` subcommands (issue #25).
|
||||||
|
|
||||||
|
Every command runs synchronously against the engine modules the GUI uses
|
||||||
|
and returns `:ok`, `{:ok, message}`, or `{:error, message}`. Mutations
|
||||||
|
write `BDS.CliSync` notification rows so a concurrently running app
|
||||||
|
refreshes its state (see `specs/cli_sync.allium`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias BDS.AI
|
||||||
|
alias BDS.CliSync
|
||||||
|
alias BDS.Desktop.ShellLive.GalleryImport
|
||||||
|
alias BDS.Generation
|
||||||
|
alias BDS.Git
|
||||||
|
alias BDS.Maintenance
|
||||||
|
alias BDS.Media
|
||||||
|
alias BDS.Metadata
|
||||||
|
alias BDS.Posts
|
||||||
|
alias BDS.Posts.AutoTranslation
|
||||||
|
alias BDS.Projects
|
||||||
|
alias BDS.Publishing
|
||||||
|
alias BDS.Repo
|
||||||
|
alias BDS.Scripting
|
||||||
|
alias BDS.Scripts
|
||||||
|
alias BDS.Scripts.Script
|
||||||
|
alias BDS.Search
|
||||||
|
alias BDS.Settings
|
||||||
|
alias BDS.Tasks
|
||||||
|
|
||||||
|
@site_sections [:core, :single, :category, :tag, :date]
|
||||||
|
@bulk_entity_types ~w(post media script template)
|
||||||
|
@gallery_concurrency 2
|
||||||
|
@poll_interval_ms 250
|
||||||
|
|
||||||
|
@doc "Dispatches a parsed subcommand path to its implementation."
|
||||||
|
@spec dispatch([atom()], Optimus.ParseResult.t()) :: :ok | {:ok, String.t()} | {:error, term()}
|
||||||
|
def dispatch([:rebuild], %{flags: %{incremental: true}}),
|
||||||
|
do: with_project(&incremental_rebuild/1)
|
||||||
|
|
||||||
|
def dispatch([:rebuild], _parse_result), do: with_project(&full_rebuild/1)
|
||||||
|
|
||||||
|
def dispatch([:repair], %{args: %{part: part}}),
|
||||||
|
do: with_project(&repair(part, &1))
|
||||||
|
|
||||||
|
def dispatch([:render], %{flags: %{incremental: true, force: true}}),
|
||||||
|
do: {:error, "--incremental and --force are mutually exclusive"}
|
||||||
|
|
||||||
|
def dispatch([:render], %{flags: %{incremental: true}}),
|
||||||
|
do: with_project(&incremental_render/1)
|
||||||
|
|
||||||
|
def dispatch([:render], %{flags: flags}),
|
||||||
|
do: with_project(&full_render(&1, flags[:force] == true))
|
||||||
|
|
||||||
|
def dispatch([:upload], _parse_result), do: with_project(&upload/1)
|
||||||
|
|
||||||
|
def dispatch([:push], _parse_result) do
|
||||||
|
with_project(fn project ->
|
||||||
|
with {:ok, result} <- Git.push(project.id) do
|
||||||
|
print_git_output(result)
|
||||||
|
{:ok, "Pushed"}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:pull], _parse_result) do
|
||||||
|
with_project(fn project ->
|
||||||
|
with {:ok, result} <- Git.pull(project.id) do
|
||||||
|
print_git_output(result)
|
||||||
|
# Update the cache database from the pulled files, like the app's
|
||||||
|
# incremental rebuild: file→db for every difference plus orphans.
|
||||||
|
incremental_rebuild(project)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:post], %{flags: flags, options: options}) do
|
||||||
|
with_project(fn project ->
|
||||||
|
with {:ok, attrs} <- post_attrs(flags, options),
|
||||||
|
{:ok, attrs} <- ensure_language(attrs),
|
||||||
|
{:ok, post} <- create_post(project, attrs) do
|
||||||
|
unless flags[:no_translate], do: translate_post(post)
|
||||||
|
{:ok, "Created post #{post.id} (#{post.slug}, #{post.language})"}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:media], %{args: %{file: file}, options: options}) do
|
||||||
|
with_project(fn project ->
|
||||||
|
with {:ok, metadata} <- Metadata.get_project_metadata(project.id),
|
||||||
|
:ok <- ensure_files_exist([file]) do
|
||||||
|
language = present(options[:language]) || metadata.main_language
|
||||||
|
targets = GalleryImport.translate_targets(metadata, language)
|
||||||
|
|
||||||
|
case GalleryImport.import_and_enrich(file, project.id, language, targets) do
|
||||||
|
{:ok, media, title} ->
|
||||||
|
:ok = notify("media", media.id, :created)
|
||||||
|
{:ok, "Imported media #{media.id} (#{title})"}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, format_reason(reason)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:gallery], %{flags: flags, options: options, unknown: unknown}) do
|
||||||
|
with_project(fn project ->
|
||||||
|
with {:ok, attrs, images} <- gallery_attrs(flags, options, unknown),
|
||||||
|
:ok <- ensure_files_exist(images),
|
||||||
|
{:ok, attrs} <- ensure_language(attrs),
|
||||||
|
{:ok, post} <- create_post(project, attrs) do
|
||||||
|
import_gallery_images(project, post, images)
|
||||||
|
:ok = notify("media", "*", :created)
|
||||||
|
:ok = notify("post", post.id, :updated)
|
||||||
|
unless flags[:no_translate], do: translate_post(post)
|
||||||
|
{:ok, "Created gallery post #{post.id} (#{post.slug}) with #{length(images)} images"}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:config, :get], %{args: %{key: key}}) do
|
||||||
|
case Settings.get_global_setting(key) do
|
||||||
|
nil -> {:error, "#{key} is not set"}
|
||||||
|
value -> {:ok, value}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:config, :set], %{args: %{key: key, value: value}}) do
|
||||||
|
with :ok <- Settings.put_global_setting(key, value) do
|
||||||
|
:ok = notify("setting", key, :updated)
|
||||||
|
{:ok, "#{key} = #{value}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:config, :list], _parse_result) do
|
||||||
|
Settings.list_global_settings()
|
||||||
|
|> Enum.each(fn {key, value} -> IO.puts("#{key}=#{value}") end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:project, :list], _parse_result) do
|
||||||
|
Enum.each(Projects.list_projects(), fn project ->
|
||||||
|
marker = if project.is_active, do: "* ", else: " "
|
||||||
|
IO.puts("#{marker}#{project.id} #{project.slug} #{project.name}")
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:project, :add], %{args: %{path: path}, options: options}) do
|
||||||
|
expanded = Path.expand(path)
|
||||||
|
|
||||||
|
if File.dir?(expanded) do
|
||||||
|
name = present(options[:name]) || Path.basename(expanded)
|
||||||
|
|
||||||
|
with {:ok, project} <- Projects.create_project(%{name: name, data_path: expanded}) do
|
||||||
|
:ok = notify("project", project.id, :created)
|
||||||
|
{:ok, "Added project #{project.id} (#{project.slug}); switch with: project switch #{project.slug}"}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:error, "#{expanded} is not a directory"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:project, :switch], %{args: %{project: reference}}) do
|
||||||
|
with {:ok, project} <- resolve_project(reference),
|
||||||
|
{:ok, project} <- Projects.set_active_project(project.id) do
|
||||||
|
:ok = notify("project", project.id, :updated)
|
||||||
|
{:ok, "Active project: #{project.name}"}
|
||||||
|
else
|
||||||
|
{:error, :not_found} -> {:error, "No project matches #{inspect(reference)}"}
|
||||||
|
{:error, reason} -> {:error, format_reason(reason)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:tui], _parse_result) do
|
||||||
|
{:error,
|
||||||
|
"The TUI is started by the bds-cli launcher script (BDS_MODE=tui); " <>
|
||||||
|
"it is not available through a direct BDS.CLI invocation"}
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:lua], %{args: %{script: slug}, unknown: script_args}) do
|
||||||
|
with_project(fn project ->
|
||||||
|
case Repo.one(
|
||||||
|
from script in Script,
|
||||||
|
where: script.project_id == ^project.id and script.slug == ^slug
|
||||||
|
) do
|
||||||
|
nil ->
|
||||||
|
{:error, "No script with slug #{inspect(slug)} in the active project"}
|
||||||
|
|
||||||
|
%Script{kind: kind} when kind != :utility ->
|
||||||
|
{:error, "Script #{inspect(slug)} is a #{kind} script; only utility (task) scripts can be run"}
|
||||||
|
|
||||||
|
%Script{enabled: false} ->
|
||||||
|
{:error, "Script #{inspect(slug)} is disabled"}
|
||||||
|
|
||||||
|
script ->
|
||||||
|
run_lua_job(project, script, script_args)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── rebuild ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp full_rebuild(project) do
|
||||||
|
project.id
|
||||||
|
|> Maintenance.full_rebuild_steps()
|
||||||
|
|> Enum.reduce_while(:ok, fn step, :ok ->
|
||||||
|
IO.puts("==> #{step.name}")
|
||||||
|
|
||||||
|
case step.work.(progress_printer()) do
|
||||||
|
{:error, message} -> {:halt, {:error, message}}
|
||||||
|
_result -> {:cont, :ok}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> case do
|
||||||
|
:ok ->
|
||||||
|
notify_bulk_change()
|
||||||
|
{:ok, "Rebuild complete"}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp incremental_rebuild(project) do
|
||||||
|
reporter = progress_printer()
|
||||||
|
{:ok, diff} = Maintenance.metadata_diff(project.id, on_progress: reporter)
|
||||||
|
|
||||||
|
items =
|
||||||
|
Enum.map(diff.diff_reports, &%{entity_type: &1.entity_type, entity_id: &1.entity_id})
|
||||||
|
|
||||||
|
orphans = Enum.map(diff.orphan_reports, &%{file_path: &1.file_path})
|
||||||
|
|
||||||
|
if items != [] do
|
||||||
|
{:ok, _repair} =
|
||||||
|
Maintenance.repair_metadata_diff(project.id, :file_to_db, items, on_progress: reporter)
|
||||||
|
end
|
||||||
|
|
||||||
|
if orphans != [] do
|
||||||
|
{:ok, _import} =
|
||||||
|
Maintenance.import_metadata_diff_orphans(project.id, orphans, on_progress: reporter)
|
||||||
|
end
|
||||||
|
|
||||||
|
if items != [] or orphans != [], do: notify_bulk_change()
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
"Applied #{length(items)} differences and imported #{length(orphans)} orphan files from the filesystem"}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── repair ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp repair("post-links", project) do
|
||||||
|
:ok = Posts.rebuild_post_links(project.id, on_progress: progress_printer())
|
||||||
|
:ok = notify("post", "*", :updated)
|
||||||
|
{:ok, "Post links rebuilt"}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp repair("media-links", project) do
|
||||||
|
{:ok, result} = Media.rebuild_media_links(project.id, on_progress: progress_printer())
|
||||||
|
:ok = notify("media", "*", :updated)
|
||||||
|
{:ok, "Media links rebuilt (#{result.links} links)"}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp repair("thumbnails", project) do
|
||||||
|
result = Media.regenerate_missing_thumbnails(project.id, on_progress: progress_printer())
|
||||||
|
:ok = notify("media", "*", :updated)
|
||||||
|
{:ok, "Missing thumbnails regenerated (#{inspect(Map.get(result, :generated, 0))} generated)"}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp repair("embeddings", project) do
|
||||||
|
case Maintenance.rebuild_embedding_index(project.id, on_progress: progress_printer()) do
|
||||||
|
{:error, message} -> {:error, message}
|
||||||
|
result -> {:ok, "Embedding index rebuilt (#{result.rebuilt_count} posts)"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp repair("search", project) do
|
||||||
|
:ok = Search.reindex_posts(project.id, on_progress: progress_printer())
|
||||||
|
:ok = Search.reindex_media(project.id, on_progress: progress_printer())
|
||||||
|
{:ok, "Search text reindexed"}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp full_render(project, force?) do
|
||||||
|
render_opts = if force?, do: [force: true], else: []
|
||||||
|
|
||||||
|
Enum.each(@site_sections, fn section ->
|
||||||
|
IO.puts("==> Rendering #{section}")
|
||||||
|
|
||||||
|
{:ok, _result} =
|
||||||
|
Generation.render_site_section(
|
||||||
|
project.id,
|
||||||
|
section,
|
||||||
|
[on_progress: progress_printer()] ++ render_opts
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
IO.puts("==> Building search index")
|
||||||
|
|
||||||
|
{:ok, _index} =
|
||||||
|
Generation.build_site_search_index(
|
||||||
|
project.id,
|
||||||
|
[on_progress: progress_printer()] ++ render_opts
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, "Site rendered"}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp incremental_render(project) do
|
||||||
|
{:ok, report} =
|
||||||
|
Generation.validate_site(project.id, @site_sections, on_progress: progress_printer())
|
||||||
|
|
||||||
|
{:ok, applied} =
|
||||||
|
Generation.apply_validation(project.id, report, on_progress: progress_printer())
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
"Validation applied (#{applied.rendered_url_count} rendered, " <>
|
||||||
|
"#{applied.deleted_url_count} deleted)"}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── upload ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp upload(project) do
|
||||||
|
with {:ok, metadata} <- Metadata.get_project_metadata(project.id),
|
||||||
|
{:ok, credentials} <- upload_credentials(metadata.publishing_preferences),
|
||||||
|
{:ok, job} <- Publishing.upload_site(project.id, credentials) do
|
||||||
|
await_task(job.task_id, "Upload complete")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp upload_credentials(prefs) when is_map(prefs) do
|
||||||
|
credentials = %{
|
||||||
|
ssh_host: Map.get(prefs, "ssh_host"),
|
||||||
|
ssh_user: Map.get(prefs, "ssh_user"),
|
||||||
|
ssh_remote_path: Map.get(prefs, "ssh_remote_path"),
|
||||||
|
ssh_mode: Map.get(prefs, "ssh_mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
if Enum.all?(
|
||||||
|
[credentials.ssh_host, credentials.ssh_user, credentials.ssh_remote_path],
|
||||||
|
&is_binary/1
|
||||||
|
) do
|
||||||
|
{:ok, credentials}
|
||||||
|
else
|
||||||
|
{:error, "Publishing preferences are incomplete; configure the upload host in Settings"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp upload_credentials(_prefs),
|
||||||
|
do: {:error, "Publishing preferences are incomplete; configure the upload host in Settings"}
|
||||||
|
|
||||||
|
# ── post / gallery ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp post_attrs(%{stdin: true}, _options) do
|
||||||
|
with {:ok, data} <- read_stdin_json() do
|
||||||
|
json_post_attrs(data)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp post_attrs(_flags, options) do
|
||||||
|
case present(options[:title]) do
|
||||||
|
nil ->
|
||||||
|
{:error, "--title is required (or pass --stdin with JSON post data)"}
|
||||||
|
|
||||||
|
title ->
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
title: title,
|
||||||
|
content: options[:content] || "",
|
||||||
|
excerpt: present(options[:excerpt]),
|
||||||
|
author: present(options[:author]),
|
||||||
|
language: present(options[:language]),
|
||||||
|
template_slug: present(options[:template]),
|
||||||
|
tags: split_list(options[:tags]),
|
||||||
|
categories: split_list(options[:categories])
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp gallery_attrs(%{stdin: true}, _options, _unknown) do
|
||||||
|
with {:ok, data} <- read_stdin_json(),
|
||||||
|
{:ok, attrs} <- json_post_attrs(data) do
|
||||||
|
case Map.get(data, "images") do
|
||||||
|
images when is_list(images) and images != [] -> {:ok, attrs, images}
|
||||||
|
_other -> {:error, "JSON gallery data needs a non-empty \"images\" array"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp gallery_attrs(flags, options, unknown) do
|
||||||
|
case unknown do
|
||||||
|
[] ->
|
||||||
|
{:error, "Pass the image files as arguments (or use --stdin with JSON gallery data)"}
|
||||||
|
|
||||||
|
images ->
|
||||||
|
with {:ok, attrs} <- post_attrs(flags, options) do
|
||||||
|
{:ok, attrs, images}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp json_post_attrs(data) when is_map(data) do
|
||||||
|
case present(Map.get(data, "title")) do
|
||||||
|
nil ->
|
||||||
|
{:error, "JSON post data needs a \"title\""}
|
||||||
|
|
||||||
|
title ->
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
title: title,
|
||||||
|
content: Map.get(data, "content") || "",
|
||||||
|
excerpt: present(Map.get(data, "excerpt")),
|
||||||
|
author: present(Map.get(data, "author")),
|
||||||
|
language: present(Map.get(data, "language")),
|
||||||
|
template_slug: present(Map.get(data, "template")),
|
||||||
|
tags: json_list(Map.get(data, "tags")),
|
||||||
|
categories: json_list(Map.get(data, "categories"))
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp json_post_attrs(_data), do: {:error, "JSON post data must be an object"}
|
||||||
|
|
||||||
|
defp read_stdin_json do
|
||||||
|
case IO.read(:stdio, :eof) do
|
||||||
|
:eof ->
|
||||||
|
{:error, "No JSON data on stdin"}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, "Could not read stdin: #{inspect(reason)}"}
|
||||||
|
|
||||||
|
data ->
|
||||||
|
case Jason.decode(data) do
|
||||||
|
{:ok, decoded} -> {:ok, decoded}
|
||||||
|
{:error, error} -> {:error, "Invalid JSON on stdin: #{Exception.message(error)}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Language auto-detection (issue #25): ask the configured AI endpoint —
|
||||||
|
# BDS.AI internally routes to the local model in airplane mode — and fall
|
||||||
|
# back to the offline search heuristic, informing the user (the CLI
|
||||||
|
# equivalent of the airplane-mode toast).
|
||||||
|
defp ensure_language(%{language: language} = attrs) when is_binary(language), do: {:ok, attrs}
|
||||||
|
|
||||||
|
defp ensure_language(attrs) do
|
||||||
|
text = String.trim("#{attrs.title}\n#{attrs.content}")
|
||||||
|
|
||||||
|
language =
|
||||||
|
case AI.detect_language(text) do
|
||||||
|
{:ok, %{language_code: language}} when is_binary(language) ->
|
||||||
|
language
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
IO.puts("AI language detection unavailable; using the offline heuristic")
|
||||||
|
Search.detect_language(text)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, %{attrs | language: language}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_post(project, attrs) do
|
||||||
|
case Posts.create_post(Map.put(attrs, :project_id, project.id)) do
|
||||||
|
{:ok, post} ->
|
||||||
|
:ok = notify("post", post.id, :created)
|
||||||
|
{:ok, post}
|
||||||
|
|
||||||
|
{:error, %Ecto.Changeset{} = changeset} ->
|
||||||
|
{:error, changeset_message(changeset)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Automatic translation mirrors the GUI: schedule the background tasks and
|
||||||
|
# wait for them, so the CLI exits only when the work is done. When nothing
|
||||||
|
# gets scheduled (airplane mode without a local model, or a single-language
|
||||||
|
# blog) the user is told instead of a silent no-op.
|
||||||
|
defp translate_post(post) do
|
||||||
|
before_count = length(active_tasks())
|
||||||
|
:ok = AutoTranslation.maybe_schedule(post)
|
||||||
|
|
||||||
|
case length(active_tasks()) - before_count do
|
||||||
|
queued when queued > 0 ->
|
||||||
|
IO.puts("Waiting for #{queued} translation task(s)...")
|
||||||
|
await_active_tasks()
|
||||||
|
:ok = notify("post", post.id, :updated)
|
||||||
|
|
||||||
|
_none ->
|
||||||
|
IO.puts("Automatic translation was not scheduled (offline, unconfigured AI, or nothing to translate)")
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp import_gallery_images(project, post, images) do
|
||||||
|
{:ok, metadata} = Metadata.get_project_metadata(project.id)
|
||||||
|
language = post.language || metadata.main_language
|
||||||
|
parent = self()
|
||||||
|
|
||||||
|
runner =
|
||||||
|
Task.async(fn ->
|
||||||
|
GalleryImport.start(
|
||||||
|
images,
|
||||||
|
project.id,
|
||||||
|
post.id,
|
||||||
|
language,
|
||||||
|
@gallery_concurrency,
|
||||||
|
parent
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
print_gallery_progress()
|
||||||
|
Task.await(runner, :infinity)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp print_gallery_progress do
|
||||||
|
receive do
|
||||||
|
{:add_image_processed, title} ->
|
||||||
|
IO.puts("Imported #{title}")
|
||||||
|
print_gallery_progress()
|
||||||
|
|
||||||
|
{:add_image_error, path, reason} ->
|
||||||
|
IO.puts(:stderr, "Failed to import #{path}: #{format_reason(reason)}")
|
||||||
|
print_gallery_progress()
|
||||||
|
|
||||||
|
{:add_images_complete, count} ->
|
||||||
|
IO.puts("Processed #{count} image(s)")
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ensure_files_exist(paths) do
|
||||||
|
case Enum.reject(paths, &File.regular?/1) do
|
||||||
|
[] -> :ok
|
||||||
|
missing -> {:error, "No such file: #{Enum.join(missing, ", ")}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── lua ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Utility ("task") scripts run with the managed-job execution budget
|
||||||
|
# (unlimited time/reductions) but synchronously — the CLI is the one
|
||||||
|
# waiting for the long-running activity.
|
||||||
|
defp run_lua_job(project, script, args) do
|
||||||
|
scripting_config = Application.fetch_env!(:bds, :scripting)
|
||||||
|
|
||||||
|
case Scripting.execute_project_script(
|
||||||
|
project.id,
|
||||||
|
Scripts.resolved_content(script),
|
||||||
|
script.entrypoint || "main",
|
||||||
|
args,
|
||||||
|
timeout: Keyword.get(scripting_config, :job_timeout, :infinity),
|
||||||
|
max_reductions: Keyword.get(scripting_config, :job_max_reductions, :none),
|
||||||
|
on_output: fn text -> IO.puts(text) end
|
||||||
|
) do
|
||||||
|
{:ok, nil} -> {:ok, "Script finished"}
|
||||||
|
{:ok, result} -> {:ok, "Script finished: #{format_reason(result)}"}
|
||||||
|
{:error, reason} -> {:error, "Script failed: #{format_reason(reason)}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── shared helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp with_project(fun) do
|
||||||
|
case Projects.get_active_project() do
|
||||||
|
nil -> {:error, "No active project selected; use: project switch <project>"}
|
||||||
|
project -> fun.(project)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_project(reference) do
|
||||||
|
projects = Projects.list_projects()
|
||||||
|
|
||||||
|
found =
|
||||||
|
Enum.find(projects, fn project ->
|
||||||
|
reference in [project.id, project.slug, project.name]
|
||||||
|
end)
|
||||||
|
|
||||||
|
if found, do: {:ok, found}, else: {:error, :not_found}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp await_task(task_id, success_message) do
|
||||||
|
case Tasks.get_task(task_id) do
|
||||||
|
nil ->
|
||||||
|
{:error, "Task disappeared before completion"}
|
||||||
|
|
||||||
|
%{status: :completed} ->
|
||||||
|
{:ok, success_message}
|
||||||
|
|
||||||
|
%{status: :failed} = task ->
|
||||||
|
{:error, format_reason(task.error || "task failed")}
|
||||||
|
|
||||||
|
%{message: message} = task ->
|
||||||
|
print_progress(task.progress || 0.0, message)
|
||||||
|
Process.sleep(@poll_interval_ms)
|
||||||
|
await_task(task_id, success_message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp active_tasks do
|
||||||
|
Enum.filter(Tasks.list_tasks(), &(&1.status in [:pending, :running]))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp await_active_tasks do
|
||||||
|
case active_tasks() do
|
||||||
|
[] ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
_tasks ->
|
||||||
|
Process.sleep(@poll_interval_ms)
|
||||||
|
await_active_tasks()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
A 2-arity progress callback that prints deduplicated `[ 42%] message`
|
||||||
|
lines to stdout.
|
||||||
|
"""
|
||||||
|
def progress_printer do
|
||||||
|
&print_progress/2
|
||||||
|
end
|
||||||
|
|
||||||
|
defp print_progress(value, message) do
|
||||||
|
percent = value |> Kernel.*(100) |> round() |> min(100) |> max(0)
|
||||||
|
line = {percent, message}
|
||||||
|
|
||||||
|
if Process.get({__MODULE__, :last_progress}) != line do
|
||||||
|
Process.put({__MODULE__, :last_progress}, line)
|
||||||
|
|
||||||
|
if is_binary(message) and message != "" do
|
||||||
|
IO.puts("[#{String.pad_leading(Integer.to_string(percent), 3)}%] #{message}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp print_git_output(%{output: output}) when is_binary(output) and output != "" do
|
||||||
|
IO.puts(String.trim_trailing(output))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp print_git_output(_result), do: :ok
|
||||||
|
|
||||||
|
defp notify(entity_type, entity_id, action) do
|
||||||
|
{:ok, _notification} = CliSync.cli_mutation_performed(entity_type, entity_id, action)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# Bulk maintenance touched an unknown set of rows; a wildcard notification
|
||||||
|
# per entity type makes a running app reload its lists.
|
||||||
|
defp notify_bulk_change do
|
||||||
|
Enum.each(@bulk_entity_types, ¬ify(&1, "*", :updated))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp split_list(nil), do: []
|
||||||
|
|
||||||
|
defp split_list(value) when is_binary(value) do
|
||||||
|
value
|
||||||
|
|> String.split(",")
|
||||||
|
|> Enum.map(&String.trim/1)
|
||||||
|
|> Enum.reject(&(&1 == ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp json_list(value) when is_list(value), do: Enum.map(value, &to_string/1)
|
||||||
|
defp json_list(value) when is_binary(value), do: split_list(value)
|
||||||
|
defp json_list(_value), do: []
|
||||||
|
|
||||||
|
defp present(nil), do: nil
|
||||||
|
|
||||||
|
defp present(value) when is_binary(value) do
|
||||||
|
case String.trim(value) do
|
||||||
|
"" -> nil
|
||||||
|
trimmed -> trimmed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp changeset_message(%Ecto.Changeset{} = changeset) do
|
||||||
|
changeset
|
||||||
|
|> Ecto.Changeset.traverse_errors(fn {message, opts} ->
|
||||||
|
Enum.reduce(opts, message, fn {key, value}, acc ->
|
||||||
|
String.replace(acc, "%{#{key}}", to_string(value))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|> Enum.map_join("; ", fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_reason(reason) when is_binary(reason), do: reason
|
||||||
|
defp format_reason(%{message: message}) when is_binary(message), do: message
|
||||||
|
defp format_reason(%{guidance: guidance}) when is_binary(guidance), do: guidance
|
||||||
|
defp format_reason(reason), do: inspect(reason)
|
||||||
|
end
|
||||||
57
lib/bds/cli/install.ex
Normal file
57
lib/bds/cli/install.ex
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
defmodule BDS.CLI.Install do
|
||||||
|
@moduledoc """
|
||||||
|
Installs the `bds-cli` launcher into `~/.local/bin` (issue #25), used by
|
||||||
|
the install buttons in the GUI and TUI settings.
|
||||||
|
|
||||||
|
The installed file is a two-line shim exec'ing the release's
|
||||||
|
`cli/bin/bds-cli` launcher (shipped via `rel/overlays`), so the CLI always
|
||||||
|
runs the same release — and therefore the same settings and cache
|
||||||
|
database — as the installed application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@launcher_name "bds-cli"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Writes the `~/.local/bin/bds-cli` shim. Returns `{:ok, installed_path}`,
|
||||||
|
or `{:error, :no_release}` when not running from a packaged release (dev
|
||||||
|
runs have no launcher to point at).
|
||||||
|
"""
|
||||||
|
@spec install(keyword()) :: {:ok, Path.t()} | {:error, :no_release | File.posix()}
|
||||||
|
def install(opts \\ []) do
|
||||||
|
bin_dir = Keyword.get(opts, :bin_dir, Path.expand("~/.local/bin"))
|
||||||
|
|
||||||
|
with {:ok, launcher} <- launcher_path(Keyword.get(opts, :release_root)) do
|
||||||
|
installed = Path.join(bin_dir, @launcher_name)
|
||||||
|
|
||||||
|
with :ok <- File.mkdir_p(bin_dir),
|
||||||
|
:ok <- File.write(installed, shim_script(launcher)),
|
||||||
|
:ok <- File.chmod(installed, 0o755) do
|
||||||
|
{:ok, installed}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
The release's CLI launcher script, resolved from `RELEASE_ROOT` (set by
|
||||||
|
release scripts) or the code root dir (the release root when running a
|
||||||
|
release). `{:error, :no_release}` when neither contains the launcher.
|
||||||
|
"""
|
||||||
|
@spec launcher_path(Path.t() | nil) :: {:ok, Path.t()} | {:error, :no_release}
|
||||||
|
def launcher_path(release_root \\ nil) do
|
||||||
|
[release_root, System.get_env("RELEASE_ROOT"), to_string(:code.root_dir())]
|
||||||
|
|> Enum.reject(&(&1 in [nil, ""]))
|
||||||
|
|> Enum.map(&Path.join([&1, "cli", "bin", @launcher_name]))
|
||||||
|
|> Enum.find(&File.regular?/1)
|
||||||
|
|> case do
|
||||||
|
nil -> {:error, :no_release}
|
||||||
|
path -> {:ok, path}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp shim_script(launcher) do
|
||||||
|
"""
|
||||||
|
#!/bin/sh
|
||||||
|
exec "#{launcher}" "$@"
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -54,6 +54,11 @@ defmodule BDS.CliSync do
|
|||||||
end)}
|
end)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def data_version do
|
||||||
|
%{rows: [[version]]} = Repo.query!("PRAGMA data_version", [])
|
||||||
|
version
|
||||||
|
end
|
||||||
|
|
||||||
def prune_notifications(now \\ Persistence.now_ms()) when is_integer(now) do
|
def prune_notifications(now \\ Persistence.now_ms()) when is_integer(now) do
|
||||||
{processed_count, _} =
|
{processed_count, _} =
|
||||||
Repo.delete_all(
|
Repo.delete_all(
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ defmodule BDS.CliSync.Watcher do
|
|||||||
Keyword.get(opts, :poll_interval_ms),
|
Keyword.get(opts, :poll_interval_ms),
|
||||||
@default_poll_interval_ms
|
@default_poll_interval_ms
|
||||||
),
|
),
|
||||||
pubsub: Keyword.get(opts, :pubsub, BDS.PubSub)
|
pubsub: Keyword.get(opts, :pubsub, BDS.PubSub),
|
||||||
|
data_version_reader: Keyword.get(opts, :data_version_reader, &CliSync.data_version/0),
|
||||||
|
notification_fetcher:
|
||||||
|
Keyword.get(opts, :notification_fetcher, &CliSync.db_file_change_detected/0),
|
||||||
|
pruner: Keyword.get(opts, :pruner, &CliSync.prune_notifications/0),
|
||||||
|
last_data_version: nil
|
||||||
}
|
}
|
||||||
|
|
||||||
{:ok, schedule_poll(state)}
|
{:ok, schedule_poll(state)}
|
||||||
@@ -49,18 +54,24 @@ defmodule BDS.CliSync.Watcher do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp process_notifications(state) do
|
defp process_notifications(state) do
|
||||||
{:ok, notifications} = CliSync.db_file_change_detected()
|
current_data_version = state.data_version_reader.()
|
||||||
{:ok, _pruned} = CliSync.prune_notifications()
|
|
||||||
|
|
||||||
Enum.each(notifications, fn notification ->
|
if state.last_data_version == current_data_version do
|
||||||
Phoenix.PubSub.broadcast(
|
%{state | last_data_version: current_data_version}
|
||||||
state.pubsub,
|
else
|
||||||
topic(),
|
{:ok, notifications} = state.notification_fetcher.()
|
||||||
{:entity_changed, notification_payload(notification)}
|
{:ok, _pruned} = state.pruner.()
|
||||||
)
|
|
||||||
end)
|
|
||||||
|
|
||||||
state
|
Enum.each(notifications, fn notification ->
|
||||||
|
Phoenix.PubSub.broadcast(
|
||||||
|
state.pubsub,
|
||||||
|
topic(),
|
||||||
|
{:entity_changed, notification_payload(notification)}
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{state | last_data_version: current_data_version}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp notification_payload(notification) do
|
defp notification_payload(notification) do
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ defmodule BDS.Desktop.Automation do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use GenServer
|
use GenServer
|
||||||
|
require Logger
|
||||||
|
|
||||||
@ready_timeout 60_000
|
@ready_timeout 60_000
|
||||||
@request_timeout 30_000
|
@request_timeout 30_000
|
||||||
@@ -13,7 +14,9 @@ defmodule BDS.Desktop.Automation do
|
|||||||
def stop_session(session) do
|
def stop_session(session) do
|
||||||
GenServer.stop(session, :normal, @request_timeout)
|
GenServer.stop(session, :normal, @request_timeout)
|
||||||
catch
|
catch
|
||||||
:exit, _reason -> :ok
|
:exit, reason ->
|
||||||
|
Logger.debug("swallowed desktop automation stop_session exit: #{inspect(reason)}")
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
def snapshot(session) do
|
def snapshot(session) do
|
||||||
@@ -58,7 +61,6 @@ defmodule BDS.Desktop.Automation do
|
|||||||
base_url = BDS.Desktop.url(port)
|
base_url = BDS.Desktop.url(port)
|
||||||
|
|
||||||
File.mkdir_p!(screenshot_dir)
|
File.mkdir_p!(screenshot_dir)
|
||||||
ensure_http_client_started()
|
|
||||||
|
|
||||||
app_port = start_app_process(project_root, port)
|
app_port = start_app_process(project_root, port)
|
||||||
:ok = wait_for_server(base_url)
|
:ok = wait_for_server(base_url)
|
||||||
@@ -221,7 +223,12 @@ defmodule BDS.Desktop.Automation do
|
|||||||
defp safe_driver_request(state, payload) do
|
defp safe_driver_request(state, payload) do
|
||||||
driver_request(state, payload)
|
driver_request(state, payload)
|
||||||
rescue
|
rescue
|
||||||
_error -> :ok
|
error ->
|
||||||
|
Logger.debug(
|
||||||
|
"swallowed desktop automation safe_driver_request error: #{inspect(error)} payload=#{inspect(payload)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
defp shutdown_driver(state) do
|
defp shutdown_driver(state) do
|
||||||
@@ -319,8 +326,14 @@ defmodule BDS.Desktop.Automation do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp do_wait_for_server(base_url, deadline) do
|
defp do_wait_for_server(base_url, deadline) do
|
||||||
case :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do
|
case Req.request(
|
||||||
{:ok, {{_, 200, _}, _headers, _body}} ->
|
method: :get,
|
||||||
|
url: base_url <> "health",
|
||||||
|
retry: false,
|
||||||
|
connect_options: [timeout: 1_000],
|
||||||
|
receive_timeout: 1_000
|
||||||
|
) do
|
||||||
|
{:ok, %Req.Response{status: 200}} ->
|
||||||
:ok
|
:ok
|
||||||
|
|
||||||
_other ->
|
_other ->
|
||||||
@@ -340,12 +353,6 @@ defmodule BDS.Desktop.Automation do
|
|||||||
port
|
port
|
||||||
end
|
end
|
||||||
|
|
||||||
defp ensure_http_client_started do
|
|
||||||
_ = Application.ensure_all_started(:inets)
|
|
||||||
_ = Application.ensure_all_started(:ssl)
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
|
|
||||||
defp await_port_exit(nil, _timeout), do: :ok
|
defp await_port_exit(nil, _timeout), do: :ok
|
||||||
|
|
||||||
defp await_port_exit(port, timeout) do
|
defp await_port_exit(port, timeout) do
|
||||||
@@ -369,7 +376,9 @@ defmodule BDS.Desktop.Automation do
|
|||||||
defp safe_close_port(port) do
|
defp safe_close_port(port) do
|
||||||
Port.close(port)
|
Port.close(port)
|
||||||
catch
|
catch
|
||||||
:error, _reason -> :ok
|
:error, reason ->
|
||||||
|
Logger.debug("swallowed desktop automation safe_close_port error: #{inspect(reason)}")
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_simple_reply("ok"), do: :ok
|
defp normalize_simple_reply("ok"), do: :ok
|
||||||
|
|||||||
117
lib/bds/desktop/deep_link.ex
Normal file
117
lib/bds/desktop/deep_link.ex
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
defmodule BDS.Desktop.DeepLink do
|
||||||
|
@moduledoc """
|
||||||
|
Receives OS URL-scheme events for the `bds2://` scheme and routes them to the
|
||||||
|
live shell (spec: script.allium `BlogmarkReceived`).
|
||||||
|
|
||||||
|
On macOS the `BDS2.app` bundle registers `bds2://` as a custom URL scheme via
|
||||||
|
the `CFBundleURLTypes` entry in its `Info.plist` (built by `BDS.MacBundle` /
|
||||||
|
`mix bds.bundle.macos`). When the browser bookmarklet navigates to
|
||||||
|
`bds2://new-post?title=&url=`, the OS launches/raises the app and `Desktop.Env`
|
||||||
|
relays erlang `:wx`'s `{:open_url, Url}` app event (where `Url` is the URL as a
|
||||||
|
charlist). This GenServer subscribes to those events and forwards recognised
|
||||||
|
`bds2://` links to the live shell.
|
||||||
|
|
||||||
|
Cold start matters here: when the bookmarklet *launches* the app, the deep
|
||||||
|
link arrives during startup, before the LiveView shell's websocket has
|
||||||
|
connected. Such links are queued and replayed the moment a shell registers via
|
||||||
|
`attach/2`; while a shell is connected, links are delivered to it directly.
|
||||||
|
|
||||||
|
The `bds2://` scheme is distinct from the legacy app's `bds://` so the two
|
||||||
|
installs do not contend for the same registration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@scheme "bds2://"
|
||||||
|
|
||||||
|
def child_spec(opts) do
|
||||||
|
%{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}}
|
||||||
|
end
|
||||||
|
|
||||||
|
def start_link(opts \\ []) do
|
||||||
|
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Registers the live shell `pid` as the deep-link sink and immediately replays
|
||||||
|
any links that arrived before a shell was connected. Future links are sent to
|
||||||
|
this pid until it dies, after which links queue again.
|
||||||
|
"""
|
||||||
|
@spec attach(pid(), GenServer.server()) :: :ok
|
||||||
|
def attach(shell_pid, server \\ __MODULE__) when is_pid(shell_pid) do
|
||||||
|
GenServer.call(server, {:attach, shell_pid})
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_opts) do
|
||||||
|
subscribe_to_env()
|
||||||
|
{:ok, %{shell: nil, pending: []}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:attach, pid}, _from, state) do
|
||||||
|
Process.monitor(pid)
|
||||||
|
|
||||||
|
state.pending
|
||||||
|
|> Enum.reverse()
|
||||||
|
|> Enum.each(&send(pid, {:blogmark_deep_link, &1}))
|
||||||
|
|
||||||
|
{:reply, :ok, %{state | shell: pid, pending: []}}
|
||||||
|
end
|
||||||
|
|
||||||
|
# erlang's :wx delivers macOS app events as `{:open_url, Url}` where `Url` is
|
||||||
|
# the URL *string itself* — a charlist on current OTP, not a list of URLs
|
||||||
|
# (see `wx:subscribe_events/0`). Desktop.Env forwards it unchanged, so the
|
||||||
|
# payload must be normalised to a binary before routing.
|
||||||
|
@impl true
|
||||||
|
def handle_info({:open_url, payload}, state) do
|
||||||
|
{:noreply, route(to_url(payload), state)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:DOWN, _ref, :process, pid, _reason}, %{shell: pid} = state) do
|
||||||
|
{:noreply, %{state | shell: nil}}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info(_message, state), do: {:noreply, state}
|
||||||
|
|
||||||
|
# Accept the charlist wx actually sends, a bare binary, or a [binary | _] list
|
||||||
|
# (the shape Desktop.Env's docs imply), normalising all to a binary URL.
|
||||||
|
defp to_url(value) when is_binary(value), do: value
|
||||||
|
defp to_url([first | _]) when is_binary(first), do: first
|
||||||
|
defp to_url(value) when is_list(value), do: List.to_string(value)
|
||||||
|
defp to_url(_other), do: ""
|
||||||
|
|
||||||
|
defp route(url, state) do
|
||||||
|
cond do
|
||||||
|
not String.starts_with?(url, @scheme) ->
|
||||||
|
Logger.debug("ignoring non-bds2 deep link: #{inspect(url)}")
|
||||||
|
state
|
||||||
|
|
||||||
|
is_pid(state.shell) and Process.alive?(state.shell) ->
|
||||||
|
send(state.shell, {:blogmark_deep_link, url})
|
||||||
|
state
|
||||||
|
|
||||||
|
true ->
|
||||||
|
# No shell connected yet (cold start) — hold the link until one attaches.
|
||||||
|
%{state | pending: [url | state.pending]}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Desktop.Env is only present when the wx desktop adapter is running. Guard the
|
||||||
|
# subscribe so the GenServer can still start in headless/test configurations.
|
||||||
|
defp subscribe_to_env do
|
||||||
|
if Process.whereis(Desktop.Env) do
|
||||||
|
try do
|
||||||
|
Desktop.Env.subscribe()
|
||||||
|
catch
|
||||||
|
:exit, reason ->
|
||||||
|
Logger.debug("swallowed deep_link Desktop.Env.subscribe exit: #{inspect(reason)}")
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
52
lib/bds/desktop/dialogs.ex
Normal file
52
lib/bds/desktop/dialogs.ex
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
defmodule BDS.Desktop.Dialogs do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
# Native single-line text prompt, same osascript approach as
|
||||||
|
# BDS.Desktop.FilePicker. Returns {:ok, text} | :cancel | {:error, map}.
|
||||||
|
def prompt_text(message, default \\ "") when is_binary(message) do
|
||||||
|
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" do
|
||||||
|
:cancel
|
||||||
|
else
|
||||||
|
case :os.type() do
|
||||||
|
{:unix, :darwin} -> prompt_text_macos(message, default)
|
||||||
|
_other -> {:error, %{message: "Dialogs are only supported on macOS desktop"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def alert(title, message) when is_binary(title) and is_binary(message) do
|
||||||
|
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" or :os.type() != {:unix, :darwin} do
|
||||||
|
:ok
|
||||||
|
else
|
||||||
|
script = "display alert \"#{escape(title)}\" message \"#{escape(message)}\""
|
||||||
|
_ = System.cmd("osascript", ["-e", script], stderr_to_stdout: true)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp prompt_text_macos(message, default) do
|
||||||
|
script =
|
||||||
|
"text returned of (display dialog \"#{escape(message)}\" default answer \"#{escape(default)}\")"
|
||||||
|
|
||||||
|
case System.cmd("osascript", ["-e", script], stderr_to_stdout: true) do
|
||||||
|
{output, 0} -> {:ok, String.trim(output)}
|
||||||
|
{output, _status} -> normalize_failure(output)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_failure(output) do
|
||||||
|
message = String.trim(output)
|
||||||
|
|
||||||
|
if message == "" or String.contains?(String.downcase(message), "canceled") do
|
||||||
|
:cancel
|
||||||
|
else
|
||||||
|
{:error, %{message: message}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp escape(value) do
|
||||||
|
value
|
||||||
|
|> String.replace("\\", "\\\\")
|
||||||
|
|> String.replace("\"", "\\\"")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -17,19 +17,14 @@ defmodule BDS.Desktop.Endpoint do
|
|||||||
plug(Plug.Static,
|
plug(Plug.Static,
|
||||||
at: "/assets",
|
at: "/assets",
|
||||||
from: {:bds, "priv/static/assets"},
|
from: {:bds, "priv/static/assets"},
|
||||||
only: ["app.css", "app.js"]
|
only: ["app.css", "app.js", "monaco.css", "monaco.js", "monaco"]
|
||||||
)
|
|
||||||
|
|
||||||
plug(Plug.Static,
|
|
||||||
at: "/monaco",
|
|
||||||
from: {:bds, "priv/ui/monaco"},
|
|
||||||
only: ["vs"]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
plug(BDS.Desktop.Router)
|
plug(BDS.Desktop.Router)
|
||||||
|
|
||||||
defp maybe_require_desktop_auth(conn, _opts) do
|
defp maybe_require_desktop_auth(conn, _opts) do
|
||||||
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do
|
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] or
|
||||||
|
not BDS.Server.desktop_auth_required?() do
|
||||||
conn
|
conn
|
||||||
else
|
else
|
||||||
Desktop.Auth.call(conn, [])
|
Desktop.Auth.call(conn, [])
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
defmodule BDS.Desktop.ExternalLinks do
|
|
||||||
@moduledoc false
|
|
||||||
|
|
||||||
@github_url "https://github.com/rfc1437/bDS2"
|
|
||||||
@github_issues_url "#{@github_url}/issues"
|
|
||||||
|
|
||||||
@spec github_url() :: String.t()
|
|
||||||
def github_url, do: @github_url
|
|
||||||
|
|
||||||
@spec github_issues_url() :: String.t()
|
|
||||||
def github_issues_url, do: @github_issues_url
|
|
||||||
end
|
|
||||||
@@ -13,6 +13,7 @@ defmodule BDS.Desktop.Layouts do
|
|||||||
<title><%= @page_title || "Blogging Desktop Server" %></title>
|
<title><%= @page_title || "Blogging Desktop Server" %></title>
|
||||||
<meta name="csrf-token" content={Phoenix.Controller.get_csrf_token()} />
|
<meta name="csrf-token" content={Phoenix.Controller.get_csrf_token()} />
|
||||||
<link phx-track-static rel="stylesheet" href="/assets/app.css" />
|
<link phx-track-static rel="stylesheet" href="/assets/app.css" />
|
||||||
|
<link phx-track-static rel="stylesheet" href="/assets/monaco.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<%= @inner_content %>
|
<%= @inner_content %>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ defmodule BDS.Desktop.MainWindow do
|
|||||||
use GenServer
|
use GenServer
|
||||||
|
|
||||||
alias Desktop.Window
|
alias Desktop.Window
|
||||||
|
require Logger
|
||||||
|
|
||||||
@window_id __MODULE__
|
@window_id __MODULE__
|
||||||
@server_name BDS.Desktop.MainWindow.Watcher
|
@server_name BDS.Desktop.MainWindow.Watcher
|
||||||
@@ -22,7 +23,9 @@ defmodule BDS.Desktop.MainWindow do
|
|||||||
def persist_now(timeout \\ 100) do
|
def persist_now(timeout \\ 100) do
|
||||||
GenServer.call(@server_name, :persist_bounds_now, timeout)
|
GenServer.call(@server_name, :persist_bounds_now, timeout)
|
||||||
catch
|
catch
|
||||||
:exit, _reason -> :ok
|
:exit, reason ->
|
||||||
|
Logger.debug("swallowed main_window persist_now exit: #{inspect(reason)}")
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
def window_options(extra_opts \\ []) do
|
def window_options(extra_opts \\ []) do
|
||||||
@@ -169,10 +172,17 @@ defmodule BDS.Desktop.MainWindow do
|
|||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
rescue
|
rescue
|
||||||
ErlangError -> nil
|
error in ErlangError ->
|
||||||
FunctionClauseError -> nil
|
Logger.debug("swallowed main_window restore_bounds error: #{inspect(error)}")
|
||||||
|
nil
|
||||||
|
|
||||||
|
error in FunctionClauseError ->
|
||||||
|
Logger.debug("swallowed main_window restore_bounds error: #{inspect(error)}")
|
||||||
|
nil
|
||||||
catch
|
catch
|
||||||
:exit, _reason -> nil
|
:exit, reason ->
|
||||||
|
Logger.debug("swallowed main_window restore_bounds exit: #{inspect(reason)}")
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -190,7 +200,7 @@ defmodule BDS.Desktop.MainWindow do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp config_dir do
|
defp config_dir do
|
||||||
case :filename.basedir(:user_config, "bds") do
|
case :filename.basedir(:user_config, "BDS2") do
|
||||||
path when is_list(path) -> List.to_string(path)
|
path when is_list(path) -> List.to_string(path)
|
||||||
path -> path
|
path -> path
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,16 +8,45 @@ defmodule BDS.Desktop.MediaController do
|
|||||||
alias BDS.Projects
|
alias BDS.Projects
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
|
|
||||||
def thumbnail(conn, %{"media_id" => media_id} = params) do
|
@web_image_types ~w(image/jpeg image/png image/webp image/gif image/svg+xml image/avif)
|
||||||
case active_media_thumbnail(media_id, Map.get(params, "size")) do
|
|
||||||
{:ok, content_type, path} ->
|
|
||||||
conn
|
|
||||||
|> Plug.Conn.put_resp_content_type(content_type)
|
|
||||||
|> Plug.Conn.send_file(200, path)
|
|
||||||
|
|
||||||
:error ->
|
def thumbnail(conn, %{"media_id" => media_id} = params) do
|
||||||
send_resp(conn, 404, "not found")
|
send_media(conn, active_media_thumbnail(media_id, Map.get(params, "size")))
|
||||||
|
end
|
||||||
|
|
||||||
|
def file(conn, %{"media_id" => media_id}) do
|
||||||
|
send_media(conn, active_media_file(media_id))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp send_media(conn, {:ok, content_type, path}) do
|
||||||
|
conn
|
||||||
|
|> Plug.Conn.put_resp_content_type(content_type)
|
||||||
|
|> Plug.Conn.send_file(200, path)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp send_media(conn, :error), do: send_resp(conn, 404, "not found")
|
||||||
|
|
||||||
|
# Serves the original image at full resolution; formats browsers cannot
|
||||||
|
# render inline fall back to the large thumbnail.
|
||||||
|
defp active_media_file(media_id) do
|
||||||
|
with %{} = project <- Projects.get_active_project(),
|
||||||
|
%MediaRecord{} = media <- Repo.get(MediaRecord, media_id),
|
||||||
|
true <- media.project_id == project.id,
|
||||||
|
true <- media.mime_type in @web_image_types,
|
||||||
|
absolute_path = Path.join(Projects.project_data_dir(project), media.file_path),
|
||||||
|
true <- File.exists?(absolute_path) do
|
||||||
|
{:ok, media.mime_type, absolute_path}
|
||||||
|
else
|
||||||
|
_other -> active_media_thumbnail(media_id, "large")
|
||||||
end
|
end
|
||||||
|
rescue
|
||||||
|
error in [Exqlite.Error, DBConnection.OwnershipError] ->
|
||||||
|
if match?(%Exqlite.Error{}, error) and
|
||||||
|
not String.contains?(Exception.message(error), "no such table") do
|
||||||
|
reraise error, __STACKTRACE__
|
||||||
|
end
|
||||||
|
|
||||||
|
:error
|
||||||
end
|
end
|
||||||
|
|
||||||
defp active_media_thumbnail(media_id, size) do
|
defp active_media_thumbnail(media_id, size) do
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use BDS.Desktop.MenuCompat
|
use BDS.Desktop.MenuCompat
|
||||||
alias BDS.Desktop.{ExternalLinks, ShellData, Shutdown, UILocale}
|
alias BDS.Desktop.{ShellData, Shutdown, UILocale}
|
||||||
alias BDS.UI.Commands
|
alias BDS.UI.Commands
|
||||||
alias BDS.UI.MenuBar, as: ShellMenuBar
|
alias BDS.UI.MenuBar, as: ShellMenuBar
|
||||||
alias Desktop.OS
|
alias Desktop.OS
|
||||||
@@ -25,12 +25,7 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
def mount(menu) do
|
def mount(menu) do
|
||||||
UILocale.put(ShellData.ui_language())
|
UILocale.put(ShellData.ui_language())
|
||||||
|
|
||||||
{:ok,
|
{:ok, Desktop.Menu.assign(menu, :groups, groups(native_menu_opts()))}
|
||||||
Desktop.Menu.assign(
|
|
||||||
menu,
|
|
||||||
:groups,
|
|
||||||
groups(dev_mode?: Application.get_env(:bds, :dev_routes, false))
|
|
||||||
)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@@ -59,7 +54,7 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("view_on_github", menu) do
|
def handle_event("view_on_github", menu) do
|
||||||
OS.launch_default_browser(ExternalLinks.github_url())
|
OS.launch_default_browser("https://github.com/rfc1437/bDS2")
|
||||||
{:noreply, menu}
|
{:noreply, menu}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -72,13 +67,60 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
{:noreply, menu}
|
{:noreply, menu}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("connect_server", menu) do
|
||||||
|
prompt =
|
||||||
|
dgettext("ui", "Server address (user@host or user@host:port), public-key auth:")
|
||||||
|
|
||||||
|
with {:ok, target} <- BDS.Desktop.Dialogs.prompt_text(prompt, "user@host"),
|
||||||
|
{:ok, url} <- BDS.Desktop.RemoteConnection.connect(target) do
|
||||||
|
Window.show(BDS.Desktop.MainWindow.window_id(), url)
|
||||||
|
else
|
||||||
|
:cancel ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
BDS.Desktop.Dialogs.alert(
|
||||||
|
dgettext("ui", "Connect to Server"),
|
||||||
|
dgettext("ui", "Connection failed: %{reason}",
|
||||||
|
reason: BDS.Desktop.RemoteConnection.format_reason(reason)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, menu}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("disconnect_server", menu) do
|
||||||
|
:ok = BDS.Desktop.RemoteConnection.disconnect()
|
||||||
|
Window.show(BDS.Desktop.MainWindow.window_id(), BDS.Desktop.url())
|
||||||
|
{:noreply, menu}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("open_data_folder", menu) do
|
def handle_event("open_data_folder", menu) do
|
||||||
_ = BDS.Desktop.ShellCommands.execute("open_data_folder")
|
_ = BDS.Desktop.ShellCommands.execute("open_data_folder")
|
||||||
{:noreply, menu}
|
{:noreply, menu}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("report_issue", menu) do
|
def handle_event("report_issue", menu) do
|
||||||
OS.launch_default_browser(ExternalLinks.github_issues_url())
|
OS.launch_default_browser("https://github.com/rfc1437/bDS2/issues")
|
||||||
|
{:noreply, menu}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("minimize", menu) do
|
||||||
|
with_frame(&:wxTopLevelWindow.iconize(&1, iconize: true))
|
||||||
|
{:noreply, menu}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("zoom", menu) do
|
||||||
|
with_frame(fn frame ->
|
||||||
|
:wxTopLevelWindow.maximize(frame, maximize: not :wxTopLevelWindow.isMaximized(frame))
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:noreply, menu}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("bring_all_to_front", menu) do
|
||||||
|
with_frame(&:wxWindow.raise/1)
|
||||||
{:noreply, menu}
|
{:noreply, menu}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -91,12 +133,7 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
def handle_info({:set_ui_locale, locale}, menu) do
|
def handle_info({:set_ui_locale, locale}, menu) do
|
||||||
UILocale.put(locale)
|
UILocale.put(locale)
|
||||||
|
|
||||||
{:noreply,
|
{:noreply, Desktop.Menu.assign(menu, :groups, groups(native_menu_opts()))}
|
||||||
Desktop.Menu.assign(
|
|
||||||
menu,
|
|
||||||
:groups,
|
|
||||||
groups(dev_mode?: Application.get_env(:bds, :dev_routes, false))
|
|
||||||
)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info(_, menu) do
|
def handle_info(_, menu) do
|
||||||
@@ -123,6 +160,32 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp native_menu_opts do
|
||||||
|
[
|
||||||
|
dev_mode?: Application.get_env(:bds, :dev_routes, false),
|
||||||
|
window_menu?: OS.type() == MacOS
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp with_frame(fun) do
|
||||||
|
case frame() do
|
||||||
|
nil ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
frame ->
|
||||||
|
:wx.set_env(Desktop.Env.wx_env())
|
||||||
|
fun.(frame)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp frame do
|
||||||
|
try do
|
||||||
|
Window.frame(BDS.Desktop.MainWindow.window_id())
|
||||||
|
catch
|
||||||
|
:exit, _ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp normalize_item(%{separator: true}), do: %{separator: true}
|
defp normalize_item(%{separator: true}), do: %{separator: true}
|
||||||
|
|
||||||
defp normalize_item(item) do
|
defp normalize_item(item) do
|
||||||
@@ -145,12 +208,15 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
defp group_label(:edit), do: dgettext("ui", "Edit")
|
defp group_label(:edit), do: dgettext("ui", "Edit")
|
||||||
defp group_label(:view), do: dgettext("ui", "View")
|
defp group_label(:view), do: dgettext("ui", "View")
|
||||||
defp group_label(:blog), do: dgettext("ui", "Blog")
|
defp group_label(:blog), do: dgettext("ui", "Blog")
|
||||||
|
defp group_label(:window), do: dgettext("ui", "Window")
|
||||||
defp group_label(:help), do: dgettext("ui", "Help")
|
defp group_label(:help), do: dgettext("ui", "Help")
|
||||||
|
|
||||||
defp item_label(:new_post), do: dgettext("ui", "New Post")
|
defp item_label(:new_post), do: dgettext("ui", "New Post")
|
||||||
defp item_label(:import_media), do: dgettext("ui", "Import Media")
|
defp item_label(:import_media), do: dgettext("ui", "Import Media")
|
||||||
defp item_label(:save), do: dgettext("ui", "Save")
|
defp item_label(:save), do: dgettext("ui", "Save")
|
||||||
defp item_label(:open_in_browser), do: dgettext("ui", "Open in Browser")
|
defp item_label(:open_in_browser), do: dgettext("ui", "Open in Browser")
|
||||||
|
defp item_label(:connect_server), do: dgettext("ui", "Connect to Server…")
|
||||||
|
defp item_label(:disconnect_server), do: dgettext("ui", "Disconnect from Server")
|
||||||
defp item_label(:open_data_folder), do: dgettext("ui", "Open Data Folder")
|
defp item_label(:open_data_folder), do: dgettext("ui", "Open Data Folder")
|
||||||
defp item_label(:close_tab), do: dgettext("ui", "Close Tab")
|
defp item_label(:close_tab), do: dgettext("ui", "Close Tab")
|
||||||
defp item_label(:quit), do: dgettext("ui", "Quit")
|
defp item_label(:quit), do: dgettext("ui", "Quit")
|
||||||
@@ -188,8 +254,12 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
defp item_label(:fill_missing_translations), do: dgettext("ui", "Fill Missing Translations")
|
defp item_label(:fill_missing_translations), do: dgettext("ui", "Fill Missing Translations")
|
||||||
defp item_label(:find_duplicates), do: dgettext("ui", "Find Duplicate Posts")
|
defp item_label(:find_duplicates), do: dgettext("ui", "Find Duplicate Posts")
|
||||||
defp item_label(:generate_sitemap), do: dgettext("ui", "Generate Site")
|
defp item_label(:generate_sitemap), do: dgettext("ui", "Generate Site")
|
||||||
|
defp item_label(:force_render_site), do: dgettext("ui", "Force Render Site")
|
||||||
defp item_label(:validate_site), do: dgettext("ui", "Validate Site")
|
defp item_label(:validate_site), do: dgettext("ui", "Validate Site")
|
||||||
defp item_label(:upload_site), do: dgettext("ui", "Upload Site")
|
defp item_label(:upload_site), do: dgettext("ui", "Upload Site")
|
||||||
|
defp item_label(:minimize), do: dgettext("ui", "Minimize")
|
||||||
|
defp item_label(:zoom), do: dgettext("ui", "Zoom")
|
||||||
|
defp item_label(:bring_all_to_front), do: dgettext("ui", "Bring All to Front")
|
||||||
defp item_label(:about), do: dgettext("ui", "About")
|
defp item_label(:about), do: dgettext("ui", "About")
|
||||||
defp item_label(:documentation), do: dgettext("ui", "Documentation")
|
defp item_label(:documentation), do: dgettext("ui", "Documentation")
|
||||||
defp item_label(:api_documentation), do: dgettext("ui", "API Documentation")
|
defp item_label(:api_documentation), do: dgettext("ui", "API Documentation")
|
||||||
|
|||||||
@@ -263,8 +263,13 @@ defmodule BDS.Desktop.Overlay do
|
|||||||
def set_ai_suggestions_error(overlay, _error_message), do: overlay
|
def set_ai_suggestions_error(overlay, _error_message), do: overlay
|
||||||
|
|
||||||
defp normalize_ai_fields(fields) do
|
defp normalize_ai_fields(fields) do
|
||||||
Enum.map(fields, fn %{key: key, label: label, current_value: current,
|
Enum.map(fields, fn %{
|
||||||
suggested_value: suggested, locked: locked} = field ->
|
key: key,
|
||||||
|
label: label,
|
||||||
|
current_value: current,
|
||||||
|
suggested_value: suggested,
|
||||||
|
locked: locked
|
||||||
|
} = field ->
|
||||||
%{
|
%{
|
||||||
key: to_string(key),
|
key: to_string(key),
|
||||||
label: label,
|
label: label,
|
||||||
|
|||||||
180
lib/bds/desktop/remote_connection.ex
Normal file
180
lib/bds/desktop/remote_connection.ex
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
defmodule BDS.Desktop.RemoteConnection do
|
||||||
|
@moduledoc """
|
||||||
|
GUI remote mode (issue #26, phase 5).
|
||||||
|
|
||||||
|
Connects the desktop app to a headless bDS2 server over the same SSH
|
||||||
|
channel (and the same public keys) the TUI uses: `:ssh.connect` with
|
||||||
|
public-key auth, then an OTP TCP/IP tunnel to the server's loopback HTTP
|
||||||
|
endpoint. The webview then loads the local tunnel end — the server never
|
||||||
|
exposes HTTP.
|
||||||
|
|
||||||
|
Client key material lives in the same private `ssh/` directory next to
|
||||||
|
the database (`BDS.Server.ssh_dir/0`): an `id_ed25519`/`id_rsa` identity
|
||||||
|
and a `known_hosts` written on first connect.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@default_ssh_port 2222
|
||||||
|
@tunnel_timeout_ms 10_000
|
||||||
|
|
||||||
|
def start_link(opts \\ []) do
|
||||||
|
case Keyword.pop(opts, :name, __MODULE__) do
|
||||||
|
{nil, init_opts} -> GenServer.start_link(__MODULE__, init_opts)
|
||||||
|
{name, init_opts} -> GenServer.start_link(__MODULE__, init_opts, name: name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Parses `user@host[:port]` into connection parameters. The port is the
|
||||||
|
server's SSH port (default #{@default_ssh_port}).
|
||||||
|
"""
|
||||||
|
@spec parse_target(String.t()) ::
|
||||||
|
{:ok, %{user: String.t(), host: String.t(), port: pos_integer()}}
|
||||||
|
| {:error, :invalid_target}
|
||||||
|
def parse_target(target) when is_binary(target) do
|
||||||
|
with [user, rest] when user != "" <- String.split(String.trim(target), "@", parts: 2),
|
||||||
|
{:ok, host, port} <- parse_host_port(rest) do
|
||||||
|
{:ok, %{user: user, host: host, port: port}}
|
||||||
|
else
|
||||||
|
_other -> {:error, :invalid_target}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_host_port(rest) do
|
||||||
|
case String.split(rest, ":", parts: 2) do
|
||||||
|
[host] when host != "" ->
|
||||||
|
{:ok, host, @default_ssh_port}
|
||||||
|
|
||||||
|
[host, port] when host != "" ->
|
||||||
|
case Integer.parse(port) do
|
||||||
|
{number, ""} when number > 0 -> {:ok, host, number}
|
||||||
|
_other -> :error
|
||||||
|
end
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
:error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Connects and tunnels; returns the local URL the webview should load."
|
||||||
|
@spec connect(GenServer.server(), String.t()) :: {:ok, String.t()} | {:error, term()}
|
||||||
|
def connect(server \\ __MODULE__, target) do
|
||||||
|
GenServer.call(server, {:connect, target}, @tunnel_timeout_ms + 5_000)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec disconnect(GenServer.server()) :: :ok
|
||||||
|
def disconnect(server \\ __MODULE__) do
|
||||||
|
GenServer.call(server, :disconnect)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec status(GenServer.server()) :: :disconnected | {:connected, String.t(), String.t()}
|
||||||
|
def status(server \\ __MODULE__) do
|
||||||
|
GenServer.call(server, :status)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Human-readable connection error for menu/shell error reporting."
|
||||||
|
@spec format_reason(term()) :: String.t()
|
||||||
|
def format_reason(:invalid_target) do
|
||||||
|
dgettext("ui", "Use the form user@host or user@host:port.")
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_reason(%{message: message}) when is_binary(message), do: message
|
||||||
|
def format_reason(reason), do: inspect(reason)
|
||||||
|
|
||||||
|
## GenServer callbacks
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(opts) do
|
||||||
|
overrides = Keyword.get(opts, :test_overrides, [])
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
conn: nil,
|
||||||
|
monitor: nil,
|
||||||
|
target: nil,
|
||||||
|
url: nil,
|
||||||
|
connect_fun: Keyword.get(overrides, :connect_fun, &:ssh.connect/3),
|
||||||
|
tunnel_fun: Keyword.get(overrides, :tunnel_fun, &:ssh.tcpip_tunnel_to_server/6),
|
||||||
|
close_fun: Keyword.get(overrides, :close_fun, &:ssh.close/1),
|
||||||
|
ssh_dir_fun: Keyword.get(overrides, :ssh_dir_fun, &BDS.Server.ssh_dir/0)
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call({:connect, target}, _from, state) do
|
||||||
|
state = close_current(state)
|
||||||
|
|
||||||
|
with {:ok, parsed} <- parse_target(target),
|
||||||
|
{:ok, conn} <- do_connect(state, parsed),
|
||||||
|
{:ok, local_port} <- do_tunnel(state, conn) do
|
||||||
|
url = "http://127.0.0.1:#{local_port}/"
|
||||||
|
|
||||||
|
{:reply, {:ok, url},
|
||||||
|
%{
|
||||||
|
state
|
||||||
|
| conn: conn,
|
||||||
|
monitor: Process.monitor(conn),
|
||||||
|
target: String.trim(target),
|
||||||
|
url: url
|
||||||
|
}}
|
||||||
|
else
|
||||||
|
{:error, reason} = error ->
|
||||||
|
Logger.warning("Remote connection to #{inspect(target)} failed: #{inspect(reason)}")
|
||||||
|
{:reply, error, state}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call(:disconnect, _from, state) do
|
||||||
|
{:reply, :ok, close_current(state)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call(:status, _from, %{conn: nil} = state) do
|
||||||
|
{:reply, :disconnected, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_call(:status, _from, state) do
|
||||||
|
{:reply, {:connected, state.target, state.url}, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:DOWN, monitor, :process, _pid, reason}, %{monitor: monitor} = state) do
|
||||||
|
Logger.warning("Remote SSH connection lost: #{inspect(reason)}")
|
||||||
|
{:noreply, %{state | conn: nil, monitor: nil, target: nil, url: nil}}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info(_message, state), do: {:noreply, state}
|
||||||
|
|
||||||
|
## Internal helpers
|
||||||
|
|
||||||
|
defp do_connect(state, %{user: user, host: host, port: port}) do
|
||||||
|
ssh_dir = BDS.Server.ensure_ssh_dir!(state.ssh_dir_fun.())
|
||||||
|
|
||||||
|
state.connect_fun.(String.to_charlist(host), port,
|
||||||
|
user: String.to_charlist(user),
|
||||||
|
user_dir: String.to_charlist(ssh_dir),
|
||||||
|
auth_methods: ~c"publickey",
|
||||||
|
# First connect records the host key in <ssh_dir>/known_hosts;
|
||||||
|
# later connects verify against it (trust-on-first-use).
|
||||||
|
silently_accept_hosts: true,
|
||||||
|
connect_timeout: @tunnel_timeout_ms
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_tunnel(state, conn) do
|
||||||
|
remote_port = Application.get_env(:bds, :desktop)[:port] || 4010
|
||||||
|
|
||||||
|
state.tunnel_fun.(conn, ~c"127.0.0.1", 0, ~c"127.0.0.1", remote_port, @tunnel_timeout_ms)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp close_current(%{conn: nil} = state), do: state
|
||||||
|
|
||||||
|
defp close_current(state) do
|
||||||
|
if state.monitor, do: Process.demonitor(state.monitor, [:flush])
|
||||||
|
_ = state.close_fun.(state.conn)
|
||||||
|
%{state | conn: nil, monitor: nil, target: nil, url: nil}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -19,6 +19,7 @@ defmodule BDS.Desktop.Router do
|
|||||||
|
|
||||||
get("/health", HealthController, :show)
|
get("/health", HealthController, :show)
|
||||||
get("/media-thumbnail/:media_id", MediaController, :thumbnail)
|
get("/media-thumbnail/:media_id", MediaController, :thumbnail)
|
||||||
|
get("/media-file/:media_id", MediaController, :file)
|
||||||
|
|
||||||
live_session :desktop_shell,
|
live_session :desktop_shell,
|
||||||
root_layout: {BDS.Desktop.Layouts, :root} do
|
root_layout: {BDS.Desktop.Layouts, :root} do
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
defmodule BDS.Desktop.Server do
|
defmodule BDS.Desktop.Server do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use GenServer
|
|
||||||
|
|
||||||
def child_spec(opts) do
|
def child_spec(opts) do
|
||||||
%{
|
Supervisor.child_spec(
|
||||||
id: __MODULE__,
|
{Bandit,
|
||||||
start: {__MODULE__, :start_link, [opts]}
|
Keyword.merge(opts,
|
||||||
}
|
plug: BDS.Desktop.Endpoint,
|
||||||
end
|
scheme: :http,
|
||||||
|
ip: {127, 0, 0, 1},
|
||||||
def start_link(opts) do
|
port: port(),
|
||||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
startup_log: false
|
||||||
|
)},
|
||||||
|
id: __MODULE__
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def url do
|
def url do
|
||||||
@@ -24,18 +25,4 @@ defmodule BDS.Desktop.Server do
|
|||||||
_other -> Application.get_env(:bds, :desktop)[:port] || 4010
|
_other -> Application.get_env(:bds, :desktop)[:port] || 4010
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
|
||||||
def init(_opts) do
|
|
||||||
{:ok, bandit_pid} =
|
|
||||||
Bandit.start_link(
|
|
||||||
plug: BDS.Desktop.Endpoint,
|
|
||||||
scheme: :http,
|
|
||||||
ip: {127, 0, 0, 1},
|
|
||||||
port: port(),
|
|
||||||
startup_log: false
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, %{bandit_pid: bandit_pid}}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -120,16 +120,7 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
"rebuild_embedding_index",
|
"rebuild_embedding_index",
|
||||||
"Rebuild Embedding Index",
|
"Rebuild Embedding Index",
|
||||||
"Embeddings",
|
"Embeddings",
|
||||||
fn report ->
|
fn report -> Maintenance.rebuild_embedding_index(project.id, on_progress: report) end
|
||||||
{:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report)
|
|
||||||
report.(1.0, "Embedding index rebuilt")
|
|
||||||
|
|
||||||
%{
|
|
||||||
project_id: project.id,
|
|
||||||
rebuilt_post_ids: rebuilt_post_ids,
|
|
||||||
rebuilt_count: length(rebuilt_post_ids)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -205,6 +196,14 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp dispatch("rebuild_media_links", project, _params) do
|
||||||
|
queue_task(project, "rebuild_media_links", "Rebuild Media Links", "Maintenance", fn report ->
|
||||||
|
{:ok, result} = BDS.Media.rebuild_media_links(project.id, on_progress: report)
|
||||||
|
report.(1.0, "Media links rebuilt")
|
||||||
|
%{project_id: project.id, counts: %{media_links: result.links}}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp dispatch("regenerate_missing_thumbnails", project, _params) do
|
defp dispatch("regenerate_missing_thumbnails", project, _params) do
|
||||||
queue_task(
|
queue_task(
|
||||||
project,
|
project,
|
||||||
@@ -222,7 +221,7 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
defp dispatch("rebuild_database", project, _params) do
|
defp dispatch("rebuild_database", project, _params) do
|
||||||
group_id = task_group_id("rebuild_database")
|
group_id = task_group_id("rebuild_database")
|
||||||
attrs = %{group_id: group_id, group_name: "Maintenance"}
|
attrs = %{group_id: group_id, group_name: "Maintenance"}
|
||||||
[first_step | remaining_steps] = rebuild_database_steps(project)
|
[first_step | remaining_steps] = Maintenance.full_rebuild_steps(project.id)
|
||||||
|
|
||||||
{:ok, posts_task} =
|
{:ok, posts_task} =
|
||||||
Tasks.submit_task(first_step.name, first_step.work, attrs)
|
Tasks.submit_task(first_step.name, first_step.work, attrs)
|
||||||
@@ -248,18 +247,13 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("generate_sitemap", project, _params) do
|
defp dispatch("generate_sitemap", project, _params) do
|
||||||
queue_task(project, "generate_sitemap", "Generate Site", "Generation", fn report ->
|
queue_render_site(project, "generate_sitemap", "Render Site", [])
|
||||||
{:ok, generation} =
|
end
|
||||||
Generation.generate_site(project.id, @site_sections, on_progress: report)
|
|
||||||
|
|
||||||
report.(1.0, "Generated site output")
|
# Forced variant of the site render: ignores the stored content hashes and
|
||||||
|
# rewrites every output file, repairing any drift on disk.
|
||||||
%{
|
defp dispatch("force_render_site", project, _params) do
|
||||||
project_id: project.id,
|
queue_render_site(project, "force_render_site", "Force Render Site", force: true)
|
||||||
sections: generation.sections,
|
|
||||||
generated_count: length(generation.generated_files)
|
|
||||||
}
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("validate_site", project, _params) do
|
defp dispatch("validate_site", project, _params) do
|
||||||
@@ -271,6 +265,42 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp dispatch("apply_site_validation", project, params) do
|
||||||
|
validation_report = normalize_apply_validation_report(BDS.MapUtils.attr(params, :report, %{}))
|
||||||
|
group_id = task_group_id("apply_site_validation")
|
||||||
|
attrs = %{group_id: group_id, group_name: "Apply Site Validation"}
|
||||||
|
|
||||||
|
{:ok, prepare_task} =
|
||||||
|
Tasks.submit_task(
|
||||||
|
"Prepare Validation Apply",
|
||||||
|
fn report ->
|
||||||
|
{:ok, preparation} =
|
||||||
|
Generation.prepare_validation_apply(project.id, validation_report,
|
||||||
|
on_progress: report
|
||||||
|
)
|
||||||
|
|
||||||
|
Map.put(preparation, :project_id, project.id)
|
||||||
|
end,
|
||||||
|
attrs
|
||||||
|
)
|
||||||
|
|
||||||
|
Task.start(fn ->
|
||||||
|
run_apply_validation_sequence(project, validation_report, group_id, attrs, prepare_task.id)
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
kind: "task_queued",
|
||||||
|
action: "apply_site_validation",
|
||||||
|
title: "Apply Site Validation",
|
||||||
|
message: "Apply Site Validation tasks queued",
|
||||||
|
project_id: project.id,
|
||||||
|
task_id: prepare_task.id,
|
||||||
|
task_group_id: group_id,
|
||||||
|
panel_tab: "tasks"
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
|
||||||
defp dispatch("metadata_diff", project, _params) do
|
defp dispatch("metadata_diff", project, _params) do
|
||||||
queue_task(project, "metadata_diff", "Metadata Diff", "Maintenance", fn report ->
|
queue_task(project, "metadata_diff", "Metadata Diff", "Maintenance", fn report ->
|
||||||
{:ok, metadata_diff} = Maintenance.metadata_diff(project.id, on_progress: report)
|
{:ok, metadata_diff} = Maintenance.metadata_diff(project.id, on_progress: report)
|
||||||
@@ -448,6 +478,207 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp queue_render_site(project, action, title, render_opts) do
|
||||||
|
group_id = task_group_id(action)
|
||||||
|
attrs = %{group_id: group_id, group_name: title}
|
||||||
|
|
||||||
|
{:ok, first_task} =
|
||||||
|
Tasks.submit_task(
|
||||||
|
section_task_name(:core),
|
||||||
|
fn report ->
|
||||||
|
{:ok, _result} =
|
||||||
|
Generation.render_site_section(
|
||||||
|
project.id,
|
||||||
|
:core,
|
||||||
|
[on_progress: report] ++ render_opts
|
||||||
|
)
|
||||||
|
|
||||||
|
report.(1.0, section_complete_message(:core))
|
||||||
|
:ok
|
||||||
|
end,
|
||||||
|
attrs
|
||||||
|
)
|
||||||
|
|
||||||
|
Task.start(fn -> run_render_site_sequence(project, group_id, attrs, render_opts) end)
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
kind: "task_queued",
|
||||||
|
action: action,
|
||||||
|
title: title,
|
||||||
|
message: title <> " tasks queued",
|
||||||
|
project_id: project.id,
|
||||||
|
task_id: first_task.id,
|
||||||
|
task_group_id: group_id,
|
||||||
|
panel_tab: "tasks"
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run_render_site_sequence(project, group_id, attrs, render_opts) do
|
||||||
|
remaining_sections = [:single, :category, :tag, :date]
|
||||||
|
|
||||||
|
Enum.each(remaining_sections, fn section ->
|
||||||
|
{:ok, _task} =
|
||||||
|
Tasks.submit_task(
|
||||||
|
section_task_name(section),
|
||||||
|
fn report ->
|
||||||
|
{:ok, _result} =
|
||||||
|
Generation.render_site_section(
|
||||||
|
project.id,
|
||||||
|
section,
|
||||||
|
[on_progress: report] ++ render_opts
|
||||||
|
)
|
||||||
|
|
||||||
|
report.(1.0, section_complete_message(section))
|
||||||
|
:ok
|
||||||
|
end,
|
||||||
|
attrs
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
section_names = Enum.map([:core | remaining_sections], §ion_task_name/1)
|
||||||
|
|
||||||
|
with :ok <- wait_for_group_phase(group_id, section_names, @rebuild_phase_timeout) do
|
||||||
|
{:ok, _task} =
|
||||||
|
Tasks.submit_task(
|
||||||
|
"Build Search Index",
|
||||||
|
fn report ->
|
||||||
|
{:ok, _result} =
|
||||||
|
Generation.build_site_search_index(
|
||||||
|
project.id,
|
||||||
|
[on_progress: report] ++ render_opts
|
||||||
|
)
|
||||||
|
|
||||||
|
report.(1.0, "Build Search Index complete")
|
||||||
|
:ok
|
||||||
|
end,
|
||||||
|
attrs
|
||||||
|
)
|
||||||
|
|
||||||
|
wait_for_group_phase(group_id, ["Build Search Index"], @rebuild_phase_timeout)
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run_apply_validation_sequence(project, validation_report, group_id, attrs, prepare_task_id) do
|
||||||
|
with :ok <-
|
||||||
|
wait_for_group_phase(group_id, ["Prepare Validation Apply"], @rebuild_phase_timeout),
|
||||||
|
{:ok, preparation} <- completed_task_result(prepare_task_id),
|
||||||
|
:ok <-
|
||||||
|
run_apply_validation_render_tasks(
|
||||||
|
project,
|
||||||
|
validation_report,
|
||||||
|
group_id,
|
||||||
|
attrs,
|
||||||
|
preparation
|
||||||
|
) do
|
||||||
|
if validation_apply_changed?(preparation) do
|
||||||
|
:ok = run_apply_validation_refresh_task(project, group_id, attrs, :calendar)
|
||||||
|
end
|
||||||
|
|
||||||
|
submit_apply_validation_check_task(project, attrs)
|
||||||
|
else
|
||||||
|
_other -> :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run_apply_validation_render_tasks(project, validation_report, group_id, attrs, preparation) do
|
||||||
|
sections = Map.get(preparation, :sections_to_render, [])
|
||||||
|
task_names = Enum.map(sections, §ion_task_name/1)
|
||||||
|
|
||||||
|
Enum.each(sections, fn section ->
|
||||||
|
task_name = section_task_name(section)
|
||||||
|
|
||||||
|
{:ok, _task} =
|
||||||
|
Tasks.submit_task(
|
||||||
|
task_name,
|
||||||
|
fn report ->
|
||||||
|
{:ok, _result} =
|
||||||
|
Generation.apply_validation_section(project.id, validation_report, section,
|
||||||
|
on_progress: report
|
||||||
|
)
|
||||||
|
|
||||||
|
report.(1.0, section_complete_message(section))
|
||||||
|
:ok
|
||||||
|
end,
|
||||||
|
attrs
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
case task_names do
|
||||||
|
[] -> :ok
|
||||||
|
names -> wait_for_group_phase(group_id, names, @rebuild_phase_timeout)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run_apply_validation_refresh_task(project, group_id, attrs, kind) do
|
||||||
|
task_name = apply_validation_refresh_task_name(kind)
|
||||||
|
|
||||||
|
{:ok, _task} =
|
||||||
|
Tasks.submit_task(
|
||||||
|
task_name,
|
||||||
|
fn report ->
|
||||||
|
{:ok, _result} =
|
||||||
|
Generation.refresh_validation_ancillary_outputs(project.id, kind, on_progress: report)
|
||||||
|
|
||||||
|
report.(1.0, apply_validation_refresh_complete_message(kind))
|
||||||
|
:ok
|
||||||
|
end,
|
||||||
|
attrs
|
||||||
|
)
|
||||||
|
|
||||||
|
wait_for_group_phase(group_id, [task_name], @rebuild_phase_timeout)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp submit_apply_validation_check_task(project, attrs) do
|
||||||
|
{:ok, _task} =
|
||||||
|
Tasks.submit_task(
|
||||||
|
"Validate Site",
|
||||||
|
fn report ->
|
||||||
|
{:ok, validation} =
|
||||||
|
Generation.validate_site(project.id, @site_sections, on_progress: report)
|
||||||
|
|
||||||
|
site_validation_result(project.id, validation)
|
||||||
|
end,
|
||||||
|
attrs
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp completed_task_result(task_id) do
|
||||||
|
case Tasks.get_task(task_id) do
|
||||||
|
%{status: :completed, result: result} when is_map(result) -> {:ok, result}
|
||||||
|
%{status: :failed, error: error} -> {:error, error}
|
||||||
|
_other -> {:error, :task_result_unavailable}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validation_apply_changed?(preparation) do
|
||||||
|
Map.get(preparation, :sections_to_render, []) != [] or
|
||||||
|
Map.get(preparation, :deleted_url_count, 0) > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
defp section_task_name(:core), do: "Render Site Core"
|
||||||
|
defp section_task_name(:single), do: "Render Single Posts"
|
||||||
|
defp section_task_name(:category), do: "Render Category Archives"
|
||||||
|
defp section_task_name(:tag), do: "Render Tag Archives"
|
||||||
|
defp section_task_name(:date), do: "Render Date Archives"
|
||||||
|
|
||||||
|
defp section_complete_message(:core), do: "Render Site Core complete"
|
||||||
|
defp section_complete_message(:single), do: "Render Single Posts complete"
|
||||||
|
|
||||||
|
defp section_complete_message(:category),
|
||||||
|
do: "Render Category Archives complete"
|
||||||
|
|
||||||
|
defp section_complete_message(:tag), do: "Render Tag Archives complete"
|
||||||
|
defp section_complete_message(:date), do: "Render Date Archives complete"
|
||||||
|
|
||||||
|
defp apply_validation_refresh_task_name(:calendar), do: "Regenerate Calendar"
|
||||||
|
|
||||||
|
defp apply_validation_refresh_complete_message(:calendar), do: "Regenerate Calendar complete"
|
||||||
|
|
||||||
defp translation_fill_enabled?(metadata) do
|
defp translation_fill_enabled?(metadata) do
|
||||||
([metadata.main_language] ++ metadata.blog_languages)
|
([metadata.main_language] ++ metadata.blog_languages)
|
||||||
|> Enum.map(fn language ->
|
|> Enum.map(fn language ->
|
||||||
@@ -461,83 +692,6 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
|> length() > 1
|
|> length() > 1
|
||||||
end
|
end
|
||||||
|
|
||||||
defp rebuild_database_steps(project) do
|
|
||||||
[
|
|
||||||
%{
|
|
||||||
name: "Rebuild Posts From Files",
|
|
||||||
work: fn report ->
|
|
||||||
{:ok, posts} =
|
|
||||||
Maintenance.rebuild_from_filesystem(project.id, "post",
|
|
||||||
on_progress: report,
|
|
||||||
rebuild_embeddings: false
|
|
||||||
)
|
|
||||||
|
|
||||||
report.(1.0, "Post rebuild complete")
|
|
||||||
%{project_id: project.id, counts: %{posts: length(posts)}}
|
|
||||||
end
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Rebuild Media From Files",
|
|
||||||
work: fn report ->
|
|
||||||
{:ok, media} =
|
|
||||||
Maintenance.rebuild_from_filesystem(project.id, "media", on_progress: report)
|
|
||||||
|
|
||||||
report.(1.0, "Media rebuild complete")
|
|
||||||
%{project_id: project.id, counts: %{media: length(media)}}
|
|
||||||
end
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Rebuild Scripts From Files",
|
|
||||||
work: fn report ->
|
|
||||||
{:ok, scripts} =
|
|
||||||
Maintenance.rebuild_from_filesystem(project.id, "script", on_progress: report)
|
|
||||||
|
|
||||||
report.(1.0, "Script rebuild complete")
|
|
||||||
%{project_id: project.id, counts: %{scripts: length(scripts)}}
|
|
||||||
end
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Rebuild Templates From Files",
|
|
||||||
work: fn report ->
|
|
||||||
{:ok, templates} =
|
|
||||||
Maintenance.rebuild_from_filesystem(project.id, "template", on_progress: report)
|
|
||||||
|
|
||||||
report.(1.0, "Template rebuild complete")
|
|
||||||
%{project_id: project.id, counts: %{templates: length(templates)}}
|
|
||||||
end
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Rebuild Post Links",
|
|
||||||
work: fn report ->
|
|
||||||
:ok = Posts.rebuild_post_links(project.id, on_progress: report)
|
|
||||||
report.(1.0, "Post links rebuilt")
|
|
||||||
%{project_id: project.id}
|
|
||||||
end
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Regenerate Missing Thumbnails",
|
|
||||||
work: fn report ->
|
|
||||||
result = BDS.Media.regenerate_missing_thumbnails(project.id, on_progress: report)
|
|
||||||
report.(1.0, "Missing thumbnails regenerated")
|
|
||||||
Map.put(result, :project_id, project.id)
|
|
||||||
end
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
name: "Rebuild Embedding Index",
|
|
||||||
work: fn report ->
|
|
||||||
{:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report)
|
|
||||||
report.(1.0, "Embedding index rebuilt")
|
|
||||||
|
|
||||||
%{
|
|
||||||
project_id: project.id,
|
|
||||||
rebuilt_post_ids: rebuilt_post_ids,
|
|
||||||
rebuilt_count: length(rebuilt_post_ids)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
}
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
defp run_rebuild_sequence(_group_id, _attrs, []), do: :ok
|
defp run_rebuild_sequence(_group_id, _attrs, []), do: :ok
|
||||||
|
|
||||||
defp run_rebuild_sequence(group_id, attrs, [step | remaining_steps]) do
|
defp run_rebuild_sequence(group_id, attrs, [step | remaining_steps]) do
|
||||||
@@ -549,27 +703,62 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp wait_for_group_phase(_group_id, _names, timeout) when timeout <= 0, do: :timeout
|
|
||||||
|
|
||||||
defp wait_for_group_phase(group_id, names, timeout) do
|
defp wait_for_group_phase(group_id, names, timeout) do
|
||||||
|
if timeout <= 0 do
|
||||||
|
:timeout
|
||||||
|
else
|
||||||
|
Phoenix.PubSub.subscribe(BDS.PubSub, Tasks.topic())
|
||||||
|
|
||||||
|
try do
|
||||||
|
case group_phase_status(group_id, names) do
|
||||||
|
:waiting -> wait_for_group_phase_message(group_id, names, timeout)
|
||||||
|
status -> status
|
||||||
|
end
|
||||||
|
after
|
||||||
|
Phoenix.PubSub.unsubscribe(BDS.PubSub, Tasks.topic())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp wait_for_group_phase_message(group_id, names, timeout) do
|
||||||
|
started_at = System.monotonic_time(:millisecond)
|
||||||
|
|
||||||
|
receive do
|
||||||
|
{:task_terminal, task} ->
|
||||||
|
elapsed = System.monotonic_time(:millisecond) - started_at
|
||||||
|
|
||||||
|
cond do
|
||||||
|
task.group_id == group_id and task.name in names and task.status == :failed ->
|
||||||
|
:failed
|
||||||
|
|
||||||
|
task.group_id == group_id and task.name in names ->
|
||||||
|
case group_phase_status(group_id, names) do
|
||||||
|
:waiting ->
|
||||||
|
wait_for_group_phase_message(group_id, names, timeout - elapsed)
|
||||||
|
|
||||||
|
status ->
|
||||||
|
status
|
||||||
|
end
|
||||||
|
|
||||||
|
true ->
|
||||||
|
wait_for_group_phase_message(group_id, names, timeout - elapsed)
|
||||||
|
end
|
||||||
|
after
|
||||||
|
timeout ->
|
||||||
|
:timeout
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp group_phase_status(group_id, names) do
|
||||||
tasks =
|
tasks =
|
||||||
BDS.Tasks.list_tasks()
|
BDS.Tasks.list_tasks()
|
||||||
|> Enum.filter(&(&1.group_id == group_id and &1.name in names))
|
|> Enum.filter(&(&1.group_id == group_id and &1.name in names))
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
length(tasks) < length(names) ->
|
length(tasks) < length(names) -> :waiting
|
||||||
Process.sleep(50)
|
Enum.any?(tasks, &(&1.status == :failed)) -> :failed
|
||||||
wait_for_group_phase(group_id, names, timeout - 50)
|
Enum.all?(tasks, &(&1.status == :completed)) -> :ok
|
||||||
|
true -> :waiting
|
||||||
Enum.any?(tasks, &(&1.status == :failed)) ->
|
|
||||||
:failed
|
|
||||||
|
|
||||||
Enum.all?(tasks, &(&1.status == :completed)) ->
|
|
||||||
:ok
|
|
||||||
|
|
||||||
true ->
|
|
||||||
Process.sleep(50)
|
|
||||||
wait_for_group_phase(group_id, names, timeout - 50)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -595,6 +784,20 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
"http://#{server.host}:#{server.port}/"
|
"http://#{server.host}:#{server.port}/"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp normalize_apply_validation_report(report) when is_map(report) do
|
||||||
|
%{
|
||||||
|
sitemap_path: BDS.MapUtils.attr(report, :sitemap_path),
|
||||||
|
sitemap_changed: BDS.MapUtils.attr(report, :sitemap_changed, false),
|
||||||
|
missing_url_paths: BDS.MapUtils.attr(report, :missing_url_paths, []),
|
||||||
|
extra_url_paths: BDS.MapUtils.attr(report, :extra_url_paths, []),
|
||||||
|
updated_post_url_paths: BDS.MapUtils.attr(report, :updated_post_url_paths, []),
|
||||||
|
expected_url_count: BDS.MapUtils.attr(report, :expected_url_count, 0),
|
||||||
|
existing_html_url_count: BDS.MapUtils.attr(report, :existing_html_url_count, 0)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_apply_validation_report(_report), do: %{}
|
||||||
|
|
||||||
defp normalize_site_validation(report) do
|
defp normalize_site_validation(report) do
|
||||||
%{
|
%{
|
||||||
sitemap_path: report.sitemap_path,
|
sitemap_path: report.sitemap_path,
|
||||||
|
|||||||
@@ -91,32 +91,16 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def assistant_cards do
|
# Accepts the caller's already-resolved locale so hot render paths never
|
||||||
[
|
# re-read the ui.language setting from the database.
|
||||||
%{
|
def editor_meta(task_status, ui_language \\ nil) do
|
||||||
label: dgettext("ui", "Offline Gate"),
|
|
||||||
text: dgettext("ui", "Automatic AI actions stay gated by airplane mode.")
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
label: dgettext("ui", "Filesystem Sync"),
|
|
||||||
text:
|
|
||||||
dgettext("ui", "Metadata flush, diffing, and rebuild hooks still need editor wiring.")
|
|
||||||
},
|
|
||||||
%{
|
|
||||||
label: dgettext("ui", "Desktop Runtime"),
|
|
||||||
text: dgettext("ui", "The app window is now served from LiveView state.")
|
|
||||||
}
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
def editor_meta(task_status) do
|
|
||||||
[
|
[
|
||||||
%{
|
%{
|
||||||
label: dgettext("ui", "Status"),
|
label: dgettext("ui", "Status"),
|
||||||
value: task_status.running_task_message || dgettext("ui", "Idle")
|
value: task_status.running_task_message || dgettext("ui", "Idle")
|
||||||
},
|
},
|
||||||
%{label: dgettext("ui", "Mode"), value: dgettext("ui", "Offline")},
|
%{label: dgettext("ui", "Mode"), value: dgettext("ui", "Offline")},
|
||||||
%{label: dgettext("ui", "Main Language"), value: ui_language()}
|
%{label: dgettext("ui", "Main Language"), value: ui_language || ui_language()}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -125,7 +109,7 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
post_count: dashboard.post_stats.total_posts,
|
post_count: dashboard.post_stats.total_posts,
|
||||||
media_count: dashboard.media_stats.media_count,
|
media_count: dashboard.media_stats.media_count,
|
||||||
theme_badge: "desktop-shell",
|
theme_badge: "desktop-shell",
|
||||||
ui_language: Keyword.get(opts, :ui_language, ui_language()),
|
ui_language: Keyword.get_lazy(opts, :ui_language, &ui_language/0),
|
||||||
offline_mode: Keyword.get(opts, :offline_mode, true),
|
offline_mode: Keyword.get(opts, :offline_mode, true),
|
||||||
running_task_message: task_status.running_task_message,
|
running_task_message: task_status.running_task_message,
|
||||||
running_task_overflow: task_status.running_task_overflow,
|
running_task_overflow: task_status.running_task_overflow,
|
||||||
|
|||||||
@@ -5,23 +5,26 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
import Phoenix.HTML
|
import Phoenix.HTML
|
||||||
|
|
||||||
alias BDS.{AI, BoundedAtoms, Metadata}
|
alias BDS.{AI, Blogmark, BoundedAtoms, Metadata}
|
||||||
alias BDS.CliSync.Watcher
|
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData, UILocale}
|
||||||
alias BDS.Desktop.{ExternalLinks, FilePicker, FolderPicker, ShellData, UILocale}
|
|
||||||
|
|
||||||
alias BDS.Desktop.ShellLive.{
|
alias BDS.Desktop.ShellLive.{
|
||||||
Bridges,
|
Bridges,
|
||||||
ChatEditor,
|
ChatEditor,
|
||||||
GalleryImport,
|
GalleryImport,
|
||||||
|
GitHandler,
|
||||||
|
GitRun,
|
||||||
ImportEditor,
|
ImportEditor,
|
||||||
MediaEditor,
|
MediaEditor,
|
||||||
MenuEditor,
|
MenuEditor,
|
||||||
MiscEditor,
|
MiscEditor,
|
||||||
|
NativeMenuEvents,
|
||||||
OverlayManager,
|
OverlayManager,
|
||||||
ScriptEditor,
|
ScriptEditor,
|
||||||
SettingsEditor,
|
SettingsEditor,
|
||||||
SidebarDelete,
|
SidebarDelete,
|
||||||
TagsEditor,
|
TagsEditor,
|
||||||
|
TabActions,
|
||||||
TemplateEditor
|
TemplateEditor
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,25 +32,20 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
alias BDS.Desktop.ShellLive.PostEditor
|
alias BDS.Desktop.ShellLive.PostEditor
|
||||||
alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents
|
alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents
|
||||||
alias BDS.Desktop.ShellLive.SidebarEvents
|
alias BDS.Desktop.ShellLive.SidebarEvents
|
||||||
alias BDS.Desktop.ShellLive.SidebarState, as: ShellSidebarState
|
|
||||||
|
|
||||||
alias BDS.Desktop.ShellLive.{
|
alias BDS.Desktop.ShellLive.{
|
||||||
ChatSurface,
|
ChatSurface,
|
||||||
|
ContentState,
|
||||||
Layout,
|
Layout,
|
||||||
PanelRenderer,
|
|
||||||
SessionUtil,
|
SessionUtil,
|
||||||
ShellCommandRunner,
|
ShellCommandRunner,
|
||||||
SidebarCreate,
|
SidebarCreate,
|
||||||
|
SocketState,
|
||||||
TabHelpers,
|
TabHelpers,
|
||||||
TaskLocalization,
|
TitlebarMenu,
|
||||||
TitlebarMenu
|
UrlState
|
||||||
}
|
}
|
||||||
|
|
||||||
import TaskLocalization,
|
|
||||||
only: [
|
|
||||||
localize_task_status: 2
|
|
||||||
]
|
|
||||||
|
|
||||||
import TabHelpers,
|
import TabHelpers,
|
||||||
only: [
|
only: [
|
||||||
tab_id_for_route: 2,
|
tab_id_for_route: 2,
|
||||||
@@ -65,7 +63,6 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
def refresh_interval, do: @refresh_interval
|
def refresh_interval, do: @refresh_interval
|
||||||
|
|
||||||
@output_entry_limit 20
|
|
||||||
@sidebar_filter_events [
|
@sidebar_filter_events [
|
||||||
"toggle_sidebar_filters",
|
"toggle_sidebar_filters",
|
||||||
"toggle_sidebar_archive",
|
"toggle_sidebar_archive",
|
||||||
@@ -84,6 +81,10 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
"load_more_sidebar"
|
"load_more_sidebar"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@git_action_events ["git_fetch", "git_pull", "git_push", "git_prune_lfs"]
|
||||||
|
|
||||||
|
@native_menu_events NativeMenuEvents.events()
|
||||||
|
|
||||||
@layout_menu_actions MapSet.new([
|
@layout_menu_actions MapSet.new([
|
||||||
:toggle_sidebar,
|
:toggle_sidebar,
|
||||||
:toggle_panel,
|
:toggle_panel,
|
||||||
@@ -107,7 +108,9 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
:quit,
|
:quit,
|
||||||
:view_on_github,
|
:view_on_github,
|
||||||
:report_issue,
|
:report_issue,
|
||||||
:about
|
:about,
|
||||||
|
:connect_server,
|
||||||
|
:disconnect_server
|
||||||
])
|
])
|
||||||
@runtime_menu_actions MapSet.new([
|
@runtime_menu_actions MapSet.new([
|
||||||
:undo,
|
:undo,
|
||||||
@@ -137,7 +140,9 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> MapSet.union(
|
|> MapSet.union(
|
||||||
MapSet.new([:validate_translations, :fill_missing_translations, :find_duplicates])
|
MapSet.new([:validate_translations, :fill_missing_translations, :find_duplicates])
|
||||||
)
|
)
|
||||||
|> MapSet.union(MapSet.new([:generate_sitemap, :validate_site, :upload_site]))
|
|> MapSet.union(
|
||||||
|
MapSet.new([:generate_sitemap, :force_render_site, :validate_site, :upload_site])
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
embed_templates("shell_live/*")
|
embed_templates("shell_live/*")
|
||||||
@@ -147,7 +152,9 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
connected = connected?(socket)
|
connected = connected?(socket)
|
||||||
|
|
||||||
if connected do
|
if connected do
|
||||||
Phoenix.PubSub.subscribe(BDS.PubSub, Watcher.topic())
|
# Entity + settings events from every source: in-app contexts, other
|
||||||
|
# connected clients (GUI or TUI), and the CLI sync watcher.
|
||||||
|
:ok = BDS.Events.subscribe()
|
||||||
Process.send_after(self(), :refresh_task_status, @refresh_interval)
|
Process.send_after(self(), :refresh_task_status, @refresh_interval)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -173,11 +180,14 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> assign(:chat_editor_request_refs, %{})
|
|> assign(:chat_editor_request_refs, %{})
|
||||||
|> assign(:file_picker_task, nil)
|
|> assign(:file_picker_task, nil)
|
||||||
|> assign(:shell_overlay, nil)
|
|> assign(:shell_overlay, nil)
|
||||||
|
|> assign(:git_run, nil)
|
||||||
|> assign(:output_entries, [])
|
|> assign(:output_entries, [])
|
||||||
|
|> assign(:collapsed_task_groups, MapSet.new())
|
||||||
|> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|
|> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|
||||||
|> assign(:panel_git_entries, [])
|
|> assign(:panel_git_entries, [])
|
||||||
|
|> assign(:auto_save_timers, %{})
|
||||||
|> reload_shell(workbench)
|
|> reload_shell(workbench)
|
||||||
|> apply_url_params(params)
|
|> UrlState.apply_params(params)
|
||||||
|> tap(&sync_menu_bar_locale/1)}
|
|> tap(&sync_menu_bar_locale/1)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -191,7 +201,8 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("toggle_assistant_sidebar", _params, socket) do
|
def handle_event("toggle_assistant_sidebar", _params, socket) do
|
||||||
{:noreply, refresh_layout(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))}
|
{:noreply,
|
||||||
|
refresh_layout(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("select_view", %{"view" => view_id}, socket) do
|
def handle_event("select_view", %{"view" => view_id}, socket) do
|
||||||
@@ -204,7 +215,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> refresh_sidebar(workbench)
|
|> refresh_sidebar(workbench)
|
||||||
|> push_url_state()}
|
|> UrlState.push()}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("select_panel_tab", %{"tab" => tab}, socket) do
|
def handle_event("select_panel_tab", %{"tab" => tab}, socket) do
|
||||||
@@ -216,6 +227,38 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
{:noreply, refresh_layout(socket, workbench)}
|
{:noreply, refresh_layout(socket, workbench)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Copy-all for the Output panel: entries are stored newest-first, so flip
|
||||||
|
# them back to chronological order before joining (old-app behavior).
|
||||||
|
def handle_event("copy_output", _params, socket) do
|
||||||
|
text =
|
||||||
|
socket.assigns.output_entries
|
||||||
|
|> Enum.reverse()
|
||||||
|
|> Enum.map_join("\n\n", & &1.message)
|
||||||
|
|
||||||
|
{:noreply, push_event(socket, "clipboard", %{text: text})}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("toggle_task_group", %{"group" => group_id}, socket) do
|
||||||
|
collapsed = socket.assigns[:collapsed_task_groups] || MapSet.new()
|
||||||
|
|
||||||
|
collapsed =
|
||||||
|
if MapSet.member?(collapsed, group_id),
|
||||||
|
do: MapSet.delete(collapsed, group_id),
|
||||||
|
else: MapSet.put(collapsed, group_id)
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :collapsed_task_groups, collapsed)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("cancel_task", %{"id" => task_id}, socket) do
|
||||||
|
_ = BDS.Tasks.cancel_task(task_id)
|
||||||
|
|
||||||
|
task_status =
|
||||||
|
BDS.Tasks.status_snapshot()
|
||||||
|
|> BDS.Desktop.ShellLive.TaskLocalization.localize_task_status(socket.assigns.page_language)
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :task_status, task_status)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("open_sidebar_item", %{"route" => _route, "id" => _id} = params, socket) do
|
def handle_event("open_sidebar_item", %{"route" => _route, "id" => _id} = params, socket) do
|
||||||
{:noreply, open_sidebar_item(socket, params, :preview)}
|
{:noreply, open_sidebar_item(socket, params, :preview)}
|
||||||
end
|
end
|
||||||
@@ -236,6 +279,37 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
SidebarEvents.handle(socket, event, params, &refresh_sidebar/2)
|
SidebarEvents.handle(socket, event, params, &refresh_sidebar/2)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event(event, _params, socket) when event in @git_action_events do
|
||||||
|
if GitRun.network_event?(event) do
|
||||||
|
{:noreply, GitRun.start(socket, event, current_project_id(socket))}
|
||||||
|
else
|
||||||
|
{:noreply, GitHandler.run_action(socket, event)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("git_run_close", _params, socket) do
|
||||||
|
socket = GitRun.close(socket)
|
||||||
|
{:noreply, refresh_sidebar(socket, socket.assigns.workbench)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("git_run_keydown", %{"key" => "Escape"}, socket) do
|
||||||
|
socket = GitRun.close(socket)
|
||||||
|
{:noreply, refresh_sidebar(socket, socket.assigns.workbench)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("git_run_keydown", _params, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
|
def handle_event("git_commit", params, socket) do
|
||||||
|
message = params |> get_in(["git", "message"]) |> to_string() |> String.trim()
|
||||||
|
|
||||||
|
{:noreply, GitHandler.commit(socket, message)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("git_initialize", params, socket) do
|
||||||
|
remote_url = params |> get_in(["git", "remote_url"]) |> GitHandler.normalize_remote_url()
|
||||||
|
{:noreply, GitHandler.initialize(socket, remote_url)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do
|
def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do
|
||||||
{:noreply, create_sidebar_item(socket, kind)}
|
{:noreply, create_sidebar_item(socket, kind)}
|
||||||
end
|
end
|
||||||
@@ -252,6 +326,8 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do
|
def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do
|
||||||
|
socket = TabActions.auto_save_current_post(socket)
|
||||||
|
|
||||||
workbench =
|
workbench =
|
||||||
Workbench.open_tab(
|
Workbench.open_tab(
|
||||||
socket.assigns.workbench,
|
socket.assigns.workbench,
|
||||||
@@ -266,10 +342,12 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
socket
|
socket
|
||||||
|> assign(:tab_meta, tab_meta)
|
|> assign(:tab_meta, tab_meta)
|
||||||
|> refresh_layout(workbench)
|
|> refresh_layout(workbench)
|
||||||
|> push_url_state()}
|
|> UrlState.push()}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do
|
def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do
|
||||||
|
socket = TabActions.auto_save_current_post(socket)
|
||||||
|
|
||||||
type_atom = BoundedAtoms.editor_route(type, :post)
|
type_atom = BoundedAtoms.editor_route(type, :post)
|
||||||
workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id)
|
workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id)
|
||||||
tab_meta = Map.delete(socket.assigns.tab_meta, {type_atom, id})
|
tab_meta = Map.delete(socket.assigns.tab_meta, {type_atom, id})
|
||||||
@@ -278,7 +356,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
socket
|
socket
|
||||||
|> assign(:tab_meta, tab_meta)
|
|> assign(:tab_meta, tab_meta)
|
||||||
|> refresh_layout(workbench)
|
|> refresh_layout(workbench)
|
||||||
|> push_url_state()}
|
|> UrlState.push()}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event(
|
def handle_event(
|
||||||
@@ -376,15 +454,6 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
def handle_event("overlay_confirm", params, socket),
|
def handle_event("overlay_confirm", params, socket),
|
||||||
do: OverlayManager.handle_event("overlay_confirm", params, socket, overlay_callbacks())
|
do: OverlayManager.handle_event("overlay_confirm", params, socket, overlay_callbacks())
|
||||||
|
|
||||||
def handle_event("overlay_select_gallery_image", params, socket),
|
|
||||||
do:
|
|
||||||
OverlayManager.handle_event(
|
|
||||||
"overlay_select_gallery_image",
|
|
||||||
params,
|
|
||||||
socket,
|
|
||||||
overlay_callbacks()
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle_event("overlay_close_lightbox", params, socket),
|
def handle_event("overlay_close_lightbox", params, socket),
|
||||||
do: OverlayManager.handle_event("overlay_close_lightbox", params, socket, overlay_callbacks())
|
do: OverlayManager.handle_event("overlay_close_lightbox", params, socket, overlay_callbacks())
|
||||||
|
|
||||||
@@ -401,7 +470,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
do: OverlayManager.handle_event("overlay_lightbox_next", params, socket, overlay_callbacks())
|
do: OverlayManager.handle_event("overlay_lightbox_next", params, socket, overlay_callbacks())
|
||||||
|
|
||||||
def handle_event("add_gallery_images", %{"post-id" => post_id}, socket) do
|
def handle_event("add_gallery_images", %{"post-id" => post_id}, socket) do
|
||||||
if socket.assigns.offline_mode do
|
if socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
append_output_entry(
|
append_output_entry(
|
||||||
socket,
|
socket,
|
||||||
@@ -419,7 +488,9 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
|
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
|
||||||
case FilePicker.choose_files(dgettext("ui", "Add Gallery Images"),
|
case FilePicker.choose_files(dgettext("ui", "Add Gallery Images"),
|
||||||
image_only: true, multiple: true) do
|
image_only: true,
|
||||||
|
multiple: true
|
||||||
|
) do
|
||||||
{:ok, paths} when is_list(paths) and paths != [] ->
|
{:ok, paths} when is_list(paths) and paths != [] ->
|
||||||
GalleryImport.start(paths, project_id, post_id, language, concurrency_limit, parent)
|
GalleryImport.start(paths, project_id, post_id, language, concurrency_limit, parent)
|
||||||
|
|
||||||
@@ -510,34 +581,24 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
def handle_event("restore_workbench_session", %{"session" => session_payload}, socket)
|
def handle_event("restore_workbench_session", %{"session" => session_payload}, socket)
|
||||||
when is_map(session_payload) do
|
when is_map(session_payload) do
|
||||||
{:noreply, reload_shell(socket, SessionUtil.restore_workbench_session(session_payload))}
|
restored =
|
||||||
|
session_payload
|
||||||
|
|> SessionUtil.restore_workbench_session()
|
||||||
|
|> preserve_active_tab(socket.assigns.workbench)
|
||||||
|
|
||||||
|
{:noreply, reload_shell(socket, restored)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("native_menu_action", %{"action" => action}, socket) do
|
# The client signals readiness after mounting its hooks and pushing any
|
||||||
{:noreply, handle_native_menu_action(socket, action)}
|
# stored workbench session. Deep links attach only now, so cold-start
|
||||||
|
# blogmark links run after rehydration and the editor they open survives.
|
||||||
|
def handle_event("shell_ready", _params, socket) do
|
||||||
|
attach_deep_link()
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("titlebar_menu_keydown", %{"key" => key}, socket) do
|
def handle_event(event, params, socket) when event in @native_menu_events do
|
||||||
{:noreply, TitlebarMenu.handle_keydown(socket, key, &handle_native_menu_action/2)}
|
NativeMenuEvents.handle(event, params, socket, &handle_native_menu_action/2)
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("toggle_titlebar_menu", %{"group" => group}, socket) do
|
|
||||||
{:noreply, TitlebarMenu.toggle(socket, group)}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("hover_titlebar_menu", %{"group" => group}, socket) do
|
|
||||||
{:noreply, TitlebarMenu.hover(socket, group)}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("close_titlebar_menu", _params, socket) do
|
|
||||||
{:noreply, TitlebarMenu.close(socket)}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("titlebar_menu_action", %{"action" => action}, socket) do
|
|
||||||
{:noreply,
|
|
||||||
socket
|
|
||||||
|> TitlebarMenu.close()
|
|
||||||
|> handle_native_menu_action(action)}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@@ -618,7 +679,13 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
def handle_info({:add_image_processed, title}, socket) do
|
def handle_info({:add_image_processed, title}, socket) do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
append_output_entry(socket, dgettext("ui", "Add Gallery Images"), dgettext("ui", "Added %{title}", title: title), nil, "info")}
|
append_output_entry(
|
||||||
|
socket,
|
||||||
|
dgettext("ui", "Add Gallery Images"),
|
||||||
|
dgettext("ui", "Added %{title}", title: title),
|
||||||
|
nil,
|
||||||
|
"info"
|
||||||
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:add_images_complete, count}, socket) do
|
def handle_info({:add_images_complete, count}, socket) do
|
||||||
@@ -655,7 +722,13 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
def handle_info({:add_images_error, reason}, socket) do
|
def handle_info({:add_images_error, reason}, socket) do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
append_output_entry(socket, dgettext("ui", "Add Gallery Images"), inspect(reason), nil, "error")}
|
append_output_entry(
|
||||||
|
socket,
|
||||||
|
dgettext("ui", "Add Gallery Images"),
|
||||||
|
inspect(reason),
|
||||||
|
nil,
|
||||||
|
"error"
|
||||||
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:add_image_error, path, reason}, socket) do
|
def handle_info({:add_image_error, path, reason}, socket) do
|
||||||
@@ -663,7 +736,10 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
append_output_entry(
|
append_output_entry(
|
||||||
socket,
|
socket,
|
||||||
dgettext("ui", "Add Gallery Images"),
|
dgettext("ui", "Add Gallery Images"),
|
||||||
dgettext("ui", "Failed to process %{path}: %{reason}", path: Path.basename(path), reason: inspect(reason)),
|
dgettext("ui", "Failed to process %{path}: %{reason}",
|
||||||
|
path: Path.basename(path),
|
||||||
|
reason: inspect(reason)
|
||||||
|
),
|
||||||
nil,
|
nil,
|
||||||
"error"
|
"error"
|
||||||
)}
|
)}
|
||||||
@@ -678,6 +754,32 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_info({:blogmark_deep_link, url}, socket) when is_binary(url) do
|
||||||
|
{:noreply, handle_blogmark_deep_link(socket, url)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:git_output, ref, stream, chunk}, socket) do
|
||||||
|
{:noreply, GitRun.append(socket, ref, stream, chunk)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:git_done, ref, status}, socket) do
|
||||||
|
socket = GitRun.done(socket, ref, status)
|
||||||
|
{:noreply, refresh_sidebar(socket, socket.assigns.workbench)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:shell_alert, title, message}, socket) do
|
||||||
|
overlay = %{kind: :alert, title: title, message: message, tone: "error"}
|
||||||
|
{:noreply, assign(socket, :shell_overlay, overlay)}
|
||||||
|
end
|
||||||
|
|
||||||
|
# The UI language is a server-side setting shared by all clients; when any
|
||||||
|
# client (or script) changes it, every shell re-renders in the new locale.
|
||||||
|
def handle_info({:settings_changed, "ui.language"}, socket) do
|
||||||
|
{:noreply, set_page_language(socket, ShellData.ui_language())}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:settings_changed, _key}, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
def handle_info(message, socket) do
|
def handle_info(message, socket) do
|
||||||
Bridges.handle_info(message, socket, bridges_callbacks())
|
Bridges.handle_info(message, socket, bridges_callbacks())
|
||||||
end
|
end
|
||||||
@@ -688,237 +790,27 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
index(assigns)
|
index(assigns)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp refresh_layout(socket, workbench) do
|
# Dark-themed toast classes for LiveToast (its default is bg-white/text-black).
|
||||||
git_badge_count = socket.assigns[:git_badge_count] || 0
|
# Mirrors the library's structural classes but uses the app's dark palette.
|
||||||
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
|
@doc false
|
||||||
task_status = socket.assigns[:task_status] || %{running_task_message: nil, running_task_overflow: nil}
|
@spec toast_class_fn(map()) :: [String.t() | false]
|
||||||
dashboard = socket.assigns[:dashboard] || BDS.UI.Dashboard.empty_snapshot()
|
def toast_class_fn(assigns) do
|
||||||
page_language = socket.assigns[:page_language] || ShellData.ui_language()
|
[
|
||||||
offline_mode = Map.get(socket.assigns, :offline_mode, true)
|
"group/toast z-100 pointer-events-auto relative w-full items-center justify-between origin-center overflow-hidden rounded-lg p-4 shadow-lg border col-start-1 col-end-1 row-start-1 row-end-2",
|
||||||
sidebar_data = socket.assigns[:sidebar_data] || %{}
|
"bg-[#2d2d2d] text-[#f0f0f0] border-[#3c3c3c]",
|
||||||
current_tab = current_tab(workbench)
|
"[@media(scripting:enabled)]:opacity-0 [@media(scripting:enabled){[data-phx-main]_&}]:opacity-100",
|
||||||
prev_tab = socket.assigns[:current_tab]
|
if(assigns[:rest][:hidden] == true, do: "hidden", else: "flex"),
|
||||||
prev_panel_tab =
|
assigns[:kind] == :error && "!bg-[#3a2020] !text-red-300 !border-[#5a2a2a]"
|
||||||
case socket.assigns[:workbench] do
|
]
|
||||||
%Workbench{panel: %{active_tab: tab}} -> tab
|
|
||||||
_ -> nil
|
|
||||||
end
|
|
||||||
|
|
||||||
socket =
|
|
||||||
socket
|
|
||||||
|> assign(:workbench, workbench)
|
|
||||||
|> assign(:activity_buttons, activity_buttons)
|
|
||||||
|> assign(
|
|
||||||
:sidebar_header,
|
|
||||||
active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data)
|
|
||||||
)
|
|
||||||
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|
|
||||||
|> assign(:current_tab, current_tab)
|
|
||||||
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|
|
||||||
|> assign(
|
|
||||||
:status,
|
|
||||||
ShellData.status_bar(workbench, task_status, dashboard,
|
|
||||||
ui_language: page_language,
|
|
||||||
offline_mode: offline_mode
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if panel_data_stale?(current_tab, prev_tab, workbench.panel.active_tab, prev_panel_tab) do
|
|
||||||
refresh_panel_data(socket)
|
|
||||||
else
|
|
||||||
socket
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp panel_data_stale?(current_tab, prev_tab, panel_tab, prev_panel_tab) do
|
defp refresh_layout(socket, workbench), do: SocketState.refresh_layout(socket, workbench)
|
||||||
current_tab != prev_tab or panel_tab != prev_panel_tab
|
|
||||||
end
|
|
||||||
|
|
||||||
defp refresh_panel_data(socket) do
|
defp refresh_sidebar(socket, workbench), do: SocketState.refresh_sidebar(socket, workbench)
|
||||||
panel_tab = socket.assigns.workbench.panel.active_tab
|
|
||||||
|
|
||||||
socket
|
defp refresh_content(socket, workbench), do: ContentState.refresh_content(socket, workbench)
|
||||||
|> assign(
|
|
||||||
:panel_post_links,
|
|
||||||
if(panel_tab == :post_links,
|
|
||||||
do: PanelRenderer.fetch_post_link_entries(socket.assigns),
|
|
||||||
else: socket.assigns[:panel_post_links] || %{backlinks: [], outlinks: []}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|> assign(
|
|
||||||
:panel_git_entries,
|
|
||||||
if(panel_tab == :git_log,
|
|
||||||
do: PanelRenderer.fetch_git_log_entries(socket.assigns),
|
|
||||||
else: socket.assigns[:panel_git_entries] || []
|
|
||||||
)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp push_url_state(socket) do
|
defp reload_shell(socket, workbench), do: ContentState.reload_shell(socket, workbench)
|
||||||
workbench = socket.assigns.workbench
|
|
||||||
|
|
||||||
params =
|
|
||||||
%{}
|
|
||||||
|> put_url_view(workbench.active_view)
|
|
||||||
|> put_url_tab(workbench.active_tab)
|
|
||||||
|
|
||||||
query = URI.encode_query(params)
|
|
||||||
path = if query == "", do: "/", else: "/?" <> query
|
|
||||||
|
|
||||||
push_event(socket, "url-state", %{path: path})
|
|
||||||
end
|
|
||||||
|
|
||||||
defp put_url_view(params, :posts), do: params
|
|
||||||
defp put_url_view(params, view), do: Map.put(params, "view", Atom.to_string(view))
|
|
||||||
|
|
||||||
defp put_url_tab(params, nil), do: params
|
|
||||||
|
|
||||||
defp put_url_tab(params, {type, id}),
|
|
||||||
do: Map.put(params, "tab", Atom.to_string(type) <> ":" <> id)
|
|
||||||
|
|
||||||
defp apply_url_params(socket, params) when is_map(params) and map_size(params) > 0 do
|
|
||||||
workbench = socket.assigns.workbench
|
|
||||||
|
|
||||||
workbench = apply_url_view(workbench, Map.get(params, "view"))
|
|
||||||
workbench = apply_url_tab(workbench, Map.get(params, "tab"))
|
|
||||||
|
|
||||||
if workbench == socket.assigns.workbench do
|
|
||||||
socket
|
|
||||||
else
|
|
||||||
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
|
||||||
|
|
||||||
socket
|
|
||||||
|> assign(:tab_meta, tab_meta)
|
|
||||||
|> refresh_sidebar(workbench)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_url_params(socket, _params), do: socket
|
|
||||||
|
|
||||||
defp apply_url_view(workbench, nil), do: workbench
|
|
||||||
|
|
||||||
defp apply_url_view(workbench, view_str) do
|
|
||||||
view = BoundedAtoms.sidebar_view(view_str, nil)
|
|
||||||
|
|
||||||
if view && view != workbench.active_view do
|
|
||||||
Workbench.click_activity(workbench, view)
|
|
||||||
else
|
|
||||||
workbench
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp apply_url_tab(workbench, nil), do: workbench
|
|
||||||
|
|
||||||
defp apply_url_tab(workbench, tab_str) do
|
|
||||||
case String.split(tab_str, ":", parts: 2) do
|
|
||||||
[type_str, id] ->
|
|
||||||
type = BoundedAtoms.editor_route(type_str, nil)
|
|
||||||
|
|
||||||
if type && workbench.active_tab != {type, id} do
|
|
||||||
Workbench.open_tab(workbench, type, id, :preview)
|
|
||||||
else
|
|
||||||
workbench
|
|
||||||
end
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
workbench
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp refresh_sidebar(socket, workbench) do
|
|
||||||
project_id = (socket.assigns[:projects] || %{})[:active_project_id]
|
|
||||||
active_view_id = Atom.to_string(workbench.active_view)
|
|
||||||
|
|
||||||
sidebar_data =
|
|
||||||
case ShellData.sidebar_view(
|
|
||||||
project_id,
|
|
||||||
active_view_id,
|
|
||||||
ShellSidebarState.current_filters(socket, active_view_id)
|
|
||||||
) do
|
|
||||||
{:ok, data} -> data
|
|
||||||
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
|
|
||||||
end
|
|
||||||
|
|
||||||
sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
|
|
||||||
|
|
||||||
socket
|
|
||||||
|> assign(:sidebar_data, sidebar_data)
|
|
||||||
|> refresh_layout(workbench)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp refresh_content(socket, workbench) do
|
|
||||||
projects =
|
|
||||||
case ShellData.project_snapshot() do
|
|
||||||
{:ok, data} -> data
|
|
||||||
{:error, :not_ready} -> ShellData.default_project_snapshot()
|
|
||||||
end
|
|
||||||
|
|
||||||
dashboard =
|
|
||||||
case ShellData.dashboard(projects.active_project_id) do
|
|
||||||
{:ok, data} -> data
|
|
||||||
{:error, :not_ready} -> BDS.UI.Dashboard.empty_snapshot()
|
|
||||||
end
|
|
||||||
|
|
||||||
git_badge_count =
|
|
||||||
case ShellData.git_badge_count(projects.active_project_id) do
|
|
||||||
{:ok, count} -> count
|
|
||||||
{:error, :not_ready} -> 0
|
|
||||||
end
|
|
||||||
|
|
||||||
active_view_id = Atom.to_string(workbench.active_view)
|
|
||||||
|
|
||||||
sidebar_data =
|
|
||||||
case ShellData.sidebar_view(
|
|
||||||
projects.active_project_id,
|
|
||||||
active_view_id,
|
|
||||||
ShellSidebarState.current_filters(socket, active_view_id)
|
|
||||||
) do
|
|
||||||
{:ok, data} -> data
|
|
||||||
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
|
|
||||||
end
|
|
||||||
|
|
||||||
sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
|
|
||||||
|
|
||||||
socket
|
|
||||||
|> assign(:projects, projects)
|
|
||||||
|> assign(:current_project, ShellData.current_project(projects))
|
|
||||||
|> assign(:dashboard, dashboard)
|
|
||||||
|> assign(:dashboard_timeline_entries, Map.get(dashboard, :timeline_entries, []))
|
|
||||||
|> assign(:dashboard_category_counts, Map.get(dashboard, :category_counts, []))
|
|
||||||
|> assign(:dashboard_recent_posts, Map.get(dashboard, :recent_posts, []))
|
|
||||||
|> assign(
|
|
||||||
:dashboard_tag_cloud_items,
|
|
||||||
ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, []))
|
|
||||||
)
|
|
||||||
|> assign(:git_badge_count, git_badge_count)
|
|
||||||
|> assign(:sidebar_data, sidebar_data)
|
|
||||||
|> refresh_layout(workbench)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp reload_shell(socket, workbench) do
|
|
||||||
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
|
||||||
raw_task_status = BDS.Tasks.status_snapshot()
|
|
||||||
page_language = socket.assigns[:page_language] || ShellData.ui_language()
|
|
||||||
|
|
||||||
offline_mode =
|
|
||||||
if connected?(socket) do
|
|
||||||
Map.get(socket.assigns, :offline_mode, AI.airplane_mode?(true))
|
|
||||||
else
|
|
||||||
Map.get(socket.assigns, :offline_mode, true)
|
|
||||||
end
|
|
||||||
|
|
||||||
task_status = localize_task_status(raw_task_status, page_language)
|
|
||||||
|
|
||||||
socket
|
|
||||||
|> assign(:tab_meta, tab_meta)
|
|
||||||
|> assign(:task_status, task_status)
|
|
||||||
|> assign(:offline_mode, offline_mode)
|
|
||||||
|> assign(:assistant_cards, ShellData.assistant_cards())
|
|
||||||
|> assign(:supported_ui_languages, ShellData.supported_ui_languages())
|
|
||||||
|> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
|
|
||||||
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|
|
||||||
|> refresh_content(workbench)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts)
|
defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts)
|
||||||
|
|
||||||
@@ -933,12 +825,6 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
defp activity_label("Source Control"), do: dgettext("ui", "Git")
|
defp activity_label("Source Control"), do: dgettext("ui", "Git")
|
||||||
defp activity_label(label), do: label
|
defp activity_label(label), do: label
|
||||||
|
|
||||||
defp active_sidebar_label(activity_buttons, active_view, sidebar_data) do
|
|
||||||
Enum.find_value(activity_buttons, Map.get(sidebar_data, :title, ""), fn button ->
|
|
||||||
if button.id == active_view, do: activity_label(button.label), else: nil
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp sidebar_header_label(label), do: label
|
defp sidebar_header_label(label), do: label
|
||||||
|
|
||||||
defp timeline_height(entry, entries) do
|
defp timeline_height(entry, entries) do
|
||||||
@@ -950,15 +836,129 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
max(4, (entry.count || 0) / max_count * 100)
|
max(4, (entry.count || 0) / max_count * 100)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp current_tab(%{active_tab: nil}), do: nil
|
|
||||||
|
|
||||||
defp current_tab(%{tabs: tabs, active_tab: {type, id}}) do
|
|
||||||
Enum.find(tabs, &(&1.type == type and &1.id == id))
|
|
||||||
end
|
|
||||||
|
|
||||||
defp create_sidebar_item(socket, kind),
|
defp create_sidebar_item(socket, kind),
|
||||||
do: SidebarCreate.create(socket, kind, sidebar_create_callbacks())
|
do: SidebarCreate.create(socket, kind, sidebar_create_callbacks())
|
||||||
|
|
||||||
|
# Receive a bds2://new-post blogmark deep link: run the transform pipeline,
|
||||||
|
# create a draft post, open it in the editor, and surface transform toasts.
|
||||||
|
defp handle_blogmark_deep_link(socket, url) do
|
||||||
|
title = dgettext("ui", "Blogmark")
|
||||||
|
link_project_id = Blogmark.deep_link_project_id(url)
|
||||||
|
active = current_project_id(socket)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
# A specific project was requested but does not exist in this install
|
||||||
|
# (e.g. a bookmarklet copied from another instance). Never guess another
|
||||||
|
# project — tell the user, import nothing.
|
||||||
|
is_binary(link_project_id) and Projects.get_project(link_project_id) == nil ->
|
||||||
|
append_output_entry(
|
||||||
|
socket,
|
||||||
|
title,
|
||||||
|
dgettext("ui", "The project this blogmark targets does not exist here."),
|
||||||
|
url,
|
||||||
|
"error"
|
||||||
|
)
|
||||||
|
|
||||||
|
# A specific, existing project that isn't active: switch to it first so
|
||||||
|
# the new post is shown in context, then import there.
|
||||||
|
is_binary(link_project_id) and link_project_id != active ->
|
||||||
|
case Projects.set_active_project(link_project_id) do
|
||||||
|
{:ok, _project} ->
|
||||||
|
socket
|
||||||
|
|> assign(:project_menu_open, false)
|
||||||
|
|> assign(:sidebar_filters_by_view, %{})
|
||||||
|
|> reload_shell(Workbench.new())
|
||||||
|
|> UrlState.push()
|
||||||
|
|> import_blogmark(link_project_id, url, title)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
append_output_entry(socket, title, inspect(reason), url, "error")
|
||||||
|
end
|
||||||
|
|
||||||
|
# The link names the already-active project, or no project_id was given
|
||||||
|
# and a project is active: import into the active project.
|
||||||
|
is_binary(active) ->
|
||||||
|
import_blogmark(socket, active, url, title)
|
||||||
|
|
||||||
|
# No project_id and nothing active.
|
||||||
|
true ->
|
||||||
|
append_output_entry(
|
||||||
|
socket,
|
||||||
|
title,
|
||||||
|
dgettext("ui", "Open a project before importing a blogmark."),
|
||||||
|
url,
|
||||||
|
"warning"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp import_blogmark(socket, project_id, url, title) do
|
||||||
|
# Transform scripts run in the sandbox process; collect anything they
|
||||||
|
# print/log so it lands in the Output panel like in the old app.
|
||||||
|
{:ok, output_sink} = Agent.start_link(fn -> [] end)
|
||||||
|
|
||||||
|
sink = fn text -> Agent.update(output_sink, fn lines -> [text | lines] end) end
|
||||||
|
|
||||||
|
try do
|
||||||
|
case Blogmark.receive_deep_link(project_id, url, on_output: sink) do
|
||||||
|
{:ok, %{post: post, toasts: toasts, errors: errors}} ->
|
||||||
|
script_output = Agent.get(output_sink, &Enum.reverse/1)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> reload_shell(socket.assigns.workbench)
|
||||||
|
|> open_sidebar_item(
|
||||||
|
%{
|
||||||
|
"route" => "post",
|
||||||
|
"id" => post.id,
|
||||||
|
"title" => post.title,
|
||||||
|
"subtitle" => post.slug
|
||||||
|
},
|
||||||
|
:pin
|
||||||
|
)
|
||||||
|
|> append_blogmark_script_output(title, script_output)
|
||||||
|
|> append_blogmark_toasts(title, toasts)
|
||||||
|
|> append_blogmark_errors(title, errors)
|
||||||
|
|> open_output_panel_when(script_output != [] or errors != [])
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
append_output_entry(socket, title, inspect(reason), url, "error")
|
||||||
|
end
|
||||||
|
after
|
||||||
|
Agent.stop(output_sink)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp append_blogmark_script_output(socket, title, lines) do
|
||||||
|
Enum.reduce(lines, socket, fn line, acc ->
|
||||||
|
append_output_entry(acc, title, line, nil, "info")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp append_blogmark_toasts(socket, title, toasts) do
|
||||||
|
Enum.reduce(toasts, socket, fn message, acc ->
|
||||||
|
append_output_entry(acc, title, message, nil, "info")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Old-app parity: transform errors or streamed script output open the
|
||||||
|
# bottom panel on the Output tab so the user actually sees them.
|
||||||
|
defp open_output_panel_when(socket, true) do
|
||||||
|
workbench =
|
||||||
|
socket.assigns.workbench
|
||||||
|
|> Workbench.set_panel_visible(true)
|
||||||
|
|> Workbench.set_panel_tab(:output)
|
||||||
|
|
||||||
|
refresh_layout(socket, workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp open_output_panel_when(socket, false), do: socket
|
||||||
|
|
||||||
|
defp append_blogmark_errors(socket, title, errors) do
|
||||||
|
Enum.reduce(errors, socket, fn %{slug: slug, reason: reason}, acc ->
|
||||||
|
append_output_entry(acc, title, inspect(reason), slug, "error")
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp handle_file_picker_result(socket, {:ok, _media}),
|
defp handle_file_picker_result(socket, {:ok, _media}),
|
||||||
do: refresh_content(socket, socket.assigns.workbench)
|
do: refresh_content(socket, socket.assigns.workbench)
|
||||||
|
|
||||||
@@ -1009,7 +1009,32 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
socket
|
socket
|
||||||
|> assign(:tab_meta, tab_meta)
|
|> assign(:tab_meta, tab_meta)
|
||||||
|> refresh_layout(workbench)
|
|> refresh_layout(workbench)
|
||||||
|> push_url_state()
|
|> UrlState.push()
|
||||||
|
end
|
||||||
|
|
||||||
|
# A tab opened before the client's stored session arrives (e.g. a blogmark
|
||||||
|
# deep link that switched projects) must survive the restore, so re-open it
|
||||||
|
# on the restored workbench with its original transient/pinned intent.
|
||||||
|
defp preserve_active_tab(restored, %{active_tab: nil}), do: restored
|
||||||
|
|
||||||
|
defp preserve_active_tab(restored, %{active_tab: {type, id}} = previous) do
|
||||||
|
intent =
|
||||||
|
case Enum.find(previous.tabs, &(&1.type == type and &1.id == id)) do
|
||||||
|
%{is_transient: true} -> :preview
|
||||||
|
_ -> :pin
|
||||||
|
end
|
||||||
|
|
||||||
|
Workbench.open_tab(restored, type, id, intent)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id]
|
||||||
|
|
||||||
|
# Register this shell with the deep-link router and drain any links queued
|
||||||
|
# before connect (cold start). The router is absent in headless/test runs.
|
||||||
|
defp attach_deep_link do
|
||||||
|
if Process.whereis(BDS.Desktop.DeepLink) do
|
||||||
|
BDS.Desktop.DeepLink.attach(self())
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp sidebar_create_action(view), do: SidebarCreate.action(view)
|
defp sidebar_create_action(view), do: SidebarCreate.action(view)
|
||||||
@@ -1033,6 +1058,9 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
socket
|
socket
|
||||||
else
|
else
|
||||||
UILocale.put(normalized)
|
UILocale.put(normalized)
|
||||||
|
# Persist server-side so every client renders the same language; the
|
||||||
|
# resulting settings_changed broadcast is a no-op for this socket.
|
||||||
|
_ = BDS.Settings.put_global_setting("ui.language", normalized)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:page_language, normalized)
|
|> assign(:page_language, normalized)
|
||||||
@@ -1054,7 +1082,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> assign(:sidebar_filters_by_view, %{})
|
|> assign(:sidebar_filters_by_view, %{})
|
||||||
|> append_output_entry(title, message_fun.(project))
|
|> append_output_entry(title, message_fun.(project))
|
||||||
|> reload_shell(Workbench.new())
|
|> reload_shell(Workbench.new())
|
||||||
|> push_url_state()
|
|> UrlState.push()
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
socket
|
socket
|
||||||
@@ -1064,11 +1092,8 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp append_output_entry(socket, title, message, details \\ nil, level \\ "info") do
|
defp append_output_entry(socket, title, message, details \\ nil, level \\ "info"),
|
||||||
entry = %{title: title, message: message, details: details, level: level}
|
do: SocketState.append_output_entry(socket, title, message, details, level)
|
||||||
entries = [entry | socket.assigns.output_entries] |> Enum.take(@output_entry_limit)
|
|
||||||
assign(socket, :output_entries, entries)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp handle_native_menu_action(socket, action) do
|
defp handle_native_menu_action(socket, action) do
|
||||||
case BoundedAtoms.menu_action(action) do
|
case BoundedAtoms.menu_action(action) do
|
||||||
@@ -1089,7 +1114,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
socket
|
socket
|
||||||
|> assign(:tab_meta, tab_meta)
|
|> assign(:tab_meta, tab_meta)
|
||||||
|> refresh_sidebar(workbench)
|
|> refresh_sidebar(workbench)
|
||||||
|> push_url_state()
|
|> UrlState.push()
|
||||||
|
|
||||||
MapSet.member?(@socket_menu_actions, action) ->
|
MapSet.member?(@socket_menu_actions, action) ->
|
||||||
handle_socket_menu_action(socket, action)
|
handle_socket_menu_action(socket, action)
|
||||||
@@ -1113,8 +1138,11 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
defp handle_socket_menu_action(socket, :new_post), do: create_sidebar_item(socket, "post")
|
defp handle_socket_menu_action(socket, :new_post), do: create_sidebar_item(socket, "post")
|
||||||
defp handle_socket_menu_action(socket, :import_media), do: create_sidebar_item(socket, "media")
|
defp handle_socket_menu_action(socket, :import_media), do: create_sidebar_item(socket, "media")
|
||||||
defp handle_socket_menu_action(socket, :save), do: save_current_tab(socket)
|
|
||||||
defp handle_socket_menu_action(socket, :publish_selected), do: publish_current_tab(socket)
|
defp handle_socket_menu_action(socket, :save), do: TabActions.save_current_tab(socket)
|
||||||
|
|
||||||
|
defp handle_socket_menu_action(socket, :publish_selected),
|
||||||
|
do: TabActions.publish_current_tab(socket)
|
||||||
|
|
||||||
defp handle_socket_menu_action(socket, :quit) do
|
defp handle_socket_menu_action(socket, :quit) do
|
||||||
Shutdown.request_quit()
|
Shutdown.request_quit()
|
||||||
@@ -1122,12 +1150,40 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp handle_socket_menu_action(socket, :view_on_github) do
|
defp handle_socket_menu_action(socket, :view_on_github) do
|
||||||
OS.launch_default_browser(ExternalLinks.github_url())
|
OS.launch_default_browser("https://github.com/rfc1437/bDS2")
|
||||||
socket
|
socket
|
||||||
end
|
end
|
||||||
|
|
||||||
defp handle_socket_menu_action(socket, :report_issue) do
|
defp handle_socket_menu_action(socket, :report_issue) do
|
||||||
OS.launch_default_browser(ExternalLinks.github_issues_url())
|
OS.launch_default_browser("https://github.com/rfc1437/bDS2/issues")
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_socket_menu_action(socket, :connect_server) do
|
||||||
|
prompt = dgettext("ui", "Server address (user@host or user@host:port), public-key auth:")
|
||||||
|
|
||||||
|
with {:ok, target} <- BDS.Desktop.Dialogs.prompt_text(prompt, "user@host"),
|
||||||
|
{:ok, url} <- BDS.Desktop.RemoteConnection.connect(target) do
|
||||||
|
Desktop.Window.show(BDS.Desktop.MainWindow.window_id(), url)
|
||||||
|
socket
|
||||||
|
else
|
||||||
|
:cancel ->
|
||||||
|
socket
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
append_output_entry(
|
||||||
|
socket,
|
||||||
|
dgettext("ui", "Connect to Server"),
|
||||||
|
BDS.Desktop.RemoteConnection.format_reason(reason),
|
||||||
|
nil,
|
||||||
|
"error"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp handle_socket_menu_action(socket, :disconnect_server) do
|
||||||
|
:ok = BDS.Desktop.RemoteConnection.disconnect()
|
||||||
|
Desktop.Window.show(BDS.Desktop.MainWindow.window_id(), BDS.Desktop.url())
|
||||||
socket
|
socket
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1143,50 +1199,6 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|
|
||||||
defp shell_command?(action), do: not is_nil(shell_command_atom(action))
|
defp shell_command?(action), do: not is_nil(shell_command_atom(action))
|
||||||
|
|
||||||
defp save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
|
|
||||||
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
|
|
||||||
socket
|
|
||||||
end
|
|
||||||
|
|
||||||
defp save_current_tab(%{assigns: %{current_tab: %{type: :media, id: media_id}}} = socket) do
|
|
||||||
send_update(MediaEditor, id: "media-editor-#{media_id}", action: :save)
|
|
||||||
socket
|
|
||||||
end
|
|
||||||
|
|
||||||
defp save_current_tab(%{assigns: %{current_tab: %{type: :settings}}} = socket) do
|
|
||||||
send_update(SettingsEditor, id: "settings-editor", action: :save_project)
|
|
||||||
socket
|
|
||||||
end
|
|
||||||
|
|
||||||
defp save_current_tab(%{assigns: %{current_tab: %{type: :menu_editor}}} = socket) do
|
|
||||||
send_update(MenuEditor, id: "menu-editor", action: :save)
|
|
||||||
socket
|
|
||||||
end
|
|
||||||
|
|
||||||
defp save_current_tab(%{assigns: %{current_tab: %{type: :tags}}} = socket) do
|
|
||||||
send_update(TagsEditor, id: "tags-editor", action: :save)
|
|
||||||
socket
|
|
||||||
end
|
|
||||||
|
|
||||||
defp save_current_tab(%{assigns: %{current_tab: %{type: :scripts, id: script_id}}} = socket) do
|
|
||||||
send_update(ScriptEditor, id: "script-editor-#{script_id}", action: :save)
|
|
||||||
socket
|
|
||||||
end
|
|
||||||
|
|
||||||
defp save_current_tab(%{assigns: %{current_tab: %{type: :templates, id: template_id}}} = socket) do
|
|
||||||
send_update(TemplateEditor, id: "template-editor-#{template_id}", action: :save)
|
|
||||||
socket
|
|
||||||
end
|
|
||||||
|
|
||||||
defp save_current_tab(socket), do: refresh_layout(socket, socket.assigns.workbench)
|
|
||||||
|
|
||||||
defp publish_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
|
|
||||||
send_update(PostEditor, id: "post-editor-#{post_id}", action: :publish)
|
|
||||||
socket
|
|
||||||
end
|
|
||||||
|
|
||||||
defp publish_current_tab(socket), do: refresh_layout(socket, socket.assigns.workbench)
|
|
||||||
|
|
||||||
defp apply_shell_command(socket, action, params \\ %{}),
|
defp apply_shell_command(socket, action, params \\ %{}),
|
||||||
do: ShellCommandRunner.execute(socket, action, params, shell_command_callbacks())
|
do: ShellCommandRunner.execute(socket, action, params, shell_command_callbacks())
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
|||||||
import Phoenix.LiveView, only: [connected?: 1, send_update: 2]
|
import Phoenix.LiveView, only: [connected?: 1, send_update: 2]
|
||||||
|
|
||||||
alias BDS.Desktop.ShellData
|
alias BDS.Desktop.ShellData
|
||||||
alias BDS.Desktop.ShellLive.{ChatEditor, PostEditor}
|
alias BDS.Desktop.ShellLive.{ChatEditor, MediaEditor, PostEditor}
|
||||||
alias BDS.Desktop.ShellLive.{CliSync, SessionUtil}
|
alias BDS.Desktop.ShellLive.{CliSync, SessionUtil}
|
||||||
alias BDS.UI.Workbench
|
alias BDS.UI.Workbench
|
||||||
|
|
||||||
@refreshable_tab_meta_types [:import, :chat]
|
@refreshable_tab_meta_types [:import, :chat, :post, :media]
|
||||||
|
|
||||||
@spec handle_info(tuple() | atom(), Phoenix.LiveView.Socket.t(), map()) ::
|
@spec handle_info(tuple() | atom(), Phoenix.LiveView.Socket.t(), map()) ::
|
||||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||||
@@ -47,6 +47,40 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
|||||||
{:noreply, assign(socket, :workbench, workbench)}
|
{:noreply, assign(socket, :workbench, workbench)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@default_auto_save_delay 3000
|
||||||
|
|
||||||
|
def handle_info({:schedule_auto_save, type, id}, socket, _callbacks) do
|
||||||
|
timers = socket.assigns[:auto_save_timers] || %{}
|
||||||
|
key = {type, id}
|
||||||
|
|
||||||
|
case Map.get(timers, key) do
|
||||||
|
nil -> :ok
|
||||||
|
old_ref -> Process.cancel_timer(old_ref)
|
||||||
|
end
|
||||||
|
|
||||||
|
delay = Application.get_env(:bds, :auto_save_delay, @default_auto_save_delay)
|
||||||
|
ref = Process.send_after(self(), {:auto_save_fire, type, id}, delay)
|
||||||
|
{:noreply, assign(socket, :auto_save_timers, Map.put(timers, key, ref))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:cancel_auto_save, type, id}, socket, _callbacks) do
|
||||||
|
timers = socket.assigns[:auto_save_timers] || %{}
|
||||||
|
key = {type, id}
|
||||||
|
|
||||||
|
case Map.get(timers, key) do
|
||||||
|
nil -> :ok
|
||||||
|
old_ref -> Process.cancel_timer(old_ref)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :auto_save_timers, Map.delete(timers, key))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:auto_save_fire, :post, post_id}, socket, _callbacks) do
|
||||||
|
timers = socket.assigns[:auto_save_timers] || %{}
|
||||||
|
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
|
||||||
|
{:noreply, assign(socket, :auto_save_timers, Map.delete(timers, {:post, post_id}))}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_info({:editor_command, action, params}, socket, callbacks) do
|
def handle_info({:editor_command, action, params}, socket, callbacks) do
|
||||||
{:noreply, callbacks.apply_shell_command.(socket, action, params)}
|
{:noreply, callbacks.apply_shell_command.(socket, action, params)}
|
||||||
end
|
end
|
||||||
@@ -70,6 +104,23 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
|||||||
{:noreply, callbacks.refresh_content.(socket, socket.assigns.workbench)}
|
{:noreply, callbacks.refresh_content.(socket, socket.assigns.workbench)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_info({:confirm_tag_delete, tag_id, _tag_name, post_count}, socket, _callbacks) do
|
||||||
|
page_language = socket.assigns.page_language
|
||||||
|
|
||||||
|
suffix = if post_count == 1, do: "", else: "s"
|
||||||
|
message = "This tag is used in #{post_count} post#{suffix}. Delete anyway?"
|
||||||
|
|
||||||
|
overlay = %{
|
||||||
|
kind: :confirm_dialog,
|
||||||
|
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Tag"),
|
||||||
|
message: message,
|
||||||
|
tag_id: tag_id,
|
||||||
|
confirm_action: :delete_tag
|
||||||
|
}
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :shell_overlay, overlay)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_info(:settings_changed, socket, callbacks) do
|
def handle_info(:settings_changed, socket, callbacks) do
|
||||||
{:noreply, callbacks.reload.(socket, socket.assigns.workbench)}
|
{:noreply, callbacks.reload.(socket, socket.assigns.workbench)}
|
||||||
end
|
end
|
||||||
@@ -165,6 +216,26 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
|||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_info({:editor_translation_completed, :post, post_id, language}, socket, _callbacks) do
|
||||||
|
send_update(PostEditor,
|
||||||
|
id: "post-editor-#{post_id}",
|
||||||
|
action: :translation_completed,
|
||||||
|
language: language
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:editor_translation_completed, :media, media_id, language}, socket, _callbacks) do
|
||||||
|
send_update(MediaEditor,
|
||||||
|
id: "media-editor-#{media_id}",
|
||||||
|
action: :translation_completed,
|
||||||
|
language: language
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_info({:post_editor_apply_ai_suggestions, post_id, fields}, socket, _callbacks) do
|
def handle_info({:post_editor_apply_ai_suggestions, post_id, fields}, socket, _callbacks) do
|
||||||
send_update(PostEditor,
|
send_update(PostEditor,
|
||||||
id: "post-editor-#{post_id}",
|
id: "post-editor-#{post_id}",
|
||||||
@@ -195,7 +266,7 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
|||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:task_status, task_status)
|
|> assign(:task_status, task_status)
|
||||||
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|
|> assign(:editor_meta, ShellData.editor_meta(task_status, socket.assigns.page_language))
|
||||||
|> assign(
|
|> assign(
|
||||||
:status,
|
:status,
|
||||||
ShellData.status_bar(
|
ShellData.status_bar(
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
socket
|
socket
|
||||||
) do
|
) do
|
||||||
next_data = Map.put(socket.assigns.surface_data, surface_id, fields)
|
next_data = Map.put(socket.assigns.surface_data, surface_id, fields)
|
||||||
{:noreply, assign(socket, :surface_data, next_data) |> schedule_surface_state_persist() |> build_data()}
|
|
||||||
|
{:noreply,
|
||||||
|
assign(socket, :surface_data, next_data) |> schedule_surface_state_persist() |> build_data()}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event(
|
def handle_event(
|
||||||
@@ -226,13 +228,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
not is_nil(socket.assigns.request) ->
|
not is_nil(socket.assigns.request) ->
|
||||||
build_data(socket)
|
build_data(socket)
|
||||||
|
|
||||||
socket.assigns.offline_mode ->
|
socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() ->
|
||||||
Notify.output(dgettext("ui", "Chat"),
|
Notify.output(
|
||||||
dgettext("ui", "Automatic AI actions stay gated by airplane mode."), "info")
|
dgettext("ui", "Chat"),
|
||||||
|
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
|
||||||
|
"info"
|
||||||
|
)
|
||||||
|
|
||||||
build_data(socket)
|
build_data(socket)
|
||||||
|
|
||||||
ModelSelection.needs_api_key?(false) ->
|
ModelSelection.needs_api_key?(socket.assigns.offline_mode) ->
|
||||||
build_data(socket)
|
build_data(socket)
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
@@ -538,10 +543,15 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
|
|
||||||
@spec markdown_html(binary()) :: Phoenix.HTML.Safe.t()
|
@spec markdown_html(binary()) :: Phoenix.HTML.Safe.t()
|
||||||
def markdown_html(content) when is_binary(content) do
|
def markdown_html(content) when is_binary(content) do
|
||||||
|
# Match Earmark's defaults (GFM tables, strikethrough, autolinks); escape
|
||||||
|
# raw HTML to text rather than rendering it, as the old escape: true did.
|
||||||
html =
|
html =
|
||||||
case Earmark.as_html(content, escape: true) do
|
case MDEx.to_html(content,
|
||||||
{:ok, rendered, _messages} -> rendered
|
extension: [table: true, strikethrough: true, autolink: true],
|
||||||
{:error, rendered, _messages} -> rendered
|
render: [escape: true]
|
||||||
|
) do
|
||||||
|
{:ok, rendered} -> rendered
|
||||||
|
{:error, _reason} -> ""
|
||||||
end
|
end
|
||||||
|> rewrite_external_images()
|
|> rewrite_external_images()
|
||||||
|
|
||||||
@@ -569,8 +579,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
def chart_width(_max_value, _value), do: 0
|
def chart_width(_max_value, _value), do: 0
|
||||||
|
|
||||||
@spec truthy?(term()) :: boolean()
|
@spec truthy?(term()) :: boolean()
|
||||||
def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true
|
def truthy?(value), do: BDS.Values.truthy?(value)
|
||||||
def truthy?(_value), do: false
|
|
||||||
|
|
||||||
# ── HEEx components ───────────────────────────────────────────────────────
|
# ── HEEx components ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -679,19 +688,116 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
<h3><%= @surface.title %></h3>
|
<h3><%= @surface.title %></h3>
|
||||||
<% end %>
|
<% end %>
|
||||||
<p class="chat-surface-chart-type"><%= @surface.chart_type %></p>
|
<p class="chat-surface-chart-type"><%= @surface.chart_type %></p>
|
||||||
<div class="chat-surface-chart-list">
|
<%= case @surface.chart_type do %>
|
||||||
<%= for series <- @surface.series do %>
|
<% chart_type when chart_type in ["pie", "donut"] -> %>
|
||||||
<div class="chat-surface-chart-row">
|
<% pie = BDS.Desktop.ShellLive.ChatEditor.ChartView.pie(@surface.series) %>
|
||||||
<div class="chat-surface-chart-meta">
|
<div class="chat-surface-chart-pie">
|
||||||
<span><%= series.label %></span>
|
<svg class="chat-surface-chart-pie-svg" viewBox={"0 0 #{pie.size} #{pie.size}"} preserveAspectRatio="xMidYMid meet">
|
||||||
<span><%= series.value %></span>
|
<%= for slice <- pie.slices do %>
|
||||||
</div>
|
<%= if slice.full_circle do %>
|
||||||
<div class="chat-surface-chart-bar">
|
<circle class="chat-surface-chart-pie-slice" cx={pie.center} cy={pie.center} r={pie.radius} fill={slice.color}>
|
||||||
<span style={"width: #{chart_width(@surface.max_value, series.value)}%"}></span>
|
<title><%= slice.label %>: <%= slice.value %></title>
|
||||||
|
</circle>
|
||||||
|
<% else %>
|
||||||
|
<path class="chat-surface-chart-pie-slice" d={slice.d} fill={slice.color}>
|
||||||
|
<title><%= slice.label %>: <%= slice.value %></title>
|
||||||
|
</path>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<%= if @surface.chart_type == "donut" do %>
|
||||||
|
<circle class="chat-surface-chart-donut-hole" cx={pie.center} cy={pie.center} r={pie.donut_inner} />
|
||||||
|
<text class="chat-surface-chart-donut-total" x={pie.center} y={pie.center} text-anchor="middle" dominant-baseline="central"><%= pie.total %></text>
|
||||||
|
<% end %>
|
||||||
|
</svg>
|
||||||
|
<div class="chat-surface-chart-legend">
|
||||||
|
<%= for item <- pie.legend do %>
|
||||||
|
<span class="chat-surface-chart-legend-item">
|
||||||
|
<span class="chat-surface-chart-legend-swatch" style={"background-color: #{item.color}"}></span>
|
||||||
|
<%= item.label %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
</div>
|
<% chart_type when chart_type in ["line", "area"] -> %>
|
||||||
|
<% line = BDS.Desktop.ShellLive.ChatEditor.ChartView.line(@surface.series, @surface.chart_type == "area") %>
|
||||||
|
<svg class="chat-surface-chart-line-svg" viewBox={line.view_box} preserveAspectRatio="xMidYMid meet">
|
||||||
|
<%= for tick <- line.grid do %>
|
||||||
|
<line class="chat-surface-chart-line-grid" x1={tick.x1} y1={tick.y} x2={tick.x2} y2={tick.y} />
|
||||||
|
<text class="chat-surface-chart-line-y-label" x={tick.label_x} y={tick.y} text-anchor="end" dominant-baseline="middle"><%= tick.label %></text>
|
||||||
|
<% end %>
|
||||||
|
<%= if line.area? do %>
|
||||||
|
<polygon class="chat-surface-chart-area-fill" points={line.area_points} />
|
||||||
|
<% end %>
|
||||||
|
<polyline class="chat-surface-chart-line-path" points={line.polyline} fill="none" />
|
||||||
|
<%= for dot <- line.dots do %>
|
||||||
|
<circle class="chat-surface-chart-line-dot" cx={dot.x} cy={dot.y} r="3">
|
||||||
|
<title><%= dot.label %>: <%= dot.value %></title>
|
||||||
|
</circle>
|
||||||
|
<% end %>
|
||||||
|
<%= for label <- line.x_labels do %>
|
||||||
|
<text class="chat-surface-chart-line-x-label" x={label.x} y={label.y} text-anchor="middle"><%= label.label %></text>
|
||||||
|
<% end %>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<% "heatmap" -> %>
|
||||||
|
<% heat = BDS.Desktop.ShellLive.ChatEditor.ChartView.heatmap(@surface.series) %>
|
||||||
|
<%= if heat.rows != [] do %>
|
||||||
|
<div class="chat-surface-chart-heatmap" style={"grid-template-columns: auto repeat(#{heat.column_count}, 1fr)"}>
|
||||||
|
<span class="chat-surface-chart-heatmap-corner"></span>
|
||||||
|
<%= for col <- heat.columns do %>
|
||||||
|
<span class="chat-surface-chart-heatmap-col-label"><%= col %></span>
|
||||||
|
<% end %>
|
||||||
|
<%= for row <- heat.rows do %>
|
||||||
|
<span class="chat-surface-chart-heatmap-row-label"><%= row.label %></span>
|
||||||
|
<%= for cell <- row.cells do %>
|
||||||
|
<span class="chat-surface-chart-heatmap-cell" style={"background: #{cell.bg}; color: #{cell.fg}"} title={cell.value}><%= if cell.value > 0, do: cell.value %></span>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% "stacked-bar" -> %>
|
||||||
|
<% stacked = BDS.Desktop.ShellLive.ChatEditor.ChartView.stacked(@surface.series) %>
|
||||||
|
<div class="chat-surface-chart-list">
|
||||||
|
<%= for row <- stacked.rows do %>
|
||||||
|
<div class="chat-surface-chart-row">
|
||||||
|
<div class="chat-surface-chart-meta">
|
||||||
|
<span><%= row.label %></span>
|
||||||
|
<span><%= row.total %></span>
|
||||||
|
</div>
|
||||||
|
<div class="chat-surface-chart-bar chat-surface-chart-bar-stacked">
|
||||||
|
<%= for seg <- row.segments do %>
|
||||||
|
<span class="chat-surface-chart-bar-segment" style={"width: #{seg.width}%; background-color: #{seg.color}"} title={"#{seg.label}: #{seg.value}"}></span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="chat-surface-chart-legend">
|
||||||
|
<%= for item <- stacked.legend do %>
|
||||||
|
<span class="chat-surface-chart-legend-item">
|
||||||
|
<span class="chat-surface-chart-legend-swatch" style={"background-color: #{item.color}"}></span>
|
||||||
|
<%= item.label %>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% _bar -> %>
|
||||||
|
<div class="chat-surface-chart-list">
|
||||||
|
<%= for series <- @surface.series do %>
|
||||||
|
<div class="chat-surface-chart-row">
|
||||||
|
<div class="chat-surface-chart-meta">
|
||||||
|
<span><%= series.label %></span>
|
||||||
|
<span><%= series.value %></span>
|
||||||
|
</div>
|
||||||
|
<div class="chat-surface-chart-bar">
|
||||||
|
<span style={"width: #{chart_width(@surface.max_value, series.value)}%"}></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<% "metric" -> %>
|
<% "metric" -> %>
|
||||||
<div class="chat-surface-metric">
|
<div class="chat-surface-metric">
|
||||||
@@ -887,7 +993,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
try do
|
try do
|
||||||
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
|
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
|
||||||
rescue
|
rescue
|
||||||
_error -> :ok
|
error ->
|
||||||
|
Logger.debug("swallowed chat_editor allow_repo_sandbox error: #{inspect(error)}")
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
:ok
|
:ok
|
||||||
@@ -923,9 +1031,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
defp present?(value) when is_binary(value), do: String.trim(value) != ""
|
defp present?(value) when is_binary(value), do: String.trim(value) != ""
|
||||||
defp present?(value), do: not is_nil(value)
|
defp present?(value), do: not is_nil(value)
|
||||||
|
|
||||||
defp format_error(%{kind: :endpoint_not_configured}),
|
# :endpoint_not_configured is handled by its own case clause before this is
|
||||||
do: dgettext("ui", "Configure an API key in Settings to enable AI chat.")
|
# reached; the chat surface already shows the configuration hint.
|
||||||
|
|
||||||
defp format_error(reason), do: inspect(reason)
|
defp format_error(reason), do: inspect(reason)
|
||||||
|
|
||||||
defp parse_integer(value) when is_integer(value), do: value
|
defp parse_integer(value) when is_integer(value), do: value
|
||||||
|
|||||||
284
lib/bds/desktop/shell_live/chat_editor/chart_view.ex
Normal file
284
lib/bds/desktop/shell_live/chat_editor/chart_view.ex
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.ChatEditor.ChartView do
|
||||||
|
@moduledoc """
|
||||||
|
Pure geometry/view helpers for rendering a2ui chart surfaces.
|
||||||
|
|
||||||
|
Mirrors the behaviour of the legacy bDS `A2UIChart` React component so the
|
||||||
|
Elixir rewrite supports the same chart types (`bar`, `stacked-bar`, `line`,
|
||||||
|
`area`, `pie`, `donut`, `heatmap`) with the same visual output. Each function
|
||||||
|
returns plain maps/lists that a HEEx template can iterate over; no markup is
|
||||||
|
produced here so the geometry stays unit-testable.
|
||||||
|
|
||||||
|
Series entries are expected to be maps with `:label`, `:value` and an optional
|
||||||
|
list of `:segments` (each a map with `:label` and `:value`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Matches the legacy bDS SEGMENT_COLORS palette.
|
||||||
|
@palette ["#75beff", "#89d185", "#d18616", "#f14c4c", "#b180d7", "#e2e210"]
|
||||||
|
|
||||||
|
# Line/area chart geometry (viewBox units), matching the legacy component.
|
||||||
|
@line_width 300
|
||||||
|
@line_height 140
|
||||||
|
@pad_top 8
|
||||||
|
@pad_right 12
|
||||||
|
@pad_bottom 24
|
||||||
|
@pad_left 40
|
||||||
|
|
||||||
|
@doc "Returns the colour for the series/segment at `index` (cycles the palette)."
|
||||||
|
@spec color(integer()) :: String.t()
|
||||||
|
def color(index), do: Enum.at(@palette, rem(index, length(@palette)))
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Pie/donut geometry: SVG slices, a legend and the running total.
|
||||||
|
"""
|
||||||
|
@spec pie([map()]) :: map()
|
||||||
|
def pie(series) do
|
||||||
|
total = series |> Enum.map(& &1.value) |> Enum.sum()
|
||||||
|
center = 70
|
||||||
|
radius = 56
|
||||||
|
|
||||||
|
{slices, _angle} =
|
||||||
|
if total > 0 do
|
||||||
|
series
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.map_reduce(0.0, fn {entry, i}, current ->
|
||||||
|
slice_angle = entry.value / total * 360
|
||||||
|
end_angle = current + slice_angle
|
||||||
|
|
||||||
|
slice =
|
||||||
|
if slice_angle >= 359.99 do
|
||||||
|
%{full_circle: true, d: nil, color: color(i), label: entry.label, value: entry.value}
|
||||||
|
else
|
||||||
|
%{
|
||||||
|
full_circle: false,
|
||||||
|
d: describe_slice(center, radius, current, end_angle),
|
||||||
|
color: color(i),
|
||||||
|
label: entry.label,
|
||||||
|
value: entry.value
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
{slice, end_angle}
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
{[], 0.0}
|
||||||
|
end
|
||||||
|
|
||||||
|
%{
|
||||||
|
size: 140,
|
||||||
|
center: center,
|
||||||
|
radius: radius,
|
||||||
|
donut_inner: radius * 0.58,
|
||||||
|
total: total,
|
||||||
|
slices: slices,
|
||||||
|
legend: legend(series)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Line/area geometry: gridlines + Y labels, the polyline points, an optional
|
||||||
|
area polygon, the data dots and the X-axis labels.
|
||||||
|
"""
|
||||||
|
@spec line([map()], boolean()) :: map()
|
||||||
|
def line(series, area?) do
|
||||||
|
plot_width = @line_width - @pad_left - @pad_right
|
||||||
|
plot_height = @line_height - @pad_top - @pad_bottom
|
||||||
|
max_value = Enum.max([0 | Enum.map(series, & &1.value)])
|
||||||
|
ticks = y_ticks(max_value)
|
||||||
|
y_max = List.last(ticks)
|
||||||
|
len = length(series)
|
||||||
|
x_step = if len > 1, do: plot_width / (len - 1), else: 0.0
|
||||||
|
|
||||||
|
points =
|
||||||
|
series
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.map(fn {entry, i} ->
|
||||||
|
x = @pad_left + if(len > 1, do: i * x_step, else: plot_width / 2)
|
||||||
|
y = @pad_top + if(y_max > 0, do: (1 - entry.value / y_max) * plot_height, else: plot_height)
|
||||||
|
%{x: x, y: y, label: entry.label, value: entry.value}
|
||||||
|
end)
|
||||||
|
|
||||||
|
polyline = Enum.map_join(points, " ", fn p -> "#{fmt(p.x)},#{fmt(p.y)}" end)
|
||||||
|
baseline = @pad_top + plot_height
|
||||||
|
|
||||||
|
area_points =
|
||||||
|
case {area?, points} do
|
||||||
|
{true, [_ | _]} ->
|
||||||
|
first = List.first(points)
|
||||||
|
last = List.last(points)
|
||||||
|
"#{fmt(first.x)},#{fmt(baseline)} #{polyline} #{fmt(last.x)},#{fmt(baseline)}"
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
""
|
||||||
|
end
|
||||||
|
|
||||||
|
grid =
|
||||||
|
Enum.map(ticks, fn tick ->
|
||||||
|
y = @pad_top + if(y_max > 0, do: (1 - tick / y_max) * plot_height, else: plot_height)
|
||||||
|
|
||||||
|
%{
|
||||||
|
y: fmt(y),
|
||||||
|
x1: fmt(@pad_left),
|
||||||
|
x2: fmt(@pad_left + plot_width),
|
||||||
|
label: fmt(tick),
|
||||||
|
label_x: fmt(@pad_left - 4)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{
|
||||||
|
view_box: "0 0 #{@line_width} #{@line_height}",
|
||||||
|
area?: area?,
|
||||||
|
area_points: area_points,
|
||||||
|
polyline: polyline,
|
||||||
|
grid: grid,
|
||||||
|
dots: Enum.map(points, fn p -> %{x: fmt(p.x), y: fmt(p.y), label: p.label, value: p.value} end),
|
||||||
|
x_labels:
|
||||||
|
Enum.map(points, fn p -> %{x: fmt(p.x), y: fmt(@line_height - 4), label: p.label} end)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Heatmap grid: column labels plus rows of colour-scaled cells. Each series
|
||||||
|
entry is a row and each of its segments is a column.
|
||||||
|
"""
|
||||||
|
@spec heatmap([map()]) :: map()
|
||||||
|
def heatmap(series) do
|
||||||
|
rows_src = Enum.filter(series, &(&1.segments != []))
|
||||||
|
columns = rows_src |> Enum.flat_map(fn e -> Enum.map(e.segments, & &1.label) end) |> Enum.uniq()
|
||||||
|
|
||||||
|
global_max =
|
||||||
|
case Enum.flat_map(rows_src, fn e -> Enum.map(e.segments, & &1.value) end) do
|
||||||
|
[] -> 0
|
||||||
|
values -> Enum.max(values)
|
||||||
|
end
|
||||||
|
|
||||||
|
rows =
|
||||||
|
Enum.map(rows_src, fn entry ->
|
||||||
|
seg_map = Map.new(entry.segments, fn s -> {s.label, s.value} end)
|
||||||
|
|
||||||
|
cells =
|
||||||
|
Enum.map(columns, fn col ->
|
||||||
|
value = Map.get(seg_map, col, 0)
|
||||||
|
alpha = if global_max > 0, do: value / global_max, else: 0.0
|
||||||
|
{bg, fg} = cell_colors(alpha)
|
||||||
|
%{value: value, bg: bg, fg: fg}
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{label: entry.label, cells: cells}
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{columns: columns, column_count: length(columns), rows: rows}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Stacked-bar geometry: each row's segments with widths and colours, plus a
|
||||||
|
legend of the unique segment labels.
|
||||||
|
"""
|
||||||
|
@spec stacked([map()]) :: map()
|
||||||
|
def stacked(series) do
|
||||||
|
seg_labels =
|
||||||
|
series |> Enum.flat_map(fn e -> Enum.map(e.segments, & &1.label) end) |> Enum.uniq()
|
||||||
|
|
||||||
|
totals =
|
||||||
|
Enum.map(series, fn e ->
|
||||||
|
if e.segments == [], do: e.value, else: Enum.sum(Enum.map(e.segments, & &1.value))
|
||||||
|
end)
|
||||||
|
|
||||||
|
max_total = Enum.max([0 | totals])
|
||||||
|
|
||||||
|
rows =
|
||||||
|
series
|
||||||
|
|> Enum.zip(totals)
|
||||||
|
|> Enum.map(fn {entry, total} ->
|
||||||
|
segments =
|
||||||
|
Enum.map(entry.segments, fn s ->
|
||||||
|
width = if max_total > 0, do: s.value / max_total * 100, else: 0.0
|
||||||
|
|
||||||
|
%{
|
||||||
|
label: s.label,
|
||||||
|
value: s.value,
|
||||||
|
width: fmt(width),
|
||||||
|
color: color(Enum.find_index(seg_labels, &(&1 == s.label)) || 0)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{label: entry.label, total: total, segments: segments}
|
||||||
|
end)
|
||||||
|
|
||||||
|
legend = seg_labels |> Enum.with_index() |> Enum.map(fn {l, i} -> %{label: l, color: color(i)} end)
|
||||||
|
|
||||||
|
%{rows: rows, legend: legend, max_total: max_total}
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Nice, rounded Y-axis tick values for `max_value`. Always starts at 0 and ends
|
||||||
|
at or above `max_value`.
|
||||||
|
"""
|
||||||
|
@spec y_ticks(number(), pos_integer()) :: [number()]
|
||||||
|
def y_ticks(max_value, tick_count \\ 4)
|
||||||
|
def y_ticks(max_value, _tick_count) when max_value <= 0, do: [0]
|
||||||
|
|
||||||
|
def y_ticks(max_value, tick_count) do
|
||||||
|
raw_step = max_value / (tick_count - 1)
|
||||||
|
magnitude = :math.pow(10, Float.floor(:math.log10(raw_step)))
|
||||||
|
normalised = raw_step / magnitude
|
||||||
|
|
||||||
|
nice_step =
|
||||||
|
cond do
|
||||||
|
normalised <= 1 -> magnitude
|
||||||
|
normalised <= 2 -> 2 * magnitude
|
||||||
|
normalised <= 5 -> 5 * magnitude
|
||||||
|
true -> 10 * magnitude
|
||||||
|
end
|
||||||
|
|
||||||
|
ticks =
|
||||||
|
0.0
|
||||||
|
|> Stream.iterate(&(&1 + nice_step))
|
||||||
|
|> Enum.take_while(&(&1 <= max_value + nice_step * 0.01))
|
||||||
|
|> Enum.map(&Float.round(&1, 6))
|
||||||
|
|
||||||
|
if length(ticks) < 2, do: ticks ++ [Float.round(nice_step, 6)], else: ticks
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── private helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp legend(series) do
|
||||||
|
series |> Enum.with_index() |> Enum.map(fn {e, i} -> %{label: e.label, color: color(i)} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp describe_slice(center, radius, start_angle, end_angle) do
|
||||||
|
start_rad = (start_angle - 90) * :math.pi() / 180
|
||||||
|
end_rad = (end_angle - 90) * :math.pi() / 180
|
||||||
|
x1 = center + radius * :math.cos(start_rad)
|
||||||
|
y1 = center + radius * :math.sin(start_rad)
|
||||||
|
x2 = center + radius * :math.cos(end_rad)
|
||||||
|
y2 = center + radius * :math.sin(end_rad)
|
||||||
|
large_arc = if end_angle - start_angle > 180, do: 1, else: 0
|
||||||
|
|
||||||
|
"M#{fmt(center)},#{fmt(center)} L#{fmt(x1)},#{fmt(y1)} " <>
|
||||||
|
"A#{fmt(radius)},#{fmt(radius)} 0 #{large_arc} 1 #{fmt(x2)},#{fmt(y2)} Z"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cell_colors(alpha) when alpha <= 0, do: {"transparent", "inherit"}
|
||||||
|
|
||||||
|
defp cell_colors(alpha) do
|
||||||
|
{r, g, b} = lerp_rgb({53, 117, 56}, {183, 72, 72}, alpha)
|
||||||
|
opacity = 0.25 + alpha * 0.75
|
||||||
|
bg = "rgba(#{r},#{g},#{b},#{Float.round(opacity, 2)})"
|
||||||
|
er = round(r * opacity + 30 * (1 - opacity))
|
||||||
|
eg = round(g * opacity + 30 * (1 - opacity))
|
||||||
|
eb = round(b * opacity + 30 * (1 - opacity))
|
||||||
|
fg = if 0.299 * er + 0.587 * eg + 0.114 * eb > 140, do: "#000", else: "#fff"
|
||||||
|
{bg, fg}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp lerp_rgb({ar, ag, ab}, {br, bg, bb}, t) do
|
||||||
|
{round(ar + (br - ar) * t), round(ag + (bg - ag) * t), round(ab + (bb - ab) * t)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fmt(n) when is_integer(n), do: Integer.to_string(n)
|
||||||
|
|
||||||
|
defp fmt(n) when is_float(n) do
|
||||||
|
rounded = Float.round(n, 2)
|
||||||
|
if rounded == Float.round(rounded, 0), do: Integer.to_string(trunc(rounded)), else: :erlang.float_to_binary(rounded, [:short])
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -189,6 +189,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
|||||||
|> mark_surfaces_expanded(assigns)
|
|> mark_surfaces_expanded(assigns)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Only called from pending_user_message/2, which already narrows the
|
||||||
|
# request to %{message: binary}.
|
||||||
defp persisted_user_message_for_request?(messages, %{message: message} = request)
|
defp persisted_user_message_for_request?(messages, %{message: message} = request)
|
||||||
when is_binary(message) do
|
when is_binary(message) do
|
||||||
messages
|
messages
|
||||||
@@ -198,8 +200,6 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp persisted_user_message_for_request?(_messages, _request), do: false
|
|
||||||
|
|
||||||
defp persisted_assistant_content_for_request?(messages, request, content)
|
defp persisted_assistant_content_for_request?(messages, request, content)
|
||||||
when is_binary(content) and content != "" do
|
when is_binary(content) and content != "" do
|
||||||
messages
|
messages
|
||||||
|
|||||||
@@ -87,7 +87,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|
|||||||
%{
|
%{
|
||||||
label: map_value(entry, "label", dgettext("ui", "Assistant")),
|
label: map_value(entry, "label", dgettext("ui", "Assistant")),
|
||||||
value: numeric_value(map_value(entry, "value", 0)),
|
value: numeric_value(map_value(entry, "value", 0)),
|
||||||
segments: List.wrap(map_value(entry, "segments", []))
|
segments:
|
||||||
|
entry
|
||||||
|
|> map_value("segments", [])
|
||||||
|
|> List.wrap()
|
||||||
|
|> Enum.map(fn segment ->
|
||||||
|
%{
|
||||||
|
label: map_value(segment, "label", ""),
|
||||||
|
value: numeric_value(map_value(segment, "value", 0))
|
||||||
|
}
|
||||||
|
end)
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -95,7 +104,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|
|||||||
id: surface_id,
|
id: surface_id,
|
||||||
type: "chart",
|
type: "chart",
|
||||||
title: map_value(arguments, "title"),
|
title: map_value(arguments, "title"),
|
||||||
chart_type: map_value(arguments, "chart_type", "bar"),
|
chart_type: map_value(arguments, "chartType") || map_value(arguments, "chart_type", "bar"),
|
||||||
series: series,
|
series: series,
|
||||||
max_value: Enum.max([0 | Enum.map(series, & &1.value)])
|
max_value: Enum.max([0 | Enum.map(series, & &1.value)])
|
||||||
}
|
}
|
||||||
@@ -294,6 +303,5 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|
|||||||
|
|
||||||
defp map_value(_map, _key, default), do: default
|
defp map_value(_map, _key, default), do: default
|
||||||
|
|
||||||
defp truthy?(value) when value in [true, "true", 1, "1", "on"], do: true
|
defp truthy?(value), do: BDS.Values.truthy?(value)
|
||||||
defp truthy?(_value), do: false
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
<h2><%= dgettext("ui", "API Key Required") %></h2>
|
<h2><%= dgettext("ui", "API Key Required") %></h2>
|
||||||
<p><%= dgettext("ui", "Configure an API key in Settings to enable AI chat.") %></p>
|
<p><%= dgettext("ui", "Configure an API key in Settings to enable AI chat.") %></p>
|
||||||
<div class="api-key-form">
|
<div class="api-key-form">
|
||||||
<button class="api-key-submit ui-button ui-button-primary" type="button" phx-click="open_chat_settings" phx-target={@myself}><%= dgettext("ui", "Open Settings") %></button>
|
<button class="api-key-submit primary ui-button ui-button-primary" type="button" phx-click="open_chat_settings" phx-target={@myself}><%= dgettext("ui", "Open Settings") %></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
|
|
||||||
<form class="chat-input-wrapper flex items-end gap-2" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message" phx-target={@myself}>
|
<form class="chat-input-wrapper flex items-end gap-2" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message" phx-target={@myself}>
|
||||||
<textarea class="chat-input chat-surface-input ui-textarea" name="message" rows="1" placeholder={dgettext("ui", "Type a message...")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea>
|
<textarea class="chat-input chat-surface-input ui-textarea" name="message" rows="1" placeholder={dgettext("ui", "Type a message...")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea>
|
||||||
<button class="chat-send-button ui-button ui-button-primary" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" phx-target={@myself} disabled={@chat_editor.send_disabled?}>↑</button>
|
<button class="chat-send-button primary ui-button ui-button-primary" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" phx-target={@myself}>↑</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<%= if @chat_editor.action_error do %>
|
<%= if @chat_editor.action_error do %>
|
||||||
|
|||||||
82
lib/bds/desktop/shell_live/content_state.ex
Normal file
82
lib/bds/desktop/shell_live/content_state.ex
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.ContentState do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
|
||||||
|
alias BDS.AI
|
||||||
|
alias BDS.Desktop.ShellData
|
||||||
|
alias BDS.Desktop.ShellLive.{SidebarState, SocketState, TabHelpers, TaskLocalization, TitlebarMenu}
|
||||||
|
|
||||||
|
def refresh_content(socket, workbench) do
|
||||||
|
projects =
|
||||||
|
case ShellData.project_snapshot() do
|
||||||
|
{:ok, data} -> data
|
||||||
|
{:error, :not_ready} -> ShellData.default_project_snapshot()
|
||||||
|
end
|
||||||
|
|
||||||
|
dashboard =
|
||||||
|
case ShellData.dashboard(projects.active_project_id) do
|
||||||
|
{:ok, data} -> data
|
||||||
|
{:error, :not_ready} -> BDS.UI.Dashboard.empty_snapshot()
|
||||||
|
end
|
||||||
|
|
||||||
|
git_badge_count =
|
||||||
|
case ShellData.git_badge_count(projects.active_project_id) do
|
||||||
|
{:ok, count} -> count
|
||||||
|
{:error, :not_ready} -> 0
|
||||||
|
end
|
||||||
|
|
||||||
|
active_view_id = Atom.to_string(workbench.active_view)
|
||||||
|
|
||||||
|
sidebar_data =
|
||||||
|
case ShellData.sidebar_view(
|
||||||
|
projects.active_project_id,
|
||||||
|
active_view_id,
|
||||||
|
SidebarState.current_filters(socket, active_view_id)
|
||||||
|
) do
|
||||||
|
{:ok, data} -> data
|
||||||
|
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
|
||||||
|
end
|
||||||
|
|
||||||
|
sidebar_data = SidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:projects, projects)
|
||||||
|
|> assign(:current_project, ShellData.current_project(projects))
|
||||||
|
|> assign(:dashboard, dashboard)
|
||||||
|
|> assign(:dashboard_timeline_entries, Map.get(dashboard, :timeline_entries, []))
|
||||||
|
|> assign(:dashboard_category_counts, Map.get(dashboard, :category_counts, []))
|
||||||
|
|> assign(:dashboard_recent_posts, Map.get(dashboard, :recent_posts, []))
|
||||||
|
|> assign(
|
||||||
|
:dashboard_tag_cloud_items,
|
||||||
|
ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, []))
|
||||||
|
)
|
||||||
|
|> assign(:git_badge_count, git_badge_count)
|
||||||
|
|> assign(:sidebar_data, sidebar_data)
|
||||||
|
|> SocketState.refresh_layout(workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reload_shell(socket, workbench) do
|
||||||
|
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
||||||
|
raw_task_status = BDS.Tasks.status_snapshot()
|
||||||
|
page_language = socket.assigns[:page_language] || ShellData.ui_language()
|
||||||
|
|
||||||
|
offline_mode =
|
||||||
|
if Phoenix.LiveView.connected?(socket) do
|
||||||
|
Map.get(socket.assigns, :offline_mode, AI.airplane_mode?(true))
|
||||||
|
else
|
||||||
|
Map.get(socket.assigns, :offline_mode, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
task_status = TaskLocalization.localize_task_status(raw_task_status, page_language)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:tab_meta, tab_meta)
|
||||||
|
|> assign(:task_status, task_status)
|
||||||
|
|> assign(:offline_mode, offline_mode)
|
||||||
|
|> assign(:supported_ui_languages, ShellData.supported_ui_languages())
|
||||||
|
|> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
|
||||||
|
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|
||||||
|
|> refresh_content(workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
110
lib/bds/desktop/shell_live/editor_image_drop.ex
Normal file
110
lib/bds/desktop/shell_live/editor_image_drop.ex
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.EditorImageDrop do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
# Implements the drag-and-drop image chain described in
|
||||||
|
# action_patterns.allium DragDropImageChain (trigger: editor_post.allium
|
||||||
|
# PostDragDropImage). A single image file dropped on the post editor body
|
||||||
|
# runs four synchronous steps the user waits on, then two background steps
|
||||||
|
# whose results are auto-applied without a modal.
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias BDS.{AI, Media, Metadata, Posts}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Synchronous portion of the chain (steps 1-4):
|
||||||
|
|
||||||
|
1. importMedia(file) -> media record + file copy + base sidecar
|
||||||
|
2. generateThumbnails(media) -> small/medium/large/ai (done inside import_media)
|
||||||
|
3. linkMediaToPost(media, post) -> update sidecar linkedPostIds
|
||||||
|
4. caller inserts the returned markdown at the cursor
|
||||||
|
|
||||||
|
Returns `{:ok, media, markdown}` where `markdown` is the reference inserted at
|
||||||
|
the cursor. These steps are not AI activities, so they run regardless of
|
||||||
|
airplane mode.
|
||||||
|
"""
|
||||||
|
@spec import_and_link(String.t(), String.t(), String.t()) ::
|
||||||
|
{:ok, Media.Media.t(), String.t()} | {:error, term()}
|
||||||
|
def import_and_link(project_id, post_id, source_path) do
|
||||||
|
with {:ok, media} <-
|
||||||
|
Media.import_media(%{project_id: project_id, source_path: source_path}),
|
||||||
|
{:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
|
||||||
|
{:ok, media, markdown_for(media)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Markdown reference inserted at the cursor (step 4): `` for
|
||||||
|
images, a plain link for other file types.
|
||||||
|
"""
|
||||||
|
@spec markdown_for(Media.Media.t()) :: String.t()
|
||||||
|
def markdown_for(media) do
|
||||||
|
if String.starts_with?(media.mime_type || "", "image/") do
|
||||||
|
""
|
||||||
|
else
|
||||||
|
"[#{media.original_name}](bds-media://#{media.id})"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Background portion of the chain (steps 5-6), gated behind airplane mode:
|
||||||
|
|
||||||
|
5. aiImageAnalysis(media) -> results auto-applied to media metadata (no modal)
|
||||||
|
6. if auto-translate enabled (post.do_not_translate == false):
|
||||||
|
translateMediaMetadata(media, lang) for each blog language
|
||||||
|
|
||||||
|
Only runs for images. Failures are logged and never roll back the import.
|
||||||
|
"""
|
||||||
|
@spec enrich(Media.Media.t(), String.t(), String.t()) :: :ok
|
||||||
|
def enrich(media, post_id, language) do
|
||||||
|
if image?(media) do
|
||||||
|
with {:ok, result} <- AI.analyze_image(media.id, language: language),
|
||||||
|
{:ok, _updated} <-
|
||||||
|
Media.update_media(media.id, %{
|
||||||
|
title: result.title,
|
||||||
|
alt: result.alt,
|
||||||
|
caption: result.caption
|
||||||
|
}) do
|
||||||
|
maybe_translate(media.id, post_id, language)
|
||||||
|
else
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("Drag-drop AI analysis failed for #{media.id}: #{inspect(reason)}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_translate(media_id, post_id, language) do
|
||||||
|
post = Posts.get_post(post_id)
|
||||||
|
|
||||||
|
if post && not post.do_not_translate do
|
||||||
|
translate_targets(post.project_id, language)
|
||||||
|
|> Enum.each(fn target ->
|
||||||
|
case AI.translate_media(media_id, target) do
|
||||||
|
{:ok, translation} ->
|
||||||
|
Media.upsert_media_translation(media_id, target, %{
|
||||||
|
title: translation.title,
|
||||||
|
alt: translation.alt,
|
||||||
|
caption: translation.caption
|
||||||
|
})
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning(
|
||||||
|
"Drag-drop media translation failed for #{media_id} -> #{target}: #{inspect(reason)}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translate_targets(project_id, language) do
|
||||||
|
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||||
|
|
||||||
|
[metadata.main_language | metadata.blog_languages || []]
|
||||||
|
|> Enum.reject(&(&1 == language or is_nil(&1)))
|
||||||
|
|> Enum.uniq()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp image?(media), do: String.starts_with?(media.mime_type || "", "image/")
|
||||||
|
end
|
||||||
@@ -15,14 +15,7 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
|
|||||||
@spec start(list(String.t()), String.t(), String.t(), String.t(), integer(), pid()) :: :ok
|
@spec start(list(String.t()), String.t(), String.t(), String.t(), integer(), pid()) :: :ok
|
||||||
def start(paths, project_id, post_id, language, concurrency_limit, parent) do
|
def start(paths, project_id, post_id, language, concurrency_limit, parent) do
|
||||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||||
main_language = metadata.main_language || language
|
translate_targets = translate_targets(metadata, language)
|
||||||
blog_languages = metadata.blog_languages || []
|
|
||||||
|
|
||||||
translate_targets =
|
|
||||||
[main_language | blog_languages]
|
|
||||||
|> Enum.reject(&(&1 == language or is_nil(&1)))
|
|
||||||
|> Enum.uniq()
|
|
||||||
|
|
||||||
{in_flight, remaining} = Enum.split(paths, concurrency_limit)
|
{in_flight, remaining} = Enum.split(paths, concurrency_limit)
|
||||||
|
|
||||||
tasks =
|
tasks =
|
||||||
@@ -35,14 +28,58 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
|
|||||||
known_refs = MapSet.new(tasks, & &1.ref)
|
known_refs = MapSet.new(tasks, & &1.ref)
|
||||||
|
|
||||||
drain_tasks(
|
drain_tasks(
|
||||||
remaining, tasks, known_refs, project_id, post_id, language, translate_targets, parent
|
remaining,
|
||||||
|
tasks,
|
||||||
|
known_refs,
|
||||||
|
project_id,
|
||||||
|
post_id,
|
||||||
|
language,
|
||||||
|
translate_targets,
|
||||||
|
parent
|
||||||
)
|
)
|
||||||
|
|
||||||
send(parent, {:add_images_complete, length(paths)})
|
send(parent, {:add_images_complete, length(paths)})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
The translation targets for AI-generated media texts: the project main
|
||||||
|
language plus all blog languages, minus the source `language`.
|
||||||
|
"""
|
||||||
|
def translate_targets(metadata, language) do
|
||||||
|
main_language = metadata.main_language || language
|
||||||
|
blog_languages = metadata.blog_languages || []
|
||||||
|
|
||||||
|
[main_language | blog_languages]
|
||||||
|
|> Enum.reject(&(&1 == language or is_nil(&1)))
|
||||||
|
|> Enum.uniq()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Imports a single file and runs the same best-effort AI enrichment
|
||||||
|
(title/alt/caption plus translations) as the gallery pipeline, without
|
||||||
|
linking the media to a post. Used by the CLI `media` command (issue #25).
|
||||||
|
|
||||||
|
Returns `{:ok, media, display_title}` or `{:error, reason}`.
|
||||||
|
"""
|
||||||
|
def import_and_enrich(path, project_id, language, translate_targets) do
|
||||||
|
case Media.import_media(%{project_id: project_id, source_path: path}) do
|
||||||
|
{:ok, media} ->
|
||||||
|
{:ok, media, enrich_media(media, language, translate_targets)}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp drain_tasks(
|
defp drain_tasks(
|
||||||
[], tasks, _known_refs, _project_id, _post_id, _language, _translate_targets, _parent
|
[],
|
||||||
|
tasks,
|
||||||
|
_known_refs,
|
||||||
|
_project_id,
|
||||||
|
_post_id,
|
||||||
|
_language,
|
||||||
|
_translate_targets,
|
||||||
|
_parent
|
||||||
) do
|
) do
|
||||||
Enum.each(tasks, fn task -> Task.await(task, :infinity) end)
|
Enum.each(tasks, fn task -> Task.await(task, :infinity) end)
|
||||||
end
|
end
|
||||||
@@ -65,7 +102,12 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
|
|||||||
new_task =
|
new_task =
|
||||||
Task.async(fn ->
|
Task.async(fn ->
|
||||||
process_single_image(
|
process_single_image(
|
||||||
next_path, project_id, post_id, language, translate_targets, parent
|
next_path,
|
||||||
|
project_id,
|
||||||
|
post_id,
|
||||||
|
language,
|
||||||
|
translate_targets,
|
||||||
|
parent
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -81,8 +123,14 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
drain_tasks(
|
drain_tasks(
|
||||||
[next_path | rest], tasks, known_refs,
|
[next_path | rest],
|
||||||
project_id, post_id, language, translate_targets, parent
|
tasks,
|
||||||
|
known_refs,
|
||||||
|
project_id,
|
||||||
|
post_id,
|
||||||
|
language,
|
||||||
|
translate_targets,
|
||||||
|
parent
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -93,7 +141,12 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
|
|||||||
new_task =
|
new_task =
|
||||||
Task.async(fn ->
|
Task.async(fn ->
|
||||||
process_single_image(
|
process_single_image(
|
||||||
next_path, project_id, post_id, language, translate_targets, parent
|
next_path,
|
||||||
|
project_id,
|
||||||
|
post_id,
|
||||||
|
language,
|
||||||
|
translate_targets,
|
||||||
|
parent
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -109,8 +162,14 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
drain_tasks(
|
drain_tasks(
|
||||||
[next_path | rest], tasks, known_refs,
|
[next_path | rest],
|
||||||
project_id, post_id, language, translate_targets, parent
|
tasks,
|
||||||
|
known_refs,
|
||||||
|
project_id,
|
||||||
|
post_id,
|
||||||
|
language,
|
||||||
|
translate_targets,
|
||||||
|
parent
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -124,19 +183,21 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp process_single_image(
|
defp process_single_image(
|
||||||
path, project_id, post_id, language, translate_targets, parent
|
path,
|
||||||
|
project_id,
|
||||||
|
post_id,
|
||||||
|
language,
|
||||||
|
translate_targets,
|
||||||
|
parent
|
||||||
) do
|
) do
|
||||||
|
# Linking is mandatory and MUST happen regardless of AI availability
|
||||||
|
# (airplane mode / no local model), mirroring the drag-drop chain and the
|
||||||
|
# old app: import -> link -> (optional) AI enrichment. Gating the link
|
||||||
|
# behind AI is what left galleries empty.
|
||||||
with {:ok, media} <- Media.import_media(%{project_id: project_id, source_path: path}),
|
with {:ok, media} <- Media.import_media(%{project_id: project_id, source_path: path}),
|
||||||
true <- String.starts_with?(media.mime_type || "", "image/"),
|
true <- String.starts_with?(media.mime_type || "", "image/"),
|
||||||
{:ok, result} <- AI.analyze_image(media.id, language: language),
|
|
||||||
{:ok, _updated} <- Media.update_media(media.id, %{
|
|
||||||
title: result.title,
|
|
||||||
alt: result.alt,
|
|
||||||
caption: result.caption
|
|
||||||
}),
|
|
||||||
{:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
|
{:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
|
||||||
translate_media_translations(media.id, translate_targets)
|
title = enrich_media(media, language, translate_targets)
|
||||||
title = result.title || media.original_name
|
|
||||||
send(parent, {:add_image_processed, title})
|
send(parent, {:add_image_processed, title})
|
||||||
else
|
else
|
||||||
false ->
|
false ->
|
||||||
@@ -148,6 +209,32 @@ defmodule BDS.Desktop.ShellLive.GalleryImport do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Best-effort AI enrichment: analysis + metadata update + translations. Any
|
||||||
|
# failure (offline, no model) is logged and never unlinks the image. Returns
|
||||||
|
# the display title used for progress reporting.
|
||||||
|
defp enrich_media(media, language, translate_targets) do
|
||||||
|
case AI.analyze_image(media.id, language: language) do
|
||||||
|
{:ok, result} ->
|
||||||
|
case Media.update_media(media.id, %{
|
||||||
|
title: result.title,
|
||||||
|
alt: result.alt,
|
||||||
|
caption: result.caption
|
||||||
|
}) do
|
||||||
|
{:ok, _updated} ->
|
||||||
|
translate_media_translations(media.id, translate_targets)
|
||||||
|
result.title || media.original_name
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("Media metadata update failed for #{media.id}: #{inspect(reason)}")
|
||||||
|
media.original_name
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning("AI image analysis skipped for #{media.id}: #{inspect(reason)}")
|
||||||
|
media.original_name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp translate_media_translations(_media_id, []), do: :ok
|
defp translate_media_translations(_media_id, []), do: :ok
|
||||||
|
|
||||||
defp translate_media_translations(media_id, [target | rest]) do
|
defp translate_media_translations(media_id, [target | rest]) do
|
||||||
|
|||||||
126
lib/bds/desktop/shell_live/git_handler.ex
Normal file
126
lib/bds/desktop/shell_live/git_handler.ex
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.GitHandler do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
|
||||||
|
alias BDS.Desktop.ShellLive.{SocketState, TabHelpers, UrlState}
|
||||||
|
alias BDS.UI.Workbench
|
||||||
|
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
|
def run_action(socket, event) do
|
||||||
|
project_id = current_project_id(socket)
|
||||||
|
|
||||||
|
{label, result} =
|
||||||
|
case event do
|
||||||
|
"git_fetch" -> {dgettext("ui", "Fetch"), git_call(project_id, &BDS.Git.fetch/1)}
|
||||||
|
"git_pull" -> {dgettext("ui", "Pull"), git_call(project_id, &BDS.Git.pull/1)}
|
||||||
|
"git_push" -> {dgettext("ui", "Push"), git_call(project_id, &BDS.Git.push/1)}
|
||||||
|
"git_prune_lfs" -> {dgettext("ui", "Prune LFS"), prune_lfs(project_id)}
|
||||||
|
end
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> append_git_result(label, result)
|
||||||
|
|> SocketState.refresh_sidebar(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
def commit(socket, "") do
|
||||||
|
socket
|
||||||
|
|> SocketState.append_output_entry(
|
||||||
|
dgettext("ui", "Commit"),
|
||||||
|
dgettext("ui", "Commit message is required"),
|
||||||
|
nil,
|
||||||
|
"error"
|
||||||
|
)
|
||||||
|
|> SocketState.refresh_sidebar(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
def commit(socket, message) do
|
||||||
|
case git_call(current_project_id(socket), &BDS.Git.commit_all(&1, message)) do
|
||||||
|
{:ok, _result} ->
|
||||||
|
workbench = close_git_diff_tabs(socket.assigns.workbench)
|
||||||
|
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:tab_meta, tab_meta)
|
||||||
|
|> SocketState.append_output_entry(dgettext("ui", "Commit"), message, nil, "info")
|
||||||
|
|> SocketState.refresh_sidebar(workbench)
|
||||||
|
|> UrlState.push()
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> SocketState.append_output_entry(dgettext("ui", "Commit"), format_git_error(reason), nil, "error")
|
||||||
|
|> SocketState.refresh_sidebar(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(socket, remote_url) do
|
||||||
|
project_id = current_project_id(socket)
|
||||||
|
|
||||||
|
case git_call(project_id, &BDS.Git.initialize_repo/1) do
|
||||||
|
{:ok, _repo} ->
|
||||||
|
_ = maybe_set_git_remote(project_id, remote_url)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> SocketState.append_output_entry(
|
||||||
|
dgettext("ui", "Initialize Git"),
|
||||||
|
dgettext("ui", "Repository initialized"),
|
||||||
|
nil,
|
||||||
|
"info"
|
||||||
|
)
|
||||||
|
|> SocketState.refresh_sidebar(socket.assigns.workbench)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> SocketState.append_output_entry(
|
||||||
|
dgettext("ui", "Initialize Git"),
|
||||||
|
format_git_error(reason),
|
||||||
|
nil,
|
||||||
|
"error"
|
||||||
|
)
|
||||||
|
|> SocketState.refresh_sidebar(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_remote_url(value) do
|
||||||
|
case value |> to_string() |> String.trim() do
|
||||||
|
"" -> nil
|
||||||
|
url -> url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp git_call(nil, _fun), do: {:error, :no_project}
|
||||||
|
defp git_call("default", _fun), do: {:error, :no_project}
|
||||||
|
defp git_call(project_id, fun) when is_binary(project_id), do: fun.(project_id)
|
||||||
|
|
||||||
|
defp prune_lfs(nil), do: {:error, :no_project}
|
||||||
|
defp prune_lfs("default"), do: {:error, :no_project}
|
||||||
|
|
||||||
|
defp prune_lfs(project_id) when is_binary(project_id),
|
||||||
|
do: BDS.Git.prune_lfs_cache(project_id, 10)
|
||||||
|
|
||||||
|
defp maybe_set_git_remote(_project_id, nil), do: :ok
|
||||||
|
defp maybe_set_git_remote(project_id, remote_url), do: BDS.Git.set_remote(project_id, remote_url)
|
||||||
|
|
||||||
|
defp append_git_result(socket, label, {:ok, _result}) do
|
||||||
|
SocketState.append_output_entry(socket, label, dgettext("ui", "Done"), nil, "info")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp append_git_result(socket, label, {:error, reason}) do
|
||||||
|
SocketState.append_output_entry(socket, label, format_git_error(reason), nil, "error")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_git_error(:no_project), do: dgettext("ui", "No active project")
|
||||||
|
defp format_git_error(%{message: message}) when is_binary(message), do: message
|
||||||
|
defp format_git_error(%{guidance: guidance}) when is_binary(guidance), do: guidance
|
||||||
|
defp format_git_error({:git_failed, message}) when is_binary(message), do: message
|
||||||
|
defp format_git_error(reason), do: inspect(reason)
|
||||||
|
|
||||||
|
defp close_git_diff_tabs(workbench) do
|
||||||
|
workbench.tabs
|
||||||
|
|> Enum.filter(&(&1.type == :git_diff))
|
||||||
|
|> Enum.reduce(workbench, fn tab, wb -> Workbench.close_tab(wb, :git_diff, tab.id) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id]
|
||||||
|
end
|
||||||
130
lib/bds/desktop/shell_live/git_run.ex
Normal file
130
lib/bds/desktop/shell_live/git_run.ex
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.GitRun do
|
||||||
|
@moduledoc """
|
||||||
|
Live modal for streaming network git commands (fetch/pull/push).
|
||||||
|
|
||||||
|
Starts a `BDS.Git.Stream`, appends its stdout/stderr chunks as they arrive
|
||||||
|
(stderr rendered in red), shows a spinner while running and a pass/fail banner
|
||||||
|
from the exit status. Closing the modal cancels a still-running command, which
|
||||||
|
kills the OS process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Phoenix.Component
|
||||||
|
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
|
alias BDS.Git
|
||||||
|
|
||||||
|
@operations %{"git_fetch" => :fetch, "git_pull" => :pull, "git_push" => :push}
|
||||||
|
|
||||||
|
@doc "True when the shell event is a streaming network git action."
|
||||||
|
def network_event?(event), do: Map.has_key?(@operations, event)
|
||||||
|
|
||||||
|
@doc "Open the modal and start streaming the operation."
|
||||||
|
def start(socket, event, project_id) do
|
||||||
|
operation = Map.fetch!(@operations, event)
|
||||||
|
|
||||||
|
run =
|
||||||
|
case start_stream(project_id, operation) do
|
||||||
|
{:ok, pid, ref} ->
|
||||||
|
%{operation: operation, pid: pid, ref: ref, lines: [], status: :running}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
%{
|
||||||
|
operation: operation,
|
||||||
|
pid: nil,
|
||||||
|
ref: nil,
|
||||||
|
lines: [{:stderr, error_text(reason)}],
|
||||||
|
status: {:done, 1}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
assign(socket, :git_run, run)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Append a streamed chunk to the modal (newest first internally)."
|
||||||
|
def append(socket, ref, stream, chunk) do
|
||||||
|
case socket.assigns[:git_run] do
|
||||||
|
%{ref: ^ref, lines: lines} = run ->
|
||||||
|
assign(socket, :git_run, %{run | lines: [{stream, chunk} | lines]})
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Mark the run finished with its exit status."
|
||||||
|
def done(socket, ref, status) do
|
||||||
|
case socket.assigns[:git_run] do
|
||||||
|
%{ref: ^ref} = run ->
|
||||||
|
assign(socket, :git_run, %{run | status: {:done, status}, pid: nil})
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Close the modal, cancelling (killing) a still-running command."
|
||||||
|
def close(socket) do
|
||||||
|
case socket.assigns[:git_run] do
|
||||||
|
%{status: :running, pid: pid} when is_pid(pid) -> Git.Stream.cancel(pid)
|
||||||
|
_other -> :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
assign(socket, :git_run, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp start_stream(nil, _operation), do: {:error, :no_project}
|
||||||
|
defp start_stream("default", _operation), do: {:error, :no_project}
|
||||||
|
|
||||||
|
defp start_stream(project_id, operation) when is_binary(project_id),
|
||||||
|
do: Git.stream(project_id, operation, self())
|
||||||
|
|
||||||
|
defp error_text(reason) when reason in [:no_project, :not_found],
|
||||||
|
do: dgettext("ui", "No active project") <> "\n"
|
||||||
|
|
||||||
|
defp error_text(reason), do: inspect(reason) <> "\n"
|
||||||
|
|
||||||
|
def git_run_modal(assigns) do
|
||||||
|
~H"""
|
||||||
|
<%= if @git_run do %>
|
||||||
|
<div class="shell-overlay-backdrop git-run-backdrop" data-testid="git-run-backdrop" phx-window-keydown="git_run_keydown">
|
||||||
|
<button class="shell-overlay-dismiss" type="button" phx-click="git_run_close" aria-label={dgettext("ui", "Close")}></button>
|
||||||
|
<div class="git-run-modal" role="dialog" aria-modal="true">
|
||||||
|
<div class="git-run-header">
|
||||||
|
<h2 class="git-run-title">{title(@git_run.operation)}</h2>
|
||||||
|
<span class={["git-run-status", status_class(@git_run.status)]} data-testid="git-run-status">
|
||||||
|
{status_label(@git_run.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre class="git-run-output" data-testid="git-run-output"><%= for {stream, text} <- Enum.reverse(@git_run.lines) do %><span class={line_class(stream)}>{text}</span><% end %></pre>
|
||||||
|
<div class="git-run-footer">
|
||||||
|
<button class="git-action-button" type="button" phx-click="git_run_close" data-testid="git-run-close">
|
||||||
|
{close_label(@git_run.status)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp title(:fetch), do: dgettext("ui", "git fetch")
|
||||||
|
defp title(:pull), do: dgettext("ui", "git pull")
|
||||||
|
defp title(:push), do: dgettext("ui", "git push")
|
||||||
|
|
||||||
|
defp line_class(:stderr), do: "git-run-line git-run-stderr"
|
||||||
|
defp line_class(:stdout), do: "git-run-line git-run-stdout"
|
||||||
|
|
||||||
|
defp status_class(:running), do: "running"
|
||||||
|
defp status_class({:done, 0}), do: "ok"
|
||||||
|
defp status_class({:done, _status}), do: "fail"
|
||||||
|
|
||||||
|
defp status_label(:running), do: dgettext("ui", "Running…")
|
||||||
|
defp status_label({:done, 0}), do: dgettext("ui", "Done")
|
||||||
|
|
||||||
|
defp status_label({:done, status}),
|
||||||
|
do: dgettext("ui", "Failed (exit %{status})", status: status)
|
||||||
|
|
||||||
|
defp close_label(:running), do: dgettext("ui", "Cancel")
|
||||||
|
defp close_label({:done, _status}), do: dgettext("ui", "Close")
|
||||||
|
end
|
||||||
@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
alias BDS.{AI, ImportAnalysis, ImportDefinitions, ImportExecution}
|
alias BDS.{AI, ImportAnalysis, ImportDefinitions, ImportExecution}
|
||||||
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData}
|
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData}
|
||||||
alias BDS.Desktop.ShellLive.Notify
|
alias BDS.Desktop.ShellLive.Notify
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias BDS.Desktop.ShellLive.ImportEditor.{
|
alias BDS.Desktop.ShellLive.ImportEditor.{
|
||||||
AnalysisState,
|
AnalysisState,
|
||||||
@@ -327,7 +328,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
|> assign(:taxonomy_edit, %{
|
|> assign(:taxonomy_edit, %{
|
||||||
type: type,
|
type: type,
|
||||||
name: name,
|
name: name,
|
||||||
value: mapped_to |> to_string() |> blank_to_nil()
|
value: mapped_to |> to_string() |> BDS.MapUtils.blank_to_nil()
|
||||||
})
|
})
|
||||||
|> build_data()
|
|> build_data()
|
||||||
|
|
||||||
@@ -434,7 +435,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
socket =
|
socket =
|
||||||
with %{} = definition <- ImportDefinitions.get_definition(definition_id),
|
with %{} = definition <- ImportDefinitions.get_definition(definition_id),
|
||||||
%{} = report <- ImportDefinitions.decode_analysis_result(definition) do
|
%{} = report <- ImportDefinitions.decode_analysis_result(definition) do
|
||||||
if socket.assigns.offline_mode? do
|
if socket.assigns.offline_mode? and not AI.airplane_endpoint_configured?() do
|
||||||
notify_output(
|
notify_output(
|
||||||
dgettext("ui", "Import"),
|
dgettext("ui", "Import"),
|
||||||
BDS.Gettext.lgettext(
|
BDS.Gettext.lgettext(
|
||||||
@@ -642,7 +643,10 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
defp maybe_update_tab_meta(socket, name) do
|
defp maybe_update_tab_meta(socket, name) do
|
||||||
title = name || dgettext("ui", "Untitled Import")
|
title = name || dgettext("ui", "Untitled Import")
|
||||||
|
|
||||||
Notify.tab_meta(:import, socket.assigns.definition_id, title,
|
Notify.tab_meta(
|
||||||
|
:import,
|
||||||
|
socket.assigns.definition_id,
|
||||||
|
title,
|
||||||
dgettext(
|
dgettext(
|
||||||
"ui",
|
"ui",
|
||||||
"Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported."
|
"Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported."
|
||||||
@@ -690,7 +694,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
wxr_file_path: analysis_state.file_path,
|
wxr_file_path: analysis_state.file_path,
|
||||||
last_analysis_result: report
|
last_analysis_result: report
|
||||||
}
|
}
|
||||||
|> maybe_put(:name, AnalysisState.suggested_definition_name(report))
|
|> BDS.MapUtils.maybe_put(:name, AnalysisState.suggested_definition_name(report))
|
||||||
|
|
||||||
case ImportDefinitions.update_definition(definition_id, attrs) do
|
case ImportDefinitions.update_definition(definition_id, attrs) do
|
||||||
{:ok, _definition} ->
|
{:ok, _definition} ->
|
||||||
@@ -836,7 +840,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
<div class={["import-file-path", if(blank?(@import_editor.uploads_folder_path), do: "placeholder")]}>
|
<div class={["import-file-path", if(blank?(@import_editor.uploads_folder_path), do: "placeholder")]}>
|
||||||
<%= @import_editor.uploads_folder_path || dgettext("ui", "No folder selected") %>
|
<%= @import_editor.uploads_folder_path || dgettext("ui", "No folder selected") %>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" phx-click="select_import_uploads_folder" phx-target={@myself}><%= dgettext("ui", "Open") %></button>
|
<button class="secondary ui-button ui-button-secondary" type="button" phx-click="select_import_uploads_folder" phx-target={@myself}><%= dgettext("ui", "Open") %></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="import-file-row">
|
<div class="import-file-row">
|
||||||
@@ -844,7 +848,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
<div class={["import-file-path", if(blank?(@import_editor.wxr_file_path), do: "placeholder")]}>
|
<div class={["import-file-path", if(blank?(@import_editor.wxr_file_path), do: "placeholder")]}>
|
||||||
<%= @import_editor.wxr_file_path || dgettext("ui", "Select a file to analyze") %>
|
<%= @import_editor.wxr_file_path || dgettext("ui", "Select a file to analyze") %>
|
||||||
</div>
|
</div>
|
||||||
<button class="import-analyze-btn" type="button" phx-click="select_import_wxr_file" phx-target={@myself}><%= dgettext("ui", "Select & Analyze") %></button>
|
<button class="import-analyze-btn primary ui-button ui-button-primary" type="button" phx-click="select_import_wxr_file" phx-target={@myself}><%= dgettext("ui", "Select & Analyze") %></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -940,7 +944,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
<%= if @counts.pages > 0 do %><span class="import-count-tag"><%= @counts.pages %> <%= dgettext("ui", "pages") %></span><% end %>
|
<%= if @counts.pages > 0 do %><span class="import-count-tag"><%= @counts.pages %> <%= dgettext("ui", "pages") %></span><% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="import-execute-btn" type="button" phx-click="execute_import_editor" phx-target={@myself} disabled={@counts.total == 0}>
|
<button class="import-execute-btn primary ui-button ui-button-primary" type="button" phx-click="execute_import_editor" phx-target={@myself} disabled={@counts.total == 0}>
|
||||||
<%= if @counts.total == 0 do %>
|
<%= if @counts.total == 0 do %>
|
||||||
<%= dgettext("ui", "Nothing to Import") %>
|
<%= dgettext("ui", "Nothing to Import") %>
|
||||||
<% else %>
|
<% else %>
|
||||||
@@ -996,7 +1000,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
<%= if @sections.taxonomy do %>
|
<%= if @sections.taxonomy do %>
|
||||||
<div class="taxonomy-analyze-row">
|
<div class="taxonomy-analyze-row">
|
||||||
<div class="taxonomy-analyze-dropdown">
|
<div class="taxonomy-analyze-dropdown">
|
||||||
<button class="taxonomy-analyze-btn" type="button" phx-click="toggle_import_ai_model_selector" phx-target={@myself}><%= dgettext("ui", "Analyze with...") %></button>
|
<button class="taxonomy-analyze-btn secondary ui-button ui-button-secondary" type="button" phx-click="toggle_import_ai_model_selector" phx-target={@myself}><%= dgettext("ui", "Analyze with...") %></button>
|
||||||
<%= if @import_editor.model_selector_open? do %>
|
<%= if @import_editor.model_selector_open? do %>
|
||||||
<div class="taxonomy-model-dropdown">
|
<div class="taxonomy-model-dropdown">
|
||||||
<%= for model <- @import_editor.available_models do %>
|
<%= for model <- @import_editor.available_models do %>
|
||||||
@@ -1008,7 +1012,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="taxonomy-analyze-btn" type="button" phx-click="analyze_import_taxonomy_ai" phx-target={@myself} disabled={Enum.empty?(@import_editor.available_models) and not @import_editor.offline?}>
|
<button class="taxonomy-analyze-btn secondary ui-button ui-button-secondary" type="button" phx-click="analyze_import_taxonomy_ai" phx-target={@myself} disabled={Enum.empty?(@import_editor.available_models) and not @import_editor.offline?}>
|
||||||
<%= @import_editor.selected_model_label %>
|
<%= @import_editor.selected_model_label %>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -1353,12 +1357,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
class="taxonomy-mapping-input"
|
class="taxonomy-mapping-input"
|
||||||
type="text"
|
type="text"
|
||||||
name="mapped_to"
|
name="mapped_to"
|
||||||
value={Map.get(@edit || %{}, :value, Map.get(item, :mapped_to) || "") || ""}
|
value={Map.get(@edit, :value, Map.get(item, :mapped_to) || "") || ""}
|
||||||
placeholder={dgettext("ui", "Map to...")}
|
placeholder={dgettext("ui", "Map to...")}
|
||||||
list={"taxonomy-suggestions-#{@type}"}
|
list={"taxonomy-suggestions-#{@type}"}
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<button class="taxonomy-edit-btn" type="submit" title={dgettext("ui", "Map to...")}>✓</button>
|
<button class="taxonomy-edit-btn secondary" type="submit" title={dgettext("ui", "Map to...")}>✓</button>
|
||||||
<button class="taxonomy-edit-btn ghost" type="button" phx-click="cancel_import_taxonomy_edit" phx-target={@myself} title={dgettext("ui", "Cancel")}>×</button>
|
<button class="taxonomy-edit-btn ghost" type="button" phx-click="cancel_import_taxonomy_edit" phx-target={@myself} title={dgettext("ui", "Cancel")}>×</button>
|
||||||
<%= if present?(item.mapped_to) do %>
|
<%= if present?(item.mapped_to) do %>
|
||||||
<button class="taxonomy-clear-btn" type="button" phx-click="clear_import_taxonomy_mapping" phx-target={@myself} phx-value-type={@type} phx-value-name={item.name} title={dgettext("ui", "Clear mapping")}>×</button>
|
<button class="taxonomy-clear-btn" type="button" phx-click="clear_import_taxonomy_mapping" phx-target={@myself} phx-value-type={@type} phx-value-name={item.name} title={dgettext("ui", "Clear mapping")}>×</button>
|
||||||
@@ -1414,7 +1418,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
try do
|
try do
|
||||||
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
|
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
|
||||||
rescue
|
rescue
|
||||||
_error -> :ok
|
error ->
|
||||||
|
Logger.debug("swallowed import_editor allow_repo_sandbox error: #{inspect(error)}")
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
:ok
|
:ok
|
||||||
@@ -1446,11 +1452,6 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
|||||||
Map.get(item, :resolution) in ["overwrite", "merge"]
|
Map.get(item, :resolution) in ["overwrite", "merge"]
|
||||||
end
|
end
|
||||||
|
|
||||||
defp present?(value), do: value not in [nil, ""]
|
defp present?(value), do: BDS.Values.present?(value)
|
||||||
defp blank?(value), do: value in [nil, ""]
|
defp blank?(value), do: BDS.Values.blank?(value)
|
||||||
defp blank_to_nil(""), do: nil
|
|
||||||
defp blank_to_nil(value), do: value
|
|
||||||
|
|
||||||
defp maybe_put(map, _key, nil), do: map
|
|
||||||
defp maybe_put(map, key, value), do: Map.put(map, key, value)
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
|||||||
|
|
||||||
alias BDS.{ImportAnalysis, ImportDefinitions, Metadata}
|
alias BDS.{ImportAnalysis, ImportDefinitions, Metadata}
|
||||||
alias BDS.Desktop.{FilePicker, FolderPicker}
|
alias BDS.Desktop.{FilePicker, FolderPicker}
|
||||||
|
require Logger
|
||||||
use Gettext, backend: BDS.Gettext
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
@spec change_definition(term(), term(), term()) :: term()
|
@spec change_definition(term(), term(), term()) :: term()
|
||||||
@@ -160,7 +161,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
|||||||
wxr_file_path: analysis_state.file_path,
|
wxr_file_path: analysis_state.file_path,
|
||||||
last_analysis_result: report
|
last_analysis_result: report
|
||||||
}
|
}
|
||||||
|> maybe_put(:name, suggested_definition_name(report))
|
|> BDS.MapUtils.maybe_put(:name, suggested_definition_name(report))
|
||||||
|
|
||||||
case ImportDefinitions.update_definition(definition_id, attrs) do
|
case ImportDefinitions.update_definition(definition_id, attrs) do
|
||||||
{:ok, _definition} ->
|
{:ok, _definition} ->
|
||||||
@@ -274,17 +275,18 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
|||||||
get_in(report, [:site_info, :url]) || get_in(report, [:site_info, :title])
|
get_in(report, [:site_info, :url]) || get_in(report, [:site_info, :title])
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec maybe_put(term(), term(), term()) :: term()
|
|
||||||
def maybe_put(map, _key, nil), do: map
|
|
||||||
def maybe_put(map, key, value), do: Map.put(map, key, value)
|
|
||||||
|
|
||||||
@spec allow_repo_sandbox(term()) :: term()
|
@spec allow_repo_sandbox(term()) :: term()
|
||||||
def allow_repo_sandbox(pid) when is_pid(pid) do
|
def allow_repo_sandbox(pid) when is_pid(pid) do
|
||||||
if Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox) do
|
if Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox) do
|
||||||
try do
|
try do
|
||||||
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
|
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
|
||||||
rescue
|
rescue
|
||||||
_error -> :ok
|
error ->
|
||||||
|
Logger.debug(
|
||||||
|
"swallowed import_editor analysis_state allow_repo_sandbox error: #{inspect(error)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
:ok
|
:ok
|
||||||
@@ -308,5 +310,5 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
|||||||
@spec translate_phase(term()) :: term()
|
@spec translate_phase(term()) :: term()
|
||||||
def translate_phase(other), do: other
|
def translate_phase(other), do: other
|
||||||
|
|
||||||
defp present?(value), do: value not in [nil, ""]
|
defp present?(value), do: BDS.Values.present?(value)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
|||||||
Map.put(socket.assigns.import_editor_taxonomy_edits, definition_id, %{
|
Map.put(socket.assigns.import_editor_taxonomy_edits, definition_id, %{
|
||||||
type: type,
|
type: type,
|
||||||
name: name,
|
name: name,
|
||||||
value: mapped_to |> to_string() |> blank_to_nil()
|
value: mapped_to |> to_string() |> BDS.MapUtils.blank_to_nil()
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
@@ -82,7 +82,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
|||||||
%{} = definition <- ImportDefinitions.get_definition(definition_id),
|
%{} = definition <- ImportDefinitions.get_definition(definition_id),
|
||||||
%{} = report <- ImportDefinitions.decode_analysis_result(definition) do
|
%{} = report <- ImportDefinitions.decode_analysis_result(definition) do
|
||||||
cond do
|
cond do
|
||||||
socket.assigns.offline_mode ->
|
socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() ->
|
||||||
socket
|
socket
|
||||||
|> append_output.(
|
|> append_output.(
|
||||||
dgettext("ui", "Import"),
|
dgettext("ui", "Import"),
|
||||||
@@ -150,7 +150,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
|||||||
@spec update_taxonomy_mapping(term(), term(), term(), term()) :: term()
|
@spec update_taxonomy_mapping(term(), term(), term(), term()) :: term()
|
||||||
def update_taxonomy_mapping(report, type, name, mapped_to) do
|
def update_taxonomy_mapping(report, type, name, mapped_to) do
|
||||||
bucket_key = if(type == "categories", do: :categories, else: :tags)
|
bucket_key = if(type == "categories", do: :categories, else: :tags)
|
||||||
normalized_value = mapped_to |> to_string() |> String.trim() |> blank_to_nil()
|
normalized_value = mapped_to |> to_string() |> String.trim() |> BDS.MapUtils.blank_to_nil()
|
||||||
|
|
||||||
updated_report =
|
updated_report =
|
||||||
update_in(report, [:items, bucket_key], fn items ->
|
update_in(report, [:items, bucket_key], fn items ->
|
||||||
@@ -229,7 +229,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
|||||||
|
|
||||||
@spec normalize_taxonomy_mapping_value(term(), term(), term()) :: term()
|
@spec normalize_taxonomy_mapping_value(term(), term(), term()) :: term()
|
||||||
def normalize_taxonomy_mapping_value(project_id, type, mapped_to) do
|
def normalize_taxonomy_mapping_value(project_id, type, mapped_to) do
|
||||||
normalized_value = mapped_to |> to_string() |> String.trim() |> blank_to_nil()
|
normalized_value = mapped_to |> to_string() |> String.trim() |> BDS.MapUtils.blank_to_nil()
|
||||||
|
|
||||||
case normalized_value do
|
case normalized_value do
|
||||||
nil ->
|
nil ->
|
||||||
@@ -284,7 +284,5 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
|||||||
def maybe_put_option(opts, _key, nil), do: opts
|
def maybe_put_option(opts, _key, nil), do: opts
|
||||||
def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value)
|
def maybe_put_option(opts, key, value), do: Keyword.put(opts, key, value)
|
||||||
|
|
||||||
defp present?(value), do: value not in [nil, ""]
|
defp present?(value), do: BDS.Values.present?(value)
|
||||||
defp blank_to_nil(""), do: nil
|
|
||||||
defp blank_to_nil(value), do: value
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -457,7 +457,7 @@
|
|||||||
<div class="panel-tabs flex min-w-0 items-center overflow-x-auto">
|
<div class="panel-tabs flex min-w-0 items-center overflow-x-auto">
|
||||||
<%= for tab <- @panel_tabs do %>
|
<%= for tab <- @panel_tabs do %>
|
||||||
<button
|
<button
|
||||||
class={["panel-tab", "ui-tab", if(@workbench.panel.active_tab == tab, do: "ui-tab-active"), "inline-flex h-9 items-center px-3 text-xs uppercase tracking-wide", if(@workbench.panel.active_tab == tab, do: "active")]}
|
class={["panel-tab", "ui-tab", if(@workbench.panel.active_tab == tab, do: "ui-tab-active"), "inline-flex items-center px-3 text-xs uppercase tracking-wide", if(@workbench.panel.active_tab == tab, do: "active")]}
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="select_panel_tab"
|
phx-click="select_panel_tab"
|
||||||
phx-value-tab={tab}
|
phx-value-tab={tab}
|
||||||
@@ -531,7 +531,7 @@
|
|||||||
><%= @assistant_prompt %></textarea>
|
><%= @assistant_prompt %></textarea>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="assistant-sidebar-start-button ui-button ui-button-primary"
|
class="assistant-sidebar-start-button primary ui-button ui-button-primary"
|
||||||
data-testid="assistant-start-button"
|
data-testid="assistant-start-button"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={String.trim(@assistant_prompt || "") == ""}
|
disabled={String.trim(@assistant_prompt || "") == ""}
|
||||||
@@ -540,16 +540,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<%= if Enum.empty?(@assistant_messages) do %>
|
<%= unless Enum.empty?(@assistant_messages) do %>
|
||||||
<div class="assistant-sidebar-welcome min-h-0 flex-1 overflow-auto">
|
|
||||||
<%= for card <- @assistant_cards do %>
|
|
||||||
<section class="assistant-card flex flex-col gap-1">
|
|
||||||
<strong><%= card.label %></strong>
|
|
||||||
<span><%= card.text %></span>
|
|
||||||
</section>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<div class="assistant-sidebar-transcript min-h-0 flex-1 overflow-auto">
|
<div class="assistant-sidebar-transcript min-h-0 flex-1 overflow-auto">
|
||||||
<%= for message <- @assistant_messages do %>
|
<%= for message <- @assistant_messages do %>
|
||||||
<article
|
<article
|
||||||
@@ -568,7 +559,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="status-bar flex h-[22px] shrink-0 items-center justify-between gap-2" data-region="status-bar" data-testid="status-bar">
|
<footer class="status-bar flex h-[22px] shrink-0 items-center justify-between gap-2" data-region="status-bar" data-testid="status-bar">
|
||||||
<div class="status-bar-left flex min-w-0 items-center gap-2 overflow-hidden">
|
<div class="status-bar-left flex min-w-0 items-center gap-2">
|
||||||
<%= if @is_mac_ui do %>
|
<%= if @is_mac_ui do %>
|
||||||
<div class="status-shell-controls flex items-center gap-1" data-testid="status-shell-controls">
|
<div class="status-shell-controls flex items-center gap-1" data-testid="status-shell-controls">
|
||||||
<button
|
<button
|
||||||
@@ -659,7 +650,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<button class="status-bar-item status-bar-task-button inline-flex items-center gap-2" data-testid="status-task-button" type="button" phx-click="open_tasks_panel">
|
<button class="status-bar-item status-bar-task-button inline-flex min-w-0 items-center gap-2" data-testid="status-task-button" type="button" phx-click="open_tasks_panel">
|
||||||
<%= if @status.left.running_task_message do %>
|
<%= if @status.left.running_task_message do %>
|
||||||
<span class="task-spinner"></span>
|
<span class="task-spinner"></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -687,4 +678,6 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<ShellOverlayComponents.shell_overlay shell_overlay={@shell_overlay} />
|
<ShellOverlayComponents.shell_overlay shell_overlay={@shell_overlay} />
|
||||||
|
<LiveToast.toast_group flash={@flash} connected={assigns[:socket] != nil} toasts_sync={assigns[:toasts_sync]} corner={:bottom_right} toast_class_fn={&toast_class_fn/1} />
|
||||||
|
<GitRun.git_run_modal git_run={@git_run} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
|||||||
|
|
||||||
alias BDS.Desktop.{FilePicker}
|
alias BDS.Desktop.{FilePicker}
|
||||||
alias BDS.Desktop.ShellLive.Notify
|
alias BDS.Desktop.ShellLive.Notify
|
||||||
alias BDS.{AI, I18n, Media}
|
alias BDS.{AI, I18n, Media, Tasks}
|
||||||
alias BDS.Media.Media, as: MediaRecord
|
alias BDS.Media.Media, as: MediaRecord
|
||||||
alias BDS.Media.Translation
|
alias BDS.Media.Translation
|
||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
@@ -57,6 +57,17 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
|||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def update(%{action: :translation_completed} = assigns, socket) do
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(Map.drop(assigns, [:action, :language]))
|
||||||
|
|> assign(:quick_actions_open?, false)
|
||||||
|
|> assign(:editing_translation, nil)
|
||||||
|
|> build_data()
|
||||||
|
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
|
||||||
def update(assigns, socket) do
|
def update(assigns, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
@@ -126,8 +137,12 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
|||||||
|
|
||||||
Notify.dirty(:media, media.id, false)
|
Notify.dirty(:media, media.id, false)
|
||||||
|
|
||||||
Notify.tab_meta(:media, media.id, display_title(updated_media),
|
Notify.tab_meta(
|
||||||
updated_media.original_name || updated_media.mime_type || "")
|
:media,
|
||||||
|
media.id,
|
||||||
|
display_title(updated_media),
|
||||||
|
updated_media.original_name || updated_media.mime_type || ""
|
||||||
|
)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
||||||
@@ -149,7 +164,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("detect_media_editor_language", _params, socket) do
|
def handle_event("detect_media_editor_language", _params, socket) do
|
||||||
if socket.assigns.offline_mode do
|
if socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() do
|
||||||
notify_output(
|
notify_output(
|
||||||
socket,
|
socket,
|
||||||
dgettext("ui", "Detect Language"),
|
dgettext("ui", "Detect Language"),
|
||||||
@@ -342,7 +357,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
|||||||
def handle_event("refresh_media_translation", %{"language" => language}, socket) do
|
def handle_event("refresh_media_translation", %{"language" => language}, socket) do
|
||||||
media = socket.assigns.media
|
media = socket.assigns.media
|
||||||
|
|
||||||
if socket.assigns.offline_mode do
|
if socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() do
|
||||||
notify_output(
|
notify_output(
|
||||||
socket,
|
socket,
|
||||||
dgettext("ui", "Translate"),
|
dgettext("ui", "Translate"),
|
||||||
@@ -484,8 +499,12 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
|||||||
|
|
||||||
Notify.dirty(:media, media.id, false)
|
Notify.dirty(:media, media.id, false)
|
||||||
|
|
||||||
Notify.tab_meta(:media, media.id, display_title(updated_media),
|
Notify.tab_meta(
|
||||||
updated_media.original_name || updated_media.mime_type || "")
|
:media,
|
||||||
|
media.id,
|
||||||
|
display_title(updated_media),
|
||||||
|
updated_media.original_name || updated_media.mime_type || ""
|
||||||
|
)
|
||||||
|
|
||||||
notify_output(socket, dgettext("ui", "Media"), dgettext("ui", "Media saved"))
|
notify_output(socket, dgettext("ui", "Media"), dgettext("ui", "Media saved"))
|
||||||
socket
|
socket
|
||||||
@@ -531,7 +550,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp do_translate(socket, language) do
|
defp do_translate(socket, language) do
|
||||||
if socket.assigns.offline_mode do
|
if socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() do
|
||||||
notify_output(
|
notify_output(
|
||||||
socket,
|
socket,
|
||||||
dgettext("ui", "Translate"),
|
dgettext("ui", "Translate"),
|
||||||
@@ -544,25 +563,34 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
|||||||
media = socket.assigns.media
|
media = socket.assigns.media
|
||||||
normalized_language = normalize_language(language)
|
normalized_language = normalize_language(language)
|
||||||
source_language = normalize_language(media.language)
|
source_language = normalize_language(media.language)
|
||||||
|
parent = self()
|
||||||
|
|
||||||
case AI.translate_media(media.id, normalized_language, source_language: source_language) do
|
{:ok, _task} =
|
||||||
{:ok, translation} ->
|
Tasks.submit_task(
|
||||||
case Media.upsert_media_translation(media.id, normalized_language, translation) do
|
"Translate Media to #{normalized_language}",
|
||||||
{:ok, _saved_translation} ->
|
fn report ->
|
||||||
socket
|
report.(0.1, "Translating media to #{normalized_language}")
|
||||||
|> assign(:quick_actions_open?, false)
|
|
||||||
|> assign(:editing_translation, nil)
|
|
||||||
|> build_data()
|
|
||||||
|
|
||||||
{:error, reason} ->
|
with {:ok, translation} <-
|
||||||
notify_output(socket, dgettext("ui", "Translate"), inspect(reason), "error")
|
AI.translate_media(media.id, normalized_language,
|
||||||
|> build_data()
|
source_language: source_language
|
||||||
end
|
),
|
||||||
|
{:ok, _saved_translation} <-
|
||||||
|
Media.upsert_media_translation(media.id, normalized_language, translation) do
|
||||||
|
report.(1.0, "Media translation complete")
|
||||||
|
send(parent, {:editor_translation_completed, :media, media.id, normalized_language})
|
||||||
|
%{media_id: media.id, language: normalized_language}
|
||||||
|
else
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
%{group_id: media.project_id, group_name: "AI"}
|
||||||
|
)
|
||||||
|
|
||||||
{:error, reason} ->
|
socket
|
||||||
notify_output(socket, dgettext("ui", "Translate"), inspect(reason), "error")
|
|> assign(:quick_actions_open?, false)
|
||||||
|> build_data()
|
|> assign(:editing_translation, nil)
|
||||||
end
|
|> build_data()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -617,7 +645,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
|||||||
|
|
||||||
defp preview_url(%MediaRecord{} = media) do
|
defp preview_url(%MediaRecord{} = media) do
|
||||||
if image?(media),
|
if image?(media),
|
||||||
do: "/media-thumbnail/#{media.id}?size=large&t=#{media.updated_at}",
|
do: "/media-file/#{media.id}?t=#{media.updated_at}",
|
||||||
else: nil
|
else: nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user