Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,19 @@ _Avoid_: demux
**Term map**:
An explicit, reusable JSON object mapping non-empty source terms to non-empty
target terms. A Job can select one, follow a Directory default, or explicitly
disable terminology mapping.
disable terminology mapping. Each Job uses at most one complete Term map;
directory inheritance selects one map rather than merging maps. A map can
include character names, places, titles, and other terms for the same work.
_Avoid_: glossary

**Directory default**:
The Term map associated with a Media directory for translations beneath it,
unless a Job explicitly selects or disables its Term map.

**Directory rule**:
An explicit Directory default managed in Settings under Term maps → Automatic
use. A subdirectory's rule takes priority over its ancestors' rules.

**Term map for this translation**:
The Term map policy selected for one Job: follow the Directory default, use a
specific Term map, or use none.
Expand Down
16 changes: 16 additions & 0 deletions cueweaver/application/directory_term_maps.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ class DirectoryTermMapState:
source_directory: str | None


@dataclass(frozen=True)
class DirectoryTermMapRule:
directory: str
term_map: TermMapSummary


class DirectoryTermMapStore(Protocol):
def snapshot_bindings(self) -> dict[str, str]: ...

Expand Down Expand Up @@ -55,6 +61,16 @@ def get(self, directory: str) -> DirectoryTermMapState:
source_directory=source,
)

def list_rules(self) -> list[DirectoryTermMapRule]:
return [
DirectoryTermMapRule(
directory=directory, term_map=self._term_maps.get(term_map_id)
)
for directory, term_map_id in sorted(
self._store.snapshot_bindings().items()
)
]

