diff --git a/.agents/skills/gog/SKILL.md b/.agents/skills/gog/SKILL.md index f252d7e..9a7e307 100644 --- a/.agents/skills/gog/SKILL.md +++ b/.agents/skills/gog/SKILL.md @@ -203,6 +203,8 @@ gog slides list-slides # read (slide gog slides delete-slide # full gog slides read-slide # read (element objectIds + text) gog slides insert-text --text 'Hi' # edit (shape; add --row/--col for a table cell) +gog slides move --x 100 --y 100 # edit (reposition, preserving scale/rotation; --scale-x/--scale-y to rescale) +gog slides reorder --to front # edit (z-order: front/back/forward/backward) ``` ## Chat diff --git a/Sources/GogCommands/GogCommand.swift b/Sources/GogCommands/GogCommand.swift index 28d7f3f..a0a0cdf 100644 --- a/Sources/GogCommands/GogCommand.swift +++ b/Sources/GogCommands/GogCommand.swift @@ -3422,7 +3422,8 @@ struct GogSlides: AsyncParsableCommand { SlidesReplaceText.self, SlidesListSlides.self, SlidesDeleteSlide.self, SlidesReadSlide.self, SlidesInsertText.self, SlidesCreateTable.self, - SlidesCreateTextbox.self, SlidesCreateImage.self], + SlidesCreateTextbox.self, SlidesCreateImage.self, + SlidesMove.self, SlidesReorder.self], aliases: ["slide"]) } @@ -4044,6 +4045,186 @@ struct SlidesCreateImage: AsyncParsableCommand { } } +/// `gog slides move --x --y ` — reposition a +/// page element (and optionally rescale it) via `updatePageElementTransform`. It +/// first reads the element's current transform and **preserves its scale, rotation, +/// and shear** — ABSOLUTE mode replaces the whole transform, so unspecified parts +/// would otherwise reset to zero — changing only the position, plus the scale when +/// `--scale-x`/`--scale-y` are given. Find object IDs with `slides read-slide`. +/// Points are converted to EMU (1 pt = 12700 EMU). +struct SlidesMove: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "move", + abstract: "Reposition/rescale a slide element (--dry-run to preview).") + + @Argument(help: "Presentation ID.") var presentationId: String + @Argument(help: "Page element object ID (from `slides read-slide`).") + var objectId: String + @Option(name: .long, help: "New left position in points.") var x: Double + @Option(name: .long, help: "New top position in points.") var y: Double + @Option(name: .long, help: "Horizontal scale (default: keep current).") var scaleX: Double? + @Option(name: .long, help: "Vertical scale (default: keep current).") var scaleY: Double? + @Flag(name: .long, help: "Build the request but do not move.") + var dryRun: Bool = false + @Flag(name: [.customShort("j"), .long], help: "Emit raw JSON.") + var json: Bool = false + + // Only the 2x2 matrix components are read; the translate is being replaced. + private struct Deck: Decodable { + struct Slide: Decodable { + struct Element: Decodable { + struct Transform: Decodable { + let scaleX: Double? + let scaleY: Double? + let shearX: Double? + let shearY: Double? + } + let objectId: String? + let transform: Transform? + } + let pageElements: [Element]? + } + let slides: [Slide]? + } + + func run() async throws { + try requireWriteTier(.edit) + guard x.isFinite, y.isFinite, max(abs(x), abs(y)) < 1_000_000 else { + Shell.bashCurrent.stderr("gog: --x/--y must be finite and within range\n") + throw ExitCode(2) + } + // A supplied scale must be finite and non-zero; negative is allowed (flip). + for s in [scaleX, scaleY] where s != nil { + guard s!.isFinite, s! != 0 else { + Shell.bashCurrent.stderr("gog: --scale-x/--scale-y must be finite and non-zero\n") + throw ExitCode(2) + } + } + // Read the current transform so ABSOLUTE mode doesn't strip the element's + // scale/rotation/shear — only position (and scale, if given) should change. + let getURL = try googleURL( + "https://slides.googleapis.com/v1/presentations/\(pathSegment(presentationId))", + query: [URLQueryItem( + name: "fields", + value: "slides(pageElements(objectId,transform(scaleX,scaleY,shearX,shearY)))")]) + let deck = try JSONDecoder().decode( + Deck.self, from: try await GoogleHTTPClient().get(getURL)) + guard let element = (deck.slides ?? []) + .flatMap({ $0.pageElements ?? [] }) + .first(where: { $0.objectId == objectId }) else { + Shell.bashCurrent.stderr( + "gog: \(objectId) is not a page element in this presentation " + + "(see `slides read-slide`)\n") + throw ExitCode(2) + } + let current = element.transform // nil = identity (scale 1, no shear) + let emu = { (points: Double) in Int((points * 12700).rounded()) } + struct Batch: Encodable { + struct Request: Encodable { + struct UpdateTransform: Encodable { + struct Transform: Encodable { + let scaleX: Double + let scaleY: Double + let shearX: Double + let shearY: Double + let translateX: Int + let translateY: Int + let unit: String + } + let objectId: String + let applyMode: String + let transform: Transform + } + let updatePageElementTransform: UpdateTransform + } + let requests: [Request] + } + // Keep the current 2x2 matrix (identity if absent); override scale only when + // the caller asked. ABSOLUTE sets the whole transform, so this reposition + // leaves size/rotation/shear untouched. + let transform = Batch.Request.UpdateTransform.Transform( + scaleX: scaleX ?? current?.scaleX ?? 1, + scaleY: scaleY ?? current?.scaleY ?? 1, + shearX: current?.shearX ?? 0, + shearY: current?.shearY ?? 0, + translateX: emu(x), translateY: emu(y), unit: "EMU") + let payload = try JSONEncoder().encode(Batch(requests: [.init( + updatePageElementTransform: .init( + objectId: objectId, applyMode: "ABSOLUTE", transform: transform))])) + if dryRun { + Shell.bashCurrent.stderr("dry-run: not moving\n") + Shell.bashCurrent.stdout(String(decoding: payload, as: UTF8.self) + "\n") + return + } + let url = try googleURL( + "https://slides.googleapis.com/v1/presentations/\(pathSegment(presentationId)):batchUpdate") + let result = try await GoogleHTTPClient().post(url, jsonBody: payload) + if json { + Shell.bashCurrent.stdout(String(decoding: result, as: UTF8.self) + "\n") + return + } + Shell.bashCurrent.stdout("moved: \(objectId)\n") + } +} + +/// `gog slides reorder --to front|back|forward|backward` +/// — change a page element's z-order (`updatePageElementsZOrder`). +struct SlidesReorder: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "reorder", + abstract: "Change a slide element's z-order (--dry-run to preview).") + + @Argument(help: "Presentation ID.") var presentationId: String + @Argument(help: "Page element object ID (from `slides read-slide`).") + var objectId: String + @Option(name: .long, help: "front, back, forward, or backward.") var to: String + @Flag(name: .long, help: "Build the request but do not reorder.") + var dryRun: Bool = false + @Flag(name: [.customShort("j"), .long], help: "Emit raw JSON.") + var json: Bool = false + + func run() async throws { + try requireWriteTier(.edit) + let operation: String + switch to.lowercased() { + case "front": operation = "BRING_TO_FRONT" + case "back": operation = "SEND_TO_BACK" + case "forward": operation = "BRING_FORWARD" + case "backward": operation = "SEND_BACKWARD" + default: + Shell.bashCurrent.stderr( + "gog: --to must be front, back, forward, or backward\n") + throw ExitCode(2) + } + struct Batch: Encodable { + struct Request: Encodable { + struct ZOrder: Encodable { + let pageElementObjectIds: [String] + let operation: String + } + let updatePageElementsZOrder: ZOrder + } + let requests: [Request] + } + let payload = try JSONEncoder().encode(Batch(requests: [.init( + updatePageElementsZOrder: .init( + pageElementObjectIds: [objectId], operation: operation))])) + if dryRun { + Shell.bashCurrent.stderr("dry-run: not reordering\n") + Shell.bashCurrent.stdout(String(decoding: payload, as: UTF8.self) + "\n") + return + } + let url = try googleURL( + "https://slides.googleapis.com/v1/presentations/\(pathSegment(presentationId)):batchUpdate") + let result = try await GoogleHTTPClient().post(url, jsonBody: payload) + if json { + Shell.bashCurrent.stdout(String(decoding: result, as: UTF8.self) + "\n") + return + } + Shell.bashCurrent.stdout("reordered \(objectId): \(operation)\n") + } +} + // MARK: - Chat /// `gog chat …` — Google Chat. diff --git a/Tests/GogShellTests/GogWiringTests.swift b/Tests/GogShellTests/GogWiringTests.swift index c293520..6e62124 100644 --- a/Tests/GogShellTests/GogWiringTests.swift +++ b/Tests/GogShellTests/GogWiringTests.swift @@ -3208,6 +3208,109 @@ extension Trait where Self == WriteTierTrait { #expect(run.stderr.contains("--object-id")) } + @Test func slidesMovePreservesTransformAndSetsPosition() async throws { + let shell = Shell() + shell.registerGogCommands() + // GET returns sh1 already scaled 2x with shear; a plain move must preserve + // both and change only the position (the GET response also serves the POST). + let transport = RecordingTransport(response: HTTPResponse(status: 200, body: Data( + #"{"slides":[{"pageElements":[{"objectId":"sh1","transform":{"scaleX":2,"scaleY":2,"shearX":0.5,"shearY":0}}]}]}"#.utf8))) + let run = try await GogTransportProvider.$current.withValue(transport) { + try await GogCredentials.$current.withValue( + StubProvider(token: "t", accountHint: nil) + ) { + try await shell.runCapturing("gog slides move P1 sh1 --x 100 --y 50") + } + } + #expect(run.exitStatus == .success) + #expect(transport.lastMethod == "POST") + #expect(transport.lastURL?.absoluteString.contains("/presentations/P1:batchUpdate") + == true) + // Decode the POST body so numeric checks don't depend on float formatting. + struct Body: Decodable { + struct R: Decodable { + struct U: Decodable { + struct T: Decodable { + let scaleX: Double; let scaleY: Double; let shearX: Double + let translateX: Int; let translateY: Int + } + let applyMode: String; let objectId: String; let transform: T + } + let updatePageElementTransform: U + } + let requests: [R] + } + let t = try JSONDecoder().decode(Body.self, from: transport.lastBody ?? Data()) + .requests.first!.updatePageElementTransform + #expect(t.applyMode == "ABSOLUTE" && t.objectId == "sh1") + #expect(t.transform.scaleX == 2 && t.transform.scaleY == 2 && t.transform.shearX == 0.5) // preserved + #expect(t.transform.translateX == 1270000 && t.transform.translateY == 635000) // 100pt / 50pt + } + + @Test func slidesMoveRejectsNonFiniteGeometry() async throws { + let shell = Shell() + shell.registerGogCommands() + let run = try await shell.runCapturing("gog slides move P1 sh1 --x inf --y 0") + #expect(run.exitStatus == ExitStatus(2)) + #expect(run.stderr.contains("finite")) + } + + @Test func slidesMoveAllowsNegativeScale() async throws { + let shell = Shell() + shell.registerGogCommands() + // GET must find sh1 (else it's "not a page element"); the response serves both. + let transport = RecordingTransport(response: HTTPResponse(status: 200, body: Data( + #"{"slides":[{"pageElements":[{"objectId":"sh1","transform":{"scaleX":1,"scaleY":1}}]}]}"#.utf8))) + // Negative scale flips the element (valid in Slides), so it must be accepted. + // --scale-x=-1 (not "--scale-x -1") so the negative isn't parsed as a flag. + let run = try await GogTransportProvider.$current.withValue(transport) { + try await GogCredentials.$current.withValue( + StubProvider(token: "t", accountHint: nil) + ) { + try await shell.runCapturing("gog slides move P1 sh1 --x 0 --y 0 --scale-x=-1") + } + } + #expect(run.exitStatus == .success) + #expect(transport.lastMethod == "POST") + #expect(String(decoding: transport.lastBody ?? Data(), as: UTF8.self) + .contains("updatePageElementTransform")) + } + + @Test func slidesMoveRejectsZeroScale() async throws { + let shell = Shell() + shell.registerGogCommands() + let run = try await shell.runCapturing("gog slides move P1 sh1 --x 0 --y 0 --scale-x 0") + #expect(run.exitStatus == ExitStatus(2)) + #expect(run.stderr.contains("non-zero")) + } + + @Test func slidesReorderPostsZOrder() async throws { + let shell = Shell() + shell.registerGogCommands() + let transport = RecordingTransport( + response: HTTPResponse(status: 200, body: Data("{}".utf8))) + let run = try await GogTransportProvider.$current.withValue(transport) { + try await GogCredentials.$current.withValue( + StubProvider(token: "t", accountHint: nil) + ) { + try await shell.runCapturing("gog slides reorder P1 sh1 --to front") + } + } + #expect(run.exitStatus == .success) + #expect(transport.lastMethod == "POST") + let body = String(decoding: transport.lastBody ?? Data(), as: UTF8.self) + #expect(body.contains("updatePageElementsZOrder") && body.contains(#""operation":"BRING_TO_FRONT""#)) + #expect(body.contains(#""sh1""#)) + } + + @Test func slidesReorderRejectsBadDirection() async throws { + let shell = Shell() + shell.registerGogCommands() + let run = try await shell.runCapturing("gog slides reorder P1 sh1 --to sideways") + #expect(run.exitStatus == ExitStatus(2)) + #expect(run.stderr.contains("--to")) + } + @Test func docsInsertImagePostsInsertInlineImage() async throws { let shell = Shell() shell.registerGogCommands()