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/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 548dcc7a5..c9db70d32 100644 --- a/word/go.mod +++ b/word/go.mod @@ -4,8 +4,10 @@ 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/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 ) @@ -16,12 +18,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 +35,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 +51,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..5732551e3 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= @@ -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= @@ -70,8 +72,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 +112,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..36aff670d 100644 --- a/word/main.go +++ b/word/main.go @@ -25,6 +25,10 @@ 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 "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 new file mode 100644 index 000000000..3ba333c2c --- /dev/null +++ b/word/pkg/commands/create.go @@ -0,0 +1,38 @@ +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 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", "name", name) + + 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" + 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/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/convert/convert.go b/word/pkg/convert/convert.go new file mode 100644 index 000000000..536253da6 --- /dev/null +++ b/word/pkg/convert/convert.go @@ -0,0 +1,123 @@ +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() + defer os.Remove(p) + + 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") + } + + // 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/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..1f4b7eb95 100644 --- a/word/pkg/graph/docs.go +++ b/word/pkg/graph/docs.go @@ -4,9 +4,16 @@ import ( "bytes" "context" "fmt" + "log/slog" + "path/filepath" + "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 +24,217 @@ 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}" + 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 string, parentID string, filename string, content []byte) (graphmodels.DriveItemable, error) { + if parentID == "" { + parentID = "root" + } + + 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 will 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 will create it.", "name", filename, "error", 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(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, + "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, 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 { + 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"}, + }, + } + docs, err := c.Drives(). + ByDriveId(deref(drive.GetId())). + SearchWithQ(ptr("docx")). + GetAsSearchWithQGetResponse(ctx, opts) if err != nil { return nil, err } @@ -39,6 +250,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..e9b6d89e9 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, Write Doc --- Name: List Docs @@ -14,15 +14,25 @@ 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: 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. 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. +Param: doc_content: Optional markdown formatted content to write to the document. + +#!${GPTSCRIPT_TOOL_DIR}/bin/gptscript-go-tool writeDoc + --- Name: Word Context Type: context @@ -32,6 +42,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 +53,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