@topogram/cli 0.3.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +67 -0
- package/CHANGELOG.md +240 -0
- package/README.md +223 -0
- package/package.json +51 -0
- package/src/adoption/index.js +3 -0
- package/src/adoption/plan.js +702 -0
- package/src/adoption/reporting.js +464 -0
- package/src/adoption/review-groups.js +313 -0
- package/src/agent-ops/query-builders.js +5012 -0
- package/src/archive/archive.js +141 -0
- package/src/archive/compact.js +26 -0
- package/src/archive/jsonl.js +70 -0
- package/src/archive/resolver-bridge.js +82 -0
- package/src/archive/schema.js +87 -0
- package/src/archive/unarchive.js +108 -0
- package/src/catalog.js +752 -0
- package/src/cli/catalog-alias.js +166 -0
- package/src/cli.js +9738 -0
- package/src/component-behavior.js +173 -0
- package/src/example-implementation.js +91 -0
- package/src/format.js +19 -0
- package/src/generator/adapters.d.ts +4 -0
- package/src/generator/adapters.js +325 -0
- package/src/generator/api.d.ts +1 -0
- package/src/generator/api.js +1196 -0
- package/src/generator/check.js +355 -0
- package/src/generator/component-conformance.js +767 -0
- package/src/generator/components.js +39 -0
- package/src/generator/context/bundle.js +291 -0
- package/src/generator/context/diff.js +256 -0
- package/src/generator/context/digest.js +182 -0
- package/src/generator/context/domain-coverage.js +94 -0
- package/src/generator/context/domain-page.js +137 -0
- package/src/generator/context/index.js +42 -0
- package/src/generator/context/report.js +121 -0
- package/src/generator/context/shared.js +1397 -0
- package/src/generator/context/slice.js +703 -0
- package/src/generator/context/task-mode.js +466 -0
- package/src/generator/docs.js +327 -0
- package/src/generator/index.js +161 -0
- package/src/generator/native/parity-bundle.js +311 -0
- package/src/generator/output.js +300 -0
- package/src/generator/registry.js +482 -0
- package/src/generator/runtime/app-bundle.js +456 -0
- package/src/generator/runtime/bundle-shared.js +166 -0
- package/src/generator/runtime/compile-check.js +163 -0
- package/src/generator/runtime/deployment.js +287 -0
- package/src/generator/runtime/environment.js +635 -0
- package/src/generator/runtime/index.js +32 -0
- package/src/generator/runtime/runtime-check.js +554 -0
- package/src/generator/runtime/shared.js +515 -0
- package/src/generator/runtime/smoke.js +219 -0
- package/src/generator/schema.js +204 -0
- package/src/generator/sdlc/board.js +66 -0
- package/src/generator/sdlc/doc-page.js +53 -0
- package/src/generator/sdlc/index.js +23 -0
- package/src/generator/sdlc/release-notes.js +62 -0
- package/src/generator/sdlc/traceability-matrix.js +65 -0
- package/src/generator/shared.js +29 -0
- package/src/generator/surfaces/contracts.js +146 -0
- package/src/generator/surfaces/databases/contract.js +40 -0
- package/src/generator/surfaces/databases/index.js +84 -0
- package/src/generator/surfaces/databases/lifecycle-shared.d.ts +1 -0
- package/src/generator/surfaces/databases/lifecycle-shared.js +612 -0
- package/src/generator/surfaces/databases/migration-plan.js +281 -0
- package/src/generator/surfaces/databases/postgres/capabilities.js +14 -0
- package/src/generator/surfaces/databases/postgres/drizzle.js +99 -0
- package/src/generator/surfaces/databases/postgres/index.js +9 -0
- package/src/generator/surfaces/databases/postgres/lifecycle.js +16 -0
- package/src/generator/surfaces/databases/postgres/prisma.js +159 -0
- package/src/generator/surfaces/databases/postgres/sql-migration.js +102 -0
- package/src/generator/surfaces/databases/postgres/sql-schema.js +34 -0
- package/src/generator/surfaces/databases/shared.d.ts +1 -0
- package/src/generator/surfaces/databases/shared.js +350 -0
- package/src/generator/surfaces/databases/snapshot.js +96 -0
- package/src/generator/surfaces/databases/sqlite/capabilities.js +14 -0
- package/src/generator/surfaces/databases/sqlite/index.js +8 -0
- package/src/generator/surfaces/databases/sqlite/lifecycle.js +16 -0
- package/src/generator/surfaces/databases/sqlite/prisma.js +143 -0
- package/src/generator/surfaces/databases/sqlite/sql-migration.js +65 -0
- package/src/generator/surfaces/databases/sqlite/sql-schema.js +27 -0
- package/src/generator/surfaces/index.js +25 -0
- package/src/generator/surfaces/native/swiftui-app.js +38 -0
- package/src/generator/surfaces/native/swiftui-templates/Package.swift.txt +20 -0
- package/src/generator/surfaces/native/swiftui-templates/README.generated.md +26 -0
- package/src/generator/surfaces/native/swiftui-templates/runtime/DynamicScreens.swift +682 -0
- package/src/generator/surfaces/native/swiftui-templates/runtime/TodoAPIClient.swift +156 -0
- package/src/generator/surfaces/native/swiftui-templates/runtime/TodoSwiftUIApp.swift +44 -0
- package/src/generator/surfaces/native/swiftui-templates/runtime/Visibility.swift +183 -0
- package/src/generator/surfaces/services/express.d.ts +1 -0
- package/src/generator/surfaces/services/express.js +766 -0
- package/src/generator/surfaces/services/hono.d.ts +1 -0
- package/src/generator/surfaces/services/hono.js +204 -0
- package/src/generator/surfaces/services/index.js +42 -0
- package/src/generator/surfaces/services/persistence-wiring.js +240 -0
- package/src/generator/surfaces/services/runtime-helpers.js +631 -0
- package/src/generator/surfaces/services/server-contract.js +80 -0
- package/src/generator/surfaces/services/stateless.d.ts +1 -0
- package/src/generator/surfaces/services/stateless.js +97 -0
- package/src/generator/surfaces/shared.js +64 -0
- package/src/generator/surfaces/web/api-client.js +1 -0
- package/src/generator/surfaces/web/forms.js +1 -0
- package/src/generator/surfaces/web/index.d.ts +2 -0
- package/src/generator/surfaces/web/index.js +53 -0
- package/src/generator/surfaces/web/react-components.js +248 -0
- package/src/generator/surfaces/web/react.js +538 -0
- package/src/generator/surfaces/web/routes.js +1 -0
- package/src/generator/surfaces/web/screens.js +1 -0
- package/src/generator/surfaces/web/shared.js +369 -0
- package/src/generator/surfaces/web/sveltekit-actions.js +28 -0
- package/src/generator/surfaces/web/sveltekit-components.js +234 -0
- package/src/generator/surfaces/web/sveltekit.js +426 -0
- package/src/generator/surfaces/web/ui-web-contract.js +65 -0
- package/src/generator/surfaces/web/vanilla.js +239 -0
- package/src/generator/verification.js +84 -0
- package/src/generator.js +1 -0
- package/src/import/core/context.js +52 -0
- package/src/import/core/contracts.js +23 -0
- package/src/import/core/registry.js +81 -0
- package/src/import/core/runner.js +646 -0
- package/src/import/core/shared.js +910 -0
- package/src/import/enrichers/auth-session.js +18 -0
- package/src/import/enrichers/django-rest.js +226 -0
- package/src/import/enrichers/doc-linking.js +20 -0
- package/src/import/enrichers/rails-controllers.js +246 -0
- package/src/import/enrichers/rails-models.js +130 -0
- package/src/import/enrichers/workflow-target-state.js +10 -0
- package/src/import/extractors/api/aspnet-core.js +304 -0
- package/src/import/extractors/api/django-routes.js +318 -0
- package/src/import/extractors/api/express.js +154 -0
- package/src/import/extractors/api/fastify.js +371 -0
- package/src/import/extractors/api/flutter-dio.js +135 -0
- package/src/import/extractors/api/generic-route-fallback.js +90 -0
- package/src/import/extractors/api/graphql-code-first.js +565 -0
- package/src/import/extractors/api/graphql-sdl.js +309 -0
- package/src/import/extractors/api/jaxrs.js +303 -0
- package/src/import/extractors/api/micronaut.js +213 -0
- package/src/import/extractors/api/next-route.js +50 -0
- package/src/import/extractors/api/next-server-action.js +51 -0
- package/src/import/extractors/api/nextauth.js +52 -0
- package/src/import/extractors/api/openapi-code.js +242 -0
- package/src/import/extractors/api/openapi.js +232 -0
- package/src/import/extractors/api/rails-routes.js +230 -0
- package/src/import/extractors/api/react-native-repository.js +128 -0
- package/src/import/extractors/api/retrofit.js +103 -0
- package/src/import/extractors/api/spring-web.js +372 -0
- package/src/import/extractors/api/swift-webapi.js +116 -0
- package/src/import/extractors/api/trpc.js +212 -0
- package/src/import/extractors/db/django-models.js +232 -0
- package/src/import/extractors/db/dotnet-models.js +93 -0
- package/src/import/extractors/db/drizzle.js +242 -0
- package/src/import/extractors/db/ef-core.js +221 -0
- package/src/import/extractors/db/flutter-entities.js +120 -0
- package/src/import/extractors/db/jpa.js +120 -0
- package/src/import/extractors/db/liquibase.js +180 -0
- package/src/import/extractors/db/mybatis-xml.js +145 -0
- package/src/import/extractors/db/prisma.js +185 -0
- package/src/import/extractors/db/rails-schema.js +175 -0
- package/src/import/extractors/db/react-native-entities.js +95 -0
- package/src/import/extractors/db/room.js +193 -0
- package/src/import/extractors/db/snapshot.js +112 -0
- package/src/import/extractors/db/sql.js +180 -0
- package/src/import/extractors/db/swiftdata.js +137 -0
- package/src/import/extractors/ui/android-compose.js +230 -0
- package/src/import/extractors/ui/backend-only.js +70 -0
- package/src/import/extractors/ui/blazor.js +227 -0
- package/src/import/extractors/ui/flutter-screens.js +152 -0
- package/src/import/extractors/ui/maui-xaml.js +135 -0
- package/src/import/extractors/ui/next-app-router.js +83 -0
- package/src/import/extractors/ui/next-pages-router.js +141 -0
- package/src/import/extractors/ui/razor-pages.js +181 -0
- package/src/import/extractors/ui/react-native-screens.js +166 -0
- package/src/import/extractors/ui/react-router.js +139 -0
- package/src/import/extractors/ui/sveltekit.js +123 -0
- package/src/import/extractors/ui/swiftui.js +193 -0
- package/src/import/extractors/ui/uikit.js +175 -0
- package/src/import/extractors/verification/generic.js +290 -0
- package/src/import/extractors/workflows/generic.js +137 -0
- package/src/import/index.js +7 -0
- package/src/import/provenance.js +158 -0
- package/src/new-project.js +2107 -0
- package/src/parser.js +439 -0
- package/src/policy/review-boundaries.js +165 -0
- package/src/project-config.js +535 -0
- package/src/proofs/backend-parity.js +19 -0
- package/src/proofs/contract-audit.js +220 -0
- package/src/proofs/ios-parity.js +7 -0
- package/src/proofs/issues-parity.js +10 -0
- package/src/proofs/web-parity.js +50 -0
- package/src/realization/api/build-api-realization.js +5 -0
- package/src/realization/api/index.js +1 -0
- package/src/realization/backend/build-backend-runtime-realization.js +82 -0
- package/src/realization/backend/index.d.ts +1 -0
- package/src/realization/backend/index.js +4 -0
- package/src/realization/db/build-db-realization.js +17 -0
- package/src/realization/db/index.js +3 -0
- package/src/realization/db/migration-plan.js +5 -0
- package/src/realization/db/snapshot.js +5 -0
- package/src/realization/ui/build-ui-shared-realization.js +305 -0
- package/src/realization/ui/build-web-realization.js +189 -0
- package/src/realization/ui/index.js +2 -0
- package/src/reconcile/docs.js +280 -0
- package/src/reconcile/index.js +3 -0
- package/src/reconcile/journeys.js +441 -0
- package/src/resolver/docs.js +1 -0
- package/src/resolver/enrich/acceptance-criterion.js +14 -0
- package/src/resolver/enrich/bug.js +12 -0
- package/src/resolver/enrich/component.js +2 -0
- package/src/resolver/enrich/index.js +1 -0
- package/src/resolver/enrich/pitch.js +18 -0
- package/src/resolver/enrich/requirement.js +20 -0
- package/src/resolver/enrich/task.js +16 -0
- package/src/resolver/expressions.js +1 -0
- package/src/resolver/index.js +2422 -0
- package/src/resolver/normalize.js +1 -0
- package/src/resolver.js +1 -0
- package/src/sdlc/adopt.js +65 -0
- package/src/sdlc/check.js +86 -0
- package/src/sdlc/dod/acceptance-criterion.js +22 -0
- package/src/sdlc/dod/bug.js +26 -0
- package/src/sdlc/dod/document.js +23 -0
- package/src/sdlc/dod/index.js +25 -0
- package/src/sdlc/dod/pitch.js +23 -0
- package/src/sdlc/dod/requirement.js +34 -0
- package/src/sdlc/dod/task.js +39 -0
- package/src/sdlc/explain.js +116 -0
- package/src/sdlc/history.js +80 -0
- package/src/sdlc/paths.js +11 -0
- package/src/sdlc/release.js +106 -0
- package/src/sdlc/scaffold.js +89 -0
- package/src/sdlc/status-filter.js +54 -0
- package/src/sdlc/transition.js +112 -0
- package/src/sdlc/transitions/acceptance-criterion.js +28 -0
- package/src/sdlc/transitions/bug.js +31 -0
- package/src/sdlc/transitions/document.js +29 -0
- package/src/sdlc/transitions/index.js +56 -0
- package/src/sdlc/transitions/pitch.js +34 -0
- package/src/sdlc/transitions/requirement.js +31 -0
- package/src/sdlc/transitions/task.js +34 -0
- package/src/template-trust.js +597 -0
- package/src/validator/expressions.js +1 -0
- package/src/validator/index.js +3424 -0
- package/src/validator/kinds.js +346 -0
- package/src/validator/per-kind/acceptance-criterion.js +91 -0
- package/src/validator/per-kind/bug.js +77 -0
- package/src/validator/per-kind/component.js +274 -0
- package/src/validator/per-kind/domain.js +205 -0
- package/src/validator/per-kind/pitch.js +101 -0
- package/src/validator/per-kind/requirement.js +75 -0
- package/src/validator/per-kind/task.js +96 -0
- package/src/validator/registry.js +1 -0
- package/src/validator/utils.js +12 -0
- package/src/validator.js +1 -0
- package/src/workflows.js +7597 -0
- package/src/workspace-docs.js +265 -0
- package/template-helpers/react.js +5 -0
- package/template-helpers/sveltekit.js +5 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
/// Holds decoded ui-web-contract.json for runtime-driven navigation (parity with web bundle).
|
|
4
|
+
public final class TodoUiContract: ObservableObject {
|
|
5
|
+
public let raw: [String: Any]
|
|
6
|
+
|
|
7
|
+
public init(data: Data) throws {
|
|
8
|
+
guard let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
9
|
+
throw NSError(domain: "TodoUiContract", code: 1)
|
|
10
|
+
}
|
|
11
|
+
self.raw = obj
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
public static func loadBundled() throws -> Data {
|
|
15
|
+
guard let url = Bundle.module.url(forResource: "ui-web-contract", withExtension: "json"),
|
|
16
|
+
let data = try? Data(contentsOf: url) else {
|
|
17
|
+
throw NSError(domain: "TodoUiContract", code: 2, userInfo: [NSLocalizedDescriptionKey: "Missing ui-web-contract.json"])
|
|
18
|
+
}
|
|
19
|
+
return data
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public var screens: [[String: Any]] {
|
|
23
|
+
(raw["screens"] as? [[String: Any]]) ?? []
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
public func screen(id: String) -> [String: Any]? {
|
|
27
|
+
screens.first { ($0["id"] as? String) == id }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
public var tabItems: [NavigationItem] {
|
|
31
|
+
guard let nav = raw["navigation"] as? [String: Any],
|
|
32
|
+
let items = nav["items"] as? [[String: Any]] else {
|
|
33
|
+
return []
|
|
34
|
+
}
|
|
35
|
+
return items.compactMap(NavigationItem.init(json:))
|
|
36
|
+
.filter { $0.visible && $0.pattern == "bottom_tabs" }
|
|
37
|
+
.sorted {
|
|
38
|
+
let o0 = Int($0.order ?? "") ?? 0
|
|
39
|
+
let o1 = Int($1.order ?? "") ?? 0
|
|
40
|
+
return o0 < o1
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
public struct NavigationItem: Identifiable {
|
|
46
|
+
public var id: String { screenId }
|
|
47
|
+
public let screenId: String
|
|
48
|
+
public let route: String?
|
|
49
|
+
public let label: String
|
|
50
|
+
public let visible: Bool
|
|
51
|
+
public let pattern: String?
|
|
52
|
+
public let order: String?
|
|
53
|
+
|
|
54
|
+
init?(json: [String: Any]) {
|
|
55
|
+
guard let screenId = json["screenId"] as? String else { return nil }
|
|
56
|
+
self.screenId = screenId
|
|
57
|
+
self.route = json["route"] as? String
|
|
58
|
+
self.label = json["label"] as? String ?? screenId
|
|
59
|
+
self.visible = (json["visible"] as? Bool) ?? false
|
|
60
|
+
self.pattern = json["pattern"] as? String
|
|
61
|
+
self.order = json["order"] as? String
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
public struct RootTabView: View {
|
|
66
|
+
@ObservedObject var contract: TodoUiContract
|
|
67
|
+
let client: TodoAPIClient
|
|
68
|
+
|
|
69
|
+
public init(contract: TodoUiContract, client: TodoAPIClient) {
|
|
70
|
+
self.contract = contract
|
|
71
|
+
self.client = client
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public var body: some View {
|
|
75
|
+
let tabs = contract.tabItems
|
|
76
|
+
TabView {
|
|
77
|
+
ForEach(tabs) { item in
|
|
78
|
+
NavigationStack {
|
|
79
|
+
DynamicScreenView(contract: contract, client: client, rootScreenId: item.screenId)
|
|
80
|
+
}
|
|
81
|
+
.tabItem { Text(item.label) }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
public struct DynamicScreenView: View {
|
|
88
|
+
@ObservedObject var contract: TodoUiContract
|
|
89
|
+
let client: TodoAPIClient
|
|
90
|
+
let rootScreenId: String
|
|
91
|
+
|
|
92
|
+
public init(contract: TodoUiContract, client: TodoAPIClient, rootScreenId: String) {
|
|
93
|
+
self.contract = contract
|
|
94
|
+
self.client = client
|
|
95
|
+
self.rootScreenId = rootScreenId
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
public var body: some View {
|
|
99
|
+
if let screen = contract.screen(id: rootScreenId) {
|
|
100
|
+
ScreenSwitcher(contract: contract, client: client, screen: screen, baseParams: [:])
|
|
101
|
+
} else {
|
|
102
|
+
Text("Unknown screen: \(rootScreenId)")
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
struct ScreenSwitcher: View {
|
|
108
|
+
@ObservedObject var contract: TodoUiContract
|
|
109
|
+
let client: TodoAPIClient
|
|
110
|
+
let screen: [String: Any]
|
|
111
|
+
let baseParams: [String: String]
|
|
112
|
+
|
|
113
|
+
var body: some View {
|
|
114
|
+
let kind = screen["kind"] as? String ?? "list"
|
|
115
|
+
Group {
|
|
116
|
+
switch kind {
|
|
117
|
+
case "list":
|
|
118
|
+
ListScreen(contract: contract, client: client, screen: screen, baseParams: baseParams)
|
|
119
|
+
case "detail":
|
|
120
|
+
DetailScreen(contract: contract, client: client, screen: screen, params: baseParams)
|
|
121
|
+
case "form", "wizard":
|
|
122
|
+
FormScreen(client: client, contract: contract, screen: screen, params: baseParams)
|
|
123
|
+
case "board":
|
|
124
|
+
BoardScreen(contract: contract, client: client, screen: screen, baseParams: baseParams)
|
|
125
|
+
case "calendar":
|
|
126
|
+
CalendarScreen(contract: contract, client: client, screen: screen, baseParams: baseParams)
|
|
127
|
+
case "job_status":
|
|
128
|
+
JobStatusScreen(client: client, contract: contract, screen: screen, params: baseParams)
|
|
129
|
+
default:
|
|
130
|
+
Text("Unsupported kind: \(kind)")
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// MARK: - List
|
|
137
|
+
|
|
138
|
+
struct ListScreen: View {
|
|
139
|
+
@ObservedObject var contract: TodoUiContract
|
|
140
|
+
let client: TodoAPIClient
|
|
141
|
+
let screen: [String: Any]
|
|
142
|
+
let baseParams: [String: String]
|
|
143
|
+
@State private var rows: [[String: Any]] = []
|
|
144
|
+
@State private var errorText: String?
|
|
145
|
+
@State private var filters: [String: String] = [:]
|
|
146
|
+
|
|
147
|
+
var body: some View {
|
|
148
|
+
let title = screen["title"] as? String ?? "List"
|
|
149
|
+
let loadCap = (screen["loadCapability"] as? [String: Any])?["id"] as? String
|
|
150
|
+
let screenId = screen["id"] as? String ?? ""
|
|
151
|
+
|
|
152
|
+
VStack(alignment: .leading, spacing: 8) {
|
|
153
|
+
if screenId == "task_list" {
|
|
154
|
+
filterBar
|
|
155
|
+
}
|
|
156
|
+
if let errorText {
|
|
157
|
+
Text(errorText).foregroundStyle(.red)
|
|
158
|
+
} else if rows.isEmpty {
|
|
159
|
+
Text((screen["emptyState"] as? [String: Any])?["title"] as? String ?? "Empty")
|
|
160
|
+
} else {
|
|
161
|
+
List(0 ..< rows.count, id: \.self) { i in
|
|
162
|
+
let row = rows[i]
|
|
163
|
+
NavigationLink {
|
|
164
|
+
linkedDetail(row: row, listScreenId: screenId)
|
|
165
|
+
} label: {
|
|
166
|
+
VStack(alignment: .leading) {
|
|
167
|
+
Text(rowTitle(row))
|
|
168
|
+
.font(.headline)
|
|
169
|
+
Text(rowSubtitle(row))
|
|
170
|
+
.font(.caption)
|
|
171
|
+
.foregroundStyle(.secondary)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
.navigationTitle(title)
|
|
178
|
+
.toolbar {
|
|
179
|
+
if let primary = (screen["actions"] as? [String: Any])?["primary"] as? [String: Any],
|
|
180
|
+
let pid = primary["id"] as? String,
|
|
181
|
+
Visibility.canShowAction(pid, screen: screen, resource: nil) {
|
|
182
|
+
ToolbarItem(placement: .primaryAction) {
|
|
183
|
+
NavigationLink("Add") {
|
|
184
|
+
linkedCreate(listScreenId: screenId)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if screenId == "task_list", Visibility.canShowAction("cap_export_tasks", screen: screen, resource: nil) {
|
|
189
|
+
ToolbarItem(placement: .automatic) {
|
|
190
|
+
ExportTasksButton(client: client, contract: contract)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
.task {
|
|
195
|
+
if let loadCap {
|
|
196
|
+
await reload(loadCap: loadCap)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@ViewBuilder
|
|
202
|
+
private var filterBar: some View {
|
|
203
|
+
VStack(alignment: .leading) {
|
|
204
|
+
Text("Filters").font(.caption).foregroundStyle(.secondary)
|
|
205
|
+
HStack {
|
|
206
|
+
TextField("project_id", text: bindingFilter("project_id"))
|
|
207
|
+
TextField("owner_id", text: bindingFilter("owner_id"))
|
|
208
|
+
TextField("status", text: bindingFilter("status"))
|
|
209
|
+
}
|
|
210
|
+
.textFieldStyle(.roundedBorder)
|
|
211
|
+
Button("Apply") {
|
|
212
|
+
Task {
|
|
213
|
+
await reload(loadCap: "cap_list_tasks")
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
.padding(.horizontal)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private func bindingFilter(_ key: String) -> Binding<String> {
|
|
221
|
+
Binding(
|
|
222
|
+
get: { filters[key] ?? "" },
|
|
223
|
+
set: { filters[key] = $0 }
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
@MainActor
|
|
228
|
+
private func reload(loadCap: String) async {
|
|
229
|
+
errorText = nil
|
|
230
|
+
do {
|
|
231
|
+
let input = baseParams.merging(filters) { _, new in new }
|
|
232
|
+
let json = try await client.requestCapability(loadCap, input: input)
|
|
233
|
+
rows = client.extractRows(from: json)
|
|
234
|
+
} catch {
|
|
235
|
+
errorText = String(describing: error)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
@ViewBuilder
|
|
240
|
+
private func linkedDetail(row: [String: Any], listScreenId: String) -> some View {
|
|
241
|
+
let rid = row["id"] as? String ?? ""
|
|
242
|
+
if listScreenId.contains("task") {
|
|
243
|
+
DetailScreen(contract: contract, client: client, screen: contract.screen(id: "task_detail") ?? [:], params: ["task_id": rid])
|
|
244
|
+
} else if listScreenId.contains("project") {
|
|
245
|
+
DetailScreen(contract: contract, client: client, screen: contract.screen(id: "project_detail") ?? [:], params: ["project_id": rid])
|
|
246
|
+
} else if listScreenId.contains("user") {
|
|
247
|
+
DetailScreen(contract: contract, client: client, screen: contract.screen(id: "user_detail") ?? [:], params: ["user_id": rid])
|
|
248
|
+
} else {
|
|
249
|
+
Text("Detail")
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
@ViewBuilder
|
|
254
|
+
private func linkedCreate(listScreenId: String) -> some View {
|
|
255
|
+
switch listScreenId {
|
|
256
|
+
case "project_list":
|
|
257
|
+
FormScreen(client: client, contract: contract, screen: contract.screen(id: "project_create") ?? [:], params: [:])
|
|
258
|
+
case "user_list":
|
|
259
|
+
FormScreen(client: client, contract: contract, screen: contract.screen(id: "user_create") ?? [:], params: [:])
|
|
260
|
+
case "task_list", "task_board", "task_calendar":
|
|
261
|
+
FormScreen(client: client, contract: contract, screen: contract.screen(id: "task_create") ?? [:], params: [:])
|
|
262
|
+
default:
|
|
263
|
+
Text("Create")
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private func rowTitle(_ row: [String: Any]) -> String {
|
|
269
|
+
(row["title"] as? String)
|
|
270
|
+
?? (row["name"] as? String)
|
|
271
|
+
?? (row["display_name"] as? String)
|
|
272
|
+
?? ((row["id"] as? String) ?? "row")
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private func rowSubtitle(_ row: [String: Any]) -> String {
|
|
276
|
+
(row["status"] as? String) ?? ""
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// MARK: - Detail
|
|
280
|
+
|
|
281
|
+
struct DetailScreen: View {
|
|
282
|
+
@ObservedObject var contract: TodoUiContract
|
|
283
|
+
let client: TodoAPIClient
|
|
284
|
+
let screen: [String: Any]
|
|
285
|
+
let params: [String: String]
|
|
286
|
+
@State private var payload: [String: Any]?
|
|
287
|
+
@State private var errorText: String?
|
|
288
|
+
|
|
289
|
+
var body: some View {
|
|
290
|
+
Group {
|
|
291
|
+
if let errorText {
|
|
292
|
+
Text(errorText).foregroundStyle(.red)
|
|
293
|
+
} else if let payload {
|
|
294
|
+
ScrollView {
|
|
295
|
+
VStack(alignment: .leading, spacing: 8) {
|
|
296
|
+
ForEach(Array(payload.keys.sorted()), id: \.self) { key in
|
|
297
|
+
HStack {
|
|
298
|
+
Text(key).font(.caption).foregroundStyle(.secondary)
|
|
299
|
+
Spacer()
|
|
300
|
+
Text(String(describing: payload[key] ?? ""))
|
|
301
|
+
.textSelection(.enabled)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
.padding()
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
ProgressView()
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
.navigationTitle((screen["title"] as? String) ?? "Detail")
|
|
312
|
+
.toolbar {
|
|
313
|
+
ToolbarItemGroup(placement: .bottomBar) {
|
|
314
|
+
if let primary = (screen["actions"] as? [String: Any])?["primary"] as? [String: Any],
|
|
315
|
+
let cid = primary["id"] as? String {
|
|
316
|
+
NavigationLink("Edit") {
|
|
317
|
+
editForm(cid: cid)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if let secondary = (screen["actions"] as? [String: Any])?["secondary"] as? [String: Any],
|
|
321
|
+
let cid = secondary["id"] as? String {
|
|
322
|
+
Button("Complete") {
|
|
323
|
+
Task { await runSecondary(cid) }
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if let destructive = (screen["actions"] as? [String: Any])?["destructive"] as? [String: Any],
|
|
327
|
+
let cid = destructive["id"] as? String {
|
|
328
|
+
Button("Delete", role: .destructive) {
|
|
329
|
+
Task { await runDestructive(cid) }
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
.task {
|
|
335
|
+
await load()
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private func loadCapId() -> String? {
|
|
340
|
+
(screen["loadCapability"] as? [String: Any])?["id"] as? String
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
@MainActor
|
|
344
|
+
private func load() async {
|
|
345
|
+
guard let cap = loadCapId() else { return }
|
|
346
|
+
do {
|
|
347
|
+
let json = try await client.requestCapability(cap, input: params)
|
|
348
|
+
payload = json as? [String: Any]
|
|
349
|
+
} catch {
|
|
350
|
+
errorText = String(describing: error)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
@MainActor
|
|
355
|
+
private func runSecondary(_ capabilityId: String) async {
|
|
356
|
+
guard capabilityId == "cap_complete_task",
|
|
357
|
+
let taskId = params["task_id"] ?? params["id"],
|
|
358
|
+
let detail = payload else { return }
|
|
359
|
+
let etag = String(describing: detail["updated_at"] ?? "")
|
|
360
|
+
var headers: [String: String] = [:]
|
|
361
|
+
if !etag.isEmpty { headers["If-Match"] = etag }
|
|
362
|
+
let body: [String: Any] = [
|
|
363
|
+
"task_id": taskId,
|
|
364
|
+
"completed": true,
|
|
365
|
+
"idempotency_key": UUID().uuidString
|
|
366
|
+
]
|
|
367
|
+
do {
|
|
368
|
+
_ = try await client.requestCapability(capabilityId, input: body, extraHeaders: headers)
|
|
369
|
+
await load()
|
|
370
|
+
} catch {
|
|
371
|
+
errorText = String(describing: error)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
@MainActor
|
|
376
|
+
private func runDestructive(_ capabilityId: String) async {
|
|
377
|
+
guard let rid = params["task_id"] ?? params["project_id"] ?? params["user_id"] ?? params["id"] else { return }
|
|
378
|
+
var input: [String: Any] = [:]
|
|
379
|
+
if capabilityId.contains("task") { input["task_id"] = rid }
|
|
380
|
+
if capabilityId.contains("project") { input["project_id"] = rid }
|
|
381
|
+
if capabilityId.contains("user") { input["user_id"] = rid }
|
|
382
|
+
do {
|
|
383
|
+
_ = try await client.requestCapability(capabilityId, input: input)
|
|
384
|
+
} catch {
|
|
385
|
+
errorText = String(describing: error)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
@ViewBuilder
|
|
390
|
+
private func editForm(cid: String) -> some View {
|
|
391
|
+
let sid = cid.contains("project") ? "project_edit" : cid.contains("user") ? "user_edit" : "task_edit"
|
|
392
|
+
FormScreen(client: client, contract: contract, screen: contract.screen(id: sid) ?? [:], params: params)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// MARK: - Form
|
|
397
|
+
|
|
398
|
+
struct FormScreen: View {
|
|
399
|
+
let client: TodoAPIClient
|
|
400
|
+
@ObservedObject var contract: TodoUiContract
|
|
401
|
+
let screen: [String: Any]
|
|
402
|
+
let params: [String: String]
|
|
403
|
+
@State private var values: [String: String] = [:]
|
|
404
|
+
@State private var message: String?
|
|
405
|
+
|
|
406
|
+
var body: some View {
|
|
407
|
+
Form {
|
|
408
|
+
ForEach(formFieldNames(), id: \.self) { name in
|
|
409
|
+
TextField(name, text: binding(name))
|
|
410
|
+
}
|
|
411
|
+
Button("Submit") {
|
|
412
|
+
Task { await submit() }
|
|
413
|
+
}
|
|
414
|
+
if let message {
|
|
415
|
+
Text(message)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
.navigationTitle((screen["title"] as? String) ?? "Form")
|
|
419
|
+
.task {
|
|
420
|
+
await preload()
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private func formFieldNames() -> [String] {
|
|
425
|
+
guard let submitId = (screen["submitCapability"] as? [String: Any])?["id"] as? String else { return [] }
|
|
426
|
+
return client.bodyFieldNames(for: submitId)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private func binding(_ name: String) -> Binding<String> {
|
|
430
|
+
Binding(
|
|
431
|
+
get: { values[name] ?? "" },
|
|
432
|
+
set: { values[name] = $0 }
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private func preload() async {
|
|
437
|
+
guard let sid = screen["id"] as? String else { return }
|
|
438
|
+
if sid.contains("edit") {
|
|
439
|
+
let getCap = sid.contains("task") ? "cap_get_task" : sid.contains("project") ? "cap_get_project" : "cap_get_user"
|
|
440
|
+
var p = params
|
|
441
|
+
if sid.contains("task") { p["task_id"] = params["task_id"] ?? params["id"] ?? "" }
|
|
442
|
+
if sid.contains("project") { p["project_id"] = params["project_id"] ?? params["id"] ?? "" }
|
|
443
|
+
if sid.contains("user") { p["user_id"] = params["user_id"] ?? params["id"] ?? "" }
|
|
444
|
+
do {
|
|
445
|
+
let json = try await client.requestCapability(getCap, input: p)
|
|
446
|
+
if let dict = json as? [String: Any] {
|
|
447
|
+
for key in dict.keys {
|
|
448
|
+
values[key] = String(describing: dict[key] ?? "")
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
} catch {
|
|
452
|
+
message = String(describing: error)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
private func submit() async {
|
|
458
|
+
guard let submitId = (screen["submitCapability"] as? [String: Any])?["id"] as? String else { return }
|
|
459
|
+
var input: [String: Any] = [:]
|
|
460
|
+
for name in formFieldNames() {
|
|
461
|
+
if let v = values[name], !v.isEmpty {
|
|
462
|
+
input[name] = v
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
for (k, v) in params where input[k] == nil {
|
|
466
|
+
input[k] = v
|
|
467
|
+
}
|
|
468
|
+
var headers: [String: String] = [:]
|
|
469
|
+
if submitId == "cap_update_task", let etag = values["updated_at"] {
|
|
470
|
+
headers["If-Match"] = etag
|
|
471
|
+
}
|
|
472
|
+
do {
|
|
473
|
+
_ = try await client.requestCapability(submitId, input: input, extraHeaders: headers)
|
|
474
|
+
message = "Saved"
|
|
475
|
+
} catch {
|
|
476
|
+
message = String(describing: error)
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
extension TodoAPIClient {
|
|
482
|
+
func bodyFieldNames(for capabilityId: String) -> [String] {
|
|
483
|
+
guard let contract = contracts[capabilityId] as? [String: Any],
|
|
484
|
+
let rc = contract["requestContract"] as? [String: Any],
|
|
485
|
+
let transport = rc["transport"] as? [String: Any],
|
|
486
|
+
let body = transport["body"] as? [[String: Any]] else {
|
|
487
|
+
return []
|
|
488
|
+
}
|
|
489
|
+
return body.compactMap { $0["name"] as? String }
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
struct ExportTasksButton: View {
|
|
494
|
+
let client: TodoAPIClient
|
|
495
|
+
@ObservedObject var contract: TodoUiContract
|
|
496
|
+
@State private var jobId: String?
|
|
497
|
+
|
|
498
|
+
var body: some View {
|
|
499
|
+
Group {
|
|
500
|
+
if let jobId {
|
|
501
|
+
NavigationLink("Export job") {
|
|
502
|
+
JobStatusScreen(
|
|
503
|
+
client: client,
|
|
504
|
+
contract: contract,
|
|
505
|
+
screen: contract.screen(id: "task_exports") ?? [:],
|
|
506
|
+
params: ["job_id": jobId]
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
} else {
|
|
510
|
+
Button("Export") {
|
|
511
|
+
Task { await run() }
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private func run() async {
|
|
518
|
+
do {
|
|
519
|
+
let json = try await client.requestCapability("cap_export_tasks", input: [:])
|
|
520
|
+
if let dict = json as? [String: Any], let id = dict["job_id"] as? String {
|
|
521
|
+
jobId = id
|
|
522
|
+
}
|
|
523
|
+
} catch {
|
|
524
|
+
jobId = nil
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// MARK: - Board & Calendar
|
|
530
|
+
|
|
531
|
+
struct BoardScreen: View {
|
|
532
|
+
@ObservedObject var contract: TodoUiContract
|
|
533
|
+
let client: TodoAPIClient
|
|
534
|
+
let screen: [String: Any]
|
|
535
|
+
let baseParams: [String: String]
|
|
536
|
+
@State private var rows: [[String: Any]] = []
|
|
537
|
+
|
|
538
|
+
private var grouped: [String: [[String: Any]]] {
|
|
539
|
+
Dictionary(grouping: rows, by: { ($0["status"] as? String) ?? "unknown" })
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
var body: some View {
|
|
543
|
+
ScrollView {
|
|
544
|
+
LazyVStack(alignment: .leading) {
|
|
545
|
+
ForEach(Array(grouped.keys.sorted()), id: \.self) { key in
|
|
546
|
+
Text(key).font(.headline)
|
|
547
|
+
ForEach(Array((grouped[key] ?? []).enumerated()), id: \.offset) { _, row in
|
|
548
|
+
NavigationLink {
|
|
549
|
+
DetailScreen(
|
|
550
|
+
contract: contract,
|
|
551
|
+
client: client,
|
|
552
|
+
screen: contract.screen(id: "task_detail") ?? [:],
|
|
553
|
+
params: ["task_id": row["id"] as? String ?? ""]
|
|
554
|
+
)
|
|
555
|
+
} label: {
|
|
556
|
+
Text(rowTitle(row))
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
.padding()
|
|
562
|
+
}
|
|
563
|
+
.navigationTitle(screen["title"] as? String ?? "Board")
|
|
564
|
+
.task {
|
|
565
|
+
await load()
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private func load() async {
|
|
570
|
+
guard let cap = (screen["loadCapability"] as? [String: Any])?["id"] as? String else { return }
|
|
571
|
+
do {
|
|
572
|
+
let json = try await client.requestCapability(cap, input: baseParams)
|
|
573
|
+
rows = client.extractRows(from: json)
|
|
574
|
+
} catch {
|
|
575
|
+
rows = []
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
struct CalendarScreen: View {
|
|
581
|
+
@ObservedObject var contract: TodoUiContract
|
|
582
|
+
let client: TodoAPIClient
|
|
583
|
+
let screen: [String: Any]
|
|
584
|
+
let baseParams: [String: String]
|
|
585
|
+
@State private var rows: [[String: Any]] = []
|
|
586
|
+
|
|
587
|
+
private var grouped: [String: [[String: Any]]] {
|
|
588
|
+
Dictionary(grouping: rows, by: { row in
|
|
589
|
+
if let d = row["due_date"] as? String {
|
|
590
|
+
return String(d.prefix(10))
|
|
591
|
+
}
|
|
592
|
+
return "unscheduled"
|
|
593
|
+
})
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
var body: some View {
|
|
597
|
+
List {
|
|
598
|
+
ForEach(Array(grouped.keys.sorted()), id: \.self) { day in
|
|
599
|
+
Section(day) {
|
|
600
|
+
ForEach(Array((grouped[day] ?? []).enumerated()), id: \.offset) { _, row in
|
|
601
|
+
NavigationLink {
|
|
602
|
+
DetailScreen(
|
|
603
|
+
contract: contract,
|
|
604
|
+
client: client,
|
|
605
|
+
screen: contract.screen(id: "task_detail") ?? [:],
|
|
606
|
+
params: ["task_id": row["id"] as? String ?? ""]
|
|
607
|
+
)
|
|
608
|
+
} label: {
|
|
609
|
+
Text(rowTitle(row))
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
.navigationTitle(screen["title"] as? String ?? "Calendar")
|
|
616
|
+
.task {
|
|
617
|
+
await load()
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private func load() async {
|
|
622
|
+
guard let cap = (screen["loadCapability"] as? [String: Any])?["id"] as? String else { return }
|
|
623
|
+
do {
|
|
624
|
+
let json = try await client.requestCapability(cap, input: baseParams)
|
|
625
|
+
rows = client.extractRows(from: json)
|
|
626
|
+
} catch {
|
|
627
|
+
rows = []
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
struct JobStatusScreen: View {
|
|
633
|
+
let client: TodoAPIClient
|
|
634
|
+
@ObservedObject var contract: TodoUiContract
|
|
635
|
+
let screen: [String: Any]
|
|
636
|
+
let params: [String: String]
|
|
637
|
+
@State private var job: [String: Any]?
|
|
638
|
+
|
|
639
|
+
var body: some View {
|
|
640
|
+
Group {
|
|
641
|
+
if let job {
|
|
642
|
+
VStack(alignment: .leading, spacing: 8) {
|
|
643
|
+
ForEach(Array(job.keys.sorted()), id: \.self) { k in
|
|
644
|
+
Text("\(k): \(String(describing: job[k] ?? ""))")
|
|
645
|
+
}
|
|
646
|
+
if let terminal = (screen["actions"] as? [String: Any])?["terminal"] as? [String: Any],
|
|
647
|
+
let cid = terminal["id"] as? String {
|
|
648
|
+
Button("Download") {
|
|
649
|
+
Task { await download(cid) }
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
.padding()
|
|
654
|
+
} else {
|
|
655
|
+
ProgressView()
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
.navigationTitle((screen["title"] as? String) ?? "Export")
|
|
659
|
+
.task {
|
|
660
|
+
await loadJob()
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private func loadJob() async {
|
|
665
|
+
guard let cap = (screen["loadCapability"] as? [String: Any])?["id"] as? String else { return }
|
|
666
|
+
do {
|
|
667
|
+
let json = try await client.requestCapability(cap, input: params)
|
|
668
|
+
job = json as? [String: Any]
|
|
669
|
+
} catch {
|
|
670
|
+
job = [:]
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private func download(_ capabilityId: String) async {
|
|
675
|
+
let jobId = params["job_id"] ?? ""
|
|
676
|
+
do {
|
|
677
|
+
_ = try await client.requestCapability(capabilityId, input: ["job_id": jobId])
|
|
678
|
+
} catch {
|
|
679
|
+
// ignore download errors in stub
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|