diff --git a/cmd/server/main.go b/cmd/server/main.go
index 0c6b261..eb1f65a 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -61,7 +61,11 @@ func registerRoutes(r *mux.Router) {
r.HandleFunc("/api/brief-sessions/{id}/answers", handlers.AnswerBriefSessionHandler).Methods("POST")
r.HandleFunc("/api/brief-sessions/{id}/answers/{answer_id}/edit", handlers.EditBriefAnswerHandler).Methods("POST")
r.HandleFunc("/api/sessions/{id}/answers/{answer_id}/edit", handlers.EditBriefAnswerHandler).Methods("POST")
+ r.HandleFunc("/api/projects", handlers.ListProjectsHandler).Methods("GET")
+ r.HandleFunc("/api/projects/{id}", handlers.GetProjectHandler).Methods("GET")
+ r.HandleFunc("/api/articles/{id}", handlers.GetArticleHandler).Methods("GET")
r.HandleFunc("/api/drafts", handlers.GenerateDraftHandler).Methods("POST")
+ r.HandleFunc("/api/drafts/{id}", handlers.GetDraftHandler).Methods("GET")
r.HandleFunc("/api/drafts/{id}/regenerate-section", handlers.RegenerateDraftSectionHandler).Methods("POST")
// ルートパスへのアクセスはindex.htmlにリダイレクト
diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go
index 24889ed..6df47bb 100644
--- a/cmd/server/main_test.go
+++ b/cmd/server/main_test.go
@@ -1,7 +1,11 @@
package main
import (
+ "io"
"net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
"testing"
"github.com/gorilla/mux"
@@ -18,9 +22,15 @@ func TestRegisterRoutesIncludesWorkflowReadAPIs(t *testing.T) {
{method: http.MethodGet, path: "/api/history"},
{method: http.MethodGet, path: "/api/workflow/artifacts"},
{method: http.MethodGet, path: "/api/author-style"},
+ {method: http.MethodGet, path: "/api/author-style/style-1"},
{method: http.MethodGet, path: "/api/brief-sessions"},
+ {method: http.MethodGet, path: "/api/brief-sessions/templates"},
+ {method: http.MethodGet, path: "/api/brief-sessions/session-1"},
{method: http.MethodGet, path: "/api/briefs"},
{method: http.MethodGet, path: "/api/briefs/session-1"},
+ {method: http.MethodGet, path: "/api/models"},
+ {method: http.MethodGet, path: "/api/personas"},
+ {method: http.MethodGet, path: "/api/formats"},
} {
request, err := http.NewRequest(tt.method, tt.path, nil)
if err != nil {
@@ -32,3 +42,80 @@ func TestRegisterRoutesIncludesWorkflowReadAPIs(t *testing.T) {
}
}
}
+
+func TestRegisterRoutesServesBrowserEntryAndStaticScript(t *testing.T) {
+ chdir(t, "../..")
+
+ router := mux.NewRouter()
+ registerRoutes(router)
+ server := httptest.NewServer(router)
+ defer server.Close()
+
+ for _, tt := range []struct {
+ path string
+ contentType string
+ body []string
+ }{
+ {
+ path: "/",
+ contentType: "text/html",
+ body: []string{
+ `
`,
+ },
+ },
+ {
+ path: "/static/js/script.js",
+ contentType: "javascript",
+ body: []string{
+ "const configStorageKey = 'note-maker-config-v1'",
+ "const historyEndpoint = '/api/workflow/artifacts'",
+ "function saveModelConfig()",
+ "function openSelectedHistory()",
+ "function renderBriefCard(brief)",
+ },
+ },
+ } {
+ t.Run(tt.path, func(t *testing.T) {
+ response, err := http.Get(server.URL + tt.path)
+ if err != nil {
+ t.Fatalf("GET %s: %v", tt.path, err)
+ }
+ defer response.Body.Close()
+ if response.StatusCode != http.StatusOK {
+ t.Fatalf("GET %s status = %d, want %d", tt.path, response.StatusCode, http.StatusOK)
+ }
+ if got := response.Header.Get("Content-Type"); got == "" || !strings.Contains(got, tt.contentType) {
+ t.Fatalf("GET %s Content-Type = %q, want contains %q", tt.path, got, tt.contentType)
+ }
+ body, err := io.ReadAll(response.Body)
+ if err != nil {
+ t.Fatalf("read response body: %v", err)
+ }
+ for _, want := range tt.body {
+ if !strings.Contains(string(body), want) {
+ t.Fatalf("GET %s body missing %q", tt.path, want)
+ }
+ }
+ })
+ }
+}
+
+func chdir(t *testing.T, dir string) {
+ t.Helper()
+ previous, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("get cwd: %v", err)
+ }
+ if err := os.Chdir(dir); err != nil {
+ t.Fatalf("chdir %s: %v", dir, err)
+ }
+ t.Cleanup(func() {
+ if err := os.Chdir(previous); err != nil {
+ t.Fatalf("restore cwd: %v", err)
+ }
+ })
+}
diff --git a/docs/adrs/0002-multi-persona-multi-format-extension.md b/docs/adrs/0002-multi-persona-multi-format-extension.md
index beef122..83edf59 100644
--- a/docs/adrs/0002-multi-persona-multi-format-extension.md
+++ b/docs/adrs/0002-multi-persona-multi-format-extension.md
@@ -222,6 +222,8 @@ Current implementation status as of 2026-05-03:
- Phase B5 is implemented: fixed interview questions are composed server-side by `persona_id × output_format_id`, Cloudia technical modes include extra viewpoint/context prompts, the frontend reads `GET /api/brief-sessions/templates`, and `cmd/scenario/media_matrix` produces a six-case cross-media evaluation matrix for note, Cor blog, Zenn, Qiita, and homepage output ([#25](https://github.com/terisuke/note_maker/issues/25)).
- Phase C1 is implemented and merged: `internal/infrastructure/repository/sqlite` adds migrations and storage for author styles, sessions, briefs, projects, articles, source snapshots, draft versions, final verification, and section-regeneration versions. The JSON store remains the compatibility path, while storage mode can now be inspected and switched from the web settings UI unless environment variables lock it ([#26](https://github.com/terisuke/note_maker/issues/26), [#61](https://github.com/terisuke/note_maker/issues/61)).
- Phase C2/C3 has an implemented first product cut for workflow history and readable artifacts ([#27](https://github.com/terisuke/note_maker/issues/27), [#28](https://github.com/terisuke/note_maker/issues/28)): the web app now exposes reusable history through `GET /api/history` and `GET /api/workflow/artifacts`, plus focused read endpoints `GET /api/author-style`, `GET /api/brief-sessions`, `GET /api/briefs`, and `GET /api/briefs/{id}`. The memory and SQLite stores both expose `ListAuthorStyles`, `ListSessions`, and `ListBriefs`; SQLite also gained `ListProjects` and `ListArticlesByProject` for the richer #26 schema. The UI adds `履歴から再開`, saved style-guide/session pickers, human-readable style-guide cards, and human-readable article-brief cards while keeping raw Markdown/JSON details available. Validation is recorded in [Issue 27/28 history and artifact UI/API validation](../validation/issue-27-28-history-artifacts-2026-05-03.md).
+- The #13 follow-up has browser-adjacent contract coverage for the static HTML, script selectors, persistence hooks, workflow read routes, and artifact-card structure. It does not close #13: Playwright or equivalent real browser coverage is still required for persona/format switching, history open, readable cards, edit/fork, streaming/cancel, regenerate-section, and localStorage migration. Validation is recorded in [Issue #13 Browser Contract Coverage](../validation/issue-13-browser-contract-coverage-2026-05-03.md).
+- A Phase C project/article/draft history follow-up now builds on the #26 SQLite schema: `GET /api/workflow/artifacts` includes project/article/draft summaries when SQLite is active, focused read routes expose project/article/draft details, and the history UI renders project, article, brief, current draft, draft versions, and source snapshot cards. The handler/server/static tests are green, but until the browser flow is covered by #13, the canonical completed Phase C closure claim remains separate from full browser E2E.
- Phase D1 is implemented and merged: handler tests now cover template selection, edit/fork errors, SSE follow-up and draft paths, completed-session draft fallback, regenerate-section context recovery, Analyze/Generate compatibility handlers, and SQLite driver selection. `go test ./internal/handlers -cover` reports 80%+ statement coverage ([#29](https://github.com/terisuke/note_maker/issues/29)).
- Runtime runner support is implemented and merged: `cmd/scenario/live_media_matrix` reads the offline matrix, emits planned aggregate JSON/Markdown by default, and executes live Evo X2 draft runs only when `RUN_LIVE_MEDIA_MATRIX=1` or `make scenario-media-matrix-live` is used ([#57](https://github.com/terisuke/note_maker/issues/57)).
- The 2026-05-03 browser 500 analysis showed an implementation drift: plain web-app startup still defaulted to workstation-local `127.0.0.1:8081`, while this ADR requires Evo X2 Tailnet as primary. Issue [#63](https://github.com/terisuke/note_maker/issues/63) restores the default order to Evo X2 Ollama over Tailnet → Evo X2 llama.cpp → workstation-local llama.cpp and makes the UI show the actual endpoint/model reported by SSE.
@@ -235,8 +237,8 @@ Current implementation status as of 2026-05-03:
Near-term execution order:
1. Close [#74](https://github.com/terisuke/note_maker/issues/74) and [#40](https://github.com/terisuke/note_maker/issues/40) for the current note/Qiita/Zenn/Cor blog publishing-target scope after linking the final `5/5` aggregate artifacts. Homepage remains a separate short-format check.
-2. Land the #27/#28 history/artifact read cut and add Browser E2E coverage ([#13](https://github.com/terisuke/note_maker/issues/13)) for history opening, readable cards, and the existing edit/fork/stream/regenerate flows.
-3. Follow with the remaining Phase C product gaps that were intentionally not included in the #27/#28 cut: add-persona authoring UI, broader edit persistence semantics beyond existing fork-on-edit/session saving, and richer project/article/draft history surfaces from the #26 SQLite schema.
+2. Keep [#13](https://github.com/terisuke/note_maker/issues/13) open after the browser-contract pass and add real browser E2E coverage for history opening, readable cards, persona/format switching, question config, edit/fork, streaming/cancel, regenerate-section, and localStorage migration.
+3. Follow with the remaining Phase C product gaps that were intentionally not included in the #27/#28 first cut: add-persona authoring UI, broader edit persistence semantics beyond existing fork-on-edit/session saving, richer project/article/draft history polish, and browser E2E over the project history cards.
4. Keep fallback-quality and runtime packaging follow-up ([#36](https://github.com/terisuke/note_maker/issues/36), [#45](https://github.com/terisuke/note_maker/issues/45), [#15](https://github.com/terisuke/note_maker/issues/15)) outside the #40 closure gate.
## Tracked issues
@@ -254,7 +256,7 @@ Filed 2026-05-02 as part of the PR that introduced this ADR.
- B5 — [#25](https://github.com/terisuke/note_maker/issues/25) Format- and persona-aware fixed question sets
- C1 — [#26](https://github.com/terisuke/note_maker/issues/26) Replace JSON store with SQLite-backed schema (extends [#14](https://github.com/terisuke/note_maker/issues/14)) — implemented in the current cut as an opt-in SQLite workflow store.
- C2 — [#27](https://github.com/terisuke/note_maker/issues/27) Persona / past-session picker UI — implemented in the current cut for saved style-guide and brief-session reuse through `履歴から再開`, backed by `GET /api/workflow/artifacts`, `GET /api/author-style`, and `GET /api/brief-sessions`. Add-persona authoring UI and broader edit-persistence expectations remain follow-up work.
-- C3 — [#28](https://github.com/terisuke/note_maker/issues/28) Render brief and style guide as human-readable cards — implemented in the current cut for style-guide cards and article-brief cards, with raw Markdown/JSON details preserved behind disclosure controls. Rich project/article/draft artifact browsing remains a later Phase C layer on top of the #26 SQLite schema.
+- C3 — [#28](https://github.com/terisuke/note_maker/issues/28) Render brief and style guide as human-readable cards — implemented in the current cut for style-guide cards and article-brief cards, with raw Markdown/JSON details preserved behind disclosure controls. Project/article/draft artifact browsing now has read APIs and history cards, but real browser E2E and remaining edit/add-persona semantics are still outside the #27/#28 first-cut closure claim.
- D1 — [#29](https://github.com/terisuke/note_maker/issues/29) HTTP handler tests for `internal/handlers/workflow.go` — implemented in the current cut with 80.0% handler package coverage.
- Runtime runner — [#57](https://github.com/terisuke/note_maker/issues/57) Add live LLM media-matrix runner and aggregate evaluator, feeding [#40](https://github.com/terisuke/note_maker/issues/40) — implemented in the current cut.
- Runtime stabilization epic — [#40](https://github.com/terisuke/note_maker/issues/40) Stabilize Tailnet Evo X2 draft quality and runtime metrics. #70-#73 provide the prerequisite validation and diagnostics. [#74](https://github.com/terisuke/note_maker/issues/74) has passed the bounded Cloudia/Zenn and Cloudia/Qiita proofs plus the final `5/5` publishing-target matrix.
diff --git a/docs/implementation-plans/next-implementation-cut.md b/docs/implementation-plans/next-implementation-cut.md
index 6502df0..7619390 100644
--- a/docs/implementation-plans/next-implementation-cut.md
+++ b/docs/implementation-plans/next-implementation-cut.md
@@ -34,10 +34,16 @@ Implemented in the current #27/#28 history/artifact cut:
- Focused tests — `internal/handlers/workflow_history_test.go` covers saved artifact responses and brief detail errors; `static/history_ui_test.go` locks the frontend contract.
- Validation — [Issue 27/28 history and artifact UI/API validation](../validation/issue-27-28-history-artifacts-2026-05-03.md).
+Implemented in the #13 follow-up contract cut:
+
+- Static browser contract coverage checks the HTML entrypoint, production script, model selectors, question controls, history controls, artifact-card containers, persistence hooks, and route-table wiring.
+- This is useful pre-Playwright coverage, but it is not equivalent to browser E2E. It does not exercise real DOM events, fetch stubbing, localStorage reload behavior, SSE/cancel, or the section-regeneration workflow in a browser.
+- Validation — [Issue #13 Browser Contract Coverage](../validation/issue-13-browser-contract-coverage-2026-05-03.md).
+
Open and active:
- Memory/history umbrella: [#14](https://github.com/terisuke/note_maker/issues/14), now backed by the #26 schema work.
-- Browser E2E coverage: [#13](https://github.com/terisuke/note_maker/issues/13).
+- Browser E2E coverage: [#13](https://github.com/terisuke/note_maker/issues/13), still open after the contract-test cut.
- Runtime evaluation: [#40](https://github.com/terisuke/note_maker/issues/40), now satisfied for the current note/Qiita/Zenn/Cor blog publishing-target acceptance scope by the 2026-05-03 full Tailnet Evo X2 matrix.
- Runtime evaluation sub-issue [#74](https://github.com/terisuke/note_maker/issues/74), satisfied by the staged reruns and the final `5/5` full matrix pass.
- Fallback and packaging follow-up: [#36](https://github.com/terisuke/note_maker/issues/36), [#45](https://github.com/terisuke/note_maker/issues/45), [#15](https://github.com/terisuke/note_maker/issues/15).
@@ -50,7 +56,32 @@ Remaining Phase C gaps after the current #27/#28 cut:
- Add-persona authoring UI is not implemented; the current UI consumes seeded personas and saved artifacts.
- Broader edit persistence called out in the issue text is not implemented beyond the existing fork-on-edit/session/brief save paths.
-- Project/article/draft artifact browsing from SQLite's normalized #26 schema is not exposed in the web UI yet; this cut intentionally uses style guides, sessions, and completed briefs as the reusable history surface.
+- Project/article/draft artifact browsing from SQLite's normalized #26 schema now has server routes, response-shape contract coverage, frontend selectors, and readable history cards. The remaining gap is real browser E2E over those cards and broader edit/add-persona semantics, so #13/#14/#27/#28 should stay open unless the owner explicitly accepts a narrower first-cut closure.
+
+## Current Review Findings
+
+Review date: 2026-05-03 on `codex/phase-c-history-e2e`.
+
+Validation that passed:
+
+```sh
+go test ./...
+go test ./internal/handlers ./static
+go test ./cmd/server ./internal/handlers ./static
+node --check static/js/script.js
+git diff --check
+```
+
+No blocking code-risk finding remains in the targeted suite after the parallel fixes. The remaining closure risk is coverage scope: #13 still lacks real browser E2E, and #27/#28 still contain product scope that is broader than the first cut.
+
+## Issue Close/Open Proposal
+
+| Issue | Proposal | Rationale |
+|---|---|---|
+| [#13](https://github.com/terisuke/note_maker/issues/13) | Keep open | Static and route contract coverage was added, but the issue asks for browser E2E. Real browser coverage still needs model config, question customisation, persona/format switching, history open, readable cards, streaming/cancel, edit/fork, regenerate-section, and localStorage migration. |
+| [#14](https://github.com/terisuke/note_maker/issues/14) | Keep open | #26 gives the SQLite schema and restart-capable storage foundation, but the product still lacks full queryable project/article/draft browsing and versioned edit/history surfaces. |
+| [#27](https://github.com/terisuke/note_maker/issues/27) | Keep open, or close only if the issue owner accepts the first-cut scope | Saved style-guide/session reuse is implemented, but the original issue still includes add-persona authoring UI, project/article navigation, restart semantics, and broader edit persistence. |
+| [#28](https://github.com/terisuke/note_maker/issues/28) | Keep open, or close only if the issue owner accepts the first-cut scope | Readable style-guide and brief cards landed, but editable card persistence/versioning and richer project/article/draft artifacts are still outstanding. |
## Final evaluation target
@@ -124,14 +155,14 @@ Use subagents with disjoint write scopes when implementation resumes:
|---|---|---|---|---|
| A | [#74](https://github.com/terisuke/note_maker/issues/74) | Full matrix worker | live aggregate and validation docs | Complete for current scope: note, Qiita, Zenn, and Cor blog rows all pass and record artifacts |
| D | [#27](https://github.com/terisuke/note_maker/issues/27) / [#28](https://github.com/terisuke/note_maker/issues/28) | History/artifact UI worker | done for this cut | style-guide/session history picker and readable brief/style cards use persisted workflow state |
-| E | [#13](https://github.com/terisuke/note_maker/issues/13) | Browser E2E worker | browser tests and fixtures | persona/format switching, history open, readable cards, edit/fork, streaming, regenerate-section, and legacy localStorage migration are covered |
+| E | [#13](https://github.com/terisuke/note_maker/issues/13) | Browser E2E worker | browser tests and fixtures | contract tests are done; close only after persona/format switching, history open, readable cards, edit/fork, streaming/cancel, regenerate-section, and legacy localStorage migration are covered in a browser |
| F | Phase C follow-up | Product worker | future history UI/API files | add-persona UI, broader edit persistence, and project/article/draft browsing are split from the #27/#28 first cut |
Lane A is the next expensive Evo X2 spend. Lane D/E can continue in parallel when they do not need the same frontend files.
## Recommended order
-1. Land the current #27/#28 history/artifact cut with its validation doc, then wire #13 Browser E2E around the new history picker and cards while preserving the existing edit/fork, streaming, and regenerate-section coverage goals.
-2. Split the remaining Phase C work into explicit follow-up issues before implementation: add-persona authoring UI, broader edit persistence semantics, and project/article/draft artifact browsing from the #26 SQLite schema.
+1. Wire #13 Browser E2E around the new history picker and cards while preserving the existing edit/fork, streaming/cancel, and regenerate-section coverage goals.
+2. Split the remaining Phase C product work into explicit follow-up issues before broadening implementation: add-persona authoring UI, broader edit persistence semantics, project/article/draft artifact browsing polish from the #26 SQLite schema, and real browser E2E over these history paths.
3. Close #74 and #40 for the current publishing-target acceptance scope after the PR lands and the issue comments link the final aggregate artifacts.
4. Keep #36/#45 as fallback/runtime P2 work and #15 as packaging after persistence/history are usable. Homepage remains a separate short-format check, not part of the #40 closure gate.
diff --git a/docs/validation/issue-13-browser-contract-coverage-2026-05-03.md b/docs/validation/issue-13-browser-contract-coverage-2026-05-03.md
new file mode 100644
index 0000000..2738589
--- /dev/null
+++ b/docs/validation/issue-13-browser-contract-coverage-2026-05-03.md
@@ -0,0 +1,59 @@
+# Issue #13 Browser Contract Coverage
+
+Date: 2026-05-03
+Branch: `codex/phase-c-history-e2e`
+
+## Scope
+
+Added practical browser-adjacent coverage that remains part of `go test ./...`:
+
+- Static HTML contract checks for model selectors, question controls, history controls, and artifact card containers.
+- Static JavaScript contract checks for model config persistence, custom question add/edit/delete/reset behavior, history loading/opening flow, and readable artifact-card rendering.
+- `httptest` coverage that the server route table exposes workflow read APIs and serves the browser entrypoint plus the production script.
+
+This validates contract shape, not user behavior in a real browser. Issue [#13](https://github.com/terisuke/note_maker/issues/13) stays open until Playwright or equivalent browser E2E covers the actual flows.
+
+## Why Not Playwright Yet
+
+The current frontend script is a single `DOMContentLoaded` closure with no importable UI functions. A Playwright suite is still the right next step, but adding it now would require a heavier test harness with stubbed API routes and browser setup that is not yet present in this repository.
+
+This pass keeps CI friction low by expanding Go tests first. The contract tests intentionally lock DOM IDs, event bindings, persistence calls, fetch endpoints, and card-rendering structure so a later Playwright suite has stable selectors and scenarios to target.
+
+## Next Playwright Path
+
+Recommended scenarios when browser E2E is introduced:
+
+1. Stub `/api/models`, `/api/personas`, `/api/formats`, `/api/brief-sessions/templates`, and `/api/workflow/artifacts`.
+2. Assert all four model selectors populate from `/api/models`, save into `localStorage["note-maker-config-v1"]`, and restore on reload.
+3. Add a custom question, edit it, delete it, reload with saved custom questions, and reset back to template-only state.
+4. Select saved style/session history, assert the open button enables, clear disables it, and opening fetches `/api/author-style/{id}` or `/api/brief-sessions/{id}` as needed.
+5. Assert non-empty style and brief cards render `.artifact-card-header`, `.artifact-meta`, and `.artifact-section`, while raw Markdown/JSON previews remain populated.
+
+## Commands
+
+```sh
+go test ./static ./cmd/server
+go test ./...
+```
+
+## Review Result
+
+Commands passed after aligning the parallel history test fixture with the selected output-format validator:
+
+```sh
+go test ./cmd/server ./internal/handlers ./static
+go test ./internal/handlers ./static
+go test ./static ./cmd/server
+go test ./...
+node --check static/js/script.js
+git diff --check
+```
+
+This browser-contract document is still a validation checkpoint, not a close signal for #13. The next cut should add browser E2E over stubbed API responses once the project has a Playwright or equivalent harness.
+
+## Issue Policy
+
+- #13: keep open. Contract coverage is useful, but the issue asks for browser E2E.
+- #14: keep open. SQLite exists, but queryable product memory is not fully exposed.
+- #27: keep open unless the owner explicitly splits and closes the first saved-history picker cut.
+- #28: keep open unless the owner explicitly splits and closes the first readable-card cut.
diff --git a/docs/validation/issue-27-28-history-artifacts-2026-05-03.md b/docs/validation/issue-27-28-history-artifacts-2026-05-03.md
index 67b27be..23e6006 100644
--- a/docs/validation/issue-27-28-history-artifacts-2026-05-03.md
+++ b/docs/validation/issue-27-28-history-artifacts-2026-05-03.md
@@ -1,7 +1,7 @@
# Issue 27/28 history and artifact UI/API validation
Date: 2026-05-03
-Branch: `codex/issue27-28-history-artifacts`
+Branch: `codex/issue27-28-history-artifacts`; reviewed again from `codex/phase-c-history-e2e`
## Scope
@@ -61,6 +61,27 @@ ok github.com/teradakousuke/note_maker/internal/handlers (cached)
ok github.com/teradakousuke/note_maker/static (cached)
```
+Follow-up review on `codex/phase-c-history-e2e`:
+
+```sh
+go test ./...
+node --check static/js/script.js
+git diff --check
+```
+
+These passed after the project/article/draft history follow-up was integrated. The follow-up adds SQLite-backed read routes and UI contract coverage, but it is still browser-contract coverage rather than a real browser E2E close signal for #13.
+
+Final follow-up validation after the fixture alignment:
+
+```sh
+go test ./...
+go test ./cmd/server ./internal/handlers ./static
+node --check static/js/script.js
+git diff --check
+```
+
+All passed. Project/article/draft history can continue as implementation work, but #13 still needs real browser E2E before it closes.
+
## Acceptance Status
- Saved style guides can be listed for picker UIs: done.
@@ -75,5 +96,5 @@ ok github.com/teradakousuke/note_maker/static (cached)
- Add-persona authoring UI is still unimplemented.
- Broader edit persistence beyond the existing fork-on-edit/session save flow is still unimplemented.
-- Project/article/draft history browsing from SQLite remains unimplemented in the UI.
-- Browser E2E coverage for the new history picker and cards remains under [#13](https://github.com/terisuke/note_maker/issues/13).
+- Project/article/draft history browsing from SQLite has a follow-up implementation through read APIs and history cards. Treat it as a separate #83 product-readiness cut from the original #27/#28 first-cut validation.
+- Browser E2E coverage for the new history picker and cards remains under [#13](https://github.com/terisuke/note_maker/issues/13). Static contract tests alone are not enough to close #13.
diff --git a/internal/handlers/workflow.go b/internal/handlers/workflow.go
index ef1d278..cd8182a 100644
--- a/internal/handlers/workflow.go
+++ b/internal/handlers/workflow.go
@@ -42,6 +42,25 @@ type workflowStoreBackend interface {
GetProfileAndGuide(string) (authordomain.AuthorStyleProfile, authordomain.WritingStyleGuide, bool)
}
+type workflowHistoryReader interface {
+ GetProject(string) (sqliterepo.ProjectRecord, bool)
+ ListProjects() ([]sqliterepo.ProjectRecord, error)
+ GetArticle(string) (sqliterepo.ArticleRecord, bool)
+ ListArticlesByProject(string) ([]sqliterepo.ArticleRecord, error)
+ ListSourceSnapshots(string, string) ([]sqliterepo.SourceSnapshotRecord, error)
+ GetDraft(string) (sqliterepo.DraftRecord, bool)
+ ListDrafts(string) ([]sqliterepo.DraftRecord, error)
+ ListSectionRegenerations(string) ([]sqliterepo.SectionRegenerationRecord, error)
+}
+
+type workflowHistoryWriter interface {
+ workflowHistoryReader
+ SaveProject(sqliterepo.ProjectRecord) error
+ SaveArticle(sqliterepo.ArticleRecord) error
+ SaveDraft(sqliterepo.DraftRecord) error
+ SaveSectionRegeneration(sqliterepo.SectionRegenerationRecord) error
+}
+
func newWorkflowStore() workflowStoreBackend {
config := resolveWorkflowStorageConfig()
setActiveWorkflowStorage(config)
@@ -179,6 +198,101 @@ type workflowArtifactsResponse struct {
StyleGuides []styleGuideArtifactResponse `json:"style_guides"`
Sessions []briefSessionSummaryResponse `json:"sessions"`
Briefs []briefArtifactResponse `json:"briefs"`
+ Projects []projectHistoryResponse `json:"projects,omitempty"`
+ Articles []articleHistoryResponse `json:"articles,omitempty"`
+ Drafts []draftHistoryResponse `json:"drafts,omitempty"`
+}
+
+type projectHistoryListResponse struct {
+ Projects []projectHistoryResponse `json:"projects"`
+}
+
+type projectHistoryDetailResponse struct {
+ Project projectHistoryResponse `json:"project"`
+ Articles []articleHistoryResponse `json:"articles"`
+ SourceSnapshots []sourceSnapshotHistoryResponse `json:"source_snapshots"`
+}
+
+type articleHistoryDetailResponse struct {
+ Article articleHistoryResponse `json:"article"`
+ Drafts []draftHistoryResponse `json:"drafts"`
+ SourceSnapshots []sourceSnapshotHistoryResponse `json:"source_snapshots"`
+}
+
+type draftHistoryDetailResponse struct {
+ Draft draftHistoryResponse `json:"draft"`
+ SourceSnapshots []sourceSnapshotHistoryResponse `json:"source_snapshots"`
+ SectionRegenerations []sectionRegenerationHistoryResponse `json:"section_regenerations"`
+}
+
+type projectHistoryResponse struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ CreatedAt string `json:"created_at,omitempty"`
+ UpdatedAt string `json:"updated_at,omitempty"`
+ Metadata map[string]any `json:"metadata,omitempty"`
+ Articles []articleHistoryResponse `json:"articles,omitempty"`
+ SourceSnapshots []sourceSnapshotHistoryResponse `json:"source_snapshots,omitempty"`
+}
+
+type articleHistoryResponse struct {
+ ID string `json:"id"`
+ ProjectID string `json:"project_id,omitempty"`
+ PersonaID string `json:"persona_id"`
+ OutputFormatID string `json:"output_format_id"`
+ BriefSessionID string `json:"brief_session_id,omitempty"`
+ CurrentDraftID string `json:"current_draft_id,omitempty"`
+ Title string `json:"title,omitempty"`
+ CreatedAt string `json:"created_at,omitempty"`
+ UpdatedAt string `json:"updated_at,omitempty"`
+ Metadata map[string]any `json:"metadata,omitempty"`
+ Drafts []draftHistoryResponse `json:"drafts,omitempty"`
+ SourceSnapshots []sourceSnapshotHistoryResponse `json:"source_snapshots,omitempty"`
+}
+
+type sourceSnapshotHistoryResponse struct {
+ ID string `json:"id"`
+ ScopeType string `json:"scope_type"`
+ ScopeID string `json:"scope_id"`
+ Selector any `json:"selector"`
+ Profile any `json:"profile,omitempty"`
+ Article any `json:"article,omitempty"`
+ ContentHash string `json:"content_hash,omitempty"`
+ FetchedAt string `json:"fetched_at,omitempty"`
+ CreatedAt string `json:"created_at,omitempty"`
+}
+
+type draftHistoryResponse struct {
+ ID string `json:"id"`
+ ArticleID string `json:"article_id,omitempty"`
+ SessionID string `json:"session_id,omitempty"`
+ StyleProfileID string `json:"style_profile_id,omitempty"`
+ PersonaID string `json:"persona_id,omitempty"`
+ OutputFormatID string `json:"output_format_id,omitempty"`
+ Version int `json:"version"`
+ Markdown string `json:"markdown"`
+ ContentHash string `json:"content_hash,omitempty"`
+ Evaluation draftapp.StyleEvaluation `json:"evaluation"`
+ Verification draftapp.FinalVerification `json:"verification"`
+ QuestionTemplateVersion string `json:"question_template_version,omitempty"`
+ CreatedAt string `json:"created_at,omitempty"`
+ SourceSnapshots []sourceSnapshotHistoryResponse `json:"source_snapshots,omitempty"`
+ SectionRegenerations []sectionRegenerationHistoryResponse `json:"section_regenerations,omitempty"`
+}
+
+type sectionRegenerationHistoryResponse struct {
+ ID string `json:"id"`
+ DraftID string `json:"draft_id"`
+ ArticleID string `json:"article_id,omitempty"`
+ SectionAnchor string `json:"section_anchor"`
+ SectionHeading string `json:"section_heading,omitempty"`
+ BaseVersion int `json:"base_version"`
+ Version int `json:"version"`
+ ReplacementMarkdown string `json:"replacement_markdown"`
+ UpdatedDraftMarkdown string `json:"updated_draft_markdown"`
+ UpdatedContentHash string `json:"updated_content_hash,omitempty"`
+ Verification draftapp.FinalVerification `json:"verification"`
+ CreatedAt string `json:"created_at,omitempty"`
}
type briefSessionTemplateResponse struct {
@@ -273,6 +387,7 @@ type regenerateDraftSectionResponse struct {
Section draftapp.MarkdownSection `json:"section"`
ReplacementMarkdown string `json:"replacement_markdown"`
UpdatedDraftMarkdown string `json:"updated_draft_markdown"`
+ RegenerationID string `json:"regeneration_id,omitempty"`
}
func toGenerateDraftResponse(result draftapp.GenerateResult, draftPath string) generateDraftResponse {
@@ -833,10 +948,128 @@ func ListWorkflowArtifactsHandler(w http.ResponseWriter, r *http.Request) {
}
sortAuthorStyles(styles)
sortBriefSessions(sessions)
- respondWithJSON(w, http.StatusOK, workflowArtifactsResponse{
+ response := workflowArtifactsResponse{
StyleGuides: toStyleGuideArtifactResponses(styles),
Sessions: toBriefSessionSummaryResponses(sessions, briefs),
Briefs: listBriefArtifactResponses(briefs),
+ }
+ if store, ok := workflowStore.(workflowHistoryReader); ok {
+ if projects, articles, drafts, err := listWorkflowHistoryResponses(store); err != nil {
+ respondWithError(w, "WORKFLOW_HISTORY_LIST_FAILED", "Failed to list project history", err.Error(), http.StatusInternalServerError)
+ return
+ } else {
+ response.Projects = projects
+ response.Articles = articles
+ response.Drafts = drafts
+ }
+ }
+ respondWithJSON(w, http.StatusOK, response)
+}
+
+// ListProjectsHandler returns SQLite-backed project history when available.
+func ListProjectsHandler(w http.ResponseWriter, r *http.Request) {
+ store, ok := workflowStore.(workflowHistoryReader)
+ if !ok {
+ respondWithJSON(w, http.StatusOK, projectHistoryListResponse{Projects: []projectHistoryResponse{}})
+ return
+ }
+ projects, err := store.ListProjects()
+ if err != nil {
+ respondWithError(w, "PROJECT_LIST_FAILED", "Failed to list projects", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ respondWithJSON(w, http.StatusOK, projectHistoryListResponse{Projects: toProjectHistoryResponses(projects)})
+}
+
+// GetProjectHandler returns one project with its article and source history.
+func GetProjectHandler(w http.ResponseWriter, r *http.Request) {
+ store, ok := workflowStore.(workflowHistoryReader)
+ if !ok {
+ respondWithError(w, "PROJECT_NOT_FOUND", "Project was not found", "", http.StatusNotFound)
+ return
+ }
+ projectID := pathValue(r, "id")
+ project, ok := store.GetProject(projectID)
+ if !ok {
+ respondWithError(w, "PROJECT_NOT_FOUND", "Project was not found", projectID, http.StatusNotFound)
+ return
+ }
+ articles, err := store.ListArticlesByProject(projectID)
+ if err != nil {
+ respondWithError(w, "PROJECT_ARTICLES_LIST_FAILED", "Failed to list project articles", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ sourceSnapshots, err := store.ListSourceSnapshots("project", projectID)
+ if err != nil {
+ respondWithError(w, "PROJECT_SOURCE_SNAPSHOT_LIST_FAILED", "Failed to list project source snapshots", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ articleResponses := make([]articleHistoryResponse, 0, len(articles))
+ for _, article := range articles {
+ response, _, err := articleHistoryResponseWithDetails(store, article)
+ if err != nil {
+ respondWithError(w, "PROJECT_ARTICLE_HISTORY_FAILED", "Failed to load project article history", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ articleResponses = append(articleResponses, response)
+ }
+ projectResponse := toProjectHistoryResponse(project)
+ projectResponse.Articles = articleResponses
+ projectResponse.SourceSnapshots = toSourceSnapshotHistoryResponses(sourceSnapshots)
+ respondWithJSON(w, http.StatusOK, projectHistoryDetailResponse{
+ Project: projectResponse,
+ Articles: articleResponses,
+ SourceSnapshots: projectResponse.SourceSnapshots,
+ })
+}
+
+// GetArticleHandler returns one article with draft versions and source history.
+func GetArticleHandler(w http.ResponseWriter, r *http.Request) {
+ store, ok := workflowStore.(workflowHistoryReader)
+ if !ok {
+ respondWithError(w, "ARTICLE_NOT_FOUND", "Article was not found", "", http.StatusNotFound)
+ return
+ }
+ articleID := pathValue(r, "id")
+ article, ok := store.GetArticle(articleID)
+ if !ok {
+ respondWithError(w, "ARTICLE_NOT_FOUND", "Article was not found", articleID, http.StatusNotFound)
+ return
+ }
+ articleResponse, _, err := articleHistoryResponseWithDetails(store, article)
+ if err != nil {
+ respondWithError(w, "ARTICLE_HISTORY_FAILED", "Failed to load article history", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ respondWithJSON(w, http.StatusOK, articleHistoryDetailResponse{
+ Article: articleResponse,
+ Drafts: articleResponse.Drafts,
+ SourceSnapshots: articleResponse.SourceSnapshots,
+ })
+}
+
+// GetDraftHandler returns one draft with regeneration history.
+func GetDraftHandler(w http.ResponseWriter, r *http.Request) {
+ store, ok := workflowStore.(workflowHistoryReader)
+ if !ok {
+ respondWithError(w, "DRAFT_NOT_FOUND", "Draft was not found", "", http.StatusNotFound)
+ return
+ }
+ draftID := pathValue(r, "id")
+ draft, ok := store.GetDraft(draftID)
+ if !ok {
+ respondWithError(w, "DRAFT_NOT_FOUND", "Draft was not found", draftID, http.StatusNotFound)
+ return
+ }
+ draftResponse, err := draftHistoryResponseWithDetails(store, draft)
+ if err != nil {
+ respondWithError(w, "DRAFT_HISTORY_FAILED", "Failed to load draft history", err.Error(), http.StatusInternalServerError)
+ return
+ }
+ respondWithJSON(w, http.StatusOK, draftHistoryDetailResponse{
+ Draft: draftResponse,
+ SourceSnapshots: draftResponse.SourceSnapshots,
+ SectionRegenerations: draftResponse.SectionRegenerations,
})
}
@@ -1030,7 +1263,8 @@ func GenerateDraftHandler(w http.ResponseWriter, r *http.Request) {
respondWithError(w, "DRAFT_GENERATION_FAILED", "Failed to generate draft", err.Error(), http.StatusInternalServerError)
return
}
- respondWithJSON(w, http.StatusOK, toGenerateDraftResponse(result, ""))
+ draftPath := saveGeneratedDraftHistory(req, result, articleBrief, persona, format)
+ respondWithJSON(w, http.StatusOK, toGenerateDraftResponse(result, draftPath))
}
func streamGenerateDraft(w http.ResponseWriter, r *http.Request, req generateDraftRequest, profile authordomain.AuthorStyleProfile, guide authordomain.WritingStyleGuide, articleBrief briefdomain.ArticleBrief, persona personadomain.Persona, format outputformat.OutputFormat) {
@@ -1086,7 +1320,8 @@ func streamGenerateDraft(w http.ResponseWriter, r *http.Request, req generateDra
_ = stream.Send("error", streamError{Code: "DRAFT_GENERATION_FAILED", Message: "Failed to generate draft", Detail: err.Error(), ElapsedMS: stream.ElapsedMS()})
return
}
- _ = stream.Send("result", toGenerateDraftResponse(result, ""))
+ draftPath := saveGeneratedDraftHistory(req, result, articleBrief, persona, format)
+ _ = stream.Send("result", toGenerateDraftResponse(result, draftPath))
_ = stream.Send("done", streamStatus{Status: "completed", Phase: "draft", Endpoint: endpoint, Model: model, StartedAt: stream.started.Format(time.RFC3339), ElapsedMS: stream.ElapsedMS(), Runes: len([]rune(result.Draft.Markdown())), Score: result.Evaluation.Comparison.Score})
}
@@ -1097,8 +1332,27 @@ func RegenerateDraftSectionHandler(w http.ResponseWriter, r *http.Request) {
respondWithError(w, "INVALID_REQUEST_FORMAT", "Invalid request body", "", http.StatusBadRequest)
return
}
+ pathID := pathValue(r, "id")
+ baseDraft, hasBaseDraft := historyDraftFromPath(pathID)
+ if hasBaseDraft {
+ if strings.TrimSpace(req.SessionID) == "" {
+ req.SessionID = baseDraft.SessionID
+ }
+ if strings.TrimSpace(req.StyleProfileID) == "" {
+ req.StyleProfileID = baseDraft.StyleProfileID
+ }
+ if strings.TrimSpace(req.PersonaID) == "" {
+ req.PersonaID = baseDraft.PersonaID
+ }
+ if strings.TrimSpace(req.OutputFormatID) == "" {
+ req.OutputFormatID = baseDraft.OutputFormatID
+ }
+ if strings.TrimSpace(req.DraftMarkdown) == "" {
+ req.DraftMarkdown = baseDraft.Markdown
+ }
+ }
if strings.TrimSpace(req.SessionID) == "" {
- req.SessionID = pathValue(r, "id")
+ req.SessionID = pathID
}
profile, guide, articleBrief, persona, format, ok := draftContextFromRequest(req.StyleProfileID, req.SessionID, req.PersonaID, req.OutputFormatID)
if !ok {
@@ -1126,10 +1380,12 @@ func RegenerateDraftSectionHandler(w http.ResponseWriter, r *http.Request) {
respondWithError(w, "DRAFT_SECTION_REGENERATE_FAILED", "Failed to regenerate draft section", err.Error(), http.StatusBadRequest)
return
}
+ regenerationID := saveSectionRegenerationHistory(baseDraft, hasBaseDraft, req, result)
respondWithJSON(w, http.StatusOK, regenerateDraftSectionResponse{
Section: result.Section,
ReplacementMarkdown: result.ReplacementMarkdown,
UpdatedDraftMarkdown: result.UpdatedDraftMarkdown,
+ RegenerationID: regenerationID,
})
}
@@ -1174,6 +1430,153 @@ func draftContextFromRequest(styleProfileID, sessionID, personaID, formatID stri
return profile, guide, articleBrief, persona, format, true
}
+func historyDraftFromPath(pathID string) (sqliterepo.DraftRecord, bool) {
+ if strings.TrimSpace(pathID) == "" {
+ return sqliterepo.DraftRecord{}, false
+ }
+ store, ok := workflowStore.(workflowHistoryReader)
+ if !ok {
+ return sqliterepo.DraftRecord{}, false
+ }
+ return store.GetDraft(pathID)
+}
+
+func saveGeneratedDraftHistory(req generateDraftRequest, result draftapp.GenerateResult, articleBrief briefdomain.ArticleBrief, persona personadomain.Persona, format outputformat.OutputFormat) string {
+ store, ok := workflowStore.(workflowHistoryWriter)
+ if !ok {
+ return ""
+ }
+ sessionID := strings.TrimSpace(req.SessionID)
+ if sessionID == "" {
+ return ""
+ }
+ now := time.Now().UTC()
+ projectID := historyRecordID("project", sessionID)
+ articleID := historyRecordID("article", sessionID)
+ title := briefTitle(sessionID, articleBrief)
+ projectName := firstNonEmpty(title, sessionID)
+
+ project := sqliterepo.ProjectRecord{
+ ID: projectID,
+ Name: projectName,
+ CreatedAt: now,
+ UpdatedAt: now,
+ Metadata: map[string]any{"session_id": sessionID},
+ }
+ if existing, ok := store.GetProject(projectID); ok {
+ project.CreatedAt = existing.CreatedAt
+ if strings.TrimSpace(existing.Name) != "" {
+ project.Name = existing.Name
+ }
+ project.Metadata = mergeHistoryMetadata(existing.Metadata, project.Metadata)
+ }
+ if err := store.SaveProject(project); err != nil {
+ return ""
+ }
+
+ drafts, err := store.ListDrafts(articleID)
+ if err != nil {
+ return ""
+ }
+ draftID := newID("draft")
+ version := len(drafts) + 1
+ article := sqliterepo.ArticleRecord{
+ ID: articleID,
+ ProjectID: projectID,
+ PersonaID: persona.ID,
+ OutputFormatID: format.ID,
+ BriefSessionID: sessionID,
+ CurrentDraftID: draftID,
+ Title: title,
+ CreatedAt: now,
+ UpdatedAt: now,
+ Metadata: map[string]any{"session_id": sessionID},
+ }
+ if existing, ok := store.GetArticle(articleID); ok {
+ article.CreatedAt = existing.CreatedAt
+ article.Metadata = mergeHistoryMetadata(existing.Metadata, article.Metadata)
+ }
+ if err := store.SaveArticle(article); err != nil {
+ return ""
+ }
+ draft := sqliterepo.DraftRecord{
+ ID: draftID,
+ ArticleID: articleID,
+ SessionID: sessionID,
+ StyleProfileID: firstNonEmpty(req.StyleProfileID, articleBrief.StyleProfileID),
+ PersonaID: persona.ID,
+ OutputFormatID: format.ID,
+ Version: version,
+ Markdown: result.Draft.Markdown(),
+ Evaluation: result.Evaluation,
+ Verification: result.Verification,
+ CreatedAt: now,
+ }
+ if err := store.SaveDraft(draft); err != nil {
+ return ""
+ }
+ return draftID
+}
+
+func saveSectionRegenerationHistory(baseDraft sqliterepo.DraftRecord, hasBaseDraft bool, req regenerateDraftSectionRequest, result draftapp.RegenerateSectionResult) string {
+ if !hasBaseDraft {
+ return ""
+ }
+ store, ok := workflowStore.(workflowHistoryWriter)
+ if !ok {
+ return ""
+ }
+ regenerations, err := store.ListSectionRegenerations(baseDraft.ID)
+ if err != nil {
+ return ""
+ }
+ now := time.Now().UTC()
+ record := sqliterepo.SectionRegenerationRecord{
+ ID: newID("regen"),
+ DraftID: baseDraft.ID,
+ ArticleID: baseDraft.ArticleID,
+ SectionAnchor: firstNonEmpty(req.SectionAnchor, result.Section.Anchor),
+ SectionHeading: result.Section.Heading,
+ BaseVersion: baseDraft.Version,
+ Version: len(regenerations) + baseDraft.Version + 1,
+ ReplacementMarkdown: result.ReplacementMarkdown,
+ UpdatedDraftMarkdown: result.UpdatedDraftMarkdown,
+ CreatedAt: now,
+ }
+ if record.BaseVersion <= 0 {
+ record.BaseVersion = 1
+ }
+ if record.Version <= record.BaseVersion {
+ record.Version = record.BaseVersion + 1
+ }
+ if err := store.SaveSectionRegeneration(record); err != nil {
+ return ""
+ }
+ return record.ID
+}
+
+func historyRecordID(prefix, seed string) string {
+ seed = strings.TrimSpace(seed)
+ if seed == "" {
+ return newID(prefix)
+ }
+ return prefix + "_" + hex.EncodeToString([]byte(seed))
+}
+
+func mergeHistoryMetadata(existing, next map[string]any) map[string]any {
+ if len(existing) == 0 {
+ return next
+ }
+ merged := make(map[string]any, len(existing)+len(next))
+ for key, value := range existing {
+ merged[key] = value
+ }
+ for key, value := range next {
+ merged[key] = value
+ }
+ return merged
+}
+
func newDraftServiceWithVerifier(generator draftapp.TextGenerator, model string) (*draftapp.Service, error) {
verifierClient, err := llamacpp.NewClientFromEnvForPurposeWithModel("VERIFY", model)
if err != nil {
@@ -1427,6 +1830,184 @@ func sortBriefSessions(sessions []briefdomain.ArticleBriefSession) {
})
}
+func toProjectHistoryResponses(projects []sqliterepo.ProjectRecord) []projectHistoryResponse {
+ items := make([]projectHistoryResponse, 0, len(projects))
+ for _, project := range projects {
+ items = append(items, toProjectHistoryResponse(project))
+ }
+ return items
+}
+
+func toProjectHistoryResponse(project sqliterepo.ProjectRecord) projectHistoryResponse {
+ return projectHistoryResponse{
+ ID: project.ID,
+ Name: project.Name,
+ CreatedAt: formatOptionalTime(project.CreatedAt),
+ UpdatedAt: formatOptionalTime(project.UpdatedAt),
+ Metadata: project.Metadata,
+ }
+}
+
+func listWorkflowHistoryResponses(store workflowHistoryReader) ([]projectHistoryResponse, []articleHistoryResponse, []draftHistoryResponse, error) {
+ projects, err := store.ListProjects()
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ projectResponses := make([]projectHistoryResponse, 0, len(projects))
+ articleResponses := []articleHistoryResponse{}
+ draftResponses := []draftHistoryResponse{}
+ for _, project := range projects {
+ projectResponse := toProjectHistoryResponse(project)
+ snapshots, err := store.ListSourceSnapshots("project", project.ID)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ projectResponse.SourceSnapshots = toSourceSnapshotHistoryResponses(snapshots)
+ articles, err := store.ListArticlesByProject(project.ID)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ projectResponse.Articles = make([]articleHistoryResponse, 0, len(articles))
+ for _, article := range articles {
+ articleResponse, drafts, err := articleHistoryResponseWithDetails(store, article)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ projectResponse.Articles = append(projectResponse.Articles, articleResponse)
+ articleResponses = append(articleResponses, articleResponse)
+ draftResponses = append(draftResponses, drafts...)
+ }
+ projectResponses = append(projectResponses, projectResponse)
+ }
+ return projectResponses, articleResponses, draftResponses, nil
+}
+
+func articleHistoryResponseWithDetails(store workflowHistoryReader, article sqliterepo.ArticleRecord) (articleHistoryResponse, []draftHistoryResponse, error) {
+ articleResponse := toArticleHistoryResponse(article)
+ snapshots, err := store.ListSourceSnapshots("article", article.ID)
+ if err != nil {
+ return articleHistoryResponse{}, nil, err
+ }
+ articleResponse.SourceSnapshots = toSourceSnapshotHistoryResponses(snapshots)
+ drafts, err := store.ListDrafts(article.ID)
+ if err != nil {
+ return articleHistoryResponse{}, nil, err
+ }
+ draftResponses := make([]draftHistoryResponse, 0, len(drafts))
+ for _, draft := range drafts {
+ draftResponse, err := draftHistoryResponseWithDetails(store, draft)
+ if err != nil {
+ return articleHistoryResponse{}, nil, err
+ }
+ draftResponses = append(draftResponses, draftResponse)
+ }
+ articleResponse.Drafts = draftResponses
+ return articleResponse, draftResponses, nil
+}
+
+func draftHistoryResponseWithDetails(store workflowHistoryReader, draft sqliterepo.DraftRecord) (draftHistoryResponse, error) {
+ draftResponse := toDraftHistoryResponse(draft)
+ snapshots, err := store.ListSourceSnapshots("draft", draft.ID)
+ if err != nil {
+ return draftHistoryResponse{}, err
+ }
+ draftResponse.SourceSnapshots = toSourceSnapshotHistoryResponses(snapshots)
+ regenerations, err := store.ListSectionRegenerations(draft.ID)
+ if err != nil {
+ return draftHistoryResponse{}, err
+ }
+ draftResponse.SectionRegenerations = toSectionRegenerationHistoryResponses(regenerations)
+ return draftResponse, nil
+}
+
+func toArticleHistoryResponse(article sqliterepo.ArticleRecord) articleHistoryResponse {
+ return articleHistoryResponse{
+ ID: article.ID,
+ ProjectID: article.ProjectID,
+ PersonaID: article.PersonaID,
+ OutputFormatID: article.OutputFormatID,
+ BriefSessionID: article.BriefSessionID,
+ CurrentDraftID: article.CurrentDraftID,
+ Title: article.Title,
+ CreatedAt: formatOptionalTime(article.CreatedAt),
+ UpdatedAt: formatOptionalTime(article.UpdatedAt),
+ Metadata: article.Metadata,
+ }
+}
+
+func toSourceSnapshotHistoryResponses(snapshots []sqliterepo.SourceSnapshotRecord) []sourceSnapshotHistoryResponse {
+ items := make([]sourceSnapshotHistoryResponse, 0, len(snapshots))
+ for _, snapshot := range snapshots {
+ items = append(items, toSourceSnapshotHistoryResponse(snapshot))
+ }
+ return items
+}
+
+func toSourceSnapshotHistoryResponse(snapshot sqliterepo.SourceSnapshotRecord) sourceSnapshotHistoryResponse {
+ return sourceSnapshotHistoryResponse{
+ ID: snapshot.ID,
+ ScopeType: snapshot.ScopeType,
+ ScopeID: snapshot.ScopeID,
+ Selector: snapshot.Selector,
+ Profile: snapshot.Profile,
+ Article: snapshot.Article,
+ ContentHash: snapshot.ContentHash,
+ FetchedAt: formatOptionalTime(snapshot.FetchedAt),
+ CreatedAt: formatOptionalTime(snapshot.CreatedAt),
+ }
+}
+
+func toDraftHistoryResponses(drafts []sqliterepo.DraftRecord) []draftHistoryResponse {
+ items := make([]draftHistoryResponse, 0, len(drafts))
+ for _, draft := range drafts {
+ items = append(items, toDraftHistoryResponse(draft))
+ }
+ return items
+}
+
+func toDraftHistoryResponse(draft sqliterepo.DraftRecord) draftHistoryResponse {
+ return draftHistoryResponse{
+ ID: draft.ID,
+ ArticleID: draft.ArticleID,
+ SessionID: draft.SessionID,
+ StyleProfileID: draft.StyleProfileID,
+ PersonaID: draft.PersonaID,
+ OutputFormatID: draft.OutputFormatID,
+ Version: draft.Version,
+ Markdown: draft.Markdown,
+ ContentHash: draft.ContentHash,
+ Evaluation: draft.Evaluation,
+ Verification: draft.Verification,
+ QuestionTemplateVersion: draft.QuestionTemplateVersion,
+ CreatedAt: formatOptionalTime(draft.CreatedAt),
+ }
+}
+
+func toSectionRegenerationHistoryResponses(regenerations []sqliterepo.SectionRegenerationRecord) []sectionRegenerationHistoryResponse {
+ items := make([]sectionRegenerationHistoryResponse, 0, len(regenerations))
+ for _, regeneration := range regenerations {
+ items = append(items, toSectionRegenerationHistoryResponse(regeneration))
+ }
+ return items
+}
+
+func toSectionRegenerationHistoryResponse(regeneration sqliterepo.SectionRegenerationRecord) sectionRegenerationHistoryResponse {
+ return sectionRegenerationHistoryResponse{
+ ID: regeneration.ID,
+ DraftID: regeneration.DraftID,
+ ArticleID: regeneration.ArticleID,
+ SectionAnchor: regeneration.SectionAnchor,
+ SectionHeading: regeneration.SectionHeading,
+ BaseVersion: regeneration.BaseVersion,
+ Version: regeneration.Version,
+ ReplacementMarkdown: regeneration.ReplacementMarkdown,
+ UpdatedDraftMarkdown: regeneration.UpdatedDraftMarkdown,
+ UpdatedContentHash: regeneration.UpdatedContentHash,
+ Verification: regeneration.Verification,
+ CreatedAt: formatOptionalTime(regeneration.CreatedAt),
+ }
+}
+
func listBriefArtifactResponses(briefs map[string]briefdomain.ArticleBrief) []briefArtifactResponse {
sessionIDs := make([]string, 0, len(briefs))
for sessionID := range briefs {
diff --git a/internal/handlers/workflow_history_api_test.go b/internal/handlers/workflow_history_api_test.go
new file mode 100644
index 0000000..fc1a11b
--- /dev/null
+++ b/internal/handlers/workflow_history_api_test.go
@@ -0,0 +1,452 @@
+package handlers
+
+import (
+ "database/sql"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/gorilla/mux"
+ draftapp "github.com/teradakousuke/note_maker/internal/application/draft"
+ articledomain "github.com/teradakousuke/note_maker/internal/domain/article"
+ briefdomain "github.com/teradakousuke/note_maker/internal/domain/brief"
+ outputformat "github.com/teradakousuke/note_maker/internal/domain/format"
+ personadomain "github.com/teradakousuke/note_maker/internal/domain/persona"
+ sourcedomain "github.com/teradakousuke/note_maker/internal/domain/source"
+ "github.com/teradakousuke/note_maker/internal/infrastructure/repository/memory"
+ sqliterepo "github.com/teradakousuke/note_maker/internal/infrastructure/repository/sqlite"
+)
+
+func TestListProjectsHandlerReturnsSQLiteProjects(t *testing.T) {
+ setupHistoryAPIStore(t)
+
+ response := httptest.NewRecorder()
+ ListProjectsHandler(response, httptest.NewRequest(http.MethodGet, "/api/projects", nil))
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ payload := decodeHistoryAPIPayload(t, response)
+ projects := historyAPIArray(t, payload, "projects")
+ if len(projects) != 1 {
+ t.Fatalf("projects = %d, want 1: %#v", len(projects), payload)
+ }
+ project := historyAPIObject(t, projects[0])
+ if got := historyAPIString(project, "id", "ID"); got != "project-history" {
+ t.Fatalf("project id = %q, want project-history: %#v", got, project)
+ }
+ if got := historyAPIString(project, "name", "Name"); got != "History project" {
+ t.Fatalf("project name = %q, want History project: %#v", got, project)
+ }
+}
+
+func TestListProjectsHandlerReturnsEmptyListForUnsupportedStore(t *testing.T) {
+ previous := workflowStore
+ workflowStore = memory.NewWorkflowStore()
+ t.Cleanup(func() { workflowStore = previous })
+
+ response := httptest.NewRecorder()
+ ListProjectsHandler(response, httptest.NewRequest(http.MethodGet, "/api/projects", nil))
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ payload := decodeHistoryAPIPayload(t, response)
+ if projects := historyAPIArray(t, payload, "projects"); len(projects) != 0 {
+ t.Fatalf("projects = %d, want 0: %#v", len(projects), payload)
+ }
+}
+
+func TestListWorkflowArtifactsHandlerIncludesSQLiteHistory(t *testing.T) {
+ setupHistoryAPIStore(t)
+
+ response := httptest.NewRecorder()
+ ListWorkflowArtifactsHandler(response, httptest.NewRequest(http.MethodGet, "/api/workflow/artifacts", nil))
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ var payload workflowArtifactsResponse
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(payload.Projects) != 1 || payload.Projects[0].ID != "project-history" {
+ t.Fatalf("unexpected projects: %#v", payload.Projects)
+ }
+ if len(payload.Articles) != 1 || payload.Articles[0].ID != "article-history" {
+ t.Fatalf("unexpected articles: %#v", payload.Articles)
+ }
+ if len(payload.Drafts) != 1 || payload.Drafts[0].ID != "draft-history" {
+ t.Fatalf("unexpected drafts: %#v", payload.Drafts)
+ }
+ if len(payload.Projects[0].Articles) != 1 || len(payload.Articles[0].Drafts) != 1 || len(payload.Drafts[0].SectionRegenerations) != 1 {
+ t.Fatalf("workflow artifacts missing nested history: %#v", payload)
+ }
+}
+
+func TestGetProjectHandlerReturnsArticlesAndSourceSnapshots(t *testing.T) {
+ setupHistoryAPIStore(t)
+
+ request := httptest.NewRequest(http.MethodGet, "/api/projects/project-history", nil)
+ request = mux.SetURLVars(request, map[string]string{"id": "project-history"})
+ response := httptest.NewRecorder()
+ GetProjectHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ project := historyAPINamedObject(t, decodeHistoryAPIPayload(t, response), "project")
+ if got := historyAPIString(project, "id", "ID"); got != "project-history" {
+ t.Fatalf("project id = %q, want project-history: %#v", got, project)
+ }
+ articles := historyAPIArrayFromObject(t, project, "articles")
+ if len(articles) != 1 || historyAPIString(historyAPIObject(t, articles[0]), "id", "ID") != "article-history" {
+ t.Fatalf("unexpected project articles: %#v", articles)
+ }
+ snapshots := historyAPIArrayFromObject(t, project, "source_snapshots", "SourceSnapshots")
+ if len(snapshots) != 1 || historyAPIString(historyAPIObject(t, snapshots[0]), "id", "ID") != "snapshot-project" {
+ t.Fatalf("unexpected project source snapshots: %#v", snapshots)
+ }
+}
+
+func TestGetArticleHandlerReturnsDraftsAndSourceSnapshots(t *testing.T) {
+ setupHistoryAPIStore(t)
+
+ request := httptest.NewRequest(http.MethodGet, "/api/articles/article-history", nil)
+ request = mux.SetURLVars(request, map[string]string{"id": "article-history"})
+ response := httptest.NewRecorder()
+ GetArticleHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ article := historyAPINamedObject(t, decodeHistoryAPIPayload(t, response), "article")
+ if got := historyAPIString(article, "id", "ID"); got != "article-history" {
+ t.Fatalf("article id = %q, want article-history: %#v", got, article)
+ }
+ drafts := historyAPIArrayFromObject(t, article, "drafts")
+ if len(drafts) != 1 || historyAPIString(historyAPIObject(t, drafts[0]), "id", "ID") != "draft-history" {
+ t.Fatalf("unexpected article drafts: %#v", drafts)
+ }
+ snapshots := historyAPIArrayFromObject(t, article, "source_snapshots", "SourceSnapshots")
+ if len(snapshots) != 1 || historyAPIString(historyAPIObject(t, snapshots[0]), "id", "ID") != "snapshot-article" {
+ t.Fatalf("unexpected article source snapshots: %#v", snapshots)
+ }
+}
+
+func TestGetDraftHandlerReturnsSectionRegenerations(t *testing.T) {
+ setupHistoryAPIStore(t)
+
+ request := httptest.NewRequest(http.MethodGet, "/api/drafts/draft-history", nil)
+ request = mux.SetURLVars(request, map[string]string{"id": "draft-history"})
+ response := httptest.NewRecorder()
+ GetDraftHandler(response, request)
+
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
+ }
+ draft := historyAPINamedObject(t, decodeHistoryAPIPayload(t, response), "draft")
+ if got := historyAPIString(draft, "id", "ID"); got != "draft-history" {
+ t.Fatalf("draft id = %q, want draft-history: %#v", got, draft)
+ }
+ if got := historyAPIString(draft, "markdown", "Markdown"); got != "# History draft\n\nOriginal body." {
+ t.Fatalf("draft markdown = %q, want seeded markdown: %#v", got, draft)
+ }
+ regenerations := historyAPIArrayFromObject(t, draft, "section_regenerations", "SectionRegenerations")
+ if len(regenerations) != 1 {
+ t.Fatalf("section regenerations = %d, want 1: %#v", len(regenerations), regenerations)
+ }
+ regeneration := historyAPIObject(t, regenerations[0])
+ if got := historyAPIString(regeneration, "section_anchor", "SectionAnchor"); got != "history-anchor" {
+ t.Fatalf("section anchor = %q, want history-anchor: %#v", got, regeneration)
+ }
+}
+
+func TestHistoryReadHandlersReturnNotFoundForMissingIDs(t *testing.T) {
+ setupHistoryAPIStore(t)
+
+ tests := []struct {
+ name string
+ target string
+ handler http.HandlerFunc
+ }{
+ {name: "project", target: "/api/projects/missing", handler: GetProjectHandler},
+ {name: "article", target: "/api/articles/missing", handler: GetArticleHandler},
+ {name: "draft", target: "/api/drafts/missing", handler: GetDraftHandler},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, tt.target, nil)
+ request = mux.SetURLVars(request, map[string]string{"id": "missing"})
+ response := httptest.NewRecorder()
+
+ tt.handler(response, request)
+
+ if response.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404, body = %s", response.Code, response.Body.String())
+ }
+ })
+ }
+}
+
+func TestSaveGeneratedDraftHistoryPersistsDraftAndRegeneration(t *testing.T) {
+ store := setupEmptyHistoryAPIStore(t)
+ persona, ok := personadomain.DefaultRegistry().Get(personadomain.IDCloudia)
+ if !ok {
+ t.Fatal("missing cloudia persona")
+ }
+ format, ok := outputformat.DefaultRegistry().Get(outputformat.IDNoteArticle)
+ if !ok {
+ t.Fatal("missing note format")
+ }
+ generatedDraft, err := articledomain.NewDraftForFormat("# Saved draft\n\nOriginal body.", format.ID)
+ if err != nil {
+ t.Fatalf("new generated draft: %v", err)
+ }
+
+ draftID := saveGeneratedDraftHistory(generateDraftRequest{
+ StyleProfileID: "style-save",
+ SessionID: "session-save",
+ }, draftapp.GenerateResult{
+ Draft: generatedDraft,
+ Evaluation: draftapp.StyleEvaluation{
+ Passed: true,
+ },
+ Verification: draftapp.FinalVerification{
+ Performed: true,
+ Passed: true,
+ Summary: "ok",
+ },
+ }, briefdomain.ArticleBrief{
+ Theme: "Saved draft",
+ StyleProfileID: "style-save",
+ PersonaID: persona.ID,
+ OutputFormatID: format.ID,
+ }, persona, format)
+ if draftID == "" {
+ t.Fatal("draft history id was empty")
+ }
+
+ articleID := historyRecordID("article", "session-save")
+ article, ok := store.GetArticle(articleID)
+ if !ok || article.CurrentDraftID != draftID {
+ t.Fatalf("saved article = %#v ok=%v, want current draft %s", article, ok, draftID)
+ }
+ savedDraft, ok := store.GetDraft(draftID)
+ if !ok || savedDraft.ArticleID != articleID || savedDraft.Version != 1 {
+ t.Fatalf("saved draft = %#v ok=%v", savedDraft, ok)
+ }
+
+ regenerationID := saveSectionRegenerationHistory(savedDraft, true, regenerateDraftSectionRequest{
+ SectionAnchor: "saved",
+ }, draftapp.RegenerateSectionResult{
+ Section: draftapp.MarkdownSection{Anchor: "saved", Heading: "Saved"},
+ ReplacementMarkdown: "## Saved\n\nUpdated body.",
+ UpdatedDraftMarkdown: "# Saved draft\n\nUpdated body.",
+ })
+ if regenerationID == "" {
+ t.Fatal("section regeneration history id was empty")
+ }
+ regenerations, err := store.ListSectionRegenerations(draftID)
+ if err != nil {
+ t.Fatalf("list section regenerations: %v", err)
+ }
+ if len(regenerations) != 1 || regenerations[0].ID != regenerationID || regenerations[0].Version != 2 {
+ t.Fatalf("unexpected regenerations: %#v", regenerations)
+ }
+}
+
+func setupHistoryAPIStore(t *testing.T) {
+ t.Helper()
+ store := setupEmptyHistoryAPIStore(t)
+ seedHistoryAPIStore(t, store)
+}
+
+func setupEmptyHistoryAPIStore(t *testing.T) *sqliterepo.WorkflowStore {
+ t.Helper()
+ previous := workflowStore
+ db, err := sql.Open("sqlite3", ":memory:")
+ if err != nil {
+ t.Fatalf("open sqlite db: %v", err)
+ }
+ store, err := sqliterepo.NewWorkflowStoreDB(db)
+ if err != nil {
+ _ = db.Close()
+ t.Fatalf("new sqlite workflow store: %v", err)
+ }
+ t.Cleanup(func() {
+ workflowStore = previous
+ _ = store.Close()
+ })
+ workflowStore = store
+ return store
+}
+
+func seedHistoryAPIStore(t *testing.T, store *sqliterepo.WorkflowStore) {
+ t.Helper()
+ now := time.Unix(1710000000, 0).UTC()
+ project := sqliterepo.ProjectRecord{
+ ID: "project-history",
+ Name: "History project",
+ CreatedAt: now,
+ UpdatedAt: now.Add(2 * time.Minute),
+ Metadata: map[string]any{"owner": "history-test"},
+ }
+ if err := store.SaveProject(project); err != nil {
+ t.Fatalf("save project: %v", err)
+ }
+ article := sqliterepo.ArticleRecord{
+ ID: "article-history",
+ ProjectID: project.ID,
+ PersonaID: personadomain.IDCloudia,
+ OutputFormatID: outputformat.IDZennArticle,
+ BriefSessionID: "brief-history",
+ Title: "History article",
+ CreatedAt: now,
+ UpdatedAt: now.Add(time.Minute),
+ }
+ if err := store.SaveArticle(article); err != nil {
+ t.Fatalf("save article: %v", err)
+ }
+ if err := store.SaveSourceSnapshot(sqliterepo.SourceSnapshotRecord{
+ ID: "snapshot-project",
+ ScopeType: "project",
+ ScopeID: project.ID,
+ Selector: sourcedomain.Ref{Kind: sourcedomain.KindZenn, Ref: "cloudia"},
+ Profile: &sourcedomain.ProfileSnapshot{
+ Kind: sourcedomain.KindZenn,
+ Ref: "cloudia",
+ Title: "Cloudia",
+ FetchedAt: now,
+ },
+ FetchedAt: now,
+ CreatedAt: now,
+ }); err != nil {
+ t.Fatalf("save project source snapshot: %v", err)
+ }
+ if err := store.SaveSourceSnapshot(sqliterepo.SourceSnapshotRecord{
+ ID: "snapshot-article",
+ ScopeType: "article",
+ ScopeID: article.ID,
+ Selector: sourcedomain.Ref{Kind: sourcedomain.KindZenn, URL: "https://zenn.dev/cloudia/articles/history"},
+ Article: &sourcedomain.ArticleSnapshot{
+ ID: "source-history",
+ Kind: sourcedomain.KindZenn,
+ URL: "https://zenn.dev/cloudia/articles/history",
+ Title: "Source history",
+ Content: "Source body",
+ FetchedAt: now,
+ },
+ FetchedAt: now,
+ CreatedAt: now.Add(time.Second),
+ }); err != nil {
+ t.Fatalf("save article source snapshot: %v", err)
+ }
+ draft := sqliterepo.DraftRecord{
+ ID: "draft-history",
+ ArticleID: article.ID,
+ SessionID: article.BriefSessionID,
+ StyleProfileID: "style-history",
+ PersonaID: article.PersonaID,
+ OutputFormatID: article.OutputFormatID,
+ Version: 1,
+ Markdown: "# History draft\n\nOriginal body.",
+ Evaluation: draftapp.StyleEvaluation{
+ Passed: true,
+ },
+ Verification: draftapp.FinalVerification{
+ Performed: true,
+ Passed: true,
+ Summary: "ok",
+ },
+ CreatedAt: now.Add(3 * time.Minute),
+ }
+ if err := store.SaveDraft(draft); err != nil {
+ t.Fatalf("save draft: %v", err)
+ }
+ if err := store.SaveSectionRegeneration(sqliterepo.SectionRegenerationRecord{
+ ID: "regen-history",
+ DraftID: draft.ID,
+ ArticleID: article.ID,
+ SectionAnchor: "history-anchor",
+ SectionHeading: "History",
+ BaseVersion: 1,
+ Version: 2,
+ ReplacementMarkdown: "## History\n\nUpdated body.",
+ UpdatedDraftMarkdown: "# History draft\n\nUpdated body.",
+ Verification: draftapp.FinalVerification{
+ Performed: true,
+ Passed: true,
+ Summary: "regenerated ok",
+ },
+ CreatedAt: now.Add(4 * time.Minute),
+ }); err != nil {
+ t.Fatalf("save section regeneration: %v", err)
+ }
+}
+
+func decodeHistoryAPIPayload(t *testing.T, response *httptest.ResponseRecorder) any {
+ t.Helper()
+ var payload any
+ if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ return payload
+}
+
+func historyAPIArray(t *testing.T, payload any, names ...string) []any {
+ t.Helper()
+ if array, ok := payload.([]any); ok {
+ return array
+ }
+ object := historyAPIObject(t, payload)
+ return historyAPIArrayFromObject(t, object, names...)
+}
+
+func historyAPINamedObject(t *testing.T, payload any, names ...string) map[string]any {
+ t.Helper()
+ object := historyAPIObject(t, payload)
+ for _, name := range names {
+ if value, ok := object[name]; ok {
+ return historyAPIObject(t, value)
+ }
+ }
+ return object
+}
+
+func historyAPIArrayFromObject(t *testing.T, object map[string]any, names ...string) []any {
+ t.Helper()
+ for _, name := range names {
+ if value, ok := object[name]; ok {
+ array, ok := value.([]any)
+ if !ok {
+ t.Fatalf("%s is %T, want array: %#v", name, value, object)
+ }
+ return array
+ }
+ }
+ t.Fatalf("none of %v found in response object: %#v", names, object)
+ return nil
+}
+
+func historyAPIObject(t *testing.T, value any) map[string]any {
+ t.Helper()
+ object, ok := value.(map[string]any)
+ if !ok {
+ t.Fatalf("value is %T, want object: %#v", value, value)
+ }
+ return object
+}
+
+func historyAPIString(object map[string]any, names ...string) string {
+ for _, name := range names {
+ if value, ok := object[name]; ok {
+ if text, ok := value.(string); ok {
+ return text
+ }
+ }
+ }
+ return ""
+}
diff --git a/static/css/style.css b/static/css/style.css
index c8086af..f91b597 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -193,7 +193,7 @@ body {
.history-picker-grid {
display: grid;
- grid-template-columns: minmax(160px, 0.65fr) minmax(0, 1fr) minmax(0, 1fr);
+ grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
@@ -221,6 +221,18 @@ body {
background: var(--warning-bg);
}
+.history-detail {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+ margin-top: 12px;
+}
+
+.history-detail .artifact-card {
+ margin: 0;
+ min-width: 0;
+}
+
.section-heading {
display: flex;
align-items: center;
@@ -704,6 +716,7 @@ pre {
.config-grid,
.history-picker-grid,
+ .history-detail,
.result-grid {
grid-template-columns: 1fr;
}
diff --git a/static/history_ui_test.go b/static/history_ui_test.go
index 76f87c5..f13634d 100644
--- a/static/history_ui_test.go
+++ b/static/history_ui_test.go
@@ -9,49 +9,427 @@ import (
"github.com/PuerkitoBio/goquery"
)
+type staticContract struct {
+ document *goquery.Document
+ script string
+}
+
func TestHistoryUIContract(t *testing.T) {
- index, err := os.ReadFile("index.html")
- if err != nil {
- t.Fatalf("read index: %v", err)
- }
- script, err := os.ReadFile("js/script.js")
- if err != nil {
- t.Fatalf("read script: %v", err)
- }
- document, err := goquery.NewDocumentFromReader(bytes.NewReader(index))
- if err != nil {
- t.Fatalf("parse index: %v", err)
- }
+ contract := loadStaticContract(t)
for _, selector := range []string{
"#history-persona-select",
+ "#history-project-select",
+ "#history-article-select",
+ "#history-draft-select",
"#history-style-select",
"#history-session-select",
"#refresh-history-btn",
"#open-history-btn",
"#clear-history-selection-btn",
"#history-status",
+ "#history-article-detail",
+ "#history-project-card",
+ "#history-article-card",
+ "#history-article-brief-card",
+ "#history-current-draft-card",
+ "#history-draft-versions-card",
+ "#history-source-snapshot-card",
"#style-guide-card",
"#brief-card",
} {
- if document.Find(selector).Length() != 1 {
- t.Fatalf("selector %s count = %d, want 1", selector, document.Find(selector).Length())
- }
+ assertSelectorCount(t, contract.document, selector, 1)
+ }
+
+ openHistoryButton := contract.document.Find("#open-history-btn")
+ if _, ok := openHistoryButton.Attr("disabled"); !ok {
+ t.Fatalf("#open-history-btn should start disabled until a history item is selected")
}
- source := string(script)
- for _, want := range []string{
+ assertScriptContains(t, contract.script, []string{
"const historyEndpoint = '/api/workflow/artifacts'",
"loadWorkflowHistory",
"normalizeWorkflowHistory",
+ "normalizeHistoryProject",
+ "normalizeHistoryArticle",
+ "normalizeHistoryDraft",
+ "renderHistoryArticleDetail",
+ "loadHistoryArticleDetail",
+ "loadHistoryDraftDetail",
"renderStyleGuideCard",
"renderBriefCard",
"requestJSON(`${historyEndpoint}?",
+ "/api/projects/",
+ "/api/articles/",
+ "/api/drafts/",
"/api/author-style/",
"/api/brief-sessions/",
+ })
+}
+
+func TestModelSelectorConfigContract(t *testing.T) {
+ contract := loadStaticContract(t)
+
+ for selector, label := range map[string]string{
+ "#style-model": "文体分析モデル",
+ "#brief-model": "深掘り質問モデル",
+ "#draft-model": "下書き生成モデル",
+ "#verify-model": "最終検証モデル",
+ } {
+ assertSelectorCount(t, contract.document, selector, 1)
+ if got := strings.TrimSpace(contract.document.Find(`label[for="` + strings.TrimPrefix(selector, "#") + `"]`).Text()); got != label {
+ t.Fatalf("label for %s = %q, want %q", selector, got, label)
+ }
+ }
+
+ assertScriptContains(t, contract.script, []string{
+ "el.styleModel.addEventListener('change', saveModelConfig)",
+ "el.briefModel.addEventListener('change', saveModelConfig)",
+ "el.draftModel.addEventListener('change', saveModelConfig)",
+ "el.verifyModel.addEventListener('change', saveModelConfig)",
+ })
+ assertFunctionContains(t, contract.script, "populateModelSelects", []string{
+ "const available = models.length ? models : ['gemma4:31b']",
+ "style: config.models.style || 'gemma4:latest'",
+ "brief: config.models.brief || 'gemma4:e2b'",
+ "draft: config.models.draft || 'gemma4:31b'",
+ "verify: config.models.verify || 'gemma4:latest'",
+ "setOptions(el.styleModel, available, defaults.style)",
+ "setOptions(el.briefModel, available, defaults.brief)",
+ "setOptions(el.draftModel, available, defaults.draft)",
+ "setOptions(el.verifyModel, available, defaults.verify)",
+ "saveModelConfig()",
+ })
+ assertFunctionContains(t, contract.script, "saveModelConfig", []string{
+ "config.models = {",
+ "style: el.styleModel.value",
+ "brief: el.briefModel.value",
+ "draft: el.draftModel.value",
+ "verify: el.verifyModel.value",
+ "saveConfig()",
+ })
+ assertFunctionContains(t, contract.script, "loadConfig", []string{
+ "models: { style: 'gemma4:e2b', brief: 'qwen3.6:27b', draft: 'gemma4:31b', verify: 'gemma4:latest' }",
+ "localStorage.getItem(configStorageKey)",
+ "models: { ...fallback.models, ...(saved.models || {}) }",
+ })
+}
+
+func TestQuestionCRUDPersistenceContract(t *testing.T) {
+ contract := loadStaticContract(t)
+
+ for _, selector := range []string{
+ "#question-config-list",
+ "#add-question-btn",
+ "#reset-questions-btn",
+ } {
+ assertSelectorCount(t, contract.document, selector, 1)
+ }
+ if got := strings.TrimSpace(contract.document.Find("#add-question-btn").Text()); got != "質問を追加" {
+ t.Fatalf("#add-question-btn text = %q, want 質問を追加", got)
+ }
+ if got := strings.TrimSpace(contract.document.Find("#reset-questions-btn").Text()); got != "初期値に戻す" {
+ t.Fatalf("#reset-questions-btn text = %q, want 初期値に戻す", got)
+ }
+
+ assertScriptContains(t, contract.script, []string{
+ "el.addQuestion.addEventListener('click', addQuestion)",
+ "el.resetQuestions.addEventListener('click', resetQuestions)",
+ "const configStorageKey = 'note-maker-config-v1'",
+ })
+ assertFunctionContains(t, contract.script, "createCustomQuestionRow", []string{
+ "input.setAttribute('aria-label', '追加質問')",
+ "input.addEventListener('input'",
+ "config.customQuestions[index].text = input.value",
+ "saveConfig()",
+ "remove.textContent = '削除'",
+ "config.customQuestions.splice(index, 1)",
+ "renderQuestionConfig()",
+ })
+ assertFunctionContains(t, contract.script, "addQuestion", []string{
+ "config.customQuestions.push",
+ "id: `custom_${Date.now()}`",
+ "text: '追加で聞きたい質問を入力してください'",
+ "target_field: 'custom'",
+ "saveConfig()",
+ "renderQuestionConfig()",
+ })
+ assertFunctionContains(t, contract.script, "resetQuestions", []string{
+ "config.customQuestions = []",
+ "saveConfig()",
+ "loadQuestionTemplate()",
+ })
+ assertFunctionContains(t, contract.script, "loadConfig", []string{
+ "saved.customQuestions || saved.custom_questions || migrateLegacyQuestions(saved.questions)",
+ "customQuestions: normalizeQuestionList(savedCustomQuestions)",
+ })
+ assertFunctionContains(t, contract.script, "currentQuestions", []string{
+ "const templateIds = new Set",
+ "const templateTexts = new Set",
+ "normalizeQuestionList(config.customQuestions)",
+ "!templateIds.has(question.id)",
+ "!templateTexts.has(question.text)",
+ })
+}
+
+func TestHistoryOpenControlsApplyReadableCardsContract(t *testing.T) {
+ contract := loadStaticContract(t)
+
+ assertScriptContains(t, contract.script, []string{
+ "el.historyPersonaSelect.addEventListener('change', loadWorkflowHistory)",
+ "el.historyStyleSelect.addEventListener('change', selectHistoryStyle)",
+ "el.historySessionSelect.addEventListener('change', selectHistorySession)",
+ "el.refreshHistory.addEventListener('click', loadWorkflowHistory)",
+ "el.openHistory.addEventListener('click', openSelectedHistory)",
+ "el.clearHistorySelection.addEventListener('click', clearHistorySelection)",
+ })
+ assertFunctionContains(t, contract.script, "loadWorkflowHistory", []string{
+ "state.historyLoading = true",
+ "state.selectedHistoryStyle = null",
+ "state.selectedHistorySession = null",
+ "fetchWorkflowHistoryIndex({ personaId, formatId })",
+ "normalizeWorkflowHistory(data, personaId, formatId)",
+ "renderHistoryPicker()",
+ })
+ assertFunctionContains(t, contract.script, "renderHistoryPicker", []string{
+ "renderHistoryOptions(el.historyStyleSelect, state.historyStyles, '文体ガイドを選択')",
+ "renderHistoryOptions(el.historySessionSelect, state.historySessions, '取材セッションを選択')",
+ "el.openHistory.disabled = state.historyLoading || !historySelectionReady()",
+ "この書き手と出力先の保存済み履歴はまだありません。",
+ "選択できます。",
+ })
+ assertFunctionContains(t, contract.script, "openSelectedHistory", []string{
+ "loadHistoryStyleDetail",
+ "loadHistorySessionDetail",
+ "applyHistoryStyle(styleForSession)",
+ "await applyHistorySession(session)",
+ "選択した履歴を現在の作業状態に反映しました。",
+ })
+ assertFunctionContains(t, contract.script, "applyHistorySession", []string{
+ "config.mode.persona = data.personaId",
+ "config.mode.format = data.outputFormatId",
+ "await loadQuestionTemplate()",
+ "state.answers = data.answers || []",
+ "rememberQuestions(data.questions || state.templateQuestions)",
+ "renderTranscript",
+ "renderBriefCard(data.brief)",
+ "el.generateDraft.disabled = !state.profileId",
+ })
+}
+
+func TestHistoryProjectArticleDraftAdapterContract(t *testing.T) {
+ contract := loadStaticContract(t)
+
+ assertFunctionContains(t, contract.script, "normalizeWorkflowHistory", []string{
+ "const projectValues = arrayFrom(source.projects || source.Projects)",
+ "...arrayFrom(source.articles || source.Articles)",
+ "...arrayFrom(source.article_history || source.articleHistory)",
+ "...arrayFrom(source.drafts || source.Drafts)",
+ "...arrayFrom(source.draft_versions || source.draftVersions)",
+ "projectValues.flatMap",
+ "project.articles || project.Articles",
+ "article.drafts || article.Drafts || article.draft_versions || article.draftVersions",
+ "article.current_draft || article.currentDraft || article.draft || article.Draft",
+ "projects: uniqueHistoryItems(projectValues.map(normalizeHistoryProject)",
+ "articles: uniqueHistoryItems(articleValues.map(normalizeHistoryArticle)",
+ "drafts: uniqueHistoryItems(draftValues.map(normalizeHistoryDraft)",
+ })
+ assertFunctionContains(t, contract.script, "normalizeHistoryProject", []string{
+ "const project = item.project || item.Project || {}",
+ "project.id || project.ID",
+ "project.title || project.Title || project.name || project.Name",
+ "project.persona_id || project.PersonaID",
+ "project.output_format_id || project.OutputFormatID",
+ "item.articles || item.Articles || project.articles || project.Articles",
+ "normalizeHistoryArticle({ project_id: id, ...article })",
+ })
+ assertFunctionContains(t, contract.script, "normalizeHistoryArticle", []string{
+ "const article = item.article || item.Article || {}",
+ "article.id || article.ID",
+ "article.title || article.Title",
+ "item.brief || item.Brief || item.article_brief || item.articleBrief || article.brief || article.Brief",
+ "item.current_draft || item.currentDraft || item.draft || item.Draft || article.current_draft || article.CurrentDraft",
+ "item.draft_versions || item.draftVersions || item.drafts || item.Drafts || article.draft_versions || article.DraftVersions || article.drafts || article.Drafts",
+ "article.source_snapshot || article.SourceSnapshot",
+ })
+ assertFunctionContains(t, contract.script, "normalizeHistoryDraft", []string{
+ "const draft = item.draft || item.Draft || {}",
+ "draft.id || draft.ID",
+ "draft.article_id || draft.ArticleID",
+ "draft.session_id || draft.SessionID",
+ "draft.style_profile_id || draft.StyleProfileID",
+ "draft.markdown || draft.Markdown || draft.text || draft.Text",
+ "draft.score ?? draft.Score",
+ })
+}
+
+func TestHistoryWrappedDetailResponseContract(t *testing.T) {
+ contract := loadStaticContract(t)
+
+ assertFunctionContains(t, contract.script, "loadHistoryProjectDetail", []string{
+ "`/api/projects/${encodeURIComponent(normalized.id)}`",
+ "`/api/history/projects/${encodeURIComponent(normalized.id)}`",
+ "return normalizeHistoryProject({ ...item, ...data })",
+ })
+ assertFunctionContains(t, contract.script, "loadHistoryArticleDetail", []string{
+ "`/api/articles/${encodeURIComponent(normalized.id)}`",
+ "`/api/projects/${encodeURIComponent(normalized.projectId)}/articles/${encodeURIComponent(normalized.id)}`",
+ "`/api/history/articles/${encodeURIComponent(normalized.id)}`",
+ "const detail = data && !data.article && !data.Article && (data.brief || data.Brief || data.theme || data.Theme)",
+ "return normalizeHistoryArticle({ ...item, ...detail })",
+ })
+ assertFunctionContains(t, contract.script, "loadHistoryDraftDetail", []string{
+ "`/api/drafts/${encodeURIComponent(normalized.id)}`",
+ "`/api/history/drafts/${encodeURIComponent(normalized.id)}`",
+ "return normalizeHistoryDraft({ ...item, ...data })",
+ })
+ assertFunctionContains(t, contract.script, "requestFirstJSON", []string{
+ "for (const url of urls.filter(Boolean))",
+ "return await requestJSON(url)",
+ "throw lastError || new Error('履歴詳細APIが見つかりません')",
+ })
+}
+
+func TestArtifactCardsReadableContract(t *testing.T) {
+ contract := loadStaticContract(t)
+
+ for _, selector := range []string{
+ "#style-result #style-guide-card",
+ "#style-result .artifact-raw #guide-preview",
+ "#brief-result #brief-card",
+ "#brief-result .artifact-raw #brief-preview",
} {
- if !strings.Contains(source, want) {
+ assertSelectorCount(t, contract.document, selector, 1)
+ }
+
+ assertFunctionContains(t, contract.script, "renderStyleGuideCard", []string{
+ "el.styleGuideCard.className = 'artifact-card empty'",
+ "文体ガイドはまだありません。",
+ "createArtifactHeader",
+ "['Profile', normalized.profileId || normalized.id]",
+ "['Guide', normalized.guideId]",
+ "['Articles', normalized.articleCount === undefined ? '' : String(normalized.articleCount)]",
+ "markdownSectionsForCard(markdown)",
+ "createArtifactSection",
+ })
+ assertFunctionContains(t, contract.script, "renderBriefCard", []string{
+ "el.briefCard.className = 'artifact-card empty'",
+ "記事ブリーフはまだありません。",
+ "createArtifactHeader",
+ "['Persona', briefField(brief, 'persona_id', 'PersonaID')]",
+ "['Format', briefField(brief, 'output_format_id', 'OutputFormatID')]",
+ "['Style', briefField(brief, 'style_profile_id', 'StyleProfileID')]",
+ "['読者', briefField(brief, 'reader', 'Reader')]",
+ "['必ず含めること', briefField(brief, 'must_include', 'MustInclude')]",
+ "createAnswerSection('追加回答', customAnswers)",
+ "createAnswerSection('深掘りメモ', deepDives)",
+ })
+ assertFunctionContains(t, contract.script, "createArtifactHeader", []string{
+ "header.className = 'artifact-card-header'",
+ "titleElement.textContent = title",
+ "meta.className = 'artifact-meta'",
+ })
+ assertFunctionContains(t, contract.script, "createAnswerSection", []string{
+ "state.questionTextById[questionId]",
+ "return `${question}: ${content}`",
+ })
+}
+
+func loadStaticContract(t *testing.T) staticContract {
+ t.Helper()
+
+ index, err := os.ReadFile("index.html")
+ if err != nil {
+ t.Fatalf("read index: %v", err)
+ }
+ script, err := os.ReadFile("js/script.js")
+ if err != nil {
+ t.Fatalf("read script: %v", err)
+ }
+ document, err := goquery.NewDocumentFromReader(bytes.NewReader(index))
+ if err != nil {
+ t.Fatalf("parse index: %v", err)
+ }
+ return staticContract{document: document, script: string(script)}
+}
+
+func assertSelectorCount(t *testing.T, document *goquery.Document, selector string, want int) {
+ t.Helper()
+ if got := document.Find(selector).Length(); got != want {
+ t.Fatalf("selector %s count = %d, want %d", selector, got, want)
+ }
+}
+
+func assertScriptContains(t *testing.T, script string, wants []string) {
+ t.Helper()
+ for _, want := range wants {
+ if !strings.Contains(script, want) {
t.Fatalf("script missing %q", want)
}
}
}
+
+func assertFunctionContains(t *testing.T, script, name string, wants []string) {
+ t.Helper()
+ body, ok := functionBody(script, name)
+ if !ok {
+ t.Fatalf("script missing function %s", name)
+ }
+ for _, want := range wants {
+ if !strings.Contains(body, want) {
+ t.Fatalf("function %s missing %q", name, want)
+ }
+ }
+}
+
+func functionBody(script, name string) (string, bool) {
+ signature := "function " + name + "("
+ start := strings.Index(script, signature)
+ if start < 0 {
+ return "", false
+ }
+ open := functionBodyOpen(script, start+len("function "+name))
+ if open < 0 {
+ return "", false
+ }
+ depth := 0
+ for index := open; index < len(script); index++ {
+ switch script[index] {
+ case '{':
+ depth++
+ case '}':
+ depth--
+ if depth == 0 {
+ return script[open : index+1], true
+ }
+ }
+ }
+ return "", false
+}
+
+func functionBodyOpen(script string, parenStart int) int {
+ if parenStart >= len(script) || script[parenStart] != '(' {
+ return -1
+ }
+ depth := 0
+ for index := parenStart; index < len(script); index++ {
+ switch script[index] {
+ case '(':
+ depth++
+ case ')':
+ depth--
+ if depth == 0 {
+ for next := index + 1; next < len(script); next++ {
+ if script[next] == '{' {
+ return next
+ }
+ if script[next] != ' ' && script[next] != '\n' && script[next] != '\t' && script[next] != '\r' {
+ return -1
+ }
+ }
+ return -1
+ }
+ }
+ }
+ return -1
+}
diff --git a/static/index.html b/static/index.html
index 882d4e7..7ab96e6 100644
--- a/static/index.html
+++ b/static/index.html
@@ -87,6 +87,18 @@
履歴から再開
+
+
+
+
+
+
+
+
+
+
+
+
@@ -101,6 +113,14 @@
履歴から再開
+
diff --git a/static/js/script.js b/static/js/script.js
index 427227e..3f9e068 100644
--- a/static/js/script.js
+++ b/static/js/script.js
@@ -51,11 +51,17 @@ document.addEventListener('DOMContentLoaded', () => {
templateRequestId: 0,
historyStyles: [],
historySessions: [],
+ historyProjects: [],
+ historyArticles: [],
+ historyDrafts: [],
historyLoading: false,
historyError: '',
historyRequestId: 0,
selectedHistoryStyle: null,
selectedHistorySession: null,
+ selectedHistoryProject: null,
+ selectedHistoryArticle: null,
+ selectedHistoryDraft: null,
storageConfig: null,
questionTextById: {},
lastSubmittedAnswer: '',
@@ -78,12 +84,22 @@ document.addEventListener('DOMContentLoaded', () => {
saveStorage: document.getElementById('save-storage-btn'),
storageSummary: document.getElementById('storage-summary'),
historyPersonaSelect: document.getElementById('history-persona-select'),
+ historyProjectSelect: document.getElementById('history-project-select'),
+ historyArticleSelect: document.getElementById('history-article-select'),
+ historyDraftSelect: document.getElementById('history-draft-select'),
historyStyleSelect: document.getElementById('history-style-select'),
historySessionSelect: document.getElementById('history-session-select'),
refreshHistory: document.getElementById('refresh-history-btn'),
openHistory: document.getElementById('open-history-btn'),
clearHistorySelection: document.getElementById('clear-history-selection-btn'),
historyStatus: document.getElementById('history-status'),
+ historyArticleDetail: document.getElementById('history-article-detail'),
+ historyProjectCard: document.getElementById('history-project-card'),
+ historyArticleCard: document.getElementById('history-article-card'),
+ historyArticleBriefCard: document.getElementById('history-article-brief-card'),
+ historyCurrentDraftCard: document.getElementById('history-current-draft-card'),
+ historyDraftVersionsCard: document.getElementById('history-draft-versions-card'),
+ historySourceSnapshotCard: document.getElementById('history-source-snapshot-card'),
questionConfigList: document.getElementById('question-config-list'),
addQuestion: document.getElementById('add-question-btn'),
resetQuestions: document.getElementById('reset-questions-btn'),
@@ -129,6 +145,7 @@ document.addEventListener('DOMContentLoaded', () => {
};
renderQuestionConfig();
+ renderHistoryArticleDetail();
initializeModeControls();
checkModels();
loadStorageConfig();
@@ -142,6 +159,9 @@ document.addEventListener('DOMContentLoaded', () => {
el.storageDriver.addEventListener('change', onStorageDriverChange);
el.saveStorage.addEventListener('click', saveStorageConfig);
el.historyPersonaSelect.addEventListener('change', loadWorkflowHistory);
+ el.historyProjectSelect.addEventListener('change', selectHistoryProject);
+ el.historyArticleSelect.addEventListener('change', selectHistoryArticle);
+ el.historyDraftSelect.addEventListener('change', selectHistoryDraft);
el.historyStyleSelect.addEventListener('change', selectHistoryStyle);
el.historySessionSelect.addEventListener('change', selectHistorySession);
el.refreshHistory.addEventListener('click', loadWorkflowHistory);
@@ -818,7 +838,11 @@ document.addEventListener('DOMContentLoaded', () => {
state.historyError = '';
state.selectedHistoryStyle = null;
state.selectedHistorySession = null;
+ state.selectedHistoryProject = null;
+ state.selectedHistoryArticle = null;
+ state.selectedHistoryDraft = null;
renderHistoryPicker();
+ renderHistoryArticleDetail();
try {
const data = await fetchWorkflowHistoryIndex({ personaId, formatId });
@@ -828,17 +852,24 @@ document.addEventListener('DOMContentLoaded', () => {
const normalized = normalizeWorkflowHistory(data, personaId, formatId);
state.historyStyles = normalized.styles;
state.historySessions = normalized.sessions;
+ state.historyProjects = normalized.projects;
+ state.historyArticles = normalized.articles;
+ state.historyDrafts = normalized.drafts;
} catch (error) {
if (requestId !== state.historyRequestId) {
return;
}
state.historyStyles = [];
state.historySessions = [];
+ state.historyProjects = [];
+ state.historyArticles = [];
+ state.historyDrafts = [];
state.historyError = historyErrorMessage(error);
} finally {
if (requestId === state.historyRequestId) {
state.historyLoading = false;
renderHistoryPicker();
+ renderHistoryArticleDetail();
}
}
}
@@ -855,16 +886,24 @@ document.addEventListener('DOMContentLoaded', () => {
}
function renderHistoryPicker() {
+ const articles = filteredHistoryArticles();
+ const drafts = filteredHistoryDrafts();
+ renderHistoryOptions(el.historyProjectSelect, state.historyProjects, 'プロジェクトを選択');
+ renderHistoryOptions(el.historyArticleSelect, articles, '記事を選択');
+ renderHistoryOptions(el.historyDraftSelect, drafts, '下書きを選択');
renderHistoryOptions(el.historyStyleSelect, state.historyStyles, '文体ガイドを選択');
renderHistoryOptions(el.historySessionSelect, state.historySessions, '取材セッションを選択');
+ el.historyProjectSelect.disabled = state.historyLoading || !state.historyProjects.length;
+ el.historyArticleSelect.disabled = state.historyLoading || !articles.length;
+ el.historyDraftSelect.disabled = state.historyLoading || !drafts.length;
el.historyStyleSelect.disabled = state.historyLoading || !state.historyStyles.length;
el.historySessionSelect.disabled = state.historyLoading || !state.historySessions.length;
el.openHistory.disabled = state.historyLoading || !historySelectionReady();
if (state.historyLoading) {
el.historyStatus.className = 'history-status loading';
- el.historyStatus.textContent = '保存済みの文体ガイドと取材セッションを読み込んでいます...';
+ el.historyStatus.textContent = '保存済みのプロジェクト、記事、下書き、文体ガイド、取材セッションを読み込んでいます...';
return;
}
if (state.historyError) {
@@ -872,15 +911,18 @@ document.addEventListener('DOMContentLoaded', () => {
el.historyStatus.textContent = state.historyError;
return;
}
- if (!state.historyStyles.length && !state.historySessions.length) {
+ if (!state.historyProjects.length && !state.historyArticles.length && !state.historyDrafts.length && !state.historyStyles.length && !state.historySessions.length) {
el.historyStatus.className = 'history-status empty';
el.historyStatus.textContent = 'この書き手と出力先の保存済み履歴はまだありません。';
return;
}
+ const projectCount = `${state.historyProjects.length}件のプロジェクト`;
+ const articleCount = `${state.historyArticles.length}件の記事`;
+ const draftCount = `${state.historyDrafts.length}件の下書き`;
const styleCount = `${state.historyStyles.length}件の文体ガイド`;
const sessionCount = `${state.historySessions.length}件の取材セッション`;
el.historyStatus.className = 'history-status';
- el.historyStatus.textContent = `${styleCount} / ${sessionCount} を選択できます。`;
+ el.historyStatus.textContent = `${projectCount} / ${articleCount} / ${draftCount} / ${styleCount} / ${sessionCount} を選択できます。`;
}
function renderHistoryOptions(select, items, placeholder) {
@@ -909,7 +951,13 @@ document.addEventListener('DOMContentLoaded', () => {
}
function historySelectionReady() {
- return Boolean(el.historyStyleSelect.value || el.historySessionSelect.value);
+ return Boolean(
+ el.historyProjectSelect.value
+ || el.historyArticleSelect.value
+ || el.historyDraftSelect.value
+ || el.historyStyleSelect.value
+ || el.historySessionSelect.value,
+ );
}
function historyErrorMessage(error) {
@@ -964,6 +1012,84 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
+ async function selectHistoryProject() {
+ state.selectedHistoryProject = findHistoryProject(el.historyProjectSelect.value);
+ state.selectedHistoryArticle = null;
+ state.selectedHistoryDraft = null;
+ el.historyArticleSelect.value = '';
+ el.historyDraftSelect.value = '';
+ renderHistoryPicker();
+ renderHistoryArticleDetail();
+ if (!state.selectedHistoryProject) {
+ return;
+ }
+ el.historyStatus.className = 'history-status loading';
+ el.historyStatus.textContent = 'プロジェクト履歴を確認しています...';
+ try {
+ state.selectedHistoryProject = await loadHistoryProjectDetail(state.selectedHistoryProject);
+ mergeProjectDetailIntoHistory(state.selectedHistoryProject);
+ renderHistoryPicker();
+ renderHistoryArticleDetail();
+ } catch (error) {
+ renderHistoryArticleDetail({ warning: `プロジェクト詳細APIは未接続です: ${error.message}` });
+ renderHistoryPicker();
+ }
+ }
+
+ async function selectHistoryArticle() {
+ state.selectedHistoryArticle = findHistoryArticle(el.historyArticleSelect.value);
+ state.selectedHistoryDraft = null;
+ el.historyDraftSelect.value = '';
+ renderHistoryPicker();
+ renderHistoryArticleDetail();
+ if (!state.selectedHistoryArticle) {
+ return;
+ }
+ if (state.selectedHistoryArticle.projectId && !state.selectedHistoryProject) {
+ state.selectedHistoryProject = findHistoryProject(state.selectedHistoryArticle.projectId);
+ if (state.selectedHistoryProject) {
+ el.historyProjectSelect.value = state.selectedHistoryProject.id;
+ }
+ }
+ el.historyStatus.className = 'history-status loading';
+ el.historyStatus.textContent = '記事履歴を確認しています...';
+ try {
+ state.selectedHistoryArticle = await loadHistoryArticleDetail(state.selectedHistoryArticle);
+ mergeArticleDetailIntoHistory(state.selectedHistoryArticle);
+ renderHistoryPicker();
+ renderHistoryArticleDetail();
+ } catch (error) {
+ renderHistoryArticleDetail({ warning: `記事詳細APIは未接続です: ${error.message}` });
+ renderHistoryPicker();
+ }
+ }
+
+ async function selectHistoryDraft() {
+ state.selectedHistoryDraft = findHistoryDraft(el.historyDraftSelect.value);
+ if (!state.selectedHistoryDraft) {
+ renderHistoryArticleDetail();
+ el.openHistory.disabled = !historySelectionReady();
+ return;
+ }
+ if (state.selectedHistoryDraft.articleId && !state.selectedHistoryArticle) {
+ state.selectedHistoryArticle = findHistoryArticle(state.selectedHistoryDraft.articleId);
+ if (state.selectedHistoryArticle) {
+ el.historyArticleSelect.value = state.selectedHistoryArticle.id;
+ }
+ }
+ el.historyStatus.className = 'history-status loading';
+ el.historyStatus.textContent = '下書き履歴を確認しています...';
+ try {
+ state.selectedHistoryDraft = await loadHistoryDraftDetail(state.selectedHistoryDraft);
+ mergeDraftDetailIntoHistory(state.selectedHistoryDraft);
+ renderHistoryPicker();
+ renderHistoryArticleDetail();
+ } catch (error) {
+ renderHistoryArticleDetail({ warning: `下書き詳細APIは未接続です: ${error.message}` });
+ renderHistoryPicker();
+ }
+ }
+
async function openSelectedHistory() {
clearError();
el.historyStatus.className = 'history-status loading';
@@ -975,6 +1101,12 @@ document.addEventListener('DOMContentLoaded', () => {
const session = el.historySessionSelect.value
? await loadHistorySessionDetail(state.selectedHistorySession || findHistorySession(el.historySessionSelect.value))
: null;
+ const article = el.historyArticleSelect.value
+ ? await loadHistoryArticleDetail(state.selectedHistoryArticle || findHistoryArticle(el.historyArticleSelect.value))
+ : null;
+ const draft = el.historyDraftSelect.value
+ ? await loadHistoryDraftDetail(state.selectedHistoryDraft || findHistoryDraft(el.historyDraftSelect.value))
+ : null;
const styleForSession = !style && session?.styleProfileId
? await loadHistoryStyleDetail({ id: session.styleProfileId })
: style;
@@ -985,7 +1117,13 @@ document.addEventListener('DOMContentLoaded', () => {
if (session) {
await applyHistorySession(session);
}
- if (!styleForSession && !session) {
+ if (article) {
+ await applyHistoryArticle(article);
+ }
+ if (draft) {
+ applyHistoryDraft(draft);
+ }
+ if (!styleForSession && !session && !article && !draft && !el.historyProjectSelect.value) {
showError('開く履歴を選択してください');
return;
}
@@ -998,11 +1136,18 @@ document.addEventListener('DOMContentLoaded', () => {
}
function clearHistorySelection() {
+ el.historyProjectSelect.value = '';
+ el.historyArticleSelect.value = '';
+ el.historyDraftSelect.value = '';
el.historyStyleSelect.value = '';
el.historySessionSelect.value = '';
+ state.selectedHistoryProject = null;
+ state.selectedHistoryArticle = null;
+ state.selectedHistoryDraft = null;
state.selectedHistoryStyle = null;
state.selectedHistorySession = null;
renderHistoryPicker();
+ renderHistoryArticleDetail();
}
async function loadHistoryStyleDetail(item) {
@@ -1027,6 +1172,81 @@ document.addEventListener('DOMContentLoaded', () => {
return normalizeHistorySession({ ...item, ...data });
}
+ async function loadHistoryProjectDetail(item) {
+ if (!item) {
+ return null;
+ }
+ const normalized = normalizeHistoryProject(item);
+ if (normalized.articles.length) {
+ return normalized;
+ }
+ const data = await requestFirstJSON([
+ `/api/projects/${encodeURIComponent(normalized.id)}`,
+ `/api/history/projects/${encodeURIComponent(normalized.id)}`,
+ ]);
+ return normalizeHistoryProject({ ...item, ...data });
+ }
+
+ async function loadHistoryArticleDetail(item) {
+ if (!item) {
+ return null;
+ }
+ const normalized = normalizeHistoryArticle(item);
+ if (hasArticleDetail(normalized)) {
+ return normalized;
+ }
+ const urls = [`/api/articles/${encodeURIComponent(normalized.id)}`];
+ if (normalized.projectId) {
+ urls.push(`/api/projects/${encodeURIComponent(normalized.projectId)}/articles/${encodeURIComponent(normalized.id)}`);
+ }
+ if (normalized.briefId) {
+ urls.push(`/api/briefs/${encodeURIComponent(normalized.briefId)}`);
+ }
+ urls.push(`/api/history/articles/${encodeURIComponent(normalized.id)}`);
+ const data = await requestFirstJSON(urls);
+ const detail = data && !data.article && !data.Article && (data.brief || data.Brief || data.theme || data.Theme)
+ ? { brief: data }
+ : data;
+ return normalizeHistoryArticle({ ...item, ...detail });
+ }
+
+ async function loadHistoryDraftDetail(item) {
+ if (!item) {
+ return null;
+ }
+ const normalized = normalizeHistoryDraft(item);
+ if (normalized.markdown || normalized.summary) {
+ return normalized;
+ }
+ const data = await requestFirstJSON([
+ `/api/drafts/${encodeURIComponent(normalized.id)}`,
+ `/api/history/drafts/${encodeURIComponent(normalized.id)}`,
+ ]);
+ return normalizeHistoryDraft({ ...item, ...data });
+ }
+
+ async function requestFirstJSON(urls) {
+ let lastError = null;
+ for (const url of urls.filter(Boolean)) {
+ try {
+ return await requestJSON(url);
+ } catch (error) {
+ lastError = error;
+ }
+ }
+ throw lastError || new Error('履歴詳細APIが見つかりません');
+ }
+
+ function hasArticleDetail(article) {
+ return Boolean(
+ article.brief
+ || article.currentDraft?.markdown
+ || article.currentDraft?.summary
+ || article.draftVersions.length
+ || article.sourceSnapshot,
+ );
+ }
+
function applyHistoryStyle(item) {
const data = normalizeHistoryStyle(item);
state.profileId = data.profileId || data.id;
@@ -1082,12 +1302,97 @@ document.addEventListener('DOMContentLoaded', () => {
updateSectionControls();
}
+ async function applyHistoryArticle(item) {
+ const data = normalizeHistoryArticle(item);
+ state.selectedHistoryArticle = data;
+ state.profileId = data.styleProfileId || state.profileId;
+ state.sessionId = data.sessionId || state.sessionId;
+ if (data.personaId && state.personas.some((persona) => persona.id === data.personaId)) {
+ el.personaSelect.value = data.personaId;
+ config.mode.persona = data.personaId;
+ }
+ if (data.outputFormatId && state.formats.some((format) => format.id === data.outputFormatId)) {
+ el.formatSelect.value = data.outputFormatId;
+ config.mode.format = data.outputFormatId;
+ }
+ saveConfig();
+ renderModeSummary();
+ await loadQuestionTemplate();
+ if (data.brief) {
+ state.completedBrief = data.brief;
+ renderBriefCard(data.brief);
+ el.briefPreview.textContent = JSON.stringify(data.brief, null, 2);
+ el.briefResult.classList.remove('hidden');
+ el.generateDraft.disabled = !state.profileId;
+ }
+ const draft = state.selectedHistoryDraft || data.currentDraft;
+ if (draft) {
+ applyHistoryDraft(draft);
+ }
+ renderHistoryArticleDetail();
+ }
+
+ function applyHistoryDraft(item) {
+ const data = normalizeHistoryDraft(item);
+ state.selectedHistoryDraft = data;
+ state.sessionId = data.sessionId || state.sessionId;
+ state.profileId = data.styleProfileId || state.profileId;
+ const draftText = data.markdown || data.summary || '';
+ if (draftText) {
+ el.markdownOutput.value = draftText;
+ syncDraftEditor();
+ el.draftResult.classList.remove('hidden');
+ setActiveTab('preview');
+ }
+ el.draftStatus.textContent = historyDraftResumeText(data);
+ el.generateDraft.disabled = !state.profileId || !state.sessionId;
+ renderHistoryArticleDetail();
+ }
+
function normalizeWorkflowHistory(data, personaId, formatId) {
const source = data || {};
const styleValues = arrayFrom(source.style_guides || source.styleGuides || source.styles || source.author_styles || source.authorStyles || source.profiles);
+ const briefValues = arrayFrom(source.briefs || source.Briefs);
const sessionValues = [
...arrayFrom(source.sessions || source.brief_sessions || source.briefSessions || source.items || (Array.isArray(source) ? source : [])),
- ...arrayFrom(source.briefs || source.Briefs),
+ ...briefValues,
+ ];
+ const projectValues = arrayFrom(source.projects || source.Projects);
+ const projectArticleValues = projectValues.flatMap((project) => arrayFrom(project.articles || project.Articles)
+ .map((article) => ({ project_id: project.id || project.ID || project.project_id || project.projectId, ...article })));
+ const sourceSnapshotValues = arrayFrom(source.source_snapshots || source.sourceSnapshots || source.SourceSnapshots);
+ const briefArticleValues = briefValues.map((brief) => ({
+ article_id: brief.article_id || brief.articleId || brief.session_id || brief.sessionId || brief.id || brief.ID,
+ persona_id: brief.persona_id || brief.personaId,
+ output_format_id: brief.output_format_id || brief.outputFormatId,
+ brief,
+ }));
+ const snapshotArticleValues = sourceSnapshotValues.map((snapshot) => ({
+ article_id: snapshot.article_id || snapshot.articleId || snapshot.id || snapshot.ID,
+ persona_id: snapshot.persona_id || snapshot.personaId,
+ output_format_id: snapshot.output_format_id || snapshot.outputFormatId,
+ source_snapshot: snapshot,
+ }));
+ const articleValues = [
+ ...arrayFrom(source.articles || source.Articles),
+ ...arrayFrom(source.article_history || source.articleHistory),
+ ...projectArticleValues,
+ ...briefArticleValues,
+ ...snapshotArticleValues,
+ ];
+ const articleDraftValues = articleValues.flatMap((article) => [
+ ...arrayFrom(article.drafts || article.Drafts || article.draft_versions || article.draftVersions),
+ ...[article.current_draft || article.currentDraft || article.draft || article.Draft].filter(Boolean),
+ ].map((draft) => ({
+ article_id: article.id || article.ID || article.article_id || article.articleId,
+ persona_id: article.persona_id || article.personaId,
+ output_format_id: article.output_format_id || article.outputFormatId,
+ ...draft,
+ })));
+ const draftValues = [
+ ...arrayFrom(source.drafts || source.Drafts),
+ ...arrayFrom(source.draft_versions || source.draftVersions),
+ ...articleDraftValues,
];
return {
styles: styleValues.map(normalizeHistoryStyle)
@@ -1096,6 +1401,15 @@ document.addEventListener('DOMContentLoaded', () => {
sessions: uniqueHistoryItems(sessionValues.map(normalizeHistorySession)
.filter((item) => item.id)
.filter((item) => historyItemMatches(item, personaId, formatId))),
+ projects: uniqueHistoryItems(projectValues.map(normalizeHistoryProject)
+ .filter((item) => item.id)
+ .filter((item) => historyItemMatches(item, personaId, formatId))),
+ articles: uniqueHistoryItems(articleValues.map(normalizeHistoryArticle)
+ .filter((item) => item.id)
+ .filter((item) => historyItemMatches(item, personaId, formatId))),
+ drafts: uniqueHistoryItems(draftValues.map(normalizeHistoryDraft)
+ .filter((item) => item.id)
+ .filter((item) => historyItemMatches(item, personaId, formatId))),
};
}
@@ -1144,6 +1458,89 @@ document.addEventListener('DOMContentLoaded', () => {
};
}
+ function normalizeHistoryProject(item = {}) {
+ const project = item.project || item.Project || {};
+ const id = String(item.project_id || item.projectId || project.id || project.ID || item.id || item.ID || '').trim();
+ return {
+ ...item,
+ id,
+ title: item.title || item.name || item.label || item.display_name || item.displayName || project.title || project.Title || project.name || project.Name || id,
+ personaId: item.persona_id || item.personaId || project.persona_id || project.PersonaID || '',
+ outputFormatId: item.output_format_id || item.outputFormatId || project.output_format_id || project.OutputFormatID || '',
+ status: item.status || item.Status || project.status || project.Status || '',
+ articleCount: item.article_count ?? item.articleCount ?? project.article_count ?? project.ArticleCount,
+ articles: arrayFrom(item.articles || item.Articles || project.articles || project.Articles).map((article) => normalizeHistoryArticle({ project_id: id, ...article })),
+ updatedAt: item.updated_at || item.updatedAt || item.created_at || item.createdAt || project.updated_at || project.UpdatedAt || '',
+ createdAt: item.created_at || item.createdAt || project.created_at || project.CreatedAt || '',
+ };
+ }
+
+ function normalizeHistoryArticle(item = {}) {
+ const article = item.article || item.Article || {};
+ const brief = item.brief || item.Brief || item.article_brief || item.articleBrief || article.brief || article.Brief || null;
+ const currentDraft = normalizeMaybeDraft(item.current_draft || item.currentDraft || item.draft || item.Draft || article.current_draft || article.CurrentDraft || null);
+ const draftVersions = arrayFrom(item.draft_versions || item.draftVersions || item.drafts || item.Drafts || article.draft_versions || article.DraftVersions || article.drafts || article.Drafts)
+ .map((draft) => normalizeHistoryDraft({ article_id: item.article_id || item.articleId || article.id || article.ID || item.id || item.ID, ...draft }))
+ .filter((draft) => draft.id || draft.markdown || draft.summary);
+ const id = String(item.article_id || item.articleId || article.id || article.ID || item.id || item.ID || '').trim();
+ const title = item.title || item.name || article.title || article.Title || briefField(brief, 'theme', 'Theme') || currentDraft?.title || id;
+ return {
+ ...item,
+ id,
+ projectId: item.project_id || item.projectId || article.project_id || article.ProjectID || '',
+ title,
+ theme: briefField(brief, 'theme', 'Theme'),
+ personaId: item.persona_id || item.personaId || article.persona_id || article.PersonaID || briefField(brief, 'persona_id', 'PersonaID') || '',
+ outputFormatId: item.output_format_id || item.outputFormatId || article.output_format_id || article.OutputFormatID || briefField(brief, 'output_format_id', 'OutputFormatID') || '',
+ styleProfileId: item.style_profile_id || item.styleProfileId || article.style_profile_id || article.StyleProfileID || briefField(brief, 'style_profile_id', 'StyleProfileID') || '',
+ sessionId: item.session_id || item.sessionId || item.brief_session_id || item.briefSessionId || article.session_id || article.SessionID || '',
+ briefId: item.brief_id || item.briefId || article.brief_id || article.BriefID || '',
+ status: item.status || item.Status || item.phase || item.Phase || article.status || article.Status || '',
+ brief,
+ currentDraft,
+ draftVersions,
+ sourceSnapshot: item.source_snapshot || item.sourceSnapshot || article.source_snapshot || article.SourceSnapshot || null,
+ updatedAt: item.updated_at || item.updatedAt || item.created_at || item.createdAt || article.updated_at || article.UpdatedAt || '',
+ createdAt: item.created_at || item.createdAt || article.created_at || article.CreatedAt || '',
+ };
+ }
+
+ function normalizeMaybeDraft(draft) {
+ if (!draft) {
+ return null;
+ }
+ const normalized = normalizeHistoryDraft(draft);
+ return normalized.id || normalized.markdown || normalized.summary ? normalized : null;
+ }
+
+ function normalizeHistoryDraft(item = {}) {
+ const draft = item.draft || item.Draft || {};
+ const markdown = item.markdown || item.Markdown || item.draft_markdown || item.draftMarkdown || item.text || item.Text || item.body || item.Body || item.content || item.Content || draft.markdown || draft.Markdown || draft.text || draft.Text || '';
+ const id = String(item.draft_id || item.draftId || draft.id || draft.ID || item.id || item.ID || '').trim();
+ return {
+ ...item,
+ id,
+ articleId: item.article_id || item.articleId || draft.article_id || draft.ArticleID || '',
+ personaId: item.persona_id || item.personaId || draft.persona_id || draft.PersonaID || '',
+ outputFormatId: item.output_format_id || item.outputFormatId || draft.output_format_id || draft.OutputFormatID || '',
+ sessionId: item.session_id || item.sessionId || item.brief_session_id || item.briefSessionId || draft.session_id || draft.SessionID || '',
+ styleProfileId: item.style_profile_id || item.styleProfileId || draft.style_profile_id || draft.StyleProfileID || '',
+ title: item.title || item.name || draft.title || draft.Title || markdownTitle(markdown) || id,
+ version: item.version ?? item.Version ?? item.attempt ?? item.Attempt ?? item.revision ?? item.Revision ?? '',
+ kind: item.kind || item.Kind || '',
+ status: item.status || item.Status || '',
+ markdown,
+ summary: item.summary || item.Summary || draft.summary || draft.Summary || '',
+ score: item.score ?? item.Score ?? item.quality_gate?.score ?? item.qualityGate?.score ?? draft.score ?? draft.Score,
+ passed: item.passed ?? item.Passed ?? item.evaluation?.passed ?? item.Evaluation?.Passed,
+ runes: item.runes ?? item.Runes ?? item.quality_gate?.runes ?? item.qualityGate?.runes,
+ validationError: item.validation_error || item.validationError || item.ValidationError || '',
+ verification: item.verification || item.Verification || null,
+ updatedAt: item.updated_at || item.updatedAt || item.created_at || item.createdAt || draft.updated_at || draft.UpdatedAt || '',
+ createdAt: item.created_at || item.createdAt || draft.created_at || draft.CreatedAt || '',
+ };
+ }
+
function normalizeHistoryQuestion(question) {
if (!question) {
return null;
@@ -1172,6 +1569,37 @@ document.addEventListener('DOMContentLoaded', () => {
return state.historySessions.find((item) => item.id === id) || null;
}
+ function findHistoryProject(id) {
+ return state.historyProjects.find((item) => item.id === id) || null;
+ }
+
+ function findHistoryArticle(id) {
+ return state.historyArticles.find((item) => item.id === id) || null;
+ }
+
+ function findHistoryDraft(id) {
+ return state.historyDrafts.find((item) => item.id === id) || null;
+ }
+
+ function filteredHistoryArticles() {
+ const projectId = el.historyProjectSelect.value;
+ if (!projectId) {
+ return state.historyArticles;
+ }
+ return state.historyArticles.filter((article) => !article.projectId || article.projectId === projectId);
+ }
+
+ function filteredHistoryDrafts() {
+ const articleId = el.historyArticleSelect.value;
+ if (!articleId) {
+ return state.historyDrafts;
+ }
+ const article = state.selectedHistoryArticle || findHistoryArticle(articleId);
+ const articleDrafts = article?.draftVersions || [];
+ const globalDrafts = state.historyDrafts.filter((draft) => !draft.articleId || draft.articleId === articleId);
+ return uniqueHistoryItems([...articleDrafts, ...globalDrafts]);
+ }
+
function uniqueHistoryItems(items) {
const byId = new Map();
items.forEach((item) => {
@@ -1183,6 +1611,248 @@ document.addEventListener('DOMContentLoaded', () => {
return [...byId.values()];
}
+ function mergeProjectDetailIntoHistory(project) {
+ if (!project) {
+ return;
+ }
+ state.historyProjects = uniqueHistoryItems([project, ...state.historyProjects]);
+ if (project.articles.length) {
+ state.historyArticles = uniqueHistoryItems([...project.articles, ...state.historyArticles]);
+ }
+ }
+
+ function mergeArticleDetailIntoHistory(article) {
+ if (!article) {
+ return;
+ }
+ state.historyArticles = uniqueHistoryItems([article, ...state.historyArticles]);
+ if (article.draftVersions.length) {
+ state.historyDrafts = uniqueHistoryItems([...article.draftVersions, ...state.historyDrafts]);
+ }
+ if (article.currentDraft) {
+ state.historyDrafts = uniqueHistoryItems([article.currentDraft, ...state.historyDrafts]);
+ }
+ }
+
+ function mergeDraftDetailIntoHistory(draft) {
+ if (!draft) {
+ return;
+ }
+ state.historyDrafts = uniqueHistoryItems([draft, ...state.historyDrafts]);
+ }
+
+ function renderHistoryArticleDetail(options = {}) {
+ renderHistoryProjectCard(state.selectedHistoryProject, options.warning);
+ renderHistoryArticleCard(state.selectedHistoryArticle, options.warning);
+ const article = state.selectedHistoryArticle;
+ const selectedDraft = state.selectedHistoryDraft;
+ renderHistoryBriefCard(article?.brief || null);
+ renderHistoryCurrentDraftCard(selectedDraft || article?.currentDraft || null);
+ renderHistoryDraftVersionsCard(article?.draftVersions || filteredHistoryDrafts());
+ renderHistorySourceSnapshotCard(article?.sourceSnapshot || null);
+ }
+
+ function renderHistoryProjectCard(project, warning) {
+ if (!project) {
+ fillArtifactCard(el.historyProjectCard, null, [], [], 'プロジェクトを選択すると、記事一覧と更新状況をここに表示します。');
+ return;
+ }
+ fillArtifactCard(
+ el.historyProjectCard,
+ project.title || 'プロジェクト',
+ [
+ ['Project', project.id],
+ ['Persona', project.personaId],
+ ['Format', project.outputFormatId],
+ ['Status', project.status],
+ ['Updated', formatDateTime(project.updatedAt)],
+ ],
+ [
+ ['記事', [`${project.articleCount ?? project.articles.length ?? 0}件`]],
+ warning ? ['注意', [warning]] : null,
+ ].filter(Boolean),
+ );
+ }
+
+ function renderHistoryArticleCard(article, warning) {
+ if (!article) {
+ fillArtifactCard(el.historyArticleCard, null, [], warning ? [['注意', [warning]]] : [], '記事を選択すると、ブリーフ、下書き、参照ソースの要約をここに表示します。');
+ return;
+ }
+ fillArtifactCard(
+ el.historyArticleCard,
+ article.title || '記事',
+ [
+ ['Article', article.id],
+ ['Project', article.projectId],
+ ['Persona', article.personaId],
+ ['Format', article.outputFormatId],
+ ['Status', article.status],
+ ['Updated', formatDateTime(article.updatedAt)],
+ ],
+ [
+ ['ブリーフ', [article.brief ? briefField(article.brief, 'theme', 'Theme') || '保存済み' : '未接続または未作成']],
+ ['下書き', [`${article.draftVersions.length}件のバージョン${article.currentDraft ? ' / 現在版あり' : ''}`]],
+ warning ? ['注意', [warning]] : null,
+ ].filter(Boolean),
+ );
+ }
+
+ function renderHistoryBriefCard(brief) {
+ if (!brief) {
+ fillArtifactCard(el.historyArticleBriefCard, null, [], [], '記事ブリーフはまだありません。詳細API接続後、テーマ、読者、含める内容を要約表示します。');
+ return;
+ }
+ fillArtifactCard(
+ el.historyArticleBriefCard,
+ briefField(brief, 'theme', 'Theme') || '記事ブリーフ',
+ [
+ ['Persona', briefField(brief, 'persona_id', 'PersonaID')],
+ ['Format', briefField(brief, 'output_format_id', 'OutputFormatID')],
+ ['Style', briefField(brief, 'style_profile_id', 'StyleProfileID')],
+ ],
+ [
+ ['読者', [briefField(brief, 'reader', 'Reader')]],
+ ['冒頭の具体例', [briefField(brief, 'opening_episode', 'OpeningEpisode')]],
+ ['必ず含めること', [briefField(brief, 'must_include', 'MustInclude')]],
+ ['読後アクション', [briefField(brief, 'expected_reader_action', 'ExpectedReaderAction')]],
+ ],
+ );
+ }
+
+ function renderHistoryCurrentDraftCard(draft) {
+ if (!draft) {
+ fillArtifactCard(el.historyCurrentDraftCard, null, [], [], '現在の下書きはまだありません。下書き詳細API接続後、本文の冒頭と評価を表示します。');
+ return;
+ }
+ fillArtifactCard(
+ el.historyCurrentDraftCard,
+ draft.title || '現在の下書き',
+ [
+ ['Draft', draft.id],
+ ['Version', draft.version],
+ ['Score', draft.score === undefined ? '' : Number(draft.score).toFixed(1)],
+ ['Status', draft.status || draft.kind],
+ ['Updated', formatDateTime(draft.updatedAt)],
+ ],
+ [
+ ['本文要約', summarizeDraftLines(draft, 5)],
+ draft.validationError ? ['検証メモ', [draft.validationError]] : null,
+ ].filter(Boolean),
+ );
+ }
+
+ function renderHistoryDraftVersionsCard(drafts) {
+ const versions = arrayFrom(drafts).filter((draft) => draft.id || draft.markdown || draft.summary);
+ if (!versions.length) {
+ fillArtifactCard(el.historyDraftVersionsCard, null, [], [], '下書きバージョンはまだありません。保存済みバージョンがあると番号、評価、更新日時を一覧表示します。');
+ return;
+ }
+ fillArtifactCard(
+ el.historyDraftVersionsCard,
+ '下書きバージョン',
+ [['Versions', versions.length]],
+ [['一覧', versions.slice(0, 8).map(historyDraftVersionLine)]],
+ );
+ }
+
+ function renderHistorySourceSnapshotCard(snapshot) {
+ const normalized = normalizeSourceSnapshot(snapshot);
+ if (!normalized) {
+ fillArtifactCard(el.historySourceSnapshotCard, null, [], [], 'ソーススナップショットはまだありません。接続後、参照記事や取得日時を表示します。');
+ return;
+ }
+ fillArtifactCard(
+ el.historySourceSnapshotCard,
+ normalized.title || 'ソーススナップショット',
+ [
+ ['Fetched', formatDateTime(normalized.fetchedAt)],
+ ['Articles', normalized.articles.length],
+ ],
+ [
+ ['参照ソース', normalized.articles.length ? normalized.articles.slice(0, 6).map(sourceArticleLine) : [normalized.summary]],
+ ],
+ );
+ }
+
+ function fillArtifactCard(card, title, metaItems, sections, emptyText) {
+ card.innerHTML = '';
+ if (!title && !arrayFrom(sections).length) {
+ card.className = 'artifact-card empty';
+ card.textContent = emptyText;
+ return;
+ }
+ card.className = 'artifact-card';
+ if (title) {
+ card.appendChild(createArtifactHeader(title, metaItems));
+ }
+ arrayFrom(sections).forEach(([sectionTitle, values]) => {
+ const visibleValues = arrayFrom(values).filter((value) => String(value || '').trim());
+ if (visibleValues.length) {
+ card.appendChild(createArtifactSection(sectionTitle, visibleValues));
+ }
+ });
+ }
+
+ function normalizeSourceSnapshot(snapshot) {
+ if (!snapshot) {
+ return null;
+ }
+ if (Array.isArray(snapshot)) {
+ return {
+ title: 'ソーススナップショット',
+ fetchedAt: '',
+ summary: '',
+ articles: snapshot,
+ };
+ }
+ if (typeof snapshot === 'string') {
+ return {
+ title: 'ソーススナップショット',
+ fetchedAt: '',
+ summary: snapshot,
+ articles: [],
+ };
+ }
+ const articles = arrayFrom(snapshot.articles || snapshot.Articles || snapshot.sources || snapshot.Sources);
+ return {
+ title: snapshot.title || snapshot.Title || snapshot.source_selector || snapshot.sourceSelector || '',
+ fetchedAt: snapshot.fetched_at || snapshot.fetchedAt || snapshot.created_at || snapshot.createdAt || '',
+ summary: snapshot.summary || snapshot.Summary || snapshot.url || snapshot.URL || '',
+ articles,
+ };
+ }
+
+ function sourceArticleLine(article) {
+ const title = article.title || article.Title || article.id || article.ID || '参照記事';
+ const url = article.url || article.URL || '';
+ const fetchedAt = formatDateTime(article.fetched_at || article.fetchedAt || article.at || article.At);
+ return [title, url, fetchedAt].filter(Boolean).join(' / ');
+ }
+
+ function summarizeDraftLines(draft, limit) {
+ return compactTextLines(draft.markdown || draft.summary || '', limit).map((line) => line.replace(/^#{1,6}\s+/, ''));
+ }
+
+ function historyDraftVersionLine(draft) {
+ const title = draft.title || draft.id || '下書き';
+ const version = draft.version ? `v${draft.version}` : draft.kind || '';
+ const score = draft.score === undefined ? '' : `score ${Number(draft.score).toFixed(1)}`;
+ const updatedAt = formatDateTime(draft.updatedAt || draft.createdAt);
+ return [version, title, score, updatedAt].filter(Boolean).join(' / ');
+ }
+
+ function historyDraftResumeText(draft) {
+ const score = draft.score === undefined ? '' : ` / style score ${Number(draft.score).toFixed(1)}`;
+ const version = draft.version ? ` v${draft.version}` : '';
+ return `保存済み下書き${version}をMarkdownエディタに反映しました${score}。`;
+ }
+
+ function markdownTitle(markdown) {
+ const heading = String(markdown || '').split('\n').find((line) => line.trim().startsWith('# '));
+ return heading ? heading.replace(/^#\s+/, '').trim() : '';
+ }
+
function applyPersonaDefaults(forceFormat) {
const persona = currentPersona();