From f7e96bd6c1c3ca2754bee389872331d75a9725c1 Mon Sep 17 00:00:00 2001 From: Laura Brekelmans Date: Sun, 7 Dec 2025 17:09:08 +0100 Subject: [PATCH 01/12] Add JSON support for the `ls` command --- main.go | 3 ++- shell/ls.go | 50 ++++++++++++++++++++++++++++++++++++++++++++++++-- shell/shell.go | 4 +++- 3 files changed, 53 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index df2d19ee..f4555276 100644 --- a/main.go +++ b/main.go @@ -38,6 +38,7 @@ func parseOfflineCommands(cmd []string) bool { func main() { ni := flag.Bool("ni", false, "not interactive (prevents asking for code)") + jsonOutput := flag.Bool("json", false, "output in JSON format") flag.Usage = func() { fmt.Println(` help detailed commands, but the user needs to be logged in @@ -79,7 +80,7 @@ Offline Commands: log.Error.Fatal("failed to build documents tree, last error: ", err) } - err = shell.RunShell(ctx, userInfo, otherFlags) + err = shell.RunShell(ctx, userInfo, otherFlags, *jsonOutput) if err != nil { log.Error.Println("Error: ", err) diff --git a/shell/ls.go b/shell/ls.go index abdc272f..b3f0bd62 100644 --- a/shell/ls.go +++ b/shell/ls.go @@ -1,6 +1,7 @@ package shell import ( + "encoding/json" "sort" "strings" "time" @@ -85,6 +86,45 @@ type LsOptions struct { ShowTemplates bool } +type NodeJSON struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Version int `json:"version"` + ModifiedClient string `json:"modifiedClient,omitempty"` + CurrentPage int `json:"currentPage,omitempty"` + Parent string `json:"parent,omitempty"` + IsDirectory bool `json:"isDirectory"` +} + +func nodeToJSON(node *model.Node) NodeJSON { + return NodeJSON{ + ID: node.Document.ID, + Name: node.Document.Name, + Type: node.Document.Type, + Version: node.Document.Version, + ModifiedClient: node.Document.ModifiedClient, + CurrentPage: node.Document.CurrentPage, + Parent: node.Document.Parent, + IsDirectory: node.IsDirectory(), + } +} + +func displayNodesJSON(c *ishell.Context, nodes []*model.Node) error { + jsonNodes := make([]NodeJSON, len(nodes)) + for i, node := range nodes { + jsonNodes[i] = nodeToJSON(node) + } + + output, err := json.MarshalIndent(jsonNodes, "", " ") + if err != nil { + return err + } + + c.Println(string(output)) + return nil +} + func lsCmd(ctx *ShellCtxt) *ishell.Cmd { return &ishell.Cmd{ Name: "ls", @@ -123,8 +163,14 @@ func lsCmd(ctx *ShellCtxt) *ishell.Cmd { sorted := sortNodes(filterNodes(nodes, d), d) - for _, e := range sorted { - displayNode(c, e, d) + if ctx.JSONOutput { + if err := displayNodesJSON(c, sorted); err != nil { + c.Err(err) + } + } else { + for _, e := range sorted { + displayNode(c, e, d) + } } }, } diff --git a/shell/shell.go b/shell/shell.go index 137b4962..a567e44a 100644 --- a/shell/shell.go +++ b/shell/shell.go @@ -15,6 +15,7 @@ type ShellCtxt struct { path string useHiddenFiles bool UserInfo api.UserInfo + JSONOutput bool } func (ctx *ShellCtxt) prompt() string { @@ -41,7 +42,7 @@ func useHiddenFiles() bool { return val != "0" } -func RunShell(apiCtx api.ApiCtx, userInfo *api.UserInfo, args []string) error { +func RunShell(apiCtx api.ApiCtx, userInfo *api.UserInfo, args []string, jsonOutput bool) error { shell := ishell.New() ctx := &ShellCtxt{ node: apiCtx.Filetree().Root(), @@ -49,6 +50,7 @@ func RunShell(apiCtx api.ApiCtx, userInfo *api.UserInfo, args []string) error { path: apiCtx.Filetree().Root().Name(), useHiddenFiles: useHiddenFiles(), UserInfo: *userInfo, + JSONOutput: jsonOutput, } shell.SetPrompt(ctx.prompt()) From f0d0e3b97a78147738c13c0846eb105686b545e7 Mon Sep 17 00:00:00 2001 From: Laura Brekelmans Date: Sun, 7 Dec 2025 17:28:29 +0100 Subject: [PATCH 02/12] Add documentation for the `--json` flag --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/README.md b/README.md index a996dbd9..81130f75 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,26 @@ Start the shell by running `rmapi` Use `ls` to list the contents of the current directory. Entries are listed with `[d]` if they are directories, and `[f]` if they are files. +Alternatively, pass the `--json` flag and receive output in JSON. + +```json +[ + { + "id": "981bece1-fd9d-499d-9b89-e5b5e2d1da34", + "name": "Journal", + "type": "DocumentType", + "version": 0, + "modifiedClient": "2025-09-21T15:53:05Z", + "currentPage": -1, + "parent": "2dccadc5-65a3-440d-86bc-da96df1f8324", + "isDirectory": false + }, + { + "...": "..." + } +] +``` + ## Change current directory Use `cd` to change the current directory to any other directory in the hierarchy. From 411b424239778413a8e5d3855a15ef08b0bc8686 Mon Sep 17 00:00:00 2001 From: Laura Brekelmans Date: Sun, 7 Dec 2025 19:45:34 +0100 Subject: [PATCH 03/12] Add more context to documentation and remove omitEmpty as well as "IsDirectory" --- README.md | 31 +++++++++++++++---------------- shell/ls.go | 8 +++----- 2 files changed, 18 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 81130f75..222f2395 100644 --- a/README.md +++ b/README.md @@ -99,22 +99,21 @@ are directories, and `[f]` if they are files. Alternatively, pass the `--json` flag and receive output in JSON. -```json -[ - { - "id": "981bece1-fd9d-499d-9b89-e5b5e2d1da34", - "name": "Journal", - "type": "DocumentType", - "version": 0, - "modifiedClient": "2025-09-21T15:53:05Z", - "currentPage": -1, - "parent": "2dccadc5-65a3-440d-86bc-da96df1f8324", - "isDirectory": false - }, - { - "...": "..." - } -] +```typescript +interface Node { + id: string; // empty string for root node + name: string; + // TemplateType are downloaded reMarkable methods + // CollectionType refers to directories or the root node + // DocumentType is any PDF-document, Ebook or notebook + type: "CollectionType" | "DocumentType" | "TemplateType"; + version: number; // Only relevant for type=DocumentType + modifiedClient: string; // RFC3339Nano timestamp, empty for root + currentPage: number; // 0-indexed, only meaningful for type=DocumentType + parent: string; // parent ID, empty string for root children +} + +type LsOutput = Node[]; ``` ## Change current directory diff --git a/shell/ls.go b/shell/ls.go index b3f0bd62..fbe8e7c8 100644 --- a/shell/ls.go +++ b/shell/ls.go @@ -91,10 +91,9 @@ type NodeJSON struct { Name string `json:"name"` Type string `json:"type"` Version int `json:"version"` - ModifiedClient string `json:"modifiedClient,omitempty"` - CurrentPage int `json:"currentPage,omitempty"` - Parent string `json:"parent,omitempty"` - IsDirectory bool `json:"isDirectory"` + ModifiedClient string `json:"modifiedClient"` + CurrentPage int `json:"currentPage"` + Parent string `json:"parent"` } func nodeToJSON(node *model.Node) NodeJSON { @@ -106,7 +105,6 @@ func nodeToJSON(node *model.Node) NodeJSON { ModifiedClient: node.Document.ModifiedClient, CurrentPage: node.Document.CurrentPage, Parent: node.Document.Parent, - IsDirectory: node.IsDirectory(), } } From 669fdd87412938c8cfa1bab74d2cc8d1cf7a88c9 Mon Sep 17 00:00:00 2001 From: Laura Brekelmans Date: Sun, 7 Dec 2025 20:14:50 +0100 Subject: [PATCH 04/12] Add "starred" property to rmapi json output --- api/sync15/blobdoc.go | 1 + model/document.go | 1 + shell/ls.go | 2 ++ 3 files changed, 4 insertions(+) diff --git a/api/sync15/blobdoc.go b/api/sync15/blobdoc.go index 08aa397e..1c56d167 100644 --- a/api/sync15/blobdoc.go +++ b/api/sync15/blobdoc.go @@ -243,6 +243,7 @@ func (d *BlobDoc) ToDocument() *model.Document { Parent: d.Metadata.Parent, Type: d.Metadata.CollectionType, CurrentPage: d.Metadata.LastOpenedPage, + Starred: d.Metadata.Pinned, ModifiedClient: lastModified, } } diff --git a/model/document.go b/model/document.go index 39269ef7..fe4b7eca 100644 --- a/model/document.go +++ b/model/document.go @@ -13,6 +13,7 @@ type Document struct { ModifiedClient string Type string CurrentPage int + Starred bool Parent string } diff --git a/shell/ls.go b/shell/ls.go index fbe8e7c8..848dad6f 100644 --- a/shell/ls.go +++ b/shell/ls.go @@ -93,6 +93,7 @@ type NodeJSON struct { Version int `json:"version"` ModifiedClient string `json:"modifiedClient"` CurrentPage int `json:"currentPage"` + Starred bool `json:"starred"` Parent string `json:"parent"` } @@ -104,6 +105,7 @@ func nodeToJSON(node *model.Node) NodeJSON { Version: node.Document.Version, ModifiedClient: node.Document.ModifiedClient, CurrentPage: node.Document.CurrentPage, + Starred: node.Document.Starred, Parent: node.Document.Parent, } } From 5849af01a774c3fe297fc4f79ca2e35ddd37b7a6 Mon Sep 17 00:00:00 2001 From: Laura Brekelmans Date: Sun, 7 Dec 2025 21:14:32 +0100 Subject: [PATCH 05/12] Add Document Tags to `ls` and `stat` output by downloading additional .content files. --- api/sync15/blobdoc.go | 45 +++++++++++++++++++++++++++++++++++++++++++ archive/file.go | 28 ++++++++++++++++++++------- archive/zipdoc.go | 4 ++-- model/document.go | 1 + shell/ls.go | 18 +++++++++-------- 5 files changed, 79 insertions(+), 17 deletions(-) diff --git a/api/sync15/blobdoc.go b/api/sync15/blobdoc.go index 1c56d167..3aab17d0 100644 --- a/api/sync15/blobdoc.go +++ b/api/sync15/blobdoc.go @@ -22,6 +22,7 @@ type BlobDoc struct { Files []*Entry Entry Metadata archive.MetadataFile + Content archive.Content } func NewBlobDoc(name, documentId, colType, parentId string) *BlobDoc { @@ -142,6 +143,43 @@ func (d *BlobDoc) ReadMetadata(fileEntry *Entry, r RemoteStorage) error { d.Metadata = metadata } + if strings.HasSuffix(fileEntry.DocumentID, ".content") { + log.Trace.Println("Reading content: " + d.DocumentID) + + contentData := archive.Content{} + + contentReader, err := r.GetReader(fileEntry.Hash, fileEntry.DocumentID) + if err != nil { + log.Warning.Printf("cannot get content reader %s: %v", fileEntry.DocumentID, err) + return nil + } + defer contentReader.Close() + + contentBytes, err := io.ReadAll(contentReader) + if err != nil { + log.Warning.Printf("cannot read content bytes %s: %v", fileEntry.DocumentID, err) + return nil + } + + err = json.Unmarshal(contentBytes, &contentData) + if err != nil { + log.Warning.Printf("cannot parse content JSON %s: %v", fileEntry.DocumentID, err) + return nil + } + + // Ensure nil slices become empty arrays + if contentData.DocumentTags == nil { + contentData.DocumentTags = []archive.Tag{} + } + if contentData.PageTags == nil { + contentData.PageTags = []archive.PageTag{} + } + + log.Trace.Printf("parsed content for %s: %d document tags, %d page tags", + d.DocumentID, len(contentData.DocumentTags), len(contentData.PageTags)) + d.Content = contentData + } + return nil } @@ -236,6 +274,12 @@ func (d *BlobDoc) ToDocument() *model.Document { t := time.Unix(unixTime/1000, 0) lastModified = t.UTC().Format(time.RFC3339Nano) } + + tags := []string{} + for _, tag := range d.Content.DocumentTags { + tags = append(tags, tag.Name) + } + return &model.Document{ ID: d.DocumentID, Name: d.Metadata.DocName, @@ -245,5 +289,6 @@ func (d *BlobDoc) ToDocument() *model.Document { CurrentPage: d.Metadata.LastOpenedPage, Starred: d.Metadata.Pinned, ModifiedClient: lastModified, + Tags: tags, } } diff --git a/archive/file.go b/archive/file.go index dedb6553..bb766a50 100644 --- a/archive/file.go +++ b/archive/file.go @@ -45,7 +45,7 @@ func NewZip() *Zip { PageCount: 0, Pages: []string{}, TextScale: 1, - Transform: Transform{ + Transform: &Transform{ M11: 1, M12: 0, M13: 0, @@ -87,6 +87,19 @@ type Layer struct { Name string `json:"name"` } +// Tag represents a document-level tag with timestamp +type Tag struct { + Name string `json:"name"` + Timestamp int64 `json:"timestamp"` +} + +// PageTag represents a page-level tag with page ID and timestamp +type PageTag struct { + Name string `json:"name"` + PageID string `json:"pageId"` + Timestamp int64 `json:"timestamp"` +} + // Content represents the structure of a .content json file. type Content struct { DummyDocument bool `json:"dummyDocument"` @@ -102,13 +115,14 @@ type Content struct { Orientation string `json:"orientation"` PageCount int `json:"pageCount"` // Pages is a list of page IDs - Pages []string `json:"pages"` - Tags []string `json:"pageTags"` - RedirectionMap []int `json:"redirectionPageMap"` - TextScale int `json:"textScale"` - CoverPageNumber *int `json:"coverPageNumber,omitempty"` + Pages []string `json:"pages"` + PageTags []PageTag `json:"pageTags"` + DocumentTags []Tag `json:"tags"` + RedirectionMap []int `json:"redirectionPageMap"` + TextScale float64 `json:"textScale"` + CoverPageNumber *int `json:"coverPageNumber,omitempty"` - Transform Transform `json:"transform"` + Transform *Transform `json:"-"` } // ExtraMetadata is a struct contained into a Content struct. diff --git a/archive/zipdoc.go b/archive/zipdoc.go index ae938946..a92dc683 100644 --- a/archive/zipdoc.go +++ b/archive/zipdoc.go @@ -193,7 +193,7 @@ func createZipContent(ext string, pageIDs []string, coverpage *int) (string, err LineHeight: -1, Margins: 180, TextScale: 1, - Transform: Transform{ + Transform: &Transform{ M11: 1, M12: 0, M13: 0, @@ -204,7 +204,7 @@ func createZipContent(ext string, pageIDs []string, coverpage *int) (string, err M32: 0, M33: 1, }, - Pages: pageIDs, + Pages: pageIDs, CoverPageNumber: coverpage, } diff --git a/model/document.go b/model/document.go index fe4b7eca..6b384e85 100644 --- a/model/document.go +++ b/model/document.go @@ -15,6 +15,7 @@ type Document struct { CurrentPage int Starred bool Parent string + Tags []string } type BlobRootStorageRequest struct { diff --git a/shell/ls.go b/shell/ls.go index 848dad6f..e1e5bdaa 100644 --- a/shell/ls.go +++ b/shell/ls.go @@ -87,14 +87,15 @@ type LsOptions struct { } type NodeJSON struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Version int `json:"version"` - ModifiedClient string `json:"modifiedClient"` - CurrentPage int `json:"currentPage"` - Starred bool `json:"starred"` - Parent string `json:"parent"` + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Version int `json:"version"` + ModifiedClient string `json:"modifiedClient"` + CurrentPage int `json:"currentPage"` + Starred bool `json:"starred"` + Parent string `json:"parent"` + Tags []string `json:"tags"` } func nodeToJSON(node *model.Node) NodeJSON { @@ -107,6 +108,7 @@ func nodeToJSON(node *model.Node) NodeJSON { CurrentPage: node.Document.CurrentPage, Starred: node.Document.Starred, Parent: node.Document.Parent, + Tags: node.Document.Tags, } } From 9e8cc241588d78273a4b87ddaf4ddf555aa2d554 Mon Sep 17 00:00:00 2001 From: Laura Brekelmans Date: Sun, 7 Dec 2025 21:35:55 +0100 Subject: [PATCH 06/12] Add additional tag filter command line options, --starred and --tag --- shell/find.go | 59 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/shell/find.go b/shell/find.go index 599af08d..33e92e13 100644 --- a/shell/find.go +++ b/shell/find.go @@ -12,15 +12,31 @@ import ( flag "github.com/ogier/pflag" ) +// tagSlice implements flag.Value to collect multiple --tag flags +type tagSlice []string + +func (t *tagSlice) String() string { + return strings.Join(*t, ",") +} + +func (t *tagSlice) Set(value string) error { + *t = append(*t, value) + return nil +} + func findCmd(ctx *ShellCtxt) *ishell.Cmd { return &ishell.Cmd{ Name: "find", - Help: "find files recursively, usage: find dir [regexp]", + Help: "find files recursively, usage: find [options] [dir] [regexp]", Completer: createDirCompleter(ctx), Func: func(c *ishell.Context) { - flagSet := flag.NewFlagSet("ls", flag.ContinueOnError) + flagSet := flag.NewFlagSet("find", flag.ContinueOnError) var compact bool + var tags tagSlice + var starred bool flagSet.BoolVarP(&compact, "compact", "c", false, "compact format") + flagSet.Var(&tags, "tag", "filter by tag (can be specified multiple times, matches files with ANY of the tags)") + flagSet.BoolVar(&starred, "starred", false, "only show starred files") if err := flagSet.Parse(c.Args); err != nil { if err != flag.ErrHelp { c.Err(err) @@ -28,6 +44,15 @@ func findCmd(ctx *ShellCtxt) *ishell.Cmd { return } argRest := flagSet.Args() + + // Check if --starred flag was actually set + starredFilterEnabled := false + flagSet.Visit(func(f *flag.Flag) { + if f.Name == "starred" { + starredFilterEnabled = true + } + }) + var start, pattern string switch len(argRest) { case 2: @@ -38,7 +63,7 @@ func findCmd(ctx *ShellCtxt) *ishell.Cmd { case 0: start = ctx.path default: - c.Err(errors.New("missing arguments; usage find [dir] [regexp]")) + c.Err(errors.New("missing arguments; usage find [options] [dir] [regexp]")) return } @@ -60,6 +85,34 @@ func findCmd(ctx *ShellCtxt) *ishell.Cmd { filetree.WalkTree(startNode, filetree.FileTreeVistor{ Visit: func(node *model.Node, path []string) bool { + // Filter by starred status if flag was set + if starredFilterEnabled && node.Document != nil { + if node.Document.Starred != starred { + return false + } + } + + // Filter by tags if specified (must have ANY of the tags - OR semantics) + if len(tags) > 0 && node.Document != nil { + nodeTags := node.Document.Tags + hasMatch := false + for _, requiredTag := range tags { + for _, nodeTag := range nodeTags { + if nodeTag == requiredTag { + hasMatch = true + break + } + } + if hasMatch { + break + } + } + if !hasMatch { + // Doesn't have any of the required tags, skip this node + return false + } + } + entryName := formatEntry(compact, path, node) if matchRegexp == nil { From 6384f03e57002e2409092ffa713cb9b3b08cf118 Mon Sep 17 00:00:00 2001 From: Laura Brekelmans Date: Sun, 7 Dec 2025 21:50:42 +0100 Subject: [PATCH 07/12] Add detailed documentation for find syntax --- README.md | 44 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 222f2395..66163e61 100644 --- a/README.md +++ b/README.md @@ -122,16 +122,48 @@ Use `cd` to change the current directory to any other directory in the hierarchy ## Find a file -The command `find` takes one or two arguments. +The find command can be used to search through all of your reMarkable files recursively. -If only the first argument is passed, all entries from that point are printed recursively. +The first argument is the directory, and the second argument is your search query. -When the second argument is also passed, a regexp is expected, and only those entries that match the regexp are printed. +The search query is optional, when left out, the command will list files recursively. -Golang standard regexps are used. For instance, to make the regexp case insensitve you can do: +### Find examples -``` -find . (?i)foo +```bash +# Find all starred files +find --starred + +# Find starred files in the root directory +find --starred / + +# Find files with the "read-later" tag in the current directory or below (recursively) +find --tag="read-later" . + +# Searching is performed using standard Go regular expressions +# Find files using a regular expression, for example when you have a particular format for diary files +find / "Diary-.*" +# For example, if you date your journals like this "Journal-DD-MM-YYYY", you can search for all journals in 2024 using +find / "Journal-..-..-2024" +# Or just search for all files with 2024 in the filename +find / ".*2024.*" +# If you want to search ignore character casing, you can do that as follows: +find / "(?!i)case_insensitive_search" + +# Find files with either "Work" or "Personal" tag +find --tag="Work" --tag="Personal" + +# Find starred files with a specific tag +find --starred --tag="Important" + +# Combine with regexp search +find --tag="Projects/2024" . ".*report.*" + +# Tags can contain special characters like /, \, ", etc. +# Just quote them as you would any shell argument +find --tag="Work/Projects" --tag="tag,with,comma" +# This tag contains a double quote, you can escape it using \" +find --tag="tag-\"with-double-quote" ``` ## Upload a file From c9b93d2717633d50b3f6236cbad489c3b4b9980d Mon Sep 17 00:00:00 2001 From: Laura Brekelmans Date: Sun, 7 Dec 2025 21:52:36 +0100 Subject: [PATCH 08/12] Elaborate slightly on the find parameters --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 66163e61..c70fc877 100644 --- a/README.md +++ b/README.md @@ -122,12 +122,14 @@ Use `cd` to change the current directory to any other directory in the hierarchy ## Find a file -The find command can be used to search through all of your reMarkable files recursively. - +The find command can be used to search through all of your reMarkable files recursively. The first argument is the directory, and the second argument is your search query. - The search query is optional, when left out, the command will list files recursively. +You can find files with a particular tag using the `--tag` parameter, and filter for starred files using the `--starred` parameter. + +See the examples below: + ### Find examples ```bash From c8597008e61cbe091fcf152ae7b93e5f4217ce04 Mon Sep 17 00:00:00 2001 From: Laura Brekelmans Date: Sun, 7 Dec 2025 22:03:57 +0100 Subject: [PATCH 09/12] Factor out JSON display code and improve readme --- README.md | 10 +++++++--- shell/find.go | 28 +++++++++++++++++++++------- shell/ls.go | 42 ------------------------------------------ shell/output.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+), 52 deletions(-) create mode 100644 shell/output.go diff --git a/README.md b/README.md index c70fc877..8d695a3d 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,8 @@ interface Node { modifiedClient: string; // RFC3339Nano timestamp, empty for root currentPage: number; // 0-indexed, only meaningful for type=DocumentType parent: string; // parent ID, empty string for root children + tags: string[]; // A list of tags. Note, this does -not- include per-page tags. Also works for directories + starred: boolean; // Whether this item is starred or not. Also works for directories. } type LsOutput = Node[]; @@ -122,13 +124,15 @@ Use `cd` to change the current directory to any other directory in the hierarchy ## Find a file + The find command can be used to search through all of your reMarkable files recursively. The first argument is the directory, and the second argument is your search query. The search query is optional, when left out, the command will list files recursively. -You can find files with a particular tag using the `--tag` parameter, and filter for starred files using the `--starred` parameter. - -See the examples below: +- Flags: + - `--tag=` only show files that include this tag. You can supply multiple tag parameters + - `--starred` if supplied, only show starred files + - `--json` show output in JSON, see the `ls` documentation for more detail ### Find examples diff --git a/shell/find.go b/shell/find.go index 33e92e13..9059ce9b 100644 --- a/shell/find.go +++ b/shell/find.go @@ -83,6 +83,10 @@ func findCmd(ctx *ShellCtxt) *ishell.Cmd { } } + // Collect matching nodes + var matchedNodes []*model.Node + var matchedPaths [][]string + filetree.WalkTree(startNode, filetree.FileTreeVistor{ Visit: func(node *model.Node, path []string) bool { // Filter by starred status if flag was set @@ -115,20 +119,30 @@ func findCmd(ctx *ShellCtxt) *ishell.Cmd { entryName := formatEntry(compact, path, node) - if matchRegexp == nil { - c.Println(entryName) - return false - } - - if !matchRegexp.Match([]byte(entryName)) { + // Check regexp match if pattern is provided + if matchRegexp != nil && !matchRegexp.Match([]byte(entryName)) { return false } - c.Println(entryName) + // Collect matching node + matchedNodes = append(matchedNodes, node) + matchedPaths = append(matchedPaths, path) return false }, }) + + // Output results + if ctx.JSONOutput { + if err := displayNodesJSON(c, matchedNodes); err != nil { + c.Err(err) + } + } else { + for i, node := range matchedNodes { + entryName := formatEntry(compact, matchedPaths[i], node) + c.Println(entryName) + } + } }, } } diff --git a/shell/ls.go b/shell/ls.go index e1e5bdaa..09e8c258 100644 --- a/shell/ls.go +++ b/shell/ls.go @@ -1,7 +1,6 @@ package shell import ( - "encoding/json" "sort" "strings" "time" @@ -86,47 +85,6 @@ type LsOptions struct { ShowTemplates bool } -type NodeJSON struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Version int `json:"version"` - ModifiedClient string `json:"modifiedClient"` - CurrentPage int `json:"currentPage"` - Starred bool `json:"starred"` - Parent string `json:"parent"` - Tags []string `json:"tags"` -} - -func nodeToJSON(node *model.Node) NodeJSON { - return NodeJSON{ - ID: node.Document.ID, - Name: node.Document.Name, - Type: node.Document.Type, - Version: node.Document.Version, - ModifiedClient: node.Document.ModifiedClient, - CurrentPage: node.Document.CurrentPage, - Starred: node.Document.Starred, - Parent: node.Document.Parent, - Tags: node.Document.Tags, - } -} - -func displayNodesJSON(c *ishell.Context, nodes []*model.Node) error { - jsonNodes := make([]NodeJSON, len(nodes)) - for i, node := range nodes { - jsonNodes[i] = nodeToJSON(node) - } - - output, err := json.MarshalIndent(jsonNodes, "", " ") - if err != nil { - return err - } - - c.Println(string(output)) - return nil -} - func lsCmd(ctx *ShellCtxt) *ishell.Cmd { return &ishell.Cmd{ Name: "ls", diff --git a/shell/output.go b/shell/output.go new file mode 100644 index 00000000..3a15b768 --- /dev/null +++ b/shell/output.go @@ -0,0 +1,49 @@ +package shell + +import ( + "encoding/json" + + "github.com/abiosoft/ishell" + "github.com/juruen/rmapi/model" +) + +type NodeJSON struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Version int `json:"version"` + ModifiedClient string `json:"modifiedClient"` + CurrentPage int `json:"currentPage"` + Starred bool `json:"starred"` + Parent string `json:"parent"` + Tags []string `json:"tags"` +} + +func nodeToJSON(node *model.Node) NodeJSON { + return NodeJSON{ + ID: node.Document.ID, + Name: node.Document.Name, + Type: node.Document.Type, + Version: node.Document.Version, + ModifiedClient: node.Document.ModifiedClient, + CurrentPage: node.Document.CurrentPage, + Starred: node.Document.Starred, + Parent: node.Document.Parent, + Tags: node.Document.Tags, + } +} + +func displayNodesJSON(c *ishell.Context, nodes []*model.Node) error { + jsonNodes := make([]NodeJSON, len(nodes)) + for i, node := range nodes { + jsonNodes[i] = nodeToJSON(node) + } + + output, err := json.MarshalIndent(jsonNodes, "", " ") + if err != nil { + return err + } + + c.Println(string(output)) + return nil +} From 0d5c2a1b01d1f457b493fa375f9e3917efbc1dbe Mon Sep 17 00:00:00 2001 From: Laura Brekelmans Date: Sun, 7 Dec 2025 22:10:44 +0100 Subject: [PATCH 10/12] Remove a few redundant comments --- shell/find.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/shell/find.go b/shell/find.go index 9059ce9b..9ef331cb 100644 --- a/shell/find.go +++ b/shell/find.go @@ -45,7 +45,6 @@ func findCmd(ctx *ShellCtxt) *ishell.Cmd { } argRest := flagSet.Args() - // Check if --starred flag was actually set starredFilterEnabled := false flagSet.Visit(func(f *flag.Flag) { if f.Name == "starred" { @@ -83,7 +82,6 @@ func findCmd(ctx *ShellCtxt) *ishell.Cmd { } } - // Collect matching nodes var matchedNodes []*model.Node var matchedPaths [][]string @@ -96,7 +94,7 @@ func findCmd(ctx *ShellCtxt) *ishell.Cmd { } } - // Filter by tags if specified (must have ANY of the tags - OR semantics) + // Filter by tags if specified - using OR semantics if len(tags) > 0 && node.Document != nil { nodeTags := node.Document.Tags hasMatch := false @@ -112,7 +110,6 @@ func findCmd(ctx *ShellCtxt) *ishell.Cmd { } } if !hasMatch { - // Doesn't have any of the required tags, skip this node return false } } @@ -124,7 +121,6 @@ func findCmd(ctx *ShellCtxt) *ishell.Cmd { return false } - // Collect matching node matchedNodes = append(matchedNodes, node) matchedPaths = append(matchedPaths, path) @@ -132,7 +128,6 @@ func findCmd(ctx *ShellCtxt) *ishell.Cmd { }, }) - // Output results if ctx.JSONOutput { if err := displayNodesJSON(c, matchedNodes); err != nil { c.Err(err) From 7146a9ef4e64d7af89c0310a94b3c4b7233974b5 Mon Sep 17 00:00:00 2001 From: Laura Brekelmans Date: Sun, 14 Dec 2025 21:59:12 +0100 Subject: [PATCH 11/12] Skip deleted files and improve warning + readme --- README.md | 2 +- shell/find.go | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8d695a3d..3ab08a4f 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ find / "Journal-..-..-2024" # Or just search for all files with 2024 in the filename find / ".*2024.*" # If you want to search ignore character casing, you can do that as follows: -find / "(?!i)case_insensitive_search" +find / "(?i)case_insensitive_search" # Find files with either "Work" or "Personal" tag find --tag="Work" --tag="Personal" diff --git a/shell/find.go b/shell/find.go index 9ef331cb..3d080ce8 100644 --- a/shell/find.go +++ b/shell/find.go @@ -62,7 +62,7 @@ func findCmd(ctx *ShellCtxt) *ishell.Cmd { case 0: start = ctx.path default: - c.Err(errors.New("missing arguments; usage find [options] [dir] [regexp]")) + flagSet.Usage() return } @@ -87,6 +87,11 @@ func findCmd(ctx *ShellCtxt) *ishell.Cmd { filetree.WalkTree(startNode, filetree.FileTreeVistor{ Visit: func(node *model.Node, path []string) bool { + // Skip items in trash + if node.Document != nil && node.Document.Parent == "trash" { + return false + } + // Filter by starred status if flag was set if starredFilterEnabled && node.Document != nil { if node.Document.Starred != starred { From 23dca7a5431ee88074747a1e2a9617d16e2e7745 Mon Sep 17 00:00:00 2001 From: Laura Brekelmans Date: Wed, 7 Jan 2026 16:37:48 +0100 Subject: [PATCH 12/12] Add ability to download files by ID --- README.md | 14 ++++++++++++++ shell/get.go | 45 ++++++++++++++++++++++++++++++++++++--------- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3ab08a4f..8ef056b4 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,20 @@ mput (-src sourcfolder) /Papers Use `get path_to_file` to download a file from the cloud to your local computer. +### Download flags + +- `--id`: Interpret the argument as a document ID instead of a path. This is useful when you have the document ID from the `find` command's JSON output. + +Examples: + +```bash +# Download by path +get /Books/MyBook + +# Download by document ID (find document ID using the stat command or --json flag) +get --id abc123-def456-789 +``` + ## Recursively download directories and files Use `mget path_to_dir` to recursively download all the files in that directory. diff --git a/shell/get.go b/shell/get.go index 843b962e..1fb7a0d0 100644 --- a/shell/get.go +++ b/shell/get.go @@ -5,30 +5,57 @@ import ( "fmt" "github.com/abiosoft/ishell" + "github.com/juruen/rmapi/model" "github.com/juruen/rmapi/util" + flag "github.com/ogier/pflag" ) func getCmd(ctx *ShellCtxt) *ishell.Cmd { return &ishell.Cmd{ Name: "get", - Help: "copy remote file to local", + Help: "copy remote file to local, usage: get [--id] ", Completer: createEntryCompleter(ctx), Func: func(c *ishell.Context) { - if len(c.Args) == 0 { - c.Err(errors.New("missing source file")) + flagSet := flag.NewFlagSet("get", flag.ContinueOnError) + var byId bool + flagSet.BoolVar(&byId, "id", false, "interpret argument as document ID instead of path") + if err := flagSet.Parse(c.Args); err != nil { + if err != flag.ErrHelp { + c.Err(err) + } return } + args := flagSet.Args() - srcName := c.Args[0] + if len(args) == 0 { + c.Err(errors.New("missing source file or id")) + return + } - node, err := ctx.api.Filetree().NodeByPath(srcName, ctx.node) + srcArg := args[0] + var node *model.Node + var err error + + if byId { + node = ctx.api.Filetree().NodeById(srcArg) + if node == nil { + c.Err(errors.New("document with given ID doesn't exist")) + return + } + } else { + node, err = ctx.api.Filetree().NodeByPath(srcArg, ctx.node) + if err != nil { + c.Err(errors.New("file doesn't exist")) + return + } + } - if err != nil || node.IsDirectory() { - c.Err(errors.New("file doesn't exist")) + if node.IsDirectory() { + c.Err(errors.New("cannot download a directory")) return } - c.Println(fmt.Sprintf("downloading: [%s]...", srcName)) + c.Println(fmt.Sprintf("downloading: [%s]...", node.Name())) err = ctx.api.FetchDocument(node.Document.ID, fmt.Sprintf("%s.%s", node.Name(), util.RMDOC)) @@ -37,7 +64,7 @@ func getCmd(ctx *ShellCtxt) *ishell.Cmd { return } - c.Err(errors.New(fmt.Sprintf("Failed to download file %s with %s", srcName, err.Error()))) + c.Err(errors.New(fmt.Sprintf("Failed to download file %s with %s", node.Name(), err.Error()))) }, } }