Compare commits

...

3 Commits

8 changed files with 93 additions and 97 deletions

View File

@@ -1,77 +0,0 @@
# Alignment Tasks
Allium CLI: `/opt/homebrew/bin/allium`. Use `allium check specs/<file>.allium` only when tending a spec; no Allium command is needed for code-only alignment tasks.
Goal: align bDS2 with old bDS behavior. Use the Allium specs as the contract only where they match old bDS. If old bDS and bDS2 agree but the spec differs, tend the spec.
## P0: MCP Proposal Lifecycle (done)
- Old bDS: proposals are in-memory and removed after `accept_proposal` or `discard_proposal`.
- bDS2 now: proposals are persisted and marked `accepted` / `discarded`.
- Spec: matches old bDS; accepted/discarded proposals should no longer exist.
- Action: change bDS2 to remove accepted/discarded proposals, update tests, and remove/adjust terminal-status expectations.
- Status: done.
## P0: MCP Cursor Resources (done)
- Old bDS: `bds://posts{?cursor}` and `bds://media{?cursor}` use base64url cursors, page size 50, and `nextCursor`.
- bDS2 now: first-page resources exist, but cursor URI/resource-template behavior is missing.
- Spec: matches old bDS cursor behavior.
- Action: implement cursor parsing/templates for posts and media resources and add tests for first page, next cursor, invalid cursor, and final page.
## P0: MCP Translation Tools (done)
- Old bDS: exposes `get_post_translations`, `get_media_translations`, and app-gated `upsert_media_translation`.
- bDS2 now: domain translation functions exist, but MCP tools are missing.
- Spec: missing these tools.
- Action: add the tools to `specs/mcp.allium`, implement them in MCP, and test tool listing and call behavior.
## P1: Missing MCP Resources (done)
- Old bDS: also exposes `bds://stats`, `bds://posts/{id}/media`, and `bds://media/{id}/image`.
- bDS2 now: only posts, media, tags, and categories are exposed.
- Spec: missing the old resources.
- Action: add these resources to `specs/mcp.allium`, implement them, and test JSON/blob/error responses.
## P1: MCP Agent Config Surface (done)
- Old bDS: agent config install/remove is a settings UI / IPC action, not an MCP tool.
- bDS2 now: implemented as settings UI action.
- Spec: incorrectly lists install/uninstall in the MCP automation surface.
- Action: tend `specs/mcp.allium` to move agent config out of MCP automation and describe it as settings UI behavior.
## P1: MCP CLI Proposal TTL (done)
- Old bDS: one proposal TTL, 30 minutes.
- bDS2 now: one proposal TTL, 30 minutes.
- Spec: adds `proposal_ttl_cli = 8.hours`.
- Action: remove the CLI-specific TTL from `specs/mcp.allium` or mark it explicitly future/non-current.
## P1: Media Thumbnail Encoding (done)
- Old bDS: small/medium/large WebP quality 80; AI JPEG quality 85.
- bDS2 now: matches old bDS.
- Spec: now specifies WebP quality 80 and AI JPEG quality 85.
- Action: tend `specs/media_processing.allium` to specify WebP quality 80 and AI JPEG quality 85.
## P2: Media Import Event Shape
- Old bDS: imports by source path plus optional metadata in project context.
- bDS2 now: imports with attrs including `source_path` and `project_id`.
- Spec: duplicates `ImportMediaRequested` with conflicting argument order across media specs.
- Action: normalize media specs to one event shape: source path plus project/context, with optional metadata where relevant.
## P2: Import Conflict Resolution Terms
- Old bDS: conflict resolutions are `ignore`, `overwrite`, and `import`.
- bDS2 now: accepts/normalizes `skip -> ignore` and `merge -> overwrite`.
- Spec: says `import`, `skip`, and `merge`.
- Action: tend `specs/editor_misc.allium` to old terms. Optional code cleanup: expose old terms directly in bDS2 UI/events to reduce mapping.
## Execution Order
1. Fix P0 MCP code gaps first: proposal removal, cursor resources, translation tools.
2. Add missing MCP resources after cursor plumbing is in place.
3. Tend MCP specs for agent config and CLI TTL.
4. Tend media specs for thumbnail quality and import event shape.
5. Tend import conflict terminology, then decide whether code cleanup is worth it.

View File

@@ -583,8 +583,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
<input type="hidden" name="item_type" value={Map.get(item, :item_type)} /> <input type="hidden" name="item_type" value={Map.get(item, :item_type)} />
<input type="hidden" name="item_name" value={Map.get(item, :slug)} /> <input type="hidden" name="item_name" value={Map.get(item, :slug)} />
<select class="resolution-select" name="resolution"> <select class="resolution-select" name="resolution">
<option value="skip" selected={Map.get(item, :resolution) == "skip"}><%= translated("importAnalysis.ignore") %></option> <option value="ignore" selected={conflict_resolution_selected?(item, "ignore")}><%= translated("importAnalysis.ignore") %></option>
<option value="merge" selected={Map.get(item, :resolution) == "merge"}><%= translated("importAnalysis.overwrite") %></option> <option value="overwrite" selected={conflict_resolution_selected?(item, "overwrite")}><%= translated("importAnalysis.overwrite") %></option>
<option value="import" selected={Map.get(item, :resolution) == "import"}><%= translated("importAnalysis.importNewSlug") %></option> <option value="import" selected={Map.get(item, :resolution) == "import"}><%= translated("importAnalysis.importNewSlug") %></option>
</select> </select>
</form> </form>
@@ -883,4 +883,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
defp present?(value), do: value not in [nil, ""] defp present?(value), do: value not in [nil, ""]
defp blank?(value), do: value in [nil, ""] defp blank?(value), do: value in [nil, ""]
defp conflict_resolution_selected?(item, "ignore") do
Map.get(item, :resolution, "ignore") in ["ignore", "skip"]
end
defp conflict_resolution_selected?(item, "overwrite") do
Map.get(item, :resolution) in ["overwrite", "merge"]
end
end end

View File

@@ -230,11 +230,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
def importable_entity_count(items) do def importable_entity_count(items) do
Enum.count(items || [], fn item -> Enum.count(items || [], fn item ->
item.status == "new" or item.status == "new" or
(item.status == "conflict" and (item.status == "conflict" and conflict_importable?(Map.get(item, :resolution, "ignore")))
Map.get(item, :resolution, "ignore") not in ["ignore", "skip"])
end) end)
end end
defp conflict_importable?(resolution), do: resolution in ["overwrite", "merge", "import"]
@spec detail_items(term(), term()) :: term() @spec detail_items(term(), term()) :: term()
def detail_items(nil, _bucket), do: [] def detail_items(nil, _bucket), do: []

View File

