From 73ed07630454bc3c4783b92be26eeefeac840847 Mon Sep 17 00:00:00 2001 From: Thorsten Klein Date: Tue, 4 Feb 2025 14:20:18 +0100 Subject: [PATCH 1/8] feat: onedrive word write --- index.yaml | 3 + word/go.mod | 23 ++-- word/go.sum | 50 ++++---- word/main.go | 6 + word/pkg/commands/create.go | 26 ++++ word/pkg/commands/get.go | 26 +++- word/pkg/global/global.go | 3 +- word/pkg/graph/docs.go | 230 +++++++++++++++++++++++++++++++++++- word/tool.gpt | 24 +++- 9 files changed, 348 insertions(+), 43 deletions(-) create mode 100644 word/pkg/commands/create.go diff --git a/index.yaml b/index.yaml index 4a9e4ee57..2baf40022 100644 --- a/index.yaml +++ b/index.yaml @@ -71,6 +71,9 @@ tools: reference: ./knowledge shell: reference: ./shell + word: + reference: ./word + all: true knowledgeDataSources: notion-data-source: diff --git a/word/go.mod b/word/go.mod index 548dcc7a5..8dc21f197 100644 --- a/word/go.mod +++ b/word/go.mod @@ -4,8 +4,9 @@ go 1.23.1 require ( code.sajari.com/docconv/v2 v2.0.0-pre.4 - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 github.com/gptscript-ai/go-gptscript v0.9.6-0.20250204133419-744b25b84a61 + github.com/microsoft/kiota-abstractions-go v1.8.1 github.com/microsoftgraph/msgraph-sdk-go v1.48.0 ) @@ -16,12 +17,12 @@ require ( github.com/advancedlogic/GoOse v0.0.0-20191112112754-e742535969c1 // indirect github.com/andybalholm/cascadia v1.2.0 // indirect github.com/araddon/dateparse v0.0.0-20200409225146-d820a6159ab1 // indirect - github.com/cjlapao/common-go v0.0.39 // indirect + github.com/cjlapao/common-go v0.0.41 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/set v0.2.1 // indirect github.com/getkin/kin-openapi v0.124.0 // indirect github.com/gigawattio/window v0.0.0-20180317192513-0f5467e35573 // indirect - github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.20.2 // indirect github.com/go-openapi/swag v0.22.8 // indirect @@ -33,7 +34,6 @@ require ( github.com/levigross/exp-html v0.0.0-20120902181939-8df60c69a8f5 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect - github.com/microsoft/kiota-abstractions-go v1.7.0 // indirect github.com/microsoft/kiota-authentication-azure-go v1.1.0 // indirect github.com/microsoft/kiota-http-go v1.4.4 // indirect github.com/microsoft/kiota-serialization-form-go v1.0.0 // indirect @@ -50,13 +50,14 @@ require ( github.com/richardlehane/mscfb v1.0.3 // indirect github.com/richardlehane/msoleps v1.0.3 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect - github.com/std-uritemplate/std-uritemplate/go v0.0.57 // indirect - github.com/stretchr/testify v1.9.0 // indirect - go.opentelemetry.io/otel v1.24.0 // indirect - go.opentelemetry.io/otel/metric v1.24.0 // indirect - go.opentelemetry.io/otel/trace v1.24.0 // indirect - golang.org/x/net v0.27.0 // indirect - golang.org/x/text v0.16.0 // indirect + github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.1 // indirect + github.com/stretchr/testify v1.10.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.34.0 // indirect + go.opentelemetry.io/otel/trace v1.34.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/text v0.21.0 // indirect google.golang.org/protobuf v1.30.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/word/go.sum b/word/go.sum index 75e621b23..e944d41e4 100644 --- a/word/go.sum +++ b/word/go.sum @@ -1,7 +1,7 @@ code.sajari.com/docconv/v2 v2.0.0-pre.4 h1:1yQrSTah9rMSC/s1T9bq2H2j1NuRTppeApqZf2A8Zbc= code.sajari.com/docconv/v2 v2.0.0-pre.4/go.mod h1:+pfeEYCOA46E5fq44sh1OKEkO9hsptg8XRioeP1vvPg= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0 h1:nyQWyZvwGTvunIMxi1Y9uXkcyr+I7TeNrr/foo4Kpk8= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0/go.mod h1:l38EPgmsp71HHLq9j7De57JcKOWPyhrsW1Awm1JS6K0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 h1:g0EZJwz7xkXQiZAI5xi9f3WWFYBlX1CPTrR+NDToRkQ= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0/go.mod h1:XCW7KnZet0Opnr7HccfUw1PLc4CjHqpcaxW8DHklNkQ= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY= github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY= github.com/JalfResi/justext v0.0.0-20170829062021-c0282dea7198 h1:8P+AjBhGByCuCX2zTkAf6UY+dj0JczX+t6cSdCSyvfw= @@ -18,8 +18,8 @@ github.com/andybalholm/cascadia v1.2.0/go.mod h1:YCyR8vOZT9aZ1CHEd8ap0gMVm2aFgxB github.com/araddon/dateparse v0.0.0-20180729174819-cfd92a431d0e/go.mod h1:SLqhdZcd+dF3TEVL2RMoob5bBP5R1P1qkox+HtCBgGI= github.com/araddon/dateparse v0.0.0-20200409225146-d820a6159ab1 h1:TEBmxO80TM04L8IuMWk77SGL1HomBmKTdzdJLLWznxI= github.com/araddon/dateparse v0.0.0-20200409225146-d820a6159ab1/go.mod h1:SLqhdZcd+dF3TEVL2RMoob5bBP5R1P1qkox+HtCBgGI= -github.com/cjlapao/common-go v0.0.39 h1:bAAUrj2B9v0kMzbAOhzjSmiyDy+rd56r2sy7oEiQLlA= -github.com/cjlapao/common-go v0.0.39/go.mod h1:M3dzazLjTjEtZJbbxoA5ZDiGCiHmpwqW9l4UWaddwOA= +github.com/cjlapao/common-go v0.0.41 h1:j30UKZJWVWIllJ66x3EOslJvIk/VjkyenrhEcH64dGM= +github.com/cjlapao/common-go v0.0.41/go.mod h1:ao5wEp0hYMNehJiHoarSjc5dKK5wi4LvnwjXaC2SxUI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -30,8 +30,8 @@ github.com/getkin/kin-openapi v0.124.0/go.mod h1:wb1aSZA/iWmorQP9KTAS/phLj/t17B5 github.com/gigawattio/window v0.0.0-20180317192513-0f5467e35573 h1:u8AQ9bPa9oC+8/A/jlWouakhIvkFfuxgIIRjiy8av7I= github.com/gigawattio/window v0.0.0-20180317192513-0f5467e35573/go.mod h1:eBvb3i++NHDH4Ugo9qCvMw8t0mTSctaEa5blJbWcNxs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= -github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= @@ -70,8 +70,8 @@ github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzp github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/microsoft/kiota-abstractions-go v1.7.0 h1:/0OKSSEe94Z1qgpcGE7ZFI9P+4iAnsDQo9v9UOk+R8E= -github.com/microsoft/kiota-abstractions-go v1.7.0/go.mod h1:FI1I2OHg0E7bK5t8DPnw+9C/CHVyLP6XeqDBT+95pTE= +github.com/microsoft/kiota-abstractions-go v1.8.1 h1:0gtK3KERmbKYm5AxJLZ8WPlNR9eACUGWuofFIa01PnA= +github.com/microsoft/kiota-abstractions-go v1.8.1/go.mod h1:YO2QCJyNM9wzvlgGLepw6s9XrPgNHODOYGVDCqQWdLI= github.com/microsoft/kiota-authentication-azure-go v1.1.0 h1:HudH57Enel9zFQ4TEaJw6lMiyZ5RbBdrRHwdU0NP2RY= github.com/microsoft/kiota-authentication-azure-go v1.1.0/go.mod h1:zfPFOiLdEqM77Hua5B/2vpcXrVaGqSWjHSRzlvAWEgc= github.com/microsoft/kiota-http-go v1.4.4 h1:HM0KT/Q7o+JsGatFkkbTIqJL24Jzo5eMI5NNe9N4TQ4= @@ -110,37 +110,39 @@ github.com/richardlehane/mscfb v1.0.3/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7 github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/richardlehane/msoleps v1.0.3 h1:aznSZzrwYRl3rLKRT3gUk9am7T/mLNSnJINvN0AQoVM= github.com/richardlehane/msoleps v1.0.3/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/simplereach/timeutils v1.2.0/go.mod h1:VVbQDfN/FHRZa1LSqcwo4kNZ62OOyqLLGQKYB3pB0Q8= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= -github.com/std-uritemplate/std-uritemplate/go v0.0.57 h1:GHGjptrsmazP4IVDlUprssiEf9ESVkbjx15xQXXzvq4= -github.com/std-uritemplate/std-uritemplate/go v0.0.57/go.mod h1:rG/bqh/ThY4xE5de7Rap3vaDkYUT76B0GPJ0loYeTTc= +github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.1 h1:/m2cTZHpqgofDsrwPqsASI6fSNMNhb+9EmUYtHEV2Uk= +github.com/std-uritemplate/std-uritemplate/go/v2 v2.0.1/go.mod h1:Z5KcoM0YLC7INlNhEezeIZ0TZNYf7WSNO0Lvah4DSeQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= -go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= -go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= -go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= -go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= -go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= -go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= diff --git a/word/main.go b/word/main.go index 0a2a39492..56c497ebf 100644 --- a/word/main.go +++ b/word/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "log/slog" "os" "github.com/gptscript-ai/tools/word/pkg/commands" @@ -25,6 +26,11 @@ func main() { err = commands.ListDocs(ctx) case "getDoc": err = commands.GetDoc(ctx, os.Getenv("DOC_ID")) + case "getDocByPath": + err = commands.GetDocByPath(ctx, os.Getenv("DOC_PATH")) + case "createDoc": + slog.Info("Creating doc", "token", os.Getenv("GPTSCRIPT_MICROSOFT_WORD_TOKEN")) + err = commands.CreateDoc(ctx, os.Getenv("DOC_DRIVE_DIR"), os.Getenv("DOC_TITLE"), os.Getenv("DOC_CONTENT")) default: fmt.Printf("Unknown command: %s\n", command) os.Exit(1) diff --git a/word/pkg/commands/create.go b/word/pkg/commands/create.go new file mode 100644 index 000000000..d356f0e38 --- /dev/null +++ b/word/pkg/commands/create.go @@ -0,0 +1,26 @@ +package commands + +import ( + "context" + "log/slog" + + "github.com/gptscript-ai/tools/word/pkg/client" + "github.com/gptscript-ai/tools/word/pkg/global" + "github.com/gptscript-ai/tools/word/pkg/graph" +) + +func CreateDoc(ctx context.Context, dir, name, content string) error { + c, err := client.NewClient(global.ReadWriteScopes) + if err != nil { + return err + } + + slog.Info("Creating new Word Document in OneDrive", "dir", dir, "name", name) + + _, _, err = graph.CreateDoc(ctx, c, dir, name, content) + if err != nil { + return err + } + + return nil +} diff --git a/word/pkg/commands/get.go b/word/pkg/commands/get.go index f95ebdd84..0bd269690 100644 --- a/word/pkg/commands/get.go +++ b/word/pkg/commands/get.go @@ -3,6 +3,7 @@ package commands import ( "context" "fmt" + "strings" "github.com/gptscript-ai/tools/word/pkg/client" "github.com/gptscript-ai/tools/word/pkg/global" @@ -15,9 +16,30 @@ func GetDoc(ctx context.Context, docID string) error { return err } - content, err := graph.GetDoc(ctx, c, docID) + var content string + if strings.HasSuffix(docID, ".docx") || strings.Contains(docID, "/") { + content, err = graph.GetDocByPath(ctx, c, docID) + } else { + content, err = graph.GetDoc(ctx, c, docID) + } + if err != nil { + return fmt.Errorf("failed to get doc %q: %w", docID, err) + } + + fmt.Println(content) + + return nil +} + +func GetDocByPath(ctx context.Context, path string) error { + c, err := client.NewClient(global.ReadOnlyScopes) + if err != nil { + return err + } + + content, err := graph.GetDocByPath(ctx, c, path) if err != nil { - return fmt.Errorf("failed to list word docs: %w", err) + return fmt.Errorf("failed to get doc %q: %w", path, err) } fmt.Println(content) diff --git a/word/pkg/global/global.go b/word/pkg/global/global.go index 23943eafd..c1a17a09b 100644 --- a/word/pkg/global/global.go +++ b/word/pkg/global/global.go @@ -3,5 +3,6 @@ package global const CredentialEnv = "GPTSCRIPT_MICROSOFT_WORD_TOKEN" var ( - ReadOnlyScopes = []string{"Files.Read", "Files.Read.All", "User.Read"} + ReadOnlyScopes = []string{"Files.Read", "Files.Read.All", "User.Read"} + ReadWriteScopes = []string{"Files.ReadWrite", "Files.ReadWrite.All", "User.Read", "Sites.ReadWrite.All"} ) diff --git a/word/pkg/graph/docs.go b/word/pkg/graph/docs.go index ef276a1d7..ef1f52f19 100644 --- a/word/pkg/graph/docs.go +++ b/word/pkg/graph/docs.go @@ -4,9 +4,15 @@ import ( "bytes" "context" "fmt" + "log/slog" + "strings" "code.sajari.com/docconv/v2" + kiota "github.com/microsoft/kiota-abstractions-go" msgraphsdkgo "github.com/microsoftgraph/msgraph-sdk-go" + "github.com/microsoftgraph/msgraph-sdk-go/drives" + graphmodels "github.com/microsoftgraph/msgraph-sdk-go/models" + "github.com/microsoftgraph/msgraph-sdk-go/models/odataerrors" ) type DocInfo struct { @@ -17,13 +23,205 @@ func (d DocInfo) String() string { return fmt.Sprintf("Name: %s\nID: %s", d.Name, d.ID) } +// getItemByPath retrieves a drive item (file or folder) by its path relative to the drive root. +func getItemByPath(ctx context.Context, client *msgraphsdkgo.GraphServiceClient, driveID, path string) (graphmodels.DriveItemable, error) { + // Build the URL using the Graph endpoint: + // GET /drives/{drive-id}/root:/{item-path} + requestInfo := kiota.NewRequestInformation() + requestInfo.UrlTemplate = "{+baseurl}/drives/{driveid}/root:/{itempath}" + // Note: URL-encode the path as needed. + requestInfo.PathParameters = map[string]string{ + "baseurl": client.RequestAdapter.GetBaseUrl(), + } + requestInfo.PathParametersAny = map[string]any{ + "driveid": driveID, + "itempath": path, + } + requestInfo.Method = kiota.GET + + u, err := requestInfo.GetUri() + if err != nil { + return nil, fmt.Errorf("failed to get URI: %w", err) + } + slog.Info("Getting item by path", "path", path, "drive", driveID, "url", u) + + // Use the factory function to create a new DriveItem instance. + res, err := client.RequestAdapter.Send(ctx, requestInfo, graphmodels.CreateDriveItemFromDiscriminatorValue, nil) + if err != nil { + if strings.HasSuffix(err.Error(), "404") { + return nil, fmt.Errorf("item not found: %w", err) + } + return nil, err + } + + driveItem, ok := res.(graphmodels.DriveItemable) + if !ok { + return nil, fmt.Errorf("unexpected response type for uploaded drive item") + } + return driveItem, nil +} + +// ensureFolderExists walks the folder path (which may include nested folders) +// and creates any folder that does not exist. It returns the DriveItem for the final folder. +func ensureFolderExists(ctx context.Context, client *msgraphsdkgo.GraphServiceClient, driveID, folderPath string) (graphmodels.DriveItemable, error) { + // Normalize and split the folder path (e.g. "FolderA/FolderB"). + parts := strings.Split(strings.Trim(folderPath, "/"), "/") + // Start at the drive root. + currentFolderID := "root" + var currentItem graphmodels.DriveItemable + + // Build the path progressively. + for idx, part := range parts { + // Build the relative path from the root up to the current folder. + currentPath := strings.Join(parts[:idx+1], "/") + // Try to get the folder by path. + item, err := getItemByPath(ctx, client, driveID, currentPath) + if err != nil { + if !strings.Contains(err.Error(), "item not found") { + return nil, fmt.Errorf("failed to get item by path %q: %w", currentPath, err) + } + // Assume an error indicates the folder was not found. + // Create the folder in the current parent folder. + newFolder := graphmodels.NewDriveItem() + newFolder.SetName(&part) + // Mark the item as a folder. + newFolder.SetFolder(graphmodels.NewFolder()) + // Set conflict behavior to "rename" (or "fail") to avoid naming conflicts. + newFolder.SetAdditionalData(map[string]any{ + "@microsoft.graph.conflictBehavior": "fail", + }) + createdFolder, err := client.Drives(). + ByDriveId(driveID). + Items(). + ByDriveItemId(currentFolderID). + Children(). + Post(ctx, newFolder, nil) + if err != nil { + return nil, fmt.Errorf("failed to create folder %q: %w", part, err) + } + currentItem = createdFolder + } else { + // Folder already exists. + currentItem = item + } + // Update the parent folder for the next iteration. + currentFolderID = deref(currentItem.GetId()) + } + + return currentItem, nil +} + +// uploadFileContent uploads file content as a new drive item under the specified parent folder. +func uploadFileContent(ctx context.Context, client *msgraphsdkgo.GraphServiceClient, driveID, parentID, filename, content string) (graphmodels.DriveItemable, error) { + if parentID == "" { + parentID = "root" + } + + // Check if file exists + doc, err := getItemByPath(ctx, client, driveID, filename) + if err != nil { + if !strings.Contains(err.Error(), "item not found") { + return nil, fmt.Errorf("failed to get item by path %q: %w", parentID+"/"+filename, err) + } + } + + // Build the URL for a simple upload: + // PUT /drives/{drive-id}/items/{parent-id}:/{filename}:/content + requestInfo := kiota.NewRequestInformation() + requestInfo.PathParameters = map[string]string{ + "baseurl": client.RequestAdapter.GetBaseUrl(), // for some weird reason, this is deprecated, but also the only way to get it working + } + requestInfo.Method = kiota.PUT + requestInfo.SetStreamContentAndContentType([]byte(content), "text/plain") + + if doc == nil { + slog.Info("File does not exist. Creating.", "name", filename) + requestInfo.UrlTemplate = "{+baseurl}/drives/{driveid}/items/{parentid}:/{filename}:/content" + requestInfo.PathParametersAny = map[string]any{ + "driveid": driveID, + "parentid": parentID, + // URL-encode the filename if necessary. + "filename": filename, + } + } else { + slog.Info("File exists. Updating.", "name", filename) + requestInfo.UrlTemplate = "{+baseurl}/drives/{driveid}/items/{itemid}/content" + requestInfo.PathParametersAny = map[string]any{ + "driveid": driveID, + "itemid": deref(doc.GetId()), + } + } + u, err := requestInfo.GetUri() + if err != nil { + return nil, fmt.Errorf("failed to get URI: %w", err) + } + slog.Info("Uploading file", "name", filename, "parent", parentID, "drive", driveID, "url", u) + + errorMapping := kiota.ErrorMappings{ + "XXX": odataerrors.CreateODataErrorFromDiscriminatorValue, + } + + res, err := client.RequestAdapter.Send(ctx, requestInfo, graphmodels.CreateDriveItemFromDiscriminatorValue, errorMapping) + if err != nil { + return nil, fmt.Errorf("failed to upload file: %w", err) + } + + driveItem, ok := res.(graphmodels.DriveItemable) + if !ok { + return nil, fmt.Errorf("unexpected response type for uploaded drive item") + } + return driveItem, nil +} + +// CreateDoc creates (or uploads) a new document with the given name and content +// into the specified directory (dir) in the user's OneDrive. +func CreateDoc(ctx context.Context, client *msgraphsdkgo.GraphServiceClient, dir, name, content string) (string, string, error) { + // Get the user's drive. + drive, err := client.Me().Drive().Get(ctx, nil) + if err != nil { + return "", "", fmt.Errorf("failed to get drive: %w", err) + } + driveID := deref(drive.GetId()) + + // Ensure the target folder exists. + folderID := "root" + if dir != "" { + folderItem, err := ensureFolderExists(ctx, client, driveID, dir) + if err != nil { + return "", "", fmt.Errorf("failed to ensure folder exists: %w", err) + } + folderID = deref(folderItem.GetId()) + } + + // Upload the file into the folder. + uploadedItem, err := uploadFileContent(ctx, client, driveID, folderID, name, content) + if err != nil { + return "", "", fmt.Errorf("failed to upload file: %w", err) + } + if uploadedItem == nil { + return "", "", fmt.Errorf("failed to upload file: uploaded item is nil") + } + slog.Info("Uploaded file", "name", name, "id", deref(uploadedItem.GetId())) + return name, deref(uploadedItem.GetId()), nil +} + func ListDocs(ctx context.Context, c *msgraphsdkgo.GraphServiceClient) ([]DocInfo, error) { drive, err := c.Me().Drive().Get(ctx, nil) if err != nil { return nil, err } - docs, err := c.Drives().ByDriveId(deref(drive.GetId())).SearchWithQ(ptr("docx")).GetAsSearchWithQGetResponse(ctx, nil) + opts := &drives.ItemSearchWithQRequestBuilderGetRequestConfiguration{ + QueryParameters: &drives.ItemSearchWithQRequestBuilderGetQueryParameters{ + // Request that these fields are returned in the response. + Select: []string{"id", "name", "parentReference"}, + }, + // You can also set headers or options if needed. + } + docs, err := c.Drives(). + ByDriveId(deref(drive.GetId())). + SearchWithQ(ptr("docx")). + GetAsSearchWithQGetResponse(ctx, opts) if err != nil { return nil, err } @@ -39,6 +237,36 @@ func ListDocs(ctx context.Context, c *msgraphsdkgo.GraphServiceClient) ([]DocInf return infos, nil } +func GetDocByPath(ctx context.Context, c *msgraphsdkgo.GraphServiceClient, path string) (string, error) { + drive, err := c.Me().Drive().Get(ctx, nil) + if err != nil { + return "", err + } + + doc, err := getItemByPath(ctx, c, deref(drive.GetId()), path) + if err != nil { + if strings.Contains(err.Error(), "item not found") { + return "", fmt.Errorf("doc not found") + } + return "", err + } + + parentPath := deref(doc.GetParentReference().GetPath()) + slog.Info("Getting doc by path", "path", path, "parentPath", parentPath) + + docContent, err := c.Drives().ByDriveId(deref(drive.GetId())).Items().ByDriveItemId(deref(doc.GetId())).Content().Get(ctx, nil) + if err != nil { + return "", err + } + + content, err := docconv.Convert(bytes.NewReader(docContent), "application/vnd.ms-word", true) + if err != nil { + return "", fmt.Errorf("failed to convert doc: %w", err) + } + + return content.Body, nil +} + func GetDoc(ctx context.Context, c *msgraphsdkgo.GraphServiceClient, docID string) (string, error) { drive, err := c.Me().Drive().Get(ctx, nil) if err != nil { diff --git a/word/tool.gpt b/word/tool.gpt index 961f254a2..b61194f84 100644 --- a/word/tool.gpt +++ b/word/tool.gpt @@ -2,7 +2,7 @@ Name: Word Description: Tools for interacting with Microsoft Word documents in OneDrive Metadata: bundle: true -Share Tools: List Docs, Get Doc +Share Tools: List Docs, Read Doc, Create Doc --- Name: List Docs @@ -14,15 +14,26 @@ Credential: ./credential #!${GPTSCRIPT_TOOL_DIR}/bin/gptscript-go-tool listDocs --- -Name: Get Doc -Description: Get the contents of a Microsoft Word document from OneDrive +Name: Read Doc +Description: Read the contents of a Microsoft Word document from OneDrive Share Context: Word Context Credential: ./credential Share Tools: List Docs -Param: doc_id: ID of the Microsoft Word document to get +Param: doc_id: ID or Path of the Microsoft Word document to get. Prefer ID if available, path only if given by user. #!${GPTSCRIPT_TOOL_DIR}/bin/gptscript-go-tool getDoc +--- +Name: Create Doc +Description: Create a Microsoft Word document in OneDrive with the specified title and optional content. +Share Context: Word Context +Credential: ./credential +Param: doc_title: The title of the document to create. +Param: doc_drive_dir: Optional folder path in OneDrive to move the document to after creating it. If not provided, the document will be created in the root folder. +Param: doc_content: Optional markdown formatted content to add to the document after creating it. + +#!${GPTSCRIPT_TOOL_DIR}/bin/gptscript-go-tool createDoc + --- Name: Word Context Type: context @@ -32,6 +43,7 @@ Type: context ## Instructions for using Microsoft Word tools Do not output Microsoft Word document IDs because they are not helpful for the user. +Microsoft Word document names are not considered document IDs. ## End of instructions for using Microsoft Word tools @@ -42,3 +54,7 @@ Word --- !metadata:*:icon https://cdn.jsdelivr.net/npm/@phosphor-icons/core@2/assets/duotone/microsoft-word-logo-duotone.svg + +--- +!metadata:*:oauth +microsoft365 \ No newline at end of file From b7ddb1d10e46f17f4660b326e4a19e943a4272a0 Mon Sep 17 00:00:00 2001 From: Thorsten Klein Date: Tue, 4 Feb 2025 19:37:01 +0100 Subject: [PATCH 2/8] add: md to docx conversion --- word/credential/tool.gpt | 2 + word/go.mod | 1 + word/go.sum | 2 + word/pkg/commands/create.go | 14 +++- word/pkg/convert/convert.go | 124 ++++++++++++++++++++++++++++++++++++ word/pkg/graph/docs.go | 6 +- 6 files changed, 144 insertions(+), 5 deletions(-) create mode 100644 word/pkg/convert/convert.go diff --git a/word/credential/tool.gpt b/word/credential/tool.gpt index d82f883ba..0e101f838 100644 --- a/word/credential/tool.gpt +++ b/word/credential/tool.gpt @@ -16,6 +16,8 @@ Tools: ../../oauth2 "Files.Read", "Files.Read.All", "User.Read", + "Files.ReadWrite", + "Files.ReadWrite.All", "offline_access" ] } diff --git a/word/go.mod b/word/go.mod index 8dc21f197..c9db70d32 100644 --- a/word/go.mod +++ b/word/go.mod @@ -5,6 +5,7 @@ go 1.23.1 require ( code.sajari.com/docconv/v2 v2.0.0-pre.4 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.17.0 + github.com/gomarkdown/markdown v0.0.0-20250202022148-4f606c78d442 github.com/gptscript-ai/go-gptscript v0.9.6-0.20250204133419-744b25b84a61 github.com/microsoft/kiota-abstractions-go v1.8.1 github.com/microsoftgraph/msgraph-sdk-go v1.48.0 diff --git a/word/go.sum b/word/go.sum index e944d41e4..5732551e3 100644 --- a/word/go.sum +++ b/word/go.sum @@ -44,6 +44,8 @@ github.com/go-resty/resty/v2 v2.3.0/go.mod h1:UpN9CgLZNsv4e9XG50UU8xdI0F43UQ4Hmx github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/gomarkdown/markdown v0.0.0-20250202022148-4f606c78d442 h1:lh+tgYKiB5F6PWv2gxb5WuX/nKpx+dDNgXkrguRuoOc= +github.com/gomarkdown/markdown v0.0.0-20250202022148-4f606c78d442/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= diff --git a/word/pkg/commands/create.go b/word/pkg/commands/create.go index d356f0e38..9c51766ac 100644 --- a/word/pkg/commands/create.go +++ b/word/pkg/commands/create.go @@ -2,14 +2,18 @@ package commands import ( "context" + "fmt" "log/slog" + "path/filepath" + "strings" "github.com/gptscript-ai/tools/word/pkg/client" + "github.com/gptscript-ai/tools/word/pkg/convert" "github.com/gptscript-ai/tools/word/pkg/global" "github.com/gptscript-ai/tools/word/pkg/graph" ) -func CreateDoc(ctx context.Context, dir, name, content string) error { +func CreateDoc(ctx context.Context, dir string, name string, content string) error { c, err := client.NewClient(global.ReadWriteScopes) if err != nil { return err @@ -17,7 +21,13 @@ func CreateDoc(ctx context.Context, dir, name, content string) error { slog.Info("Creating new Word Document in OneDrive", "dir", dir, "name", name) - _, _, err = graph.CreateDoc(ctx, c, dir, name, content) + contentBytes, err := convert.MarkdownToDocx(content) + if err != nil { + return fmt.Errorf("failed to convert markdown to docx: %w", err) + } + + name = strings.TrimSuffix(name, filepath.Ext(name)) + ".docx" + _, _, err = graph.CreateDoc(ctx, c, dir, name, contentBytes) if err != nil { return err } diff --git a/word/pkg/convert/convert.go b/word/pkg/convert/convert.go new file mode 100644 index 000000000..8ac7d5b84 --- /dev/null +++ b/word/pkg/convert/convert.go @@ -0,0 +1,124 @@ +package convert + +import ( + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" +) + +func MarkdownToDocx(in string) ([]byte, error) { + tempfile, err := os.CreateTemp(os.TempDir(), fmt.Sprintf("word-convsource-*.md")) + if err != nil { + return nil, err + } + defer tempfile.Close() + + p := tempfile.Name() + _, err = tempfile.WriteString(in) + if err != nil { + return nil, err + } + _ = tempfile.Close() + + var cmd *exec.Cmd + var outputFile string + if _, err := exec.LookPath("pandoc"); err == nil { + cmd, outputFile = pandocCmd(p) + slog.Info("Used pandoc to convert markdown to docx", "input", p, "output", outputFile) + } else if _, err := exec.LookPath("soffice"); err == nil { + var cleanupFunc func() + cmd, cleanupFunc, outputFile, err = sofficeCmd(p) + if err != nil { + return nil, err + } + slog.Info("Used soffice to convert markdown to docx", "input", p, "output", outputFile) + defer cleanupFunc() + } else { + return nil, fmt.Errorf("neither pandoc nor soffice binary found") + } + _ = os.Remove(p) + + // capture stdout and stderr in a buffer + var outb, errb strings.Builder + cmd.Stdout = &outb + cmd.Stderr = &errb + + err = cmd.Run() + if err != nil { + slog.Error("Failed to run pandoc/soffice command", "error", err, "stderr", errb.String(), "stdout", outb.String()) + return nil, err + } + + slog.Info("pandoc/soffice command output", "stdout", outb.String(), "stderr", errb.String()) + + content, err := os.ReadFile(outputFile) + if err != nil { + return nil, err + } + return content, os.Remove(outputFile) +} + +func pandocCmd(p string) (*exec.Cmd, string) { + outFile := fmt.Sprintf("%s.docx", p) + return exec.Command( + "pandoc", + "-f", "markdown", + "-t", "docx", + "--output", outFile, + p, + ), outFile +} + +func sofficeCmd(p string) (*exec.Cmd, func(), string, error) { + var err error + p, err = markdownToHTML(p) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to convert markdown to html: %w", err) + } + + profileDir, err := os.MkdirTemp(os.TempDir(), "libreoffice-profile-*") + if err != nil { + slog.Error("Failed to create soffice profile directory", "path", profileDir, "error", err) + return nil, nil, "", fmt.Errorf("failed to create soffice profile directory: %w", err) + } + out := strings.TrimSuffix(p, filepath.Ext(p)) + ".docx" + return exec.Command( + "soffice", + "--headless", + fmt.Sprintf("-env:UserInstallation=file://%s", profileDir), + "--convert-to", "docx:Office Open XML Text", + "--outdir", filepath.Dir(out), + p, + ), func() { + _ = os.Remove(p) + _ = os.RemoveAll(profileDir) + }, out, nil +} + +func markdownToHTML(p string) (string, error) { + extensions := parser.CommonExtensions | parser.AutoHeadingIDs + pars := parser.NewWithExtensions(extensions) + + htmlFlags := html.CommonFlags | html.HrefTargetBlank + opts := html.RendererOptions{Flags: htmlFlags} + renderer := html.NewRenderer(opts) + + md, err := os.ReadFile(p) + if err != nil { + return "", err + } + + outFile := fmt.Sprintf("%s.html", p) + if err = os.WriteFile(outFile, markdown.ToHTML(md, pars, renderer), 0644); err != nil { + return "", err + } + return outFile, nil + +} diff --git a/word/pkg/graph/docs.go b/word/pkg/graph/docs.go index ef1f52f19..c3ab45953 100644 --- a/word/pkg/graph/docs.go +++ b/word/pkg/graph/docs.go @@ -112,7 +112,7 @@ func ensureFolderExists(ctx context.Context, client *msgraphsdkgo.GraphServiceCl } // uploadFileContent uploads file content as a new drive item under the specified parent folder. -func uploadFileContent(ctx context.Context, client *msgraphsdkgo.GraphServiceClient, driveID, parentID, filename, content string) (graphmodels.DriveItemable, error) { +func uploadFileContent(ctx context.Context, client *msgraphsdkgo.GraphServiceClient, driveID string, parentID string, filename string, content []byte) (graphmodels.DriveItemable, error) { if parentID == "" { parentID = "root" } @@ -132,7 +132,7 @@ func uploadFileContent(ctx context.Context, client *msgraphsdkgo.GraphServiceCli "baseurl": client.RequestAdapter.GetBaseUrl(), // for some weird reason, this is deprecated, but also the only way to get it working } requestInfo.Method = kiota.PUT - requestInfo.SetStreamContentAndContentType([]byte(content), "text/plain") + requestInfo.SetStreamContentAndContentType(content, "text/plain") if doc == nil { slog.Info("File does not exist. Creating.", "name", filename) @@ -175,7 +175,7 @@ func uploadFileContent(ctx context.Context, client *msgraphsdkgo.GraphServiceCli // CreateDoc creates (or uploads) a new document with the given name and content // into the specified directory (dir) in the user's OneDrive. -func CreateDoc(ctx context.Context, client *msgraphsdkgo.GraphServiceClient, dir, name, content string) (string, string, error) { +func CreateDoc(ctx context.Context, client *msgraphsdkgo.GraphServiceClient, dir string, name string, content []byte) (string, string, error) { // Get the user's drive. drive, err := client.Me().Drive().Get(ctx, nil) if err != nil { From 68ac0e8b819240e67a08b4c3bbb1cb753e7413b8 Mon Sep 17 00:00:00 2001 From: Thorsten Klein Date: Wed, 5 Feb 2025 17:07:02 +0100 Subject: [PATCH 3/8] chore: cleanup --- word/main.go | 6 ++---- word/pkg/commands/create.go | 8 +++++--- word/pkg/convert/convert.go | 3 +-- word/pkg/graph/docs.go | 28 ++++++++++++++++++++++------ word/tool.gpt | 13 ++++++------- 5 files changed, 36 insertions(+), 22 deletions(-) diff --git a/word/main.go b/word/main.go index 56c497ebf..36aff670d 100644 --- a/word/main.go +++ b/word/main.go @@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "log/slog" "os" "github.com/gptscript-ai/tools/word/pkg/commands" @@ -28,9 +27,8 @@ func main() { err = commands.GetDoc(ctx, os.Getenv("DOC_ID")) case "getDocByPath": err = commands.GetDocByPath(ctx, os.Getenv("DOC_PATH")) - case "createDoc": - slog.Info("Creating doc", "token", os.Getenv("GPTSCRIPT_MICROSOFT_WORD_TOKEN")) - err = commands.CreateDoc(ctx, os.Getenv("DOC_DRIVE_DIR"), os.Getenv("DOC_TITLE"), os.Getenv("DOC_CONTENT")) + case "writeDoc": + err = commands.WriteDoc(ctx, os.Getenv("DOC_NAME"), os.Getenv("DOC_CONTENT")) default: fmt.Printf("Unknown command: %s\n", command) os.Exit(1) diff --git a/word/pkg/commands/create.go b/word/pkg/commands/create.go index 9c51766ac..3ba333c2c 100644 --- a/word/pkg/commands/create.go +++ b/word/pkg/commands/create.go @@ -13,13 +13,13 @@ import ( "github.com/gptscript-ai/tools/word/pkg/graph" ) -func CreateDoc(ctx context.Context, dir string, name string, content string) error { +func WriteDoc(ctx context.Context, name string, content string) error { c, err := client.NewClient(global.ReadWriteScopes) if err != nil { return err } - slog.Info("Creating new Word Document in OneDrive", "dir", dir, "name", name) + slog.Info("Creating new Word Document in OneDrive", "name", name) contentBytes, err := convert.MarkdownToDocx(content) if err != nil { @@ -27,10 +27,12 @@ func CreateDoc(ctx context.Context, dir string, name string, content string) err } name = strings.TrimSuffix(name, filepath.Ext(name)) + ".docx" - _, _, err = graph.CreateDoc(ctx, c, dir, name, contentBytes) + name, id, err := graph.CreateDoc(ctx, c, name, contentBytes) if err != nil { return err } + fmt.Printf("Wrote content to document with name=%q and id=%q\n", name, id) + return nil } diff --git a/word/pkg/convert/convert.go b/word/pkg/convert/convert.go index 8ac7d5b84..536253da6 100644 --- a/word/pkg/convert/convert.go +++ b/word/pkg/convert/convert.go @@ -26,6 +26,7 @@ func MarkdownToDocx(in string) ([]byte, error) { return nil, err } _ = tempfile.Close() + defer os.Remove(p) var cmd *exec.Cmd var outputFile string @@ -43,7 +44,6 @@ func MarkdownToDocx(in string) ([]byte, error) { } else { return nil, fmt.Errorf("neither pandoc nor soffice binary found") } - _ = os.Remove(p) // capture stdout and stderr in a buffer var outb, errb strings.Builder @@ -120,5 +120,4 @@ func markdownToHTML(p string) (string, error) { return "", err } return outFile, nil - } diff --git a/word/pkg/graph/docs.go b/word/pkg/graph/docs.go index c3ab45953..ab4b2cea8 100644 --- a/word/pkg/graph/docs.go +++ b/word/pkg/graph/docs.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "log/slog" + "path/filepath" "strings" "code.sajari.com/docconv/v2" @@ -117,11 +118,19 @@ func uploadFileContent(ctx context.Context, client *msgraphsdkgo.GraphServiceCli parentID = "root" } - // Check if file exists - doc, err := getItemByPath(ctx, client, driveID, filename) - if err != nil { - if !strings.Contains(err.Error(), "item not found") { - return nil, fmt.Errorf("failed to get item by path %q: %w", parentID+"/"+filename, err) + var doc graphmodels.DriveItemable + var err error + + // Check if the file exists by path or ID + if strings.ContainsAny(filename, "/.:_-") { + doc, err = getItemByPath(ctx, client, driveID, filename) + if err != nil { + slog.Info("Failed to get item by path. It may not exist so we create it.", "path", filename, "error", err) + } + } else { + doc, err = client.Drives().ByDriveId(driveID).Items().ByDriveItemId(filename).Get(ctx, nil) + if err != nil { + slog.Info("Failed to get item by ID. It may not exist so we create it.", "name", filename, "error", err) } } @@ -175,7 +184,14 @@ func uploadFileContent(ctx context.Context, client *msgraphsdkgo.GraphServiceCli // CreateDoc creates (or uploads) a new document with the given name and content // into the specified directory (dir) in the user's OneDrive. -func CreateDoc(ctx context.Context, client *msgraphsdkgo.GraphServiceClient, dir string, name string, content []byte) (string, string, error) { +func CreateDoc(ctx context.Context, client *msgraphsdkgo.GraphServiceClient, name string, content []byte) (string, string, error) { + name = filepath.Clean(name) + if name == "" { + return "", "", fmt.Errorf("name cannot be empty") + } + dir := filepath.Dir(name) + name = filepath.Base(name) + // Get the user's drive. drive, err := client.Me().Drive().Get(ctx, nil) if err != nil { diff --git a/word/tool.gpt b/word/tool.gpt index b61194f84..12277d70b 100644 --- a/word/tool.gpt +++ b/word/tool.gpt @@ -2,7 +2,7 @@ Name: Word Description: Tools for interacting with Microsoft Word documents in OneDrive Metadata: bundle: true -Share Tools: List Docs, Read Doc, Create Doc +Share Tools: List Docs, Read Doc, Write Doc --- Name: List Docs @@ -24,15 +24,14 @@ Param: doc_id: ID or Path of the Microsoft Word document to get. Prefer ID if av #!${GPTSCRIPT_TOOL_DIR}/bin/gptscript-go-tool getDoc --- -Name: Create Doc -Description: Create a Microsoft Word document in OneDrive with the specified title and optional content. +Name: Write Doc +Description: Write a Microsoft Word document in OneDrive with the specified title and optional content. The file will be created if it doesn't exist. Share Context: Word Context Credential: ./credential -Param: doc_title: The title of the document to create. -Param: doc_drive_dir: Optional folder path in OneDrive to move the document to after creating it. If not provided, the document will be created in the root folder. -Param: doc_content: Optional markdown formatted content to add to the document after creating it. +Param: doc_name: The name of the document to write to. This might be the OneDrive ID of an existing document or a filepath in OneDrive. +Param: doc_content: Optional markdown formatted content to write to the document. -#!${GPTSCRIPT_TOOL_DIR}/bin/gptscript-go-tool createDoc +#!${GPTSCRIPT_TOOL_DIR}/bin/gptscript-go-tool writeDoc --- Name: Word Context From 55e74ceebad9e6e046152438db29b13c747e9dbe Mon Sep 17 00:00:00 2001 From: Thorsten Klein Date: Thu, 6 Feb 2025 21:14:53 +0100 Subject: [PATCH 4/8] Update word/tool.gpt Co-authored-by: Grant Linville --- word/tool.gpt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/word/tool.gpt b/word/tool.gpt index 12277d70b..e9b6d89e9 100644 --- a/word/tool.gpt +++ b/word/tool.gpt @@ -25,7 +25,7 @@ Param: doc_id: ID or Path of the Microsoft Word document to get. Prefer ID if av --- Name: Write Doc -Description: Write a Microsoft Word document in OneDrive with the specified title and optional content. The file will be created if it doesn't exist. +Description: Write a Microsoft Word document in OneDrive with the specified title and optional content. The file will be created if it doesn't exist. It will be overwritten if it already exists. Share Context: Word Context Credential: ./credential Param: doc_name: The name of the document to write to. This might be the OneDrive ID of an existing document or a filepath in OneDrive. From 1fb0361e16f0fac3a9dee050d47b467ecc878b54 Mon Sep 17 00:00:00 2001 From: Thorsten Klein Date: Thu, 6 Feb 2025 21:16:25 +0100 Subject: [PATCH 5/8] Update word/pkg/graph/docs.go Co-authored-by: Grant Linville --- word/pkg/graph/docs.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/word/pkg/graph/docs.go b/word/pkg/graph/docs.go index ab4b2cea8..9396ae7d9 100644 --- a/word/pkg/graph/docs.go +++ b/word/pkg/graph/docs.go @@ -125,7 +125,7 @@ func uploadFileContent(ctx context.Context, client *msgraphsdkgo.GraphServiceCli if strings.ContainsAny(filename, "/.:_-") { doc, err = getItemByPath(ctx, client, driveID, filename) if err != nil { - slog.Info("Failed to get item by path. It may not exist so we create it.", "path", filename, "error", err) + slog.Info("Failed to get item by path. It may not exist so we will create it.", "path", filename, "error", err) } } else { doc, err = client.Drives().ByDriveId(driveID).Items().ByDriveItemId(filename).Get(ctx, nil) From e76516247bc8e4b91fa69b0c01856d8d191a6ba8 Mon Sep 17 00:00:00 2001 From: Thorsten Klein Date: Thu, 6 Feb 2025 21:16:45 +0100 Subject: [PATCH 6/8] Update word/pkg/graph/docs.go Co-authored-by: Grant Linville --- word/pkg/graph/docs.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/word/pkg/graph/docs.go b/word/pkg/graph/docs.go index 9396ae7d9..c4dae97b8 100644 --- a/word/pkg/graph/docs.go +++ b/word/pkg/graph/docs.go @@ -130,7 +130,7 @@ func uploadFileContent(ctx context.Context, client *msgraphsdkgo.GraphServiceCli } else { doc, err = client.Drives().ByDriveId(driveID).Items().ByDriveItemId(filename).Get(ctx, nil) if err != nil { - slog.Info("Failed to get item by ID. It may not exist so we create it.", "name", filename, "error", err) + slog.Info("Failed to get item by ID. It may not exist so we will create it.", "name", filename, "error", err) } } From 451f5241dd925ab424db66d4667d1472b015be3e Mon Sep 17 00:00:00 2001 From: Thorsten Klein Date: Thu, 6 Feb 2025 21:17:20 +0100 Subject: [PATCH 7/8] Update word/pkg/graph/docs.go Co-authored-by: Grant Linville --- word/pkg/graph/docs.go | 1 - 1 file changed, 1 deletion(-) diff --git a/word/pkg/graph/docs.go b/word/pkg/graph/docs.go index c4dae97b8..074ddd539 100644 --- a/word/pkg/graph/docs.go +++ b/word/pkg/graph/docs.go @@ -232,7 +232,6 @@ func ListDocs(ctx context.Context, c *msgraphsdkgo.GraphServiceClient) ([]DocInf // Request that these fields are returned in the response. Select: []string{"id", "name", "parentReference"}, }, - // You can also set headers or options if needed. } docs, err := c.Drives(). ByDriveId(deref(drive.GetId())). From 94a1427e733181c9f8308ebf2a037df59d86b394 Mon Sep 17 00:00:00 2001 From: Thorsten Klein Date: Fri, 7 Feb 2025 10:02:40 +0100 Subject: [PATCH 8/8] fix: comments --- word/pkg/graph/docs.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/word/pkg/graph/docs.go b/word/pkg/graph/docs.go index 074ddd539..1f4b7eb95 100644 --- a/word/pkg/graph/docs.go +++ b/word/pkg/graph/docs.go @@ -30,7 +30,6 @@ func getItemByPath(ctx context.Context, client *msgraphsdkgo.GraphServiceClient, // GET /drives/{drive-id}/root:/{item-path} requestInfo := kiota.NewRequestInformation() requestInfo.UrlTemplate = "{+baseurl}/drives/{driveid}/root:/{itempath}" - // Note: URL-encode the path as needed. requestInfo.PathParameters = map[string]string{ "baseurl": client.RequestAdapter.GetBaseUrl(), } @@ -149,7 +148,6 @@ func uploadFileContent(ctx context.Context, client *msgraphsdkgo.GraphServiceCli requestInfo.PathParametersAny = map[string]any{ "driveid": driveID, "parentid": parentID, - // URL-encode the filename if necessary. "filename": filename, } } else {