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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/skills/gog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ gog slides list-slides <presentationId> # read (slide
gog slides delete-slide <presentationId> <slideObjectId> # full
gog slides read-slide <presentationId> <slideObjectId> # read (element objectIds + text)
gog slides insert-text <presentationId> <objectId> --text 'Hi' # edit (shape; add --row/--col for a table cell)
gog slides move <presentationId> <elementObjectId> --x 100 --y 100 # edit (reposition, preserving scale/rotation; --scale-x/--scale-y to rescale)
gog slides reorder <presentationId> <elementObjectId> --to front # edit (z-order: front/back/forward/backward)
```

## Chat
Expand Down
183 changes: 182 additions & 1 deletion Sources/GogCommands/GogCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}

Expand Down Expand Up @@ -4044,6 +4045,186 @@ struct SlidesCreateImage: AsyncParsableCommand {
}
}

/// `gog slides move <presentationId> <objectId> --x <pt> --y <pt>` — 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 <presentationId> <objectId> --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.
Expand Down
103 changes: 103 additions & 0 deletions Tests/GogShellTests/GogWiringTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading