11package cmd
22
33import (
4+ "context"
45 "encoding/json"
56 "fmt"
67 "io"
@@ -9,18 +10,28 @@ import (
910 "strconv"
1011 "strings"
1112 "text/tabwriter"
13+ "time"
1214
1315 "github.com/pepabo/xpoint-cli/internal/xpoint"
1416 "github.com/spf13/cobra"
1517)
1618
1719var (
18- docSearchBody string
19- docSearchSize int
20- docSearchOffset int
21- docSearchPage int
22- docSearchOutput string
23- docSearchJQ string
20+ docSearchBody string
21+ docSearchSize int
22+ docSearchOffset int
23+ docSearchPage int
24+ docSearchOutput string
25+ docSearchJQ string
26+ docSearchTitle string
27+ docSearchFormName string
28+ docSearchFormID int
29+ docSearchFGID int
30+ docSearchWriters []string
31+ docSearchGroups []string
32+ docSearchMe bool
33+ docSearchSince string
34+ docSearchUntil string
2435
2536 docCreateBody string
2637 docCreateOutput string
@@ -69,12 +80,28 @@ var documentSearchCmd = &cobra.Command{
6980 Short : "Search documents" ,
7081 Long : `Search documents via POST /api/v1/search/documents.
7182
72- The search condition JSON is provided with --body, which accepts one of:
83+ The search condition can be specified by either a raw JSON body (--body) or
84+ convenience filter flags. Mixing --body with filter flags is rejected.
85+
86+ Raw body (--body) accepts one of:
7387 - inline JSON string (e.g. --body '{"title":"経費"}')
7488 - a path to a JSON file (e.g. --body ./search.json)
7589 - "-" to read the body from stdin (e.g. --body -)
7690
77- If --body is omitted, an empty object is sent (matches all documents).` ,
91+ Filter flags build a search body automatically:
92+ --title <s> partial match on the document title (件名)
93+ --form-name <s> partial match on the form name
94+ --form-id <n> form ID (fid)
95+ --form-group-id <n> form group ID (fgid)
96+ --writer <code> writer user code (repeatable)
97+ --writer-group <code> writer user-group code (repeatable)
98+ --me shorthand for --writer <current user code>;
99+ looked up via XPOINT_USER or /scim/v2/{domain_code}/Me
100+ --since <YYYY-MM-DD> lower bound of 新規更新日 (cr_dt)
101+ --until <YYYY-MM-DD> upper bound of 新規更新日 (cr_dt)
102+
103+ If neither --body nor any filter flag is given, an empty object is sent
104+ (matches all documents).` ,
78105 RunE : runDocumentSearch ,
79106}
80107
@@ -230,12 +257,21 @@ func init() {
230257 documentCommentCmd .AddCommand (documentCommentDeleteCmd )
231258
232259 f := documentSearchCmd .Flags ()
233- f .StringVar (& docSearchBody , "body" , "" , "search condition JSON: inline, file path, or - for stdin" )
260+ f .StringVar (& docSearchBody , "body" , "" , "search condition JSON: inline, file path, or - for stdin (cannot be combined with filter flags) " )
234261 f .IntVar (& docSearchSize , "size" , 0 , "number of items per page (0 = omit, server default 50; max 1000)" )
235262 f .IntVar (& docSearchOffset , "offset" , 0 , "result offset (0 = omit)" )
236263 f .IntVar (& docSearchPage , "page" , 0 , "result page (0 = omit)" )
237264 f .StringVarP (& docSearchOutput , "output" , "o" , "" , "output format: table|json (default: table on TTY, json otherwise)" )
238265 f .StringVar (& docSearchJQ , "jq" , "" , "apply a gojq filter to the JSON response (forces JSON output)" )
266+ f .StringVar (& docSearchTitle , "title" , "" , "partial match on document title" )
267+ f .StringVar (& docSearchFormName , "form-name" , "" , "partial match on form name" )
268+ f .IntVar (& docSearchFormID , "form-id" , 0 , "form ID (fid); 0 = omit" )
269+ f .IntVar (& docSearchFGID , "form-group-id" , 0 , "form group ID (fgid); 0 = omit" )
270+ f .StringSliceVar (& docSearchWriters , "writer" , nil , "writer user code (repeatable)" )
271+ f .StringSliceVar (& docSearchGroups , "writer-group" , nil , "writer user-group code (repeatable)" )
272+ f .BoolVar (& docSearchMe , "me" , false , "restrict to documents written by the current user (XPOINT_USER, or /scim/v2/{domain_code}/Me)" )
273+ f .StringVar (& docSearchSince , "since" , "" , "lower bound of 新規更新日 (YYYY-MM-DD)" )
274+ f .StringVar (& docSearchUntil , "until" , "" , "upper bound of 新規更新日 (YYYY-MM-DD)" )
239275
240276 cf := documentCreateCmd .Flags ()
241277 cf .StringVar (& docCreateBody , "body" , "" , "request body JSON: inline, file path, or - for stdin (required)" )
@@ -299,6 +335,27 @@ func runDocumentSearch(cmd *cobra.Command, args []string) error {
299335 return err
300336 }
301337
338+ hasFilters := docSearchTitle != "" || docSearchFormName != "" || docSearchFormID != 0 ||
339+ docSearchFGID != 0 || len (docSearchWriters ) > 0 || len (docSearchGroups ) > 0 ||
340+ docSearchMe || docSearchSince != "" || docSearchUntil != ""
341+ if hasFilters {
342+ if len (bodyBytes ) > 0 {
343+ return fmt .Errorf ("--body cannot be combined with filter flags (--title, --form-*, --writer*, --me, --since, --until)" )
344+ }
345+ meCode := ""
346+ if docSearchMe {
347+ meCode , err = resolveCurrentUserCode (cmd .Context (), client )
348+ if err != nil {
349+ return err
350+ }
351+ }
352+ built , err := buildSearchBodyFromFlags (meCode )
353+ if err != nil {
354+ return err
355+ }
356+ bodyBytes = built
357+ }
358+
302359 params := xpoint.SearchDocumentsParams {}
303360 if docSearchSize != 0 {
304361 v := docSearchSize
@@ -721,6 +778,107 @@ func confirmDelete(docID int) bool {
721778 return false
722779}
723780
781+ type writerListEntry struct {
782+ Type string `json:"type"`
783+ Code string `json:"code"`
784+ }
785+
786+ // resolveCurrentUserCode returns the authenticated user's X-point user code
787+ // for --me. It prefers XPOINT_USER / --xpoint-user; if neither is set, it
788+ // falls back to GET /scim/v2/{domain_code}/Me and reads the atled SCIM
789+ // extension's userCode (not userName — that's the login name, while the
790+ // writer_list API expects the numeric user code).
791+ func resolveCurrentUserCode (ctx context.Context , client * xpoint.Client ) (string , error ) {
792+ if u := pick (flagUser , "XPOINT_USER" ); u != "" {
793+ return u , nil
794+ }
795+ domain := resolveDomainCode ()
796+ if domain == "" {
797+ return "" , fmt .Errorf ("--me requires the current user code: set --xpoint-user / XPOINT_USER, or provide a domain code (--xpoint-domain-code / XPOINT_DOMAIN_CODE / stored OAuth login) to look it up via /scim/v2/{domain_code}/Me" )
798+ }
799+ info , err := client .GetSelfInfo (ctx , domain )
800+ if err != nil {
801+ return "" , fmt .Errorf ("resolve --me via /scim/v2/%s/Me: %w" , domain , err )
802+ }
803+ if info .AtledExt .UserCode == "" {
804+ return "" , fmt .Errorf ("resolve --me: userCode is empty in /scim/v2/%s/Me response (atled SCIM extension missing)" , domain )
805+ }
806+ return info .AtledExt .UserCode , nil
807+ }
808+
809+ // buildSearchBodyFromFlags converts --title / --form-* / --writer* / --me /
810+ // --since / --until into a JSON request body for POST /api/v1/search/documents.
811+ //
812+ // meCode is the resolved user code to use for --me (empty if --me was not set
813+ // or resolution is not needed).
814+ func buildSearchBodyFromFlags (meCode string ) (json.RawMessage , error ) {
815+ body := map [string ]any {}
816+
817+ if docSearchTitle != "" {
818+ body ["title" ] = docSearchTitle
819+ }
820+ if docSearchFormName != "" {
821+ body ["form_name" ] = docSearchFormName
822+ }
823+ if docSearchFormID != 0 {
824+ body ["fid" ] = docSearchFormID
825+ }
826+ if docSearchFGID != 0 {
827+ body ["fgid" ] = docSearchFGID
828+ }
829+
830+ var writers []writerListEntry
831+ for _ , code := range docSearchWriters {
832+ if code = strings .TrimSpace (code ); code != "" {
833+ writers = append (writers , writerListEntry {Type : "user" , Code : code })
834+ }
835+ }
836+ for _ , code := range docSearchGroups {
837+ if code = strings .TrimSpace (code ); code != "" {
838+ writers = append (writers , writerListEntry {Type : "group" , Code : code })
839+ }
840+ }
841+ if meCode != "" {
842+ writers = append (writers , writerListEntry {Type : "user" , Code : meCode })
843+ }
844+ if len (writers ) > 0 {
845+ body ["writer_list" ] = writers
846+ }
847+
848+ if docSearchSince != "" || docSearchUntil != "" {
849+ body ["date_type" ] = "cr_dt"
850+ body ["dt_cond_type" ] = "1"
851+ if docSearchSince != "" {
852+ t , err := parseSearchDate (docSearchSince )
853+ if err != nil {
854+ return nil , fmt .Errorf ("--since: %w" , err )
855+ }
856+ body ["lower_year" ] = t .Year ()
857+ body ["lower_month" ] = int (t .Month ())
858+ body ["lower_day" ] = t .Day ()
859+ }
860+ if docSearchUntil != "" {
861+ t , err := parseSearchDate (docSearchUntil )
862+ if err != nil {
863+ return nil , fmt .Errorf ("--until: %w" , err )
864+ }
865+ body ["upper_year" ] = t .Year ()
866+ body ["upper_month" ] = int (t .Month ())
867+ body ["upper_day" ] = t .Day ()
868+ }
869+ }
870+
871+ return json .Marshal (body )
872+ }
873+
874+ func parseSearchDate (s string ) (time.Time , error ) {
875+ t , err := time .Parse ("2006-01-02" , strings .TrimSpace (s ))
876+ if err != nil {
877+ return time.Time {}, fmt .Errorf ("invalid date %q: expected YYYY-MM-DD" , s )
878+ }
879+ return t , nil
880+ }
881+
724882// loadSearchBody resolves --body into JSON bytes.
725883func loadSearchBody (spec string ) (json.RawMessage , error ) {
726884 if spec == "" {
0 commit comments