Skip to content
Open
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
1 change: 1 addition & 0 deletions .agents/skills/gog/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ 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 format-text <presentationId> <objectId> --bold --font-size 18 # edit (style text: bold/italic/underline/--foreground hex; all text or --start/--end; --row/--col for a 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)
```
Expand Down
22 changes: 15 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,20 @@ jobs:
working-directory: SwiftGog
run: swift build --build-tests -v

# Run tests serially. SwiftPM's parallel runner (swiftpm_testing_helper)
# dlopens the .xctest bundle from a process whose dyld search path omits the
# toolchain's swift lib dir, so on the Xcode 26 / Swift 6.2 runner it fails to
# load libswiftCompatibilitySpan.dylib (a Span back-deployment shim) and the
# whole suite aborts before running. --no-parallel uses the normal xctest path,
# which resolves the dylib. Mirrors SwiftBash's own macOS CI.
# macos-latest now ships Xcode 26.0.1, whose `swift test` runner dlopens the
# xctest bundle without the toolchain lib dir on its dyld search path, so it
# can't load libswiftCompatibilitySpan.dylib — a Span back-deployment shim
# linked for our macOS 13 target — and the suite aborts before any test runs.
# Locate that shim in the active toolchain and put its directory on
# DYLD_FALLBACK_LIBRARY_PATH so the bundle resolves it. The echo is a
# self-diagnostic: if the shim isn't found, the log says so. (--no-parallel
# keeps the task-local-based tests serial.)
- name: Test
working-directory: SwiftGog
run: swift test --skip-build --no-parallel
run: |
span="$(find "$(xcode-select -p)/Toolchains" -name libswiftCompatibilitySpan.dylib 2>/dev/null | head -1)"
echo "libswiftCompatibilitySpan.dylib: ${span:-<not found>}"
if [ -n "$span" ]; then
export DYLD_FALLBACK_LIBRARY_PATH="$(dirname "$span"):${DYLD_FALLBACK_LIBRARY_PATH:-}"
fi
swift test --skip-build --no-parallel
137 changes: 136 additions & 1 deletion Sources/GogCommands/GogCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3596,7 +3596,8 @@ struct GogSlides: AsyncParsableCommand {
SlidesDeleteSlide.self, SlidesReadSlide.self,
SlidesInsertText.self, SlidesCreateTable.self,
SlidesCreateTextbox.self, SlidesCreateImage.self,
SlidesMove.self, SlidesReorder.self],
SlidesMove.self, SlidesReorder.self,
SlidesFormatText.self],
aliases: ["slide"])
}

Expand Down Expand Up @@ -4398,6 +4399,140 @@ struct SlidesReorder: AsyncParsableCommand {
}
}

/// `gog slides format-text <presentationId> <objectId>` — set text style (bold /
/// italic / underline / size / color) on a shape, or a table cell with `--row`/`--col`,
/// via `updateTextStyle`. Styles all of the element's text by default, or a
/// `--start`/`--end` range. Find object IDs with `slides read-slide`.
struct SlidesFormatText: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "format-text",
abstract: "Set bold/italic/underline/size/color on a slide element's text (--dry-run).")

@Argument(help: "Presentation ID.") var presentationId: String
@Argument(help: "Page element object ID (from `slides read-slide`).")
var objectId: String
@Flag(inversion: .prefixedNo, help: "Bold (--no-bold to unset).") var bold: Bool?
@Flag(inversion: .prefixedNo, help: "Italic (--no-italic to unset).") var italic: Bool?
@Flag(inversion: .prefixedNo, help: "Underline (--no-underline to unset).") var underline: Bool?
@Option(name: .long, help: "Font size in points.") var fontSize: Double?
@Option(name: .long, help: "Text color as hex RGB, e.g. FF0000.") var foreground: String?
@Option(name: .long, help: "Range start index within the element (with --end).") var start: Int?
@Option(name: .long, help: "Range end index within the element (with --start).") var end: Int?
@Option(name: .long, help: "Table cell row (0-based); requires --col.") var row: Int?
@Option(name: .long, help: "Table cell column (0-based); requires --row.") var col: Int?
@Flag(name: .long, help: "Build the request but do not apply.") var dryRun: Bool = false
@Flag(name: [.customShort("j"), .long], help: "Emit raw JSON.") var json: Bool = false

func run() async throws {
try requireWriteTier(.edit)
// Build the field mask from whichever style options were given.
var fields: [String] = []
if bold != nil { fields.append("bold") }
if italic != nil { fields.append("italic") }
if underline != nil { fields.append("underline") }
if fontSize != nil { fields.append("fontSize") }
if foreground != nil { fields.append("foregroundColor") }
guard !fields.isEmpty else {
Shell.bashCurrent.stderr(
"gog: give at least one of --bold/--italic/--underline/--font-size/--foreground\n")
throw ExitCode(2)
}
if let fontSize, !(fontSize.isFinite && fontSize > 0) {
Shell.bashCurrent.stderr("gog: --font-size must be a positive number\n")
throw ExitCode(2)
}
// Parse "RRGGBB" (or "#RRGGBB") to 0-1 RGB components.
var rgb: (r: Double, g: Double, b: Double)?
if let foreground {
let hex = foreground.hasPrefix("#") ? String(foreground.dropFirst()) : foreground
// allSatisfy(isHexDigit) rejects a leading sign — Int(_:radix:) would
// otherwise accept "-F0000"/"+F0000" and yield a wrong color.
guard hex.count == 6, hex.allSatisfy(\.isHexDigit),
let v = Int(hex, radix: 16) else {
Shell.bashCurrent.stderr("gog: --foreground must be a 6-digit hex RGB, e.g. FF0000\n")
throw ExitCode(2)
}
rgb = (Double((v >> 16) & 0xFF) / 255, Double((v >> 8) & 0xFF) / 255, Double(v & 0xFF) / 255)
}
guard (start == nil) == (end == nil) else {
Shell.bashCurrent.stderr("gog: --start and --end must be given together (a range)\n")
throw ExitCode(2)
}
if let start, let end, !(start >= 0 && end > start) {
Shell.bashCurrent.stderr("gog: --start must be >= 0 and --end greater than --start\n")
throw ExitCode(2)
}
guard (row == nil) == (col == nil) else {
Shell.bashCurrent.stderr("gog: --row and --col must be given together (table cell)\n")
throw ExitCode(2)
}
Comment on lines +4465 to +4468

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

While we ensure that --row and --col are provided together, we do not validate that they are non-negative. Negative row or column indices are invalid and will cause the Google Slides API request to fail. We should add a validation check to ensure both indices are >= 0.

        guard (row == nil) == (col == nil) else {
            Shell.bashCurrent.stderr("gog: --row and --col must be given together (table cell)\n")
            throw ExitCode(2)
        }
        if let row, let col, !(row >= 0 && col >= 0) {
            Shell.bashCurrent.stderr("gog: --row and --col must be non-negative\n")
            throw ExitCode(2)
        }

if let row, let col, !(row >= 0 && col >= 0) {
Shell.bashCurrent.stderr("gog: --row and --col must be non-negative\n")
throw ExitCode(2)
}
struct Batch: Encodable {
struct Request: Encodable {
struct UpdateTextStyle: Encodable {
struct CellLocation: Encodable { let rowIndex: Int; let columnIndex: Int }
struct TextRange: Encodable {
let type: String
let startIndex: Int?
let endIndex: Int?
}
struct Style: Encodable {
struct FontSize: Encodable { let magnitude: Double; let unit: String }
struct Color: Encodable {
struct Opaque: Encodable {
struct Rgb: Encodable { let red: Double; let green: Double; let blue: Double }
let rgbColor: Rgb
}
let opaqueColor: Opaque
}
let bold: Bool?
let italic: Bool?
let underline: Bool?
let fontSize: FontSize?
let foregroundColor: Color?
}
let objectId: String
let cellLocation: CellLocation?
let textRange: TextRange
let style: Style
let fields: String
}
let updateTextStyle: UpdateTextStyle
}
let requests: [Request]
}
let cell = row.flatMap { r in
col.map { Batch.Request.UpdateTextStyle.CellLocation(rowIndex: r, columnIndex: $0) } }
let textRange = start.flatMap { s in end.map { e in
Batch.Request.UpdateTextStyle.TextRange(type: "FIXED_RANGE", startIndex: s, endIndex: e) } }
?? .init(type: "ALL", startIndex: nil, endIndex: nil)
let style = Batch.Request.UpdateTextStyle.Style(
bold: bold, italic: italic, underline: underline,
fontSize: fontSize.map { .init(magnitude: $0, unit: "PT") },
foregroundColor: rgb.map {
.init(opaqueColor: .init(rgbColor: .init(red: $0.r, green: $0.g, blue: $0.b))) })
let payload = try JSONEncoder().encode(Batch(requests: [.init(updateTextStyle: .init(
objectId: objectId, cellLocation: cell, textRange: textRange, style: style,
fields: fields.joined(separator: ",")))]))
if dryRun {
Shell.bashCurrent.stderr("dry-run: not formatting\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("formatted text in: \(objectId)\n")
}
}

// MARK: - Chat

/// `gog chat …` — Google Chat.
Expand Down
114 changes: 114 additions & 0 deletions Tests/GogShellTests/GogWiringTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3311,6 +3311,120 @@ extension Trait where Self == WriteTierTrait {
#expect(run.stderr.contains("--to"))
}

@Test func slidesFormatTextPostsUpdateTextStyle() 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 format-text P1 sh1 --bold --font-size 18")
}
}
#expect(run.exitStatus == .success)
#expect(transport.lastMethod == "POST")
#expect(transport.lastURL?.absoluteString.contains("/presentations/P1:batchUpdate") == true)
let body = String(decoding: transport.lastBody ?? Data(), as: UTF8.self)
#expect(body.contains("updateTextStyle") && body.contains(#""objectId":"sh1""#))
#expect(body.contains(#""type":"ALL""#) && body.contains(#""bold":true"#))
#expect(body.contains(#""fields":"bold,fontSize""#)) // mask lists only set props, in order
}

@Test func slidesFormatTextRequiresAStyle() async throws {
let shell = Shell()
shell.registerGogCommands()
let run = try await shell.runCapturing("gog slides format-text P1 sh1")
#expect(run.exitStatus == ExitStatus(2))
#expect(run.stderr.contains("at least one"))
}

@Test func slidesFormatTextParsesForegroundHex() 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 format-text P1 sh1 --foreground FF0000")
}
}
#expect(run.exitStatus == .success)
// Decode so numeric checks don't depend on float formatting.
struct Body: Decodable {
struct R: Decodable {
struct U: Decodable {
struct S: Decodable {
struct C: Decodable {
struct O: Decodable {
struct Rgb: Decodable { let red: Double; let green: Double; let blue: Double }
let rgbColor: Rgb
}
let opaqueColor: O
}
let foregroundColor: C
}
let style: S
let fields: String
}
let updateTextStyle: U
}
let requests: [R]
}
let u = try JSONDecoder().decode(Body.self, from: transport.lastBody ?? Data())
.requests.first!.updateTextStyle
let rgb = u.style.foregroundColor.opaqueColor.rgbColor
#expect(rgb.red == 1 && rgb.green == 0 && rgb.blue == 0) // FF0000
#expect(u.fields == "foregroundColor")
}

@Test func slidesFormatTextRejectsBadHex() async throws {
let shell = Shell()
shell.registerGogCommands()
let run = try await shell.runCapturing("gog slides format-text P1 sh1 --foreground ZZ")
#expect(run.exitStatus == ExitStatus(2))
#expect(run.stderr.contains("hex"))
}

@Test func slidesFormatTextIntoCellEncodesCellLocation() 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 format-text P1 tbl1 --bold --row 1 --col 2")
}
}
#expect(run.exitStatus == .success)
let body = String(decoding: transport.lastBody ?? Data(), as: UTF8.self)
#expect(body.contains("cellLocation"))
#expect(body.contains(#""rowIndex":1"#) && body.contains(#""columnIndex":2"#))
}

@Test func slidesFormatTextRejectsSignedHex() async throws {
let shell = Shell()
shell.registerGogCommands()
// A signed 6-char value parses as an Int but isn't a valid color;
// --foreground=-F0000 (equals form) so the value isn't parsed as a flag.
let run = try await shell.runCapturing("gog slides format-text P1 sh1 --foreground=-F0000")
#expect(run.exitStatus == ExitStatus(2))
#expect(run.stderr.contains("hex"))
}

@Test func slidesFormatTextRejectsNegativeCell() async throws {
let shell = Shell()
shell.registerGogCommands()
let run = try await shell.runCapturing("gog slides format-text P1 tbl1 --bold --row=-1 --col 0")
#expect(run.exitStatus == ExitStatus(2))
#expect(run.stderr.contains("non-negative"))
}

@Test func docsInsertImagePostsInsertInlineImage() async throws {
let shell = Shell()
shell.registerGogCommands()
Expand Down
Loading