def bind(self, directory: str, term_map_id: str) -> DirectoryTermMapState:
canonical = self._canonical_directory(directory, require_existing=True)
self._store.bind(canonical, term_map_id)
Expand Down
4 changes: 4 additions & 0 deletions cueweaver/http/media_discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,12 @@ def discover(body: MediaDiscoverBody) -> dict[str, object]:
def result_body(
result: DiscoverResult, media_root: Path, relative_media_path: Path
) -> dict[str, object]:
# Jobs resolve file links before choosing a Directory default; previews must
# use the same directory while retaining the browsed path as the Media label.
directory = result.media_path.parent.relative_to(media_root)
return {
"path": str(relative_media_path),
"directory": "" if str(directory) == "." else directory.as_posix(),
"candidates": [
candidate_body(candidate, media_root) for candidate in result.candidates
],
Expand Down
9 changes: 9 additions & 0 deletions cueweaver/http/term_maps.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@ def get_directory_term_map(
) -> dict[str, object]:
return directory_state_body(application.directory_term_maps.get(path))

@app.get("/api/term-maps/directory-rules")
def list_directory_rules() -> dict[str, object]:
return {
"rules": [
{"directory": rule.directory, "term_map": summary_body(rule.term_map)}
for rule in application.directory_term_maps.list_rules()
]
}

@app.put("/api/term-maps/directory")
def bind_directory_term_map(body: DirectoryTermMapBody) -> dict[str, object]:
if body.term_map_id is None:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
# Separate Directory Defaults from Translation Selection

CueWeaver keeps a reusable Directory default separate from the Term map policy
for an individual translation. The Translate flow must expose both concepts:
the Directory default can be inherited, while a Job can explicitly select a
specific Term map or disable terminology mapping. This preserves reusable
directory configuration without removing per-Job control, and the UI must name
and explain the two scopes distinctly.
CueWeaver manages reusable Directory defaults in Settings under Term maps →
Automatic use. Translate exposes only the Term map choice for the current
translation: automatic selection, one specific map, or no map.

Automatic selection shows the resolved map name without an inheritance path.
It resolves each Media item's actual directory, including in batch mode. Source
directories are available in the settings editor when needed. Directory rules
are listed explicitly; inherited child directories do not create extra rows.
Batch translation retains its same-directory constraint and selects one map
for the batch. Discovery reports the resolved directory so file links use the
same default in the preview and in the created Job.

Each Job uses at most one complete map. A more specific Directory rule replaces
the ancestor's choice rather than merging its content. Jobs already created
retain their captured Term map content. Translate drafts survive navigation to
Settings without writing Media paths to browser storage.

## Considered Options

- Hide the Directory default from Translate and keep only per-Job selection.
- Manage Directory defaults in Settings and show the resolved automatic choice
in Translate.
- Always follow the Directory default and remove per-Job override choices.
- Keep both scopes with explicit inheritance, selection, and disable states.
- Keep editable directory and per-Job settings together in Translate.

The third option was accepted because the first two discard existing control
over directory-wide behavior or individual translation behavior.
The first option is accepted. Putting long-lived directory edits beside a
single translation made the scope of changes unclear. Settings keeps directory
control available while Translate focuses on the result for the current Job.
44 changes: 44 additions & 0 deletions tests/test_directory_term_maps.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,50 @@ def test_directory_term_map_supports_root_local_inherited_and_remove(tmp_path: P
)


def test_directory_rules_list_only_explicit_settings_and_reflect_changes(
tmp_path: Path,
):
client = make_client(tmp_path)
(tmp_path / "media" / "Series" / "Season 1").mkdir(parents=True)
assert client.get("/api/term-maps/directory-rules").json() == {"rules": []}
root_map = create_term_map(client, "Example library")
series_map = create_term_map(client, "Example series")
directory_request(client, "PUT", term_map_id=root_map["id"])
bind_series(client, series_map["id"])

response = client.get("/api/term-maps/directory-rules")
assert response.status_code == 200
assert response.json() == {
"rules": [
{"directory": "", "term_map": root_map},
{"directory": "Series", "term_map": series_map},
]
}
directory_request(client, "DELETE", "Series")
assert client.get("/api/term-maps/directory-rules").json()["rules"] == [
{"directory": "", "term_map": root_map}
]
assert (
directory_request(client, "GET", "Series/Season 1").json()["effective"]["id"]
== root_map["id"]
)
client.patch(
f"/api/term-maps/{root_map['id']}", json={"name": "Example collection"}
)
assert (
client.get("/api/term-maps/directory-rules").json()["rules"][0]["term_map"][
"name"
]
== "Example collection"
)
client.request(
"DELETE",
f"/api/term-maps/{root_map['id']}",
json={"name": "Example collection"},
)
assert client.get("/api/term-maps/directory-rules").json() == {"rules": []}


def test_directory_term_map_persists_and_delete_cleans_bindings(tmp_path: Path):
client = make_client(tmp_path)
(tmp_path / "media" / "Series").mkdir(parents=True)
Expand Down
26 changes: 26 additions & 0 deletions tests/test_http_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,31 @@ def test_http_deletes_one_job_and_clears_completed_jobs_without_a_request_body()
assert application.cleared_completed is True


def test_product_discover_reports_resolved_media_directory_for_file_links(
tmp_path: Path,
):
media_root = tmp_path / "media"
series = media_root / "Example Series"
series.mkdir(parents=True)
media = series / "Episode.mkv"
media.write_bytes(b"synthetic media")
(media_root / "Linked.mkv").symlink_to(media)

class LinkedApplication(ApplicationFixture):
def discover(self, request: DiscoverRequest) -> DiscoverResult:
return DiscoverResult(request.media_path)

application = LinkedApplication()
client = TestClient(create_app(application, media_root))

response = client.post("/api/media/discover", json={"path": "Linked.mkv"})

assert response.status_code == 200
assert response.json()["path"] == "Linked.mkv"
assert response.json()["directory"] == "Example Series"
assert str(media_root) not in response.text


def test_product_discover_resolves_relative_media_path_and_redacts_absolute_paths(
tmp_path: Path,
):
Expand Down Expand Up @@ -428,6 +453,7 @@ def discover(self, request: DiscoverRequest) -> DiscoverResult:
assert response.status_code == 200
assert response.json() == {
"path": "Movie.mkv",
"directory": "",
**expected_discovery_payload("Movie.en.srt"),
}
assert application.discover_request == DiscoverRequest(media_root / "Movie.mkv")
Expand Down
1 change: 1 addition & 0 deletions vulture_whitelist.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
replace_term_map
delete_term_map
get_directory_term_map
list_directory_rules
bind_directory_term_map
remove_directory_term_map
product_status
Expand Down
Loading
Loading