@@ -697,7 +697,7 @@ value ImportYearDistribution {
value ImportConflict { value ImportConflict {
item_type: String -- post | page | media item_type: String -- post | page | media
item_name: String item_name: String
resolution: String -- import | skip | merge resolution: String -- ignore | overwrite | import
} }
value ImportMacro { value ImportMacro {
@@ -745,8 +745,8 @@ surface ImportAnalysisSurface {
-- Year-by-year bar charts for posts + media. -- Year-by-year bar charts for posts + media.
@guarantee ConflictsSection @guarantee ConflictsSection
-- Collapsible. Per-item dropdown: Import/Skip/Merge. -- Collapsible. Per-item dropdown: Ignore/Overwrite/Import.
-- Default: Import for new items, Skip for existing matches. -- Default: Import for new items, Ignore for existing matches.
@guarantee TaxonomySection @guarantee TaxonomySection
-- Collapsible. Category + tag pills. -- Collapsible. Category + tag pills.

View File

@@ -9,7 +9,7 @@ surface MediaControlSurface {
facing _: MediaOperator facing _: MediaOperator
provides: provides:
ImportMediaRequested(project, source_file) ImportMediaRequested(source_path, project, metadata)
UpdateMediaRequested(media, changes) UpdateMediaRequested(media, changes)
DeleteMediaRequested(media) DeleteMediaRequested(media)
UpsertMediaTranslationRequested(media, language, title, alt, caption) UpsertMediaTranslationRequested(media, language, title, alt, caption)
@@ -121,22 +121,29 @@ invariant DateBasedMediaLayout {
} }
rule ImportMedia { rule ImportMedia {
when: ImportMediaRequested(project, source_file) when: ImportMediaRequested(source_path, project, metadata)
let uuid_name = generate_uuid() + extension(source_file) -- metadata is optional import context: title, alt, caption, author,
-- language, and tags may be supplied by the caller.
let uuid_name = generate_uuid() + extension(source_path)
let dest = format("media/{yyyy}/{mm}/{uuid_name}", let dest = format("media/{yyyy}/{mm}/{uuid_name}",
yyyy: now.year, mm: now.month_padded) yyyy: now.year, mm: now.month_padded)
ensures: Media.created( ensures: Media.created(
project: project, project: project,
filename: uuid_name, filename: uuid_name,
original_name: source_file.name, original_name: basename(source_path),
mime_type: detect_mime(source_file), mime_type: detect_mime(source_path),
size: source_file.size, size: file_size(source_path),
width: detect_width(source_file), width: detect_width(source_path),
height: detect_height(source_file), height: detect_height(source_path),
title: metadata.title,
alt: metadata.alt,
caption: metadata.caption,
author: metadata.author,
language: metadata.language,
file_path: dest, file_path: dest,
tags: {} tags: metadata.tags
) )
ensures: FileCopied(source_file, dest) ensures: FileCopied(source_path, dest)
ensures: SidecarWritten(media) ensures: SidecarWritten(media)
ensures: ThumbnailsGenerated(media) ensures: ThumbnailsGenerated(media)
ensures: SearchIndexUpdated(media) ensures: SearchIndexUpdated(media)

View File

@@ -14,7 +14,7 @@ surface MediaProcessingControlSurface {
facing _: MediaProcessingOperator facing _: MediaProcessingOperator
provides: provides:
ImportMediaRequested(source_path, project) ImportMediaRequested(source_path, project, metadata)
TagMediaRequested(media, tags) TagMediaRequested(media, tags)
DeleteMediaRequested(media) DeleteMediaRequested(media)
ValidateMediaRequested(project) ValidateMediaRequested(project)
@@ -239,11 +239,13 @@ invariant MediaTranslationFileLayout {
-- ============================================================================ -- ============================================================================
rule ImportMedia { rule ImportMedia {
when: ImportMediaRequested(source_path, project) when: ImportMediaRequested(source_path, project, metadata)
-- metadata is optional import context: title, alt, caption, author,
-- language, and tags may be supplied by the caller.
-- 1. Validate file type (must be supported image) -- 1. Validate file type (must be supported image)
-- 2. Generate UUID v4 filename -- 2. Generate UUID v4 filename
-- 3. Copy to media/{YYYY}/{MM}/{uuid}.{ext} -- 3. Copy to media/{YYYY}/{MM}/{uuid}.{ext}
-- 4. Write sidecar {binary_path}.meta -- 4. Apply optional metadata and write sidecar {binary_path}.meta
-- 5. Generate four thumbnail sizes -- 5. Generate four thumbnail sizes
-- 6. Index for search (FTS5) -- 6. Index for search (FTS5)
ensures: media/Media.created( ensures: media/Media.created(
@@ -253,6 +255,12 @@ rule ImportMedia {
size: file_size(source_path), size: file_size(source_path),
width: extract_width_from_header(source_path), width: extract_width_from_header(source_path),
height: extract_height_from_header(source_path), height: extract_height_from_header(source_path),
title: metadata.title,
alt: metadata.alt,
caption: metadata.caption,
author: metadata.author,
language: metadata.language,
tags: metadata.tags,
file_path: format("media/{yyyy}/{mm}/{uuid}.{ext}"), file_path: format("media/{yyyy}/{mm}/{uuid}.{ext}"),
sidecar_path: format("media/{yyyy}/{mm}/{uuid}.{ext}.meta"), sidecar_path: format("media/{yyyy}/{mm}/{uuid}.{ext}.meta"),
checksum: sha256(source_path) checksum: sha256(source_path)

View File

@@ -0,0 +1,31 @@
defmodule BDS.AlignmentTest do
use ExUnit.Case, async: true
@media_import_specs [
"specs/media.allium",
"specs/media_processing.allium"
]
test "media import specs use one source-path-first event shape" do
signatures =
@media_import_specs
|> Enum.flat_map(fn path ->
path
|> File.read!()
|> import_media_signatures(path)
end)
assert signatures == [
{"specs/media.allium", "source_path, project, metadata"},
{"specs/media.allium", "source_path, project, metadata"},
{"specs/media_processing.allium", "source_path, project, metadata"},
{"specs/media_processing.allium", "source_path, project, metadata"}
]
end
defp import_media_signatures(source, path) do
~r/ImportMediaRequested\(([^)]+)\)/
|> Regex.scan(source, capture: :all_but_first)
|> Enum.map(fn [signature] -> {path, signature} end)
end
end

View File

@@ -56,6 +56,11 @@ defmodule BDS.Desktop.ImportShellLiveTest do
assert html =~ "Ready to import:" assert html =~ "Ready to import:"
assert html =~ "Import 5 Items" assert html =~ "Import 5 Items"
assert html =~ "Post Slug Conflicts" assert html =~ "Post Slug Conflicts"
assert html =~ ~s(<option value="ignore" selected)
assert html =~ ~s(<option value="overwrite")
assert html =~ ~s(<option value="import")
refute html =~ ~s(<option value="skip")
refute html =~ ~s(<option value="merge")
assert html =~ "Analyze with..." assert html =~ "Analyze with..."
assert html =~ "Posts (2)" assert html =~ "Posts (2)"
assert html =~ "Pages (1)" assert html =~ "Pages (1)"
@@ -64,6 +69,19 @@ defmodule BDS.Desktop.ImportShellLiveTest do
refute html =~ ~s(name="mapped_to") refute html =~ ~s(name="mapped_to")
refute html =~ "Desktop workbench content routed through the Elixir shell." refute html =~ "Desktop workbench content routed through the Elixir shell."
_html =
render_change(view, "change_import_conflict_resolution", %{
"item_type" => "post",
"item_name" => "conflict-me",
"resolution" => "overwrite"
})
updated_definition = ImportDefinitions.get_definition(definition.id)
updated_report = ImportDefinitions.decode_analysis_result(updated_definition)
assert [%{resolution: "overwrite"}] =
Enum.filter(updated_report.details.posts, &(&1.slug == "conflict-me"))
posts_html = posts_html =
view view
|> element("button[phx-value-section='posts']") |> element("button[phx-value-section='posts']")