diff --git a/.agents/skills/use-ocis-cli/SKILL.md b/.agents/skills/use-ocis-cli/SKILL.md index 0cdd8d9..4f6f1f0 100644 --- a/.agents/skills/use-ocis-cli/SKILL.md +++ b/.agents/skills/use-ocis-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: use-ocis-cli -description: Safely operate oCIS servers through the installed ocis command-line client. Use when an AI agent is asked to inspect, list, search, transfer, synchronize, share, restore, or administer files, Spaces, shares, users, or groups in oCIS. Do not use for developing the ocis-cli source code. +description: Safely operate oCIS servers through the installed ocis command-line client. Use when an AI agent is asked to inspect, list, search, transfer, synchronize, share, restore, manage notifications, or administer files, Spaces, shares, users, or groups in oCIS. Do not use for developing the ocis-cli source code. --- # Use oCIS CLI @@ -44,8 +44,8 @@ protocol-level work. parse human-readable tables when structured output is available. - Use read-only discovery commands such as `ls`, `stat`, `search`, `tree`, `space list`, `share overview`, `federation connection list`, `trash list`, - and admin `list` or `info` commands to resolve names and IDs before changing - anything. + `notification list`, and admin `list` or `info` commands to resolve names and + IDs before changing anything. - Interpret a remote path in the selected Space. Keep local filesystem paths and remote oCIS paths distinct according to the command help. @@ -61,6 +61,9 @@ protocol-level work. file or folder is a separate explicit operation. - Use `trash` for recoverable deletion management and `version` for historical file versions. +- Use `notification list` and `notification info` to inspect unread events. + In oCIS, `notification dismiss` is the server's mark-as-read operation; it + does not delete the resource referenced by the notification. - Use `admin` only when the user explicitly requests administration. A normal user may not have permission; report authorization failures without trying to bypass them. @@ -82,7 +85,7 @@ Never add `--yes` merely to avoid a prompt. Use it only when the user has explicitly authorized that exact operation. Do not silently overwrite files, empty trash, permanently delete resources, disable or delete accounts, change roles, accept federation invitations, remove federation connections, accept or -decline shares, or execute unreviewed batch input. +decline shares, clear all notifications, or execute unreviewed batch input. ## Protect authentication and secrets diff --git a/.agents/skills/use-ocis-cli/references/commands.md b/.agents/skills/use-ocis-cli/references/commands.md index b471e32..c363c50 100644 --- a/.agents/skills/use-ocis-cli/references/commands.md +++ b/.agents/skills/use-ocis-cli/references/commands.md @@ -19,6 +19,7 @@ same command. - Spaces - Sharing - Federation +- Notifications - Metadata, trash, and versions - Administration - CLI utilities and global flags @@ -116,6 +117,19 @@ An invitation token establishes identity trust; it does not share a resource. Treat invitation tokens as secrets and never accept one without an explicit user request. +## Notifications + +| Command | Purpose | +| --- | --- | +| `notification list, notification ls` | List unread in-app notifications; optionally filter them with a search argument. | +| `notification info` | Inspect one unread notification by its opaque ID. | +| `notification dismiss, notification read` | Remove one or more notifications from the unread list. | +| `notification clear, notification read-all` | Remove every notification from the unread list after confirmation. | + +In oCIS, dismissing is the server's mark-as-read operation. It does not delete +the resource mentioned by the notification. Preview dismissal or clearing with +`--dry-run`; never clear all notifications without an explicit user request. + ## Metadata, trash, and versions | Command | Purpose | diff --git a/.claude/skills/use-ocis-cli/SKILL.md b/.claude/skills/use-ocis-cli/SKILL.md index 0cdd8d9..4f6f1f0 100644 --- a/.claude/skills/use-ocis-cli/SKILL.md +++ b/.claude/skills/use-ocis-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: use-ocis-cli -description: Safely operate oCIS servers through the installed ocis command-line client. Use when an AI agent is asked to inspect, list, search, transfer, synchronize, share, restore, or administer files, Spaces, shares, users, or groups in oCIS. Do not use for developing the ocis-cli source code. +description: Safely operate oCIS servers through the installed ocis command-line client. Use when an AI agent is asked to inspect, list, search, transfer, synchronize, share, restore, manage notifications, or administer files, Spaces, shares, users, or groups in oCIS. Do not use for developing the ocis-cli source code. --- # Use oCIS CLI @@ -44,8 +44,8 @@ protocol-level work. parse human-readable tables when structured output is available. - Use read-only discovery commands such as `ls`, `stat`, `search`, `tree`, `space list`, `share overview`, `federation connection list`, `trash list`, - and admin `list` or `info` commands to resolve names and IDs before changing - anything. + `notification list`, and admin `list` or `info` commands to resolve names and + IDs before changing anything. - Interpret a remote path in the selected Space. Keep local filesystem paths and remote oCIS paths distinct according to the command help. @@ -61,6 +61,9 @@ protocol-level work. file or folder is a separate explicit operation. - Use `trash` for recoverable deletion management and `version` for historical file versions. +- Use `notification list` and `notification info` to inspect unread events. + In oCIS, `notification dismiss` is the server's mark-as-read operation; it + does not delete the resource referenced by the notification. - Use `admin` only when the user explicitly requests administration. A normal user may not have permission; report authorization failures without trying to bypass them. @@ -82,7 +85,7 @@ Never add `--yes` merely to avoid a prompt. Use it only when the user has explicitly authorized that exact operation. Do not silently overwrite files, empty trash, permanently delete resources, disable or delete accounts, change roles, accept federation invitations, remove federation connections, accept or -decline shares, or execute unreviewed batch input. +decline shares, clear all notifications, or execute unreviewed batch input. ## Protect authentication and secrets diff --git a/.claude/skills/use-ocis-cli/references/commands.md b/.claude/skills/use-ocis-cli/references/commands.md index b471e32..c363c50 100644 --- a/.claude/skills/use-ocis-cli/references/commands.md +++ b/.claude/skills/use-ocis-cli/references/commands.md @@ -19,6 +19,7 @@ same command. - Spaces - Sharing - Federation +- Notifications - Metadata, trash, and versions - Administration - CLI utilities and global flags @@ -116,6 +117,19 @@ An invitation token establishes identity trust; it does not share a resource. Treat invitation tokens as secrets and never accept one without an explicit user request. +## Notifications + +| Command | Purpose | +| --- | --- | +| `notification list, notification ls` | List unread in-app notifications; optionally filter them with a search argument. | +| `notification info` | Inspect one unread notification by its opaque ID. | +| `notification dismiss, notification read` | Remove one or more notifications from the unread list. | +| `notification clear, notification read-all` | Remove every notification from the unread list after confirmation. | + +In oCIS, dismissing is the server's mark-as-read operation. It does not delete +the resource mentioned by the notification. Preview dismissal or clearing with +`--dry-run`; never clear all notifications without an explicit user request. + ## Metadata, trash, and versions | Command | Purpose | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 980b6f1..ac83d4e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -19,6 +19,7 @@ internal/ graph/ LibreGraph Spaces, directory, and permission client httpapi/ authenticated retrying HTTP transport logging/ opt-in diagnostic logging abstraction + notifications/ authenticated oCIS userlog/OCS notification client output/ terminal and JSON/JSONL rendering retry/ shared bounded retry and backoff policy search/ WebDAV search-files REPORT client and response mapping @@ -57,6 +58,7 @@ without starting a subprocess. `batch_service.go`, `filesystem_service.go`, `filesystem_tree_service.go`, `filesystem_du_service.go`, `filesystem_touch_service.go`, `filesystem_walk.go`, `metadata_service.go`, + `notification_service.go`, `share_overview_service.go`, `space_member_service.go`, `space_update_service.go`, `space_lifecycle_service.go`, and @@ -87,6 +89,10 @@ without starting a subprocess. - `internal/httpapi`: send replayable authenticated API requests with bounded retries for non-WebDAV protocols, using the shared `internal/retry` policy. - `internal/logging`: provide an injected no-op or text diagnostic logger. +- `internal/notifications`: list and dismiss the authenticated user's unread + in-app notifications through the bounded oCIS userlog OCS API. It does not + invent a separate read state: dismissing is the server's mark-as-read + operation. - `internal/output`: render human-readable output and versioned JSON/JSONL envelopes through injected writers. - `internal/retry`: decide which responses may be retried and how long to wait, @@ -130,8 +136,9 @@ without starting a subprocess. scalar custom-property `PROPFIND`/`PROPPATCH` operations. Protocol-specific behavior belongs in dedicated `internal/auth`, -`internal/federation`, `internal/graph`, `internal/search`, `internal/sharing`, -`internal/trash`, `internal/versions`, and `internal/webdav` adapters. Recursive local/remote +`internal/federation`, `internal/graph`, `internal/notifications`, +`internal/search`, `internal/sharing`, `internal/trash`, `internal/versions`, +and `internal/webdav` adapters. Recursive local/remote traversal belongs in `internal/transfer`. Configuration, credentials, protected upload-session storage, named sync jobs, @@ -169,6 +176,9 @@ Fast package tests remain Docker-independent. resolve only server-returned users of type `Federated` using the exact oCIS Graph filter. Federated roles are requested from the server separately from local-user roles. +- Clearing all unread notifications requires explicit intent in both the Cobra + and application layers. Dismissal resolves every requested opaque ID against + the authenticated user's current unread list before changing server state. - Space names and aliases are convenience selectors. Destructive operations on disabled Spaces use stable IDs. - Server-advertised permissions and roles are authoritative; the CLI does not diff --git a/Makefile b/Makefile index a4fc745..e293d38 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ check: fmt coverage: go run ./tools/covercheck -min $(COVERAGE_MIN) \ - app auth federation graph httpapi retry search sharing sync trash transfer versions \ + app auth federation graph httpapi notifications retry search sharing sync trash transfer versions \ webdav fmt: diff --git a/README.md b/README.md index dde2b41..4ce0d45 100644 --- a/README.md +++ b/README.md @@ -1204,6 +1204,44 @@ sharing restrictions, and resource permissions. A user may be able to read a file without being allowed to share it, update a share, or remove another user's permission. +## Notifications + +List and inspect the authenticated user's unread in-app notifications: + +```sh +ocis notification list +ocis notification list report +ocis notification info NOTIFICATION_ID +ocis --json notification list +``` + +The optional `list` argument searches locally across the notification ID, +actor, subject, message, application, and related object. Human output shows +the newest notifications first; JSON and JSONL retain the full mapped server +record for automation. + +In oCIS, marking a notification as read removes it from the unread userlog. +The CLI calls that operation `dismiss`; `read` is an equivalent alias. It does +not delete a file, share, or other referenced resource: + +```sh +ocis notification dismiss NOTIFICATION_ID --dry-run +ocis notification dismiss NOTIFICATION_ID +ocis notification read NOTIFICATION_ID +``` + +Multiple IDs are dismissed in one server request. Clearing the complete unread +list is explicit, prompts for confirmation, and can be previewed safely: + +```sh +ocis notification clear --dry-run +ocis notification clear +``` + +The server controls which events create notifications and whether its +notification service is enabled. These commands manage only notifications +visible to the currently selected CLI profile. + ## Machine-readable output `--json` writes one indented result and `--jsonl` writes one compact record per diff --git a/internal/app/notification_api.go b/internal/app/notification_api.go new file mode 100644 index 0000000..035da30 --- /dev/null +++ b/internal/app/notification_api.go @@ -0,0 +1,37 @@ +package app + +import "context" + +// NotificationOperation identifies an unread-notification use case. +type NotificationOperation string + +const ( + NotificationList NotificationOperation = "list" + NotificationInfo NotificationOperation = "info" + NotificationDismiss NotificationOperation = "dismiss" + NotificationClear NotificationOperation = "clear" +) + +// NotificationRequest describes one notification operation. +type NotificationRequest struct { + Operation NotificationOperation + IDs []string + Search string + Confirmed bool + DryRun bool +} + +// RunNotificationWithOptions manages unread in-app notifications. +func RunNotificationWithOptions( + ctx context.Context, + request NotificationRequest, + selectedProfile string, + options RunOptions, +) error { + return classifyProtocolError( + "notification "+string(request.Operation), + runNotification( + ctx, request, selectedProfile, options.normalized(), + ), + ) +} diff --git a/internal/app/notification_service.go b/internal/app/notification_service.go new file mode 100644 index 0000000..80dd8bb --- /dev/null +++ b/internal/app/notification_service.go @@ -0,0 +1,309 @@ +package app + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/mzner/ocis-cli/internal/apperror" + "github.com/mzner/ocis-cli/internal/notifications" + appoutput "github.com/mzner/ocis-cli/internal/output" +) + +func runNotification( + ctx context.Context, + request NotificationRequest, + selectedProfile string, + options RunOptions, +) error { + if request.Operation == NotificationClear && !request.DryRun && + !request.Confirmed { + return usageNotification( + "clearing every notification requires explicit confirmation", + ) + } + client, err := newClientWithOptions(ctx, selectedProfile, options) + if err != nil { + return err + } + switch request.Operation { + case NotificationList: + return listNotifications(ctx, client, request.Search, options) + case NotificationInfo: + return showNotification(ctx, client, firstNotificationID(request.IDs), options) + case NotificationDismiss: + return dismissNotifications(ctx, client, request.IDs, request.DryRun, options) + case NotificationClear: + return clearNotifications(ctx, client, request.DryRun, options) + default: + return usageNotification(fmt.Sprintf( + "unknown notification command %q", request.Operation, + )) + } +} + +func listNotifications( + ctx context.Context, + client *client, + search string, + options RunOptions, +) error { + values, err := client.notificationsClient().List(ctx) + if err != nil { + return err + } + values = filterNotifications(values, search) + sortNotifications(values) + return writeNotifications(values, options) +} + +func showNotification( + ctx context.Context, + client *client, + id string, + options RunOptions, +) error { + value, err := resolveNotification(ctx, client, id) + if err != nil { + return err + } + if options.OutputMode != appoutput.Human { + return writeOutput(options, "notification", value) + } + if _, err := fmt.Fprintf(options.Out, "ID: %s\n", value.ID); err != nil { + return err + } + if _, err := fmt.Fprintf(options.Out, "Date: %s\n", value.DateTime); err != nil { + return err + } + if _, err := fmt.Fprintf(options.Out, "Subject: %s\n", value.Subject); err != nil { + return err + } + if _, err := fmt.Fprintf(options.Out, "Message: %s\n", oneLine(value.Message)); err != nil { + return err + } + if value.User != "" { + if _, err := fmt.Fprintf(options.Out, "Actor: %s\n", value.User); err != nil { + return err + } + } + if value.ObjectType != "" || value.ObjectID != "" { + _, err := fmt.Fprintf( + options.Out, "Object: %s %s\n", value.ObjectType, value.ObjectID, + ) + return err + } + return nil +} + +func dismissNotifications( + ctx context.Context, + client *client, + ids []string, + dryRun bool, + options RunOptions, +) error { + selected, err := resolveNotifications(ctx, client, ids) + if err != nil { + return err + } + result := map[string]any{ + "operation": "dismiss", "notifications": selected, + "count": len(selected), "dryRun": dryRun, + } + if dryRun { + return output( + options, "notification-dismissal", result, + "Would dismiss %d notification(s)\n", len(selected), + ) + } + selectedIDs := notificationIDs(selected) + if len(selectedIDs) == 1 { + err = client.notificationsClient().Dismiss(ctx, selectedIDs[0]) + } else { + err = client.notificationsClient().DismissMany(ctx, selectedIDs) + } + if err != nil { + return err + } + result["dryRun"] = false + return output( + options, "notification-dismissal", result, + "Dismissed %d notification(s)\n", len(selected), + ) +} + +func clearNotifications( + ctx context.Context, + client *client, + dryRun bool, + options RunOptions, +) error { + values, err := client.notificationsClient().List(ctx) + if err != nil { + return err + } + result := map[string]any{ + "operation": "clear", "count": len(values), "dryRun": dryRun, + } + if dryRun { + return output( + options, "notification-clear", result, + "Would dismiss %d notification(s)\n", len(values), + ) + } + if len(values) == 0 { + return output( + options, "notification-clear", result, + "There are no unread notifications\n", + ) + } + if err := client.notificationsClient().DismissMany( + ctx, notificationIDs(values), + ); err != nil { + return err + } + return output( + options, "notification-clear", result, + "Dismissed %d notification(s)\n", len(values), + ) +} + +func writeNotifications( + values []notifications.Notification, options RunOptions, +) error { + if options.OutputMode != appoutput.Human { + return writeOutput(options, "notification", values) + } + writer := tabwriter.NewWriter(options.Out, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(writer, "DATE\tSUBJECT\tMESSAGE\tID"); err != nil { + return err + } + for _, value := range values { + if _, err := fmt.Fprintf( + writer, "%s\t%s\t%s\t%s\n", value.DateTime, + oneLine(value.Subject), oneLine(value.Message), value.ID, + ); err != nil { + return err + } + } + return writer.Flush() +} + +func filterNotifications( + values []notifications.Notification, search string, +) []notifications.Notification { + search = strings.ToLower(strings.TrimSpace(search)) + if search == "" { + return values + } + result := make([]notifications.Notification, 0, len(values)) + for _, value := range values { + haystack := strings.ToLower(strings.Join([]string{ + value.ID, value.App, value.User, value.ObjectID, value.ObjectType, + value.Subject, value.Message, + }, "\x00")) + if strings.Contains(haystack, search) { + result = append(result, value) + } + } + return result +} + +func resolveNotification( + ctx context.Context, client *client, id string, +) (notifications.Notification, error) { + values, err := resolveNotifications(ctx, client, []string{id}) + if err != nil { + return notifications.Notification{}, err + } + return values[0], nil +} + +func resolveNotifications( + ctx context.Context, + client *client, + ids []string, +) ([]notifications.Notification, error) { + requested := make([]string, 0, len(ids)) + seen := make(map[string]struct{}, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + return nil, usageNotification("notification ID must not be empty") + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + requested = append(requested, id) + } + if len(requested) == 0 { + return nil, usageNotification("at least one notification ID is required") + } + values, err := client.notificationsClient().List(ctx) + if err != nil { + return nil, err + } + byID := make(map[string]notifications.Notification, len(values)) + for _, value := range values { + byID[value.ID] = value + } + selected := make([]notifications.Notification, 0, len(requested)) + for _, id := range requested { + value, exists := byID[id] + if !exists { + return nil, apperror.Wrap( + apperror.KindNotFound, "notification", + fmt.Errorf( + "unknown notification %q; run ocis notification list", id, + ), + ) + } + selected = append(selected, value) + } + return selected, nil +} + +func sortNotifications(values []notifications.Notification) { + sort.SliceStable(values, func(left, right int) bool { + leftTime, leftErr := time.Parse(time.RFC3339Nano, values[left].DateTime) + rightTime, rightErr := time.Parse(time.RFC3339Nano, values[right].DateTime) + if leftErr == nil && rightErr == nil && !leftTime.Equal(rightTime) { + return leftTime.After(rightTime) + } + if values[left].DateTime != values[right].DateTime { + return values[left].DateTime > values[right].DateTime + } + return values[left].ID < values[right].ID + }) +} + +func notificationIDs(values []notifications.Notification) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + result = append(result, value.ID) + } + return result +} + +func firstNotificationID(ids []string) string { + if len(ids) == 0 { + return "" + } + return ids[0] +} + +func oneLine(value string) string { + return strings.Join(strings.Fields(value), " ") +} + +func usageNotification(message string) error { + return apperror.Wrap( + apperror.KindUsage, "notification", errors.New(message), + ) +} diff --git a/internal/app/notification_service_test.go b/internal/app/notification_service_test.go new file mode 100644 index 0000000..ddb4eb6 --- /dev/null +++ b/internal/app/notification_service_test.go @@ -0,0 +1,136 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/mzner/ocis-cli/internal/apperror" + appoutput "github.com/mzner/ocis-cli/internal/output" +) + +const appNotificationResponse = `{"ocs":{"meta":{ + "status":"ok","statuscode":200,"message":"OK" +},"data":[{ + "notification_id":"notification-1","app":"userlog","user":"Alice", + "datetime":"2026-08-10T08:00:00Z","object_id":"share-1", + "object_type":"share","subject":"Resource shared", + "message":"Alice shared report.txt with you" +},{ + "notification_id":"notification-2","app":"userlog","user":"System", + "datetime":"2026-08-10T09:00:00Z","object_id":"file-1", + "object_type":"resource","subject":"Virus found", + "message":"A virus was found in upload.txt" +}]}}` + +func TestNotificationUseCases(t *testing.T) { + var dismissed string + var dismissedMany []string + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, request *http.Request, + ) { + switch request.Method + " " + request.URL.Path { + case "GET /ocs/v2.php/apps/notifications/api/v1/notifications": + _, _ = io.WriteString(writer, appNotificationResponse) + case "DELETE /ocs/v2.php/apps/notifications/api/v1/notifications/notification-1": + dismissed = "notification-1" + case "DELETE /ocs/v2.php/apps/notifications/api/v1/notifications": + var body struct { + IDs []string `json:"ids"` + } + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + dismissedMany = body.IDs + default: + t.Fatalf("unexpected request: %s %s", request.Method, request.URL.Path) + } + })) + defer server.Close() + configureSpaceTestProfile(t, server.URL, "") + + var list bytes.Buffer + if err := RunNotificationWithOptions( + context.Background(), NotificationRequest{ + Operation: NotificationList, Search: "virus", + }, "", RunOptions{Out: &list, OutputMode: appoutput.JSON}, + ); err != nil || !strings.Contains(list.String(), `"id": "notification-2"`) || + strings.Contains(list.String(), `"id": "notification-1"`) { + t.Fatalf("list=%q error=%v", list.String(), err) + } + + var info bytes.Buffer + if err := RunNotificationWithOptions( + context.Background(), NotificationRequest{ + Operation: NotificationInfo, IDs: []string{"notification-1"}, + }, "", RunOptions{Out: &info}, + ); err != nil || !strings.Contains(info.String(), "Resource shared") || + !strings.Contains(info.String(), "share share-1") { + t.Fatalf("info=%q error=%v", info.String(), err) + } + + if err := RunNotificationWithOptions( + context.Background(), NotificationRequest{ + Operation: NotificationDismiss, IDs: []string{"notification-1"}, + DryRun: true, + }, "", RunOptions{Out: io.Discard}, + ); err != nil || dismissed != "" { + t.Fatalf("dry-run dismissed=%q error=%v", dismissed, err) + } + if err := RunNotificationWithOptions( + context.Background(), NotificationRequest{ + Operation: NotificationDismiss, IDs: []string{"notification-1"}, + }, "", RunOptions{Out: io.Discard}, + ); err != nil || dismissed != "notification-1" { + t.Fatalf("dismissed=%q error=%v", dismissed, err) + } + + if err := RunNotificationWithOptions( + context.Background(), NotificationRequest{ + Operation: NotificationClear, Confirmed: true, + }, "", RunOptions{Out: io.Discard}, + ); err != nil || !reflect.DeepEqual( + dismissedMany, []string{"notification-1", "notification-2"}, + ) { + t.Fatalf("dismissed=%v error=%v", dismissedMany, err) + } +} + +func TestNotificationClearFailsClosed(t *testing.T) { + t.Setenv("OCIS_CONFIG", filepath.Join(t.TempDir(), "missing", "config.json")) + err := RunNotificationWithOptions( + context.Background(), NotificationRequest{ + Operation: NotificationClear, + }, "", RunOptions{Out: io.Discard}, + ) + if !apperror.IsKind(err, apperror.KindUsage) || + !strings.Contains(err.Error(), "explicit confirmation") { + t.Fatalf("error: %v", err) + } +} + +func TestNotificationResolutionRejectsUnknownID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, _ *http.Request, + ) { + _, _ = io.WriteString(writer, appNotificationResponse) + })) + defer server.Close() + configureSpaceTestProfile(t, server.URL, "") + err := RunNotificationWithOptions( + context.Background(), NotificationRequest{ + Operation: NotificationInfo, IDs: []string{"missing"}, + }, "", RunOptions{Out: io.Discard}, + ) + if !apperror.IsKind(err, apperror.KindNotFound) || + !strings.Contains(err.Error(), "notification list") { + t.Fatalf("error: %v", err) + } +} diff --git a/internal/app/runtime.go b/internal/app/runtime.go index 7879462..0f5d854 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -17,6 +17,7 @@ import ( "github.com/mzner/ocis-cli/internal/graph" "github.com/mzner/ocis-cli/internal/httpapi" "github.com/mzner/ocis-cli/internal/logging" + "github.com/mzner/ocis-cli/internal/notifications" appoutput "github.com/mzner/ocis-cli/internal/output" "github.com/mzner/ocis-cli/internal/search" "github.com/mzner/ocis-cli/internal/sharing" @@ -32,22 +33,23 @@ type profile = appconfig.Profile type item = webdav.Item type client struct { - name string - profile profile - http *http.Client - store *store - ctx context.Context - dav *webdav.Client - graph *graph.Client - search *search.Client - sharing *sharing.Client - recycle *trash.Client - versions *versions.Client - federation *federation.Client - space *graph.Drive - retries int - logger logging.Logger - dependencies Dependencies + name string + profile profile + http *http.Client + store *store + ctx context.Context + dav *webdav.Client + graph *graph.Client + search *search.Client + sharing *sharing.Client + recycle *trash.Client + versions *versions.Client + federation *federation.Client + notifications *notifications.Client + space *graph.Drive + retries int + logger logging.Logger + dependencies Dependencies } func (client *client) apiConfig() httpapi.Config { @@ -80,6 +82,13 @@ func (client *client) federationClient() *federation.Client { return client.federation } +func (client *client) notificationsClient() *notifications.Client { + if client.notifications == nil { + client.notifications = notifications.NewClient(client.apiConfig(), client.http) + } + return client.notifications +} + func (client *client) searchClient() *search.Client { if client.search == nil { client.search = search.NewClient(client.apiConfig(), client.http) diff --git a/internal/command/notification.go b/internal/command/notification.go new file mode 100644 index 0000000..8b25f3e --- /dev/null +++ b/internal/command/notification.go @@ -0,0 +1,102 @@ +package command + +import ( + "github.com/mzner/ocis-cli/internal/app" + "github.com/spf13/cobra" +) + +func newNotificationCommand(options *globalOptions) *cobra.Command { + command := &cobra.Command{ + Use: "notification", Aliases: []string{"notifications"}, + Short: "Manage unread in-app notifications", + } + command.AddCommand( + newNotificationListCommand(options), + newNotificationInfoCommand(options), + newNotificationDismissCommand(options), + newNotificationClearCommand(options), + ) + return command +} + +func newNotificationListCommand(options *globalOptions) *cobra.Command { + return &cobra.Command{ + Use: "list [SEARCH]", Aliases: []string{"ls"}, + Short: "List unread notifications", Args: maximumArgs(1), + RunE: func(command *cobra.Command, args []string) error { + search := "" + if len(args) == 1 { + search = args[0] + } + return runNotification(command, options, app.NotificationRequest{ + Operation: app.NotificationList, Search: search, + }) + }, + } +} + +func newNotificationInfoCommand(options *globalOptions) *cobra.Command { + return &cobra.Command{ + Use: "info ID", Short: "Show one unread notification", Args: exactArgs(1), + RunE: func(command *cobra.Command, args []string) error { + return runNotification(command, options, app.NotificationRequest{ + Operation: app.NotificationInfo, IDs: []string{args[0]}, + }) + }, + } +} + +func newNotificationDismissCommand(options *globalOptions) *cobra.Command { + var dryRun bool + command := &cobra.Command{ + Use: "dismiss ID [ID...]", Aliases: []string{"read", "remove", "rm", "delete"}, + Short: "Dismiss notifications from the unread list", Args: minimumArgs(1), + RunE: func(command *cobra.Command, args []string) error { + return runNotification(command, options, app.NotificationRequest{ + Operation: app.NotificationDismiss, IDs: args, DryRun: dryRun, + }) + }, + } + command.Flags().BoolVar( + &dryRun, "dry-run", false, + "resolve notifications without dismissing them", + ) + return command +} + +func newNotificationClearCommand(options *globalOptions) *cobra.Command { + var dryRun, yes bool + command := &cobra.Command{ + Use: "clear", Aliases: []string{"read-all"}, + Short: "Dismiss every unread notification", Args: noArgs, + RunE: func(command *cobra.Command, _ []string) error { + if !yes && !dryRun { + confirmed, err := confirmAction( + command, "Dismiss every unread notification?", + ) + if err != nil || !confirmed { + return err + } + } + return runNotification(command, options, app.NotificationRequest{ + Operation: app.NotificationClear, Confirmed: true, DryRun: dryRun, + }) + }, + } + command.Flags().BoolVar( + &dryRun, "dry-run", false, + "count unread notifications without dismissing them", + ) + command.Flags().BoolVar(&yes, "yes", false, "skip the confirmation prompt") + return command +} + +func runNotification( + command *cobra.Command, + options *globalOptions, + request app.NotificationRequest, +) error { + return app.RunNotificationWithOptions( + command.Context(), request, options.profile, options.runOptions(command), + ) +} diff --git a/internal/command/root.go b/internal/command/root.go index 7cd0ffb..1a1ee55 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -127,6 +127,7 @@ func NewRootCommand() *cobra.Command { newVersionCommand(options), newShareCommand(options), newFederationCommand(options), + newNotificationCommand(options), newSearchCommand(options), newSyncCommand(options), newTagCommand(options), diff --git a/internal/command/root_test.go b/internal/command/root_test.go index a01f5df..1fa4b8c 100644 --- a/internal/command/root_test.go +++ b/internal/command/root_test.go @@ -70,6 +70,7 @@ func TestGeneratedHelpIncludesGlobalFlags(t *testing.T) { "search, find", "tag", "favorite", "property", "admin", "sync", "config", "cat", "tree", "du", "batch", "touch", "federation, federated, ocm", + "notification, notifications", } { if !strings.Contains(help, expected) { t.Fatalf("help does not contain %q:\n%s", expected, help) @@ -77,6 +78,35 @@ func TestGeneratedHelpIncludesGlobalFlags(t *testing.T) { } } +func TestNotificationCommandsAndAliasesAreDiscoverable(t *testing.T) { + root := NewRootCommand() + var output bytes.Buffer + root.SetOut(&output) + root.SetErr(&output) + root.SetArgs([]string{"notification", "--help"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + for _, expected := range []string{ + "list, ls", "info", "dismiss, read, remove, rm, delete", + "clear, read-all", + } { + if !strings.Contains(output.String(), expected) { + t.Fatalf("notification help missing %q:\n%s", expected, output.String()) + } + } + for _, args := range [][]string{ + {"notifications", "ls"}, + {"notification", "read", "notification-1"}, + {"notification", "read-all"}, + } { + root = NewRootCommand() + if _, _, err := root.Find(args); err != nil { + t.Fatalf("%v: %v", args, err) + } + } +} + func TestFederationCommandsAndAliasesAreDiscoverable(t *testing.T) { for _, test := range []struct { command []string diff --git a/internal/notifications/client.go b/internal/notifications/client.go new file mode 100644 index 0000000..f38c4e8 --- /dev/null +++ b/internal/notifications/client.go @@ -0,0 +1,183 @@ +// Package notifications implements the authenticated oCIS userlog API used +// for in-app notifications. +package notifications + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/mzner/ocis-cli/internal/httpapi" +) + +const ( + endpoint = "/ocs/v2.php/apps/notifications/api/v1/notifications" + maxResponseBytes = 8 << 20 +) + +// Notification is one unread in-app notification returned by oCIS. +type Notification struct { + ID string `json:"id"` + App string `json:"app,omitempty"` + User string `json:"user,omitempty"` + DateTime string `json:"dateTime,omitempty"` + ObjectID string `json:"objectId,omitempty"` + ObjectType string `json:"objectType,omitempty"` + Subject string `json:"subject"` + SubjectRich string `json:"subjectRich,omitempty"` + Message string `json:"message"` + MessageRich string `json:"messageRich,omitempty"` + MessageDetails map[string]any `json:"messageDetails,omitempty"` +} + +// Client manages unread in-app notifications for the authenticated user. +type Client struct { + api *httpapi.Client +} + +// NewClient constructs a notifications client. +func NewClient(config httpapi.Config, httpClient *http.Client) *Client { + return &Client{api: httpapi.NewClient(config, httpClient)} +} + +// List returns the authenticated user's unread notifications. +func (client *Client) List(ctx context.Context) ([]Notification, error) { + response, err := client.api.Do( + ctx, http.MethodGet, endpoint+"?format=json", nil, headers(""), + ) + if err != nil { + return nil, err + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, httpapi.ResponseError(response) + } + var envelope rawEnvelope + if err := json.NewDecoder( + io.LimitReader(response.Body, maxResponseBytes), + ).Decode(&envelope); err != nil { + return nil, fmt.Errorf("decode notifications response: %w", err) + } + if err := envelope.check(); err != nil { + return nil, err + } + result := make([]Notification, 0, len(envelope.OCS.Data)) + for _, value := range envelope.OCS.Data { + result = append(result, value.notification()) + } + return result, nil +} + +// Dismiss removes one notification from the unread userlog. +func (client *Client) Dismiss(ctx context.Context, id string) error { + id = strings.TrimSpace(id) + if id == "" { + return errors.New("notification ID must not be empty") + } + return client.delete( + ctx, endpoint+"/"+url.PathEscape(id)+"?format=json", nil, + ) +} + +// DismissMany removes multiple notifications from the unread userlog in one +// request. +func (client *Client) DismissMany(ctx context.Context, ids []string) error { + normalized := make([]string, 0, len(ids)) + for _, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + return errors.New("notification IDs must not be empty") + } + normalized = append(normalized, id) + } + if len(normalized) == 0 { + return errors.New("at least one notification ID is required") + } + payload, err := json.Marshal(struct { + IDs []string `json:"ids"` + }{IDs: normalized}) + if err != nil { + return fmt.Errorf("encode notification dismissal request: %w", err) + } + return client.delete(ctx, endpoint+"?format=json", payload) +} + +func (client *Client) delete( + ctx context.Context, resource string, payload []byte, +) error { + contentType := "" + if payload != nil { + contentType = "application/json" + } + response, err := client.api.Do( + ctx, http.MethodDelete, resource, payload, headers(contentType), + ) + if err != nil { + return err + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return httpapi.ResponseError(response) + } + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxResponseBytes)) + return nil +} + +func headers(contentType string) http.Header { + result := http.Header{ + "Accept": {"application/json"}, + "OCS-APIRequest": {"true"}, + } + if contentType != "" { + result.Set("Content-Type", contentType) + } + return result +} + +type rawEnvelope struct { + OCS struct { + Meta struct { + Status string `json:"status"` + StatusCode int `json:"statuscode"` + Message string `json:"message"` + } `json:"meta"` + Data []rawNotification `json:"data"` + } `json:"ocs"` +} + +func (value rawEnvelope) check() error { + code := value.OCS.Meta.StatusCode + if code == 100 || code == 200 { + return nil + } + if code == 997 { + code = http.StatusUnauthorized + } + return &httpapi.HTTPError{ + StatusCode: code, Status: value.OCS.Meta.Status, + Message: value.OCS.Meta.Message, + } +} + +type rawNotification struct { + ID string `json:"notification_id"` + App string `json:"app"` + User string `json:"user"` + DateTime string `json:"datetime"` + ObjectID string `json:"object_id"` + ObjectType string `json:"object_type"` + Subject string `json:"subject"` + SubjectRich string `json:"subjectRich"` + Message string `json:"message"` + MessageRich string `json:"messageRich"` + MessageDetails map[string]any `json:"messageRichParameters"` +} + +func (value rawNotification) notification() Notification { + return Notification(value) +} diff --git a/internal/notifications/client_test.go b/internal/notifications/client_test.go new file mode 100644 index 0000000..cc6a85e --- /dev/null +++ b/internal/notifications/client_test.go @@ -0,0 +1,129 @@ +package notifications + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/mzner/ocis-cli/internal/httpapi" +) + +func TestNotificationLifecycle(t *testing.T) { + var dismissed string + var dismissedMany []string + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, request *http.Request, + ) { + if request.Header.Get("Authorization") != "Bearer token" { + t.Fatalf("authorization: %q", request.Header.Get("Authorization")) + } + if request.Header.Get("OCS-APIRequest") != "true" { + t.Fatalf("OCS-APIRequest: %q", request.Header.Get("OCS-APIRequest")) + } + switch request.Method + " " + request.URL.Path { + case "GET " + endpoint: + if request.URL.Query().Get("format") != "json" { + t.Fatalf("query: %s", request.URL.RawQuery) + } + _, _ = io.WriteString(writer, `{"ocs":{"meta":{ + "status":"ok","statuscode":200,"message":"OK" + },"data":[{ + "notification_id":"notification-1","app":"userlog", + "user":"Alice","datetime":"2026-08-10T08:00:00Z", + "object_id":"share-1","object_type":"share", + "subject":"Resource shared","subjectRich":"Resource shared", + "message":"Alice shared report.txt with you", + "messageRich":"{user} shared {resource} with you", + "messageRichParameters":{"resource":{"name":"report.txt"}} + }]}}`) + case "DELETE " + endpoint + "/notification-1": + dismissed = "notification-1" + case "DELETE " + endpoint: + if request.Header.Get("Content-Type") != "application/json" { + t.Fatalf("content type: %q", request.Header.Get("Content-Type")) + } + var body struct { + IDs []string `json:"ids"` + } + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Fatal(err) + } + dismissedMany = body.IDs + default: + t.Fatalf("unexpected request: %s %s", request.Method, request.URL.Path) + } + })) + defer server.Close() + + client := NewClient(httpapi.Config{ + Server: server.URL, AuthType: "oidc", AccessToken: "token", + }, server.Client()) + ctx := context.Background() + values, err := client.List(ctx) + if err != nil || len(values) != 1 || values[0].ID != "notification-1" || + values[0].Subject != "Resource shared" || + values[0].MessageDetails["resource"] == nil { + t.Fatalf("notifications: %#v, %v", values, err) + } + if err := client.Dismiss(ctx, "notification-1"); err != nil || + dismissed != "notification-1" { + t.Fatalf("dismissed=%q error=%v", dismissed, err) + } + if err := client.DismissMany( + ctx, []string{"notification-1", "notification-2"}, + ); err != nil || !reflect.DeepEqual( + dismissedMany, []string{"notification-1", "notification-2"}, + ) { + t.Fatalf("dismissed=%v error=%v", dismissedMany, err) + } +} + +func TestListAcceptsNullData(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, _ *http.Request, + ) { + _, _ = io.WriteString(writer, `{"ocs":{"meta":{ + "status":"ok","statuscode":200,"message":"OK" + },"data":null}}`) + })) + defer server.Close() + client := NewClient(httpapi.Config{Server: server.URL}, server.Client()) + values, err := client.List(context.Background()) + if err != nil || len(values) != 0 { + t.Fatalf("notifications: %#v, %v", values, err) + } +} + +func TestNotificationClientValidatesDismissals(t *testing.T) { + client := NewClient(httpapi.Config{Server: "http://127.0.0.1:1"}, nil) + if err := client.Dismiss(context.Background(), " "); err == nil { + t.Fatal("dismissed an empty notification ID") + } + if err := client.DismissMany(context.Background(), nil); err == nil { + t.Fatal("dismissed an empty notification collection") + } + if err := client.DismissMany( + context.Background(), []string{"notification-1", " "}, + ); err == nil { + t.Fatal("dismissed a collection containing an empty ID") + } +} + +func TestListReturnsOCSError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func( + writer http.ResponseWriter, _ *http.Request, + ) { + _, _ = io.WriteString(writer, `{"ocs":{"meta":{ + "status":"failure","statuscode":997,"message":"Unauthenticated" + },"data":null}}`) + })) + defer server.Close() + client := NewClient(httpapi.Config{Server: server.URL}, server.Client()) + if _, err := client.List(context.Background()); err == nil { + t.Fatal("accepted an OCS authentication error") + } +}